From 37ad99b73525ac34a58562d2425b38d472805d37 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:59:57 -0400 Subject: [PATCH] test: add SDL3 HIDAPI rumble Windows consumer test Adds an optional Windows integration test that opens virtual DualShock 4, DualSense, and Switch Pro devices through SDL3 HIDAPI and verifies rumble output reaches normalized GamepadOutput callbacks. The test build now links SDL3 (static or shared target) when available and sets a compile definition to gate SDL-dependent code. Also trims the rumble TODO entry to point ongoing Steam/Chromium validation work to GitHub issue #80. --- docs/todo.md | 36 +------- tests/CMakeLists.txt | 8 ++ tests/unit/test_windows_consumers.cpp | 123 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 34 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 4d7cd65..5fd5ec7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -78,37 +78,5 @@ Proposed solution: ## PlayStation and Nintendo Rumble -Status: unresolved or not fully validated for DualShock 4, DualSense, and -Switch Pro-style virtual gamepads in consumer paths. - -The profiles expose the native descriptors and the Windows backend answers the -initialization feature reports needed by HIDAPI-style clients. The remaining -gap is output behavior after consumers open the virtual controllers: rumble is -not reliably reaching the public `GamepadOutput` callback for PlayStation 4, -PlayStation 5, and Nintendo-style gamepads in manual validation. - -Proposed solution: - -1. Capture physical-controller output traffic for DualShock 4, DualSense, and - Switch Pro over the relevant transports used by Steam, SDL, and browser HID - testers. -2. Compare those output and feature reports against the virtual backend's - accepted reports, including report IDs, leading zero/report-ID conventions, - initialization order, and transport-specific payload variants. -3. Extend the report parsers and backend output routing only where a captured - consumer report proves the missing behavior. -4. Add regression tests for each accepted rumble payload shape before relying - on manual testing. -5. Validate with at least one real consumer per platform path. For Steam, the - required proof is a received virtual-control output report or a normalized - rumble callback after triggering controller rumble in Steam's tester. - -Implementation notes: - -- DualShock 4 and DualSense output paths may differ between USB, Bluetooth, and - HIDAPI-initialized reports. -- Switch Pro output may require the native initialization sequence to complete - before rumble packets are accepted. -- Linux `uhid` and Windows VHF have different report-ID delivery behavior, so - both prefixed and unprefixed payloads need explicit coverage where consumers - actually send them. +Status: native output-path validation and the remaining Steam and Chromium +consumer gaps are tracked in [GitHub issue #80](https://github.com/LizardByte/libvirtualhid/issues/80). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 03c9dd1..d588464 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -133,6 +133,14 @@ elseif(WIN32) hid setupapi winhttp) + + if(TARGET SDL3::SDL3-static) + target_compile_definitions(${TEST_BINARY} PRIVATE LIBVIRTUALHID_TEST_HAS_SDL3=1) + target_link_libraries(${TEST_BINARY} PRIVATE SDL3::SDL3-static) + elseif(TARGET SDL3::SDL3) + target_compile_definitions(${TEST_BINARY} PRIVATE LIBVIRTUALHID_TEST_HAS_SDL3=1) + target_link_libraries(${TEST_BINARY} PRIVATE SDL3::SDL3) + endif() endif() if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND LIBVIRTUALHID_ENABLE_XTEST AND X11_FOUND AND X11_XTest_FOUND) diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp index 368d75e..a451e05 100644 --- a/tests/unit/test_windows_consumers.cpp +++ b/tests/unit/test_windows_consumers.cpp @@ -21,6 +21,10 @@ #include #include +#if defined(LIBVIRTUALHID_TEST_HAS_SDL3) + #include +#endif + // standard includes #include #include @@ -373,8 +377,127 @@ namespace { std::size_t next_output_ = 0; }; +#if defined(LIBVIRTUALHID_TEST_HAS_SDL3) + using SdlGamepad = std::unique_ptr; + + class SdlGamepadSubsystem { + public: + SdlGamepadSubsystem() { + SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_ENHANCED_REPORTS, "1"); + initialized_ = SDL_Init(SDL_INIT_GAMEPAD | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS); + } + + SdlGamepadSubsystem(const SdlGamepadSubsystem &) = delete; + SdlGamepadSubsystem &operator=(const SdlGamepadSubsystem &) = delete; + + ~SdlGamepadSubsystem() { + if (initialized_) { + SDL_Quit(); + } + } + + bool initialized() const { + return initialized_; + } + + private: + bool initialized_ = false; + }; + + std::set current_sdl_gamepads() { + auto count = 0; + auto *gamepads = SDL_GetGamepads(&count); + std::set result; + if (gamepads != nullptr) { + result.insert(gamepads, gamepads + count); + SDL_free(gamepads); + } + return result; + } + + SdlGamepad wait_for_new_sdl_gamepad( + const std::set &previous_gamepads, + std::uint16_t vendor_id, + std::uint16_t product_id + ) { + const auto deadline = std::chrono::steady_clock::now() + 10s; + while (std::chrono::steady_clock::now() < deadline) { + SDL_UpdateGamepads(); + auto count = 0; + auto *gamepads = SDL_GetGamepads(&count); + for (auto index = 0; index < count; ++index) { + const auto gamepad_id = gamepads[index]; + if ( + !previous_gamepads.contains(gamepad_id) && SDL_GetGamepadVendorForID(gamepad_id) == vendor_id && + SDL_GetGamepadProductForID(gamepad_id) == product_id + ) { + auto *opened = SDL_OpenGamepad(gamepad_id); + SDL_free(gamepads); + return {opened, &SDL_CloseGamepad}; + } + } + SDL_free(gamepads); + std::this_thread::sleep_for(100ms); + } + return {nullptr, &SDL_CloseGamepad}; + } +#endif + } // namespace +#if defined(LIBVIRTUALHID_TEST_HAS_SDL3) +TEST_F(WindowsConsumerTest, SdlHidapiRumbleReachesPlayStationAndSwitchCallbacks) { + SdlGamepadSubsystem sdl; + ASSERT_TRUE(sdl.initialized()) << SDL_GetError(); + + 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"; + + const std::array profiles { + lvh::profiles::dualshock4_usb(), + lvh::profiles::dualsense_usb(), + lvh::profiles::switch_pro(), + }; + for (const auto &profile : profiles) { + SCOPED_TRACE(profile.name); + const auto previous_gamepads = current_sdl_gamepads(); + + lvh::CreateGamepadOptions options; + options.profile = profile; + options.metadata.stable_id = "02:11:22:33:44:55"; + auto created = lvh::GamepadStateAdapter::create(*runtime, options); + ASSERT_TRUE(created) << created.status.message(); + GamepadOutputCapture output_capture; + output_capture.attach(*created.adapter); + + auto gamepad = wait_for_new_sdl_gamepad(previous_gamepads, profile.vendor_id, profile.product_id); + ASSERT_NE(gamepad.get(), nullptr) << SDL_GetError(); + auto *joystick = SDL_GetGamepadJoystick(gamepad.get()); + ASSERT_NE(joystick, nullptr) << SDL_GetError(); + const auto properties = SDL_GetGamepadProperties(gamepad.get()); + ASSERT_NE(properties, 0U) << SDL_GetError(); + ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false)) << SDL_GetError(); + ASSERT_TRUE(SDL_RumbleGamepad(gamepad.get(), 0x5678U, 0x1234U, 1000U)) << SDL_GetError(); + + const auto rumble = output_capture.wait_for_rumble(true); + ASSERT_TRUE(rumble.has_value()) << "No normalized rumble callback followed SDL HIDAPI rumble"; + EXPECT_GT(rumble->low_frequency_rumble, 0U); + EXPECT_GT(rumble->high_frequency_rumble, 0U); + + gamepad.reset(); + ASSERT_TRUE(created.adapter->close().ok()); + } +} +#endif + TEST_F(WindowsConsumerTest, NativePlayStationFeatureAndOutputReportsReachOwningRuntime) { struct NativePlayStationCase { lvh::DeviceProfile profile;