/** * @file tests/unit/test_windows_consumers.cpp * @brief Windows consumer integration tests for installed VHF gamepads. */ #ifndef NOMINMAX #define NOMINMAX #endif #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #ifndef DIRECTINPUT_VERSION #define DIRECTINPUT_VERSION 0x0800 #endif // Windows base types must be declared before the HID headers. #include // platform includes #include #include #include #include #if defined(LIBVIRTUALHID_TEST_HAS_SDL3) #include #endif // standard includes #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // local includes #include "fixtures/fixtures.hpp" #include "platform/windows/control_protocol.hpp" #include namespace { using namespace std::chrono_literals; using HidInterfacePaths = std::set>; class WindowsConsumerTest: public WindowsTest {}; struct HidGamepadInterface { std::wstring path; std::uint16_t vendor_id = 0; std::uint16_t product_id = 0; std::uint16_t version_number = 0; std::uint16_t usage = 0; std::uint16_t input_report_size = 0; std::uint16_t output_report_size = 0; std::uint16_t feature_report_size = 0; }; class DeviceInfoSet { public: explicit DeviceInfoSet(HDEVINFO value): value_ {value} {} DeviceInfoSet(const DeviceInfoSet &) = delete; DeviceInfoSet &operator=(const DeviceInfoSet &) = delete; ~DeviceInfoSet() { if (value_ != INVALID_HANDLE_VALUE) { static_cast(SetupDiDestroyDeviceInfoList(value_)); } } HDEVINFO get() const { return value_; } private: HDEVINFO value_; }; class Handle { public: explicit Handle(HANDLE value = INVALID_HANDLE_VALUE): value_ {value} {} Handle(const Handle &) = delete; Handle &operator=(const Handle &) = delete; ~Handle() { if (value_ != INVALID_HANDLE_VALUE) { static_cast(CloseHandle(value_)); } } HANDLE get() const { return value_; } explicit operator bool() const { return value_ != INVALID_HANDLE_VALUE; } private: HANDLE value_; }; class XInputApi { public: using GetState = DWORD(WINAPI *)(DWORD, XINPUT_STATE *); using SetState = DWORD(WINAPI *)(DWORD, XINPUT_VIBRATION *); using GetBatteryInformation = DWORD(WINAPI *)(DWORD, BYTE, XINPUT_BATTERY_INFORMATION *); XInputApi(): module_ {LoadLibraryW(L"xinput1_4.dll")} { if (module_ != nullptr) { get_state_ = std::bit_cast(GetProcAddress(module_, "XInputGetState")); set_state_ = std::bit_cast(GetProcAddress(module_, "XInputSetState")); get_battery_information_ = std::bit_cast( GetProcAddress(module_, "XInputGetBatteryInformation") ); } } XInputApi(const XInputApi &) = delete; XInputApi &operator=(const XInputApi &) = delete; ~XInputApi() { if (module_ != nullptr) { static_cast(FreeLibrary(module_)); } } explicit operator bool() const { return get_state_ != nullptr && set_state_ != nullptr && get_battery_information_ != nullptr; } DWORD state(DWORD slot, XINPUT_STATE *state) const { return get_state_(slot, state); } DWORD set_state(DWORD slot, XINPUT_VIBRATION *vibration) const { return set_state_(slot, vibration); } DWORD battery_information( DWORD slot, BYTE device_type, XINPUT_BATTERY_INFORMATION *battery ) const { return get_battery_information_(slot, device_type, battery); } private: HMODULE module_ = nullptr; GetState get_state_ = nullptr; SetState set_state_ = nullptr; GetBatteryInformation get_battery_information_ = nullptr; }; std::set current_xinput_slots(const XInputApi &xinput) { std::set result; for (auto slot = DWORD {0}; slot < XUSER_MAX_COUNT; ++slot) { XINPUT_STATE state {}; if (xinput.state(slot, &state) == ERROR_SUCCESS) { result.insert(slot); } } return result; } std::optional wait_for_new_xinput_slot( const XInputApi &xinput, const std::set &previous_slots ) { const auto deadline = std::chrono::steady_clock::now() + 10s; while (std::chrono::steady_clock::now() < deadline) { for (const auto slot : current_xinput_slots(xinput)) { if (!previous_slots.contains(slot)) { return slot; } } std::this_thread::sleep_for(20ms); } return std::nullopt; } template struct ComReleaser { void operator()(Interface *value) const { if (value != nullptr) { value->Release(); } } }; template using ComInterface = std::unique_ptr>; struct DirectInputDeviceMatch { std::uint16_t vendor_id = 0; std::uint16_t product_id = 0; GUID instance_guid {}; bool found = false; }; BOOL CALLBACK find_directinput_device(const DIDEVICEINSTANCEW *instance, void *context) { auto &match = *static_cast(context); const auto vendor_id = static_cast(LOWORD(instance->guidProduct.Data1)); if (const auto product_id = static_cast(HIWORD(instance->guidProduct.Data1)); vendor_id != match.vendor_id || product_id != match.product_id) { return DIENUM_CONTINUE; } match.instance_guid = instance->guidInstance; match.found = true; return DIENUM_STOP; } std::optional wait_for_directinput_device( IDirectInput8W &direct_input, 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) { DirectInputDeviceMatch match {.vendor_id = vendor_id, .product_id = product_id}; if (const auto status = direct_input.EnumDevices(DI8DEVCLASS_GAMECTRL, find_directinput_device, &match, DIEDFL_ATTACHEDONLY); SUCCEEDED(status) && match.found) { return match.instance_guid; } std::this_thread::sleep_for(100ms); } return std::nullopt; } BOOL CALLBACK find_constant_force_effect(const DIEFFECTINFOW *effect, void *context) { auto &found = *static_cast(context); if (IsEqualGUID(effect->guid, GUID_ConstantForce) != FALSE) { found = true; return DIENUM_STOP; } return DIENUM_CONTINUE; } BOOL CALLBACK collect_force_feedback_axes(const DIDEVICEOBJECTINSTANCEW *object, void *context) { if ((object->dwType & DIDFT_FFACTUATOR) != 0U) { static_cast *>(context)->push_back(object->dwOfs); } return DIENUM_CONTINUE; } std::vector enumerate_gamepad_interfaces() { GUID hid_guid {}; HidD_GetHidGuid(&hid_guid); DeviceInfoSet devices {SetupDiGetClassDevsW( &hid_guid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE )}; if (devices.get() == INVALID_HANDLE_VALUE) { return {}; } std::vector interfaces; for (DWORD index = 0;; ++index) { SP_DEVICE_INTERFACE_DATA interface_data {}; interface_data.cbSize = sizeof(interface_data); if (SetupDiEnumDeviceInterfaces(devices.get(), nullptr, &hid_guid, index, &interface_data) == FALSE) { if (GetLastError() == ERROR_NO_MORE_ITEMS) { break; } continue; } DWORD required_size = 0; static_cast(SetupDiGetDeviceInterfaceDetailW( devices.get(), &interface_data, nullptr, 0, &required_size, nullptr )); if (required_size < sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W)) { continue; } const auto detail_count = (required_size + sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W) - 1U) / sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); std::vector detail_storage(detail_count); auto *detail = detail_storage.data(); detail->cbSize = sizeof(*detail); if (SetupDiGetDeviceInterfaceDetailW(devices.get(), &interface_data, detail, required_size, nullptr, nullptr) == FALSE) { continue; } Handle handle {CreateFileW( detail->DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr )}; if (!handle) { continue; } HIDD_ATTRIBUTES attributes {}; attributes.Size = sizeof(attributes); PHIDP_PREPARSED_DATA preparsed_data = nullptr; HIDP_CAPS capabilities {}; if (HidD_GetAttributes(handle.get(), &attributes) == FALSE || HidD_GetPreparsedData(handle.get(), &preparsed_data) == FALSE) { continue; } const auto caps_status = HidP_GetCaps(preparsed_data, &capabilities); static_cast(HidD_FreePreparsedData(preparsed_data)); if ( caps_status != HIDP_STATUS_SUCCESS || capabilities.UsagePage != 0x01U || (capabilities.Usage != 0x04U && capabilities.Usage != 0x05U) ) { continue; } interfaces.push_back({ .path = detail->DevicePath, .vendor_id = attributes.VendorID, .product_id = attributes.ProductID, .version_number = attributes.VersionNumber, .usage = capabilities.Usage, .input_report_size = capabilities.InputReportByteLength, .output_report_size = capabilities.OutputReportByteLength, .feature_report_size = capabilities.FeatureReportByteLength, }); } return interfaces; } std::optional wait_for_new_interface( const HidInterfacePaths &previous_paths, 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) { const auto interfaces = enumerate_gamepad_interfaces(); if (const auto position = std::ranges::find_if(interfaces, [vendor_id, product_id, &previous_paths](const auto &hid_interface) { return hid_interface.vendor_id == vendor_id && hid_interface.product_id == product_id && !previous_paths.contains(hid_interface.path); }); position != interfaces.end()) { return *position; } std::this_thread::sleep_for(100ms); } return std::nullopt; } std::optional> read_hid_report_with_timeout( HANDLE hid, std::size_t report_size, std::chrono::milliseconds timeout ) { Handle event {CreateEventW(nullptr, TRUE, FALSE, nullptr)}; if (!event) { return std::nullopt; } std::vector report(report_size, 0); OVERLAPPED overlapped {}; overlapped.hEvent = event.get(); DWORD bytes_read = 0; if (const auto started = ReadFile(hid, report.data(), static_cast(report.size()), &bytes_read, &overlapped); started == FALSE && GetLastError() != ERROR_IO_PENDING) { return std::nullopt; } if (const auto wait_result = WaitForSingleObject(event.get(), static_cast(timeout.count())); wait_result != WAIT_OBJECT_0) { static_cast(CancelIoEx(hid, &overlapped)); static_cast(WaitForSingleObject(event.get(), INFINITE)); static_cast(GetOverlappedResult(hid, &overlapped, &bytes_read, FALSE)); return std::nullopt; } if (GetOverlappedResult(hid, &overlapped, &bytes_read, FALSE) == FALSE) { return std::nullopt; } report.resize(bytes_read); return report; } template std::optional> read_hid_report_matching( HANDLE hid, std::size_t report_size, std::chrono::milliseconds timeout, Predicate predicate ) { const auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { const auto remaining = std::chrono::duration_cast( deadline - std::chrono::steady_clock::now() ); auto report = read_hid_report_with_timeout(hid, report_size, std::max(remaining, 1ms)); if (!report.has_value()) { return std::nullopt; } if (predicate(*report)) { return report; } } return std::nullopt; } std::optional> send_switch_proprietary_command( const HidGamepadInterface &hid_interface, HANDLE reader, HANDLE writer, std::uint8_t command ) { std::vector output(hid_interface.output_report_size, 0); output[0] = 0x80; output[1] = command; DWORD bytes_written = 0; EXPECT_TRUE(WriteFile( writer, output.data(), static_cast(output.size()), &bytes_written, nullptr )) << "Switch proprietary WriteFile failed: " << GetLastError(); EXPECT_EQ(bytes_written, output.size()); return read_hid_report_matching( reader, hid_interface.input_report_size, 5s, [command](const auto &report) { return report.size() >= 2U && report[0] == 0x81U && report[1] == command; } ); } HidInterfacePaths current_gamepad_interface_paths() { HidInterfacePaths paths; for (const auto &hid_interface : enumerate_gamepad_interfaces()) { paths.insert(hid_interface.path); } return paths; } class GamepadOutputCapture { public: void attach(lvh::GamepadStateAdapter &adapter) { adapter.set_output_callback([this](const lvh::GamepadOutput &output) { { std::lock_guard lock {mutex_}; outputs_.push_back(output); } ready_.notify_all(); }); } std::optional wait_for_rumble(bool expect_nonzero) { return wait_for([expect_nonzero](const lvh::GamepadOutput &output) { const auto has_strength = output.low_frequency_rumble > 0U || output.high_frequency_rumble > 0U; return output.kind == lvh::GamepadOutputKind::rumble && has_strength == expect_nonzero; }); } std::optional wait_for_rgb_led( std::uint8_t red, std::uint8_t green, std::uint8_t blue ) { return wait_for([red, green, blue](const lvh::GamepadOutput &output) { return output.kind == lvh::GamepadOutputKind::rgb_led && output.red == red && output.green == green && output.blue == blue; }); } std::optional wait_for_player_leds( const std::array &solid, const std::array &flashing ) { return wait_for([&solid, &flashing](const lvh::GamepadOutput &output) { return output.kind == lvh::GamepadOutputKind::player_leds && output.player_leds == solid && output.flashing_player_leds == flashing; }); } private: template std::optional wait_for(Predicate matches) { const auto find_next_match = [this, &matches] { return std::find_if( outputs_.begin() + static_cast(next_output_), outputs_.end(), matches ); }; if (std::unique_lock lock {mutex_}; ready_.wait_for(lock, 5s, [this, &find_next_match] { return find_next_match() != outputs_.end(); })) { const auto match = find_next_match(); next_output_ = static_cast(std::distance(outputs_.begin(), match)) + 1U; return *match; } return std::nullopt; } std::mutex mutex_; std::condition_variable ready_; std::vector outputs_; 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}; } bool wait_for_sdl_gamepad_removal(SDL_JoystickID gamepad_id) { const auto deadline = std::chrono::steady_clock::now() + 10s; while (std::chrono::steady_clock::now() < deadline) { SDL_UpdateGamepads(); SDL_PumpEvents(); if (!current_sdl_gamepads().contains(gamepad_id)) { return true; } std::this_thread::sleep_for(20ms); } return false; } bool wait_for_switch_pro_motion(SDL_Gamepad *gamepad) { std::array acceleration {}; std::array gyroscope {}; const auto deadline = std::chrono::steady_clock::now() + 5s; while (std::chrono::steady_clock::now() < deadline) { SDL_UpdateGamepads(); SDL_PumpEvents(); if ( SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_ACCEL, acceleration.data(), acceleration.size()) && SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_GYRO, gyroscope.data(), gyroscope.size()) && std::abs(acceleration[1]) > 15.0F && std::abs(gyroscope[2]) > 0.25F ) { return true; } std::this_thread::sleep_for(20ms); } return false; } #endif } // namespace #if defined(LIBVIRTUALHID_TEST_HAS_SDL3) TEST_F(WindowsConsumerTest, SdlHidapiOutputReachesDefaultPlayStationAndSwitchCallbacks) { 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(), lvh::profiles::dualsense(), 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(); ASSERT_NE(created.adapter->gamepad(), nullptr); if ( profile.gamepad_kind == lvh::GamepadProfileKind::dualshock4 || profile.gamepad_kind == lvh::GamepadProfileKind::dualsense ) { EXPECT_EQ(created.adapter->gamepad()->profile().bus_type, lvh::BusType::usb); } 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(); if ( profile.gamepad_kind == lvh::GamepadProfileKind::dualshock4 || profile.gamepad_kind == lvh::GamepadProfileKind::dualsense ) { ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false)) << SDL_GetError(); ASSERT_TRUE(SDL_SetGamepadLED(gamepad.get(), 0x12U, 0x34U, 0x56U)) << SDL_GetError(); const auto rgb_led = output_capture.wait_for_rgb_led(0x12U, 0x34U, 0x56U); ASSERT_TRUE(rgb_led.has_value()) << "No normalized RGB LED callback followed SDL HIDAPI LED output"; } if (profile.gamepad_kind == lvh::GamepadProfileKind::switch_pro) { ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_MONO_LED_BOOLEAN, false)) << SDL_GetError(); ASSERT_TRUE(SDL_SetGamepadLED(gamepad.get(), 0xFFU, 0xFFU, 0xFFU)) << SDL_GetError(); const auto home_led = output_capture.wait_for_rgb_led(0xFFU, 0xFFU, 0xFFU); ASSERT_TRUE(home_led.has_value()) << "No normalized HOME LED callback followed SDL HIDAPI LED output"; ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_ACCEL)) << SDL_GetError(); ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_GYRO)) << SDL_GetError(); ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_ACCEL, true)) << SDL_GetError(); ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_GYRO, true)) << SDL_GetError(); ASSERT_TRUE(created.adapter->set_motion( lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F}, lvh::Vector3 {.x = 10.0F, .y = 20.0F, .z = -30.0F} ) .ok()); ASSERT_TRUE(wait_for_switch_pro_motion(gamepad.get())) << "SDL HIDAPI did not receive live Switch Pro motion"; ASSERT_TRUE(SDL_SetGamepadPlayerIndex(gamepad.get(), 2)) << SDL_GetError(); const auto player_leds = output_capture.wait_for_player_leds( std::array {true, true, true, false}, std::array {false, false, false, false} ); ASSERT_TRUE(player_leds.has_value()) << "No normalized player LED callback followed SDL HIDAPI player-index output"; } 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()); } } TEST_F(WindowsConsumerTest, SdlExposesSubmittedBatteryStateForNonXboxProfiles) { 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"; struct BatteryTestCase { lvh::DeviceProfile profile; int expected_percentage; }; const std::array test_cases { BatteryTestCase {lvh::profiles::dualshock4(), 55}, BatteryTestCase {lvh::profiles::dualsense(), 55}, BatteryTestCase {lvh::profiles::switch_pro(), 50}, }; for (const auto &[profile, expected_percentage] : test_cases) { 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"; options.metadata.has_battery = true; auto created = lvh::GamepadStateAdapter::create(*runtime, options); ASSERT_TRUE(created) << created.status.message(); ASSERT_NE(created.adapter->gamepad(), nullptr); auto gamepad = wait_for_new_sdl_gamepad(previous_gamepads, profile.vendor_id, profile.product_id); ASSERT_NE(gamepad.get(), nullptr) << SDL_GetError(); ASSERT_TRUE(created.adapter->set_battery({ .state = lvh::GamepadBatteryState::discharging, .percentage = 50, }) .ok()); auto power_state = SDL_POWERSTATE_UNKNOWN; auto percentage = -1; const auto deadline = std::chrono::steady_clock::now() + 3s; while (std::chrono::steady_clock::now() < deadline) { SDL_UpdateGamepads(); SDL_PumpEvents(); power_state = SDL_GetGamepadPowerInfo(gamepad.get(), &percentage); if (power_state == SDL_POWERSTATE_ON_BATTERY && percentage == expected_percentage) { break; } std::this_thread::sleep_for(20ms); } EXPECT_EQ(power_state, SDL_POWERSTATE_ON_BATTERY) << "percentage=" << percentage; EXPECT_EQ(percentage, expected_percentage); const auto gamepad_id = SDL_GetGamepadID(gamepad.get()); gamepad.reset(); ASSERT_TRUE(created.adapter->close().ok()); ASSERT_TRUE(wait_for_sdl_gamepad_removal(gamepad_id)); } } #endif TEST_F(WindowsConsumerTest, XInputDoesNotExposeSubmittedXboxBattery) { XInputApi xinput; ASSERT_TRUE(xinput); const auto previous_slots = current_xinput_slots(xinput); 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"; lvh::CreateGamepadOptions options; options.profile = lvh::profiles::xbox_series(); options.metadata.stable_id = "xinput-battery-test"; options.metadata.has_battery = true; auto created = lvh::GamepadStateAdapter::create(*runtime, options); ASSERT_TRUE(created) << created.status.message(); ASSERT_TRUE(created.adapter->set_battery({ .state = lvh::GamepadBatteryState::discharging, .percentage = 50, }) .ok()); ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::a, true).ok()); const auto slot = wait_for_new_xinput_slot(xinput, previous_slots); if (!slot.has_value()) { ASSERT_TRUE(created.adapter->close().ok()); GTEST_SKIP() << "XInput did not enumerate the VHF gamepad in this Windows environment"; } XINPUT_BATTERY_INFORMATION battery {}; ASSERT_EQ(xinput.battery_information(*slot, BATTERY_DEVTYPE_GAMEPAD, &battery), ERROR_SUCCESS); EXPECT_EQ(battery.BatteryType, BATTERY_TYPE_DISCONNECTED); EXPECT_EQ(battery.BatteryLevel, BATTERY_LEVEL_EMPTY); ASSERT_TRUE(created.adapter->close().ok()); } TEST_F(WindowsConsumerTest, Xbox360PublishesXInputStateAndRumble) { XInputApi xinput; ASSERT_TRUE(xinput); const auto previous_slots = current_xinput_slots(xinput); 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"; lvh::CreateGamepadOptions options; options.profile = lvh::profiles::xbox_360(); options.metadata.stable_id = "xinput-xbox360-test"; auto created = lvh::GamepadStateAdapter::create(*runtime, options); ASSERT_TRUE(created) << created.status.message(); ASSERT_NE(created.adapter->gamepad(), nullptr); GamepadOutputCapture output_capture; output_capture.attach(*created.adapter); const auto slot = wait_for_new_xinput_slot(xinput, previous_slots); ASSERT_TRUE(slot.has_value()) << "XInput did not enumerate the Xbox 360 software device"; ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::a, true).ok()); ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::dpad_right, true).ok()); ASSERT_TRUE(created.adapter->set_left_trigger(0.5F).ok()); ASSERT_TRUE(created.adapter->set_right_stick({.x = 0.25F, .y = -0.75F}).ok()); XINPUT_STATE state {}; const auto input_deadline = std::chrono::steady_clock::now() + 5s; while (std::chrono::steady_clock::now() < input_deadline) { ASSERT_EQ(xinput.state(*slot, &state), ERROR_SUCCESS); if ((state.Gamepad.wButtons & (XINPUT_GAMEPAD_A | XINPUT_GAMEPAD_DPAD_RIGHT)) == (XINPUT_GAMEPAD_A | XINPUT_GAMEPAD_DPAD_RIGHT) && state.Gamepad.bLeftTrigger >= 127U && state.Gamepad.sThumbRX > 0 && state.Gamepad.sThumbRY < 0) { break; } std::this_thread::sleep_for(20ms); } EXPECT_EQ( state.Gamepad.wButtons & (XINPUT_GAMEPAD_A | XINPUT_GAMEPAD_DPAD_RIGHT), XINPUT_GAMEPAD_A | XINPUT_GAMEPAD_DPAD_RIGHT ); EXPECT_GE(state.Gamepad.bLeftTrigger, 127U); EXPECT_GT(state.Gamepad.sThumbRX, 0); EXPECT_LT(state.Gamepad.sThumbRY, 0); XINPUT_VIBRATION vibration { .wLeftMotorSpeed = 0x5678U, .wRightMotorSpeed = 0x1234U, }; ASSERT_EQ(xinput.set_state(*slot, &vibration), ERROR_SUCCESS); const auto rumble = output_capture.wait_for_rumble(true); ASSERT_TRUE(rumble.has_value()) << "No normalized rumble callback followed XInputSetState"; EXPECT_NEAR(rumble->low_frequency_rumble, 0x5656U, 0x0101U); EXPECT_NEAR(rumble->high_frequency_rumble, 0x1212U, 0x0101U); ASSERT_TRUE(created.adapter->close().ok()); } TEST_F(WindowsConsumerTest, NativePlayStationFeatureAndOutputReportsReachOwningRuntime) { struct NativePlayStationCase { lvh::DeviceProfile profile; std::uint8_t calibration_report_id; std::uint8_t pairing_report_id; std::uint8_t firmware_report_id; std::array firmware_prefix; std::uint8_t output_report_id; std::size_t valid_flags_offset; std::size_t right_motor_offset; std::size_t left_motor_offset; std::size_t minimum_feature_report_size; }; const std::array test_cases { NativePlayStationCase { .profile = lvh::profiles::dualshock4(), .calibration_report_id = 0x02, .pairing_report_id = 0x12, .firmware_report_id = 0xA3, .firmware_prefix = {'A', 'u', 'g'}, .output_report_id = 0x05, .valid_flags_offset = 1U, .right_motor_offset = 4U, .left_motor_offset = 5U, .minimum_feature_report_size = 49U, }, NativePlayStationCase { .profile = lvh::profiles::dualsense(), .calibration_report_id = 0x05, .pairing_report_id = 0x09, .firmware_report_id = 0x20, .firmware_prefix = {'J', 'u', 'l'}, .output_report_id = 0x02, .valid_flags_offset = 1U, .right_motor_offset = 3U, .left_motor_offset = 4U, .minimum_feature_report_size = 64U, }, }; lvh::RuntimeOptions runtime_options; runtime_options.backend = lvh::BackendKind::platform_default; auto decoy_runtime = lvh::Runtime::create(runtime_options); ASSERT_NE(decoy_runtime, nullptr); ASSERT_TRUE(decoy_runtime->capabilities().supports_gamepad); std::this_thread::sleep_for(100ms); 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"; for (const auto &test_case : test_cases) { const auto &profile = test_case.profile; const auto previous_paths = current_gamepad_interface_paths(); 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) << profile.name << ": " << created.status.message(); ASSERT_NE(created.adapter->gamepad(), nullptr); const auto &effective_profile = created.adapter->gamepad()->profile(); ASSERT_EQ(effective_profile.bus_type, lvh::BusType::usb); GamepadOutputCapture output_capture; output_capture.attach(*created.adapter); const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id); ASSERT_TRUE(hid_interface.has_value()) << "The VHF " << profile.name << " HID interface was not enumerated"; ASSERT_EQ(hid_interface->output_report_size, effective_profile.output_report_size); Handle hid {CreateFileW( hid_interface->path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr )}; ASSERT_TRUE(hid) << "Unable to open the VHF " << profile.name << " HID interface: " << GetLastError(); ASSERT_GE(hid_interface->feature_report_size, test_case.minimum_feature_report_size); std::vector calibration(hid_interface->feature_report_size, 0); calibration[0] = test_case.calibration_report_id; ASSERT_TRUE(HidD_GetFeature(hid.get(), calibration.data(), static_cast(calibration.size()))) << profile.name << " calibration GetFeature failed: " << GetLastError(); EXPECT_EQ(calibration[0], test_case.calibration_report_id); EXPECT_EQ(calibration[7], 0x10U); EXPECT_EQ(calibration[8], 0x27U); std::vector pairing(hid_interface->feature_report_size, 0); pairing[0] = test_case.pairing_report_id; ASSERT_TRUE(HidD_GetFeature(hid.get(), pairing.data(), static_cast(pairing.size()))) << profile.name << " pairing GetFeature failed: " << GetLastError(); EXPECT_EQ(pairing[0], test_case.pairing_report_id); EXPECT_EQ(pairing[1], 0x55U); EXPECT_EQ(pairing[2], 0x44U); EXPECT_EQ(pairing[3], 0x33U); EXPECT_EQ(pairing[4], 0x22U); EXPECT_EQ(pairing[5], 0x11U); EXPECT_EQ(pairing[6], 0x02U); std::vector firmware(hid_interface->feature_report_size, 0); firmware[0] = test_case.firmware_report_id; ASSERT_TRUE(HidD_GetFeature(hid.get(), firmware.data(), static_cast(firmware.size()))) << profile.name << " firmware GetFeature failed: " << GetLastError(); EXPECT_EQ(firmware[0], test_case.firmware_report_id); EXPECT_TRUE(std::ranges::equal(test_case.firmware_prefix, std::span {firmware}.subspan(1U, 3U))); if (profile.gamepad_kind == lvh::GamepadProfileKind::dualsense) { EXPECT_EQ(firmware[22], 0x04U); EXPECT_EQ(firmware[23], 0x00U); EXPECT_EQ(firmware[44], 0x30U); EXPECT_EQ(firmware[45], 0x06U); } std::vector report(hid_interface->output_report_size, 0); report[0] = test_case.output_report_id; report[test_case.valid_flags_offset] = 0x01; report[test_case.right_motor_offset] = 0x80; report[test_case.left_motor_offset] = 0x40; DWORD bytes_written = 0; ASSERT_TRUE(WriteFile( hid.get(), report.data(), static_cast(report.size()), &bytes_written, nullptr )) << profile.name << " WriteFile failed: " << GetLastError(); ASSERT_EQ(bytes_written, report.size()); const auto rumble = output_capture.wait_for_rumble(true); ASSERT_TRUE(rumble.has_value()) << "No normalized rumble callback followed the native " << profile.name << " HID output write"; EXPECT_EQ(rumble->low_frequency_rumble, 16448U); EXPECT_EQ(rumble->high_frequency_rumble, 32896U); EXPECT_EQ(rumble->raw_report, report); ASSERT_TRUE(created.adapter->close().ok()); } } TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) { const auto profile = lvh::profiles::switch_pro(); const auto previous_paths = current_gamepad_interface_paths(); 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"; lvh::CreateGamepadOptions options; options.profile = profile; options.metadata.stable_id = "02:00:00:00:00:05"; auto created = lvh::GamepadStateAdapter::create(*runtime, options); ASSERT_TRUE(created) << created.status.message(); GamepadOutputCapture output_capture; output_capture.attach(*created.adapter); const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id); ASSERT_TRUE(hid_interface.has_value()) << "The VHF Switch Pro HID interface was not enumerated"; ASSERT_EQ(hid_interface->version_number, profile.version); ASSERT_EQ(hid_interface->input_report_size, profile.input_report_size); ASSERT_EQ(hid_interface->output_report_size, profile.output_report_size); Handle writer {CreateFileW( hid_interface->path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr )}; ASSERT_TRUE(writer) << "Unable to open the VHF Switch Pro HID interface for writes: " << GetLastError(); Handle reader {CreateFileW( hid_interface->path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, nullptr )}; ASSERT_TRUE(reader) << "Unable to open the VHF Switch Pro HID interface for reads: " << GetLastError(); const auto status_reply = send_switch_proprietary_command(*hid_interface, reader.get(), writer.get(), 0x01); ASSERT_TRUE(status_reply.has_value()) << "No 0x81 Switch status reply reached the HID client"; ASSERT_GE(status_reply->size(), 10U); EXPECT_EQ(status_reply->at(0), 0x81U); EXPECT_EQ(status_reply->at(1), 0x01U); EXPECT_EQ(status_reply->at(3), 0x03U); // Pro Controller. const auto handshake_reply = send_switch_proprietary_command(*hid_interface, reader.get(), writer.get(), 0x02); ASSERT_TRUE(handshake_reply.has_value()) << "No 0x81 Switch handshake reply reached the HID client"; ASSERT_GE(handshake_reply->size(), 2U); EXPECT_EQ(handshake_reply->at(0), 0x81U); EXPECT_EQ(handshake_reply->at(1), 0x02U); std::vector rumble_report(hid_interface->output_report_size, 0); constexpr std::array native_rumble { std::uint8_t {0x10}, std::uint8_t {0x07}, std::uint8_t {0x74}, std::uint8_t {0x1A}, std::uint8_t {0x3D}, std::uint8_t {0x59}, std::uint8_t {0x74}, std::uint8_t {0x1A}, std::uint8_t {0x3D}, std::uint8_t {0x59}, }; std::ranges::copy(native_rumble, rumble_report.begin()); DWORD bytes_written = 0; ASSERT_TRUE(WriteFile( writer.get(), rumble_report.data(), static_cast(rumble_report.size()), &bytes_written, nullptr )) << "Switch rumble WriteFile failed: " << GetLastError(); ASSERT_EQ(bytes_written, rumble_report.size()); const auto rumble = output_capture.wait_for_rumble(true); ASSERT_TRUE(rumble.has_value()) << "No normalized rumble callback followed the native Switch 0x10 output write"; EXPECT_EQ(rumble->low_frequency_rumble, 22251U); EXPECT_EQ(rumble->high_frequency_rumble, 5213U); EXPECT_EQ(rumble->raw_report, rumble_report); std::vector player_lights_report(hid_interface->output_report_size, 0); player_lights_report[0] = 0x01; player_lights_report[1] = 0x08; constexpr std::array neutral_rumble { std::uint8_t {0x00}, std::uint8_t {0x01}, std::uint8_t {0x40}, std::uint8_t {0x40}, std::uint8_t {0x00}, std::uint8_t {0x01}, std::uint8_t {0x40}, std::uint8_t {0x40}, }; std::ranges::copy(neutral_rumble, player_lights_report.begin() + 2); player_lights_report[10] = 0x30; player_lights_report[11] = 0xA5; bytes_written = 0; ASSERT_TRUE(WriteFile( writer.get(), player_lights_report.data(), static_cast(player_lights_report.size()), &bytes_written, nullptr )) << "Switch player-light WriteFile failed: " << GetLastError(); ASSERT_EQ(bytes_written, player_lights_report.size()); const auto player_lights_reply = read_hid_report_matching( reader.get(), hid_interface->input_report_size, 5s, [](const auto &report) { return report.size() >= 15U && report[0] == 0x21U && report[14] == 0x30U; } ); ASSERT_TRUE(player_lights_reply.has_value()) << "No Switch player-light acknowledgement reached the HID client"; ASSERT_GE(player_lights_reply->size(), 15U); EXPECT_EQ(player_lights_reply->at(0), 0x21U); EXPECT_EQ(player_lights_reply->at(13), 0x80U); EXPECT_EQ(player_lights_reply->at(14), 0x30U); const auto player_lights = output_capture.wait_for_player_leds( {true, false, true, false}, {false, true, false, true} ); ASSERT_TRUE(player_lights.has_value()) << "No normalized player-light callback followed the native Switch 0x30 output write"; EXPECT_EQ(player_lights->raw_report, player_lights_report); lvh::GamepadState state; state.buttons.set(lvh::GamepadButton::a); state.buttons.set(lvh::GamepadButton::guide); state.buttons.set(lvh::GamepadButton::dpad_left); state.buttons.set(lvh::GamepadButton::right_stick); state.left_stick = {.x = 1.0F, .y = 0.0F}; state.right_stick = {.x = 0.0F, .y = -1.0F}; state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F}; state.gyroscope = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F}; ASSERT_TRUE(created.adapter->set_state(state).ok()); const auto input = read_hid_report_matching( reader.get(), hid_interface->input_report_size, 5s, [](const auto &report) { return report.size() >= 6U && report[0] == 0x30U && report[3] == 0x08U && report[4] == 0x14U && report[5] == 0x08U; } ); ASSERT_TRUE(input.has_value()) << "No native Switch 0x30 input report reached the HID client"; ASSERT_EQ(input->size(), profile.input_report_size); EXPECT_EQ(input->at(0), 0x30U); EXPECT_EQ(input->at(3), 0x08U); // Nintendo A in the native right-button byte. EXPECT_EQ(input->at(4), 0x14U); // R3 and Home. EXPECT_EQ(input->at(5), 0x08U); // D-pad left. EXPECT_EQ(input->at(6), 0xFFU); EXPECT_EQ(input->at(7), 0x0FU); EXPECT_EQ(input->at(8), 0x80U); EXPECT_EQ(input->at(9), 0x00U); EXPECT_EQ(input->at(10), 0x08U); EXPECT_EQ(input->at(11), 0x00U); const auto read_i16 = [&input](std::size_t offset) { return static_cast( static_cast(input->at(offset)) | (static_cast(input->at(offset + 1U)) << 8U) ); }; for (const auto offset : {13U, 25U, 37U}) { EXPECT_EQ(read_i16(offset), 4096); EXPECT_EQ(read_i16(offset + 2U), -4096); EXPECT_EQ(read_i16(offset + 4U), 8192); EXPECT_EQ(read_i16(offset + 6U), 43); EXPECT_EQ(read_i16(offset + 8U), -14); EXPECT_EQ(read_i16(offset + 10U), 29); } state.gyroscope = lvh::Vector3 {.x = -4.0F, .y = 5.0F, .z = 6.0F}; ASSERT_TRUE(created.adapter->set_state(state).ok()); const auto next_input = read_hid_report_matching( reader.get(), hid_interface->input_report_size, 5s, [&input](const auto &report) { return report.size() > 19U && report[0] == 0x30U && report[19] != input->at(19); } ); ASSERT_TRUE(next_input.has_value()) << "No second native Switch motion report reached the HID client"; ASSERT_EQ(next_input->size(), profile.input_report_size); EXPECT_NE(next_input->at(1), input->at(1)); EXPECT_NE(next_input->at(19), input->at(19)); ASSERT_TRUE(created.adapter->close().ok()); } TEST_F(WindowsConsumerTest, NativeXboxPidRumbleWritesAreNormalized) { 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 auto profile = lvh::profiles::xbox_one(); const auto previous_paths = current_gamepad_interface_paths(); lvh::CreateGamepadOptions options; options.profile = profile; auto created = lvh::GamepadStateAdapter::create(*runtime, options); ASSERT_TRUE(created) << created.status.message(); GamepadOutputCapture output_capture; output_capture.attach(*created.adapter); 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"; if (hid_interface->output_report_size == 0U) { GTEST_SKIP() << "The enumerated Xbox HID game-controller child is input-only on this host"; } ASSERT_EQ(hid_interface->output_report_size, profile.output_report_size + 1U); Handle hid {CreateFileW( hid_interface->path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr )}; ASSERT_TRUE(hid) << "Unable to open the VHF Xbox HID interface: " << GetLastError(); const std::vector report {0, 0x0F, 25, 50, 75, 100, 10, 0, 0}; DWORD bytes_written = 0; ASSERT_TRUE(WriteFile( hid.get(), report.data(), static_cast(report.size()), &bytes_written, nullptr )) << "WriteFile failed: " << GetLastError(); ASSERT_EQ(bytes_written, report.size()); const auto rumble = output_capture.wait_for_rumble(true); ASSERT_TRUE(rumble.has_value()) << "No normalized Xbox rumble callback followed the native HID output write"; EXPECT_EQ(rumble->low_frequency_rumble, 49151U); EXPECT_EQ(rumble->high_frequency_rumble, 65535U); 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(), lvh::profiles::xbox_series(), lvh::profiles::dualshock4(), lvh::profiles::dualsense(), lvh::profiles::switch_pro(), }; 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"; for (const auto &profile : profiles) { SCOPED_TRACE(profile.name); const auto previous_paths = current_gamepad_interface_paths(); lvh::CreateGamepadOptions options; options.profile = profile; options.metadata.has_battery = true; options.metadata.stable_id = profile.name; auto created = lvh::GamepadStateAdapter::create(*runtime, options); ASSERT_TRUE(created) << created.status.message(); ASSERT_TRUE(created.adapter->set_battery({ .state = lvh::GamepadBatteryState::discharging, .percentage = 50, }) .ok()); const auto &effective_profile = created.adapter->gamepad()->profile(); const auto hid_interface = wait_for_new_interface(previous_paths, effective_profile.vendor_id, effective_profile.product_id); ASSERT_TRUE(hid_interface.has_value()) << "The VHF " << profile.name << " HID interface was not enumerated"; Handle hid {CreateFileW( hid_interface->path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr )}; ASSERT_TRUE(hid) << "Unable to open the VHF " << profile.name << " HID interface: " << GetLastError(); auto expected_report = created.adapter->gamepad()->last_input_report(); if (effective_profile.report_id == 0U) { expected_report.insert(expected_report.begin(), 0U); } std::vector queried_report(hid_interface->input_report_size, 0); DWORD last_get_input_report_error = ERROR_SUCCESS; auto received_expected_report = false; const auto deadline = std::chrono::steady_clock::now() + 3s; while (std::chrono::steady_clock::now() < deadline) { std::ranges::fill(queried_report, std::uint8_t {}); queried_report[0] = effective_profile.report_id; if (HidD_GetInputReport(hid.get(), queried_report.data(), static_cast(queried_report.size())) == FALSE) { last_get_input_report_error = GetLastError(); std::this_thread::sleep_for(10ms); continue; } last_get_input_report_error = ERROR_SUCCESS; if (queried_report == expected_report) { received_expected_report = true; break; } std::this_thread::sleep_for(10ms); } ASSERT_TRUE(received_expected_report) << profile.name << " did not return the current input report before the deadline; last error: " << last_get_input_report_error; EXPECT_TRUE(created.adapter->close().ok()); } } TEST_F(WindowsConsumerTest, XboxSeriesNativeInputCarriesShareButton) { 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 auto profile = lvh::profiles::xbox_series(); const auto previous_paths = current_gamepad_interface_paths(); lvh::CreateGamepadOptions options; options.profile = profile; 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, lvh::detail::windows::xbox_series_windows_product_id); ASSERT_TRUE(hid_interface.has_value()) << "The VHF Xbox Series HID interface was not enumerated"; ASSERT_EQ(hid_interface->input_report_size, profile.input_report_size + 1U); ASSERT_EQ(hid_interface->output_report_size, profile.output_report_size + 1U); Handle hid {CreateFileW( hid_interface->path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, nullptr )}; ASSERT_TRUE(hid) << "Unable to open the VHF Xbox Series HID interface: " << GetLastError(); lvh::GamepadState state; state.buttons.set(lvh::GamepadButton::a); state.buttons.set(lvh::GamepadButton::back); state.buttons.set(lvh::GamepadButton::start); state.buttons.set(lvh::GamepadButton::guide); state.buttons.set(lvh::GamepadButton::misc1); ASSERT_TRUE(created.adapter->set_state(state).ok()); const auto input = read_hid_report_with_timeout( hid.get(), hid_interface->input_report_size, 5s ); ASSERT_TRUE(input.has_value()) << "No Xbox Series input report reached the HID client"; ASSERT_EQ(input->size(), hid_interface->input_report_size); EXPECT_EQ(input->at(0), 0U); // Unnumbered HID read buffers carry a leading zero. EXPECT_NE(input->at(13) & 0x01U, 0U); // A EXPECT_NE(input->at(13) & 0x40U, 0U); // Back/View EXPECT_NE(input->at(13) & 0x80U, 0U); // Start/Menu EXPECT_NE(input->at(14) & 0x08U, 0U); // Share / misc1 EXPECT_NE(input->at(16) & 0x01U, 0U); // Guide ASSERT_TRUE(created.adapter->close().ok()); } TEST_F(WindowsConsumerTest, NativeGenericPidInterfaceIsAdvertisedToDirectInput) { const auto profile = lvh::profiles::generic_gamepad(); const auto previous_paths = current_gamepad_interface_paths(); 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"; lvh::CreateGamepadOptions options; options.profile = profile; 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 Generic PID HID interface was not enumerated"; EXPECT_EQ(hid_interface->usage, 0x04U); // DirectInput PID requires a Joystick TLC. ASSERT_EQ(hid_interface->output_report_size, lvh::detail::windows::generic_pid_output_report_size); void *raw_direct_input = nullptr; ASSERT_TRUE(SUCCEEDED(DirectInput8Create( GetModuleHandleW(nullptr), DIRECTINPUT_VERSION, IID_IDirectInput8W, &raw_direct_input, nullptr ))); ComInterface direct_input {static_cast(raw_direct_input)}; const auto direct_input_guid = wait_for_directinput_device( *direct_input, profile.vendor_id, profile.product_id ); ASSERT_TRUE(direct_input_guid.has_value()) << "DirectInput did not enumerate the VHF Generic Controller"; IDirectInputDevice8W *raw_direct_input_device = nullptr; ASSERT_TRUE(SUCCEEDED(direct_input->CreateDevice( *direct_input_guid, &raw_direct_input_device, nullptr ))); ComInterface direct_input_device {raw_direct_input_device}; DIDEVCAPS direct_input_caps {}; direct_input_caps.dwSize = sizeof(direct_input_caps); ASSERT_TRUE(SUCCEEDED(direct_input_device->GetCapabilities(&direct_input_caps))); EXPECT_NE(direct_input_caps.dwFlags & DIDC_FORCEFEEDBACK, 0U); bool has_constant_force = false; ASSERT_TRUE(SUCCEEDED(direct_input_device->EnumEffects( find_constant_force_effect, &has_constant_force, DIEFT_ALL ))); ASSERT_TRUE(has_constant_force) << "DirectInput did not advertise GUID_ConstantForce"; std::vector force_feedback_axes; ASSERT_TRUE(SUCCEEDED(direct_input_device->EnumObjects( collect_force_feedback_axes, &force_feedback_axes, DIDFT_AXIS ))); ASSERT_FALSE(force_feedback_axes.empty()) << "DirectInput did not expose a force-feedback actuator axis"; }