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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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;