From b44a75e883cc47ea7ae7467f1a6d443a5008db76 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:15:32 -0400 Subject: [PATCH 01/12] fix(linux): route Xbox Series gamepad through uinput Adds a dedicated Linux uinput Xbox Series backend that emits canonical evdev button/axis events and translates force-feedback effects back into normalized rumble callbacks, while keeping other gamepads on UHID. The backend gamepad submit interface now accepts both normalized state and packed report so native backends can consume state directly. It also updates gamepad descriptors/report packing (including misc1 button handling and hat-based dpad bits), sets Switch Pro UHID bus to virtual, refreshes platform/usage docs, and expands Linux adapter/backend/consumer tests accordingly. --- README.md | 5 +- docs/platform-support.md | 26 +- docs/usage.md | 3 + src/core/backend.cpp | 5 +- src/core/backend.hpp | 8 +- src/core/gamepad_adapter.cpp | 4 +- src/core/profiles.cpp | 292 ++++++++----- src/core/report.cpp | 20 +- src/core/runtime.cpp | 5 +- src/platform/linux/uhid_backend.cpp | 401 +++++++++++++++++- src/platform/windows/windows_backend.cpp | 10 +- tests/CMakeLists.txt | 6 +- .../fixtures/linux_backend_test_hooks.hpp | 48 +++ tests/fixtures/linux_backend_test_hooks.cpp | 118 +++++- tests/fixtures/windows_backend_test_hooks.cpp | 10 +- tests/unit/test_gamepad_adapter.cpp | 5 + tests/unit/test_linux_backend.cpp | 184 +++++--- tests/unit/test_linux_consumers.cpp | 145 +++++-- tests/unit/test_macos_backend.cpp | 9 +- tests/unit/test_profiles.cpp | 121 +++++- tests/unit/test_report.cpp | 2 +- 21 files changed, 1128 insertions(+), 299 deletions(-) diff --git a/README.md b/README.md index b08354c..67e7220 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ behind backend implementations. - Gamepad profiles for generic HID, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Nintendo Switch Pro-style controllers. -- Descriptor-driven Linux gamepads through `uhid`, plus keyboard, mouse, - touchscreen, trackpad, and pen tablet devices through `uinput`. +- Descriptor-driven Linux gamepads through `uhid`; Xbox Series gamepads plus + keyboard, mouse, touchscreen, trackpad, and pen tablet devices through + `uinput`. - Windows gamepads through a user-mode UMDF2 control driver backed by Virtual HID Framework, with keyboard and mouse support through normal Win32 APIs. - Output callbacks for profile-specific feedback such as rumble, LEDs, diff --git a/docs/platform-support.md b/docs/platform-support.md index 96abb61..2046287 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -46,15 +46,29 @@ and signing details. The Linux backend uses standard user-space kernel interfaces: - `uhid` for descriptor-driven HID gamepads. -- `uinput` for keyboard, mouse, touchscreen, trackpad, and pen tablet devices. +- `uinput` for Xbox Series gamepads, keyboard, mouse, touchscreen, trackpad, + and pen tablet devices. - `libevdev` internally for uinput device construction. - X11/XTest only as a keyboard and mouse fallback when `uinput` cannot be used and an X11 session is available. -Gamepad support prefers `uhid` because descriptors, raw HID identity, feature -reports, and output reports matter for controller compatibility. Keyboard and -pointer devices prefer `uinput` because those devices map naturally to Linux -input devices. +Gamepad support normally prefers `uhid` because descriptors, raw HID identity, +feature reports, and output reports matter for controller compatibility. Xbox +Series is the exception: on Linux it uses `uinput` so SDL, Steam, and other +evdev consumers receive the native Xbox button codes without interpreting the +descriptor report as Xbox GIP traffic. Face buttons, shoulders, menu buttons, +stick clicks, the guide button, and Share are exposed through their canonical +evdev codes; sticks and the directional pad use absolute axes; triggers remain +independent analog `ABS_Z` and `ABS_RZ` axes. Force-feedback effects are +normalized back into the public rumble callback. The backend advertises the +full 15-slot Xbox Series button range expected by Steam for this controller +identity. Its unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` slots are never +pressed; they keep the active buttons after them at the correct Linux indices. + +Other gamepad profiles remain descriptor-driven through `uhid`. The Switch Pro +profile keeps its Nintendo identity but uses the virtual UHID bus on Linux, +preventing `hid-nintendo` from claiming the descriptor-only device and waiting +for physical-controller initialization handshakes. The optional `virtualhid_control` diagnostic UI uses SDL3 and Dear ImGui through the repository CPM lockfile. It is intended to stay on the same UI framework for @@ -87,7 +101,7 @@ KERNEL=="hidraw*", ATTRS{name}=="Your App Controller*", GROUP="input", MODE="066 SUBSYSTEMS=="input", ATTRS{name}=="Your App Controller*", GROUP="input", MODE="0660", TAG+="uaccess" ``` -For `uhid` gamepad support, install a modules-load entry such as +For descriptor-driven `uhid` gamepad support, install a modules-load entry such as `/etc/modules-load.d/60-libvirtualhid.conf`: ```text diff --git a/docs/usage.md b/docs/usage.md index f956279..bf99d3c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -178,3 +178,6 @@ Profiles advertise support for features such as rumble, trigger rumble, RGB LEDs, adaptive triggers, motion sensors, touchpads, battery state, profile specific buttons, and raw output reports. Consumers should query profile and backend capabilities before warning users about unsupported client features. +The `misc1` button represents Share/Capture/Mic Mute-style controls and is +available on the generic, Xbox Series, DualSense, and Switch Pro profiles; Xbox +360 and Xbox One do not advertise that extra button. diff --git a/src/core/backend.cpp b/src/core/backend.cpp index 930c88b..1476cba 100644 --- a/src/core/backend.cpp +++ b/src/core/backend.cpp @@ -19,7 +19,10 @@ namespace lvh::detail { */ class FakeGamepad final: public BackendGamepad { public: - OperationStatus submit(const std::vector & /*report*/) override { + OperationStatus submit( + const GamepadState & /*state*/, + const std::vector & /*report*/ + ) override { return OperationStatus::success(); } diff --git a/src/core/backend.hpp b/src/core/backend.hpp index 1896a7f..d4a4553 100644 --- a/src/core/backend.hpp +++ b/src/core/backend.hpp @@ -30,12 +30,16 @@ namespace lvh::detail { virtual ~BackendGamepad() = default; /** - * @brief Submit a packed input report to the backend. + * @brief Submit normalized state and its packed input report to the backend. * + * HID backends consume @p report, while native platform input backends may + * consume @p state. + * + * @param state Normalized gamepad state. * @param report Packed HID input report. * @return Submit status. */ - virtual OperationStatus submit(const std::vector &report) = 0; + virtual OperationStatus submit(const GamepadState &state, const std::vector &report) = 0; /** * @brief Get platform-visible nodes associated with this backend device. diff --git a/src/core/gamepad_adapter.cpp b/src/core/gamepad_adapter.cpp index 394c7fb..d7360e5 100644 --- a/src/core/gamepad_adapter.cpp +++ b/src/core/gamepad_adapter.cpp @@ -44,12 +44,12 @@ namespace lvh { using enum GamepadProfileKind; case generic: - case xbox_360: - case xbox_one: case xbox_series: case dualsense: case switch_pro: return true; + case xbox_360: + case xbox_one: case dualshock4: return false; } diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index 9305536..c9dee61 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -18,8 +18,6 @@ namespace lvh::profiles { constexpr std::uint8_t common_button_count = 12; - constexpr std::uint8_t standard_button_count = 16; - constexpr std::uint8_t common_axis_count = 6; constexpr std::size_t common_button_bytes = 2; @@ -86,18 +84,87 @@ namespace lvh::profiles { return descriptor; } + void append_common_gamepad_buttons(std::vector &descriptor, bool include_misc_button) { + descriptor.insert( + descriptor.end(), + { + 0x05, + 0x09, // Usage Page (Button) + 0x09, + 0x01, // Usage (Button 1 / BTN_SOUTH) + 0x09, + 0x02, // Usage (Button 2 / BTN_EAST) + 0x09, + 0x04, // Usage (Button 4 / BTN_NORTH) + 0x09, + 0x05, // Usage (Button 5 / BTN_WEST) + 0x09, + 0x07, // Usage (Button 7 / BTN_TL) + 0x09, + 0x08, // Usage (Button 8 / BTN_TR) + 0x09, + 0x0B, // Usage (Button 11 / BTN_SELECT) + 0x09, + 0x0C, // Usage (Button 12 / BTN_START) + 0x09, + 0x0E, // Usage (Button 14 / BTN_THUMBL) + 0x09, + 0x0F, // Usage (Button 15 / BTN_THUMBR) + 0x09, + 0x0D, // Usage (Button 13 / BTN_MODE) + } + ); + if (include_misc_button) { + descriptor.insert( + descriptor.end(), + { + 0x09, + 0x06, // Usage (Button 6 / BTN_Z) + } + ); + } + descriptor.insert( + descriptor.end(), + { + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + static_cast(include_misc_button ? common_button_count : common_button_count - 1U), + 0x81, + 0x02, // Input (Data,Var,Abs) + } + ); + if (!include_misc_button) { + descriptor.insert( + descriptor.end(), + { + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Const,Var,Abs) + } + ); + } + } + std::vector make_xbox_gip_report_descriptor(bool include_share_button) { constexpr std::string_view xbox_one_descriptor = "05010905a101a10009300931150027ffff0000950275108102c0a10009330934150027ffff0000950275108102c0" "05010932150026ff039501750a81021500250075069501810305010935150026ff039501750a8102150025007506" - "9501810305091901290a950a750181021500250075069501810305010939150125083500463b0166140075049501" + "950181030509090109020904090509070908090b090c090e090f150025017501950a81021500250075069501810305010939150125083500463b0166140075049501" "814275049501150025003500450065008103a102050f099715002501750495019102150025009103097015002564" "7508950491020950660110550e26ff009501910209a7910265005500097c9102c005010980a100098515002501" "95017501810215002500750795018103c005060920150026ff00750895018102c0"; constexpr std::string_view xbox_series_descriptor = "05010905a101a10009300931150027ffff0000950275108102c0a10009330934150027ffff0000950275108102c0" "05010932150026ff039501750a81021500250075069501810305010935150026ff039501750a8102150025007506" - "9501810305091901290c950c750181021500250075049501810305010939150125083500463b0166140075049501" + "950181030509090109020904090509070908090b090c090e090f150025017501950a8102150025007501950181030906150025017501950181021500250075049501810305010939150125083500463b0166140075049501" "814275049501150025003500450065008103a102050f099715002501750495019102150025009103097015002564" "7508950491020950660110550e26ff009501910209a7910265005500097c9102c005010980a100098515002501" "95017501810215002500750795018103c005060920150026ff00750895018102c0"; @@ -107,9 +174,9 @@ namespace lvh::profiles { std::vector make_switch_pro_report_descriptor() { constexpr std::string_view descriptor = - "050115000904a1018530050105091901290a150025017501950a5500650081020509190b290e150025017501" + "050115000904a1018530050105090902090109040905090709080909090a090b090c150025017501950a5500650081020509090e090f090d0906150025017501" "950481027501950281030b01000100a1000b300001000b310001000b320001000b35000100150027ffff0000" - "751095048102c00b39000100150025073500463b0165147504950181020509190f291215002501750195048102" + "751095048102c00b39000100150025073500463b016514750495018102750495018103" "7508953481030600ff852109017508953f8103858109027508953f8103850109037508953f9183851009047508" "953f9183858009057508953f9183858209067508953f9183c0"; @@ -126,71 +193,61 @@ namespace lvh::profiles { 0x01, // Collection (Application) 0x85, report_id, // Report ID - 0x05, - 0x09, // Usage Page (Button) - 0x19, - 0x01, // Usage Minimum (Button 1) - 0x29, - common_button_count, // Usage Maximum - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x01, // Logical Maximum (1) - 0x75, - 0x01, // Report Size (1) - 0x95, - common_button_count, // Report Count - 0x81, - 0x02, // Input (Data,Var,Abs) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x09, - 0x39, // Usage (Hat switch) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x07, // Logical Maximum (7) - 0x35, - 0x00, // Physical Minimum (0) - 0x46, - 0x3B, - 0x01, // Physical Maximum (315) - 0x65, - 0x14, // Unit (Eng Rot:Angular Pos) - 0x75, - 0x04, // Report Size (4) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x42, // Input (Data,Var,Abs,Null) - 0x65, - 0x00, // Unit (None) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - common_axis_count, // Report Count - 0x09, - 0x30, // Usage (X) - 0x09, - 0x31, // Usage (Y) - 0x09, - 0x32, // Usage (Z) - 0x09, - 0x33, // Usage (Rx) - 0x09, - 0x34, // Usage (Ry) - 0x09, - 0x35, // Usage (Rz) - 0x81, - 0x02, // Input (Data,Var,Abs) }; + append_common_gamepad_buttons(descriptor, false); + descriptor.insert( + descriptor.end(), + { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x39, // Usage (Hat switch) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x07, // Logical Maximum (7) + 0x35, + 0x00, // Physical Minimum (0) + 0x46, + 0x3B, + 0x01, // Physical Maximum (315) + 0x65, + 0x14, // Unit (Eng Rot:Angular Pos) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x42, // Input (Data,Var,Abs,Null) + 0x65, + 0x00, // Unit (None) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + common_axis_count, // Report Count + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x09, + 0x32, // Usage (Z) + 0x09, + 0x33, // Usage (Rx) + 0x09, + 0x34, // Usage (Ry) + 0x09, + 0x35, // Usage (Rz) + 0x81, + 0x02, // Input (Data,Var,Abs) + } + ); if (supports_rumble) { descriptor.insert( @@ -233,48 +290,61 @@ namespace lvh::profiles { 0x01, // Collection (Application) 0x85, report_id, // Report ID - 0x05, - 0x09, // Usage Page (Button) - 0x19, - 0x01, // Usage Minimum (Button 1) - 0x29, - standard_button_count, // Usage Maximum - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x01, // Logical Maximum (1) - 0x75, - 0x01, // Report Size (1) - 0x95, - standard_button_count, // Report Count - 0x81, - 0x02, // Input (Data,Var,Abs) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - common_axis_count, // Report Count - 0x09, - 0x30, // Usage (X) - 0x09, - 0x31, // Usage (Y) - 0x09, - 0x33, // Usage (Rx) - 0x09, - 0x34, // Usage (Ry) - 0x09, - 0x32, // Usage (Z) - 0x09, - 0x35, // Usage (Rz) - 0x81, - 0x02, // Input (Data,Var,Abs) }; + append_common_gamepad_buttons(descriptor, true); + descriptor.insert( + descriptor.end(), + { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x39, // Usage (Hat switch) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x07, // Logical Maximum (7) + 0x35, + 0x00, // Physical Minimum (0) + 0x46, + 0x3B, + 0x01, // Physical Maximum (315) + 0x65, + 0x14, // Unit (Eng Rot:Angular Pos) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x42, // Input (Data,Var,Abs,Null) + 0x65, + 0x00, // Unit (None) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + common_axis_count, // Report Count + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x09, + 0x33, // Usage (Rx) + 0x09, + 0x34, // Usage (Ry) + 0x09, + 0x32, // Usage (Z) + 0x09, + 0x35, // Usage (Rz) + 0x81, + 0x02, // Input (Data,Var,Abs) + } + ); if (supports_rumble) { descriptor.insert( diff --git a/src/core/report.cpp b/src/core/report.cpp index b4d546e..7876baa 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -214,17 +214,6 @@ namespace lvh::reports { }; } - constexpr auto standard_dpad_button_map() { - using enum GamepadButton; - - return std::array { - ButtonBit {12U, dpad_up}, - ButtonBit {13U, dpad_down}, - ButtonBit {14U, dpad_left}, - ButtonBit {15U, dpad_right}, - }; - } - constexpr auto xbox_extra_button_map() { using enum GamepadButton; @@ -264,13 +253,6 @@ namespace lvh::reports { ); } - std::uint16_t standard_gamepad_button_bits(const ButtonSet &buttons) { - return static_cast( - common_button_bits(buttons) | - button_bits(standard_dpad_button_map(), buttons) - ); - } - std::uint16_t xbox_gip_button_bits(const ButtonSet &buttons) { return static_cast( button_bits(face_shoulder_button_map(), buttons) | @@ -846,7 +828,7 @@ namespace lvh::reports { std::vector report; report.reserve(standard_report_size); report.push_back(profile.report_id); - append_u16(report, standard_gamepad_button_bits(normalized.buttons)); + append_u16(report, static_cast(common_button_bits(normalized.buttons) | dpad_hat_bits(normalized.buttons))); report.push_back(normalize_u8_axis(normalized.left_stick.x)); report.push_back(normalize_u8_axis(-normalized.left_stick.y)); report.push_back(normalize_u8_axis(normalized.right_stick.x)); diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 4a59f8e..3717a45 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -388,13 +388,14 @@ namespace lvh { return OperationStatus::failure(ErrorCode::backend_failure, "failed to pack gamepad input report"); } + const auto normalized = reports::normalize_state(state); if (device.backend) { - if (const auto status = device.backend->submit(report); !status.ok()) { + if (const auto status = device.backend->submit(normalized, report); !status.ok()) { return status; } } - device.last_state = reports::normalize_state(state); + device.last_state = normalized; device.last_report = std::move(report); ++device.submitted_reports; return OperationStatus::success(); diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 0e44dec..8741341 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +78,7 @@ namespace lvh::detail { constexpr auto tablet_distance_max = 1024; constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; + constexpr auto xbox_trigger_max = 255; constexpr auto dualshock4_usb_calibration_report = 0x02; constexpr auto dualshock4_bluetooth_calibration_report = 0x05; constexpr auto dualshock4_pairing_report = 0x12; @@ -249,6 +251,7 @@ namespace lvh::detail { 0x00, 0x00, 0x00, + 0x00, 0x10, 0x27, 0xF0, @@ -497,6 +500,13 @@ namespace lvh::detail { return BUS_USB; } + std::uint16_t to_uhid_bus(const DeviceProfile &profile) { + if (profile.gamepad_kind == GamepadProfileKind::switch_pro) { + return BUS_VIRTUAL; + } + return to_uhid_bus(profile.bus_type); + } + std::uint16_t to_uinput_bus(BusType bus_type) { return to_uhid_bus(bus_type); } @@ -1273,8 +1283,72 @@ namespace lvh::detail { return enable_evdev_property(device, INPUT_PROP_DIRECT, "tablet direct"); } - OperationStatus configure_evdev_device(libevdev *device, DeviceType device_type) { - switch (device_type) { + OperationStatus configure_evdev_xbox_series_gamepad(libevdev *device) { + if (const auto status = enable_evdev_type(device, EV_KEY, "Xbox gamepad button events"); !status.ok()) { + return status; + } + if (const auto status = enable_evdev_type(device, EV_ABS, "Xbox gamepad absolute events"); !status.ok()) { + return status; + } + if (const auto status = enable_evdev_type(device, EV_FF, "Xbox gamepad force-feedback events"); !status.ok()) { + return status; + } + + // Steam maps the Xbox Series VID/PID using the controller's 15-slot HID button layout. + // Keep the unused C, Z, and digital-trigger slots so Linux button indices do not collapse. + // The submit path never presses these reserved slots; triggers remain analog axes. + for (const auto button : { + BTN_SOUTH, + BTN_EAST, + BTN_C, + BTN_NORTH, + BTN_WEST, + BTN_Z, + BTN_TL, + BTN_TR, + BTN_TL2, + BTN_TR2, + BTN_SELECT, + BTN_START, + BTN_MODE, + BTN_THUMBL, + BTN_THUMBR, + KEY_RECORD, + }) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "Xbox gamepad button"); !status.ok()) { + return status; + } + } + + auto dpad = make_absinfo(-1, 1); + for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad directional axis", &dpad); !status.ok()) { + return status; + } + } + + auto stick = make_absinfo(-32768, 32767, 16, 128); + for (const auto code : {ABS_X, ABS_Y, ABS_RX, ABS_RY}) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad stick axis", &stick); !status.ok()) { + return status; + } + } + + auto trigger = make_absinfo(0, xbox_trigger_max); + for (const auto code : {ABS_Z, ABS_RZ}) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad trigger axis", &trigger); !status.ok()) { + return status; + } + } + + if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "Xbox gamepad force-feedback effect"); !status.ok()) { + return status; + } + return enable_evdev_code(device, EV_FF, FF_GAIN, "Xbox gamepad force-feedback gain"); + } + + OperationStatus configure_evdev_device(libevdev *device, const DeviceProfile &profile) { + switch (profile.device_type) { using enum DeviceType; case keyboard: @@ -1288,7 +1362,10 @@ namespace lvh::detail { case pen_tablet: return configure_evdev_pen_tablet(device); case gamepad: - return OperationStatus::failure(ErrorCode::unsupported_profile, "gamepads are created through UHID, not uinput"); + if (profile.gamepad_kind == GamepadProfileKind::xbox_series) { + return configure_evdev_xbox_series_gamepad(device); + } + return OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile is not supported through uinput"); } return OperationStatus::failure(ErrorCode::unsupported_profile, "unsupported uinput device type"); @@ -1310,7 +1387,7 @@ namespace lvh::detail { libevdev_set_id_product(device.get(), profile.product_id); libevdev_set_id_version(device.get(), profile.version); - if (const auto status = configure_evdev_device(device.get(), profile.device_type); !status.ok()) { + if (const auto status = configure_evdev_device(device.get(), profile); !status.ok()) { return {status, nullptr}; } @@ -2232,6 +2309,295 @@ namespace lvh::detail { } #endif + struct UinputRumbleEffect { + std::uint16_t weak_magnitude = 0; + std::uint16_t strong_magnitude = 0; + std::chrono::milliseconds length {0}; + std::chrono::milliseconds delay {0}; + std::chrono::steady_clock::time_point start {}; + std::chrono::steady_clock::time_point end {}; + bool active = false; + }; + + /** + * @brief Xbox Series gamepad exposed through canonical Linux input events. + */ + class UinputXboxGamepad final: public BackendGamepad, private UinputDevice { + public: + explicit UinputXboxGamepad(int file_descriptor): + UinputDevice {file_descriptor} {} + + ~UinputXboxGamepad() override = default; + + OperationStatus create(DeviceId id, const CreateGamepadOptions &options) { + if (options.profile.gamepad_kind != GamepadProfileKind::xbox_series) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "uinput Xbox backend requires the Xbox Series profile"); + } + + device_name_ = options.profile.name; + if (const auto status = create_uinput_device(options.profile, id); !status.ok()) { + return status; + } + + running_ = true; + reader_ = std::jthread {[this](std::stop_token stop_token) { + read_output_loop(stop_token); + }}; + return OperationStatus::success(); + } + + OperationStatus submit( + const GamepadState &state, + const std::vector & /*report*/ + ) override { + using enum GamepadButton; + + if (!is_open()) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput Xbox gamepad is closed"); + } + + const auto normalized = reports::normalize_state(state); + std::lock_guard lock {submit_mutex_}; + + constexpr std::array button_map { + std::pair {a, BTN_SOUTH}, + std::pair {b, BTN_EAST}, + std::pair {x, BTN_NORTH}, + std::pair {y, BTN_WEST}, + std::pair {left_shoulder, BTN_TL}, + std::pair {right_shoulder, BTN_TR}, + std::pair {back, BTN_SELECT}, + std::pair {start, BTN_START}, + std::pair {guide, BTN_MODE}, + std::pair {left_stick, BTN_THUMBL}, + std::pair {right_stick, BTN_THUMBR}, + std::pair {misc1, KEY_RECORD}, + }; + for (const auto &[button, code] : button_map) { + if (const auto status = emit_event(EV_KEY, code, normalized.buttons.test(button) ? 1 : 0); !status.ok()) { + return status; + } + } + + if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(normalized.buttons, dpad_left, dpad_right)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(normalized.buttons, dpad_up, dpad_down)); !status.ok()) { + return status; + } + + const std::array axes { + std::pair {ABS_X, static_cast(reports::normalize_axis(normalized.left_stick.x))}, + std::pair {ABS_Y, static_cast(reports::normalize_axis(-normalized.left_stick.y))}, + std::pair {ABS_RX, static_cast(reports::normalize_axis(normalized.right_stick.x))}, + std::pair {ABS_RY, static_cast(reports::normalize_axis(-normalized.right_stick.y))}, + std::pair {ABS_Z, static_cast(reports::normalize_trigger(normalized.left_trigger))}, + std::pair {ABS_RZ, static_cast(reports::normalize_trigger(normalized.right_trigger))}, + }; + for (const auto &[code, value] : axes) { + if (const auto status = emit_event(EV_ABS, code, value); !status.ok()) { + return status; + } + } + return sync(); + } + + void set_output_callback(OutputCallback callback) override { + std::lock_guard lock {callback_mutex_}; + output_callback_ = std::move(callback); + } + + std::vector device_nodes() const override { + return discover_input_nodes_by_name(device_name_); + } + + OperationStatus close() override { + running_ = false; + if (reader_.joinable()) { + reader_.request_stop(); + reader_.join(); + } + dispatch_rumble(0, 0); + return close_uinput("uinput Xbox gamepad"); + } + + private: + static int dpad_axis(const ButtonSet &buttons, GamepadButton negative, GamepadButton positive) { + const auto negative_pressed = buttons.test(negative); + if (const auto positive_pressed = buttons.test(positive); negative_pressed == positive_pressed) { + return 0; + } + return negative_pressed ? -1 : 1; + } + + void read_output_loop(std::stop_token stop_token) { + while (!stop_token.stop_requested() && running_) { + pollfd descriptor {}; + descriptor.fd = file_descriptor(); + descriptor.events = POLLIN; + + const auto poll_result = system_poll(&descriptor, 1, poll_timeout_ms); + if (poll_result < 0) { + if (errno == EINTR) { + continue; + } + return; + } + if (poll_result > 0) { + if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + return; + } + if ((descriptor.revents & POLLIN) != 0 && !read_output_events()) { + return; + } + } + update_rumble(); + } + } + + bool read_output_events() { + std::array events {}; + const auto result = system_read(file_descriptor(), std::as_writable_bytes(std::span {events})); + if (result < 0) { + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; + } + if (result == 0) { + return false; + } + + const auto event_count = static_cast(result) / sizeof(input_event); + for (std::size_t index = 0; index < event_count; ++index) { + handle_output_event(events[index]); + } + return true; + } + + void handle_output_event(const input_event &event) { + if (event.type == EV_UINPUT && event.code == UI_FF_UPLOAD) { + upload_rumble_effect(event.value); + return; + } + if (event.type == EV_UINPUT && event.code == UI_FF_ERASE) { + erase_rumble_effect(event.value); + return; + } + if (event.type != EV_FF) { + return; + } + if (event.code == FF_GAIN) { + gain_ = static_cast(std::clamp(event.value, 0, static_cast(std::numeric_limits::max()))); + return; + } + + const auto effect = rumble_effects_.find(event.code); + if (effect == rumble_effects_.end()) { + return; + } + if (event.value <= 0) { + effect->second.active = false; + return; + } + + const auto now = std::chrono::steady_clock::now(); + effect->second.active = true; + effect->second.start = now + effect->second.delay; + effect->second.end = effect->second.start + effect->second.length * std::max(event.value, 1); + } + + void upload_rumble_effect(int request_id) { + uinput_ff_upload upload {}; + upload.request_id = request_id; + if (system_ioctl(file_descriptor(), UI_BEGIN_FF_UPLOAD, reinterpret_cast(&upload)) < 0) { + return; + } + + if (upload.effect.type == FF_RUMBLE) { + auto effect = UinputRumbleEffect { + .weak_magnitude = upload.effect.u.rumble.weak_magnitude, + .strong_magnitude = upload.effect.u.rumble.strong_magnitude, + .length = std::chrono::milliseconds {upload.effect.replay.length}, + .delay = std::chrono::milliseconds {upload.effect.replay.delay}, + }; + if (const auto existing = rumble_effects_.find(upload.effect.id); existing != rumble_effects_.end()) { + effect.start = existing->second.start; + effect.end = existing->second.end; + effect.active = existing->second.active; + } + rumble_effects_.insert_or_assign(upload.effect.id, effect); + } + + upload.retval = 0; + static_cast(system_ioctl(file_descriptor(), UI_END_FF_UPLOAD, reinterpret_cast(&upload))); + } + + void erase_rumble_effect(int request_id) { + uinput_ff_erase erase {}; + erase.request_id = request_id; + if (system_ioctl(file_descriptor(), UI_BEGIN_FF_ERASE, reinterpret_cast(&erase)) < 0) { + return; + } + + rumble_effects_.erase(erase.effect_id); + erase.retval = 0; + static_cast(system_ioctl(file_descriptor(), UI_END_FF_ERASE, reinterpret_cast(&erase))); + } + + void update_rumble() { + const auto now = std::chrono::steady_clock::now(); + auto weak = std::uint64_t {0}; + auto strong = std::uint64_t {0}; + for (auto &[id, effect] : rumble_effects_) { + static_cast(id); + if (!effect.active || now < effect.start) { + continue; + } + if (now >= effect.end) { + effect.active = false; + continue; + } + weak += effect.weak_magnitude; + strong += effect.strong_magnitude; + } + + constexpr auto maximum = std::numeric_limits::max(); + const auto scaled_weak = static_cast(std::min(weak, maximum) * gain_ / maximum); + const auto scaled_strong = static_cast(std::min(strong, maximum) * gain_ / maximum); + dispatch_rumble(scaled_strong, scaled_weak); + } + + void dispatch_rumble(std::uint16_t low_frequency, std::uint16_t high_frequency) { + if (last_low_frequency_ == low_frequency && last_high_frequency_ == high_frequency) { + return; + } + last_low_frequency_ = low_frequency; + last_high_frequency_ = high_frequency; + + OutputCallback callback; + { + std::lock_guard lock {callback_mutex_}; + callback = output_callback_; + } + if (callback) { + GamepadOutput output; + output.kind = GamepadOutputKind::rumble; + output.low_frequency_rumble = low_frequency; + output.high_frequency_rumble = high_frequency; + callback(output); + } + } + + std::string device_name_; + std::atomic_bool running_ = false; + std::mutex submit_mutex_; + std::mutex callback_mutex_; + OutputCallback output_callback_; + std::map rumble_effects_; + std::uint16_t gain_ = std::numeric_limits::max(); + std::uint16_t last_low_frequency_ = 0; + std::uint16_t last_high_frequency_ = 0; + std::jthread reader_; + }; + /** * @brief Backend gamepad backed by one Linux UHID file descriptor. */ @@ -2268,7 +2634,7 @@ namespace lvh::detail { copy_string(request.phys, std::format("libvirtualhid/uhid/{}", id)); copy_string(request.uniq, unique_id); request.rd_size = static_cast(options.profile.report_descriptor.size()); - request.bus = to_uhid_bus(options.profile.bus_type); + request.bus = to_uhid_bus(options.profile); request.vendor = options.profile.vendor_id; request.product = options.profile.product_id; request.version = options.profile.version; @@ -2296,7 +2662,10 @@ namespace lvh::detail { return OperationStatus::success(); } - OperationStatus submit(const std::vector &report) override { + OperationStatus submit( + const GamepadState & /*state*/, + const std::vector &report + ) override { if (!open_) { return OperationStatus::failure(ErrorCode::device_closed, "UHID gamepad is closed"); } @@ -2453,7 +2822,7 @@ namespace lvh::detail { report = last_report_; } if (!report.empty()) { - static_cast(submit(report)); + static_cast(submit({}, report)); } } } @@ -2585,13 +2954,13 @@ namespace lvh::detail { const auto xtest_accessible = can_use_xtest(); capabilities_.backend_name = "linux-uhid-uinput"; capabilities_.supports_virtual_hid = uhid_accessible || uinput_accessible; - capabilities_.supports_gamepad = uhid_accessible; + capabilities_.supports_gamepad = uhid_accessible || uinput_accessible; capabilities_.supports_keyboard = uinput_accessible || xtest_accessible; capabilities_.supports_mouse = uinput_accessible || xtest_accessible; capabilities_.supports_touchscreen = uinput_accessible; capabilities_.supports_trackpad = uinput_accessible; capabilities_.supports_pen_tablet = uinput_accessible; - capabilities_.supports_output_reports = uhid_accessible; + capabilities_.supports_output_reports = uhid_accessible || uinput_accessible; capabilities_.supports_xtest_fallback = xtest_accessible; } @@ -2600,6 +2969,20 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { + if (options.profile.gamepad_kind == GamepadProfileKind::xbox_series) { + const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; + } + + auto gamepad = std::make_unique(fd); + if (const auto status = gamepad->create(id, options); !status.ok()) { + static_cast(gamepad->close()); + return {status, nullptr}; + } + return {OperationStatus::success(), std::move(gamepad)}; + } + const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index f388bd8..7b687b6 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -711,7 +711,10 @@ namespace lvh::detail { context_ {std::move(context)}, state_ {std::move(state)} {} - OperationStatus submit(const std::vector &report) override; + OperationStatus submit( + const GamepadState &state, + const std::vector &report + ) override; void set_output_callback(OutputCallback callback) override; std::vector device_nodes() const override; OperationStatus close() override; @@ -870,7 +873,10 @@ namespace lvh::detail { std::map> gamepads_; }; - OperationStatus WindowsGamepad::submit(const std::vector &report) { + OperationStatus WindowsGamepad::submit( + const GamepadState & /*state*/, + const std::vector &report + ) { using enum ErrorCode; { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 929ae67..81258e7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,8 +24,6 @@ set(TEST_BINARY test_libvirtualhid) set(LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_adapter.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_backend.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_consumers.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_profiles.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_report.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp" @@ -43,7 +41,9 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) list(APPEND LIBVIRTUALHID_TEST_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/linux_backend_test_hooks.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/linux_backend_test_hooks.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_backend.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_consumers.cpp") if(LIBVIRTUALHID_ENABLE_XTEST) find_package(X11 QUIET) diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 3c98cab..2c61ab3 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -51,6 +51,31 @@ namespace lvh::detail::test { std::vector events; }; + /** + * @brief Result from a fake uinput Xbox force-feedback exchange. + */ + struct LinuxUinputRumbleResult { + /** + * @brief Create operation status. + */ + OperationStatus create_status; + + /** + * @brief Close operation status. + */ + OperationStatus close_status; + + /** + * @brief Number of normalized rumble callbacks received. + */ + std::size_t callback_count = 0; + + /** + * @brief Last normalized rumble callback. + */ + GamepadOutput last_output; + }; + /** * @brief Result from a socketpair-backed UHID lifecycle test. */ @@ -363,6 +388,14 @@ namespace lvh::detail::test { */ std::uint16_t linux_uhid_bus(BusType bus_type); + /** + * @brief Select the Linux UHID bus code for a built-in gamepad profile. + * + * @param kind Built-in gamepad profile kind. + * @return Linux UHID bus code. + */ + std::uint16_t linux_gamepad_uhid_bus(GamepadProfileKind kind); + /** * @brief Translate a bus type to a Linux uinput bus code. * @@ -567,6 +600,21 @@ namespace lvh::detail::test { */ LinuxInputSubmissionResult linux_uinput_keyboard_submit_pipe(const KeyboardEvent &event); + /** + * @brief Submit normalized state through a pipe-backed Xbox Series uinput gamepad. + * + * @param state Gamepad state to submit. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_xbox_series_submit_pipe(const GamepadState &state); + + /** + * @brief Exercise Xbox Series uinput force-feedback upload and playback. + * + * @return Creation, callback, and close results. + */ + LinuxUinputRumbleResult linux_uinput_xbox_series_fake_rumble(); + /** * @brief Try creating a libevdev uinput mouse on an invalid file descriptor. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index c93e11e..dd8f79e 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -138,6 +138,9 @@ namespace lvh::detail::test { std::vector read_results; std::vector read_errors; uhid_event read_event {}; + std::vector read_input_events; + ff_effect uploaded_ff_effect {}; + bool provide_uploaded_ff_effect = false; bool override_xtest_query = false; bool xtest_query_result = true; bool override_x_keycode = false; @@ -237,6 +240,13 @@ int lvh_linux_test_ioctl(int fd, unsigned long request, unsigned long argument) errno = EINVAL; return -1; } + if ( + request == UI_BEGIN_FF_UPLOAD && + lvh::detail::test::active_test_syscalls()->provide_uploaded_ff_effect + ) { + auto *upload = std::bit_cast(argument); + upload->effect = lvh::detail::test::active_test_syscalls()->uploaded_ff_effect; + } return 0; } return ::ioctl(fd, request, argument); @@ -283,6 +293,13 @@ std::ptrdiff_t lvh_linux_test_read(int fd, std::byte *buffer, std::size_t size) } if (result > 0) { + if (const auto &input_events = lvh::detail::test::active_test_syscalls()->read_input_events; + call_index < input_events.size()) { + const auto bytes = std::min(static_cast(result), std::min(size, sizeof(input_event))); + std::memcpy(buffer, &input_events[call_index], bytes); + return static_cast(bytes); + } + const auto bytes = std::min(static_cast(result), std::min(size, sizeof(uhid_event))); std::memcpy(buffer, &lvh::detail::test::active_test_syscalls()->read_event, bytes); return static_cast(bytes); @@ -724,7 +741,7 @@ namespace lvh::detail::test { case DeviceType::pen_tablet: return profiles::pen_tablet(); case DeviceType::gamepad: - return profiles::generic_gamepad(); + return profiles::xbox_series(); } return profiles::mouse(); @@ -769,9 +786,10 @@ namespace lvh::detail::test { } case DeviceType::gamepad: { - auto status = create_libevdev_uinput_device(fd, profile_for_uinput_device_type(device_type), 1).status; - static_cast(system_close(fd)); - return status; + CreateGamepadOptions options; + options.profile = profile_for_uinput_device_type(device_type); + UinputXboxGamepad gamepad {fd}; + return gamepad.create(1, options); } } @@ -839,6 +857,11 @@ namespace lvh::detail::test { return to_uhid_bus(bus_type); } + std::uint16_t linux_gamepad_uhid_bus(GamepadProfileKind kind) { + const auto profile = profiles::gamepad_profile(kind); + return profile ? to_uhid_bus(*profile) : to_uhid_bus(BusType::unknown); + } + std::uint16_t linux_uinput_bus(BusType bus_type) { return to_uinput_bus(bus_type); } @@ -935,13 +958,13 @@ namespace lvh::detail::test { OperationStatus linux_uhid_submit_report_size(std::size_t report_size) { UhidGamepad gamepad {-1}; - return gamepad.submit(std::vector(report_size, 0)); + return gamepad.submit({}, std::vector(report_size, 0)); } OperationStatus linux_uhid_submit_after_close() { UhidGamepad gamepad {-1}; static_cast(gamepad.close()); - return gamepad.submit({0}); + return gamepad.submit({}, {0}); } OperationStatus linux_uinput_keyboard_create_invalid_fd() { @@ -982,6 +1005,79 @@ namespace lvh::detail::test { return {std::move(status), std::move(records)}; } + LinuxInputSubmissionResult linux_uinput_xbox_series_submit_pipe(const GamepadState &state) { + std::array descriptors {-1, -1}; + if (::pipe(descriptors.data()) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + const auto profile = profiles::xbox_series(); + UinputXboxGamepad gamepad {descriptors[1]}; + auto status = gamepad.submit(state, reports::pack_input_report(profile, state)); + static_cast(gamepad.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + + LinuxUinputRumbleResult linux_uinput_xbox_series_fake_rumble() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.override_poll = true; + syscalls.poll_results = {1, 1, 0}; + syscalls.poll_revents = {POLLIN, POLLIN, 0}; + syscalls.override_read = true; + syscalls.read_results = { + static_cast(sizeof(input_event)), + static_cast(sizeof(input_event)), + }; + syscalls.read_input_events = { + input_event {.type = EV_UINPUT, .code = UI_FF_UPLOAD, .value = 7}, + input_event {.type = EV_FF, .code = 3, .value = 1}, + }; + syscalls.provide_uploaded_ff_effect = true; + syscalls.uploaded_ff_effect.type = FF_RUMBLE; + syscalls.uploaded_ff_effect.id = 3; + syscalls.uploaded_ff_effect.u.rumble.weak_magnitude = 0x1234; + syscalls.uploaded_ff_effect.u.rumble.strong_magnitude = 0x5678; + syscalls.uploaded_ff_effect.replay.length = 1000; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUinputRumbleResult result; + const auto fd = open_test_fd(); + if (fd < 0) { + result.create_status = system_error_status(ErrorCode::backend_failure, "failed to open test file descriptor", errno); + result.close_status = result.create_status; + return result; + } + + std::atomic_size_t callback_count = 0; + std::mutex output_mutex; + GamepadOutput last_output; + UinputXboxGamepad gamepad {fd}; + gamepad.set_output_callback([&callback_count, &last_output, &output_mutex](const GamepadOutput &output) { + if (output.low_frequency_rumble != 0 || output.high_frequency_rumble != 0) { + std::lock_guard lock {output_mutex}; + last_output = output; + } + ++callback_count; + }); + + CreateGamepadOptions options; + options.profile = profiles::xbox_series(); + result.create_status = gamepad.create(8, options); + for (auto attempt = 0; attempt < 100 && callback_count.load() == 0; ++attempt) { + std::this_thread::sleep_for(std::chrono::milliseconds {1}); + } + result.close_status = gamepad.close(); + result.callback_count = callback_count.load(); + { + std::lock_guard lock {output_mutex}; + result.last_output = last_output; + } + return result; + } + OperationStatus linux_uinput_user_device_invalid_fd() { return create_libevdev_uinput_device(-1, profiles::mouse(), 1).status; } @@ -1268,7 +1364,7 @@ namespace lvh::detail::test { lvh::GamepadState state; state.buttons.set(GamepadButton::a); const auto report = reports::pack_input_report(profile, state); - result.submit_status = gamepad.submit(report); + result.submit_status = gamepad.submit(state, report); if (read_uhid_event(descriptors[1], event)) { result.saw_input = event.type == UHID_INPUT2 && event.u.input2.size == report.size(); } @@ -1311,7 +1407,7 @@ namespace lvh::detail::test { event.u.get_report.rnum = 0x05; static_cast(write_uhid_event(descriptors[1], event)); if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) { - result.saw_dualsense_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 0 && + result.saw_dualsense_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 41U && event.u.get_report_reply.data[0] == 0x05; } @@ -1555,7 +1651,7 @@ namespace lvh::detail::test { result.capabilities = backend.capabilities(); CreateGamepadOptions gamepad_options; - gamepad_options.profile = profiles::xbox_360(); + gamepad_options.profile = profiles::xbox_series(); gamepad_options.metadata.stable_id = "fake-linux-gamepad"; auto gamepad = backend.create_gamepad(1, gamepad_options); result.gamepad_status = gamepad.status; @@ -1862,7 +1958,7 @@ namespace lvh::detail::test { ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; UhidGamepad gamepad {fake_fd}; - return gamepad.submit({0}); + return gamepad.submit({}, {0}); } OperationStatus linux_uhid_submit_fake_short_write() { @@ -1872,7 +1968,7 @@ namespace lvh::detail::test { ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; UhidGamepad gamepad {fake_fd}; - return gamepad.submit({0}); + return gamepad.submit({}, {0}); } OperationStatus linux_uhid_close_fake_write_failure() { diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index aaa6df9..71bccca 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -420,7 +420,7 @@ namespace lvh::detail { if (created) { result.device_nodes = created.gamepad->device_nodes(); std::vector report(options.profile.input_report_size, 0x7A); - result.submit_status = created.gamepad->submit(report); + result.submit_status = created.gamepad->submit({}, report); const auto driver_id = last_created_driver_id(command_state); enqueue_output_report(event_state, driver_id, options.profile); @@ -441,7 +441,7 @@ namespace lvh::detail { enqueue_output_report(event_state, driver_id, options.profile); wait_for_output_events_to_drain(event_state); result.second_close_status = created.gamepad->close(); - result.submit_after_close_status = created.gamepad->submit(report); + result.submit_after_close_status = created.gamepad->submit({}, report); result.create_requests = command_state->create_request_count(); result.submit_requests = command_state->submit_report_count(); @@ -511,8 +511,10 @@ namespace lvh::detail { result.empty_nodes_create_status = created.status; if (created) { result.empty_device_nodes = created.gamepad->device_nodes(); - result.oversized_submit_status = - created.gamepad->submit(std::vector(LVH_WINDOWS_MAX_INPUT_REPORT_SIZE + 1U, 0x7B)); + result.oversized_submit_status = created.gamepad->submit( + {}, + std::vector(LVH_WINDOWS_MAX_INPUT_REPORT_SIZE + 1U, 0x7B) + ); } } diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp index 9f207ab..72d03d8 100644 --- a/tests/unit/test_gamepad_adapter.cpp +++ b/tests/unit/test_gamepad_adapter.cpp @@ -14,6 +14,8 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { const auto generic = lvh::profiles::generic_gamepad(); + const auto xbox_360 = lvh::profiles::xbox_360(); + const auto xbox_one = lvh::profiles::xbox_one(); const auto dualshock4 = lvh::profiles::dualshock4(); const auto dualsense = lvh::profiles::dualsense(); const auto switch_pro = lvh::profiles::switch_pro(); @@ -25,6 +27,9 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_TRUE(generic_support.supports_misc1_button); EXPECT_FALSE(generic_support.supports_trigger_rumble); + EXPECT_FALSE(lvh::gamepad_profile_support(xbox_360).supports_misc1_button); + EXPECT_FALSE(lvh::gamepad_profile_support(xbox_one).supports_misc1_button); + const auto dualshock4_support = lvh::gamepad_profile_support(dualshock4); EXPECT_TRUE(dualshock4_support.supports_rumble); EXPECT_TRUE(dualshock4_support.supports_rgb_led); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index a5b2c92..b71f539 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -5,19 +5,19 @@ // standard includes #include +#include #include #include #include #include +#include #include #include // platform includes -#if defined(__linux__) - #include - #if defined(LIBVIRTUALHID_HAVE_XTEST) - #include - #endif +#include +#if defined(LIBVIRTUALHID_HAVE_XTEST) + #include #endif // lib includes @@ -25,17 +25,13 @@ // local includes #include "fixtures/fixtures.hpp" - -#if defined(__linux__) - #include "fixtures/linux_backend_test_hooks.hpp" -#endif +#include "fixtures/linux_backend_test_hooks.hpp" /** * @brief Test fixture for Linux backend internals. */ class LinuxBackendTest: public LinuxTest {}; -#if defined(__linux__) TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) { EXPECT_EQ(lvh::detail::test::linux_key_code(0x08), KEY_BACKSPACE); EXPECT_EQ(lvh::detail::test::linux_key_code(0x09), KEY_TAB); @@ -111,6 +107,8 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::unknown), BUS_USB); EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::usb), BUS_USB); EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); + EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::xbox_series), BUS_USB); + EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::switch_pro), BUS_VIRTUAL); EXPECT_EQ(lvh::detail::test::linux_uinput_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::pen), BTN_TOOL_PEN); @@ -308,6 +306,84 @@ TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) { EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_pipe().code(), lvh::ErrorCode::backend_failure); } +TEST_F(LinuxBackendTest, PipeBackedXboxSeriesUsesCanonicalLinuxGamepadEvents) { + using enum lvh::GamepadButton; + + struct ButtonCase { + lvh::GamepadButton button; + std::uint16_t linux_code; + }; + + constexpr std::array button_cases { + ButtonCase {a, BTN_SOUTH}, + ButtonCase {b, BTN_EAST}, + ButtonCase {x, BTN_NORTH}, + ButtonCase {y, BTN_WEST}, + ButtonCase {left_shoulder, BTN_TL}, + ButtonCase {right_shoulder, BTN_TR}, + ButtonCase {back, BTN_SELECT}, + ButtonCase {start, BTN_START}, + ButtonCase {guide, BTN_MODE}, + ButtonCase {left_stick, BTN_THUMBL}, + ButtonCase {right_stick, BTN_THUMBR}, + ButtonCase {misc1, KEY_RECORD}, + }; + + for (const auto &[button, linux_code] : button_cases) { + lvh::GamepadState state; + state.buttons.set(button); + const auto result = lvh::detail::test::linux_uinput_xbox_series_submit_pipe(state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + + std::vector pressed_codes; + for (const auto &event : result.events) { + if (event.type == EV_KEY && event.value == 1) { + pressed_codes.push_back(event.code); + } + } + ASSERT_EQ(pressed_codes.size(), 1U) << "logical button " << static_cast(std::to_underlying(button)); + EXPECT_EQ(pressed_codes.front(), linux_code) << "logical button " << static_cast(std::to_underlying(button)); + } + + lvh::GamepadState state; + state.buttons.set(dpad_up); + state.buttons.set(dpad_right); + state.left_stick = {.x = -0.5F, .y = 0.25F}; + state.right_stick = {.x = 0.75F, .y = -1.0F}; + state.left_trigger = 0.25F; + state.right_trigger = 0.75F; + const auto result = lvh::detail::test::linux_uinput_xbox_series_submit_pipe(state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + + const auto event_value = [&result](std::uint16_t code) -> std::optional { + const auto event = std::ranges::find_if(result.events, [code](const auto &candidate) { + return candidate.type == EV_ABS && candidate.code == code; + }); + if (event == result.events.end()) { + return std::nullopt; + } + return event->value; + }; + EXPECT_EQ(event_value(ABS_HAT0X), 1); + EXPECT_EQ(event_value(ABS_HAT0Y), -1); + EXPECT_EQ(event_value(ABS_X), lvh::reports::normalize_axis(-0.5F)); + EXPECT_EQ(event_value(ABS_Y), lvh::reports::normalize_axis(-0.25F)); + EXPECT_EQ(event_value(ABS_RX), lvh::reports::normalize_axis(0.75F)); + EXPECT_EQ(event_value(ABS_RY), lvh::reports::normalize_axis(1.0F)); + EXPECT_EQ(event_value(ABS_Z), lvh::reports::normalize_trigger(0.25F)); + EXPECT_EQ(event_value(ABS_RZ), lvh::reports::normalize_trigger(0.75F)); +} + +TEST_F(LinuxBackendTest, XboxSeriesUinputNormalizesForceFeedback) { + const auto result = lvh::detail::test::linux_uinput_xbox_series_fake_rumble(); + ASSERT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + ASSERT_GE(result.callback_count, 1U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); +} + TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_create_invalid_fd().code(), lvh::ErrorCode::backend_failure); @@ -402,13 +478,13 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { ASSERT_TRUE(result.status.ok()) << result.status.message(); ASSERT_EQ(result.events.size(), 2U); EXPECT_EQ(result.events[0].type, EV_REL); - #if defined(REL_WHEEL_HI_RES) +#if defined(REL_WHEEL_HI_RES) EXPECT_EQ(result.events[0].code, REL_WHEEL_HI_RES); EXPECT_EQ(result.events[0].value, 120); - #else +#else EXPECT_EQ(result.events[0].code, REL_WHEEL); EXPECT_EQ(result.events[0].value, 1); - #endif +#endif EXPECT_EQ(result.events[1].type, EV_SYN); event = {}; @@ -418,13 +494,13 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { ASSERT_TRUE(result.status.ok()) << result.status.message(); ASSERT_EQ(result.events.size(), 2U); EXPECT_EQ(result.events[0].type, EV_REL); - #if defined(REL_HWHEEL_HI_RES) +#if defined(REL_HWHEEL_HI_RES) EXPECT_EQ(result.events[0].code, REL_HWHEEL_HI_RES); EXPECT_EQ(result.events[0].value, -120); - #else +#else EXPECT_EQ(result.events[0].code, REL_HWHEEL); EXPECT_EQ(result.events[0].value, -1); - #endif +#endif EXPECT_EQ(result.events[1].type, EV_SYN); } @@ -593,15 +669,15 @@ TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { EXPECT_FALSE(unavailable.supports_virtual_hid); EXPECT_FALSE(unavailable.supports_gamepad); EXPECT_FALSE(unavailable.supports_output_reports); - #if defined(LIBVIRTUALHID_HAVE_XTEST) +#if defined(LIBVIRTUALHID_HAVE_XTEST) EXPECT_TRUE(unavailable.supports_keyboard); EXPECT_TRUE(unavailable.supports_mouse); EXPECT_TRUE(unavailable.supports_xtest_fallback); - #else +#else EXPECT_FALSE(unavailable.supports_keyboard); EXPECT_FALSE(unavailable.supports_mouse); EXPECT_FALSE(unavailable.supports_xtest_fallback); - #endif +#endif EXPECT_EQ(lvh::detail::test::linux_backend_gamepad_fake_open_failure().code(), lvh::ErrorCode::backend_unavailable); EXPECT_EQ(lvh::detail::test::linux_backend_gamepad_fake_create_failure().code(), lvh::ErrorCode::backend_failure); @@ -693,6 +769,24 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_NE(find_code(keyboard, EV_KEY, KEY_A), nullptr); EXPECT_EQ(keyboard.destroy_count, 1U); + const auto gamepad = lvh::detail::test::linux_uinput_create_fake_libevdev_device(lvh::DeviceType::gamepad); + ASSERT_TRUE(gamepad.status.ok()) << gamepad.status.message(); + EXPECT_EQ(gamepad.name, lvh::profiles::xbox_series().name); + EXPECT_EQ(gamepad.vendor, lvh::profiles::xbox_series().vendor_id); + EXPECT_EQ(gamepad.product, lvh::profiles::xbox_series().product_id); + EXPECT_TRUE(has_type(gamepad, EV_KEY)); + EXPECT_TRUE(has_type(gamepad, EV_ABS)); + EXPECT_TRUE(has_type(gamepad, EV_FF)); + for (auto button = BTN_SOUTH; button <= BTN_THUMBR; ++button) { + EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing Xbox HID button slot " << button; + } + EXPECT_NE(find_code(gamepad, EV_KEY, KEY_RECORD), nullptr); + const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); + ASSERT_NE(left_trigger, nullptr); + EXPECT_EQ(left_trigger->minimum, 0); + EXPECT_EQ(left_trigger->maximum, 255); + EXPECT_NE(find_code(gamepad, EV_FF, FF_RUMBLE), nullptr); + const auto mouse = lvh::detail::test::linux_uinput_create_fake_libevdev_device(lvh::DeviceType::mouse); ASSERT_TRUE(mouse.status.ok()) << mouse.status.message(); EXPECT_TRUE(has_type(mouse, EV_KEY)); @@ -761,11 +855,6 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc lvh::detail::test::linux_uinput_create_fake_libevdev_create_failure(lvh::DeviceType::pen_tablet).code(), lvh::ErrorCode::backend_failure ); - EXPECT_EQ( - lvh::detail::test::linux_uinput_create_fake_libevdev_device(lvh::DeviceType::gamepad).status.code(), - lvh::ErrorCode::unsupported_profile - ); - EXPECT_EQ( lvh::detail::test::linux_uinput_keyboard_submit_fake_write_failure().code(), lvh::ErrorCode::backend_failure @@ -828,7 +917,7 @@ TEST_F(LinuxBackendTest, XTestFallbackCoversKeyboardAndMousePaths) { const auto mouse_closed_status = lvh::detail::test::linux_xtest_mouse_submit_closed(); EXPECT_TRUE(mouse_closed_status.code() == lvh::ErrorCode::device_closed || mouse_closed_status.code() == lvh::ErrorCode::backend_unavailable); - #if defined(LIBVIRTUALHID_HAVE_XTEST) +#if defined(LIBVIRTUALHID_HAVE_XTEST) EXPECT_EQ(lvh::detail::test::linux_xtest_keysym(0x08), XK_BackSpace); EXPECT_EQ(lvh::detail::test::linux_xtest_keysym(0x09), XK_Tab); EXPECT_EQ(lvh::detail::test::linux_xtest_keysym(0x0D), XK_Return); @@ -887,10 +976,10 @@ TEST_F(LinuxBackendTest, XTestFallbackCoversKeyboardAndMousePaths) { EXPECT_EQ(lvh::detail::test::linux_xtest_mouse_button(lvh::MouseButton::side), 8); EXPECT_EQ(lvh::detail::test::linux_xtest_mouse_button(lvh::MouseButton::extra), 9); EXPECT_EQ(lvh::detail::test::linux_xtest_mouse_button(static_cast(255)), 1); - #else +#else EXPECT_EQ(lvh::detail::test::linux_xtest_keysym(0x41), 0UL); EXPECT_EQ(lvh::detail::test::linux_xtest_mouse_button(lvh::MouseButton::left), 1); - #endif +#endif } TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) { @@ -916,44 +1005,3 @@ TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNode EXPECT_EQ(mouse.status.code(), lvh::ErrorCode::backend_unavailable); } } -#else -TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) {} - -TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {} - -TEST_F(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) {} - -TEST_F(LinuxBackendTest, DecodesTextHelpers) {} - -TEST_F(LinuxBackendTest, CoversLinuxDiscoveryAndIdentityHelpers) {} - -TEST_F(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) {} - -TEST_F(LinuxBackendTest, HandlesUinputKeyboardInvalidFileDescriptorPaths) {} - -TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) {} - -TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) {} - -TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) {} - -TEST_F(LinuxBackendTest, PipeBackedUinputTouchDevicesEmitEvents) {} - -TEST_F(LinuxBackendTest, PipeBackedUinputTouchDevicesCoverStateTransitions) {} - -TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {} - -TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {} - -TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) {} - -TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) {} - -TEST_F(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) {} - -TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranches) {} - -TEST_F(LinuxBackendTest, XTestFallbackCoversKeyboardAndMousePaths) {} - -TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) {} -#endif diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 7d684a4..87e7e59 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -5,6 +5,7 @@ // standard includes #include +#include #include #include #include @@ -26,19 +27,15 @@ #include // platform includes -#if defined(__linux__) - #include - #include - #include - #include -#endif +#include +#include +#include +#include // lib includes -#if defined(__linux__) - #include - #include -#endif +#include #include +#include // local includes #include "fixtures/fixtures.hpp" @@ -50,7 +47,6 @@ class LinuxConsumerTest: public LinuxTest {}; namespace { -#if defined(__linux__) using LibinputContext = std::unique_ptr; using LibinputEvent = std::unique_ptr; using SdlGameController = std::unique_ptr; @@ -323,6 +319,22 @@ namespace { return false; } + bool wait_for_sdl_controller_button( + SDL_GameController *controller, + SDL_GameControllerButton expected_button + ) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while (std::chrono::steady_clock::now() < deadline) { + SDL_GameControllerUpdate(); + pump_sdl_events(); + if (SDL_GameControllerGetButton(controller, expected_button) != 0) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + } + return false; + } + void configure_sdl_hidapi_hints() { SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); @@ -457,6 +469,72 @@ namespace { ); } + void exercise_sdl_xbox_series_controller( + const SdlGamepadConsumerCase &test_case, + const lvh::DeviceProfile &expected_profile, + int joystick_index, + lvh::Gamepad &gamepad + ) { + using enum lvh::GamepadButton; + + ASSERT_EQ(SDL_IsGameController(joystick_index), SDL_TRUE) << SDL_GetError(); + SdlGameController controller {SDL_GameControllerOpen(joystick_index), &SDL_GameControllerClose}; + ASSERT_NE(controller.get(), nullptr) << SDL_GetError(); + + auto *joystick = SDL_GameControllerGetJoystick(controller.get()); + ASSERT_NE(joystick, nullptr) << SDL_GetError(); + expect_sdl_joystick_profile(joystick, expected_profile, test_case.minimum_buttons, test_case.minimum_axes); + + struct ButtonCase { + lvh::GamepadButton logical_button; + SDL_GameControllerButton sdl_button; + }; + + constexpr std::array button_cases { + ButtonCase {a, SDL_CONTROLLER_BUTTON_A}, + ButtonCase {b, SDL_CONTROLLER_BUTTON_B}, + ButtonCase {x, SDL_CONTROLLER_BUTTON_X}, + ButtonCase {y, SDL_CONTROLLER_BUTTON_Y}, + ButtonCase {left_shoulder, SDL_CONTROLLER_BUTTON_LEFTSHOULDER}, + ButtonCase {right_shoulder, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER}, + ButtonCase {back, SDL_CONTROLLER_BUTTON_BACK}, + ButtonCase {start, SDL_CONTROLLER_BUTTON_START}, + ButtonCase {left_stick, SDL_CONTROLLER_BUTTON_LEFTSTICK}, + ButtonCase {right_stick, SDL_CONTROLLER_BUTTON_RIGHTSTICK}, + }; + + for (const auto &[logical_button, sdl_button] : button_cases) { + lvh::GamepadState state; + state.buttons.set(logical_button); + ASSERT_TRUE(gamepad.submit(state).ok()); + ASSERT_TRUE(wait_for_sdl_controller_button(controller.get(), sdl_button)) + << "logical button " << static_cast(std::to_underlying(logical_button)) << " " + << describe_sdl_controller_state(controller.get()); + for (const auto &[other_logical_button, other_sdl_button] : button_cases) { + if (other_logical_button != logical_button) { + EXPECT_EQ(SDL_GameControllerGetButton(controller.get(), other_sdl_button), 0) + << "logical button " << static_cast(std::to_underlying(logical_button)); + } + } + } + + lvh::GamepadState trigger_state; + trigger_state.left_trigger = 0.25F; + trigger_state.right_trigger = 0.75F; + ASSERT_TRUE(gamepad.submit(trigger_state).ok()); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while ( + std::chrono::steady_clock::now() < deadline && + SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT) < 16000 + ) { + SDL_GameControllerUpdate(); + pump_sdl_events(); + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + } + EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERLEFT), 0); + EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT), 16000); + } + void destroy_libinput_event(libinput_event *event) { if (event != nullptr) { libinput_event_destroy(event); @@ -472,7 +550,7 @@ namespace { int open_restricted(const char *path, int flags, void *user_data) { // NOSONAR(cpp:S5008): libinput_interface is a C callback ABI with void* user data. static_cast(user_data); - const auto fd = ::open(path, flags); + const auto fd = ::openat(AT_FDCWD, path, flags); return fd < 0 ? -errno : fd; } @@ -526,11 +604,8 @@ namespace { return LibinputEvent {nullptr, destroy_libinput_event}; } -#endif // defined(__linux__) - } // namespace -#if defined(__linux__) TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) { ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); @@ -541,6 +616,25 @@ TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) { }); } +TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + const SdlGamepadConsumerCase test_case { + .profile = lvh::profiles::xbox_series(), + .name_suffix = "SDL Xbox Series", + .stable_id = "libvirtualhid-sdl-xbox-series-test", + .minimum_buttons = 11, + .minimum_axes = 6, + }; + run_sdl_gamepad_test( + test_case, + SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS, + [&test_case](const auto &expected_profile, int joystick_index, lvh::Gamepad &gamepad) { + exercise_sdl_xbox_series_controller(test_case, expected_profile, joystick_index, gamepad); + } + ); +} + TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) { ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); @@ -816,24 +910,3 @@ TEST_F(LinuxConsumerTest, LibinputSeesUinputPenTabletTool) { ASSERT_NE(event.get(), nullptr); ASSERT_NE(libinput_event_get_tablet_tool_event(event.get()), nullptr); } -#else -TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) {} - -TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) {} - -TEST_F(LinuxConsumerTest, SdlSeesDualShock4UsbControllerBehavior) {} - -TEST_F(LinuxConsumerTest, SdlSeesDualShock4BluetoothControllerDiscovery) {} - -TEST_F(LinuxConsumerTest, SdlSeesDualSenseBluetoothControllerDiscovery) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputKeyboardKeys) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputTouchscreenContacts) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputTrackpadButton) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputPenTabletTool) {} -#endif diff --git a/tests/unit/test_macos_backend.cpp b/tests/unit/test_macos_backend.cpp index 866aa0a..452956b 100644 --- a/tests/unit/test_macos_backend.cpp +++ b/tests/unit/test_macos_backend.cpp @@ -8,20 +8,16 @@ // local includes #include "fixtures/fixtures.hpp" - -#if defined(__APPLE__) && defined(__MACH__) - #include "fixtures/macos_backend_test_hooks.hpp" +#include "fixtures/macos_backend_test_hooks.hpp" // platform includes - #include -#endif +#include /** * @brief Test fixture for macOS backend internals. */ class MacosBackendTest: public MacOSTest {}; -#if defined(__APPLE__) && defined(__MACH__) TEST_F(MacosBackendTest, TranslatesKeyboardKeys) { EXPECT_EQ(lvh::detail::test::macos_backend_key_code(0x08), kVK_Delete); EXPECT_EQ(lvh::detail::test::macos_backend_key_code(0x09), kVK_Tab); @@ -138,4 +134,3 @@ TEST_F(MacosBackendTest, ReportsCapabilitiesAndUnsupportedDevices) { EXPECT_EQ(result.trackpad_status.code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(result.pen_tablet_status.code(), lvh::ErrorCode::unsupported_profile); } -#endif diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 8baa564..8b620da 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -49,17 +49,37 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_series.report_id, 0); EXPECT_EQ(xbox_series.input_report_size, 17U); - const std::array xbox_gip_button_descriptor { + const std::array xbox_gip_button_descriptor { 0x05, 0x09, - 0x19, + 0x09, + 0x01, + 0x09, + 0x02, + 0x09, + 0x04, + 0x09, + 0x05, + 0x09, + 0x07, + 0x09, + 0x08, + 0x09, + 0x0B, + 0x09, + 0x0C, + 0x09, + 0x0E, + 0x09, + 0x0F, + 0x15, + 0x00, + 0x25, 0x01, - 0x29, - 0x0A, - 0x95, - 0x0A, 0x75, 0x01, + 0x95, + 0x0A, 0x81, 0x02, }; @@ -182,19 +202,43 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_TRUE(switch_pro.capabilities.supports_battery); const auto generic = lvh::profiles::generic_gamepad(); - const std::array standard_button_descriptor { + const std::array standard_button_descriptor { 0x05, 0x09, - 0x19, + 0x09, 0x01, - 0x29, - 0x10, + 0x09, + 0x02, + 0x09, + 0x04, + 0x09, + 0x05, + 0x09, + 0x07, + 0x09, + 0x08, + 0x09, + 0x0B, + 0x09, + 0x0C, + 0x09, + 0x0E, + 0x09, + 0x0F, + 0x09, + 0x0D, + 0x09, + 0x06, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, + 0x0C, + 0x81, + 0x02, }; EXPECT_TRUE( std::ranges::search(generic.report_descriptor, standard_button_descriptor).begin() != generic.report_descriptor.end() @@ -225,11 +269,29 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { switch_pro.report_descriptor.end() ); - const std::array switch_pro_button_descriptor { - 0x19, + const std::array switch_pro_button_descriptor { + 0x05, + 0x09, + 0x09, + 0x02, + 0x09, 0x01, - 0x29, + 0x09, + 0x04, + 0x09, + 0x05, + 0x09, + 0x07, + 0x09, + 0x08, + 0x09, + 0x09, + 0x09, 0x0A, + 0x09, + 0x0B, + 0x09, + 0x0C, 0x15, 0x00, 0x25, @@ -238,11 +300,44 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { 0x01, 0x95, 0x0A, + 0x55, + 0x00, + 0x65, + 0x00, + 0x81, + 0x02, }; EXPECT_TRUE( std::ranges::search(switch_pro.report_descriptor, switch_pro_button_descriptor).begin() != switch_pro.report_descriptor.end() ); + + const std::array switch_pro_system_button_descriptor { + 0x05, + 0x09, + 0x09, + 0x0E, + 0x09, + 0x0F, + 0x09, + 0x0D, + 0x09, + 0x06, + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x01, + 0x95, + 0x04, + 0x81, + 0x02, + }; + EXPECT_TRUE( + std::ranges::search(switch_pro.report_descriptor, switch_pro_system_button_descriptor).begin() != + switch_pro.report_descriptor.end() + ); } TEST(ProfileTest, RumbleProfilesExposeOutputReports) { diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index f4f7a06..a93d749 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -124,7 +124,7 @@ TEST(ReportTest, PacksStandardGamepadReport) { ASSERT_EQ(report.size(), profile.input_report_size); EXPECT_EQ(report[0], profile.report_id); EXPECT_EQ(report[1], 0x81); // A and Start. - EXPECT_EQ(report[2], 0x4C); // Guide, Misc/share, and D-pad-left button. + EXPECT_EQ(report[2], 0x6C); // Guide, Misc/share, and D-pad-left hat value. EXPECT_EQ(report[3], 255); // Left stick X. EXPECT_EQ(report[4], 255); // Left stick Y. EXPECT_EQ(report[5], 191); // Right stick X. From 1c3a235a9ff9b70b6487b699da907f16f944949b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:28:48 -0400 Subject: [PATCH 02/12] fix(linux): Xbox Series uinput button layout and version Match the active evdev capabilities exposed by the USB xpad driver instead of the full 15-slot HID layout. Remove unused BTN_C, BTN_Z, BTN_TL2, and BTN_TR2 capabilities that shifted Guide, L3, and R3 indices. Set firmware version to 0x050D for a known SDL/Steam USB mapping. Add Guide button to consumer test coverage. --- docs/platform-support.md | 9 +++++---- src/platform/linux/uhid_backend.cpp | 15 +++++++-------- tests/unit/test_linux_backend.cpp | 21 +++++++++++++++++++-- tests/unit/test_linux_consumers.cpp | 1 + 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 2046287..ed777be 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -60,10 +60,11 @@ descriptor report as Xbox GIP traffic. Face buttons, shoulders, menu buttons, stick clicks, the guide button, and Share are exposed through their canonical evdev codes; sticks and the directional pad use absolute axes; triggers remain independent analog `ABS_Z` and `ABS_RZ` axes. Force-feedback effects are -normalized back into the public rumble callback. The backend advertises the -full 15-slot Xbox Series button range expected by Steam for this controller -identity. Its unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` slots are never -pressed; they keep the active buttons after them at the correct Linux indices. +normalized back into the public rumble callback. The backend advertises the same +compact set of active button capabilities as Linux's USB `xpad` driver, and the +Linux uinput identity uses a firmware version with a known SDL/Steam USB mapping. +This keeps Guide, L3, and R3 on the mapped USB button indices without adding +unused digital-button capabilities that would shift those indices. Other gamepad profiles remain descriptor-driven through `uhid`. The Switch Pro profile keeps its Nintendo identity but uses the virtual UHID bus on Linux, diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 8741341..6d88d0e 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -79,6 +79,7 @@ namespace lvh::detail { constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; constexpr auto xbox_trigger_max = 255; + constexpr auto xbox_series_uinput_version = 0x050D; constexpr auto dualshock4_usb_calibration_report = 0x02; constexpr auto dualshock4_bluetooth_calibration_report = 0x05; constexpr auto dualshock4_pairing_report = 0x12; @@ -1294,20 +1295,15 @@ namespace lvh::detail { return status; } - // Steam maps the Xbox Series VID/PID using the controller's 15-slot HID button layout. - // Keep the unused C, Z, and digital-trigger slots so Linux button indices do not collapse. - // The submit path never presses these reserved slots; triggers remain analog axes. + // Match the active evdev capabilities exposed by the USB xpad driver. The Xbox Series + // profile uses a firmware version with a known SDL/Steam mapping for this compact layout. for (const auto button : { BTN_SOUTH, BTN_EAST, - BTN_C, BTN_NORTH, BTN_WEST, - BTN_Z, BTN_TL, BTN_TR, - BTN_TL2, - BTN_TR2, BTN_SELECT, BTN_START, BTN_MODE, @@ -1385,7 +1381,10 @@ namespace lvh::detail { libevdev_set_id_bustype(device.get(), to_uinput_bus(profile.bus_type)); libevdev_set_id_vendor(device.get(), profile.vendor_id); libevdev_set_id_product(device.get(), profile.product_id); - libevdev_set_id_version(device.get(), profile.version); + libevdev_set_id_version( + device.get(), + profile.gamepad_kind == GamepadProfileKind::xbox_series ? xbox_series_uinput_version : profile.version + ); if (const auto status = configure_evdev_device(device.get(), profile); !status.ok()) { return {status, nullptr}; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index b71f539..1056a48 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -774,11 +774,28 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_EQ(gamepad.name, lvh::profiles::xbox_series().name); EXPECT_EQ(gamepad.vendor, lvh::profiles::xbox_series().vendor_id); EXPECT_EQ(gamepad.product, lvh::profiles::xbox_series().product_id); + EXPECT_EQ(gamepad.version, 0x050D); EXPECT_TRUE(has_type(gamepad, EV_KEY)); EXPECT_TRUE(has_type(gamepad, EV_ABS)); EXPECT_TRUE(has_type(gamepad, EV_FF)); - for (auto button = BTN_SOUTH; button <= BTN_THUMBR; ++button) { - EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing Xbox HID button slot " << button; + for (const auto button : { + BTN_SOUTH, + BTN_EAST, + BTN_NORTH, + BTN_WEST, + BTN_TL, + BTN_TR, + BTN_SELECT, + BTN_START, + BTN_MODE, + BTN_THUMBL, + BTN_THUMBR, + }) { + EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing Xbox USB button " << button; + } + for (const auto reserved_button : {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}) { + EXPECT_EQ(find_code(gamepad, EV_KEY, reserved_button), nullptr) + << "unexpected Xbox USB button capability " << reserved_button; } EXPECT_NE(find_code(gamepad, EV_KEY, KEY_RECORD), nullptr); const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 87e7e59..38a02df 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -499,6 +499,7 @@ namespace { ButtonCase {right_shoulder, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER}, ButtonCase {back, SDL_CONTROLLER_BUTTON_BACK}, ButtonCase {start, SDL_CONTROLLER_BUTTON_START}, + ButtonCase {guide, SDL_CONTROLLER_BUTTON_GUIDE}, ButtonCase {left_stick, SDL_CONTROLLER_BUTTON_LEFTSTICK}, ButtonCase {right_stick, SDL_CONTROLLER_BUTTON_RIGHTSTICK}, }; From 2b8a5fbfed6e718c97738a2036c8d0e6776255a5 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:38:27 -0400 Subject: [PATCH 03/12] Align Linux Xbox Series uinput identity Update the Linux uinput backend to advertise Xbox Series controllers as Bluetooth with product ID 0x0B13, while keeping the public profile identity unchanged. The evdev key capabilities now include the full 15-slot HID button range (including reserved C/Z/TL2/TR2 slots) so SDL/Steam button indices stay stable and Guide/L3/R3 map correctly. Unit and consumer tests were updated accordingly, and platform support docs now describe the identity split and reserved-slot behavior. --- docs/platform-support.md | 12 +++++++----- src/platform/linux/uhid_backend.cpp | 23 +++++++++++++++-------- tests/unit/test_linux_backend.cpp | 25 +++++-------------------- tests/unit/test_linux_consumers.cpp | 7 ++++++- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index ed777be..b472fd6 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -60,11 +60,13 @@ descriptor report as Xbox GIP traffic. Face buttons, shoulders, menu buttons, stick clicks, the guide button, and Share are exposed through their canonical evdev codes; sticks and the directional pad use absolute axes; triggers remain independent analog `ABS_Z` and `ABS_RZ` axes. Force-feedback effects are -normalized back into the public rumble callback. The backend advertises the same -compact set of active button capabilities as Linux's USB `xpad` driver, and the -Linux uinput identity uses a firmware version with a known SDL/Steam USB mapping. -This keeps Guide, L3, and R3 on the mapped USB button indices without adding -unused digital-button capabilities that would shift those indices. +normalized back into the public rumble callback. The Linux uinput device uses +the Xbox Series Bluetooth product identity (`0x0B13`), whose native 15-slot +button layout matches these evdev capabilities. The public profile retains the +physical USB identity used by other backends. Unused `BTN_C`, `BTN_Z`, +`BTN_TL2`, and `BTN_TR2` slots are never pressed; they keep every active button, +including Guide, L3, and R3, at the indices expected for the Linux uinput +identity. Other gamepad profiles remain descriptor-driven through `uhid`. The Switch Pro profile keeps its Nintendo identity but uses the virtual UHID bus on Linux, diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 6d88d0e..a93e703 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -79,7 +79,8 @@ namespace lvh::detail { constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; constexpr auto xbox_trigger_max = 255; - constexpr auto xbox_series_uinput_version = 0x050D; + constexpr auto xbox_series_uinput_bus = BUS_BLUETOOTH; + constexpr std::uint16_t xbox_series_uinput_product_id = 0x0B13; constexpr auto dualshock4_usb_calibration_report = 0x02; constexpr auto dualshock4_bluetooth_calibration_report = 0x05; constexpr auto dualshock4_pairing_report = 0x12; @@ -1295,15 +1296,20 @@ namespace lvh::detail { return status; } - // Match the active evdev capabilities exposed by the USB xpad driver. The Xbox Series - // profile uses a firmware version with a known SDL/Steam mapping for this compact layout. + // Steam maps the Xbox Series VID/PID using the controller's 15-slot HID button layout. + // Keep the unused C, Z, and digital-trigger slots so Linux button indices do not collapse. + // The submit path never presses these reserved slots; triggers remain analog axes. for (const auto button : { BTN_SOUTH, BTN_EAST, + BTN_C, BTN_NORTH, BTN_WEST, + BTN_Z, BTN_TL, BTN_TR, + BTN_TL2, + BTN_TR2, BTN_SELECT, BTN_START, BTN_MODE, @@ -1378,13 +1384,14 @@ namespace lvh::detail { } libevdev_set_name(device.get(), profile.name.c_str()); - libevdev_set_id_bustype(device.get(), to_uinput_bus(profile.bus_type)); - libevdev_set_id_vendor(device.get(), profile.vendor_id); - libevdev_set_id_product(device.get(), profile.product_id); - libevdev_set_id_version( + const auto xbox_series = profile.gamepad_kind == GamepadProfileKind::xbox_series; + libevdev_set_id_bustype( device.get(), - profile.gamepad_kind == GamepadProfileKind::xbox_series ? xbox_series_uinput_version : profile.version + xbox_series ? xbox_series_uinput_bus : to_uinput_bus(profile.bus_type) ); + libevdev_set_id_vendor(device.get(), profile.vendor_id); + libevdev_set_id_product(device.get(), xbox_series ? xbox_series_uinput_product_id : profile.product_id); + libevdev_set_id_version(device.get(), profile.version); if (const auto status = configure_evdev_device(device.get(), profile); !status.ok()) { return {status, nullptr}; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 1056a48..34e33c9 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -773,29 +773,14 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc ASSERT_TRUE(gamepad.status.ok()) << gamepad.status.message(); EXPECT_EQ(gamepad.name, lvh::profiles::xbox_series().name); EXPECT_EQ(gamepad.vendor, lvh::profiles::xbox_series().vendor_id); - EXPECT_EQ(gamepad.product, lvh::profiles::xbox_series().product_id); - EXPECT_EQ(gamepad.version, 0x050D); + EXPECT_EQ(gamepad.bustype, BUS_BLUETOOTH); + EXPECT_EQ(gamepad.product, 0x0B13); + EXPECT_EQ(gamepad.version, lvh::profiles::xbox_series().version); EXPECT_TRUE(has_type(gamepad, EV_KEY)); EXPECT_TRUE(has_type(gamepad, EV_ABS)); EXPECT_TRUE(has_type(gamepad, EV_FF)); - for (const auto button : { - BTN_SOUTH, - BTN_EAST, - BTN_NORTH, - BTN_WEST, - BTN_TL, - BTN_TR, - BTN_SELECT, - BTN_START, - BTN_MODE, - BTN_THUMBL, - BTN_THUMBR, - }) { - EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing Xbox USB button " << button; - } - for (const auto reserved_button : {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}) { - EXPECT_EQ(find_code(gamepad, EV_KEY, reserved_button), nullptr) - << "unexpected Xbox USB button capability " << reserved_button; + for (auto button = BTN_SOUTH; button <= BTN_THUMBR; ++button) { + EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing Xbox HID button slot " << button; } EXPECT_NE(find_code(gamepad, EV_KEY, KEY_RECORD), nullptr); const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 38a02df..31bce3b 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -59,6 +59,7 @@ namespace { lvh::DeviceProfile profile; std::string_view name_suffix; std::string_view stable_id; + std::optional expected_product_id; int minimum_buttons = 1; int minimum_axes = 2; bool expect_live_input = true; @@ -367,6 +368,9 @@ namespace { const auto expected_profile = [&test_case]() { auto profile = test_case.profile; profile.name = unique_device_name(test_case.name_suffix); + if (test_case.expected_product_id) { + profile.product_id = *test_case.expected_product_id; + } return profile; }(); @@ -624,7 +628,8 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) { .profile = lvh::profiles::xbox_series(), .name_suffix = "SDL Xbox Series", .stable_id = "libvirtualhid-sdl-xbox-series-test", - .minimum_buttons = 11, + .expected_product_id = 0x0B13, + .minimum_buttons = 16, .minimum_axes = 6, }; run_sdl_gamepad_test( From 4c55b6b37705bb55d23d51d3bfb0574b25cf610b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:49:19 -0400 Subject: [PATCH 04/12] Extend uinput gamepad to generic/360/One profiles Generalizes the Linux uinput gamepad backend from Xbox Series-only to support Generic, Xbox 360, Xbox One, and Xbox Series profiles. Extracts `uses_uinput_gamepad_profile()` predicate, renames `UinputXboxGamepad` to `UinputGamepad`, makes rumble and misc1 (Share) button conditional on profile capabilities, and updates tests and docs accordingly. --- docs/platform-support.md | 44 ++--- src/platform/linux/uhid_backend.cpp | 128 ++++++++----- .../fixtures/linux_backend_test_hooks.hpp | 13 +- tests/fixtures/linux_backend_test_hooks.cpp | 44 +++-- tests/unit/test_linux_backend.cpp | 170 ++++++++++++------ tests/unit/test_linux_consumers.cpp | 95 +++++----- 6 files changed, 305 insertions(+), 189 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index b472fd6..ca0a9d3 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -46,32 +46,34 @@ and signing details. The Linux backend uses standard user-space kernel interfaces: - `uhid` for descriptor-driven HID gamepads. -- `uinput` for Xbox Series gamepads, keyboard, mouse, touchscreen, trackpad, - and pen tablet devices. +- `uinput` for Generic, Xbox 360, Xbox One, and Xbox Series gamepads, plus + keyboard, mouse, touchscreen, trackpad, and pen tablet devices. - `libevdev` internally for uinput device construction. - X11/XTest only as a keyboard and mouse fallback when `uinput` cannot be used and an X11 session is available. Gamepad support normally prefers `uhid` because descriptors, raw HID identity, -feature reports, and output reports matter for controller compatibility. Xbox -Series is the exception: on Linux it uses `uinput` so SDL, Steam, and other -evdev consumers receive the native Xbox button codes without interpreting the -descriptor report as Xbox GIP traffic. Face buttons, shoulders, menu buttons, -stick clicks, the guide button, and Share are exposed through their canonical -evdev codes; sticks and the directional pad use absolute axes; triggers remain -independent analog `ABS_Z` and `ABS_RZ` axes. Force-feedback effects are -normalized back into the public rumble callback. The Linux uinput device uses -the Xbox Series Bluetooth product identity (`0x0B13`), whose native 15-slot -button layout matches these evdev capabilities. The public profile retains the -physical USB identity used by other backends. Unused `BTN_C`, `BTN_Z`, -`BTN_TL2`, and `BTN_TR2` slots are never pressed; they keep every active button, -including Guide, L3, and R3, at the indices expected for the Linux uinput -identity. - -Other gamepad profiles remain descriptor-driven through `uhid`. The Switch Pro -profile keeps its Nintendo identity but uses the virtual UHID bus on Linux, -preventing `hid-nintendo` from claiming the descriptor-only device and waiting -for physical-controller initialization handshakes. +feature reports, and output reports matter for controller compatibility. +Generic and Xbox-family profiles instead use `uinput` so SDL, Steam, and other +evdev consumers receive canonical Linux gamepad events without interpreting a +standard or Xbox GIP descriptor. Face buttons, shoulders, menu buttons, stick +clicks, and Guide use their native evdev codes; sticks and the directional pad +use absolute axes; triggers remain independent analog `ABS_Z` and `ABS_RZ` +axes. Profiles with rumble support normalize force-feedback effects back into +the public callback. + +Xbox 360 and Xbox One retain their public USB identities. Xbox Series uses the +Bluetooth product identity (`0x0B13`) only for its Linux uinput device; its +public profile retains the physical USB identity used by other backends. All +four uinput gamepad profiles preserve the sparse 15-slot Linux gamepad button +sequence expected by Steam. Unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` +slots are advertised but never pressed, keeping face buttons, shoulders, menu +buttons, Guide, L3, and R3 at their expected indices. + +Descriptor-driven profiles remain on `uhid`. The Switch Pro profile keeps its +Nintendo identity but uses the virtual UHID bus on Linux, preventing +`hid-nintendo` from claiming the descriptor-only device and waiting for +physical-controller initialization handshakes. The optional `virtualhid_control` diagnostic UI uses SDL3 and Dear ImGui through the repository CPM lockfile. It is intended to stay on the same UI framework for diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index a93e703..d4a16c2 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -495,6 +495,28 @@ namespace lvh::detail { return kind == GamepadProfileKind::dualshock4 || kind == GamepadProfileKind::dualsense; } + bool uses_uinput_gamepad_profile(GamepadProfileKind kind) { + switch (kind) { + using enum GamepadProfileKind; + + case generic: + case xbox_360: + case xbox_one: + case xbox_series: + return true; + case dualshock4: + case dualsense: + case switch_pro: + return false; + } + + return false; + } + + bool has_uinput_misc1_button(GamepadProfileKind kind) { + return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_series; + } + std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -1285,42 +1307,49 @@ namespace lvh::detail { return enable_evdev_property(device, INPUT_PROP_DIRECT, "tablet direct"); } - OperationStatus configure_evdev_xbox_series_gamepad(libevdev *device) { + OperationStatus configure_evdev_gamepad(libevdev *device, const DeviceProfile &profile) { if (const auto status = enable_evdev_type(device, EV_KEY, "Xbox gamepad button events"); !status.ok()) { return status; } if (const auto status = enable_evdev_type(device, EV_ABS, "Xbox gamepad absolute events"); !status.ok()) { return status; } - if (const auto status = enable_evdev_type(device, EV_FF, "Xbox gamepad force-feedback events"); !status.ok()) { - return status; + if (profile.capabilities.supports_rumble) { + if (const auto status = enable_evdev_type(device, EV_FF, "Xbox gamepad force-feedback events"); !status.ok()) { + return status; + } } - // Steam maps the Xbox Series VID/PID using the controller's 15-slot HID button layout. - // Keep the unused C, Z, and digital-trigger slots so Linux button indices do not collapse. - // The submit path never presses these reserved slots; triggers remain analog axes. - for (const auto button : { - BTN_SOUTH, - BTN_EAST, - BTN_C, - BTN_NORTH, - BTN_WEST, - BTN_Z, - BTN_TL, - BTN_TR, - BTN_TL2, - BTN_TR2, - BTN_SELECT, - BTN_START, - BTN_MODE, - BTN_THUMBL, - BTN_THUMBR, - KEY_RECORD, - }) { + // Steam assigns the standard logical buttons using the sparse Linux gamepad + // button sequence. Advertise the unused slots as capabilities so the active + // buttons retain their canonical indices instead of being compacted. + constexpr std::array button_slots { + BTN_SOUTH, + BTN_EAST, + BTN_C, + BTN_NORTH, + BTN_WEST, + BTN_Z, + BTN_TL, + BTN_TR, + BTN_TL2, + BTN_TR2, + BTN_SELECT, + BTN_START, + BTN_MODE, + BTN_THUMBL, + BTN_THUMBR, + }; + for (const auto button : button_slots) { if (const auto status = enable_evdev_code(device, EV_KEY, button, "Xbox gamepad button"); !status.ok()) { return status; } } + if (has_uinput_misc1_button(profile.gamepad_kind)) { + if (const auto status = enable_evdev_code(device, EV_KEY, KEY_RECORD, "gamepad share button"); !status.ok()) { + return status; + } + } auto dpad = make_absinfo(-1, 1); for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { @@ -1343,10 +1372,13 @@ namespace lvh::detail { } } - if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "Xbox gamepad force-feedback effect"); !status.ok()) { - return status; + if (profile.capabilities.supports_rumble) { + if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "Xbox gamepad force-feedback effect"); !status.ok()) { + return status; + } + return enable_evdev_code(device, EV_FF, FF_GAIN, "Xbox gamepad force-feedback gain"); } - return enable_evdev_code(device, EV_FF, FF_GAIN, "Xbox gamepad force-feedback gain"); + return OperationStatus::success(); } OperationStatus configure_evdev_device(libevdev *device, const DeviceProfile &profile) { @@ -1364,8 +1396,8 @@ namespace lvh::detail { case pen_tablet: return configure_evdev_pen_tablet(device); case gamepad: - if (profile.gamepad_kind == GamepadProfileKind::xbox_series) { - return configure_evdev_xbox_series_gamepad(device); + if (uses_uinput_gamepad_profile(profile.gamepad_kind)) { + return configure_evdev_gamepad(device, profile); } return OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile is not supported through uinput"); } @@ -2326,18 +2358,19 @@ namespace lvh::detail { }; /** - * @brief Xbox Series gamepad exposed through canonical Linux input events. + * @brief Standard gamepad exposed through canonical Linux input events. */ - class UinputXboxGamepad final: public BackendGamepad, private UinputDevice { + class UinputGamepad final: public BackendGamepad, private UinputDevice { public: - explicit UinputXboxGamepad(int file_descriptor): - UinputDevice {file_descriptor} {} + UinputGamepad(int file_descriptor, GamepadProfileKind profile_kind): + UinputDevice {file_descriptor}, + profile_kind_ {profile_kind} {} - ~UinputXboxGamepad() override = default; + ~UinputGamepad() override = default; OperationStatus create(DeviceId id, const CreateGamepadOptions &options) { - if (options.profile.gamepad_kind != GamepadProfileKind::xbox_series) { - return OperationStatus::failure(ErrorCode::unsupported_profile, "uinput Xbox backend requires the Xbox Series profile"); + if (options.profile.gamepad_kind != profile_kind_ || !uses_uinput_gamepad_profile(profile_kind_)) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile is not supported through uinput"); } device_name_ = options.profile.name; @@ -2345,10 +2378,12 @@ namespace lvh::detail { return status; } - running_ = true; - reader_ = std::jthread {[this](std::stop_token stop_token) { - read_output_loop(stop_token); - }}; + if (options.profile.capabilities.supports_rumble) { + running_ = true; + reader_ = std::jthread {[this](std::stop_token stop_token) { + read_output_loop(stop_token); + }}; + } return OperationStatus::success(); } @@ -2377,13 +2412,17 @@ namespace lvh::detail { std::pair {guide, BTN_MODE}, std::pair {left_stick, BTN_THUMBL}, std::pair {right_stick, BTN_THUMBR}, - std::pair {misc1, KEY_RECORD}, }; for (const auto &[button, code] : button_map) { if (const auto status = emit_event(EV_KEY, code, normalized.buttons.test(button) ? 1 : 0); !status.ok()) { return status; } } + if (has_uinput_misc1_button(profile_kind_)) { + if (const auto status = emit_event(EV_KEY, KEY_RECORD, normalized.buttons.test(misc1) ? 1 : 0); !status.ok()) { + return status; + } + } if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(normalized.buttons, dpad_left, dpad_right)); !status.ok()) { return status; @@ -2424,7 +2463,7 @@ namespace lvh::detail { reader_.join(); } dispatch_rumble(0, 0); - return close_uinput("uinput Xbox gamepad"); + return close_uinput("uinput gamepad"); } private: @@ -2593,6 +2632,7 @@ namespace lvh::detail { } std::string device_name_; + GamepadProfileKind profile_kind_; std::atomic_bool running_ = false; std::mutex submit_mutex_; std::mutex callback_mutex_; @@ -2975,13 +3015,13 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { - if (options.profile.gamepad_kind == GamepadProfileKind::xbox_series) { + if (uses_uinput_gamepad_profile(options.profile.gamepad_kind)) { const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; } - auto gamepad = std::make_unique(fd); + auto gamepad = std::make_unique(fd, options.profile.gamepad_kind); if (const auto status = gamepad->create(id, options); !status.ok()) { static_cast(gamepad->close()); return {status, nullptr}; diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 2c61ab3..8330a9a 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -601,12 +601,13 @@ namespace lvh::detail::test { LinuxInputSubmissionResult linux_uinput_keyboard_submit_pipe(const KeyboardEvent &event); /** - * @brief Submit normalized state through a pipe-backed Xbox Series uinput gamepad. + * @brief Submit normalized state through a pipe-backed uinput gamepad. * + * @param kind Gamepad profile kind. * @param state Gamepad state to submit. * @return Submission status and captured input events. */ - LinuxInputSubmissionResult linux_uinput_xbox_series_submit_pipe(const GamepadState &state); + LinuxInputSubmissionResult linux_uinput_gamepad_submit_pipe(GamepadProfileKind kind, const GamepadState &state); /** * @brief Exercise Xbox Series uinput force-feedback upload and playback. @@ -936,6 +937,14 @@ namespace lvh::detail::test { */ LinuxLibevdevCreationResult linux_uinput_create_fake_libevdev_device(DeviceType device_type); + /** + * @brief Create a uinput gamepad through the fake libevdev recorder. + * + * @param kind Gamepad profile kind to create. + * @return Recorded fake libevdev construction result. + */ + LinuxLibevdevCreationResult linux_uinput_create_fake_gamepad(GamepadProfileKind kind); + /** * @brief Try creating a uinput device while fake libevdev allocation fails. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index dd8f79e..2295905 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -747,7 +747,11 @@ namespace lvh::detail::test { return profiles::mouse(); } - OperationStatus create_uinput_device_by_type(int fd, DeviceType device_type) { + OperationStatus create_uinput_device_by_type( + int fd, + DeviceType device_type, + std::optional gamepad_kind = std::nullopt + ) { switch (device_type) { case DeviceType::keyboard: { @@ -787,8 +791,8 @@ namespace lvh::detail::test { case DeviceType::gamepad: { CreateGamepadOptions options; - options.profile = profile_for_uinput_device_type(device_type); - UinputXboxGamepad gamepad {fd}; + options.profile = gamepad_kind ? *profiles::gamepad_profile(*gamepad_kind) : profile_for_uinput_device_type(device_type); + UinputGamepad gamepad {fd, options.profile.gamepad_kind}; return gamepad.create(1, options); } } @@ -797,7 +801,11 @@ namespace lvh::detail::test { } template - LinuxLibevdevCreationResult create_fake_libevdev_device(DeviceType device_type, ConfigureFailure configure_failure) { + LinuxLibevdevCreationResult create_fake_libevdev_device( + DeviceType device_type, + ConfigureFailure configure_failure, + std::optional gamepad_kind = std::nullopt + ) { LinuxTestSyscalls syscalls; syscalls.override_libevdev = true; configure_failure(syscalls); @@ -810,7 +818,7 @@ namespace lvh::detail::test { return result; } - result.status = create_uinput_device_by_type(fd, device_type); + result.status = create_uinput_device_by_type(fd, device_type, gamepad_kind); if (!syscalls.libevdev_devices.empty()) { const auto &device = syscalls.libevdev_devices.back(); result.name = device.name; @@ -837,6 +845,10 @@ namespace lvh::detail::test { return create_fake_libevdev_device(device_type, keep_fake_libevdev_successful); } + LinuxLibevdevCreationResult create_fake_libevdev_gamepad(GamepadProfileKind kind) { + return create_fake_libevdev_device(DeviceType::gamepad, keep_fake_libevdev_successful, kind); + } + } // namespace std::string linux_copy_string_char_buffer(const std::string &source) { @@ -1005,15 +1017,21 @@ namespace lvh::detail::test { return {std::move(status), std::move(records)}; } - LinuxInputSubmissionResult linux_uinput_xbox_series_submit_pipe(const GamepadState &state) { + LinuxInputSubmissionResult linux_uinput_gamepad_submit_pipe(GamepadProfileKind kind, const GamepadState &state) { std::array descriptors {-1, -1}; if (::pipe(descriptors.data()) != 0) { return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; } - const auto profile = profiles::xbox_series(); - UinputXboxGamepad gamepad {descriptors[1]}; - auto status = gamepad.submit(state, reports::pack_input_report(profile, state)); + const auto profile = profiles::gamepad_profile(kind); + if (!profile) { + static_cast(::close(descriptors[0])); + static_cast(::close(descriptors[1])); + return {OperationStatus::failure(ErrorCode::unsupported_profile, "unknown gamepad profile"), {}}; + } + + UinputGamepad gamepad {descriptors[1], kind}; + auto status = gamepad.submit(state, reports::pack_input_report(*profile, state)); static_cast(gamepad.close()); auto records = read_input_events_until_eof(descriptors[0]); static_cast(::close(descriptors[0])); @@ -1054,7 +1072,7 @@ namespace lvh::detail::test { std::atomic_size_t callback_count = 0; std::mutex output_mutex; GamepadOutput last_output; - UinputXboxGamepad gamepad {fd}; + UinputGamepad gamepad {fd, GamepadProfileKind::xbox_series}; gamepad.set_output_callback([&callback_count, &last_output, &output_mutex](const GamepadOutput &output) { if (output.low_frequency_rumble != 0 || output.high_frequency_rumble != 0) { std::lock_guard lock {output_mutex}; @@ -1740,7 +1758,7 @@ namespace lvh::detail::test { LinuxUhidBackend backend; CreateGamepadOptions options; - options.profile = profiles::xbox_360(); + options.profile = profiles::dualshock4_usb(); return backend.create_gamepad(1, options).status; } @@ -2044,6 +2062,10 @@ namespace lvh::detail::test { return create_fake_libevdev_device(device_type); } + LinuxLibevdevCreationResult linux_uinput_create_fake_gamepad(GamepadProfileKind kind) { + return create_fake_libevdev_gamepad(kind); + } + OperationStatus linux_uinput_create_fake_libevdev_allocation_failure(DeviceType device_type) { return create_fake_libevdev_device(device_type, [](LinuxTestSyscalls &syscalls) { syscalls.libevdev_new_returns_null = true; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 34e33c9..cdde76c 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -306,8 +306,9 @@ TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) { EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_pipe().code(), lvh::ErrorCode::backend_failure); } -TEST_F(LinuxBackendTest, PipeBackedXboxSeriesUsesCanonicalLinuxGamepadEvents) { +TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { using enum lvh::GamepadButton; + using enum lvh::GamepadProfileKind; struct ButtonCase { lvh::GamepadButton button; @@ -326,52 +327,71 @@ TEST_F(LinuxBackendTest, PipeBackedXboxSeriesUsesCanonicalLinuxGamepadEvents) { ButtonCase {guide, BTN_MODE}, ButtonCase {left_stick, BTN_THUMBL}, ButtonCase {right_stick, BTN_THUMBR}, - ButtonCase {misc1, KEY_RECORD}, }; + constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series}; + + for (const auto kind : profile_kinds) { + for (const auto &[button, linux_code] : button_cases) { + lvh::GamepadState state; + state.buttons.set(button); + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + + std::vector pressed_codes; + for (const auto &event : result.events) { + if (event.type == EV_KEY && event.value == 1) { + pressed_codes.push_back(event.code); + } + } + ASSERT_EQ(pressed_codes.size(), 1U) + << "profile " << static_cast(std::to_underlying(kind)) << " logical button " + << static_cast(std::to_underlying(button)); + EXPECT_EQ(pressed_codes.front(), linux_code) + << "profile " << static_cast(std::to_underlying(kind)) << " logical button " + << static_cast(std::to_underlying(button)); + } + } - for (const auto &[button, linux_code] : button_cases) { + for (const auto kind : {generic, xbox_series}) { lvh::GamepadState state; - state.buttons.set(button); - const auto result = lvh::detail::test::linux_uinput_xbox_series_submit_pipe(state); + state.buttons.set(misc1); + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); ASSERT_TRUE(result.status.ok()) << result.status.message(); - - std::vector pressed_codes; - for (const auto &event : result.events) { - if (event.type == EV_KEY && event.value == 1) { - pressed_codes.push_back(event.code); - } - } - ASSERT_EQ(pressed_codes.size(), 1U) << "logical button " << static_cast(std::to_underlying(button)); - EXPECT_EQ(pressed_codes.front(), linux_code) << "logical button " << static_cast(std::to_underlying(button)); + const auto pressed = std::ranges::find_if(result.events, [](const auto &event) { + return event.type == EV_KEY && event.code == KEY_RECORD && event.value == 1; + }); + EXPECT_NE(pressed, result.events.end()); } - lvh::GamepadState state; - state.buttons.set(dpad_up); - state.buttons.set(dpad_right); - state.left_stick = {.x = -0.5F, .y = 0.25F}; - state.right_stick = {.x = 0.75F, .y = -1.0F}; - state.left_trigger = 0.25F; - state.right_trigger = 0.75F; - const auto result = lvh::detail::test::linux_uinput_xbox_series_submit_pipe(state); - ASSERT_TRUE(result.status.ok()) << result.status.message(); + for (const auto kind : profile_kinds) { + lvh::GamepadState state; + state.buttons.set(dpad_up); + state.buttons.set(dpad_right); + state.left_stick = {.x = -0.5F, .y = 0.25F}; + state.right_stick = {.x = 0.75F, .y = -1.0F}; + state.left_trigger = 0.25F; + state.right_trigger = 0.75F; + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); - const auto event_value = [&result](std::uint16_t code) -> std::optional { - const auto event = std::ranges::find_if(result.events, [code](const auto &candidate) { - return candidate.type == EV_ABS && candidate.code == code; - }); - if (event == result.events.end()) { - return std::nullopt; - } - return event->value; - }; - EXPECT_EQ(event_value(ABS_HAT0X), 1); - EXPECT_EQ(event_value(ABS_HAT0Y), -1); - EXPECT_EQ(event_value(ABS_X), lvh::reports::normalize_axis(-0.5F)); - EXPECT_EQ(event_value(ABS_Y), lvh::reports::normalize_axis(-0.25F)); - EXPECT_EQ(event_value(ABS_RX), lvh::reports::normalize_axis(0.75F)); - EXPECT_EQ(event_value(ABS_RY), lvh::reports::normalize_axis(1.0F)); - EXPECT_EQ(event_value(ABS_Z), lvh::reports::normalize_trigger(0.25F)); - EXPECT_EQ(event_value(ABS_RZ), lvh::reports::normalize_trigger(0.75F)); + const auto event_value = [&result](std::uint16_t code) -> std::optional { + const auto event = std::ranges::find_if(result.events, [code](const auto &candidate) { + return candidate.type == EV_ABS && candidate.code == code; + }); + if (event == result.events.end()) { + return std::nullopt; + } + return event->value; + }; + EXPECT_EQ(event_value(ABS_HAT0X), 1); + EXPECT_EQ(event_value(ABS_HAT0Y), -1); + EXPECT_EQ(event_value(ABS_X), lvh::reports::normalize_axis(-0.5F)); + EXPECT_EQ(event_value(ABS_Y), lvh::reports::normalize_axis(-0.25F)); + EXPECT_EQ(event_value(ABS_RX), lvh::reports::normalize_axis(0.75F)); + EXPECT_EQ(event_value(ABS_RY), lvh::reports::normalize_axis(1.0F)); + EXPECT_EQ(event_value(ABS_Z), lvh::reports::normalize_trigger(0.25F)); + EXPECT_EQ(event_value(ABS_RZ), lvh::reports::normalize_trigger(0.75F)); + } } TEST_F(LinuxBackendTest, XboxSeriesUinputNormalizesForceFeedback) { @@ -769,25 +789,59 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_NE(find_code(keyboard, EV_KEY, KEY_A), nullptr); EXPECT_EQ(keyboard.destroy_count, 1U); - const auto gamepad = lvh::detail::test::linux_uinput_create_fake_libevdev_device(lvh::DeviceType::gamepad); - ASSERT_TRUE(gamepad.status.ok()) << gamepad.status.message(); - EXPECT_EQ(gamepad.name, lvh::profiles::xbox_series().name); - EXPECT_EQ(gamepad.vendor, lvh::profiles::xbox_series().vendor_id); - EXPECT_EQ(gamepad.bustype, BUS_BLUETOOTH); - EXPECT_EQ(gamepad.product, 0x0B13); - EXPECT_EQ(gamepad.version, lvh::profiles::xbox_series().version); - EXPECT_TRUE(has_type(gamepad, EV_KEY)); - EXPECT_TRUE(has_type(gamepad, EV_ABS)); - EXPECT_TRUE(has_type(gamepad, EV_FF)); - for (auto button = BTN_SOUTH; button <= BTN_THUMBR; ++button) { - EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing Xbox HID button slot " << button; + struct GamepadCase { + lvh::GamepadProfileKind kind; + bool series_identity; + bool misc1; + }; + + constexpr std::array gamepad_cases { + GamepadCase {lvh::GamepadProfileKind::generic, false, true}, + GamepadCase {lvh::GamepadProfileKind::xbox_360, false, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_one, false, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_series, true, true}, + }; + constexpr std::array active_buttons { + BTN_SOUTH, + BTN_EAST, + BTN_NORTH, + BTN_WEST, + BTN_TL, + BTN_TR, + BTN_SELECT, + BTN_START, + BTN_MODE, + BTN_THUMBL, + BTN_THUMBR, + }; + constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; + + for (const auto &[kind, series_identity, misc1] : gamepad_cases) { + const auto expected_profile = lvh::profiles::gamepad_profile(kind); + ASSERT_TRUE(expected_profile.has_value()); + const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind); + ASSERT_TRUE(gamepad.status.ok()) << gamepad.status.message(); + EXPECT_EQ(gamepad.name, expected_profile->name); + EXPECT_EQ(gamepad.vendor, expected_profile->vendor_id); + EXPECT_EQ(gamepad.bustype, series_identity ? BUS_BLUETOOTH : BUS_USB); + EXPECT_EQ(gamepad.product, series_identity ? 0x0B13 : expected_profile->product_id); + EXPECT_EQ(gamepad.version, expected_profile->version); + EXPECT_TRUE(has_type(gamepad, EV_KEY)); + EXPECT_TRUE(has_type(gamepad, EV_ABS)); + EXPECT_EQ(has_type(gamepad, EV_FF), expected_profile->capabilities.supports_rumble); + for (const auto button : active_buttons) { + EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing canonical gamepad button " << button; + } + for (const auto button : reserved_buttons) { + EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing reserved gamepad button slot " << button; + } + EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, misc1); + const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); + ASSERT_NE(left_trigger, nullptr); + EXPECT_EQ(left_trigger->minimum, 0); + EXPECT_EQ(left_trigger->maximum, 255); + EXPECT_EQ(find_code(gamepad, EV_FF, FF_RUMBLE) != nullptr, expected_profile->capabilities.supports_rumble); } - EXPECT_NE(find_code(gamepad, EV_KEY, KEY_RECORD), nullptr); - const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); - ASSERT_NE(left_trigger, nullptr); - EXPECT_EQ(left_trigger->minimum, 0); - EXPECT_EQ(left_trigger->maximum, 255); - EXPECT_NE(find_code(gamepad, EV_FF, FF_RUMBLE), nullptr); const auto mouse = lvh::detail::test::linux_uinput_create_fake_libevdev_device(lvh::DeviceType::mouse); ASSERT_TRUE(mouse.status.ok()) << mouse.status.message(); diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 31bce3b..08adea7 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -50,7 +50,6 @@ namespace { using LibinputContext = std::unique_ptr; using LibinputEvent = std::unique_ptr; using SdlGameController = std::unique_ptr; - using SdlJoystick = std::unique_ptr; /** * @brief SDL-visible gamepad case. @@ -241,22 +240,6 @@ namespace { return false; } - bool wait_for_sdl_gamepad_input(SDL_Joystick *joystick) { - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; - - while (std::chrono::steady_clock::now() < deadline) { - pump_sdl_events(); - - if (sdl_joystick_has_pressed_button(joystick) && sdl_joystick_has_moved_axis(joystick)) { - return true; - } - - std::this_thread::sleep_for(std::chrono::milliseconds {50}); - } - - return false; - } - bool sdl_controller_has_pressed_button(SDL_GameController *controller) { for (int button = SDL_CONTROLLER_BUTTON_A; button < SDL_CONTROLLER_BUTTON_MAX; ++button) { if (SDL_GameControllerGetButton(controller, static_cast(button)) != 0) { @@ -439,30 +422,6 @@ namespace { } } - void run_sdl_uhid_joystick_test(const SdlGamepadConsumerCase &test_case) { - run_sdl_gamepad_test( - test_case, - SDL_INIT_JOYSTICK | SDL_INIT_EVENTS, - [&test_case](const auto &expected_profile, int joystick_index, lvh::Gamepad &gamepad) { - SdlJoystick joystick {SDL_JoystickOpen(joystick_index), &SDL_JoystickClose}; - ASSERT_NE(joystick.get(), nullptr) << SDL_GetError(); - expect_sdl_joystick_profile( - joystick.get(), - expected_profile, - test_case.minimum_buttons, - test_case.minimum_axes - ); - - lvh::GamepadState state; - state.buttons.set(lvh::GamepadButton::a); - state.left_stick = {0.75F, -0.5F}; - ASSERT_TRUE(gamepad.submit(state).ok()); - - EXPECT_TRUE(wait_for_sdl_gamepad_input(joystick.get())) << describe_sdl_state(joystick.get()); - } - ); - } - void run_sdl_playstation_controller_test(const SdlGamepadConsumerCase &test_case) { run_sdl_gamepad_test( test_case, @@ -473,7 +432,7 @@ namespace { ); } - void exercise_sdl_xbox_series_controller( + void exercise_sdl_canonical_gamepad_controller( const SdlGamepadConsumerCase &test_case, const lvh::DeviceProfile &expected_profile, int joystick_index, @@ -540,6 +499,16 @@ namespace { EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT), 16000); } + void run_sdl_canonical_gamepad_test(const SdlGamepadConsumerCase &test_case) { + run_sdl_gamepad_test( + test_case, + SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS, + [&test_case](const auto &expected_profile, int joystick_index, lvh::Gamepad &gamepad) { + exercise_sdl_canonical_gamepad_controller(test_case, expected_profile, joystick_index, gamepad); + } + ); + } + void destroy_libinput_event(libinput_event *event) { if (event != nullptr) { libinput_event_destroy(event); @@ -611,13 +580,39 @@ namespace { } // namespace -TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) { - ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); +TEST_F(LinuxConsumerTest, SdlSeesGenericCanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); - run_sdl_uhid_joystick_test({ + run_sdl_canonical_gamepad_test({ .profile = lvh::profiles::generic_gamepad(), - .name_suffix = "SDL Gamepad", + .name_suffix = "SDL Generic Gamepad", .stable_id = "libvirtualhid-sdl-gamepad-test", + .minimum_buttons = 16, + .minimum_axes = 6, + }); +} + +TEST_F(LinuxConsumerTest, SdlSeesXbox360CanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + run_sdl_canonical_gamepad_test({ + .profile = lvh::profiles::xbox_360(), + .name_suffix = "SDL Xbox 360", + .stable_id = "libvirtualhid-sdl-xbox-360-test", + .minimum_buttons = 15, + .minimum_axes = 6, + }); +} + +TEST_F(LinuxConsumerTest, SdlSeesXboxOneCanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + run_sdl_canonical_gamepad_test({ + .profile = lvh::profiles::xbox_one(), + .name_suffix = "SDL Xbox One", + .stable_id = "libvirtualhid-sdl-xbox-one-test", + .minimum_buttons = 15, + .minimum_axes = 6, }); } @@ -632,13 +627,7 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) { .minimum_buttons = 16, .minimum_axes = 6, }; - run_sdl_gamepad_test( - test_case, - SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS, - [&test_case](const auto &expected_profile, int joystick_index, lvh::Gamepad &gamepad) { - exercise_sdl_xbox_series_controller(test_case, expected_profile, joystick_index, gamepad); - } - ); + run_sdl_canonical_gamepad_test(test_case); } TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) { From af8d80581fd77888957b92dbd701f79c34dab36e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:31:12 -0400 Subject: [PATCH 05/12] Add digital D-pad button support for Linux uinput Enable generic and Xbox 360 gamepad profiles to emit BTN_DPAD_* events alongside their hat axes, allowing consumers that prefer digital D-pad buttons to receive them directly. Also correct Xbox One and Xbox Series to use their Bluetooth product IDs (0x0B20 and 0x0B13) for their Linux uinput devices, matching the standard consumer mappings those identities carry. Update documentation and tests to verify D-pad button capabilities and events across profiles. --- docs/platform-support.md | 23 +++++--- src/platform/linux/uhid_backend.cpp | 67 +++++++++++++++++++---- tests/unit/test_linux_backend.cpp | 85 ++++++++++++++++++++++------- tests/unit/test_linux_consumers.cpp | 7 ++- 4 files changed, 140 insertions(+), 42 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index ca0a9d3..d38a8db 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -59,15 +59,20 @@ evdev consumers receive canonical Linux gamepad events without interpreting a standard or Xbox GIP descriptor. Face buttons, shoulders, menu buttons, stick clicks, and Guide use their native evdev codes; sticks and the directional pad use absolute axes; triggers remain independent analog `ABS_Z` and `ABS_RZ` -axes. Profiles with rumble support normalize force-feedback effects back into -the public callback. - -Xbox 360 and Xbox One retain their public USB identities. Xbox Series uses the -Bluetooth product identity (`0x0B13`) only for its Linux uinput device; its -public profile retains the physical USB identity used by other backends. All -four uinput gamepad profiles preserve the sparse 15-slot Linux gamepad button -sequence expected by Steam. Unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` -slots are advertised but never pressed, keeping face buttons, shoulders, menu +axes. Generic and Xbox 360 also emit the directional pad through `BTN_DPAD_*` +alongside their hat axes for consumers that prefer digital D-pad events. +Profiles with rumble support normalize force-feedback effects back into the +public callback. + +Xbox 360 retains its public USB identity (`0x045E:0x028E`). It has a compact +11-button core sequence with no reserved slots, plus four active digital D-pad +buttons for 15 active button capabilities in total. Xbox One and Xbox Series +retain their public USB identities, but their Linux uinput devices use the +corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, +respectively), whose standard consumer mappings match the events that uinput +exposes. Generic, Xbox One, and Xbox Series preserve the sparse 15-slot Linux +gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` slots +are advertised but never pressed, keeping face buttons, shoulders, menu buttons, Guide, L3, and R3 at their expected indices. Descriptor-driven profiles remain on `uhid`. The Switch Pro profile keeps its diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index d4a16c2..3b4625b 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -79,7 +79,10 @@ namespace lvh::detail { constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; constexpr auto xbox_trigger_max = 255; - constexpr auto xbox_series_uinput_bus = BUS_BLUETOOTH; + // The Xbox Bluetooth identities select the sparse evdev mappings that match + // the button capabilities exposed by these uinput devices. + constexpr auto xbox_wireless_uinput_bus = BUS_BLUETOOTH; + constexpr std::uint16_t xbox_wireless_uinput_product_id = 0x0B20; constexpr std::uint16_t xbox_series_uinput_product_id = 0x0B13; constexpr auto dualshock4_usb_calibration_report = 0x02; constexpr auto dualshock4_bluetooth_calibration_report = 0x05; @@ -517,6 +520,14 @@ namespace lvh::detail { return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_series; } + bool uses_sparse_uinput_button_slots(GamepadProfileKind kind) { + return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_one || kind == GamepadProfileKind::xbox_series; + } + + bool has_uinput_dpad_buttons(GamepadProfileKind kind) { + return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_360; + } + std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -1320,36 +1331,47 @@ namespace lvh::detail { } } - // Steam assigns the standard logical buttons using the sparse Linux gamepad - // button sequence. Advertise the unused slots as capabilities so the active - // buttons retain their canonical indices instead of being compacted. - constexpr std::array button_slots { + constexpr std::array active_buttons { BTN_SOUTH, BTN_EAST, - BTN_C, BTN_NORTH, BTN_WEST, - BTN_Z, BTN_TL, BTN_TR, - BTN_TL2, - BTN_TR2, BTN_SELECT, BTN_START, BTN_MODE, BTN_THUMBL, BTN_THUMBR, }; - for (const auto button : button_slots) { + for (const auto button : active_buttons) { if (const auto status = enable_evdev_code(device, EV_KEY, button, "Xbox gamepad button"); !status.ok()) { return status; } } + + if (uses_sparse_uinput_button_slots(profile.gamepad_kind)) { + // Steam's Generic and Xbox Series mappings use the sparse Linux gamepad + // sequence. These unused capabilities preserve the active button indices. + constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; + for (const auto button : reserved_buttons) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "reserved gamepad button slot"); !status.ok()) { + return status; + } + } + } if (has_uinput_misc1_button(profile.gamepad_kind)) { if (const auto status = enable_evdev_code(device, EV_KEY, KEY_RECORD, "gamepad share button"); !status.ok()) { return status; } } + if (has_uinput_dpad_buttons(profile.gamepad_kind)) { + for (const auto button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad directional button"); !status.ok()) { + return status; + } + } + } auto dpad = make_absinfo(-1, 1); for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { @@ -1416,13 +1438,21 @@ namespace lvh::detail { } libevdev_set_name(device.get(), profile.name.c_str()); + const auto xbox_one = profile.gamepad_kind == GamepadProfileKind::xbox_one; const auto xbox_series = profile.gamepad_kind == GamepadProfileKind::xbox_series; + const auto xbox_wireless = xbox_one || xbox_series; + auto product_id = profile.product_id; + if (xbox_one) { + product_id = xbox_wireless_uinput_product_id; + } else if (xbox_series) { + product_id = xbox_series_uinput_product_id; + } libevdev_set_id_bustype( device.get(), - xbox_series ? xbox_series_uinput_bus : to_uinput_bus(profile.bus_type) + xbox_wireless ? xbox_wireless_uinput_bus : to_uinput_bus(profile.bus_type) ); libevdev_set_id_vendor(device.get(), profile.vendor_id); - libevdev_set_id_product(device.get(), xbox_series ? xbox_series_uinput_product_id : profile.product_id); + libevdev_set_id_product(device.get(), product_id); libevdev_set_id_version(device.get(), profile.version); if (const auto status = configure_evdev_device(device.get(), profile); !status.ok()) { @@ -2423,6 +2453,19 @@ namespace lvh::detail { return status; } } + if (has_uinput_dpad_buttons(profile_kind_)) { + constexpr std::array dpad_button_map { + std::pair {dpad_up, BTN_DPAD_UP}, + std::pair {dpad_down, BTN_DPAD_DOWN}, + std::pair {dpad_left, BTN_DPAD_LEFT}, + std::pair {dpad_right, BTN_DPAD_RIGHT}, + }; + for (const auto &[button, code] : dpad_button_map) { + if (const auto status = emit_event(EV_KEY, code, normalized.buttons.test(button) ? 1 : 0); !status.ok()) { + return status; + } + } + } if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(normalized.buttons, dpad_left, dpad_right)); !status.ok()) { return status; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index cdde76c..5a8f86e 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -374,23 +374,59 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); ASSERT_TRUE(result.status.ok()) << result.status.message(); - const auto event_value = [&result](std::uint16_t code) -> std::optional { - const auto event = std::ranges::find_if(result.events, [code](const auto &candidate) { - return candidate.type == EV_ABS && candidate.code == code; + const auto event_value = [&result](std::uint16_t type, std::uint16_t code) -> std::optional { + const auto event = std::ranges::find_if(result.events, [type, code](const auto &candidate) { + return candidate.type == type && candidate.code == code; }); if (event == result.events.end()) { return std::nullopt; } return event->value; }; - EXPECT_EQ(event_value(ABS_HAT0X), 1); - EXPECT_EQ(event_value(ABS_HAT0Y), -1); - EXPECT_EQ(event_value(ABS_X), lvh::reports::normalize_axis(-0.5F)); - EXPECT_EQ(event_value(ABS_Y), lvh::reports::normalize_axis(-0.25F)); - EXPECT_EQ(event_value(ABS_RX), lvh::reports::normalize_axis(0.75F)); - EXPECT_EQ(event_value(ABS_RY), lvh::reports::normalize_axis(1.0F)); - EXPECT_EQ(event_value(ABS_Z), lvh::reports::normalize_trigger(0.25F)); - EXPECT_EQ(event_value(ABS_RZ), lvh::reports::normalize_trigger(0.75F)); + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0X), 1); + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0Y), -1); + EXPECT_EQ(event_value(EV_ABS, ABS_X), lvh::reports::normalize_axis(-0.5F)); + EXPECT_EQ(event_value(EV_ABS, ABS_Y), lvh::reports::normalize_axis(-0.25F)); + EXPECT_EQ(event_value(EV_ABS, ABS_RX), lvh::reports::normalize_axis(0.75F)); + EXPECT_EQ(event_value(EV_ABS, ABS_RY), lvh::reports::normalize_axis(1.0F)); + EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F)); + EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F)); + + if (kind == generic || kind == xbox_360) { + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), 1); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), 0); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), 0); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_RIGHT), 1); + } else { + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_RIGHT), std::nullopt); + } + } + + constexpr std::array dpad_cases { + std::pair {dpad_up, BTN_DPAD_UP}, + std::pair {dpad_down, BTN_DPAD_DOWN}, + std::pair {dpad_left, BTN_DPAD_LEFT}, + std::pair {dpad_right, BTN_DPAD_RIGHT}, + }; + for (const auto kind : {generic, xbox_360}) { + for (const auto &[logical_button, linux_code] : dpad_cases) { + lvh::GamepadState state; + state.buttons.set(logical_button); + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + + std::vector pressed_codes; + for (const auto &event : result.events) { + if (event.type == EV_KEY && event.value == 1) { + pressed_codes.push_back(event.code); + } + } + ASSERT_EQ(pressed_codes.size(), 1U); + EXPECT_EQ(pressed_codes.front(), linux_code); + } } } @@ -791,15 +827,18 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc struct GamepadCase { lvh::GamepadProfileKind kind; - bool series_identity; + std::uint16_t bustype; + std::uint16_t product_id; bool misc1; + bool sparse_button_slots; + bool dpad_buttons; }; constexpr std::array gamepad_cases { - GamepadCase {lvh::GamepadProfileKind::generic, false, true}, - GamepadCase {lvh::GamepadProfileKind::xbox_360, false, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_one, false, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_series, true, true}, + GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, true}, + GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_USB, 0x028E, false, false, true}, + GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, }; constexpr std::array active_buttons { BTN_SOUTH, @@ -815,16 +854,17 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc BTN_THUMBR, }; constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; + constexpr std::array dpad_buttons {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}; - for (const auto &[kind, series_identity, misc1] : gamepad_cases) { + for (const auto &[kind, bustype, product_id, misc1, sparse_button_slots, has_dpad_buttons] : gamepad_cases) { const auto expected_profile = lvh::profiles::gamepad_profile(kind); ASSERT_TRUE(expected_profile.has_value()); const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind); ASSERT_TRUE(gamepad.status.ok()) << gamepad.status.message(); EXPECT_EQ(gamepad.name, expected_profile->name); EXPECT_EQ(gamepad.vendor, expected_profile->vendor_id); - EXPECT_EQ(gamepad.bustype, series_identity ? BUS_BLUETOOTH : BUS_USB); - EXPECT_EQ(gamepad.product, series_identity ? 0x0B13 : expected_profile->product_id); + EXPECT_EQ(gamepad.bustype, bustype); + EXPECT_EQ(gamepad.product, product_id); EXPECT_EQ(gamepad.version, expected_profile->version); EXPECT_TRUE(has_type(gamepad, EV_KEY)); EXPECT_TRUE(has_type(gamepad, EV_ABS)); @@ -833,7 +873,12 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing canonical gamepad button " << button; } for (const auto button : reserved_buttons) { - EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing reserved gamepad button slot " << button; + EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, sparse_button_slots) + << "unexpected reserved gamepad button slot state for " << button; + } + for (const auto button : dpad_buttons) { + EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, has_dpad_buttons) + << "unexpected directional gamepad button state for " << button; } EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, misc1); const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 08adea7..a4d5d9d 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -465,6 +465,10 @@ namespace { ButtonCase {guide, SDL_CONTROLLER_BUTTON_GUIDE}, ButtonCase {left_stick, SDL_CONTROLLER_BUTTON_LEFTSTICK}, ButtonCase {right_stick, SDL_CONTROLLER_BUTTON_RIGHTSTICK}, + ButtonCase {dpad_up, SDL_CONTROLLER_BUTTON_DPAD_UP}, + ButtonCase {dpad_down, SDL_CONTROLLER_BUTTON_DPAD_DOWN}, + ButtonCase {dpad_left, SDL_CONTROLLER_BUTTON_DPAD_LEFT}, + ButtonCase {dpad_right, SDL_CONTROLLER_BUTTON_DPAD_RIGHT}, }; for (const auto &[logical_button, sdl_button] : button_cases) { @@ -587,7 +591,7 @@ TEST_F(LinuxConsumerTest, SdlSeesGenericCanonicalButtons) { .profile = lvh::profiles::generic_gamepad(), .name_suffix = "SDL Generic Gamepad", .stable_id = "libvirtualhid-sdl-gamepad-test", - .minimum_buttons = 16, + .minimum_buttons = 20, .minimum_axes = 6, }); } @@ -611,6 +615,7 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxOneCanonicalButtons) { .profile = lvh::profiles::xbox_one(), .name_suffix = "SDL Xbox One", .stable_id = "libvirtualhid-sdl-xbox-one-test", + .expected_product_id = 0x0B20, .minimum_buttons = 15, .minimum_axes = 6, }); From 0f04a6f077948e70ee947a765c4f648822ec12e5 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:30:27 -0400 Subject: [PATCH 06/12] Align Linux Xbox 360 gamepad mapping Update the Linux uinput backend so Xbox 360 profiles use the sparse Linux gamepad button slots like the other uinput gamepads, while limiting BTN_DPAD_* emission to the generic profile. The tests and platform support docs were updated to reflect the new capability layout and D-pad behavior. --- docs/platform-support.md | 23 +++++++++--------- src/platform/linux/uhid_backend.cpp | 9 +++---- tests/unit/test_linux_backend.cpp | 37 ++++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index d38a8db..7401381 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -59,21 +59,20 @@ evdev consumers receive canonical Linux gamepad events without interpreting a standard or Xbox GIP descriptor. Face buttons, shoulders, menu buttons, stick clicks, and Guide use their native evdev codes; sticks and the directional pad use absolute axes; triggers remain independent analog `ABS_Z` and `ABS_RZ` -axes. Generic and Xbox 360 also emit the directional pad through `BTN_DPAD_*` -alongside their hat axes for consumers that prefer digital D-pad events. -Profiles with rumble support normalize force-feedback effects back into the -public callback. - -Xbox 360 retains its public USB identity (`0x045E:0x028E`). It has a compact -11-button core sequence with no reserved slots, plus four active digital D-pad -buttons for 15 active button capabilities in total. Xbox One and Xbox Series -retain their public USB identities, but their Linux uinput devices use the -corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, +axes. Generic also emits the directional pad through `BTN_DPAD_*` alongside its +hat axes for consumers that prefer digital D-pad events. Profiles with rumble +support normalize force-feedback effects back into the public callback. + +Xbox 360 retains its public USB identity (`0x045E:0x028E`). Xbox One and Xbox +Series retain their public USB identities, but their Linux uinput devices use +the corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, respectively), whose standard consumer mappings match the events that uinput -exposes. Generic, Xbox One, and Xbox Series preserve the sparse 15-slot Linux +exposes. All four uinput gamepad profiles preserve the sparse 15-slot Linux gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` slots are advertised but never pressed, keeping face buttons, shoulders, menu -buttons, Guide, L3, and R3 at their expected indices. +buttons, Guide, L3, and R3 at their expected indices. D-pad directions are +reported through the hat axes and exposed as logical buttons by standard +gamepad consumers. Descriptor-driven profiles remain on `uhid`. The Switch Pro profile keeps its Nintendo identity but uses the virtual UHID bus on Linux, preventing diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 3b4625b..96c32f5 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -521,11 +521,12 @@ namespace lvh::detail { } bool uses_sparse_uinput_button_slots(GamepadProfileKind kind) { - return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_one || kind == GamepadProfileKind::xbox_series; + return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_360 || kind == GamepadProfileKind::xbox_one || + kind == GamepadProfileKind::xbox_series; } bool has_uinput_dpad_buttons(GamepadProfileKind kind) { - return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_360; + return kind == GamepadProfileKind::generic; } std::uint16_t to_uhid_bus(BusType bus_type) { @@ -1351,8 +1352,8 @@ namespace lvh::detail { } if (uses_sparse_uinput_button_slots(profile.gamepad_kind)) { - // Steam's Generic and Xbox Series mappings use the sparse Linux gamepad - // sequence. These unused capabilities preserve the active button indices. + // Linux gamepad consumers use the sparse button sequence. These unused + // capabilities preserve the active button indices. constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; for (const auto button : reserved_buttons) { if (const auto status = enable_evdev_code(device, EV_KEY, button, "reserved gamepad button slot"); !status.ok()) { diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 5a8f86e..f8afc46 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -392,7 +392,7 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F)); EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F)); - if (kind == generic || kind == xbox_360) { + if (kind == generic) { EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), 1); EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), 0); EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), 0); @@ -411,7 +411,7 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { std::pair {dpad_left, BTN_DPAD_LEFT}, std::pair {dpad_right, BTN_DPAD_RIGHT}, }; - for (const auto kind : {generic, xbox_360}) { + for (const auto kind : {generic}) { for (const auto &[logical_button, linux_code] : dpad_cases) { lvh::GamepadState state; state.buttons.set(logical_button); @@ -836,7 +836,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc constexpr std::array gamepad_cases { GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, true}, - GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_USB, 0x028E, false, false, true}, + GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_USB, 0x028E, false, true, false}, GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, }; @@ -888,6 +888,37 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_EQ(find_code(gamepad, EV_FF, FF_RUMBLE) != nullptr, expected_profile->capabilities.supports_rumble); } + const auto xbox_360 = lvh::detail::test::linux_uinput_create_fake_gamepad(lvh::GamepadProfileKind::xbox_360); + ASSERT_TRUE(xbox_360.status.ok()) << xbox_360.status.message(); + std::vector xbox_360_button_slots; + for (const auto &event_code : xbox_360.event_codes) { + if (event_code.type == EV_KEY && event_code.code >= BTN_SOUTH && event_code.code <= BTN_THUMBR) { + xbox_360_button_slots.push_back(event_code.code); + } + } + std::ranges::sort(xbox_360_button_slots); + constexpr std::array expected_xbox_360_button_slots { + BTN_SOUTH, + BTN_EAST, + BTN_C, + BTN_NORTH, + BTN_WEST, + BTN_Z, + BTN_TL, + BTN_TR, + BTN_TL2, + BTN_TR2, + BTN_SELECT, + BTN_START, + BTN_MODE, + BTN_THUMBL, + BTN_THUMBR, + }; + ASSERT_EQ(xbox_360_button_slots.size(), expected_xbox_360_button_slots.size()); + for (std::size_t index = 0; index < expected_xbox_360_button_slots.size(); ++index) { + EXPECT_EQ(xbox_360_button_slots[index], expected_xbox_360_button_slots[index]) << "button slot " << index; + } + const auto mouse = lvh::detail::test::linux_uinput_create_fake_libevdev_device(lvh::DeviceType::mouse); ASSERT_TRUE(mouse.status.ok()) << mouse.status.message(); EXPECT_TRUE(has_type(mouse, EV_KEY)); From c5fadf435359522900c406c6a7a40c6a3e6be293 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:11:40 -0400 Subject: [PATCH 07/12] Expand sparse Xbox uinput mapping Use the Bluetooth bus for the sparse Xbox uinput evdev mapping across Xbox 360, One, and Series profiles, and rename the bus constant to reflect the broader scope. --- docs/platform-support.md | 21 +++++++++++---------- src/platform/linux/uhid_backend.cpp | 11 ++++++----- tests/unit/test_linux_backend.cpp | 2 +- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 7401381..97c94c8 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -63,16 +63,17 @@ axes. Generic also emits the directional pad through `BTN_DPAD_*` alongside its hat axes for consumers that prefer digital D-pad events. Profiles with rumble support normalize force-feedback effects back into the public callback. -Xbox 360 retains its public USB identity (`0x045E:0x028E`). Xbox One and Xbox -Series retain their public USB identities, but their Linux uinput devices use -the corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, -respectively), whose standard consumer mappings match the events that uinput -exposes. All four uinput gamepad profiles preserve the sparse 15-slot Linux -gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` slots -are advertised but never pressed, keeping face buttons, shoulders, menu -buttons, Guide, L3, and R3 at their expected indices. D-pad directions are -reported through the hat axes and exposed as logical buttons by standard -gamepad consumers. +Xbox 360 retains its name and `0x045E:0x028E` identity, while its Linux uinput +device uses the Bluetooth bus so consumers select the sparse button mapping. +Xbox One and Xbox Series retain their public USB identities, but their Linux +uinput devices use the corresponding Bluetooth product identities (`0x0B20` +and `0x0B13`, respectively), whose standard consumer mappings match the events +that uinput exposes. All four uinput gamepad profiles preserve the sparse +15-slot Linux gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and +`BTN_TR2` slots are advertised but never pressed, keeping face buttons, +shoulders, menu buttons, Guide, L3, and R3 at their expected indices. D-pad +directions are reported through the hat axes and exposed as logical buttons by +standard gamepad consumers. Descriptor-driven profiles remain on `uhid`. The Switch Pro profile keeps its Nintendo identity but uses the virtual UHID bus on Linux, preventing diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 96c32f5..72eb593 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -79,9 +79,9 @@ namespace lvh::detail { constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; constexpr auto xbox_trigger_max = 255; - // The Xbox Bluetooth identities select the sparse evdev mappings that match - // the button capabilities exposed by these uinput devices. - constexpr auto xbox_wireless_uinput_bus = BUS_BLUETOOTH; + // The Bluetooth bus selects the sparse evdev mapping that matches the + // button capabilities exposed by these Xbox uinput devices. + constexpr auto xbox_sparse_uinput_bus = BUS_BLUETOOTH; constexpr std::uint16_t xbox_wireless_uinput_product_id = 0x0B20; constexpr std::uint16_t xbox_series_uinput_product_id = 0x0B13; constexpr auto dualshock4_usb_calibration_report = 0x02; @@ -1439,9 +1439,10 @@ namespace lvh::detail { } libevdev_set_name(device.get(), profile.name.c_str()); + const auto xbox_360 = profile.gamepad_kind == GamepadProfileKind::xbox_360; const auto xbox_one = profile.gamepad_kind == GamepadProfileKind::xbox_one; const auto xbox_series = profile.gamepad_kind == GamepadProfileKind::xbox_series; - const auto xbox_wireless = xbox_one || xbox_series; + const auto uses_sparse_xbox_mapping = xbox_360 || xbox_one || xbox_series; auto product_id = profile.product_id; if (xbox_one) { product_id = xbox_wireless_uinput_product_id; @@ -1450,7 +1451,7 @@ namespace lvh::detail { } libevdev_set_id_bustype( device.get(), - xbox_wireless ? xbox_wireless_uinput_bus : to_uinput_bus(profile.bus_type) + uses_sparse_xbox_mapping ? xbox_sparse_uinput_bus : to_uinput_bus(profile.bus_type) ); libevdev_set_id_vendor(device.get(), profile.vendor_id); libevdev_set_id_product(device.get(), product_id); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index f8afc46..a8acad4 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -836,7 +836,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc constexpr std::array gamepad_cases { GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, true}, - GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_USB, 0x028E, false, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_BLUETOOTH, 0x028E, false, true, false}, GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, }; From 2bfce4d6e52cf96999ebcd1db6f08c11a4ee0336 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:45:46 -0400 Subject: [PATCH 08/12] Refactor Linux gamepad event setup Split Linux uhid/uinput gamepad setup and submit logic into focused helper functions for event types, buttons, axes, and force-feedback while preserving behavior. This improves readability and reduces duplication in button emission paths. Unit tests were updated to reuse a shared pressed-key extraction helper, and consumer profile setup now explicitly checks optional product IDs with `has_value()` for clarity. --- src/platform/linux/uhid_backend.cpp | 172 +++++++++++++++++----------- tests/unit/test_linux_backend.cpp | 28 +++-- tests/unit/test_linux_consumers.cpp | 2 +- 3 files changed, 125 insertions(+), 77 deletions(-) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 72eb593..271fcab 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -521,8 +521,9 @@ namespace lvh::detail { } bool uses_sparse_uinput_button_slots(GamepadProfileKind kind) { - return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_360 || kind == GamepadProfileKind::xbox_one || - kind == GamepadProfileKind::xbox_series; + using enum GamepadProfileKind; + + return kind == generic || kind == xbox_360 || kind == xbox_one || kind == xbox_series; } bool has_uinput_dpad_buttons(GamepadProfileKind kind) { @@ -1319,19 +1320,20 @@ namespace lvh::detail { return enable_evdev_property(device, INPUT_PROP_DIRECT, "tablet direct"); } - OperationStatus configure_evdev_gamepad(libevdev *device, const DeviceProfile &profile) { + OperationStatus configure_evdev_gamepad_event_types(libevdev *device, bool supports_rumble) { if (const auto status = enable_evdev_type(device, EV_KEY, "Xbox gamepad button events"); !status.ok()) { return status; } if (const auto status = enable_evdev_type(device, EV_ABS, "Xbox gamepad absolute events"); !status.ok()) { return status; } - if (profile.capabilities.supports_rumble) { - if (const auto status = enable_evdev_type(device, EV_FF, "Xbox gamepad force-feedback events"); !status.ok()) { - return status; - } + if (!supports_rumble) { + return OperationStatus::success(); } + return enable_evdev_type(device, EV_FF, "Xbox gamepad force-feedback events"); + } + OperationStatus configure_evdev_gamepad_buttons(libevdev *device, GamepadProfileKind profile_kind) { constexpr std::array active_buttons { BTN_SOUTH, BTN_EAST, @@ -1351,7 +1353,7 @@ namespace lvh::detail { } } - if (uses_sparse_uinput_button_slots(profile.gamepad_kind)) { + if (uses_sparse_uinput_button_slots(profile_kind)) { // Linux gamepad consumers use the sparse button sequence. These unused // capabilities preserve the active button indices. constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; @@ -1361,19 +1363,22 @@ namespace lvh::detail { } } } - if (has_uinput_misc1_button(profile.gamepad_kind)) { + if (has_uinput_misc1_button(profile_kind)) { if (const auto status = enable_evdev_code(device, EV_KEY, KEY_RECORD, "gamepad share button"); !status.ok()) { return status; } } - if (has_uinput_dpad_buttons(profile.gamepad_kind)) { + if (has_uinput_dpad_buttons(profile_kind)) { for (const auto button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad directional button"); !status.ok()) { return status; } } } + return OperationStatus::success(); + } + OperationStatus configure_evdev_gamepad_axes(libevdev *device) { auto dpad = make_absinfo(-1, 1); for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad directional axis", &dpad); !status.ok()) { @@ -1394,14 +1399,31 @@ namespace lvh::detail { return status; } } + return OperationStatus::success(); + } - if (profile.capabilities.supports_rumble) { - if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "Xbox gamepad force-feedback effect"); !status.ok()) { - return status; - } - return enable_evdev_code(device, EV_FF, FF_GAIN, "Xbox gamepad force-feedback gain"); + OperationStatus configure_evdev_gamepad_force_feedback(libevdev *device, bool supports_rumble) { + if (!supports_rumble) { + return OperationStatus::success(); } - return OperationStatus::success(); + if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "Xbox gamepad force-feedback effect"); !status.ok()) { + return status; + } + return enable_evdev_code(device, EV_FF, FF_GAIN, "Xbox gamepad force-feedback gain"); + } + + OperationStatus configure_evdev_gamepad(libevdev *device, const DeviceProfile &profile) { + const auto supports_rumble = profile.capabilities.supports_rumble; + if (const auto status = configure_evdev_gamepad_event_types(device, supports_rumble); !status.ok()) { + return status; + } + if (const auto status = configure_evdev_gamepad_buttons(device, profile.gamepad_kind); !status.ok()) { + return status; + } + if (const auto status = configure_evdev_gamepad_axes(device); !status.ok()) { + return status; + } + return configure_evdev_gamepad_force_feedback(device, supports_rumble); } OperationStatus configure_evdev_device(libevdev *device, const DeviceProfile &profile) { @@ -2423,14 +2445,56 @@ namespace lvh::detail { const GamepadState &state, const std::vector & /*report*/ ) override { - using enum GamepadButton; - if (!is_open()) { return OperationStatus::failure(ErrorCode::device_closed, "uinput Xbox gamepad is closed"); } const auto normalized = reports::normalize_state(state); std::lock_guard lock {submit_mutex_}; + if (const auto status = emit_button_state(normalized.buttons); !status.ok()) { + return status; + } + if (const auto status = emit_axis_state(normalized); !status.ok()) { + return status; + } + return sync(); + } + + void set_output_callback(OutputCallback callback) override { + std::lock_guard lock {callback_mutex_}; + output_callback_ = std::move(callback); + } + + std::vector device_nodes() const override { + return discover_input_nodes_by_name(device_name_); + } + + OperationStatus close() override { + running_ = false; + if (reader_.joinable()) { + reader_.request_stop(); + reader_.join(); + } + dispatch_rumble(0, 0); + return close_uinput("uinput gamepad"); + } + + private: + template + OperationStatus emit_button_map( + const ButtonSet &buttons, + const std::array, Size> &button_map + ) { + for (const auto &[button, code] : button_map) { + if (const auto status = emit_event(EV_KEY, code, buttons.test(button) ? 1 : 0); !status.ok()) { + return status; + } + } + return OperationStatus::success(); + } + + OperationStatus emit_button_state(const ButtonSet &buttons) { + using enum GamepadButton; constexpr std::array button_map { std::pair {a, BTN_SOUTH}, @@ -2445,73 +2509,53 @@ namespace lvh::detail { std::pair {left_stick, BTN_THUMBL}, std::pair {right_stick, BTN_THUMBR}, }; - for (const auto &[button, code] : button_map) { - if (const auto status = emit_event(EV_KEY, code, normalized.buttons.test(button) ? 1 : 0); !status.ok()) { - return status; - } + if (const auto status = emit_button_map(buttons, button_map); !status.ok()) { + return status; } if (has_uinput_misc1_button(profile_kind_)) { - if (const auto status = emit_event(EV_KEY, KEY_RECORD, normalized.buttons.test(misc1) ? 1 : 0); !status.ok()) { + if (const auto status = emit_event(EV_KEY, KEY_RECORD, buttons.test(misc1) ? 1 : 0); !status.ok()) { return status; } } - if (has_uinput_dpad_buttons(profile_kind_)) { - constexpr std::array dpad_button_map { - std::pair {dpad_up, BTN_DPAD_UP}, - std::pair {dpad_down, BTN_DPAD_DOWN}, - std::pair {dpad_left, BTN_DPAD_LEFT}, - std::pair {dpad_right, BTN_DPAD_RIGHT}, - }; - for (const auto &[button, code] : dpad_button_map) { - if (const auto status = emit_event(EV_KEY, code, normalized.buttons.test(button) ? 1 : 0); !status.ok()) { - return status; - } - } + if (!has_uinput_dpad_buttons(profile_kind_)) { + return OperationStatus::success(); } - if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(normalized.buttons, dpad_left, dpad_right)); !status.ok()) { + constexpr std::array dpad_button_map { + std::pair {dpad_up, BTN_DPAD_UP}, + std::pair {dpad_down, BTN_DPAD_DOWN}, + std::pair {dpad_left, BTN_DPAD_LEFT}, + std::pair {dpad_right, BTN_DPAD_RIGHT}, + }; + return emit_button_map(buttons, dpad_button_map); + } + + OperationStatus emit_axis_state(const GamepadState &state) { + using enum GamepadButton; + + if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(state.buttons, dpad_left, dpad_right)); !status.ok()) { return status; } - if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(normalized.buttons, dpad_up, dpad_down)); !status.ok()) { + if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(state.buttons, dpad_up, dpad_down)); !status.ok()) { return status; } const std::array axes { - std::pair {ABS_X, static_cast(reports::normalize_axis(normalized.left_stick.x))}, - std::pair {ABS_Y, static_cast(reports::normalize_axis(-normalized.left_stick.y))}, - std::pair {ABS_RX, static_cast(reports::normalize_axis(normalized.right_stick.x))}, - std::pair {ABS_RY, static_cast(reports::normalize_axis(-normalized.right_stick.y))}, - std::pair {ABS_Z, static_cast(reports::normalize_trigger(normalized.left_trigger))}, - std::pair {ABS_RZ, static_cast(reports::normalize_trigger(normalized.right_trigger))}, + std::pair {ABS_X, static_cast(reports::normalize_axis(state.left_stick.x))}, + std::pair {ABS_Y, static_cast(reports::normalize_axis(-state.left_stick.y))}, + std::pair {ABS_RX, static_cast(reports::normalize_axis(state.right_stick.x))}, + std::pair {ABS_RY, static_cast(reports::normalize_axis(-state.right_stick.y))}, + std::pair {ABS_Z, static_cast(reports::normalize_trigger(state.left_trigger))}, + std::pair {ABS_RZ, static_cast(reports::normalize_trigger(state.right_trigger))}, }; for (const auto &[code, value] : axes) { if (const auto status = emit_event(EV_ABS, code, value); !status.ok()) { return status; } } - return sync(); - } - - void set_output_callback(OutputCallback callback) override { - std::lock_guard lock {callback_mutex_}; - output_callback_ = std::move(callback); - } - - std::vector device_nodes() const override { - return discover_input_nodes_by_name(device_name_); - } - - OperationStatus close() override { - running_ = false; - if (reader_.joinable()) { - reader_.request_stop(); - reader_.join(); - } - dispatch_rumble(0, 0); - return close_uinput("uinput gamepad"); + return OperationStatus::success(); } - private: static int dpad_axis(const ButtonSet &buttons, GamepadButton negative, GamepadButton positive) { const auto negative_pressed = buttons.test(negative); if (const auto positive_pressed = buttons.test(positive); negative_pressed == positive_pressed) { diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index a8acad4..40ef2b4 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -32,6 +32,20 @@ */ class LinuxBackendTest: public LinuxTest {}; +namespace { + std::vector pressed_key_codes( + const std::vector &events + ) { + std::vector pressed_codes; + for (const auto &event : events) { + if (event.type == EV_KEY && event.value == 1) { + pressed_codes.push_back(event.code); + } + } + return pressed_codes; + } +} // namespace + TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) { EXPECT_EQ(lvh::detail::test::linux_key_code(0x08), KEY_BACKSPACE); EXPECT_EQ(lvh::detail::test::linux_key_code(0x09), KEY_TAB); @@ -337,12 +351,7 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); ASSERT_TRUE(result.status.ok()) << result.status.message(); - std::vector pressed_codes; - for (const auto &event : result.events) { - if (event.type == EV_KEY && event.value == 1) { - pressed_codes.push_back(event.code); - } - } + const auto pressed_codes = pressed_key_codes(result.events); ASSERT_EQ(pressed_codes.size(), 1U) << "profile " << static_cast(std::to_underlying(kind)) << " logical button " << static_cast(std::to_underlying(button)); @@ -418,12 +427,7 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); ASSERT_TRUE(result.status.ok()) << result.status.message(); - std::vector pressed_codes; - for (const auto &event : result.events) { - if (event.type == EV_KEY && event.value == 1) { - pressed_codes.push_back(event.code); - } - } + const auto pressed_codes = pressed_key_codes(result.events); ASSERT_EQ(pressed_codes.size(), 1U); EXPECT_EQ(pressed_codes.front(), linux_code); } diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index a4d5d9d..bc85ced 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -351,7 +351,7 @@ namespace { const auto expected_profile = [&test_case]() { auto profile = test_case.profile; profile.name = unique_device_name(test_case.name_suffix); - if (test_case.expected_product_id) { + if (test_case.expected_product_id.has_value()) { profile.product_id = *test_case.expected_product_id; } return profile; From 0b0f6df5fc645471632dfb4a4df4fd84526b1aa4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:39:54 -0400 Subject: [PATCH 09/12] Route Switch Pro to Linux uinput backend Moves Linux Switch Pro gamepads from descriptor-driven UHID behavior to a uinput-native mapping, including Nintendo face-button positions, button-based ZL/ZR triggers, BTN_Z for Capture, and hat-only D-pad reporting. The Linux backend now treats Generic/Xbox/Switch Pro as uinput gamepads with normalized FF rumble callbacks, while DualShock 4 and DualSense stay on UHID. Also enables rumble capability for Generic and Switch Pro profiles, updates output parsing so Switch Pro reports remain raw instead of being misread as generic rumble packets, and improves UHID output handling by honoring SET_REPORT report numbers and control-path output frames. Tests and docs were updated to match the new profile capabilities and Linux platform behavior. --- README.md | 6 +- docs/platform-support.md | 49 +++--- docs/streaming-host-integration.md | 3 +- src/core/profiles.cpp | 4 +- src/core/report.cpp | 3 +- src/platform/linux/uhid_backend.cpp | 142 ++++++++++++------ .../fixtures/linux_backend_test_hooks.hpp | 5 +- tests/fixtures/linux_backend_test_hooks.cpp | 53 ++++++- tests/unit/test_gamepad_adapter.cpp | 8 +- tests/unit/test_linux_backend.cpp | 140 +++++++++-------- tests/unit/test_profiles.cpp | 6 +- tests/unit/test_report.cpp | 19 ++- tests/unit/test_virtualhid_control_model.cpp | 6 +- 13 files changed, 292 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 67e7220..f260a3b 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ behind backend implementations. - Gamepad profiles for generic HID, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Nintendo Switch Pro-style controllers. -- Descriptor-driven Linux gamepads through `uhid`; Xbox Series gamepads plus - keyboard, mouse, touchscreen, trackpad, and pen tablet devices through - `uinput`. +- Descriptor-driven PlayStation gamepads through Linux `uhid`; Generic, Xbox, + and Switch Pro gamepads plus keyboard, mouse, touchscreen, trackpad, and pen + tablet devices through `uinput`. - Windows gamepads through a user-mode UMDF2 control driver backed by Virtual HID Framework, with keyboard and mouse support through normal Win32 APIs. - Output callbacks for profile-specific feedback such as rumble, LEDs, diff --git a/docs/platform-support.md b/docs/platform-support.md index 97c94c8..02e2877 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -46,39 +46,48 @@ and signing details. The Linux backend uses standard user-space kernel interfaces: - `uhid` for descriptor-driven HID gamepads. -- `uinput` for Generic, Xbox 360, Xbox One, and Xbox Series gamepads, plus - keyboard, mouse, touchscreen, trackpad, and pen tablet devices. +- `uinput` for Generic, Xbox 360, Xbox One, Xbox Series, and Switch Pro + gamepads, plus keyboard, mouse, touchscreen, trackpad, and pen tablet + devices. - `libevdev` internally for uinput device construction. - X11/XTest only as a keyboard and mouse fallback when `uinput` cannot be used and an X11 session is available. Gamepad support normally prefers `uhid` because descriptors, raw HID identity, feature reports, and output reports matter for controller compatibility. -Generic and Xbox-family profiles instead use `uinput` so SDL, Steam, and other -evdev consumers receive canonical Linux gamepad events without interpreting a -standard or Xbox GIP descriptor. Face buttons, shoulders, menu buttons, stick +Generic, Xbox-family, and Switch Pro profiles instead use `uinput` so SDL, +Steam, browser Gamepad API implementations, and other evdev consumers receive +canonical Linux gamepad events. Face buttons, shoulders, menu buttons, stick clicks, and Guide use their native evdev codes; sticks and the directional pad -use absolute axes; triggers remain independent analog `ABS_Z` and `ABS_RZ` -axes. Generic also emits the directional pad through `BTN_DPAD_*` alongside its -hat axes for consumers that prefer digital D-pad events. Profiles with rumble -support normalize force-feedback effects back into the public callback. +use absolute axes. Generic and Xbox triggers remain independent analog `ABS_Z` +and `ABS_RZ` axes. Switch Pro uses the Nintendo face-button positions, button +events for ZL/ZR, and `BTN_Z` for Capture. The directional pad is exposed only +through `ABS_HAT0X` and `ABS_HAT0Y`, avoiding the ambiguous duplicate +`BTN_DPAD_*` representation used by some consumers. Profiles with rumble +support normalize uinput force-feedback effects back into the public callback. Xbox 360 retains its name and `0x045E:0x028E` identity, while its Linux uinput device uses the Bluetooth bus so consumers select the sparse button mapping. Xbox One and Xbox Series retain their public USB identities, but their Linux uinput devices use the corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, respectively), whose standard consumer mappings match the events -that uinput exposes. All four uinput gamepad profiles preserve the sparse -15-slot Linux gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and -`BTN_TR2` slots are advertised but never pressed, keeping face buttons, -shoulders, menu buttons, Guide, L3, and R3 at their expected indices. D-pad -directions are reported through the hat axes and exposed as logical buttons by -standard gamepad consumers. - -Descriptor-driven profiles remain on `uhid`. The Switch Pro profile keeps its -Nintendo identity but uses the virtual UHID bus on Linux, preventing -`hid-nintendo` from claiming the descriptor-only device and waiting for -physical-controller initialization handshakes. +that uinput exposes. Those four sparse-layout profiles preserve the 15-slot +Linux gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` +slots are advertised but never pressed, keeping face buttons, shoulders, menu +buttons, Guide, L3, and R3 at their expected indices. D-pad directions are +reported through the hat axes and exposed as logical buttons by standard +gamepad consumers. + +DualShock 4 and DualSense remain on `uhid` so their descriptors, motion, +touchpad, battery, feature reports, and profile-specific output reports stay +available. The backend accepts PlayStation output through both UHID interrupt +and control channels, including control reports whose report number is supplied +separately from the payload. + +Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows +the evdev layout used by Linux-native virtual-controller implementations and +allows standard `FF_RUMBLE` effects without emulating the physical controller's +proprietary initialization handshake. The optional `virtualhid_control` diagnostic UI uses SDL3 and Dear ImGui through the repository CPM lockfile. It is intended to stay on the same UI framework for diff --git a/docs/streaming-host-integration.md b/docs/streaming-host-integration.md index fc86aa8..d0f8c65 100644 --- a/docs/streaming-host-integration.md +++ b/docs/streaming-host-integration.md @@ -51,7 +51,8 @@ The core API and adapter shape cover the major streaming-host requirements: - Rich controller metadata. - Gamepad output callbacks. - Keyboard and mouse input paths. -- Linux `uhid` gamepads and `uinput` keyboard/pointer devices. +- Linux PlayStation gamepads through `uhid`, Generic/Xbox/Switch Pro gamepads + through `uinput`, and `uinput` keyboard/pointer devices. - Linux DualSense and DualShock 4 USB/Bluetooth report handling. - Linux touchscreen, trackpad, and pen tablet device types. - Windows UMDF/VHF gamepad creation through an installed driver package. diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index c9dee61..96d2e57 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -1975,7 +1975,7 @@ namespace lvh::profiles { profile.output_report_size = switch_pro_output_report_size; profile.name = "Pro Controller"; profile.manufacturer = "Nintendo Co., Ltd."; - profile.capabilities = {.supports_motion = true, .supports_battery = true}; + profile.capabilities = {.supports_rumble = true, .supports_motion = true, .supports_battery = true}; profile.report_descriptor = make_switch_pro_report_descriptor(); return profile; } @@ -2002,7 +2002,7 @@ namespace lvh::profiles { 0x1209, 0x0001, 0x0001, - {} + {.supports_rumble = true} ); } diff --git a/src/core/report.cpp b/src/core/report.cpp index 7876baa..5366e65 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -935,7 +935,8 @@ namespace lvh::reports { } if ( - profile.capabilities.supports_rumble && profile.output_report_size >= 5U && + profile.gamepad_kind != GamepadProfileKind::switch_pro && profile.capabilities.supports_rumble && + profile.output_report_size >= 5U && report.size() >= profile.output_report_size && report[0] == profile.report_id ) { GamepadOutput output; diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 271fcab..74da3b4 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -506,18 +506,33 @@ namespace lvh::detail { case xbox_360: case xbox_one: case xbox_series: + case switch_pro: return true; case dualshock4: case dualsense: - case switch_pro: return false; } return false; } - bool has_uinput_misc1_button(GamepadProfileKind kind) { - return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_series; + std::optional uinput_misc1_button(GamepadProfileKind kind) { + switch (kind) { + using enum GamepadProfileKind; + + case generic: + case xbox_series: + return KEY_RECORD; + case switch_pro: + return BTN_Z; + case xbox_360: + case xbox_one: + case dualshock4: + case dualsense: + return std::nullopt; + } + + return std::nullopt; } bool uses_sparse_uinput_button_slots(GamepadProfileKind kind) { @@ -526,10 +541,6 @@ namespace lvh::detail { return kind == generic || kind == xbox_360 || kind == xbox_one || kind == xbox_series; } - bool has_uinput_dpad_buttons(GamepadProfileKind kind) { - return kind == GamepadProfileKind::generic; - } - std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -1321,16 +1332,16 @@ namespace lvh::detail { } OperationStatus configure_evdev_gamepad_event_types(libevdev *device, bool supports_rumble) { - if (const auto status = enable_evdev_type(device, EV_KEY, "Xbox gamepad button events"); !status.ok()) { + if (const auto status = enable_evdev_type(device, EV_KEY, "gamepad button events"); !status.ok()) { return status; } - if (const auto status = enable_evdev_type(device, EV_ABS, "Xbox gamepad absolute events"); !status.ok()) { + if (const auto status = enable_evdev_type(device, EV_ABS, "gamepad absolute events"); !status.ok()) { return status; } if (!supports_rumble) { return OperationStatus::success(); } - return enable_evdev_type(device, EV_FF, "Xbox gamepad force-feedback events"); + return enable_evdev_type(device, EV_FF, "gamepad force-feedback events"); } OperationStatus configure_evdev_gamepad_buttons(libevdev *device, GamepadProfileKind profile_kind) { @@ -1348,7 +1359,7 @@ namespace lvh::detail { BTN_THUMBR, }; for (const auto button : active_buttons) { - if (const auto status = enable_evdev_code(device, EV_KEY, button, "Xbox gamepad button"); !status.ok()) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad button"); !status.ok()) { return status; } } @@ -1363,39 +1374,43 @@ namespace lvh::detail { } } } - if (has_uinput_misc1_button(profile_kind)) { - if (const auto status = enable_evdev_code(device, EV_KEY, KEY_RECORD, "gamepad share button"); !status.ok()) { - return status; - } - } - if (has_uinput_dpad_buttons(profile_kind)) { - for (const auto button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { - if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad directional button"); !status.ok()) { + if (profile_kind == GamepadProfileKind::switch_pro) { + for (const auto button : {BTN_TL2, BTN_TR2}) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad trigger button"); !status.ok()) { return status; } } } + if (const auto misc1_button = uinput_misc1_button(profile_kind); misc1_button.has_value()) { + if (const auto status = enable_evdev_code(device, EV_KEY, *misc1_button, "gamepad auxiliary button"); !status.ok()) { + return status; + } + } return OperationStatus::success(); } - OperationStatus configure_evdev_gamepad_axes(libevdev *device) { + OperationStatus configure_evdev_gamepad_axes(libevdev *device, GamepadProfileKind profile_kind) { auto dpad = make_absinfo(-1, 1); for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { - if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad directional axis", &dpad); !status.ok()) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "gamepad directional axis", &dpad); !status.ok()) { return status; } } auto stick = make_absinfo(-32768, 32767, 16, 128); for (const auto code : {ABS_X, ABS_Y, ABS_RX, ABS_RY}) { - if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad stick axis", &stick); !status.ok()) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "gamepad stick axis", &stick); !status.ok()) { return status; } } + if (profile_kind == GamepadProfileKind::switch_pro) { + return OperationStatus::success(); + } + auto trigger = make_absinfo(0, xbox_trigger_max); for (const auto code : {ABS_Z, ABS_RZ}) { - if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad trigger axis", &trigger); !status.ok()) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "gamepad trigger axis", &trigger); !status.ok()) { return status; } } @@ -1406,10 +1421,10 @@ namespace lvh::detail { if (!supports_rumble) { return OperationStatus::success(); } - if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "Xbox gamepad force-feedback effect"); !status.ok()) { + if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "gamepad force-feedback effect"); !status.ok()) { return status; } - return enable_evdev_code(device, EV_FF, FF_GAIN, "Xbox gamepad force-feedback gain"); + return enable_evdev_code(device, EV_FF, FF_GAIN, "gamepad force-feedback gain"); } OperationStatus configure_evdev_gamepad(libevdev *device, const DeviceProfile &profile) { @@ -1420,7 +1435,7 @@ namespace lvh::detail { if (const auto status = configure_evdev_gamepad_buttons(device, profile.gamepad_kind); !status.ok()) { return status; } - if (const auto status = configure_evdev_gamepad_axes(device); !status.ok()) { + if (const auto status = configure_evdev_gamepad_axes(device, profile.gamepad_kind); !status.ok()) { return status; } return configure_evdev_gamepad_force_feedback(device, supports_rumble); @@ -2496,11 +2511,19 @@ namespace lvh::detail { OperationStatus emit_button_state(const ButtonSet &buttons) { using enum GamepadButton; - constexpr std::array button_map { + constexpr std::array standard_face_button_map { std::pair {a, BTN_SOUTH}, std::pair {b, BTN_EAST}, std::pair {x, BTN_NORTH}, std::pair {y, BTN_WEST}, + }; + constexpr std::array switch_face_button_map { + std::pair {a, BTN_EAST}, + std::pair {b, BTN_SOUTH}, + std::pair {x, BTN_NORTH}, + std::pair {y, BTN_WEST}, + }; + constexpr std::array common_button_map { std::pair {left_shoulder, BTN_TL}, std::pair {right_shoulder, BTN_TR}, std::pair {back, BTN_SELECT}, @@ -2509,25 +2532,21 @@ namespace lvh::detail { std::pair {left_stick, BTN_THUMBL}, std::pair {right_stick, BTN_THUMBR}, }; - if (const auto status = emit_button_map(buttons, button_map); !status.ok()) { + + const auto &face_button_map = + profile_kind_ == GamepadProfileKind::switch_pro ? switch_face_button_map : standard_face_button_map; + if (const auto status = emit_button_map(buttons, face_button_map); !status.ok()) { + return status; + } + if (const auto status = emit_button_map(buttons, common_button_map); !status.ok()) { return status; } - if (has_uinput_misc1_button(profile_kind_)) { - if (const auto status = emit_event(EV_KEY, KEY_RECORD, buttons.test(misc1) ? 1 : 0); !status.ok()) { + if (const auto misc1_button = uinput_misc1_button(profile_kind_); misc1_button.has_value()) { + if (const auto status = emit_event(EV_KEY, *misc1_button, buttons.test(misc1) ? 1 : 0); !status.ok()) { return status; } } - if (!has_uinput_dpad_buttons(profile_kind_)) { - return OperationStatus::success(); - } - - constexpr std::array dpad_button_map { - std::pair {dpad_up, BTN_DPAD_UP}, - std::pair {dpad_down, BTN_DPAD_DOWN}, - std::pair {dpad_left, BTN_DPAD_LEFT}, - std::pair {dpad_right, BTN_DPAD_RIGHT}, - }; - return emit_button_map(buttons, dpad_button_map); + return OperationStatus::success(); } OperationStatus emit_axis_state(const GamepadState &state) { @@ -2540,15 +2559,30 @@ namespace lvh::detail { return status; } - const std::array axes { + const std::array stick_axes { std::pair {ABS_X, static_cast(reports::normalize_axis(state.left_stick.x))}, std::pair {ABS_Y, static_cast(reports::normalize_axis(-state.left_stick.y))}, std::pair {ABS_RX, static_cast(reports::normalize_axis(state.right_stick.x))}, std::pair {ABS_RY, static_cast(reports::normalize_axis(-state.right_stick.y))}, + }; + for (const auto &[code, value] : stick_axes) { + if (const auto status = emit_event(EV_ABS, code, value); !status.ok()) { + return status; + } + } + + if (profile_kind_ == GamepadProfileKind::switch_pro) { + if (const auto status = emit_event(EV_KEY, BTN_TL2, state.left_trigger > 0.0F ? 1 : 0); !status.ok()) { + return status; + } + return emit_event(EV_KEY, BTN_TR2, state.right_trigger > 0.0F ? 1 : 0); + } + + const std::array trigger_axes { std::pair {ABS_Z, static_cast(reports::normalize_trigger(state.left_trigger))}, std::pair {ABS_RZ, static_cast(reports::normalize_trigger(state.right_trigger))}, }; - for (const auto &[code, value] : axes) { + for (const auto &[code, value] : trigger_axes) { if (const auto status = emit_event(EV_ABS, code, value); !status.ok()) { return status; } @@ -2936,7 +2970,11 @@ namespace lvh::detail { send_get_report_reply(event.u.get_report.id, event.u.get_report.rnum); break; case UHID_SET_REPORT: - dispatch_output_report(event.u.set_report.data, event.u.set_report.size); + dispatch_output_report( + event.u.set_report.data, + event.u.set_report.size, + event.u.set_report.rnum + ); send_set_report_reply(event.u.set_report.id, 0); break; default: @@ -2962,7 +3000,11 @@ namespace lvh::detail { } } - void dispatch_output_report(const __u8 *data, std::size_t report_size) { + void dispatch_output_report( + const __u8 *data, + std::size_t report_size, + std::optional report_number = std::nullopt + ) { OutputCallback callback; { std::lock_guard lock {callback_mutex_}; @@ -2975,6 +3017,14 @@ namespace lvh::detail { const auto size = std::min(report_size, UHID_DATA_MAX); std::vector report(data, data + size); + const auto omits_report_number = profile_.output_report_size > 0U && + report.size() + 1U == profile_.output_report_size; + if ( + report_number.has_value() && + (omits_report_number || report.empty() || report.front() != *report_number) + ) { + report.insert(report.begin(), *report_number); + } for (const auto &output : reports::parse_output_reports(profile_, report)) { callback(output); } @@ -3118,7 +3168,7 @@ namespace lvh::detail { return {OperationStatus::success(), std::move(gamepad)}; } - const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; } diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 8330a9a..f09f58e 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -610,11 +610,12 @@ namespace lvh::detail::test { LinuxInputSubmissionResult linux_uinput_gamepad_submit_pipe(GamepadProfileKind kind, const GamepadState &state); /** - * @brief Exercise Xbox Series uinput force-feedback upload and playback. + * @brief Exercise uinput gamepad force-feedback upload and playback. * + * @param kind Gamepad profile kind. * @return Creation, callback, and close results. */ - LinuxUinputRumbleResult linux_uinput_xbox_series_fake_rumble(); + LinuxUinputRumbleResult linux_uinput_gamepad_fake_rumble(GamepadProfileKind kind); /** * @brief Try creating a libevdev uinput mouse on an invalid file descriptor. diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 2295905..ae5b3e3 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -1038,7 +1038,7 @@ namespace lvh::detail::test { return {std::move(status), std::move(records)}; } - LinuxUinputRumbleResult linux_uinput_xbox_series_fake_rumble() { + LinuxUinputRumbleResult linux_uinput_gamepad_fake_rumble(GamepadProfileKind kind) { LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); syscalls.override_poll = true; @@ -1072,7 +1072,7 @@ namespace lvh::detail::test { std::atomic_size_t callback_count = 0; std::mutex output_mutex; GamepadOutput last_output; - UinputGamepad gamepad {fd, GamepadProfileKind::xbox_series}; + UinputGamepad gamepad {fd, kind}; gamepad.set_output_callback([&callback_count, &last_output, &output_mutex](const GamepadOutput &output) { if (output.low_frequency_rumble != 0 || output.high_frequency_rumble != 0) { std::lock_guard lock {output_mutex}; @@ -1082,7 +1082,14 @@ namespace lvh::detail::test { }); CreateGamepadOptions options; - options.profile = profiles::xbox_series(); + const auto profile = profiles::gamepad_profile(kind); + if (!profile.has_value()) { + result.create_status = OperationStatus::failure(ErrorCode::unsupported_profile, "unknown gamepad profile"); + result.close_status = result.create_status; + static_cast(system_close(fd)); + return result; + } + options.profile = *profile; result.create_status = gamepad.create(8, options); for (auto attempt = 0; attempt < 100 && callback_count.load() == 0; ++attempt) { std::this_thread::sleep_for(std::chrono::milliseconds {1}); @@ -1364,6 +1371,8 @@ namespace lvh::detail::test { event = {}; event.type = UHID_SET_REPORT; event.u.set_report.id = 10; + event.u.set_report.rnum = profile.report_id; + event.u.set_report.rtype = UHID_OUTPUT_REPORT; event.u.set_report.size = static_cast<__u16>(profile.output_report_size); event.u.set_report.data[0] = profile.report_id; event.u.set_report.data[1] = 0x78; @@ -1419,6 +1428,23 @@ namespace lvh::detail::test { event.u.create2.product == options.profile.product_id; } + gamepad.set_output_callback([&result](const GamepadOutput &output) { + if (output.kind == GamepadOutputKind::rumble) { + ++result.output_callback_count; + result.last_output = output; + } + }); + + event = {}; + event.type = UHID_OUTPUT; + event.u.output.rtype = UHID_OUTPUT_REPORT; + event.u.output.size = 63; + event.u.output.data[0] = 0x02; + event.u.output.data[1] = 0x03; + event.u.output.data[3] = 0x12; + event.u.output.data[4] = 0x56; + static_cast(write_uhid_event(descriptors[1], event)); + event = {}; event.type = UHID_GET_REPORT; event.u.get_report.id = 11; @@ -1549,6 +1575,27 @@ namespace lvh::detail::test { event.u.create2.product == options.profile.product_id; } + gamepad.set_output_callback([&result](const GamepadOutput &output) { + if (output.kind == GamepadOutputKind::rumble) { + ++result.output_callback_count; + result.last_output = output; + } + }); + + event = {}; + event.type = UHID_SET_REPORT; + event.u.set_report.id = 21; + event.u.set_report.rnum = 0x05; + event.u.set_report.rtype = UHID_OUTPUT_REPORT; + event.u.set_report.size = 31; + event.u.set_report.data[0] = 0x05; + event.u.set_report.data[3] = 0x12; + event.u.set_report.data[4] = 0x56; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event_type(descriptors[1], UHID_SET_REPORT_REPLY, event)) { + result.saw_set_report_reply = event.u.set_report_reply.id == 21 && event.u.set_report_reply.err == 0; + } + event = {}; event.type = UHID_GET_REPORT; event.u.get_report.id = 16; diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp index 72d03d8..b94d1fe 100644 --- a/tests/unit/test_gamepad_adapter.cpp +++ b/tests/unit/test_gamepad_adapter.cpp @@ -22,7 +22,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { const auto keyboard = lvh::profiles::keyboard(); const auto generic_support = lvh::gamepad_profile_support(generic); - EXPECT_FALSE(generic_support.supports_rumble); + EXPECT_TRUE(generic_support.supports_rumble); EXPECT_FALSE(generic_support.supports_touchpad); EXPECT_TRUE(generic_support.supports_misc1_button); EXPECT_FALSE(generic_support.supports_trigger_rumble); @@ -51,7 +51,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_EQ(dualsense_support.supported_rear_paddle_count, 0U); const auto switch_pro_support = lvh::gamepad_profile_support(switch_pro); - EXPECT_FALSE(switch_pro_support.supports_rumble); + EXPECT_TRUE(switch_pro_support.supports_rumble); EXPECT_TRUE(switch_pro_support.supports_motion); EXPECT_TRUE(switch_pro_support.supports_battery); EXPECT_TRUE(switch_pro_support.supports_misc1_button); @@ -96,9 +96,9 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) { EXPECT_FALSE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::trigger_rumble)); EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::raw_report)); EXPECT_TRUE(lvh::supports_gamepad_output(dualsense, lvh::GamepadOutputKind::adaptive_triggers)); - EXPECT_FALSE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rumble)); + EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rumble)); EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::raw_report)); - EXPECT_FALSE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report)); + EXPECT_TRUE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report)); EXPECT_FALSE(lvh::supports_gamepad_output(keyboard, lvh::GamepadOutputKind::rumble)); EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast(255))); } diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 40ef2b4..f9455c5 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -327,25 +327,26 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { struct ButtonCase { lvh::GamepadButton button; std::uint16_t linux_code; + std::uint16_t switch_code; }; constexpr std::array button_cases { - ButtonCase {a, BTN_SOUTH}, - ButtonCase {b, BTN_EAST}, - ButtonCase {x, BTN_NORTH}, - ButtonCase {y, BTN_WEST}, - ButtonCase {left_shoulder, BTN_TL}, - ButtonCase {right_shoulder, BTN_TR}, - ButtonCase {back, BTN_SELECT}, - ButtonCase {start, BTN_START}, - ButtonCase {guide, BTN_MODE}, - ButtonCase {left_stick, BTN_THUMBL}, - ButtonCase {right_stick, BTN_THUMBR}, + ButtonCase {a, BTN_SOUTH, BTN_EAST}, + ButtonCase {b, BTN_EAST, BTN_SOUTH}, + ButtonCase {x, BTN_NORTH, BTN_NORTH}, + ButtonCase {y, BTN_WEST, BTN_WEST}, + ButtonCase {left_shoulder, BTN_TL, BTN_TL}, + ButtonCase {right_shoulder, BTN_TR, BTN_TR}, + ButtonCase {back, BTN_SELECT, BTN_SELECT}, + ButtonCase {start, BTN_START, BTN_START}, + ButtonCase {guide, BTN_MODE, BTN_MODE}, + ButtonCase {left_stick, BTN_THUMBL, BTN_THUMBL}, + ButtonCase {right_stick, BTN_THUMBR, BTN_THUMBR}, }; - constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series}; + constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series, switch_pro}; for (const auto kind : profile_kinds) { - for (const auto &[button, linux_code] : button_cases) { + for (const auto &[button, linux_code, switch_code] : button_cases) { lvh::GamepadState state; state.buttons.set(button); const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); @@ -355,7 +356,7 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { ASSERT_EQ(pressed_codes.size(), 1U) << "profile " << static_cast(std::to_underlying(kind)) << " logical button " << static_cast(std::to_underlying(button)); - EXPECT_EQ(pressed_codes.front(), linux_code) + EXPECT_EQ(pressed_codes.front(), kind == switch_pro ? switch_code : linux_code) << "profile " << static_cast(std::to_underlying(kind)) << " logical button " << static_cast(std::to_underlying(button)); } @@ -372,6 +373,17 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { EXPECT_NE(pressed, result.events.end()); } + { + lvh::GamepadState state; + state.buttons.set(misc1); + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(switch_pro, state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + const auto pressed = std::ranges::find_if(result.events, [](const auto &event) { + return event.type == EV_KEY && event.code == BTN_Z && event.value == 1; + }); + EXPECT_NE(pressed, result.events.end()); + } + for (const auto kind : profile_kinds) { lvh::GamepadState state; state.buttons.set(dpad_up); @@ -398,50 +410,38 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { EXPECT_EQ(event_value(EV_ABS, ABS_Y), lvh::reports::normalize_axis(-0.25F)); EXPECT_EQ(event_value(EV_ABS, ABS_RX), lvh::reports::normalize_axis(0.75F)); EXPECT_EQ(event_value(EV_ABS, ABS_RY), lvh::reports::normalize_axis(1.0F)); - EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F)); - EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F)); - - if (kind == generic) { - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), 1); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), 0); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), 0); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_RIGHT), 1); + if (kind == switch_pro) { + EXPECT_EQ(event_value(EV_ABS, ABS_Z), std::nullopt); + EXPECT_EQ(event_value(EV_ABS, ABS_RZ), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_TL2), 1); + EXPECT_EQ(event_value(EV_KEY, BTN_TR2), 1); } else { - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), std::nullopt); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), std::nullopt); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), std::nullopt); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_RIGHT), std::nullopt); + EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F)); + EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F)); } - } - - constexpr std::array dpad_cases { - std::pair {dpad_up, BTN_DPAD_UP}, - std::pair {dpad_down, BTN_DPAD_DOWN}, - std::pair {dpad_left, BTN_DPAD_LEFT}, - std::pair {dpad_right, BTN_DPAD_RIGHT}, - }; - for (const auto kind : {generic}) { - for (const auto &[logical_button, linux_code] : dpad_cases) { - lvh::GamepadState state; - state.buttons.set(logical_button); - const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); - ASSERT_TRUE(result.status.ok()) << result.status.message(); - const auto pressed_codes = pressed_key_codes(result.events); - ASSERT_EQ(pressed_codes.size(), 1U); - EXPECT_EQ(pressed_codes.front(), linux_code); + for (const auto dpad_button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { + EXPECT_EQ(event_value(EV_KEY, dpad_button), std::nullopt); } } } -TEST_F(LinuxBackendTest, XboxSeriesUinputNormalizesForceFeedback) { - const auto result = lvh::detail::test::linux_uinput_xbox_series_fake_rumble(); - ASSERT_TRUE(result.create_status.ok()) << result.create_status.message(); - EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); - ASSERT_GE(result.callback_count, 1U); - EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); - EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); - EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); +TEST_F(LinuxBackendTest, UinputGamepadsNormalizeForceFeedback) { + for (const auto kind : { + lvh::GamepadProfileKind::generic, + lvh::GamepadProfileKind::xbox_360, + lvh::GamepadProfileKind::xbox_one, + lvh::GamepadProfileKind::xbox_series, + lvh::GamepadProfileKind::switch_pro, + }) { + const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(kind); + ASSERT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + ASSERT_GE(result.callback_count, 1U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); + } } TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { @@ -691,6 +691,10 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) { EXPECT_TRUE(result.saw_dualsense_calibration); EXPECT_TRUE(result.saw_dualsense_pairing); EXPECT_TRUE(result.saw_dualsense_firmware); + ASSERT_GE(result.output_callback_count, 1U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5656); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1212); } TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) { @@ -711,6 +715,11 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) { EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); EXPECT_TRUE(result.saw_dualshock4_firmware); + EXPECT_TRUE(result.saw_set_report_reply); + ASSERT_GE(result.output_callback_count, 1U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5656); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1212); } TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) { @@ -833,16 +842,17 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc lvh::GamepadProfileKind kind; std::uint16_t bustype; std::uint16_t product_id; - bool misc1; + bool key_record; bool sparse_button_slots; - bool dpad_buttons; + bool switch_controls; }; constexpr std::array gamepad_cases { - GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, true}, + GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, false}, GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_BLUETOOTH, 0x028E, false, true, false}, GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, + GamepadCase {lvh::GamepadProfileKind::switch_pro, BUS_USB, 0x2009, false, false, true}, }; constexpr std::array active_buttons { BTN_SOUTH, @@ -860,7 +870,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; constexpr std::array dpad_buttons {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}; - for (const auto &[kind, bustype, product_id, misc1, sparse_button_slots, has_dpad_buttons] : gamepad_cases) { + for (const auto &[kind, bustype, product_id, key_record, sparse_button_slots, switch_controls] : gamepad_cases) { const auto expected_profile = lvh::profiles::gamepad_profile(kind); ASSERT_TRUE(expected_profile.has_value()); const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind); @@ -877,18 +887,26 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing canonical gamepad button " << button; } for (const auto button : reserved_buttons) { - EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, sparse_button_slots) + const auto expected = sparse_button_slots || (switch_controls && button != BTN_C); + EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, expected) << "unexpected reserved gamepad button slot state for " << button; } for (const auto button : dpad_buttons) { - EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, has_dpad_buttons) + EXPECT_EQ(find_code(gamepad, EV_KEY, button), nullptr) << "unexpected directional gamepad button state for " << button; } - EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, misc1); + EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, key_record); const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); - ASSERT_NE(left_trigger, nullptr); - EXPECT_EQ(left_trigger->minimum, 0); - EXPECT_EQ(left_trigger->maximum, 255); + if (switch_controls) { + EXPECT_EQ(left_trigger, nullptr); + EXPECT_NE(find_code(gamepad, EV_KEY, BTN_TL2), nullptr); + EXPECT_NE(find_code(gamepad, EV_KEY, BTN_TR2), nullptr); + EXPECT_NE(find_code(gamepad, EV_KEY, BTN_Z), nullptr); + } else { + ASSERT_NE(left_trigger, nullptr); + EXPECT_EQ(left_trigger->minimum, 0); + EXPECT_EQ(left_trigger->maximum, 255); + } EXPECT_EQ(find_code(gamepad, EV_FF, FF_RUMBLE) != nullptr, expected_profile->capabilities.supports_rumble); } diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 8b620da..1097050 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -197,7 +197,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(switch_pro.report_id, 0x30); EXPECT_EQ(switch_pro.input_report_size, 64U); EXPECT_EQ(switch_pro.output_report_size, 64U); - EXPECT_FALSE(switch_pro.capabilities.supports_rumble); + EXPECT_TRUE(switch_pro.capabilities.supports_rumble); EXPECT_TRUE(switch_pro.capabilities.supports_motion); EXPECT_TRUE(switch_pro.capabilities.supports_battery); @@ -344,8 +344,8 @@ TEST(ProfileTest, RumbleProfilesExposeOutputReports) { const auto generic = lvh::profiles::generic_gamepad(); const auto xbox_360 = lvh::profiles::xbox_360(); - EXPECT_FALSE(generic.capabilities.supports_rumble); - EXPECT_EQ(generic.output_report_size, 0U); + EXPECT_TRUE(generic.capabilities.supports_rumble); + EXPECT_EQ(generic.output_report_size, 5U); EXPECT_TRUE(xbox_360.capabilities.supports_rumble); EXPECT_EQ(xbox_360.output_report_size, 5U); diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index a93d749..3f4c113 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -476,8 +476,21 @@ TEST(ReportTest, KeepsUnrecognizedOutputReportsRaw) { const auto generic_output = lvh::reports::parse_output_report(generic_profile, generic_report); - EXPECT_EQ(generic_output.kind, lvh::GamepadOutputKind::raw_report); - EXPECT_EQ(generic_output.low_frequency_rumble, 0U); - EXPECT_EQ(generic_output.high_frequency_rumble, 0U); + EXPECT_EQ(generic_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(generic_output.low_frequency_rumble, 0x1234); + EXPECT_EQ(generic_output.high_frequency_rumble, 0xABCD); EXPECT_EQ(generic_output.raw_report, generic_report); + + const auto switch_profile = lvh::profiles::switch_pro(); + std::vector switch_report(switch_profile.output_report_size, 0); + switch_report[0] = switch_profile.report_id; + switch_report[1] = 0x34; + switch_report[2] = 0x12; + switch_report[3] = 0xCD; + switch_report[4] = 0xAB; + + const auto switch_output = lvh::reports::parse_output_report(switch_profile, switch_report); + + EXPECT_EQ(switch_output.kind, lvh::GamepadOutputKind::raw_report); + EXPECT_EQ(switch_output.raw_report, switch_report); } diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp index 9f47387..935dc9c 100644 --- a/tests/unit/test_virtualhid_control_model.cpp +++ b/tests/unit/test_virtualhid_control_model.cpp @@ -127,12 +127,12 @@ TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) { const auto generic = lvh::profiles::generic_gamepad(); const auto dualsense = lvh::profiles::dualsense(); - EXPECT_FALSE(control::supports_normalized_feedback(generic)); + EXPECT_TRUE(control::supports_normalized_feedback(generic)); EXPECT_TRUE(control::supports_normalized_feedback(dualsense)); EXPECT_EQ( control::profile_feature_summary(generic), - L"Features: battery no | rumble no | trigger rumble no | RGB LED no | adaptive triggers no | raw output no" + L"Features: battery no | rumble yes | trigger rumble no | RGB LED no | adaptive triggers no | raw output yes" ); EXPECT_EQ( control::profile_feature_summary(dualsense), @@ -170,7 +170,7 @@ TEST(VirtualHidControlModelTest, SummarizesOutputState) { control::OutputState state; EXPECT_EQ( control::output_summary(state, generic), - L"Output: no reports received | profile has no normalized feedback categories" + L"Output: no reports received" ); EXPECT_EQ(control::output_summary(state, dualsense), L"Output: no reports received"); From 297389eae1db1230f09ee270561fce1f1151898e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:52:20 -0400 Subject: [PATCH 10/12] Refine Linux gamepad mappings and names Update built-in profiles to use clearer default device names and document those names in the usage guide. On Linux, adjust uinput gamepad behavior so the generic profile uses BTN_DPAD_* instead of hat axes, expand supported force-feedback effect normalization beyond FF_RUMBLE, and stop re-inserting UHID report numbers into output reports. Tests were extended to cover the new profile names, D-pad capabilities, Switch Pro SDL behavior, and additional rumble effect types. --- docs/platform-support.md | 21 +- docs/usage.md | 24 ++- src/core/profiles.cpp | 16 +- src/platform/linux/uhid_backend.cpp | 190 ++++++++++++++---- .../fixtures/linux_backend_test_hooks.hpp | 6 +- tests/fixtures/linux_backend_test_hooks.cpp | 35 +++- tests/unit/test_linux_backend.cpp | 64 ++++-- tests/unit/test_linux_consumers.cpp | 45 +++++ tests/unit/test_profiles.cpp | 18 +- 9 files changed, 313 insertions(+), 106 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 02e2877..397e38b 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -58,16 +58,17 @@ feature reports, and output reports matter for controller compatibility. Generic, Xbox-family, and Switch Pro profiles instead use `uinput` so SDL, Steam, browser Gamepad API implementations, and other evdev consumers receive canonical Linux gamepad events. Face buttons, shoulders, menu buttons, stick -clicks, and Guide use their native evdev codes; sticks and the directional pad -use absolute axes. Generic and Xbox triggers remain independent analog `ABS_Z` -and `ABS_RZ` axes. Switch Pro uses the Nintendo face-button positions, button -events for ZL/ZR, and `BTN_Z` for Capture. The directional pad is exposed only -through `ABS_HAT0X` and `ABS_HAT0Y`, avoiding the ambiguous duplicate -`BTN_DPAD_*` representation used by some consumers. Profiles with rumble -support normalize uinput force-feedback effects back into the public callback. - -Xbox 360 retains its name and `0x045E:0x028E` identity, while its Linux uinput -device uses the Bluetooth bus so consumers select the sparse button mapping. +clicks, and Guide use their native evdev codes; sticks use absolute axes. +Generic exposes its directional pad only through `BTN_DPAD_*`, while Xbox and +Switch Pro use `ABS_HAT0X` and `ABS_HAT0Y`; each profile therefore has one +unambiguous D-pad representation. Generic and Xbox triggers remain independent +analog `ABS_Z` and `ABS_RZ` axes. Switch Pro uses the Nintendo face-button +positions, button events for ZL/ZR, and `BTN_Z` for Capture. Profiles with +rumble support normalize rumble, constant, periodic, and ramp uinput +force-feedback effects back into the public callback. + +Xbox 360 retains its `0x045E:0x028E` identity, while its Linux uinput device uses +the Bluetooth bus so consumers select the sparse button mapping. Xbox One and Xbox Series retain their public USB identities, but their Linux uinput devices use the corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, respectively), whose standard consumer mappings match the events diff --git a/docs/usage.md b/docs/usage.md index bf99d3c..1c3a84d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -164,15 +164,21 @@ touch, motion, battery, feedback, and lifecycle updates onto the platform-neutra ## Built-In Profiles -Built-in gamepad profiles include: - -- Generic HID gamepad. -- Xbox 360. -- Xbox One. -- Xbox Series. -- DualShock 4 USB and Bluetooth. -- DualSense USB and Bluetooth. -- Nintendo Switch Pro. +Built-in gamepad profiles and their platform-neutral default device names are: + +| Profile | Default device name | +| --- | --- | +| Generic HID gamepad | `(libvirtualhid) Generic Controller` | +| Xbox 360 | `(libvirtualhid) X-Box 360 Controller` | +| Xbox One | `(libvirtualhid) X-Box One Controller` | +| Xbox Series | `(libvirtualhid) X-Box Series Controller` | +| DualShock 4 USB and Bluetooth | `(libvirtualhid) PS4 Controller` | +| DualSense USB and Bluetooth | `(libvirtualhid) PS5 Controller` | +| Nintendo Switch Pro | `(libvirtualhid) Nintendo Pro Controller` | + +Consumers may replace `DeviceProfile::name` before creating a gamepad, for +example to prepend an application name while preserving the default controller +identity across platform backends. Profiles advertise support for features such as rumble, trigger rumble, RGB LEDs, adaptive triggers, motion sensors, touchpads, battery state, profile diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index 96d2e57..a625e84 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -1920,7 +1920,7 @@ namespace lvh::profiles { bus_type == BusType::bluetooth ? dualshock4_bluetooth_input_report_size : dualshock4_usb_input_report_size; profile.output_report_size = bus_type == BusType::bluetooth ? dualshock4_bluetooth_output_report_size : dualshock4_usb_output_report_size; - profile.name = "Wireless Controller"; + profile.name = "(libvirtualhid) PS4 Controller"; profile.manufacturer = "Sony Computer Entertainment"; profile.capabilities = { .supports_rumble = true, @@ -1947,7 +1947,7 @@ namespace lvh::profiles { bus_type == BusType::bluetooth ? dualsense_bluetooth_input_report_size : dualsense_usb_input_report_size; profile.output_report_size = bus_type == BusType::bluetooth ? dualsense_bluetooth_output_report_size : dualsense_usb_output_report_size; - profile.name = "Wireless Controller"; + profile.name = "(libvirtualhid) PS5 Controller"; profile.manufacturer = "Sony Interactive Entertainment"; profile.capabilities = { .supports_rumble = true, @@ -1973,7 +1973,7 @@ namespace lvh::profiles { profile.report_id = switch_pro_report_id; profile.input_report_size = switch_pro_input_report_size; profile.output_report_size = switch_pro_output_report_size; - profile.name = "Pro Controller"; + profile.name = "(libvirtualhid) Nintendo Pro Controller"; profile.manufacturer = "Nintendo Co., Ltd."; profile.capabilities = {.supports_rumble = true, .supports_motion = true, .supports_battery = true}; profile.report_descriptor = make_switch_pro_report_descriptor(); @@ -1997,7 +1997,7 @@ namespace lvh::profiles { DeviceProfile generic_gamepad() { return make_standard_gamepad_profile( GamepadProfileKind::generic, - "libvirtualhid Generic Gamepad", + "(libvirtualhid) Generic Controller", "LizardByte", 0x1209, 0x0001, @@ -2009,7 +2009,7 @@ namespace lvh::profiles { DeviceProfile xbox_360() { return make_gamepad_profile( GamepadProfileKind::xbox_360, - "Microsoft X-Box 360 pad", + "(libvirtualhid) X-Box 360 Controller", "Microsoft", 0x045E, 0x028E, @@ -2021,7 +2021,7 @@ namespace lvh::profiles { DeviceProfile xbox_one() { return make_xbox_gip_profile( GamepadProfileKind::xbox_one, - "Xbox One Controller", + "(libvirtualhid) X-Box One Controller", 0x02EA, 0x0408, false @@ -2031,7 +2031,7 @@ namespace lvh::profiles { DeviceProfile xbox_series() { return make_xbox_gip_profile( GamepadProfileKind::xbox_series, - "Xbox Controller", + "(libvirtualhid) X-Box Series Controller", 0x0B12, 0x0500, true @@ -2060,7 +2060,7 @@ namespace lvh::profiles { DeviceProfile dualsense_bluetooth() { auto profile = make_dualsense_profile(BusType::bluetooth); - profile.name = "Wireless Controller"; + profile.name = "(libvirtualhid) PS5 Controller"; return profile; } diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 74da3b4..a7826b4 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -541,6 +541,10 @@ namespace lvh::detail { return kind == generic || kind == xbox_360 || kind == xbox_one || kind == xbox_series; } + bool uses_uinput_dpad_buttons(GamepadProfileKind kind) { + return kind == GamepadProfileKind::generic; + } + std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -1386,14 +1390,23 @@ namespace lvh::detail { return status; } } + if (uses_uinput_dpad_buttons(profile_kind)) { + for (const auto button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad directional button"); !status.ok()) { + return status; + } + } + } return OperationStatus::success(); } OperationStatus configure_evdev_gamepad_axes(libevdev *device, GamepadProfileKind profile_kind) { - auto dpad = make_absinfo(-1, 1); - for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { - if (const auto status = enable_evdev_code(device, EV_ABS, code, "gamepad directional axis", &dpad); !status.ok()) { - return status; + if (!uses_uinput_dpad_buttons(profile_kind)) { + auto dpad = make_absinfo(-1, 1); + for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "gamepad directional axis", &dpad); !status.ok()) { + return status; + } } } @@ -1421,8 +1434,11 @@ namespace lvh::detail { if (!supports_rumble) { return OperationStatus::success(); } - if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "gamepad force-feedback effect"); !status.ok()) { - return status; + constexpr std::array effect_codes {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_SINE, FF_RAMP}; + for (const auto code : effect_codes) { + if (const auto status = enable_evdev_code(device, EV_FF, code, "gamepad force-feedback effect"); !status.ok()) { + return status; + } } return enable_evdev_code(device, EV_FF, FF_GAIN, "gamepad force-feedback gain"); } @@ -2417,8 +2433,11 @@ namespace lvh::detail { #endif struct UinputRumbleEffect { - std::uint16_t weak_magnitude = 0; - std::uint16_t strong_magnitude = 0; + std::uint16_t start_weak_magnitude = 0; + std::uint16_t start_strong_magnitude = 0; + std::uint16_t end_weak_magnitude = 0; + std::uint16_t end_strong_magnitude = 0; + ff_envelope envelope {}; std::chrono::milliseconds length {0}; std::chrono::milliseconds delay {0}; std::chrono::steady_clock::time_point start {}; @@ -2546,17 +2565,28 @@ namespace lvh::detail { return status; } } + if (uses_uinput_dpad_buttons(profile_kind_)) { + constexpr std::array dpad_button_map { + std::pair {dpad_up, BTN_DPAD_UP}, + std::pair {dpad_down, BTN_DPAD_DOWN}, + std::pair {dpad_left, BTN_DPAD_LEFT}, + std::pair {dpad_right, BTN_DPAD_RIGHT}, + }; + return emit_button_map(buttons, dpad_button_map); + } return OperationStatus::success(); } OperationStatus emit_axis_state(const GamepadState &state) { using enum GamepadButton; - if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(state.buttons, dpad_left, dpad_right)); !status.ok()) { - return status; - } - if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(state.buttons, dpad_up, dpad_down)); !status.ok()) { - return status; + if (!uses_uinput_dpad_buttons(profile_kind_)) { + if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(state.buttons, dpad_left, dpad_right)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(state.buttons, dpad_up, dpad_down)); !status.ok()) { + return status; + } } const std::array stick_axes { @@ -2679,19 +2709,13 @@ namespace lvh::detail { return; } - if (upload.effect.type == FF_RUMBLE) { - auto effect = UinputRumbleEffect { - .weak_magnitude = upload.effect.u.rumble.weak_magnitude, - .strong_magnitude = upload.effect.u.rumble.strong_magnitude, - .length = std::chrono::milliseconds {upload.effect.replay.length}, - .delay = std::chrono::milliseconds {upload.effect.replay.delay}, - }; + if (auto effect = normalize_rumble_effect(upload.effect); effect.has_value()) { if (const auto existing = rumble_effects_.find(upload.effect.id); existing != rumble_effects_.end()) { - effect.start = existing->second.start; - effect.end = existing->second.end; - effect.active = existing->second.active; + effect->start = existing->second.start; + effect->end = existing->second.end; + effect->active = existing->second.active; } - rumble_effects_.insert_or_assign(upload.effect.id, effect); + rumble_effects_.insert_or_assign(upload.effect.id, *effect); } upload.retval = 0; @@ -2723,8 +2747,9 @@ namespace lvh::detail { effect.active = false; continue; } - weak += effect.weak_magnitude; - strong += effect.strong_magnitude; + const auto [effect_weak, effect_strong] = rumble_magnitudes(effect, now); + weak += effect_weak; + strong += effect_strong; } constexpr auto maximum = std::numeric_limits::max(); @@ -2733,6 +2758,99 @@ namespace lvh::detail { dispatch_rumble(scaled_strong, scaled_weak); } + static std::uint16_t scale_signed_magnitude(std::int16_t value) { + const auto magnitude = static_cast(std::abs(static_cast(value))); + return static_cast(std::min(magnitude * 2U, std::numeric_limits::max())); + } + + static std::optional normalize_rumble_effect(const ff_effect &source) { + auto effect = UinputRumbleEffect { + .length = std::chrono::milliseconds {source.replay.length}, + .delay = std::chrono::milliseconds {source.replay.delay}, + }; + + switch (source.type) { + case FF_RUMBLE: + effect.start_weak_magnitude = source.u.rumble.weak_magnitude; + effect.start_strong_magnitude = source.u.rumble.strong_magnitude; + effect.end_weak_magnitude = effect.start_weak_magnitude; + effect.end_strong_magnitude = effect.start_strong_magnitude; + break; + case FF_CONSTANT: + { + const auto magnitude = scale_signed_magnitude(source.u.constant.level); + effect.start_weak_magnitude = magnitude; + effect.start_strong_magnitude = magnitude; + effect.end_weak_magnitude = magnitude; + effect.end_strong_magnitude = magnitude; + effect.envelope = source.u.constant.envelope; + break; + } + case FF_PERIODIC: + { + const auto magnitude = scale_signed_magnitude(source.u.periodic.magnitude); + effect.start_weak_magnitude = magnitude; + effect.start_strong_magnitude = magnitude; + effect.end_weak_magnitude = magnitude; + effect.end_strong_magnitude = magnitude; + effect.envelope = source.u.periodic.envelope; + break; + } + case FF_RAMP: + effect.start_weak_magnitude = scale_signed_magnitude(source.u.ramp.start_level); + effect.start_strong_magnitude = effect.start_weak_magnitude; + effect.end_weak_magnitude = scale_signed_magnitude(source.u.ramp.end_level); + effect.end_strong_magnitude = effect.end_weak_magnitude; + effect.envelope = source.u.ramp.envelope; + break; + default: + return std::nullopt; + } + + return effect; + } + + static std::uint16_t interpolate_magnitude(std::uint16_t start, std::uint16_t end, std::uint64_t elapsed, std::uint64_t length) { + if (length == 0U || elapsed >= length) { + return end; + } + const auto weighted = static_cast(start) * (length - elapsed) + static_cast(end) * elapsed; + return static_cast(weighted / length); + } + + static std::uint16_t apply_envelope( + std::uint16_t magnitude, + const ff_envelope &envelope, + std::uint64_t elapsed, + std::uint64_t remaining + ) { + if (envelope.attack_length > 0U && elapsed < envelope.attack_length) { + return interpolate_magnitude(envelope.attack_level, magnitude, elapsed, envelope.attack_length); + } + if (envelope.fade_length > 0U && remaining < envelope.fade_length) { + return interpolate_magnitude(envelope.fade_level, magnitude, remaining, envelope.fade_length); + } + return magnitude; + } + + static std::pair rumble_magnitudes( + const UinputRumbleEffect &effect, + std::chrono::steady_clock::time_point now + ) { + const auto elapsed = static_cast(std::max( + std::chrono::duration_cast(now - effect.start).count(), + 0 + )); + const auto length = static_cast(std::max(effect.length.count(), 0)); + const auto remaining = elapsed >= length ? 0U : length - elapsed; + const auto weak = interpolate_magnitude(effect.start_weak_magnitude, effect.end_weak_magnitude, elapsed, length); + const auto strong = interpolate_magnitude(effect.start_strong_magnitude, effect.end_strong_magnitude, elapsed, length); + return { + apply_envelope(weak, effect.envelope, elapsed, remaining), + apply_envelope(strong, effect.envelope, elapsed, remaining), + }; + } + void dispatch_rumble(std::uint16_t low_frequency, std::uint16_t high_frequency) { if (last_low_frequency_ == low_frequency && last_high_frequency_ == high_frequency) { return; @@ -2970,11 +3088,7 @@ namespace lvh::detail { send_get_report_reply(event.u.get_report.id, event.u.get_report.rnum); break; case UHID_SET_REPORT: - dispatch_output_report( - event.u.set_report.data, - event.u.set_report.size, - event.u.set_report.rnum - ); + dispatch_output_report(event.u.set_report.data, event.u.set_report.size); send_set_report_reply(event.u.set_report.id, 0); break; default: @@ -3000,11 +3114,7 @@ namespace lvh::detail { } } - void dispatch_output_report( - const __u8 *data, - std::size_t report_size, - std::optional report_number = std::nullopt - ) { + void dispatch_output_report(const __u8 *data, std::size_t report_size) { OutputCallback callback; { std::lock_guard lock {callback_mutex_}; @@ -3017,14 +3127,6 @@ namespace lvh::detail { const auto size = std::min(report_size, UHID_DATA_MAX); std::vector report(data, data + size); - const auto omits_report_number = profile_.output_report_size > 0U && - report.size() + 1U == profile_.output_report_size; - if ( - report_number.has_value() && - (omits_report_number || report.empty() || report.front() != *report_number) - ) { - report.insert(report.begin(), *report_number); - } for (const auto &output : reports::parse_output_reports(profile_, report)) { callback(output); } diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index f09f58e..eba8b88 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -613,9 +613,13 @@ namespace lvh::detail::test { * @brief Exercise uinput gamepad force-feedback upload and playback. * * @param kind Gamepad profile kind. + * @param effect_type Linux force-feedback effect type. * @return Creation, callback, and close results. */ - LinuxUinputRumbleResult linux_uinput_gamepad_fake_rumble(GamepadProfileKind kind); + LinuxUinputRumbleResult linux_uinput_gamepad_fake_rumble( + GamepadProfileKind kind, + std::uint16_t effect_type + ); /** * @brief Try creating a libevdev uinput mouse on an invalid file descriptor. diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index ae5b3e3..fa8877b 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -1038,7 +1038,10 @@ namespace lvh::detail::test { return {std::move(status), std::move(records)}; } - LinuxUinputRumbleResult linux_uinput_gamepad_fake_rumble(GamepadProfileKind kind) { + LinuxUinputRumbleResult linux_uinput_gamepad_fake_rumble( + GamepadProfileKind kind, + std::uint16_t effect_type + ) { LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); syscalls.override_poll = true; @@ -1054,10 +1057,27 @@ namespace lvh::detail::test { input_event {.type = EV_FF, .code = 3, .value = 1}, }; syscalls.provide_uploaded_ff_effect = true; - syscalls.uploaded_ff_effect.type = FF_RUMBLE; + syscalls.uploaded_ff_effect.type = effect_type; syscalls.uploaded_ff_effect.id = 3; - syscalls.uploaded_ff_effect.u.rumble.weak_magnitude = 0x1234; - syscalls.uploaded_ff_effect.u.rumble.strong_magnitude = 0x5678; + switch (effect_type) { + case FF_RUMBLE: + syscalls.uploaded_ff_effect.u.rumble.weak_magnitude = 0x1234; + syscalls.uploaded_ff_effect.u.rumble.strong_magnitude = 0x5678; + break; + case FF_CONSTANT: + syscalls.uploaded_ff_effect.u.constant.level = 0x1234; + break; + case FF_PERIODIC: + syscalls.uploaded_ff_effect.u.periodic.waveform = FF_SINE; + syscalls.uploaded_ff_effect.u.periodic.magnitude = 0x1234; + break; + case FF_RAMP: + syscalls.uploaded_ff_effect.u.ramp.start_level = 0x1234; + syscalls.uploaded_ff_effect.u.ramp.end_level = 0x2345; + break; + default: + break; + } syscalls.uploaded_ff_effect.replay.length = 1000; ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; @@ -1587,10 +1607,11 @@ namespace lvh::detail::test { event.u.set_report.id = 21; event.u.set_report.rnum = 0x05; event.u.set_report.rtype = UHID_OUTPUT_REPORT; - event.u.set_report.size = 31; + event.u.set_report.size = 32; event.u.set_report.data[0] = 0x05; - event.u.set_report.data[3] = 0x12; - event.u.set_report.data[4] = 0x56; + event.u.set_report.data[1] = 0x03; + event.u.set_report.data[4] = 0x12; + event.u.set_report.data[5] = 0x56; static_cast(write_uhid_event(descriptors[1], event)); if (read_uhid_event_type(descriptors[1], UHID_SET_REPORT_REPLY, event)) { result.saw_set_report_reply = event.u.set_report_reply.id == 21 && event.u.set_report_reply.err == 0; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index f9455c5..445444b 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -404,8 +404,20 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { } return event->value; }; - EXPECT_EQ(event_value(EV_ABS, ABS_HAT0X), 1); - EXPECT_EQ(event_value(EV_ABS, ABS_HAT0Y), -1); + if (kind == generic) { + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0X), std::nullopt); + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0Y), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), 1); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), 0); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), 0); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_RIGHT), 1); + } else { + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0X), 1); + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0Y), -1); + for (const auto dpad_button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { + EXPECT_EQ(event_value(EV_KEY, dpad_button), std::nullopt); + } + } EXPECT_EQ(event_value(EV_ABS, ABS_X), lvh::reports::normalize_axis(-0.5F)); EXPECT_EQ(event_value(EV_ABS, ABS_Y), lvh::reports::normalize_axis(-0.25F)); EXPECT_EQ(event_value(EV_ABS, ABS_RX), lvh::reports::normalize_axis(0.75F)); @@ -419,10 +431,6 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F)); EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F)); } - - for (const auto dpad_button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { - EXPECT_EQ(event_value(EV_KEY, dpad_button), std::nullopt); - } } } @@ -434,13 +442,20 @@ TEST_F(LinuxBackendTest, UinputGamepadsNormalizeForceFeedback) { lvh::GamepadProfileKind::xbox_series, lvh::GamepadProfileKind::switch_pro, }) { - const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(kind); - ASSERT_TRUE(result.create_status.ok()) << result.create_status.message(); - EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); - ASSERT_GE(result.callback_count, 1U); - EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); - EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); - EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); + for (const auto effect_type : {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_RAMP}) { + const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(kind, effect_type); + ASSERT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + ASSERT_GE(result.callback_count, 1U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + if (effect_type == FF_RUMBLE) { + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); + } else { + EXPECT_GT(result.last_output.low_frequency_rumble, 0); + EXPECT_GT(result.last_output.high_frequency_rumble, 0); + } + } } } @@ -845,14 +860,15 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc bool key_record; bool sparse_button_slots; bool switch_controls; + bool dpad_buttons; }; constexpr std::array gamepad_cases { - GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_BLUETOOTH, 0x028E, false, true, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, - GamepadCase {lvh::GamepadProfileKind::switch_pro, BUS_USB, 0x2009, false, false, true}, + GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, false, true}, + GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_BLUETOOTH, 0x028E, false, true, false, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false, false}, + GamepadCase {lvh::GamepadProfileKind::switch_pro, BUS_USB, 0x2009, false, false, true, false}, }; constexpr std::array active_buttons { BTN_SOUTH, @@ -870,7 +886,9 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; constexpr std::array dpad_buttons {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}; - for (const auto &[kind, bustype, product_id, key_record, sparse_button_slots, switch_controls] : gamepad_cases) { + constexpr std::array feedback_codes {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_SINE, FF_RAMP, FF_GAIN}; + + for (const auto &[kind, bustype, product_id, key_record, sparse_button_slots, switch_controls, dpad_button_capabilities] : gamepad_cases) { const auto expected_profile = lvh::profiles::gamepad_profile(kind); ASSERT_TRUE(expected_profile.has_value()); const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind); @@ -892,9 +910,11 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc << "unexpected reserved gamepad button slot state for " << button; } for (const auto button : dpad_buttons) { - EXPECT_EQ(find_code(gamepad, EV_KEY, button), nullptr) + EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, dpad_button_capabilities) << "unexpected directional gamepad button state for " << button; } + EXPECT_EQ(find_code(gamepad, EV_ABS, ABS_HAT0X) != nullptr, !dpad_button_capabilities); + EXPECT_EQ(find_code(gamepad, EV_ABS, ABS_HAT0Y) != nullptr, !dpad_button_capabilities); EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, key_record); const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); if (switch_controls) { @@ -907,7 +927,9 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_EQ(left_trigger->minimum, 0); EXPECT_EQ(left_trigger->maximum, 255); } - EXPECT_EQ(find_code(gamepad, EV_FF, FF_RUMBLE) != nullptr, expected_profile->capabilities.supports_rumble); + for (const auto code : feedback_codes) { + EXPECT_EQ(find_code(gamepad, EV_FF, code) != nullptr, expected_profile->capabilities.supports_rumble); + } } const auto xbox_360 = lvh::detail::test::linux_uinput_create_fake_gamepad(lvh::GamepadProfileKind::xbox_360); diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index bc85ced..505080e 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -6,6 +6,7 @@ // standard includes #include #include +#include #include #include #include @@ -323,7 +324,9 @@ namespace { SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4", "1"); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", "1"); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5_RUMBLE", "1"); } lvh::GamepadCreationResult create_sdl_gamepad(lvh::Runtime &runtime, const SdlGamepadConsumerCase &test_case) { @@ -381,6 +384,34 @@ namespace { } } + void expect_sdl_rumble_callback(SDL_GameController *controller, lvh::Gamepad &gamepad) { + struct RumbleState { + std::atomic_uint16_t low_frequency {0}; + std::atomic_uint16_t high_frequency {0}; + }; + + const auto rumble = std::make_shared(); + gamepad.set_output_callback([rumble](const lvh::GamepadOutput &output) { + if (output.kind == lvh::GamepadOutputKind::rumble) { + rumble->low_frequency = output.low_frequency_rumble; + rumble->high_frequency = output.high_frequency_rumble; + } + }); + + ASSERT_EQ(SDL_GameControllerRumble(controller, 0x5678, 0x1234, 1000), 0) << SDL_GetError(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while ( + std::chrono::steady_clock::now() < deadline && + rumble->low_frequency.load() == 0 && rumble->high_frequency.load() == 0 + ) { + pump_sdl_events(); + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + } + + EXPECT_GT(rumble->low_frequency.load(), 0); + EXPECT_GT(rumble->high_frequency.load(), 0); + } + void exercise_sdl_playstation_controller( const SdlGamepadConsumerCase &test_case, const lvh::DeviceProfile &expected_profile, @@ -419,6 +450,7 @@ namespace { expect_sdl_playstation_controller_profile(controller.get()); if (test_case.expect_live_input) { EXPECT_TRUE(wait_for_sdl_controller_input(controller.get())) << describe_sdl_controller_state(controller.get()); + expect_sdl_rumble_callback(controller.get(), gamepad); } } @@ -501,6 +533,7 @@ namespace { } EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERLEFT), 0); EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT), 16000); + expect_sdl_rumble_callback(controller.get(), gamepad); } void run_sdl_canonical_gamepad_test(const SdlGamepadConsumerCase &test_case) { @@ -635,6 +668,18 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) { run_sdl_canonical_gamepad_test(test_case); } +TEST_F(LinuxConsumerTest, SdlSeesSwitchProCanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + run_sdl_canonical_gamepad_test({ + .profile = lvh::profiles::switch_pro(), + .name_suffix = "SDL Switch Pro", + .stable_id = "libvirtualhid-sdl-switch-pro-test", + .minimum_buttons = 14, + .minimum_axes = 4, + }); +} + TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) { ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 1097050..a082c91 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -26,6 +26,16 @@ TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { } } +TEST(ProfileTest, BuiltInProfilesUseDefaultDeviceNames) { + EXPECT_EQ(lvh::profiles::generic_gamepad().name, "(libvirtualhid) Generic Controller"); + EXPECT_EQ(lvh::profiles::xbox_360().name, "(libvirtualhid) X-Box 360 Controller"); + EXPECT_EQ(lvh::profiles::xbox_one().name, "(libvirtualhid) X-Box One Controller"); + EXPECT_EQ(lvh::profiles::xbox_series().name, "(libvirtualhid) X-Box Series Controller"); + EXPECT_EQ(lvh::profiles::dualshock4().name, "(libvirtualhid) PS4 Controller"); + EXPECT_EQ(lvh::profiles::dualsense().name, "(libvirtualhid) PS5 Controller"); + EXPECT_EQ(lvh::profiles::switch_pro().name, "(libvirtualhid) Nintendo Pro Controller"); +} + TEST(ProfileTest, StreamingControllerProfilesArePresent) { const auto xbox_one = lvh::profiles::xbox_one(); const auto dualshock4 = lvh::profiles::dualshock4(); @@ -44,7 +54,6 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_series.vendor_id, 0x045E); EXPECT_EQ(xbox_series.product_id, 0x0B12); EXPECT_EQ(xbox_series.bus_type, lvh::BusType::usb); - EXPECT_EQ(xbox_series.name, "Xbox Controller"); EXPECT_EQ(xbox_series.manufacturer, "Microsoft"); EXPECT_EQ(xbox_series.report_id, 0); EXPECT_EQ(xbox_series.input_report_size, 17U); @@ -152,7 +161,6 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(dualshock4.vendor_id, 0x054C); EXPECT_EQ(dualshock4.product_id, 0x05C4); EXPECT_EQ(dualshock4.version, 0x0100); - EXPECT_EQ(dualshock4.name, "Wireless Controller"); EXPECT_EQ(dualshock4.input_report_size, 64U); EXPECT_EQ(dualshock4.output_report_size, 32U); EXPECT_TRUE(dualshock4.capabilities.supports_motion); @@ -165,7 +173,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { const auto dualshock4_bluetooth = lvh::profiles::dualshock4_bluetooth(); EXPECT_EQ(dualshock4_bluetooth.bus_type, lvh::BusType::bluetooth); EXPECT_EQ(dualshock4_bluetooth.version, 0x0100); - EXPECT_EQ(dualshock4_bluetooth.name, "Wireless Controller"); + EXPECT_EQ(dualshock4_bluetooth.name, "(libvirtualhid) PS4 Controller"); EXPECT_EQ(dualshock4_bluetooth.manufacturer, "Sony Computer Entertainment"); EXPECT_EQ(dualshock4_bluetooth.report_id, 0x11); EXPECT_EQ(dualshock4_bluetooth.input_report_size, 78U); @@ -173,7 +181,6 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_NE(dualshock4_bluetooth.report_descriptor, dualshock4.report_descriptor); EXPECT_EQ(dualsense.vendor_id, 0x054C); - EXPECT_EQ(dualsense.name, "Wireless Controller"); EXPECT_TRUE(dualsense.capabilities.supports_motion); EXPECT_TRUE(dualsense.capabilities.supports_touchpad); EXPECT_TRUE(dualsense.capabilities.supports_rgb_led); @@ -184,7 +191,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { const auto dualsense_bluetooth = lvh::profiles::dualsense_bluetooth(); EXPECT_EQ(dualsense_bluetooth.bus_type, lvh::BusType::bluetooth); - EXPECT_EQ(dualsense_bluetooth.name, "Wireless Controller"); + EXPECT_EQ(dualsense_bluetooth.name, "(libvirtualhid) PS5 Controller"); EXPECT_EQ(dualsense_bluetooth.report_id, 0x31); EXPECT_EQ(dualsense_bluetooth.input_report_size, 78U); EXPECT_EQ(dualsense_bluetooth.output_report_size, 78U); @@ -192,7 +199,6 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(switch_pro.vendor_id, 0x057E); EXPECT_EQ(switch_pro.product_id, 0x2009); - EXPECT_EQ(switch_pro.name, "Pro Controller"); EXPECT_EQ(switch_pro.manufacturer, "Nintendo Co., Ltd."); EXPECT_EQ(switch_pro.report_id, 0x30); EXPECT_EQ(switch_pro.input_report_size, 64U); From 324691848397bbc1a0f0e23ee2e6f24a2a823fc0 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:46:45 -0400 Subject: [PATCH 11/12] Unify Linux gamepad D-pad and rumble Move the generic gamepad's D-pad into the platform-neutral button bitmap and expose all Linux uinput gamepads through ABS_HAT0X/ABS_HAT0Y for a single evdev mapping. On the Linux UHID path, normalize SET_REPORT payloads, discover hidraw nodes by UHID metadata, and add a short startup delay before reading feedback so PlayStation and early rumble output stay reliable. Docs and tests were updated to match the new report layout and backend behavior. --- docs/platform-support.md | 23 +-- docs/usage.md | 5 + src/core/profiles.cpp | 97 +++++------ src/core/report.cpp | 20 ++- src/platform/linux/uhid_backend.cpp | 151 +++++++++++++----- .../fixtures/linux_backend_test_hooks.hpp | 16 ++ tests/fixtures/linux_backend_test_hooks.cpp | 34 +++- tests/unit/test_linux_backend.cpp | 69 +++++--- tests/unit/test_linux_consumers.cpp | 110 ++++++++++--- tests/unit/test_profiles.cpp | 34 +--- tests/unit/test_report.cpp | 2 +- 11 files changed, 375 insertions(+), 186 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 397e38b..6c6e10e 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -58,14 +58,16 @@ feature reports, and output reports matter for controller compatibility. Generic, Xbox-family, and Switch Pro profiles instead use `uinput` so SDL, Steam, browser Gamepad API implementations, and other evdev consumers receive canonical Linux gamepad events. Face buttons, shoulders, menu buttons, stick -clicks, and Guide use their native evdev codes; sticks use absolute axes. -Generic exposes its directional pad only through `BTN_DPAD_*`, while Xbox and -Switch Pro use `ABS_HAT0X` and `ABS_HAT0Y`; each profile therefore has one -unambiguous D-pad representation. Generic and Xbox triggers remain independent -analog `ABS_Z` and `ABS_RZ` axes. Switch Pro uses the Nintendo face-button -positions, button events for ZL/ZR, and `BTN_Z` for Capture. Profiles with -rumble support normalize rumble, constant, periodic, and ramp uinput -force-feedback effects back into the public callback. +clicks, and Guide use their native evdev codes; sticks use absolute axes. Every +uinput gamepad exposes its directional pad only through `ABS_HAT0X` and +`ABS_HAT0Y`, giving consumers one unambiguous D-pad representation. Generic and +Xbox triggers remain independent analog `ABS_Z` and `ABS_RZ` axes. Switch Pro +uses the Nintendo face-button positions, button events for ZL/ZR, and `BTN_Z` +for Capture. Profiles with rumble support normalize rumble, constant, periodic, +and ramp uinput force-feedback effects back into the public callback. The Linux +backend lets a new uinput device settle before reading those effects so an early +poll error cannot disable feedback for the device lifetime. PlayStation rumble +is read from native UHID interrupt-channel output reports. Xbox 360 retains its `0x045E:0x028E` identity, while its Linux uinput device uses the Bluetooth bus so consumers select the sparse button mapping. @@ -82,8 +84,9 @@ gamepad consumers. DualShock 4 and DualSense remain on `uhid` so their descriptors, motion, touchpad, battery, feature reports, and profile-specific output reports stay available. The backend accepts PlayStation output through both UHID interrupt -and control channels, including control reports whose report number is supplied -separately from the payload. +and control channels. Numbered control-channel output is normalized before +parsing, whether the kernel includes the report number in the payload or +provides it separately on the UHID event. Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows the evdev layout used by Linux-native virtual-controller implementations and diff --git a/docs/usage.md b/docs/usage.md index 1c3a84d..939c6ed 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -180,6 +180,11 @@ Consumers may replace `DeviceProfile::name` before creating a gamepad, for example to prepend an application name while preserving the default controller identity across platform backends. +The platform-neutral Generic HID descriptor reports the D-pad as buttons 13 +through 16 in the input report. Linux may still route that profile through +`uinput`, where the backend exposes those same logical directions through the +standard `ABS_HAT0X` and `ABS_HAT0Y` axes expected by evdev consumers. + Profiles advertise support for features such as rumble, trigger rumble, RGB LEDs, adaptive triggers, motion sensors, touchpads, battery state, profile specific buttons, and raw output reports. Consumers should query profile and diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index a625e84..0f112ab 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -18,6 +18,8 @@ namespace lvh::profiles { constexpr std::uint8_t common_button_count = 12; + constexpr std::uint8_t standard_button_count = 16; + constexpr std::uint8_t common_axis_count = 6; constexpr std::size_t common_button_bytes = 2; @@ -290,61 +292,48 @@ namespace lvh::profiles { 0x01, // Collection (Application) 0x85, report_id, // Report ID + 0x05, + 0x09, // Usage Page (Button) + 0x19, + 0x01, // Usage Minimum (Button 1) + 0x29, + standard_button_count, // Usage Maximum + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + standard_button_count, // Report Count + 0x81, + 0x02, // Input (Data,Var,Abs) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + common_axis_count, // Report Count + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x09, + 0x33, // Usage (Rx) + 0x09, + 0x34, // Usage (Ry) + 0x09, + 0x32, // Usage (Z) + 0x09, + 0x35, // Usage (Rz) + 0x81, + 0x02, // Input (Data,Var,Abs) }; - append_common_gamepad_buttons(descriptor, true); - descriptor.insert( - descriptor.end(), - { - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x09, - 0x39, // Usage (Hat switch) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x07, // Logical Maximum (7) - 0x35, - 0x00, // Physical Minimum (0) - 0x46, - 0x3B, - 0x01, // Physical Maximum (315) - 0x65, - 0x14, // Unit (Eng Rot:Angular Pos) - 0x75, - 0x04, // Report Size (4) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x42, // Input (Data,Var,Abs,Null) - 0x65, - 0x00, // Unit (None) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - common_axis_count, // Report Count - 0x09, - 0x30, // Usage (X) - 0x09, - 0x31, // Usage (Y) - 0x09, - 0x33, // Usage (Rx) - 0x09, - 0x34, // Usage (Ry) - 0x09, - 0x32, // Usage (Z) - 0x09, - 0x35, // Usage (Rz) - 0x81, - 0x02, // Input (Data,Var,Abs) - } - ); if (supports_rumble) { descriptor.insert( diff --git a/src/core/report.cpp b/src/core/report.cpp index 5366e65..9acc9eb 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -214,6 +214,17 @@ namespace lvh::reports { }; } + constexpr auto standard_dpad_button_map() { + using enum GamepadButton; + + return std::array { + ButtonBit {12U, dpad_up}, + ButtonBit {13U, dpad_down}, + ButtonBit {14U, dpad_left}, + ButtonBit {15U, dpad_right}, + }; + } + constexpr auto xbox_extra_button_map() { using enum GamepadButton; @@ -253,6 +264,13 @@ namespace lvh::reports { ); } + std::uint16_t standard_gamepad_button_bits(const ButtonSet &buttons) { + return static_cast( + common_button_bits(buttons) | + button_bits(standard_dpad_button_map(), buttons) + ); + } + std::uint16_t xbox_gip_button_bits(const ButtonSet &buttons) { return static_cast( button_bits(face_shoulder_button_map(), buttons) | @@ -828,7 +846,7 @@ namespace lvh::reports { std::vector report; report.reserve(standard_report_size); report.push_back(profile.report_id); - append_u16(report, static_cast(common_button_bits(normalized.buttons) | dpad_hat_bits(normalized.buttons))); + append_u16(report, standard_gamepad_button_bits(normalized.buttons)); report.push_back(normalize_u8_axis(normalized.left_stick.x)); report.push_back(normalize_u8_axis(-normalized.left_stick.y)); report.push_back(normalize_u8_axis(normalized.right_stick.x)); diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index a7826b4..ab04ce6 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -78,6 +78,7 @@ namespace lvh::detail { constexpr auto tablet_distance_max = 1024; constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; + constexpr auto uinput_feedback_startup_delay = std::chrono::milliseconds {100}; constexpr auto xbox_trigger_max = 255; // The Bluetooth bus selects the sparse evdev mapping that matches the // button capabilities exposed by these Xbox uinput devices. @@ -541,10 +542,6 @@ namespace lvh::detail { return kind == generic || kind == xbox_360 || kind == xbox_one || kind == xbox_series; } - bool uses_uinput_dpad_buttons(GamepadProfileKind kind) { - return kind == GamepadProfileKind::generic; - } - std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -599,6 +596,16 @@ namespace lvh::detail { nodes.push_back({.kind = kind, .path = path.string()}); } + void append_node_if_missing(std::vector &nodes, DeviceNodeKind kind, const std::filesystem::path &path) { + const auto path_string = path.string(); + const auto existing = std::ranges::find_if(nodes, [kind, &path_string](const DeviceNode &node) { + return node.kind == kind && node.path == path_string; + }); + if (existing == nodes.end()) { + nodes.push_back({.kind = kind, .path = path_string}); + } + } + bool hidraw_name_matches(const std::filesystem::path &uevent_path, std::string_view name) { std::ifstream file {uevent_path}; if (!file) { @@ -616,6 +623,67 @@ namespace lvh::detail { return false; } + bool hidraw_metadata_matches( + const std::filesystem::path &uevent_path, + std::string_view name, + std::string_view physical_id, + std::string_view unique_id + ) { + std::ifstream file {uevent_path}; + if (!file) { + return false; + } + + std::string line; + while (std::getline(file, line)) { + constexpr std::string_view name_key {"HID_NAME="}; + if (!name.empty() && line.starts_with(name_key) && line.size() == name_key.size() + name.size() && line.ends_with(name)) { + return true; + } + + constexpr std::string_view phys_key {"HID_PHYS="}; + if ( + !physical_id.empty() && line.starts_with(phys_key) && + line.size() == phys_key.size() + physical_id.size() && line.ends_with(physical_id) + ) { + return true; + } + + constexpr std::string_view uniq_key {"HID_UNIQ="}; + if (!unique_id.empty() && line.starts_with(uniq_key) && line.size() == uniq_key.size() + unique_id.size() && line.ends_with(unique_id)) { + return true; + } + } + + return false; + } + + std::vector discover_hidraw_nodes_by_metadata( + std::string_view name, + std::string_view physical_id, + std::string_view unique_id, + const std::filesystem::path &hidraw_root = "/sys/class/hidraw" + ) { + using enum DeviceNodeKind; + + std::vector nodes; + std::error_code error; + if (!std::filesystem::exists(hidraw_root, error)) { + return nodes; + } + + for (std::filesystem::directory_iterator it {hidraw_root, error}, end; !error && it != end; it.increment(error)) { + if (!hidraw_metadata_matches(it->path() / "device" / "uevent", name, physical_id, unique_id)) { + continue; + } + + append_node(nodes, hidraw, std::filesystem::path {"/dev"} / it->path().filename()); + append_node(nodes, sysfs, it->path()); + } + + return nodes; + } + std::vector discover_input_nodes_by_name( const std::string &name, const std::filesystem::path &input_root, @@ -1390,23 +1458,14 @@ namespace lvh::detail { return status; } } - if (uses_uinput_dpad_buttons(profile_kind)) { - for (const auto button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { - if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad directional button"); !status.ok()) { - return status; - } - } - } return OperationStatus::success(); } OperationStatus configure_evdev_gamepad_axes(libevdev *device, GamepadProfileKind profile_kind) { - if (!uses_uinput_dpad_buttons(profile_kind)) { - auto dpad = make_absinfo(-1, 1); - for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { - if (const auto status = enable_evdev_code(device, EV_ABS, code, "gamepad directional axis", &dpad); !status.ok()) { - return status; - } + auto dpad = make_absinfo(-1, 1); + for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "gamepad directional axis", &dpad); !status.ok()) { + return status; } } @@ -2565,28 +2624,17 @@ namespace lvh::detail { return status; } } - if (uses_uinput_dpad_buttons(profile_kind_)) { - constexpr std::array dpad_button_map { - std::pair {dpad_up, BTN_DPAD_UP}, - std::pair {dpad_down, BTN_DPAD_DOWN}, - std::pair {dpad_left, BTN_DPAD_LEFT}, - std::pair {dpad_right, BTN_DPAD_RIGHT}, - }; - return emit_button_map(buttons, dpad_button_map); - } return OperationStatus::success(); } OperationStatus emit_axis_state(const GamepadState &state) { using enum GamepadButton; - if (!uses_uinput_dpad_buttons(profile_kind_)) { - if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(state.buttons, dpad_left, dpad_right)); !status.ok()) { - return status; - } - if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(state.buttons, dpad_up, dpad_down)); !status.ok()) { - return status; - } + if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(state.buttons, dpad_left, dpad_right)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(state.buttons, dpad_up, dpad_down)); !status.ok()) { + return status; } const std::array stick_axes { @@ -2629,6 +2677,7 @@ namespace lvh::detail { } void read_output_loop(std::stop_token stop_token) { + std::this_thread::sleep_for(uinput_feedback_startup_delay); while (!stop_token.stop_requested() && running_) { pollfd descriptor {}; descriptor.fd = file_descriptor(); @@ -2911,15 +2960,16 @@ namespace lvh::detail { } event.type = UHID_CREATE2; - auto unique_id = options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id; + unique_id_ = options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id; if (is_playstation_profile(options.profile.gamepad_kind)) { playstation_mac_address_ = parse_mac_address(options.metadata.stable_id).value_or(generated_mac_address(id)); - unique_id = format_mac_address(playstation_mac_address_); + unique_id_ = format_mac_address(playstation_mac_address_); } + physical_id_ = std::format("libvirtualhid/uhid/{}", id); copy_string(request.name, options.profile.name); - copy_string(request.phys, std::format("libvirtualhid/uhid/{}", id)); - copy_string(request.uniq, unique_id); + copy_string(request.phys, physical_id_); + copy_string(request.uniq, unique_id_); request.rd_size = static_cast(options.profile.report_descriptor.size()); request.bus = to_uhid_bus(options.profile); request.vendor = options.profile.vendor_id; @@ -2979,7 +3029,11 @@ namespace lvh::detail { } std::vector device_nodes() const override { - return discover_input_nodes_by_name(device_name_); + auto nodes = discover_input_nodes_by_name(device_name_); + for (const auto &node : discover_hidraw_nodes_by_metadata(device_name_, physical_id_, unique_id_)) { + append_node_if_missing(nodes, node.kind, node.path); + } + return nodes; } OperationStatus close() override { @@ -3088,7 +3142,7 @@ namespace lvh::detail { send_get_report_reply(event.u.get_report.id, event.u.get_report.rnum); break; case UHID_SET_REPORT: - dispatch_output_report(event.u.set_report.data, event.u.set_report.size); + dispatch_set_report(event.u.set_report.rnum, event.u.set_report.data, event.u.set_report.size); send_set_report_reply(event.u.set_report.id, 0); break; default: @@ -3115,6 +3169,21 @@ namespace lvh::detail { } void dispatch_output_report(const __u8 *data, std::size_t report_size) { + const auto size = std::min(report_size, UHID_DATA_MAX); + std::vector report(data, data + size); + dispatch_output_report(report); + } + + void dispatch_set_report(std::uint8_t report_number, const __u8 *data, std::size_t report_size) { + const auto size = std::min(report_size, UHID_DATA_MAX); + std::vector report(data, data + size); + if (report_number != 0 && (report.empty() || report.front() != report_number)) { + report.insert(report.begin(), report_number); + } + dispatch_output_report(report); + } + + void dispatch_output_report(const std::vector &report) { OutputCallback callback; { std::lock_guard lock {callback_mutex_}; @@ -3125,8 +3194,6 @@ namespace lvh::detail { return; } - const auto size = std::min(report_size, UHID_DATA_MAX); - std::vector report(data, data + size); for (const auto &output : reports::parse_output_reports(profile_, report)) { callback(output); } @@ -3218,6 +3285,8 @@ namespace lvh::detail { int fd_ = -1; DeviceProfile profile_; std::string device_name_; + std::string physical_id_; + std::string unique_id_; std::array playstation_mac_address_ {}; std::vector last_report_; std::atomic_bool open_ = true; diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index eba8b88..ea750b6 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -496,6 +496,22 @@ namespace lvh::detail::test { */ bool linux_hidraw_name_matches(std::string_view path, std::string_view name); + /** + * @brief Discover hidraw nodes using UHID metadata. + * + * @param name Expected HID name. + * @param physical_id Expected HID physical path. + * @param unique_id Expected HID unique id. + * @param hidraw_root Test hidraw sysfs root. + * @return Matching hidraw device nodes. + */ + std::vector linux_discover_hidraw_nodes_by_metadata( + std::string_view name, + std::string_view physical_id, + std::string_view unique_id, + const std::string &hidraw_root + ); + /** * @brief Discover Linux input nodes for a device name. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index fa8877b..7f9605f 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -923,6 +923,15 @@ namespace lvh::detail::test { return hidraw_name_matches(std::filesystem::path {std::string {path}}, name); } + std::vector linux_discover_hidraw_nodes_by_metadata( + std::string_view name, + std::string_view physical_id, + std::string_view unique_id, + const std::string &hidraw_root + ) { + return discover_hidraw_nodes_by_metadata(name, physical_id, unique_id, hidraw_root); + } + std::vector linux_discover_nodes_by_name(const std::string &name) { return discover_input_nodes_by_name(name); } @@ -1111,7 +1120,7 @@ namespace lvh::detail::test { } options.profile = *profile; result.create_status = gamepad.create(8, options); - for (auto attempt = 0; attempt < 100 && callback_count.load() == 0; ++attempt) { + for (auto attempt = 0; attempt < 500 && callback_count.load() == 0; ++attempt) { std::this_thread::sleep_for(std::chrono::milliseconds {1}); } result.close_status = gamepad.close(); @@ -1465,6 +1474,20 @@ namespace lvh::detail::test { event.u.output.data[4] = 0x56; static_cast(write_uhid_event(descriptors[1], event)); + event = {}; + event.type = UHID_SET_REPORT; + event.u.set_report.id = 20; + event.u.set_report.rnum = 0x02; + event.u.set_report.rtype = UHID_OUTPUT_REPORT; + event.u.set_report.size = 62; + event.u.set_report.data[0] = 0x03; + event.u.set_report.data[2] = 0x12; + event.u.set_report.data[3] = 0x56; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event_type(descriptors[1], UHID_SET_REPORT_REPLY, event)) { + result.saw_set_report_reply = event.u.set_report_reply.id == 20 && event.u.set_report_reply.err == 0; + } + event = {}; event.type = UHID_GET_REPORT; event.u.get_report.id = 11; @@ -1607,11 +1630,10 @@ namespace lvh::detail::test { event.u.set_report.id = 21; event.u.set_report.rnum = 0x05; event.u.set_report.rtype = UHID_OUTPUT_REPORT; - event.u.set_report.size = 32; - event.u.set_report.data[0] = 0x05; - event.u.set_report.data[1] = 0x03; - event.u.set_report.data[4] = 0x12; - event.u.set_report.data[5] = 0x56; + event.u.set_report.size = 31; + event.u.set_report.data[0] = 0x03; + event.u.set_report.data[3] = 0x12; + event.u.set_report.data[4] = 0x56; static_cast(write_uhid_event(descriptors[1], event)); if (read_uhid_event_type(descriptors[1], UHID_SET_REPORT_REPLY, event)) { result.saw_set_report_reply = event.u.set_report_reply.id == 21 && event.u.set_report_reply.err == 0; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 445444b..77fd6fd 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -219,6 +219,8 @@ TEST_F(LinuxBackendTest, CoversLinuxDiscoveryAndIdentityHelpers) { std::filesystem::create_directories(hidraw_root / "hidraw0" / "device"); std::filesystem::create_directories(hidraw_root / "hidraw1" / "device"); std::filesystem::create_directories(hidraw_root / "hidraw2" / "device"); + std::filesystem::create_directories(hidraw_root / "hidraw3" / "device"); + std::filesystem::create_directories(hidraw_root / "hidraw4" / "device"); { std::ofstream file {input_root / "event0" / "device" / "name"}; file << "libvirtualhid device\n"; @@ -247,6 +249,18 @@ TEST_F(LinuxBackendTest, CoversLinuxDiscoveryAndIdentityHelpers) { std::ofstream file {hidraw_root / "hidraw2" / "device" / "uevent"}; file << "HID_ID=0003:0000:0000\n"; } + { + std::ofstream file {hidraw_root / "hidraw3" / "device" / "uevent"}; + file << "HID_NAME=kernel renamed controller\n" + << "HID_PHYS=libvirtualhid/uhid/42\n" + << "HID_UNIQ=02:00:00:00:00:01\n"; + } + { + std::ofstream file {hidraw_root / "hidraw4" / "device" / "uevent"}; + file << "HID_NAME=other device\n" + << "HID_PHYS=other/physical/path\n" + << "HID_UNIQ=02:00:00:00:00:02\n"; + } const auto nodes = lvh::detail::test::linux_discover_nodes_by_name( "libvirtualhid device", input_root.string(), @@ -265,6 +279,22 @@ TEST_F(LinuxBackendTest, CoversLinuxDiscoveryAndIdentityHelpers) { return node.kind == lvh::DeviceNodeKind::sysfs && node.path.starts_with(sysfs_root.string()); })); + const auto metadata_nodes = lvh::detail::test::linux_discover_hidraw_nodes_by_metadata( + "missing hidraw name", + "libvirtualhid/uhid/42", + "02:00:00:00:00:01", + hidraw_root.string() + ); + EXPECT_TRUE(std::ranges::any_of(metadata_nodes, [](const auto &node) { + return node.kind == lvh::DeviceNodeKind::hidraw && node.path == "/dev/hidraw3"; + })); + EXPECT_TRUE(std::ranges::any_of(metadata_nodes, [hidraw_root](const auto &node) { + return node.kind == lvh::DeviceNodeKind::sysfs && node.path == (hidraw_root / "hidraw3").string(); + })); + EXPECT_FALSE(std::ranges::any_of(metadata_nodes, [](const auto &node) { + return node.path.ends_with("hidraw4"); + })); + std::filesystem::remove_all(temp_dir); } @@ -404,19 +434,10 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { } return event->value; }; - if (kind == generic) { - EXPECT_EQ(event_value(EV_ABS, ABS_HAT0X), std::nullopt); - EXPECT_EQ(event_value(EV_ABS, ABS_HAT0Y), std::nullopt); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), 1); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), 0); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), 0); - EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_RIGHT), 1); - } else { - EXPECT_EQ(event_value(EV_ABS, ABS_HAT0X), 1); - EXPECT_EQ(event_value(EV_ABS, ABS_HAT0Y), -1); - for (const auto dpad_button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { - EXPECT_EQ(event_value(EV_KEY, dpad_button), std::nullopt); - } + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0X), 1); + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0Y), -1); + for (const auto dpad_button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { + EXPECT_EQ(event_value(EV_KEY, dpad_button), std::nullopt); } EXPECT_EQ(event_value(EV_ABS, ABS_X), lvh::reports::normalize_axis(-0.5F)); EXPECT_EQ(event_value(EV_ABS, ABS_Y), lvh::reports::normalize_axis(-0.25F)); @@ -706,6 +727,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) { EXPECT_TRUE(result.saw_dualsense_calibration); EXPECT_TRUE(result.saw_dualsense_pairing); EXPECT_TRUE(result.saw_dualsense_firmware); + EXPECT_TRUE(result.saw_set_report_reply); ASSERT_GE(result.output_callback_count, 1U); EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5656); @@ -860,15 +882,14 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc bool key_record; bool sparse_button_slots; bool switch_controls; - bool dpad_buttons; }; constexpr std::array gamepad_cases { - GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, false, true}, - GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_BLUETOOTH, 0x028E, false, true, false, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false, false}, - GamepadCase {lvh::GamepadProfileKind::switch_pro, BUS_USB, 0x2009, false, false, true, false}, + GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_BLUETOOTH, 0x028E, false, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, + GamepadCase {lvh::GamepadProfileKind::switch_pro, BUS_USB, 0x2009, false, false, true}, }; constexpr std::array active_buttons { BTN_SOUTH, @@ -888,7 +909,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc constexpr std::array feedback_codes {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_SINE, FF_RAMP, FF_GAIN}; - for (const auto &[kind, bustype, product_id, key_record, sparse_button_slots, switch_controls, dpad_button_capabilities] : gamepad_cases) { + for (const auto &[kind, bustype, product_id, key_record, sparse_button_slots, switch_controls] : gamepad_cases) { const auto expected_profile = lvh::profiles::gamepad_profile(kind); ASSERT_TRUE(expected_profile.has_value()); const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind); @@ -910,11 +931,11 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc << "unexpected reserved gamepad button slot state for " << button; } for (const auto button : dpad_buttons) { - EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, dpad_button_capabilities) - << "unexpected directional gamepad button state for " << button; + EXPECT_EQ(find_code(gamepad, EV_KEY, button), nullptr) + << "unexpected directional gamepad button capability for " << button; } - EXPECT_EQ(find_code(gamepad, EV_ABS, ABS_HAT0X) != nullptr, !dpad_button_capabilities); - EXPECT_EQ(find_code(gamepad, EV_ABS, ABS_HAT0Y) != nullptr, !dpad_button_capabilities); + EXPECT_NE(find_code(gamepad, EV_ABS, ABS_HAT0X), nullptr); + EXPECT_NE(find_code(gamepad, EV_ABS, ABS_HAT0Y), nullptr); EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, key_record); const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); if (switch_controls) { diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 505080e..14d3ef1 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -175,6 +176,69 @@ namespace { } } + struct RumbleState { + std::atomic_uint16_t low_frequency {0}; + std::atomic_uint16_t high_frequency {0}; + std::atomic_bool observed {false}; + }; + + std::shared_ptr observe_rumble(lvh::Gamepad &gamepad) { + const auto rumble = std::make_shared(); + gamepad.set_output_callback([rumble](const lvh::GamepadOutput &output) { + if ( + output.kind == lvh::GamepadOutputKind::rumble && + output.low_frequency_rumble > 0 && output.high_frequency_rumble > 0 + ) { + rumble->low_frequency = output.low_frequency_rumble; + rumble->high_frequency = output.high_frequency_rumble; + rumble->observed = true; + } + }); + return rumble; + } + + bool wait_for_rumble(const std::shared_ptr &rumble, bool pump_sdl) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while (std::chrono::steady_clock::now() < deadline && !rumble->observed.load()) { + if (pump_sdl) { + pump_sdl_events(); + } + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + } + return rumble->observed.load(); + } + + std::optional wait_for_hidraw_node(lvh::Gamepad &gamepad) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while (std::chrono::steady_clock::now() < deadline) { + for (const auto &node : gamepad.device_nodes()) { + if (node.kind == lvh::DeviceNodeKind::hidraw && ::access(node.path.c_str(), W_OK) == 0) { + return std::filesystem::path {node.path}; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds {50}); + } + return std::nullopt; + } + + std::vector playstation_rumble_report(const lvh::DeviceProfile &profile) { + std::vector report(profile.output_report_size, 0); + if (profile.gamepad_kind == lvh::GamepadProfileKind::dualshock4 && report.size() >= 32U) { + report[0] = 0x05; + report[1] = 0x01; + report[4] = 0x12; + report[5] = 0x56; + } else if (profile.gamepad_kind == lvh::GamepadProfileKind::dualsense && report.size() >= 48U) { + report[0] = 0x02; + report[1] = 0x01; + report[3] = 0x12; + report[4] = 0x56; + } else { + report.clear(); + } + return report; + } + void dump_sdl_joysticks() { const auto joystick_count = SDL_NumJoysticks(); std::cout << "SDL joystick count: " << joystick_count << '\n'; @@ -385,29 +449,31 @@ namespace { } void expect_sdl_rumble_callback(SDL_GameController *controller, lvh::Gamepad &gamepad) { - struct RumbleState { - std::atomic_uint16_t low_frequency {0}; - std::atomic_uint16_t high_frequency {0}; - }; - - const auto rumble = std::make_shared(); - gamepad.set_output_callback([rumble](const lvh::GamepadOutput &output) { - if (output.kind == lvh::GamepadOutputKind::rumble) { - rumble->low_frequency = output.low_frequency_rumble; - rumble->high_frequency = output.high_frequency_rumble; - } - }); + const auto rumble = observe_rumble(gamepad); ASSERT_EQ(SDL_GameControllerRumble(controller, 0x5678, 0x1234, 1000), 0) << SDL_GetError(); - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; - while ( - std::chrono::steady_clock::now() < deadline && - rumble->low_frequency.load() == 0 && rumble->high_frequency.load() == 0 - ) { - pump_sdl_events(); - std::this_thread::sleep_for(std::chrono::milliseconds {20}); - } + EXPECT_TRUE(wait_for_rumble(rumble, true)); + EXPECT_GT(rumble->low_frequency.load(), 0); + EXPECT_GT(rumble->high_frequency.load(), 0); + } + + void expect_hidraw_rumble_callback(const lvh::DeviceProfile &profile, lvh::Gamepad &gamepad) { + const auto rumble = observe_rumble(gamepad); + const auto hidraw_node = wait_for_hidraw_node(gamepad); + ASSERT_TRUE(hidraw_node.has_value()); + + const auto report = playstation_rumble_report(profile); + ASSERT_FALSE(report.empty()); + const auto descriptor = ::open(hidraw_node->c_str(), O_WRONLY | O_CLOEXEC); + ASSERT_GE(descriptor, 0) << std::strerror(errno); + ScopeExit close_descriptor {[descriptor]() { + static_cast(::close(descriptor)); + }}; + + ASSERT_EQ(::write(descriptor, report.data(), report.size()), static_cast(report.size())) + << std::strerror(errno); + EXPECT_TRUE(wait_for_rumble(rumble, false)); EXPECT_GT(rumble->low_frequency.load(), 0); EXPECT_GT(rumble->high_frequency.load(), 0); } @@ -450,7 +516,7 @@ namespace { expect_sdl_playstation_controller_profile(controller.get()); if (test_case.expect_live_input) { EXPECT_TRUE(wait_for_sdl_controller_input(controller.get())) << describe_sdl_controller_state(controller.get()); - expect_sdl_rumble_callback(controller.get(), gamepad); + expect_hidraw_rumble_callback(expected_profile, gamepad); } } @@ -624,7 +690,7 @@ TEST_F(LinuxConsumerTest, SdlSeesGenericCanonicalButtons) { .profile = lvh::profiles::generic_gamepad(), .name_suffix = "SDL Generic Gamepad", .stable_id = "libvirtualhid-sdl-gamepad-test", - .minimum_buttons = 20, + .minimum_buttons = 16, .minimum_axes = 6, }); } diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index a082c91..832a464 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -208,33 +208,13 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_TRUE(switch_pro.capabilities.supports_battery); const auto generic = lvh::profiles::generic_gamepad(); - const std::array standard_button_descriptor { + const std::array standard_button_descriptor { 0x05, - 0x09, - 0x09, - 0x01, - 0x09, - 0x02, - 0x09, - 0x04, - 0x09, - 0x05, - 0x09, - 0x07, - 0x09, - 0x08, - 0x09, - 0x0B, - 0x09, - 0x0C, - 0x09, - 0x0E, - 0x09, - 0x0F, - 0x09, - 0x0D, - 0x09, - 0x06, + 0x09, // Usage Page (Button) + 0x19, + 0x01, // Usage Minimum (Button 1) + 0x29, + 0x10, // Usage Maximum (Button 16) 0x15, 0x00, 0x25, @@ -242,7 +222,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { 0x75, 0x01, 0x95, - 0x0C, + 0x10, 0x81, 0x02, }; diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index 3f4c113..1f58f12 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -124,7 +124,7 @@ TEST(ReportTest, PacksStandardGamepadReport) { ASSERT_EQ(report.size(), profile.input_report_size); EXPECT_EQ(report[0], profile.report_id); EXPECT_EQ(report[1], 0x81); // A and Start. - EXPECT_EQ(report[2], 0x6C); // Guide, Misc/share, and D-pad-left hat value. + EXPECT_EQ(report[2], 0x4C); // Guide, Misc/share, and D-pad-left button. EXPECT_EQ(report[3], 255); // Left stick X. EXPECT_EQ(report[4], 255); // Left stick Y. EXPECT_EQ(report[5], 191); // Right stick X. From 5a46bbdf0aad40455acc8d5097a696adbe8e8544 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:15:54 -0400 Subject: [PATCH 12/12] Normalize rumble and route output by runtime Reworks gamepad output handling to use native PID-style rumble for Generic/Xbox profiles, decodes Switch Pro native rumble reports, and limits legacy 5-byte rumble parsing to Xbox 360. On Windows, UMDF output reads/events are now tracked per file handle so output callbacks are delivered to the runtime that created each device. On Linux, Generic uinput devices now use an Xbox-compatible VID/PID mapping for better Steam/browser behavior while keeping the public profile identity unchanged. Also updates CI udev rules, platform docs, and broad test coverage (including new Windows consumer integration tests) to validate descriptor changes, button packing, output normalization, and backend routing behavior. --- .github/workflows/ci.yml | 4 +- docs/platform-support.md | 20 +- docs/windows-driver.md | 5 + src/core/profiles.cpp | 108 ++++-- src/core/report.cpp | 283 +++++++++++++- src/platform/linux/uhid_backend.cpp | 29 +- .../windows/driver/libvirtualhid_umdf.cpp | 78 +++- src/platform/windows/windows_backend.cpp | 57 ++- tests/CMakeLists.txt | 8 +- tests/fixtures/windows_backend_test_hooks.cpp | 25 +- tests/unit/test_linux_backend.cpp | 35 +- tests/unit/test_linux_consumers.cpp | 96 ++++- tests/unit/test_profiles.cpp | 261 +++++++++---- tests/unit/test_report.cpp | 194 +++++++++- tests/unit/test_windows_backend.cpp | 8 +- tests/unit/test_windows_consumers.cpp | 364 ++++++++++++++++++ 16 files changed, 1384 insertions(+), 191 deletions(-) create mode 100644 tests/unit/test_windows_consumers.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a7e5a1..41cf4cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,8 +121,8 @@ jobs: echo "::warning::${kernel_modules_package} is unavailable; relying on the runner image kernel modules." fi sudo tee /etc/udev/rules.d/99-libvirtualhid-ci.rules >/dev/null <<'EOF' - KERNEL=="hidraw*", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess" - SUBSYSTEMS=="input", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess" + SUBSYSTEM=="hidraw", KERNEL=="hidraw*", MODE="0666", TAG+="uaccess" + SUBSYSTEM=="input", KERNEL=="event*", MODE="0666", TAG+="uaccess" EOF sudo udevadm control --reload-rules for module in uhid uinput; do diff --git a/docs/platform-support.md b/docs/platform-support.md index 6c6e10e..908f124 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -38,6 +38,13 @@ HID consumers can enumerate, including SDL/HIDAPI, DirectInput, Windows.Gaming.Input/GameInput, and browser Gamepad API clients. XInput is not a direct target of the HID backend. +The Generic descriptor advertises its four rumble actuators through the HID +Physical Interface Device usage page instead of an opaque vendor output. Xbox +One and Xbox Series use the same native eight-byte PID payload exposed by the +Windows Xbox HID stack. The library applies the actuator-enable mask and +duration field, then reports the body motors as normalized low/high-frequency +rumble and the independent trigger motors as trigger-rumble output. + See [Windows driver package](windows-driver.md) for build, install, validation, and signing details. @@ -69,12 +76,19 @@ backend lets a new uinput device settle before reading those effects so an early poll error cannot disable feedback for the device lifetime. PlayStation rumble is read from native UHID interrupt-channel output reports. +The Generic profile keeps its public `0x1209:0x0001` identity on descriptor- +driven backends. Its Linux uinput device uses the proven compact Xbox-compatible +`0x045E:0x02EA` evdev identity used by Inputtino. Linux browser and Steam mapping +tables do not define a standard mapping for the vendor-neutral identity; the +compatible uinput identity makes the hat axes appear as logical D-pad buttons +and exposes standard force feedback without changing the public profile name. + Xbox 360 retains its `0x045E:0x028E` identity, while its Linux uinput device uses the Bluetooth bus so consumers select the sparse button mapping. Xbox One and Xbox Series retain their public USB identities, but their Linux uinput devices use the corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, respectively), whose standard consumer mappings match the events -that uinput exposes. Those four sparse-layout profiles preserve the 15-slot +that uinput exposes. Those three Xbox profiles preserve the 15-slot Linux gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` slots are advertised but never pressed, keeping face buttons, shoulders, menu buttons, Guide, L3, and R3 at their expected indices. D-pad directions are @@ -93,6 +107,10 @@ the evdev layout used by Linux-native virtual-controller implementations and allows standard `FF_RUMBLE` effects without emulating the physical controller's proprietary initialization handshake. +On descriptor-driven backends, native Switch Pro output reports `0x01` and +`0x10` are decoded into the normalized low- and high-frequency rumble callback. +The original native report remains available in `GamepadOutput::raw_report`. + The optional `virtualhid_control` diagnostic UI uses SDL3 and Dear ImGui through the repository CPM lockfile. It is intended to stay on the same UI framework for Windows, Linux, and future macOS support. Static Linux linking is possible only diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 504bf05..bf0d7ab 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -36,6 +36,11 @@ device from the requested descriptor, VID/PID, version, and report layout. Input reports are submitted through VHF, and HID output writes are normalized back to the C++ output callback path. +Each backend runtime uses one control-file handle for commands and its pending +output read. The driver associates output events with that handle, so feedback +from a virtual gamepad is delivered only to the runtime that created it instead +of being consumed by another libvirtualhid client. + The driver opens a separate VHF source target for each virtual gamepad and parents that target to the control-file handle that created it. If the creating process exits or crashes, Windows cleans up gamepads that were not explicitly diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index 0f112ab..20c752f 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -28,6 +28,10 @@ namespace lvh::profiles { constexpr std::size_t common_output_report_size = 5; + constexpr std::size_t pid_rumble_payload_size = 8; + + constexpr std::size_t pid_rumble_output_report_size = pid_rumble_payload_size + 1U; + constexpr std::size_t xbox_gip_input_report_size = 17; constexpr std::uint8_t switch_pro_report_id = 0x30; @@ -155,18 +159,87 @@ namespace lvh::profiles { } } + void append_pid_rumble_output(std::vector &descriptor) { + descriptor.insert( + descriptor.end(), + { + 0xA1, + 0x02, // Collection (Logical) + 0x05, + 0x0F, // Usage Page (Physical Interface Device) + 0x09, + 0x97, // Usage (DC Enable Actuators) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data,Var,Abs) + 0x15, + 0x00, + 0x25, + 0x00, + 0x91, + 0x03, // Output (Const,Var,Abs) padding + 0x09, + 0x70, // Usage (Magnitude) + 0x15, + 0x00, + 0x25, + 0x64, // Logical Maximum (100) + 0x75, + 0x08, + 0x95, + 0x04, // Four actuator magnitudes + 0x91, + 0x02, + 0x09, + 0x50, // Usage (Duration) + 0x66, + 0x01, + 0x10, + 0x55, + 0x0E, + 0x26, + 0xFF, + 0x00, + 0x95, + 0x01, + 0x91, + 0x02, + 0x09, + 0xA7, // Usage (Start Delay) + 0x91, + 0x02, + 0x65, + 0x00, + 0x55, + 0x00, + 0x09, + 0x7C, // Usage (Loop Count) + 0x91, + 0x02, + 0xC0, // End logical collection + } + ); + } + std::vector make_xbox_gip_report_descriptor(bool include_share_button) { constexpr std::string_view xbox_one_descriptor = "05010905a101a10009300931150027ffff0000950275108102c0a10009330934150027ffff0000950275108102c0" "05010932150026ff039501750a81021500250075069501810305010935150026ff039501750a8102150025007506" - "950181030509090109020904090509070908090b090c090e090f150025017501950a81021500250075069501810305010939150125083500463b0166140075049501" + "9501810305091901290a950a750181021500250075069501810305010939150125083500463b0166140075049501" "814275049501150025003500450065008103a102050f099715002501750495019102150025009103097015002564" "7508950491020950660110550e26ff009501910209a7910265005500097c9102c005010980a100098515002501" "95017501810215002500750795018103c005060920150026ff00750895018102c0"; constexpr std::string_view xbox_series_descriptor = "05010905a101a10009300931150027ffff0000950275108102c0a10009330934150027ffff0000950275108102c0" "05010932150026ff039501750a81021500250075069501810305010935150026ff039501750a8102150025007506" - "950181030509090109020904090509070908090b090c090e090f150025017501950a8102150025007501950181030906150025017501950181021500250075049501810305010939150125083500463b0166140075049501" + "9501810305091901290c950c750181021500250075049501810305010939150125083500463b0166140075049501" "814275049501150025003500450065008103a102050f099715002501750495019102150025009103097015002564" "7508950491020950660110550e26ff009501910209a7910265005500097c9102c005010980a100098515002501" "95017501810215002500750795018103c005060920150026ff00750895018102c0"; @@ -176,9 +249,9 @@ namespace lvh::profiles { std::vector make_switch_pro_report_descriptor() { constexpr std::string_view descriptor = - "050115000904a1018530050105090902090109040905090709080909090a090b090c150025017501950a5500650081020509090e090f090d0906150025017501" + "050115000904a1018530050105091901290a150025017501950a5500650081020509190b290e150025017501" "950481027501950281030b01000100a1000b300001000b310001000b320001000b35000100150027ffff0000" - "751095048102c00b39000100150025073500463b016514750495018102750495018103" + "751095048102c00b39000100150025073500463b0165147504950181020509190f291215002501750195048102" "7508953481030600ff852109017508953f8103858109027508953f8103850109037508953f9183851009047508" "953f9183858009057508953f9183858209067508953f9183c0"; @@ -336,27 +409,7 @@ namespace lvh::profiles { }; if (supports_rumble) { - descriptor.insert( - descriptor.end(), - { - 0x06, - 0x00, - 0xFF, // Usage Page (Vendor Defined) - 0x09, - 0x01, // Usage (Vendor Usage 1) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - 0x04, // Report Count (4) - 0x91, - 0x02, // Output (Data,Var,Abs) - } - ); + append_pid_rumble_output(descriptor); } descriptor.push_back(0xC0); // End Collection @@ -1869,6 +1922,9 @@ namespace lvh::profiles { ); profile.report_descriptor = make_standard_gamepad_report_descriptor(profile.report_id, profile.capabilities.supports_rumble); + if (profile.capabilities.supports_rumble) { + profile.output_report_size = pid_rumble_output_report_size; + } return profile; } @@ -1888,7 +1944,7 @@ namespace lvh::profiles { profile.version = version; profile.report_id = 0; profile.input_report_size = xbox_gip_input_report_size; - profile.output_report_size = common_output_report_size; + profile.output_report_size = pid_rumble_payload_size; profile.name = std::move(name); profile.manufacturer = "Microsoft"; profile.capabilities = {.supports_rumble = true, .supports_battery = include_share_button}; diff --git a/src/core/report.cpp b/src/core/report.cpp index 9acc9eb..693181a 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -61,6 +61,119 @@ namespace lvh::reports { constexpr auto dualsense_flag2_compatible_vibration = std::byte {0x04}; + constexpr std::uint8_t switch_rumble_and_subcommand_output_report_id = 0x01; + + constexpr std::uint8_t switch_rumble_only_output_report_id = 0x10; + + constexpr std::size_t switch_rumble_output_report_size = 10; + + // SDL maps 16-bit rumble strengths to Nintendo's shared 101-step amplitude + // scale. This is the inverse table for the packed high- and low-band values: + // https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch.c + constexpr std::array switch_rumble_amplitudes { + 0, + 514, + 775, + 921, + 1096, + 1303, + 1550, + 1843, + 2192, + 2606, + 3100, + 3686, + 4383, + 5213, + 6199, + 7372, + 7698, + 8039, + 8395, + 8767, + 9155, + 9560, + 9984, + 10426, + 10887, + 11369, + 11873, + 12398, + 12947, + 13520, + 14119, + 14744, + 15067, + 15397, + 15734, + 16079, + 16431, + 16790, + 17158, + 17534, + 17918, + 18310, + 18711, + 19121, + 19540, + 19967, + 20405, + 20851, + 21308, + 21775, + 22251, + 22739, + 23236, + 23745, + 24265, + 24797, + 25340, + 25894, + 26462, + 27041, + 27633, + 28238, + 28856, + 29488, + 30134, + 30794, + 31468, + 32157, + 32861, + 33581, + 34316, + 35068, + 35836, + 36620, + 37422, + 38242, + 39079, + 39935, + 40809, + 41703, + 42616, + 43549, + 44503, + 45477, + 46473, + 47491, + 48531, + 49593, + 50679, + 51789, + 52923, + 54082, + 55266, + 56476, + 57713, + 58977, + 60268, + 61588, + 62936, + 64315, + 65535, + }; + constexpr std::byte to_byte(std::uint8_t value) { return static_cast(value); } @@ -176,6 +289,145 @@ namespace lvh::reports { return static_cast(std::lround((static_cast(value) / 255.0F) * 65535.0F)); } + constexpr std::size_t pid_rumble_payload_size = 8; + + constexpr std::size_t pid_rumble_report_size = pid_rumble_payload_size + 1U; + + constexpr std::uint8_t pid_rumble_maximum_magnitude = 100; + + constexpr std::uint8_t pid_rumble_low_frequency_enabled = 0x02; + + constexpr std::uint8_t pid_rumble_high_frequency_enabled = 0x01; + + constexpr std::uint8_t pid_rumble_left_trigger_enabled = 0x08; + + constexpr std::uint8_t pid_rumble_right_trigger_enabled = 0x04; + + struct PidRumbleAmplitude { + std::uint16_t left_trigger; + std::uint16_t right_trigger; + std::uint16_t low_frequency; + std::uint16_t high_frequency; + }; + + std::uint16_t scale_pid_rumble_magnitude(std::uint8_t value) { + return static_cast( + (static_cast(value) * 65535U + 50U) / pid_rumble_maximum_magnitude + ); + } + + std::optional pid_rumble_payload_offset( + const DeviceProfile &profile, + const std::vector &report + ) { + using enum GamepadProfileKind; + + if (profile.gamepad_kind == generic) { + if (report.size() >= pid_rumble_report_size && report[0] == profile.report_id) { + return 1U; + } + return std::nullopt; + } + + if (profile.gamepad_kind != xbox_one && profile.gamepad_kind != xbox_series) { + return std::nullopt; + } + + // The Windows HID write buffer includes a leading zero for devices that + // do not use report IDs. VHF may preserve that byte or expose only the + // eight-byte PID payload, so accept both representations. + if (report.size() >= pid_rumble_report_size && report[0] == 0U) { + return 1U; + } + if (report.size() >= pid_rumble_payload_size) { + return 0U; + } + return std::nullopt; + } + + std::optional decode_pid_rumble_report( + const DeviceProfile &profile, + const std::vector &report + ) { + const auto offset = pid_rumble_payload_offset(profile, report); + if (!offset.has_value()) { + return std::nullopt; + } + + const auto magnitudes = std::span {report}.subspan(*offset + 1U, 4U); + if (std::ranges::any_of(magnitudes, [](const auto magnitude) { + return magnitude > pid_rumble_maximum_magnitude; + })) { + return std::nullopt; + } + + const auto enabled = report[*offset] & 0x0FU; + const auto duration = report[*offset + 5U]; + const auto enabled_magnitude = [&](std::uint8_t enable_bit, std::size_t magnitude_index) { + if (duration == 0U || (enabled & enable_bit) == 0U) { + return std::uint16_t {0}; + } + return scale_pid_rumble_magnitude(magnitudes[magnitude_index]); + }; + + return PidRumbleAmplitude { + .left_trigger = enabled_magnitude(pid_rumble_left_trigger_enabled, 0U), + .right_trigger = enabled_magnitude(pid_rumble_right_trigger_enabled, 1U), + .low_frequency = enabled_magnitude(pid_rumble_low_frequency_enabled, 2U), + .high_frequency = enabled_magnitude(pid_rumble_high_frequency_enabled, 3U), + }; + } + + struct SwitchRumbleAmplitude { + std::uint16_t low_frequency; + std::uint16_t high_frequency; + }; + + std::optional decode_switch_rumble_frame( + const std::vector &report, + std::size_t offset + ) { + const auto high_frequency_index = static_cast((report[offset + 1U] & 0xFEU) / 2U); + + const auto low_frequency_code = report[offset + 3U]; + if (high_frequency_index >= switch_rumble_amplitudes.size() || low_frequency_code < 0x40U || low_frequency_code > 0x72U) { + return std::nullopt; + } + + const auto low_frequency_index = + static_cast((low_frequency_code - 0x40U) * 2U) + ((report[offset + 2U] & 0x80U) != 0U ? 1U : 0U); + if (low_frequency_index >= switch_rumble_amplitudes.size()) { + return std::nullopt; + } + + return SwitchRumbleAmplitude { + .low_frequency = switch_rumble_amplitudes[low_frequency_index], + .high_frequency = switch_rumble_amplitudes[high_frequency_index], + }; + } + + std::optional decode_switch_rumble_report( + const std::vector &report + ) { + if (report.size() < switch_rumble_output_report_size || (report[0] != switch_rumble_and_subcommand_output_report_id && report[0] != switch_rumble_only_output_report_id)) { + return std::nullopt; + } + + const auto left = decode_switch_rumble_frame(report, 2U); + const auto right = decode_switch_rumble_frame(report, 6U); + if (!left.has_value() || !right.has_value()) { + return std::nullopt; + } + + // The normalized API has one strength per frequency band, while the + // native report has one frame per actuator. Retain the strongest request + // in each band so asymmetric native effects are not lost entirely. + return SwitchRumbleAmplitude { + .low_frequency = std::max(left->low_frequency, right->low_frequency), + .high_frequency = std::max(left->high_frequency, right->high_frequency), + }; + } + struct ButtonBit { std::uint16_t bit; GamepadButton button; @@ -952,8 +1204,37 @@ namespace lvh::reports { } } + if (profile.gamepad_kind == GamepadProfileKind::switch_pro) { + if (const auto rumble = decode_switch_rumble_report(report); rumble.has_value()) { + GamepadOutput output; + output.kind = GamepadOutputKind::rumble; + output.low_frequency_rumble = rumble->low_frequency; + output.high_frequency_rumble = rumble->high_frequency; + output.raw_report = report; + outputs.push_back(std::move(output)); + return outputs; + } + } + + if (const auto rumble = decode_pid_rumble_report(profile, report); rumble.has_value()) { + GamepadOutput motor_output; + motor_output.kind = GamepadOutputKind::rumble; + motor_output.low_frequency_rumble = rumble->low_frequency; + motor_output.high_frequency_rumble = rumble->high_frequency; + motor_output.raw_report = report; + outputs.push_back(std::move(motor_output)); + + GamepadOutput trigger_output; + trigger_output.kind = GamepadOutputKind::trigger_rumble; + trigger_output.left_trigger_rumble = rumble->left_trigger; + trigger_output.right_trigger_rumble = rumble->right_trigger; + trigger_output.raw_report = report; + outputs.push_back(std::move(trigger_output)); + return outputs; + } + if ( - profile.gamepad_kind != GamepadProfileKind::switch_pro && profile.capabilities.supports_rumble && + profile.gamepad_kind == GamepadProfileKind::xbox_360 && profile.capabilities.supports_rumble && profile.output_report_size >= 5U && report.size() >= profile.output_report_size && report[0] == profile.report_id ) { diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index ab04ce6..6ff7801 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -83,6 +83,8 @@ namespace lvh::detail { // The Bluetooth bus selects the sparse evdev mapping that matches the // button capabilities exposed by these Xbox uinput devices. constexpr auto xbox_sparse_uinput_bus = BUS_BLUETOOTH; + constexpr std::uint16_t xbox_uinput_vendor_id = 0x045E; + constexpr std::uint16_t xbox_one_usb_uinput_product_id = 0x02EA; constexpr std::uint16_t xbox_wireless_uinput_product_id = 0x0B20; constexpr std::uint16_t xbox_series_uinput_product_id = 0x0B13; constexpr auto dualshock4_usb_calibration_report = 0x02; @@ -539,7 +541,7 @@ namespace lvh::detail { bool uses_sparse_uinput_button_slots(GamepadProfileKind kind) { using enum GamepadProfileKind; - return kind == generic || kind == xbox_360 || kind == xbox_one || kind == xbox_series; + return kind == xbox_360 || kind == xbox_one || kind == xbox_series; } std::uint16_t to_uhid_bus(BusType bus_type) { @@ -636,21 +638,15 @@ namespace lvh::detail { std::string line; while (std::getline(file, line)) { - constexpr std::string_view name_key {"HID_NAME="}; - if (!name.empty() && line.starts_with(name_key) && line.size() == name_key.size() + name.size() && line.ends_with(name)) { + if (constexpr std::string_view name_key {"HID_NAME="}; !name.empty() && line.starts_with(name_key) && line.size() == name_key.size() + name.size() && line.ends_with(name)) { return true; } - constexpr std::string_view phys_key {"HID_PHYS="}; - if ( - !physical_id.empty() && line.starts_with(phys_key) && - line.size() == phys_key.size() + physical_id.size() && line.ends_with(physical_id) - ) { + if (constexpr std::string_view phys_key {"HID_PHYS="}; !physical_id.empty() && line.starts_with(phys_key) && line.size() == phys_key.size() + physical_id.size() && line.ends_with(physical_id)) { return true; } - constexpr std::string_view uniq_key {"HID_UNIQ="}; - if (!unique_id.empty() && line.starts_with(uniq_key) && line.size() == uniq_key.size() + unique_id.size() && line.ends_with(unique_id)) { + if (constexpr std::string_view uniq_key {"HID_UNIQ="}; !unique_id.empty() && line.starts_with(uniq_key) && line.size() == uniq_key.size() + unique_id.size() && line.ends_with(unique_id)) { return true; } } @@ -1551,12 +1547,21 @@ namespace lvh::detail { } libevdev_set_name(device.get(), profile.name.c_str()); + const auto generic = profile.gamepad_kind == GamepadProfileKind::generic; const auto xbox_360 = profile.gamepad_kind == GamepadProfileKind::xbox_360; const auto xbox_one = profile.gamepad_kind == GamepadProfileKind::xbox_one; const auto xbox_series = profile.gamepad_kind == GamepadProfileKind::xbox_series; const auto uses_sparse_xbox_mapping = xbox_360 || xbox_one || xbox_series; + auto vendor_id = profile.vendor_id; auto product_id = profile.product_id; - if (xbox_one) { + if (generic) { + // Linux consumers do not have a standard mapping for libvirtualhid's + // vendor-neutral identity. Use the same compact, Xbox-compatible evdev + // identity as Inputtino so hats and force feedback are recognized by + // Steam and browser Gamepad API implementations. + vendor_id = xbox_uinput_vendor_id; + product_id = xbox_one_usb_uinput_product_id; + } else if (xbox_one) { product_id = xbox_wireless_uinput_product_id; } else if (xbox_series) { product_id = xbox_series_uinput_product_id; @@ -1565,7 +1570,7 @@ namespace lvh::detail { device.get(), uses_sparse_xbox_mapping ? xbox_sparse_uinput_bus : to_uinput_bus(profile.bus_type) ); - libevdev_set_id_vendor(device.get(), profile.vendor_id); + libevdev_set_id_vendor(device.get(), vendor_id); libevdev_set_id_product(device.get(), product_id); libevdev_set_id_version(device.get(), profile.version); diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp index fcd366a..72bea00 100644 --- a/src/platform/windows/driver/libvirtualhid_umdf.cpp +++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp @@ -78,13 +78,23 @@ namespace { std::wstring hardware_ids; }; + struct PendingOutputRequest { + WDFREQUEST request {}; + WDFFILEOBJECT owner_file {}; + }; + + struct BufferedOutputEvent { + LvhWindowsOutputReportEvent event {}; + WDFFILEOBJECT owner_file {}; + }; + struct DriverState { std::atomic next_driver_device_id {1}; std::mutex devices_mutex; std::map> devices; std::mutex output_requests_mutex; - std::vector pending_output_requests; - std::vector buffered_output_events; + std::vector pending_output_requests; + std::vector buffered_output_events; }; DriverState &driver_state() { @@ -160,7 +170,7 @@ namespace { bool remove_pending_output_request(WDFREQUEST request) { auto &state = driver_state(); std::lock_guard lock {state.output_requests_mutex}; - const auto iter = std::ranges::find(state.pending_output_requests, request); + const auto iter = std::ranges::find(state.pending_output_requests, request, &PendingOutputRequest::request); if (iter == state.pending_output_requests.end()) { return false; } @@ -217,29 +227,34 @@ namespace { return true; } - void queue_output_event(const LvhWindowsOutputReportEvent &event) { + void queue_output_event(WDFFILEOBJECT owner_file, const LvhWindowsOutputReportEvent &event) { WDFREQUEST request = nullptr; { auto &state = driver_state(); std::lock_guard lock {state.output_requests_mutex}; - if (state.pending_output_requests.empty()) { - state.buffered_output_events.push_back(event); + const auto pending = std::ranges::find( + state.pending_output_requests, + owner_file, + &PendingOutputRequest::owner_file + ); + if (pending == state.pending_output_requests.end()) { + state.buffered_output_events.push_back({event, owner_file}); return; } - request = state.pending_output_requests.front(); - state.pending_output_requests.erase(state.pending_output_requests.begin()); + request = pending->request; + state.pending_output_requests.erase(pending); } if (!complete_output_request(request, event, true)) { auto &state = driver_state(); std::lock_guard lock {state.output_requests_mutex}; - state.buffered_output_events.push_back(event); + state.buffered_output_events.push_back({event, owner_file}); } } void complete_pending_output_requests(NTSTATUS status) { - std::vector requests; + std::vector requests; { auto &state = driver_state(); std::lock_guard lock {state.output_requests_mutex}; @@ -247,9 +262,33 @@ namespace { state.buffered_output_events.clear(); } + for (const auto &pending : requests) { + if (NT_SUCCESS(WdfRequestUnmarkCancelable(pending.request))) { + complete_request(pending.request, status); + } + } + } + + void cancel_output_requests_for_file(WDFFILEOBJECT file_object) { + std::vector requests; + auto &state = driver_state(); + { + std::lock_guard lock {state.output_requests_mutex}; + static_cast(std::erase_if(state.pending_output_requests, [&](const auto &pending) { + if (pending.owner_file != file_object) { + return false; + } + requests.push_back(pending.request); + return true; + })); + static_cast(std::erase_if(state.buffered_output_events, [&](const auto &buffered) { + return buffered.owner_file == file_object; + })); + } + for (const auto request : requests) { if (NT_SUCCESS(WdfRequestUnmarkCancelable(request))) { - complete_request(request, status); + complete_request(request, STATUS_CANCELLED); } } } @@ -704,12 +743,18 @@ namespace { void handle_read_output_report_request(WDFREQUEST request) { std::optional buffered_event; + const auto owner_file = WdfRequestGetFileObject(request); auto &state = driver_state(); { std::lock_guard lock {state.output_requests_mutex}; - if (!state.buffered_output_events.empty()) { - buffered_event = state.buffered_output_events.front(); - state.buffered_output_events.erase(state.buffered_output_events.begin()); + const auto buffered = std::ranges::find( + state.buffered_output_events, + owner_file, + &BufferedOutputEvent::owner_file + ); + if (buffered != state.buffered_output_events.end()) { + buffered_event = buffered->event; + state.buffered_output_events.erase(buffered); } else { const auto status = WdfRequestMarkCancelableEx(request, LvhEvtOutputReadCanceled); if (!NT_SUCCESS(status)) { @@ -717,7 +762,7 @@ namespace { return; } - state.pending_output_requests.push_back(request); + state.pending_output_requests.push_back({request, owner_file}); } } @@ -825,6 +870,7 @@ void LvhEvtDeviceCleanup(WDFOBJECT device_object) { void LvhEvtFileCleanup(WDFFILEOBJECT file_object) { trace_status("EvtFileCleanup begin"); delete_vhf_devices_for_file(file_object); + cancel_output_requests_for_file(file_object); } void LvhEvtOutputReadCanceled(WDFREQUEST request) { @@ -853,7 +899,7 @@ void LvhEvtVhfWriteReport( event.driver_device_id = record->driver_device_id; copy_vhf_output_payload(event, *hid_transfer_packet); - queue_output_event(event); + queue_output_event(record->owner_file, event); static_cast(VhfAsyncOperationComplete(vhf_operation_handle, STATUS_SUCCESS)); } diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index 7b687b6..f8aaa1b 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -504,7 +504,16 @@ namespace lvh::detail { class Win32WindowsControlChannel final: public WindowsControlChannel { public: - static std::unique_ptr open(const std::string &path) { + struct SharedHandle { + explicit SharedHandle(UniqueHandle value): + value {std::move(value)} {} + + UniqueHandle value; + }; + + static std::pair, std::unique_ptr> open_pair( + const std::string &path + ) { const auto handle = ::CreateFileA( path.c_str(), GENERIC_READ | GENERIC_WRITE, @@ -516,10 +525,14 @@ namespace lvh::detail { ); if (handle == INVALID_HANDLE_VALUE) { - return nullptr; + return {}; } - return std::make_unique(path, make_unique_handle(handle)); + auto shared_handle = std::make_shared(make_unique_handle(handle)); + return { + std::make_unique(path, shared_handle), + std::make_unique(path, std::move(shared_handle)), + }; } const std::string &path() const override { @@ -590,7 +603,7 @@ namespace lvh::detail { overlapped.hEvent = operation_event.get(); DWORD bytes_returned = 0; - if (const auto started = ::DeviceIoControl(handle_.get(), LVH_WINDOWS_IOCTL_READ_OUTPUT_REPORT, nullptr, 0, &event, sizeof(event), &bytes_returned, &overlapped); started == FALSE) { + if (const auto started = ::DeviceIoControl(handle_->value.get(), LVH_WINDOWS_IOCTL_READ_OUTPUT_REPORT, nullptr, 0, &event, sizeof(event), &bytes_returned, &overlapped); started == FALSE) { if (const auto error_code = ::GetLastError(); error_code != ERROR_IO_PENDING) { return std::nullopt; } @@ -606,16 +619,16 @@ namespace lvh::detail { INFINITE ); if (wait_result == WAIT_OBJECT_0 + 1U) { - static_cast(::CancelIoEx(handle_.get(), &overlapped)); + static_cast(::CancelIoEx(handle_->value.get(), &overlapped)); return std::nullopt; } if (wait_result != WAIT_OBJECT_0) { - static_cast(::CancelIoEx(handle_.get(), &overlapped)); + static_cast(::CancelIoEx(handle_->value.get(), &overlapped)); return std::nullopt; } } - if (::GetOverlappedResult(handle_.get(), &overlapped, &bytes_returned, FALSE) == FALSE) { + if (::GetOverlappedResult(handle_->value.get(), &overlapped, &bytes_returned, FALSE) == FALSE) { return std::nullopt; } @@ -627,7 +640,7 @@ namespace lvh::detail { return event; } - Win32WindowsControlChannel(std::string path, UniqueHandle handle): + Win32WindowsControlChannel(std::string path, std::shared_ptr handle): path_ {std::move(path)}, handle_ {std::move(handle)} {} @@ -642,7 +655,7 @@ namespace lvh::detail { ) const { using enum ErrorCode; - if (::DeviceIoControl(handle_.get(), control_code, &input, sizeof(input), &output, sizeof(output), bytes_returned, nullptr) == FALSE) { + if (::DeviceIoControl(handle_->value.get(), control_code, &input, sizeof(input), &output, sizeof(output), bytes_returned, nullptr) == FALSE) { return windows_failure(backend_failure, operation, ::GetLastError()); } @@ -658,7 +671,7 @@ namespace lvh::detail { ) const { using enum ErrorCode; - if (::DeviceIoControl(handle_.get(), control_code, &input, sizeof(input), nullptr, 0, bytes_returned, nullptr) == FALSE) { + if (::DeviceIoControl(handle_->value.get(), control_code, &input, sizeof(input), nullptr, 0, bytes_returned, nullptr) == FALSE) { return windows_failure(backend_failure, operation, ::GetLastError()); } @@ -666,17 +679,23 @@ namespace lvh::detail { } std::string path_; - UniqueHandle handle_; + std::shared_ptr handle_; }; - std::unique_ptr open_control_channel() { + struct WindowsControlChannels { + std::unique_ptr command; + std::unique_ptr event; + }; + + WindowsControlChannels open_control_channels() { for (const auto &path : resolve_control_device_paths()) { - if (auto channel = Win32WindowsControlChannel::open(path)) { - return channel; + auto [command, event] = Win32WindowsControlChannel::open_pair(path); + if (command && event) { + return {std::move(command), std::move(event)}; } } - return nullptr; + return {}; } class WindowsGamepadState { @@ -1592,10 +1611,10 @@ namespace lvh::detail { class WindowsBackend final: public Backend { public: WindowsBackend(): - WindowsBackend( - open_control_channel(), - open_control_channel() - ) {} + WindowsBackend(open_control_channels()) {} + + explicit WindowsBackend(WindowsControlChannels channels): + WindowsBackend(std::move(channels.command), std::move(channels.event)) {} WindowsBackend( std::unique_ptr command_channel, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 81258e7..ca9d45e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -51,7 +51,8 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") elseif(WIN32) list(APPEND LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/windows_backend_test_hooks.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_windows_backend.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_windows_backend.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_windows_consumers.cpp") elseif(APPLE) list(APPEND LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/macos_backend_test_hooks.cpp" @@ -92,6 +93,11 @@ elseif(APPLE) "-framework Carbon" "-framework CoreFoundation" "-framework IOKit") +elseif(WIN32) + target_link_libraries(${TEST_BINARY} + PRIVATE + hid + setupapi) endif() if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND LIBVIRTUALHID_ENABLE_XTEST AND X11_FOUND AND X11_XTest_FOUND) diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index 71bccca..43a7079 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -214,11 +214,18 @@ namespace lvh::detail { event.size = sizeof(event); event.driver_device_id = driver_id; event.report_size = static_cast(profile.output_report_size); - event.report[0] = profile.report_id; - event.report[1] = 0x78; - event.report[2] = 0x56; - event.report[3] = 0x34; - event.report[4] = 0x12; + if (profile.gamepad_kind == GamepadProfileKind::xbox_one || profile.gamepad_kind == GamepadProfileKind::xbox_series) { + event.report[0] = 0x03; + event.report[3] = 75; + event.report[4] = 100; + event.report[5] = 10; + } else { + event.report[0] = profile.report_id; + event.report[1] = 0x78; + event.report[2] = 0x56; + event.report[3] = 0x34; + event.report[4] = 0x12; + } state->enqueue_output_event(event); } @@ -428,9 +435,11 @@ namespace lvh::detail { std::atomic_bool output_seen {false}; created.gamepad->set_output_callback([&result, &output_seen](const GamepadOutput &output) { - result.last_output = output; - result.saw_output = true; - output_seen.store(true); + if (output.kind == GamepadOutputKind::rumble) { + result.last_output = output; + result.saw_output = true; + output_seen.store(true); + } }); enqueue_output_report(event_state, driver_id, options.profile); static_cast(wait_until([&output_seen] { diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 77fd6fd..ebae0d6 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -456,12 +456,14 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { } TEST_F(LinuxBackendTest, UinputGamepadsNormalizeForceFeedback) { + using enum lvh::GamepadProfileKind; + for (const auto kind : { - lvh::GamepadProfileKind::generic, - lvh::GamepadProfileKind::xbox_360, - lvh::GamepadProfileKind::xbox_one, - lvh::GamepadProfileKind::xbox_series, - lvh::GamepadProfileKind::switch_pro, + generic, + xbox_360, + xbox_one, + xbox_series, + switch_pro, }) { for (const auto effect_type : {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_RAMP}) { const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(kind, effect_type); @@ -854,6 +856,8 @@ TEST_F(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) { } TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranches) { + using enum lvh::GamepadProfileKind; + const auto has_type = [](const lvh::detail::test::LinuxLibevdevCreationResult &result, std::uint32_t type) { return std::ranges::find(result.event_types, type) != result.event_types.end(); }; @@ -878,6 +882,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc struct GamepadCase { lvh::GamepadProfileKind kind; std::uint16_t bustype; + std::uint16_t vendor_id; std::uint16_t product_id; bool key_record; bool sparse_button_slots; @@ -885,11 +890,11 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc }; constexpr std::array gamepad_cases { - GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_BLUETOOTH, 0x028E, false, true, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, - GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, - GamepadCase {lvh::GamepadProfileKind::switch_pro, BUS_USB, 0x2009, false, false, true}, + GamepadCase {generic, BUS_USB, 0x045E, 0x02EA, true, false, false}, + GamepadCase {xbox_360, BUS_BLUETOOTH, 0x045E, 0x028E, false, true, false}, + GamepadCase {xbox_one, BUS_BLUETOOTH, 0x045E, 0x0B20, false, true, false}, + GamepadCase {xbox_series, BUS_BLUETOOTH, 0x045E, 0x0B13, true, true, false}, + GamepadCase {switch_pro, BUS_USB, 0x057E, 0x2009, false, false, true}, }; constexpr std::array active_buttons { BTN_SOUTH, @@ -909,13 +914,13 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc constexpr std::array feedback_codes {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_SINE, FF_RAMP, FF_GAIN}; - for (const auto &[kind, bustype, product_id, key_record, sparse_button_slots, switch_controls] : gamepad_cases) { + for (const auto &[kind, bustype, vendor_id, product_id, key_record, sparse_button_slots, switch_controls] : gamepad_cases) { const auto expected_profile = lvh::profiles::gamepad_profile(kind); ASSERT_TRUE(expected_profile.has_value()); const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind); ASSERT_TRUE(gamepad.status.ok()) << gamepad.status.message(); EXPECT_EQ(gamepad.name, expected_profile->name); - EXPECT_EQ(gamepad.vendor, expected_profile->vendor_id); + EXPECT_EQ(gamepad.vendor, vendor_id); EXPECT_EQ(gamepad.bustype, bustype); EXPECT_EQ(gamepad.product, product_id); EXPECT_EQ(gamepad.version, expected_profile->version); @@ -953,10 +958,10 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc } } - const auto xbox_360 = lvh::detail::test::linux_uinput_create_fake_gamepad(lvh::GamepadProfileKind::xbox_360); - ASSERT_TRUE(xbox_360.status.ok()) << xbox_360.status.message(); + const auto xbox_360_result = lvh::detail::test::linux_uinput_create_fake_gamepad(lvh::GamepadProfileKind::xbox_360); + ASSERT_TRUE(xbox_360_result.status.ok()) << xbox_360_result.status.message(); std::vector xbox_360_button_slots; - for (const auto &event_code : xbox_360.event_codes) { + for (const auto &event_code : xbox_360_result.event_codes) { if (event_code.type == EV_KEY && event_code.code >= BTN_SOUTH && event_code.code <= BTN_THUMBR) { xbox_360_button_slots.push_back(event_code.code); } diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 14d3ef1..bd330cb 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include // lib includes @@ -60,6 +61,7 @@ namespace { lvh::DeviceProfile profile; std::string_view name_suffix; std::string_view stable_id; + std::optional expected_vendor_id; std::optional expected_product_id; int minimum_buttons = 1; int minimum_axes = 2; @@ -208,11 +210,11 @@ namespace { return rumble->observed.load(); } - std::optional wait_for_hidraw_node(lvh::Gamepad &gamepad) { + std::optional wait_for_hidraw_node(const lvh::Gamepad &gamepad) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; while (std::chrono::steady_clock::now() < deadline) { for (const auto &node : gamepad.device_nodes()) { - if (node.kind == lvh::DeviceNodeKind::hidraw && ::access(node.path.c_str(), W_OK) == 0) { + if (node.kind == lvh::DeviceNodeKind::hidraw) { return std::filesystem::path {node.path}; } } @@ -221,6 +223,62 @@ namespace { return std::nullopt; } + std::string describe_device_nodes(const std::vector &nodes) { + if (nodes.empty()) { + return ""; + } + + std::ostringstream description; + for (std::size_t index = 0; index < nodes.size(); ++index) { + if (index != 0) { + description << ", "; + } + description << nodes[index].path + << " (kind=" << static_cast(std::to_underlying(nodes[index].kind)) << ')'; + } + return description.str(); + } + + std::string errno_message(int error) { + return std::error_code {error, std::generic_category()}.message(); + } + + std::string describe_node_permissions(const std::filesystem::path &path) { + struct stat status {}; + if (::stat(path.c_str(), &status) != 0) { + const auto stat_error = errno; + return std::format( + "path={} stat failed: {} (errno={})", + path.string(), + errno_message(stat_error), + stat_error + ); + } + + return std::format( + "path={} mode={:04o} uid={} gid={} euid={} egid={}", + path.string(), + status.st_mode & 07777, + status.st_uid, + status.st_gid, + ::geteuid(), + ::getegid() + ); + } + + int wait_for_write_access(const std::filesystem::path &path) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + int access_error = 0; + while (std::chrono::steady_clock::now() < deadline) { + if (::access(path.c_str(), W_OK) == 0) { + return 0; + } + access_error = errno; + std::this_thread::sleep_for(std::chrono::milliseconds {50}); + } + return access_error; + } + std::vector playstation_rumble_report(const lvh::DeviceProfile &profile) { std::vector report(profile.output_report_size, 0); if (profile.gamepad_kind == lvh::GamepadProfileKind::dualshock4 && report.size() >= 32U) { @@ -418,6 +476,9 @@ namespace { const auto expected_profile = [&test_case]() { auto profile = test_case.profile; profile.name = unique_device_name(test_case.name_suffix); + if (test_case.expected_vendor_id.has_value()) { + profile.vendor_id = *test_case.expected_vendor_id; + } if (test_case.expected_product_id.has_value()) { profile.product_id = *test_case.expected_product_id; } @@ -460,19 +521,34 @@ namespace { void expect_hidraw_rumble_callback(const lvh::DeviceProfile &profile, lvh::Gamepad &gamepad) { const auto rumble = observe_rumble(gamepad); const auto hidraw_node = wait_for_hidraw_node(gamepad); - ASSERT_TRUE(hidraw_node.has_value()); + ASSERT_TRUE(hidraw_node.has_value()) + << "No hidraw node was discovered. Reported device nodes: " + << describe_device_nodes(gamepad.device_nodes()); const auto report = playstation_rumble_report(profile); ASSERT_FALSE(report.empty()); + const auto access_error = wait_for_write_access(*hidraw_node); + ASSERT_EQ(access_error, 0) + << "hidraw node is not writable: " << errno_message(access_error) + << " (errno=" << access_error << "); " << describe_node_permissions(*hidraw_node); + + errno = 0; const auto descriptor = ::open(hidraw_node->c_str(), O_WRONLY | O_CLOEXEC); - ASSERT_GE(descriptor, 0) << std::strerror(errno); + const auto open_error = errno; + ASSERT_GE(descriptor, 0) + << "Failed to open hidraw node: " << errno_message(open_error) + << " (errno=" << open_error << "); " << describe_node_permissions(*hidraw_node); ScopeExit close_descriptor {[descriptor]() { static_cast(::close(descriptor)); }}; - ASSERT_EQ(::write(descriptor, report.data(), report.size()), static_cast(report.size())) - << std::strerror(errno); + errno = 0; + const auto bytes_written = ::write(descriptor, report.data(), report.size()); + const auto write_error = errno; + ASSERT_EQ(bytes_written, static_cast(report.size())) + << "Failed to write the complete rumble report: " << errno_message(write_error) + << " (errno=" << write_error << ")"; EXPECT_TRUE(wait_for_rumble(rumble, false)); EXPECT_GT(rumble->low_frequency.load(), 0); EXPECT_GT(rumble->high_frequency.load(), 0); @@ -516,6 +592,7 @@ namespace { expect_sdl_playstation_controller_profile(controller.get()); if (test_case.expect_live_input) { EXPECT_TRUE(wait_for_sdl_controller_input(controller.get())) << describe_sdl_controller_state(controller.get()); + expect_sdl_rumble_callback(controller.get(), gamepad); expect_hidraw_rumble_callback(expected_profile, gamepad); } } @@ -591,8 +668,7 @@ namespace { const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; while ( std::chrono::steady_clock::now() < deadline && - SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT) < 16000 - ) { + SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT) < 16000) { SDL_GameControllerUpdate(); pump_sdl_events(); std::this_thread::sleep_for(std::chrono::milliseconds {20}); @@ -690,7 +766,9 @@ TEST_F(LinuxConsumerTest, SdlSeesGenericCanonicalButtons) { .profile = lvh::profiles::generic_gamepad(), .name_suffix = "SDL Generic Gamepad", .stable_id = "libvirtualhid-sdl-gamepad-test", - .minimum_buttons = 16, + .expected_vendor_id = 0x045E, + .expected_product_id = 0x02EA, + .minimum_buttons = 11, .minimum_axes = 6, }); } diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 832a464..a0629e8 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -6,11 +6,54 @@ // standard includes #include #include +#include +#include +#include +#include // local includes #include "fixtures/fixtures.hpp" #include +#include + +namespace { + struct PackedButtonCase { + lvh::GamepadButton button; + std::uint16_t bit; + }; + + template + void expect_descriptor_contains( + const lvh::DeviceProfile &profile, + const std::array &expected + ) { + const auto match = std::ranges::search(profile.report_descriptor, expected); + EXPECT_NE(match.begin(), profile.report_descriptor.end()); + } + + std::uint16_t read_u16_le(const std::vector &report, std::size_t offset) { + return static_cast( + report[offset] | static_cast(static_cast(report[offset + 1U]) << 8U) + ); + } + + void expect_packed_button_bits( + const lvh::DeviceProfile &profile, + const std::vector &button_cases, + std::size_t report_offset + ) { + for (const auto &[button, bit] : button_cases) { + lvh::GamepadState state; + state.buttons.set(button); + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_GT(report.size(), report_offset + 1U); + EXPECT_EQ(read_u16_le(report, report_offset), static_cast(1U << bit)) + << "logical button " << static_cast(std::to_underlying(button)); + } + } +} // namespace TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { const auto profiles = lvh::profiles::built_in_gamepad_profiles(); @@ -49,6 +92,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_TRUE(xbox_one.capabilities.supports_rumble); EXPECT_EQ(xbox_one.report_id, 0); EXPECT_EQ(xbox_one.input_report_size, 17U); + EXPECT_EQ(xbox_one.output_report_size, 8U); const auto xbox_series = lvh::profiles::xbox_series(); EXPECT_EQ(xbox_series.vendor_id, 0x045E); @@ -57,44 +101,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_series.manufacturer, "Microsoft"); EXPECT_EQ(xbox_series.report_id, 0); EXPECT_EQ(xbox_series.input_report_size, 17U); - - const std::array xbox_gip_button_descriptor { - 0x05, - 0x09, - 0x09, - 0x01, - 0x09, - 0x02, - 0x09, - 0x04, - 0x09, - 0x05, - 0x09, - 0x07, - 0x09, - 0x08, - 0x09, - 0x0B, - 0x09, - 0x0C, - 0x09, - 0x0E, - 0x09, - 0x0F, - 0x15, - 0x00, - 0x25, - 0x01, - 0x75, - 0x01, - 0x95, - 0x0A, - 0x81, - 0x02, - }; - EXPECT_TRUE( - std::ranges::search(xbox_one.report_descriptor, xbox_gip_button_descriptor).begin() != xbox_one.report_descriptor.end() - ); + EXPECT_EQ(xbox_series.output_report_size, 8U); const std::array xbox_gip_stick_axis_descriptor { 0x15, @@ -254,30 +261,48 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { std::ranges::search(switch_pro.report_descriptor, switch_pro_report_id_descriptor).begin() != switch_pro.report_descriptor.end() ); +} + +TEST(ProfileTest, NativeControllerDescriptorsUseContiguousButtonUsages) { + const auto xbox_one = lvh::profiles::xbox_one(); + const auto xbox_series = lvh::profiles::xbox_series(); + const auto switch_pro = lvh::profiles::switch_pro(); - const std::array switch_pro_button_descriptor { + constexpr std::array xbox_one_buttons { 0x05, 0x09, - 0x09, + 0x19, + 0x01, + 0x29, + 0x0A, + 0x95, + 0x0A, + 0x75, + 0x01, + 0x81, 0x02, + }; + constexpr std::array xbox_series_buttons { + 0x05, 0x09, + 0x19, 0x01, - 0x09, - 0x04, - 0x09, + 0x29, + 0x0C, + 0x95, + 0x0C, + 0x75, + 0x01, + 0x81, + 0x02, + }; + constexpr std::array switch_primary_buttons { 0x05, 0x09, - 0x07, - 0x09, - 0x08, - 0x09, - 0x09, - 0x09, + 0x19, + 0x01, + 0x29, 0x0A, - 0x09, - 0x0B, - 0x09, - 0x0C, 0x15, 0x00, 0x25, @@ -293,22 +318,31 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { 0x81, 0x02, }; - EXPECT_TRUE( - std::ranges::search(switch_pro.report_descriptor, switch_pro_button_descriptor).begin() != - switch_pro.report_descriptor.end() - ); - - const std::array switch_pro_system_button_descriptor { + constexpr std::array switch_system_buttons { 0x05, 0x09, - 0x09, + 0x19, + 0x0B, + 0x29, 0x0E, + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x01, + 0x95, + 0x04, + 0x81, + 0x02, + }; + constexpr std::array switch_compatibility_buttons { + 0x05, 0x09, + 0x19, 0x0F, - 0x09, - 0x0D, - 0x09, - 0x06, + 0x29, + 0x12, 0x15, 0x00, 0x25, @@ -320,18 +354,102 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { 0x81, 0x02, }; - EXPECT_TRUE( - std::ranges::search(switch_pro.report_descriptor, switch_pro_system_button_descriptor).begin() != - switch_pro.report_descriptor.end() - ); + + expect_descriptor_contains(xbox_one, xbox_one_buttons); + expect_descriptor_contains(xbox_series, xbox_series_buttons); + expect_descriptor_contains(switch_pro, switch_primary_buttons); + expect_descriptor_contains(switch_pro, switch_system_buttons); + expect_descriptor_contains(switch_pro, switch_compatibility_buttons); +} + +TEST(ProfileTest, NativeControllerPackedButtonsUseExpectedBitSlots) { + using enum lvh::GamepadButton; + + const auto xbox_one = lvh::profiles::xbox_one(); + const auto xbox_series = lvh::profiles::xbox_series(); + const auto switch_pro = lvh::profiles::switch_pro(); + + const std::vector xbox_button_cases { + {a, 0U}, + {b, 1U}, + {x, 2U}, + {y, 3U}, + {left_shoulder, 4U}, + {right_shoulder, 5U}, + {back, 6U}, + {start, 7U}, + {left_stick, 8U}, + {right_stick, 9U}, + }; + expect_packed_button_bits(xbox_one, xbox_button_cases, 12U); + expect_packed_button_bits(xbox_series, xbox_button_cases, 12U); + expect_packed_button_bits(xbox_series, {{misc1, 11U}}, 12U); + + for (const auto &profile : {xbox_one, xbox_series}) { + lvh::GamepadState state; + state.buttons.set(guide); + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_GT(report.size(), 15U); + EXPECT_EQ(read_u16_le(report, 12U), 0U); + EXPECT_EQ(report[15], 1U); + } + + const std::vector switch_button_cases { + {a, 0U}, + {b, 1U}, + {x, 2U}, + {y, 3U}, + {left_shoulder, 4U}, + {right_shoulder, 5U}, + {back, 8U}, + {start, 9U}, + {left_stick, 10U}, + {right_stick, 11U}, + {guide, 12U}, + {misc1, 13U}, + }; + expect_packed_button_bits(switch_pro, switch_button_cases, 1U); + + lvh::GamepadState trigger_state; + trigger_state.left_trigger = 1.0F; + trigger_state.right_trigger = 1.0F; + const auto trigger_report = lvh::reports::pack_input_report(switch_pro, trigger_state); + ASSERT_GT(trigger_report.size(), 2U); + EXPECT_EQ(read_u16_le(trigger_report, 1U), static_cast((1U << 6U) | (1U << 7U))); + + // Switch usages 15 through 18 are inactive compatibility slots in the upper + // nibble after the hat, not public logical paddle buttons. + EXPECT_EQ(trigger_report[11] & 0xF0U, 0U); } TEST(ProfileTest, RumbleProfilesExposeOutputReports) { const auto generic = lvh::profiles::generic_gamepad(); const auto xbox_360 = lvh::profiles::xbox_360(); + const auto xbox_one = lvh::profiles::xbox_one(); + const auto xbox_series = lvh::profiles::xbox_series(); EXPECT_TRUE(generic.capabilities.supports_rumble); - EXPECT_EQ(generic.output_report_size, 5U); + EXPECT_EQ(generic.output_report_size, 9U); + constexpr std::array pid_rumble_descriptor { + 0x05, + 0x0F, + 0x09, + 0x97, + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x04, + 0x95, + 0x01, + 0x91, + 0x02, + 0x15, + 0x00, + }; + expect_descriptor_contains(generic, pid_rumble_descriptor); EXPECT_TRUE(xbox_360.capabilities.supports_rumble); EXPECT_EQ(xbox_360.output_report_size, 5U); @@ -339,6 +457,11 @@ TEST(ProfileTest, RumbleProfilesExposeOutputReports) { EXPECT_EQ(xbox_360.report_descriptor[xbox_360.report_descriptor.size() - 3U], 0x91); EXPECT_EQ(xbox_360.report_descriptor[xbox_360.report_descriptor.size() - 2U], 0x02); EXPECT_EQ(xbox_360.report_descriptor.back(), 0xC0); + + EXPECT_EQ(xbox_one.output_report_size, 8U); + EXPECT_EQ(xbox_series.output_report_size, 8U); + expect_descriptor_contains(xbox_one, pid_rumble_descriptor); + expect_descriptor_contains(xbox_series, pid_rumble_descriptor); } TEST(ProfileTest, CanFindProfileByKind) { diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index 1f58f12..69bd2bd 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -4,6 +4,7 @@ */ // standard includes +#include #include #include #include @@ -341,6 +342,85 @@ TEST(ReportTest, ParsesRumbleOutputReport) { EXPECT_EQ(output.raw_report, report); } +TEST(ReportTest, ParsesPidRumbleReports) { + const auto expect_outputs = [](const lvh::DeviceProfile &profile, const std::vector &report) { + const auto outputs = lvh::reports::parse_output_reports(profile, report); + + ASSERT_EQ(outputs.size(), 2U); + EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(outputs[0].low_frequency_rumble, 49151U); + EXPECT_EQ(outputs[0].high_frequency_rumble, 65535U); + EXPECT_EQ(outputs[0].raw_report, report); + EXPECT_EQ(outputs[1].kind, lvh::GamepadOutputKind::trigger_rumble); + EXPECT_EQ(outputs[1].left_trigger_rumble, 16384U); + EXPECT_EQ(outputs[1].right_trigger_rumble, 32768U); + EXPECT_EQ(outputs[1].raw_report, report); + }; + + const auto generic = lvh::profiles::generic_gamepad(); + const std::vector generic_report {generic.report_id, 0x0F, 25, 50, 75, 100, 10, 0, 0}; + expect_outputs(generic, generic_report); + + for (const auto &profile : {lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) { + const std::vector payload {0x0F, 25, 50, 75, 100, 10, 0, 0}; + expect_outputs(profile, payload); + + auto prefixed_report = payload; + prefixed_report.insert(prefixed_report.begin(), 0); + expect_outputs(profile, prefixed_report); + } +} + +TEST(ReportTest, PidRumbleHonorsEnableMaskAndDuration) { + const auto profile = lvh::profiles::xbox_one(); + + struct EnableMaskCase { + std::uint8_t mask; + std::uint16_t low_frequency; + std::uint16_t high_frequency; + std::uint16_t left_trigger; + std::uint16_t right_trigger; + }; + + constexpr std::array enable_mask_cases { + EnableMaskCase {0x01, 0, 65535, 0, 0}, + EnableMaskCase {0x02, 65535, 0, 0, 0}, + EnableMaskCase {0x04, 0, 0, 0, 65535}, + EnableMaskCase {0x08, 0, 0, 65535, 0}, + }; + + for (const auto &test_case : enable_mask_cases) { + const std::vector report {test_case.mask, 100, 100, 100, 100, 10, 0, 0}; + const auto outputs = lvh::reports::parse_output_reports(profile, report); + + ASSERT_EQ(outputs.size(), 2U); + EXPECT_EQ(outputs[0].low_frequency_rumble, test_case.low_frequency); + EXPECT_EQ(outputs[0].high_frequency_rumble, test_case.high_frequency); + EXPECT_EQ(outputs[1].left_trigger_rumble, test_case.left_trigger); + EXPECT_EQ(outputs[1].right_trigger_rumble, test_case.right_trigger); + } + + const std::vector zero_duration {0x0F, 100, 100, 100, 100, 0, 0, 0}; + const auto stopped_outputs = lvh::reports::parse_output_reports(profile, zero_duration); + + ASSERT_EQ(stopped_outputs.size(), 2U); + EXPECT_EQ(stopped_outputs[0].low_frequency_rumble, 0U); + EXPECT_EQ(stopped_outputs[0].high_frequency_rumble, 0U); + EXPECT_EQ(stopped_outputs[1].left_trigger_rumble, 0U); + EXPECT_EQ(stopped_outputs[1].right_trigger_rumble, 0U); +} + +TEST(ReportTest, KeepsMalformedPidRumbleReportRaw) { + const auto profile = lvh::profiles::xbox_series(); + const std::vector report {0x0F, 101, 0, 0, 0, 10, 0, 0}; + + const auto outputs = lvh::reports::parse_output_reports(profile, report); + + ASSERT_EQ(outputs.size(), 1U); + EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::raw_report); + EXPECT_EQ(outputs[0].raw_report, report); +} + TEST(ReportTest, ParsesDualSenseOutputReportEvents) { const auto profile = lvh::profiles::dualsense_usb(); std::vector report(profile.output_report_size, 0); @@ -476,21 +556,119 @@ TEST(ReportTest, KeepsUnrecognizedOutputReportsRaw) { const auto generic_output = lvh::reports::parse_output_report(generic_profile, generic_report); - EXPECT_EQ(generic_output.kind, lvh::GamepadOutputKind::rumble); - EXPECT_EQ(generic_output.low_frequency_rumble, 0x1234); - EXPECT_EQ(generic_output.high_frequency_rumble, 0xABCD); + EXPECT_EQ(generic_output.kind, lvh::GamepadOutputKind::raw_report); + EXPECT_EQ(generic_output.low_frequency_rumble, 0U); + EXPECT_EQ(generic_output.high_frequency_rumble, 0U); EXPECT_EQ(generic_output.raw_report, generic_report); const auto switch_profile = lvh::profiles::switch_pro(); std::vector switch_report(switch_profile.output_report_size, 0); - switch_report[0] = switch_profile.report_id; - switch_report[1] = 0x34; - switch_report[2] = 0x12; - switch_report[3] = 0xCD; - switch_report[4] = 0xAB; + switch_report[0] = 0x80; + switch_report[1] = 0x02; const auto switch_output = lvh::reports::parse_output_report(switch_profile, switch_report); EXPECT_EQ(switch_output.kind, lvh::GamepadOutputKind::raw_report); EXPECT_EQ(switch_output.raw_report, switch_report); } + +TEST(ReportTest, ParsesSwitchProRumbleOnlyReport) { + const auto profile = lvh::profiles::switch_pro(); + const std::vector report { + 0x10, + 0x07, + 0x74, + 0x1A, + 0x3D, + 0x59, + 0x74, + 0x1A, + 0x3D, + 0x59, + }; + + const auto output = lvh::reports::parse_output_report(profile, report); + + EXPECT_EQ(output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(output.low_frequency_rumble, 22251U); + EXPECT_EQ(output.high_frequency_rumble, 5213U); + EXPECT_EQ(output.raw_report, report); + + auto padded_report = report; + padded_report.resize(profile.output_report_size, 0); + const auto padded_output = lvh::reports::parse_output_report(profile, padded_report); + + EXPECT_EQ(padded_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(padded_output.low_frequency_rumble, 22251U); + EXPECT_EQ(padded_output.high_frequency_rumble, 5213U); + EXPECT_EQ(padded_output.raw_report, padded_report); +} + +TEST(ReportTest, ParsesSwitchProRumbleFromSubcommandReport) { + const auto profile = lvh::profiles::switch_pro(); + const std::vector report { + 0x01, + 0x0F, + 0x74, + 0x00, + 0xBD, + 0x71, + 0x74, + 0xC8, + 0x3D, + 0x40, + 0x48, + 0x01, + }; + + const auto output = lvh::reports::parse_output_report(profile, report); + + EXPECT_EQ(output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(output.low_frequency_rumble, 64315U); + EXPECT_EQ(output.high_frequency_rumble, 65535U); + EXPECT_EQ(output.raw_report, report); +} + +TEST(ReportTest, ParsesSwitchProNeutralRumbleReport) { + const auto profile = lvh::profiles::switch_pro(); + const std::vector report { + 0x10, + 0x00, + 0x00, + 0x01, + 0x40, + 0x40, + 0x00, + 0x01, + 0x40, + 0x40, + }; + + const auto output = lvh::reports::parse_output_report(profile, report); + + EXPECT_EQ(output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(output.low_frequency_rumble, 0U); + EXPECT_EQ(output.high_frequency_rumble, 0U); + EXPECT_EQ(output.raw_report, report); +} + +TEST(ReportTest, KeepsMalformedSwitchProRumbleReportRaw) { + const auto profile = lvh::profiles::switch_pro(); + const std::vector report { + 0x10, + 0x00, + 0x74, + 0x1A, + 0x3D, + 0x20, + 0x74, + 0x1A, + 0x3D, + 0x59, + }; + + const auto output = lvh::reports::parse_output_report(profile, report); + + EXPECT_EQ(output.kind, lvh::GamepadOutputKind::raw_report); + EXPECT_EQ(output.raw_report, report); +} diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index d9d5e53..33342dd 100644 --- a/tests/unit/test_windows_backend.cpp +++ b/tests/unit/test_windows_backend.cpp @@ -88,10 +88,10 @@ TEST_F(WindowsBackendTest, FakeChannelExercisesLifecycleSubmitCloseAndOutput) { ASSERT_TRUE(result.saw_output); EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); - EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678U); - EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234U); - ASSERT_GE(result.last_output.raw_report.size(), 5U); - EXPECT_EQ(result.last_output.raw_report[0], 0U); + EXPECT_EQ(result.last_output.low_frequency_rumble, 49151U); + EXPECT_EQ(result.last_output.high_frequency_rumble, 65535U); + ASSERT_EQ(result.last_output.raw_report.size(), 8U); + EXPECT_EQ(result.last_output.raw_report[0], 0x03U); } TEST_F(WindowsBackendTest, FakeChannelCoversCreateFailureBranches) { diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp new file mode 100644 index 0000000..81a9b3f --- /dev/null +++ b/tests/unit/test_windows_consumers.cpp @@ -0,0 +1,364 @@ +/** + * @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 + +// Windows base types must be declared before the HID headers. +#include + +// platform includes +#include +#include + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "fixtures/fixtures.hpp" + +#include + +namespace { + using namespace std::chrono_literals; + + class WindowsConsumerTest: public WindowsTest {}; + + struct HidGamepadInterface { + std::wstring path; + std::uint16_t vendor_id = 0; + std::uint16_t product_id = 0; + std::uint16_t output_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_; + }; + + 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; + } + + std::vector detail_storage(required_size); + auto *detail = reinterpret_cast(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 != 0x05U) { + continue; + } + + interfaces.push_back({ + .path = detail->DevicePath, + .vendor_id = attributes.VendorID, + .product_id = attributes.ProductID, + .output_report_size = capabilities.OutputReportByteLength, + }); + } + return interfaces; + } + + std::optional wait_for_new_interface( + const std::set &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(); + const auto position = std::ranges::find_if(interfaces, [&](const auto &interface) { + return interface.vendor_id == vendor_id && interface.product_id == product_id && + !previous_paths.contains(interface.path); + }); + if (position != interfaces.end()) { + return *position; + } + std::this_thread::sleep_for(100ms); + } + return std::nullopt; + } + +} // namespace + +TEST_F(WindowsConsumerTest, NativeDualSenseOutputWriteReachesOwningRuntime) { + const auto profile = lvh::profiles::dualsense_usb(); + std::set previous_paths; + for (const auto &interface : enumerate_gamepad_interfaces()) { + previous_paths.insert(interface.path); + } + + 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"; + + lvh::CreateGamepadOptions options; + options.profile = profile; + std::mutex output_mutex; + std::condition_variable output_ready; + std::vector outputs; + + auto created = lvh::GamepadStateAdapter::create(*runtime, options); + ASSERT_TRUE(created) << created.status.message(); + created.adapter->set_output_callback([&](const lvh::GamepadOutput &output) { + { + std::lock_guard lock {output_mutex}; + outputs.push_back(output); + } + output_ready.notify_all(); + }); + + const auto interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id); + ASSERT_TRUE(interface.has_value()) << "The VHF DualSense HID interface was not enumerated"; + ASSERT_EQ(interface->output_report_size, profile.output_report_size); + + Handle hid {CreateFileW( + 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 DualSense HID interface: " << GetLastError(); + + std::vector report(interface->output_report_size, 0); + report[0] = 0x02; + report[1] = 0x0D; + report[2] = 0x04; + report[3] = 0x80; + report[4] = 0x40; + 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()); + + { + std::unique_lock lock {output_mutex}; + ASSERT_TRUE(output_ready.wait_for(lock, 5s, [&] { + return std::ranges::any_of(outputs, [](const auto &output) { + return output.kind == lvh::GamepadOutputKind::rumble; + }); + })) + << "No normalized rumble callback followed the native HID output writes"; + } + + const auto rumble = std::ranges::find_if(outputs, [](const auto &output) { + return output.kind == lvh::GamepadOutputKind::rumble; + }); + ASSERT_NE(rumble, outputs.end()); + EXPECT_GT(rumble->low_frequency_rumble, 0U); + EXPECT_GT(rumble->high_frequency_rumble, 0U); + EXPECT_EQ(rumble->raw_report, report); +} + +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"; + + for (const auto &profile : {lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) { + std::set previous_paths; + for (const auto &interface : enumerate_gamepad_interfaces()) { + previous_paths.insert(interface.path); + } + + lvh::CreateGamepadOptions options; + options.profile = profile; + std::mutex output_mutex; + std::condition_variable output_ready; + std::vector outputs; + + auto created = lvh::GamepadStateAdapter::create(*runtime, options); + ASSERT_TRUE(created) << created.status.message(); + created.adapter->set_output_callback([&](const lvh::GamepadOutput &output) { + { + std::lock_guard lock {output_mutex}; + outputs.push_back(output); + } + output_ready.notify_all(); + }); + + const auto interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id); + ASSERT_TRUE(interface.has_value()) << "The VHF Xbox HID interface was not enumerated"; + ASSERT_EQ(interface->output_report_size, profile.output_report_size + 1U); + + Handle hid {CreateFileW( + 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()); + + { + std::unique_lock lock {output_mutex}; + ASSERT_TRUE(output_ready.wait_for(lock, 5s, [&] { + return std::ranges::any_of(outputs, [](const auto &output) { + return output.kind == lvh::GamepadOutputKind::rumble; + }); + })) + << "No normalized Xbox rumble callback followed the native HID output write"; + } + + const auto rumble = std::ranges::find_if(outputs, [](const auto &output) { + return output.kind == lvh::GamepadOutputKind::rumble; + }); + ASSERT_NE(rumble, outputs.end()); + EXPECT_EQ(rumble->low_frequency_rumble, 49151U); + EXPECT_EQ(rumble->high_frequency_rumble, 65535U); + ASSERT_TRUE(created.adapter->close().ok()); + } +}