diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 54a45a9..6411cbb 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -454,7 +454,10 @@ observed from physical Xbox Series USB and Xbox Wireless Adapter connections. The VHF child preserves the native 17-byte GIP-shaped input report, and the last byte carries battery strength for both Xbox One and Xbox Series. The report parser accepts the native eight-byte four-motor Xbox payload when a consumer -delivers it. The Xbox 360 profile is rejected by the UMDF/VHF backend because a +delivers it. The Windows backend submits Xbox input only when the packed state +changes. State transitions still reach VHF, while raw HID consumers are not +asked to reinterpret the same unchanged Xbox state as fresh input. The Xbox 360 +profile is rejected by the UMDF/VHF backend because a real Xbox 360 controller is an XUSB device rather than a VHF HID gamepad. DualShock 4 and DualSense answer the calibration, pairing, and firmware feature diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index 150c35a..e8a8c7f 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -980,6 +980,8 @@ namespace lvh::detail { std::shared_ptr context_; std::shared_ptr state_; + std::mutex input_report_mutex_; + std::optional> last_input_report_; std::condition_variable switch_pro_report_ready_; std::mutex switch_pro_report_mutex_; std::jthread switch_pro_report_thread_; @@ -1368,6 +1370,8 @@ namespace lvh::detail { ) { using enum ErrorCode; + auto suppress_unchanged_report = false; + { std::lock_guard lock {state_->mutex_}; if (!state_->open) { @@ -1386,6 +1390,25 @@ namespace lvh::detail { if (state_->uses_generic_pid) { return context_->submit_device_report(state_, windows::make_generic_windows_input_report(report)); } + + suppress_unchanged_report = + state_->profile.gamepad_kind == GamepadProfileKind::xbox_one || + state_->profile.gamepad_kind == GamepadProfileKind::xbox_series; + } + + if (suppress_unchanged_report) { + // Xbox input reports have no sequence or timestamp field. Keep raw HID + // consumers transition-driven when a streaming host repeats a held state. + std::lock_guard lock {input_report_mutex_}; + if (last_input_report_.has_value() && *last_input_report_ == report) { + return OperationStatus::success(); + } + + auto status = context_->submit_device_report(state_, report); + if (status.ok()) { + last_input_report_ = report; + } + return status; } return context_->submit_device_report(state_, report); diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp index 12f06c7..a16245a 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -39,6 +39,17 @@ namespace lvh::detail::test { std::vector> submitted_reports; }; + struct WindowsXboxInputDeduplicationResult { + OperationStatus create_status; + OperationStatus left_shoulder_status; + OperationStatus repeated_left_shoulder_status; + OperationStatus right_shoulder_status; + OperationStatus repeated_right_shoulder_status; + OperationStatus release_status; + OperationStatus close_status; + std::vector> submitted_reports; + }; + struct WindowsBackendFailureResult { OperationStatus invalid_argument_status; OperationStatus unsupported_profile_status; @@ -249,6 +260,7 @@ namespace lvh::detail::test { WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle(); WindowsSwitchReportStreamResult windows_backend_switch_report_stream(); + WindowsXboxInputDeduplicationResult windows_backend_xbox_input_deduplication(GamepadProfileKind kind); WindowsPlayStationTransportResult windows_backend_playstation_transport(); WindowsGenericPidOrderingResult windows_backend_generic_pid_callback_ordering(); WindowsHidKeyboardResult windows_backend_hid_keyboard(); diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index ad1048c..bb6c204 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -551,6 +551,34 @@ namespace lvh::detail { return result; } + WindowsXboxInputDeduplicationResult windows_backend_xbox_input_deduplication(GamepadProfileKind kind) { + WindowsXboxInputDeduplicationResult result; + auto command_state = std::make_shared(); + auto backend = make_fake_windows_backend(command_state, std::make_shared()); + + CreateGamepadOptions options; + options.profile = kind == GamepadProfileKind::xbox_one ? profiles::xbox_one() : profiles::xbox_series(); + auto created = backend->create_gamepad(9, options); + result.create_status = created.status; + if (created) { + auto report = std::vector(options.profile.input_report_size, 0U); + report[12] = 0x10U; + result.left_shoulder_status = created.gamepad->submit({}, report); + result.repeated_left_shoulder_status = created.gamepad->submit({}, report); + + report[12] = 0x20U; + result.right_shoulder_status = created.gamepad->submit({}, report); + result.repeated_right_shoulder_status = created.gamepad->submit({}, report); + + report[12] = 0U; + result.release_status = created.gamepad->submit({}, report); + result.close_status = created.gamepad->close(); + result.submitted_reports = command_state->submit_reports(); + } + + return result; + } + WindowsHidMouseResult windows_backend_hid_mouse() { using enum MouseEventKind; diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index a2e5bce..9834bfa 100644 --- a/tests/unit/test_windows_backend.cpp +++ b/tests/unit/test_windows_backend.cpp @@ -118,6 +118,25 @@ TEST_F(WindowsBackendTest, SwitchReportsStreamAtTheNativeCadence) { })); } +TEST_F(WindowsBackendTest, XboxReportsOnlySubmitShoulderStateTransitions) { + for (const auto kind : {lvh::GamepadProfileKind::xbox_one, lvh::GamepadProfileKind::xbox_series}) { + SCOPED_TRACE(static_cast(kind)); + const auto result = lvh::detail::test::windows_backend_xbox_input_deduplication(kind); + + expect_ok(result.create_status); + expect_ok(result.left_shoulder_status); + expect_ok(result.repeated_left_shoulder_status); + expect_ok(result.right_shoulder_status); + expect_ok(result.repeated_right_shoulder_status); + expect_ok(result.release_status); + expect_ok(result.close_status); + ASSERT_EQ(result.submitted_reports.size(), 3U); + EXPECT_EQ(result.submitted_reports[0][12], 0x10U); + EXPECT_EQ(result.submitted_reports[1][12], 0x20U); + EXPECT_EQ(result.submitted_reports[2][12], 0U); + } +} + TEST_F(WindowsBackendTest, PlayStationDefaultsUseEffectiveUsbProfiles) { const auto result = lvh::detail::test::windows_backend_playstation_transport(); const auto dualshock4_usb = lvh::profiles::dualshock4_usb(); diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp index fc0401e..bca7273 100644 --- a/tests/unit/test_windows_consumers.cpp +++ b/tests/unit/test_windows_consumers.cpp @@ -1220,6 +1220,88 @@ TEST_F(WindowsConsumerTest, NativeXboxPidRumbleWritesAreNormalized) { ASSERT_TRUE(created.adapter->close().ok()); } +TEST_F(WindowsConsumerTest, XboxNativeInputSuppressesRepeatedHeldShoulderReports) { + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + ASSERT_NE(runtime, nullptr); + ASSERT_TRUE(runtime->capabilities().supports_gamepad) + << "The installed libvirtualhid Windows driver is required for this integration test"; + + auto profile = lvh::profiles::xbox_one(); + // Avoid a report descriptor cached for an older installed-driver identity. + profile.vendor_id = 0x1234U; + profile.product_id = 0x5670U; + profile.version = 0x0001U; + const auto previous_paths = current_gamepad_interface_paths(); + + lvh::CreateGamepadOptions options; + options.profile = profile; + options.metadata.stable_id = "native-shoulder-hold-test"; + auto created = lvh::GamepadStateAdapter::create(*runtime, options); + ASSERT_TRUE(created) << created.status.message(); + + const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id); + ASSERT_TRUE(hid_interface.has_value()) << "The VHF Xbox HID interface was not enumerated"; + ASSERT_EQ(hid_interface->input_report_size, profile.input_report_size + 1U); + + Handle reader {CreateFileW( + hid_interface->path.c_str(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, + nullptr + )}; + ASSERT_TRUE(reader) << "Unable to open the VHF Xbox HID interface: " << GetLastError(); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::left_shoulder); + ASSERT_TRUE(created.adapter->set_state(state).ok()); + const auto left_pressed = read_hid_report_matching( + reader.get(), + hid_interface->input_report_size, + 5s, + [](const auto &report) { + return report.size() > 13U && (report[13] & 0x30U) == 0x10U; + } + ); + ASSERT_TRUE(left_pressed.has_value()) << "No Xbox LB press reached the native HID client"; + + for (auto repeat = 0U; repeat < 32U; ++repeat) { + ASSERT_TRUE(created.adapter->set_state(state).ok()); + } + EXPECT_FALSE(read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 200ms).has_value()) + << "An unchanged held LB state was resubmitted to the native HID client"; + + state.buttons.reset(lvh::GamepadButton::left_shoulder); + state.buttons.set(lvh::GamepadButton::right_shoulder); + ASSERT_TRUE(created.adapter->set_state(state).ok()); + const auto right_pressed = read_hid_report_matching( + reader.get(), + hid_interface->input_report_size, + 5s, + [](const auto &report) { + return report.size() > 13U && (report[13] & 0x30U) == 0x20U; + } + ); + ASSERT_TRUE(right_pressed.has_value()) << "No Xbox RB press reached the native HID client"; + + state.buttons.reset(lvh::GamepadButton::right_shoulder); + ASSERT_TRUE(created.adapter->set_state(state).ok()); + const auto released = read_hid_report_matching( + reader.get(), + hid_interface->input_report_size, + 5s, + [](const auto &report) { + return report.size() > 13U && (report[13] & 0x30U) == 0U; + } + ); + ASSERT_TRUE(released.has_value()) << "No Xbox shoulder release reached the native HID client"; + ASSERT_TRUE(created.adapter->close().ok()); +} + TEST_F(WindowsConsumerTest, BatteryStateIsAvailableThroughGetInputReport) { const std::array profiles { lvh::profiles::xbox_one(),