Skip to content

Commit 370a425

Browse files
Stream Switch Pro reports on Windows
Cache the latest native Switch Pro input report in the Windows backend and resend it every 15 ms to match the controller's native 0x30 report cadence. This coalesces separate motion updates, adjusts HID consumer tests to match reports by type instead of exact packet counters, and updates the related Windows support documentation.
1 parent b2eb32d commit 370a425

8 files changed

Lines changed: 210 additions & 33 deletions

File tree

docs/end-user-gamepad-guide.md

Lines changed: 19 additions & 19 deletions
Large diffs are not rendered by default.

docs/platform-support.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ uses the `0x0210` hardware revision reported by a physical Nintendo controller,
8686
and the Windows VHF device exposes that revision to HID consumers. Full-state
8787
and subcommand-reply reports use the same per-device packet counter on Windows,
8888
matching the counter that a native controller advances for every input report.
89+
The Windows client backend caches the newest complete Switch Pro state and
90+
streams native `0x30` reports every 15 milliseconds. This coalesces separate
91+
acceleration and gyroscope API updates into the three-sample report cadence used
92+
by a physical USB controller.
8993

9094
Windows VHF devices do not expose a Bluetooth transport identity to HIDAPI.
9195
The Windows backend therefore reports DualShock 4 and DualSense requests as

docs/windows-driver.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -428,10 +428,13 @@ gamepad.
428428
DualShock 4 and DualSense answer the calibration, pairing, and firmware feature
429429
requests used by their Windows HIDAPI initialization paths. Switch Pro answers
430430
the native USB and subcommand handshake and submits native `0x30` input reports
431-
with three live IMU samples. Its Set Player Lights subcommand is normalized into
432-
solid and flashing player-indicator output states for the creating runtime, and
433-
its monochrome HOME light is normalized as equal RGB channels so existing
434-
streaming LED feedback paths can preserve its intensity.
431+
with three live IMU samples. The client backend caches the newest complete
432+
Switch state and submits it every 15 milliseconds, matching a physical USB
433+
controller's report cadence while coalescing separate acceleration and
434+
gyroscope updates. Its Set Player Lights subcommand is normalized into solid
435+
and flashing player-indicator output states for the creating runtime, and its
436+
monochrome HOME light is normalized as equal RGB channels so existing streaming
437+
LED feedback paths can preserve its intensity.
435438
The built-in Generic profile is presented to Windows as a DirectInput PID
436439
Joystick with the complete output-report set required for DirectInput
437440
enumeration. Constant Force and Sine output is normalized to the portable

src/platform/windows/windows_backend.cpp

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ namespace lvh::detail {
8888
using SendInputFunction = std::function<UINT(std::span<INPUT>)>;
8989
using SyncThreadDesktopFunction = std::function<HDESK()>;
9090

91+
constexpr auto switch_pro_report_interval = std::chrono::milliseconds {15};
92+
9193
/**
9294
* @brief Thread-local desktop identity used for SendInput retry decisions.
9395
*/
@@ -956,13 +958,13 @@ namespace lvh::detail {
956958
OutputCallback output_callback;
957959
bool uses_generic_pid = false;
958960
windows::GenericPidRumbleState generic_pid_rumble;
961+
std::vector<std::uint8_t> switch_pro_input_report;
959962
};
960963

961964
class WindowsGamepad final: public BackendGamepad {
962965
public:
963-
WindowsGamepad(std::shared_ptr<WindowsBackendContext> context, std::shared_ptr<WindowsVhfDeviceState> state):
964-
context_ {std::move(context)},
965-
state_ {std::move(state)} {}
966+
WindowsGamepad(std::shared_ptr<WindowsBackendContext> context, std::shared_ptr<WindowsVhfDeviceState> state);
967+
~WindowsGamepad() override;
966968

967969
OperationStatus submit(
968970
const GamepadState &state,
@@ -973,8 +975,14 @@ namespace lvh::detail {
973975
OperationStatus close() override;
974976

975977
private:
978+
void stream_switch_pro_reports(std::stop_token stop_token);
979+
void stop_switch_pro_report_stream();
980+
976981
std::shared_ptr<WindowsBackendContext> context_;
977982
std::shared_ptr<WindowsVhfDeviceState> state_;
983+
std::condition_variable switch_pro_report_ready_;
984+
std::mutex switch_pro_report_mutex_;
985+
std::jthread switch_pro_report_thread_;
978986
};
979987

980988
class WindowsBackendContext: public std::enable_shared_from_this<WindowsBackendContext> {
@@ -1294,6 +1302,66 @@ namespace lvh::detail {
12941302
std::map<std::uint64_t, std::weak_ptr<WindowsVhfDeviceState>> devices_;
12951303
};
12961304

1305+
WindowsGamepad::WindowsGamepad(
1306+
std::shared_ptr<WindowsBackendContext> context,
1307+
std::shared_ptr<WindowsVhfDeviceState> state
1308+
):
1309+
context_ {std::move(context)},
1310+
state_ {std::move(state)} {
1311+
if (state_->profile.gamepad_kind == GamepadProfileKind::switch_pro) {
1312+
switch_pro_report_thread_ = std::jthread {[this](std::stop_token stop_token) {
1313+
stream_switch_pro_reports(stop_token);
1314+
}};
1315+
}
1316+
}
1317+
1318+
WindowsGamepad::~WindowsGamepad() {
1319+
stop_switch_pro_report_stream();
1320+
}
1321+
1322+
void WindowsGamepad::stream_switch_pro_reports(std::stop_token stop_token) {
1323+
auto next_report = std::chrono::steady_clock::now() + switch_pro_report_interval;
1324+
std::unique_lock report_lock {switch_pro_report_mutex_};
1325+
while (!stop_token.stop_requested()) {
1326+
static_cast<void>(switch_pro_report_ready_.wait_until(report_lock, next_report, [&stop_token] {
1327+
return stop_token.stop_requested();
1328+
}));
1329+
if (stop_token.stop_requested()) {
1330+
break;
1331+
}
1332+
1333+
report_lock.unlock();
1334+
std::vector<std::uint8_t> report;
1335+
{
1336+
std::lock_guard state_lock {state_->mutex_};
1337+
if (!state_->open) {
1338+
return;
1339+
}
1340+
report = state_->switch_pro_input_report;
1341+
}
1342+
if (!report.empty()) {
1343+
static_cast<void>(context_->submit_device_report(state_, report));
1344+
}
1345+
report_lock.lock();
1346+
1347+
next_report += switch_pro_report_interval;
1348+
const auto now = std::chrono::steady_clock::now();
1349+
if (next_report <= now) {
1350+
next_report = now + switch_pro_report_interval;
1351+
}
1352+
}
1353+
}
1354+
1355+
void WindowsGamepad::stop_switch_pro_report_stream() {
1356+
if (!switch_pro_report_thread_.joinable()) {
1357+
return;
1358+
}
1359+
1360+
switch_pro_report_thread_.request_stop();
1361+
switch_pro_report_ready_.notify_all();
1362+
switch_pro_report_thread_.join();
1363+
}
1364+
12971365
OperationStatus WindowsGamepad::submit(
12981366
const GamepadState & /*state*/,
12991367
const std::vector<std::uint8_t> &report
@@ -1310,6 +1378,11 @@ namespace lvh::detail {
13101378
return OperationStatus::failure(invalid_argument, "Windows gamepad input report exceeds protocol limit");
13111379
}
13121380

1381+
if (state_->profile.gamepad_kind == GamepadProfileKind::switch_pro) {
1382+
state_->switch_pro_input_report = report;
1383+
return OperationStatus::success();
1384+
}
1385+
13131386
if (state_->uses_generic_pid) {
13141387
return context_->submit_device_report(state_, windows::make_generic_windows_input_report(report));
13151388
}
@@ -1333,6 +1406,7 @@ namespace lvh::detail {
13331406
}
13341407

13351408
OperationStatus WindowsGamepad::close() {
1409+
stop_switch_pro_report_stream();
13361410
return context_->close_device(state_);
13371411
}
13381412

tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ namespace lvh::detail::test {
3030
std::size_t destroy_requests = 0;
3131
};
3232

33+
struct WindowsSwitchReportStreamResult {
34+
OperationStatus create_status;
35+
OperationStatus submit_status;
36+
OperationStatus close_status;
37+
bool repeated_report = false;
38+
std::vector<std::uint8_t> expected_report;
39+
std::vector<std::vector<std::uint8_t>> submitted_reports;
40+
};
41+
3342
struct WindowsBackendFailureResult {
3443
OperationStatus invalid_argument_status;
3544
OperationStatus unsupported_profile_status;
@@ -239,6 +248,7 @@ namespace lvh::detail::test {
239248
};
240249

241250
WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle();
251+
WindowsSwitchReportStreamResult windows_backend_switch_report_stream();
242252
WindowsPlayStationTransportResult windows_backend_playstation_transport();
243253
WindowsGenericPidOrderingResult windows_backend_generic_pid_callback_ordering();
244254
WindowsHidKeyboardResult windows_backend_hid_keyboard();

tests/fixtures/windows_backend_test_hooks.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,29 @@ namespace lvh::detail {
528528
return result;
529529
}
530530

531+
WindowsSwitchReportStreamResult windows_backend_switch_report_stream() {
532+
WindowsSwitchReportStreamResult result;
533+
auto command_state = std::make_shared<FakeWindowsControlChannelState>();
534+
auto backend = make_fake_windows_backend(command_state, std::make_shared<FakeWindowsControlChannelState>());
535+
536+
CreateGamepadOptions options;
537+
options.profile = profiles::switch_pro();
538+
auto created = backend->create_gamepad(8, options);
539+
result.create_status = created.status;
540+
if (created) {
541+
result.expected_report.assign(options.profile.input_report_size, 0x5AU);
542+
result.expected_report[0] = 0x30U;
543+
result.submit_status = created.gamepad->submit({}, result.expected_report);
544+
result.repeated_report = wait_until([&command_state] {
545+
return command_state->submit_report_count() >= 2U;
546+
});
547+
result.close_status = created.gamepad->close();
548+
result.submitted_reports = command_state->submit_reports();
549+
}
550+
551+
return result;
552+
}
553+
531554
WindowsHidMouseResult windows_backend_hid_mouse() {
532555
using enum MouseEventKind;
533556

tests/unit/test_windows_backend.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@ TEST_F(WindowsBackendTest, FakeChannelExercisesLifecycleSubmitCloseAndOutput) {
105105
EXPECT_EQ(result.last_output.raw_report[0], 0x03U);
106106
}
107107

108+
TEST_F(WindowsBackendTest, SwitchReportsStreamAtTheNativeCadence) {
109+
const auto result = lvh::detail::test::windows_backend_switch_report_stream();
110+
111+
expect_ok(result.create_status);
112+
expect_ok(result.submit_status);
113+
ASSERT_TRUE(result.repeated_report);
114+
expect_ok(result.close_status);
115+
ASSERT_GE(result.submitted_reports.size(), 2U);
116+
EXPECT_TRUE(std::ranges::all_of(result.submitted_reports, [&result](const auto &report) {
117+
return report == result.expected_report;
118+
}));
119+
}
120+
108121
TEST_F(WindowsBackendTest, PlayStationDefaultsUseEffectiveUsbProfiles) {
109122
const auto result = lvh::detail::test::windows_backend_playstation_transport();
110123
const auto dualshock4_usb = lvh::profiles::dualshock4_usb();

tests/unit/test_windows_consumers.cpp

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,30 @@ namespace {
320320
return report;
321321
}
322322

323+
template<typename Predicate>
324+
std::optional<std::vector<std::uint8_t>> read_hid_report_matching(
325+
HANDLE hid,
326+
std::size_t report_size,
327+
std::chrono::milliseconds timeout,
328+
Predicate predicate
329+
) {
330+
const auto deadline = std::chrono::steady_clock::now() + timeout;
331+
while (std::chrono::steady_clock::now() < deadline) {
332+
const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(
333+
deadline - std::chrono::steady_clock::now()
334+
);
335+
auto report = read_hid_report_with_timeout(hid, report_size, std::max(remaining, 1ms));
336+
if (!report.has_value()) {
337+
return std::nullopt;
338+
}
339+
if (predicate(*report)) {
340+
return report;
341+
}
342+
}
343+
344+
return std::nullopt;
345+
}
346+
323347
HidInterfacePaths current_gamepad_interface_paths() {
324348
HidInterfacePaths paths;
325349
for (const auto &hid_interface : enumerate_gamepad_interfaces()) {
@@ -780,7 +804,14 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
780804
)) << "Switch proprietary WriteFile failed: "
781805
<< GetLastError();
782806
EXPECT_EQ(bytes_written, output.size());
783-
return read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 5s);
807+
return read_hid_report_matching(
808+
reader.get(),
809+
hid_interface->input_report_size,
810+
5s,
811+
[command](const auto &report) {
812+
return report.size() >= 2U && report[0] == 0x81U && report[1] == command;
813+
}
814+
);
784815
};
785816

786817
const auto status_reply = send_proprietary_command(0x01);
@@ -854,11 +885,17 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
854885
<< GetLastError();
855886
ASSERT_EQ(bytes_written, player_lights_report.size());
856887

857-
const auto player_lights_reply = read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 5s);
888+
const auto player_lights_reply = read_hid_report_matching(
889+
reader.get(),
890+
hid_interface->input_report_size,
891+
5s,
892+
[](const auto &report) {
893+
return report.size() >= 15U && report[0] == 0x21U && report[14] == 0x30U;
894+
}
895+
);
858896
ASSERT_TRUE(player_lights_reply.has_value()) << "No Switch player-light acknowledgement reached the HID client";
859897
ASSERT_GE(player_lights_reply->size(), 15U);
860898
EXPECT_EQ(player_lights_reply->at(0), 0x21U);
861-
EXPECT_EQ(player_lights_reply->at(1), 0x00U);
862899
EXPECT_EQ(player_lights_reply->at(13), 0x80U);
863900
EXPECT_EQ(player_lights_reply->at(14), 0x30U);
864901

@@ -881,11 +918,17 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
881918
state.gyroscope = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F};
882919
ASSERT_TRUE(created.adapter->set_state(state).ok());
883920

884-
const auto input = read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 5s);
921+
const auto input = read_hid_report_matching(
922+
reader.get(),
923+
hid_interface->input_report_size,
924+
5s,
925+
[](const auto &report) {
926+
return report.size() >= 6U && report[0] == 0x30U && report[3] == 0x08U && report[4] == 0x14U && report[5] == 0x08U;
927+
}
928+
);
885929
ASSERT_TRUE(input.has_value()) << "No native Switch 0x30 input report reached the HID client";
886930
ASSERT_EQ(input->size(), profile.input_report_size);
887931
EXPECT_EQ(input->at(0), 0x30U);
888-
EXPECT_EQ(input->at(1), static_cast<std::uint8_t>(player_lights_reply->at(1) + 1U));
889932
EXPECT_EQ(input->at(3), 0x08U); // Nintendo A in the native right-button byte.
890933
EXPECT_EQ(input->at(4), 0x14U); // R3 and Home.
891934
EXPECT_EQ(input->at(5), 0x08U); // D-pad left.
@@ -912,10 +955,17 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
912955

913956
state.gyroscope = lvh::Vector3 {.x = -4.0F, .y = 5.0F, .z = 6.0F};
914957
ASSERT_TRUE(created.adapter->set_state(state).ok());
915-
const auto next_input = read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 5s);
958+
const auto next_input = read_hid_report_matching(
959+
reader.get(),
960+
hid_interface->input_report_size,
961+
5s,
962+
[&input](const auto &report) {
963+
return report.size() > 19U && report[0] == 0x30U && report[19] != input->at(19);
964+
}
965+
);
916966
ASSERT_TRUE(next_input.has_value()) << "No second native Switch motion report reached the HID client";
917967
ASSERT_EQ(next_input->size(), profile.input_report_size);
918-
EXPECT_EQ(next_input->at(1), static_cast<std::uint8_t>(input->at(1) + 1U));
968+
EXPECT_NE(next_input->at(1), input->at(1));
919969
EXPECT_NE(next_input->at(19), input->at(19));
920970
ASSERT_TRUE(created.adapter->close().ok());
921971
}

0 commit comments

Comments
 (0)