From eb6e80a9d1d2cfcfe7751e385a47bb0f0e87ae62 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:24:31 -0400 Subject: [PATCH] fix(Linux): Add Linux Xbox GIP UHID transport support Implement Xbox One/Series over Linux UHID as a GIP transport, including hello/metadata/state handling, guide packets, and direct-motor output decoding to ordinary plus trigger rumble callbacks. Keep a uinput fallback when UHID is unavailable, but clear trigger-rumble capability there, and update profile support wiring, consumer/backend tests, and docs to reflect the new transport preference and behavior. --- README.md | 12 +- docs/end-user-gamepad-guide.md | 34 +- docs/platform-support.md | 84 +-- docs/streaming-host-integration.md | 6 +- docs/usage.md | 4 +- src/core/gamepad_adapter.cpp | 1 + src/core/profiles.cpp | 6 +- src/core/report.cpp | 6 +- src/include/libvirtualhid/types.hpp | 5 + src/platform/linux/uhid_backend.cpp | 484 +++++++++++++++-- tests/CMakeLists.txt | 20 + tests/consumers/linux_sdl3_xbox_consumer.cpp | 492 ++++++++++++++++++ .../fixtures/linux_backend_test_hooks.hpp | 117 +++++ tests/fixtures/linux_backend_test_hooks.cpp | 191 ++++++- tests/unit/test_gamepad_adapter.cpp | 2 + tests/unit/test_linux_backend.cpp | 45 +- tests/unit/test_linux_consumers.cpp | 127 ++++- tests/unit/test_profiles.cpp | 2 + tests/unit/test_report.cpp | 7 +- 19 files changed, 1533 insertions(+), 112 deletions(-) create mode 100644 tests/consumers/linux_sdl3_xbox_consumer.cpp diff --git a/README.md b/README.md index 01da443..295d056 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,16 @@ 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 PlayStation and Switch Pro gamepads through Linux `uhid`; - Generic and Xbox gamepads plus keyboard, mouse, touchscreen, trackpad, and - pen tablet devices through `uinput`. +- Descriptor-driven PlayStation, Switch Pro, Xbox One, and Xbox Series gamepads + through Linux `uhid`; Generic and Xbox 360 gamepads plus keyboard, mouse, + touchscreen, trackpad, and pen tablet devices through `uinput`. Xbox One and + Xbox Series fall back to `uinput` when `uhid` is unavailable. - Windows gamepads, keyboards, and Raw Input-visible mice through a user-mode UMDF2 control driver backed by Virtual HID Framework, with Win32 keyboard and mouse fallbacks when the licensed driver path is unavailable. -- Output callbacks for profile-specific feedback such as rumble, RGB and player - LEDs, adaptive triggers, and raw HID output reports when available. +- Output callbacks for profile-specific feedback such as ordinary and trigger + rumble, RGB and player LEDs, adaptive triggers, and raw HID output reports + when available. - An optional `virtualhid_control` native UI tool for creating, removing, controlling, and inspecting test gamepads and mice through the public C++ API. - CMake consumption through installed packages, vendored source, diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md index d96a004..4d886e6 100644 --- a/docs/end-user-gamepad-guide.md +++ b/docs/end-user-gamepad-guide.md @@ -158,23 +158,23 @@ When a backend is marked ❌, that path cannot establish whether an additional client-side limitation exists. The owner below identifies the first known layer that prevents the feature from working end to end. -| Note | Owner | Limitation or status | Tracker or reference | -|-------------------------------------------------|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) | -| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) | -| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) | -| 4 | Host profile | The Xbox One profile rejects battery updates independently of the client. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) | -| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) | -| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) | -| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) | -| 8 | Linux host backend | Steam exposes the Xbox Series Share button through the Linux uinput device, but pressing it does not change the button state. | [libvirtualhid issue #110](https://github.com/LizardByte/libvirtualhid/issues/110) | -| 9 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) | -| 10 | Host backend | The Linux uinput/evdev Xbox One and Xbox Series profiles support basic rumble but cannot deliver independent Impulse Triggers. | [libvirtualhid issue #109](https://github.com/LizardByte/libvirtualhid/issues/109) | -| 11 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) | -| 12 | Linux host backend | The Linux backend uses descriptor-driven UHID for Switch Pro, advertises a backend-only Bluetooth transport identity that SDL2 HIDAPI accepts for virtual devices, answers its initialization protocol, and carries live native motion reports. Motion and battery were validated end to end from Moonlight Qt v6.1.0 on Windows through Sunshine on Linux into Steam. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) | -| 13 | Streaming host and client output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. Released moonlight-common-c and Moonlight clients do not consume that extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged on both host backends. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) | -| 14 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) | -| 15 | Client capability and output pipeline | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) | +| Note | Owner | Limitation or status | Tracker or reference | +|-------------------------------------------------|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) | +| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) | +| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) | +| 4 | Host profile | The Xbox One profile rejects battery updates independently of the client. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) | +| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) | +| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) | +| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) | +| 8 | Linux host backend | Steam exposes the Xbox Series Share button through the Linux virtual controller, but pressing it does not change the button state. | [libvirtualhid issue #110](https://github.com/LizardByte/libvirtualhid/issues/110) | +| 9 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) | +| 10 | Linux host backend | Xbox One and Xbox Series prefer native Bluetooth HID over UHID, which preserves canonical controls, ordinary rumble, and independent Impulse Triggers for compatible HIDAPI consumers. If UHID is unavailable, they fall back to uinput: standard controls, analog triggers, and ordinary rumble remain available, but the effective profile reports Impulse Triggers as unsupported. | [libvirtualhid issue #109](https://github.com/LizardByte/libvirtualhid/issues/109) | +| 11 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) | +| 12 | Linux host backend | The Linux backend uses descriptor-driven UHID for Switch Pro, advertises a backend-only Bluetooth transport identity that SDL2 HIDAPI accepts for virtual devices, answers its initialization protocol, and carries live native motion reports. Motion and battery were validated end to end from Moonlight Qt v6.1.0 on Windows through Sunshine on Linux into Steam. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) | +| 13 | Streaming host and client output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. Released moonlight-common-c and Moonlight clients do not consume that extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged on both host backends. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) | +| 14 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) | +| 15 | Client capability and output pipeline | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) | Analog trigger input reports intermediate values between 0 and 1. Switch Pro ZL/ZR input is digital and reports only 0 or 1. Trigger input is also separate diff --git a/docs/platform-support.md b/docs/platform-support.md index 1d3e3fb..b9bdaaf 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -109,30 +109,49 @@ and signing details. The Linux backend uses standard user-space kernel interfaces: -- `uhid` for descriptor-driven PlayStation and Switch Pro gamepads. -- `uinput` for Generic, Xbox 360, Xbox One, and Xbox Series gamepads, plus - keyboard, mouse, touchscreen, trackpad, and pen tablet devices. +- `uhid` for descriptor-driven PlayStation, Switch Pro, Xbox One, and Xbox + Series gamepads. +- `uinput` for Generic and Xbox 360 gamepads, for Xbox One and Xbox Series when + `uhid` is unavailable, and for keyboard, mouse, touchscreen, trackpad, and pen + tablet devices. - `libevdev` internally for uinput device construction. - X11/XTest only as a keyboard and mouse fallback when `uinput` cannot be used and an X11 session is available. Gamepad support normally prefers `uhid` because descriptors, raw HID identity, -feature reports, and output reports matter for controller compatibility. -Generic and Xbox-family profiles instead use `uinput` so SDL, -Steam, browser Gamepad API implementations, and other evdev consumers receive -canonical Linux gamepad events. Face buttons, shoulders, menu buttons, stick -clicks, and Guide use their native evdev codes; sticks use absolute axes. Every -uinput gamepad exposes its directional pad through `ABS_HAT0X` and `ABS_HAT0Y`. -Generic and Xbox triggers remain independent analog `ABS_Z` and `ABS_RZ` axes. -Profiles with rumble support normalize rumble, constant, periodic, and ramp -uinput force-feedback effects back into the public callback. Each requested playback repetition -restarts the effect's ramp and envelope timing. A zero-length effect remains -active until its explicit stop event, matching the infinite-effect contract used -by SDL and Steam. The Linux backend lets a new uinput device settle before -reading those effects, so an early poll error cannot disable feedback for the -device lifetime. Generated UHID nodes are correlated by stable physical and -unique identifiers when available, with device-name matching used only as a -fallback. PlayStation rumble is read from native UHID interrupt-channel output +feature reports, and output reports matter for controller compatibility. Xbox +One and Xbox Series use backend-only Bluetooth identities with a 283-byte BLE +descriptor, sparse input bitmap, and four-motor output framing. The report keeps +the native byte layout used by HIDAPI while advertising `Rx`/`Ry` for the right +stick and `Z`/`Rz` for the triggers, so Linux evdev exposes the canonical +`ABS_RX`/`ABS_RY` and `ABS_Z`/`ABS_RZ` axes expected by Steam. This keeps the bus, +vendor/product identity, descriptor, and reports consistent so Linux input, +Steam, SDL2, and SDL3 select the canonical Xbox mapping and can expose ordinary +and independent trigger rumble. A Bluetooth transport is necessary because +Linux HIDAPI implementations require a physical USB parent for `BUS_USB` +hidraw devices, which a user-space UHID device cannot provide. This transport +override does not change the public Xbox profiles or the platform-neutral API. + +Generic and Xbox 360 profiles use `uinput` so SDL, Steam, browser Gamepad API +implementations, and other evdev consumers receive canonical Linux gamepad +events. Xbox One and Xbox Series use the same path only as a fallback when +`/dev/uhid` cannot be opened or initialized. Face buttons, shoulders, menu +buttons, stick clicks, and Guide use their native evdev codes; sticks use +absolute axes. Every uinput gamepad exposes its directional pad through +`ABS_HAT0X` and `ABS_HAT0Y`. Generic and Xbox triggers remain independent analog +`ABS_Z` and `ABS_RZ` axes. Profiles with rumble support normalize rumble, +constant, periodic, and ramp uinput force-feedback effects back into the public +callback. Each requested playback repetition restarts the effect's ramp and +envelope timing. A zero-length effect remains active until its explicit stop +event, matching the infinite-effect contract used by SDL and Steam. The Linux +backend lets a new uinput device settle before reading those effects, so an +early poll error cannot disable feedback for the device lifetime. + +Generated UHID nodes are correlated by stable physical and unique identifiers +when available, with device-name matching used only as a fallback. UHID +identities include the virtual profile's vendor and product IDs so applications +do not reuse metadata from another profile after the same virtual slot changes +profiles. PlayStation rumble is read from native UHID interrupt-channel output reports. The Generic profile keeps its public `0x1209:0x0001` identity, USB bus, and @@ -142,16 +161,23 @@ which avoids changing the raw button capability surface. It uses a compact Generic button layout rather than the sparse Xbox button slots. Xbox 360 retains its `0x045E:0x028E` identity, while its Linux uinput device uses -the Bluetooth bus, so consumers select the sparse button mapping. -Xbox One and Xbox Series retain their public USB identities, but their Linux -uinput devices use the corresponding Bluetooth product identities (`0x0B20` -and `0x0B13`, respectively), whose standard consumer mappings match the events -that uinput exposes. Those three Xbox profiles preserve the 15-slot -Linux gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` -slots are advertised but never pressed, keeping face buttons, shoulders, menu -buttons, Guide, L3, and R3 at their expected indices. D-pad directions are -reported through the hat axes and exposed as logical buttons by standard -gamepad consumers. +the Bluetooth bus, so consumers select the sparse button mapping. The Xbox One +and Xbox Series UHID transports use the native Bluetooth product identities +`0x045E:0x0B20` and `0x045E:0x0B13`, respectively. Their Bluetooth HID reports +carry canonical gamepad input and four-motor output, which the backend decodes +into ordinary and independent trigger-rumble callbacks. + +If UHID is unavailable, the Xbox One and Xbox Series uinput fallbacks use the +corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, respectively), +whose standard consumer mappings match the events that uinput exposes. The Xbox +uinput profiles preserve the 15-slot Linux gamepad button sequence: unused +`BTN_C`, `BTN_Z`, `BTN_TL2`, and `BTN_TR2` slots are advertised but never +pressed, keeping face buttons, shoulders, menu buttons, Guide, L3, and R3 at +their expected indices. D-pad directions are reported through the hat axes and +exposed as logical buttons by standard gamepad consumers. The fallback retains +all of those controls, analog trigger input, and ordinary force feedback, but +Linux uinput cannot expose independent trigger motors, so its effective profile +clears trigger-rumble support. DualShock 4 and DualSense remain on `uhid` so their descriptors, motion, touchpad, battery, feature reports, and profile-specific output reports stay diff --git a/docs/streaming-host-integration.md b/docs/streaming-host-integration.md index d913bc6..872bae4 100644 --- a/docs/streaming-host-integration.md +++ b/docs/streaming-host-integration.md @@ -52,8 +52,10 @@ The core API and adapter shape cover the major streaming-host requirements: - Rich controller metadata. - Gamepad output callbacks. - Keyboard and mouse input paths. -- Linux PlayStation and Switch Pro gamepads through `uhid`, Generic/Xbox - gamepads through `uinput`, and `uinput` keyboard/pointer devices. +- Linux PlayStation, Switch Pro, Xbox One, and Xbox Series gamepads through + descriptor-driven `uhid`, Generic and Xbox 360 gamepads through `uinput`, + Xbox One and Xbox Series uinput fallbacks, and `uinput` keyboard/pointer + devices. - Native Switch Pro motion, initialization replies, rumble, HOME-light, and player-light output handling on Linux and Windows descriptor-driven backends. - Linux DualSense and DualShock 4 USB/Bluetooth report handling. diff --git a/docs/usage.md b/docs/usage.md index 6dcd69e..a2f34ba 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -233,7 +233,9 @@ Profiles advertise support for features such as rumble, trigger rumble, RGB and player 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. +features. Xbox One and Xbox Series advertise `supports_trigger_rumble`; the +Linux UHID Bluetooth transport preserves that capability, while the uinput +fallback clears it and retains ordinary rumble. 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/gamepad_adapter.cpp b/src/core/gamepad_adapter.cpp index 3800876..9be266c 100644 --- a/src/core/gamepad_adapter.cpp +++ b/src/core/gamepad_adapter.cpp @@ -81,6 +81,7 @@ namespace lvh { } support.supports_rumble = profile.capabilities.supports_rumble; + support.supports_trigger_rumble = profile.capabilities.supports_trigger_rumble; support.supports_rgb_led = profile.capabilities.supports_rgb_led; support.supports_adaptive_triggers = profile.capabilities.supports_adaptive_triggers; support.supports_player_leds = profile.capabilities.supports_player_leds; diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index 0790b6a..abb71ed 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -1958,7 +1958,11 @@ namespace lvh::profiles { profile.output_report_size = pid_rumble_payload_size; profile.name = std::move(name); profile.manufacturer = "Microsoft"; - profile.capabilities = {.supports_rumble = true, .supports_battery = include_share_button}; + profile.capabilities = { + .supports_rumble = true, + .supports_trigger_rumble = true, + .supports_battery = include_share_button, + }; profile.report_descriptor = make_xbox_gip_report_descriptor(include_share_button); return profile; } diff --git a/src/core/report.cpp b/src/core/report.cpp index 2a41614..9ba47aa 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -367,9 +367,9 @@ namespace lvh::reports { return std::nullopt; } - // Some native Xbox Series transports use report ID 3 followed by the - // same eight-byte four-motor rumble payload. - if (profile.gamepad_kind == xbox_series && report.size() >= pid_rumble_report_size && report[0] == 0x03U) { + // Native Xbox Bluetooth transports use report ID 3 followed by the same + // eight-byte four-motor rumble payload for both Xbox One and Series. + if (report.size() >= pid_rumble_report_size && report[0] == 0x03U) { return 1U; } diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index b1d58de..7f3bc1c 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -246,6 +246,11 @@ namespace lvh { */ bool supports_rumble = false; + /** + * @brief Whether the profile supports independent trigger rumble output. + */ + bool supports_trigger_rumble = false; + /** * @brief Whether the profile exposes motion sensors. */ diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index de06311..83f39cf 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -103,6 +103,305 @@ namespace lvh::detail { namespace ps = playstation_feature_reports; constexpr auto playstation_periodic_report_ms = 10; constexpr auto uhid_start_timeout = std::chrono::seconds {5}; + + constexpr std::uint16_t xbox_bluetooth_version = 0x0513; + constexpr std::uint8_t xbox_bluetooth_input_report_id = 0x01; + constexpr std::size_t xbox_bluetooth_input_report_size = 17; + constexpr std::uint8_t xbox_bluetooth_rumble_report_id = 0x03; + constexpr std::size_t xbox_bluetooth_rumble_report_size = 9; + + std::vector make_xbox_bluetooth_report_descriptor() { + // Preserve the native 283-byte Xbox BLE report layout while advertising + // the conventional Linux evdev usages for the right stick and triggers. + // HIDAPI consumes the same byte offsets directly, while the kernel maps + // these usages to ABS_RX/ABS_RY and ABS_Z/ABS_RZ for Steam. + constexpr std::array descriptor { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x05, // Usage (Game Pad) + 0xA1, + 0x01, // Collection (Application) + 0x85, + xbox_bluetooth_input_report_id, // Report ID (1) + 0x09, + 0x01, // Usage (Pointer) + 0xA1, + 0x00, // Collection (Physical) + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x15, + 0x00, // Logical Minimum (0) + 0x27, + 0xFF, + 0xFF, + 0x00, + 0x00, // Logical Maximum (65534) + 0x95, + 0x02, // Report Count (2) + 0x75, + 0x10, // Report Size (16) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0xC0, // End Collection + 0x09, + 0x01, // Usage (Pointer) + 0xA1, + 0x00, // Collection (Physical) + 0x09, + 0x33, // Usage (Rx) + 0x09, + 0x34, // Usage (Ry) + 0x15, + 0x00, // Logical Minimum (0) + 0x27, + 0xFF, + 0xFF, + 0x00, + 0x00, // Logical Maximum (65534) + 0x95, + 0x02, // Report Count (2) + 0x75, + 0x10, // Report Size (16) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0xC0, // End Collection + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x32, // Usage (Z) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x03, // Logical Maximum (1023) + 0x95, + 0x01, // Report Count (1) + 0x75, + 0x0A, // Report Size (10) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x06, // Report Size (6) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x35, // Usage (Rz) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x03, // Logical Maximum (1023) + 0x95, + 0x01, // Report Count (1) + 0x75, + 0x0A, // Report Size (10) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x06, // Report Size (6) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x39, // Usage (Hat Switch) + 0x15, + 0x01, // Logical Minimum (1) + 0x25, + 0x08, // Logical Maximum (8) + 0x35, + 0x00, // Physical Minimum (0) + 0x46, + 0x3B, + 0x01, // Physical Maximum (315) + 0x66, + 0x14, + 0x00, // Unit (Degrees) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x42, // Input (Data, Variable, Absolute, Null State) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x35, + 0x00, // Physical Minimum (0) + 0x45, + 0x00, // Physical Maximum (0) + 0x65, + 0x00, // Unit (None) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x09, // Usage Page (Button) + 0x19, + 0x01, // Usage Minimum (Button 1) + 0x29, + 0x0F, // Usage Maximum (Button 15) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x0F, // Report Count (15) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x0C, // Usage Page (Consumer) + 0x0A, + 0xB2, + 0x00, // Usage (Record) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x95, + 0x01, // Report Count (1) + 0x75, + 0x01, // Report Size (1) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x07, // Report Size (7) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x0F, // Usage Page (Physical Interface Device) + 0x09, + 0x21, // Usage (Set Effect Report) + 0x85, + xbox_bluetooth_rumble_report_id, // Report ID (3) + 0xA1, + 0x02, // Collection (Logical) + 0x09, + 0x97, // Usage (DC Enable Actuators) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x03, // Output (Constant, Variable, Absolute) + 0x09, + 0x70, // Usage (Magnitude) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x64, // Logical Maximum (100) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x04, // Report Count (4) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x09, + 0x50, // Usage (Duration) + 0x66, + 0x01, + 0x10, // Unit (Seconds) + 0x55, + 0x0E, // Unit Exponent (-2) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x09, + 0xA7, // Usage (Start Delay) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x65, + 0x00, // Unit (None) + 0x55, + 0x00, // Unit Exponent (0) + 0x09, + 0x7C, // Usage (Loop Count) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0xC0, // End Collection + 0xC0, // End Collection + }; + return {descriptor.begin(), descriptor.end()}; + } #endif int system_access(const char *path, int mode) { @@ -239,6 +538,10 @@ namespace lvh::detail { bool is_playstation_profile(GamepadProfileKind kind) { return kind == GamepadProfileKind::dualshock4 || kind == GamepadProfileKind::dualsense; } + + bool is_xbox_uhid_profile(GamepadProfileKind kind) { + return kind == GamepadProfileKind::xbox_one || kind == GamepadProfileKind::xbox_series; + } #endif bool uses_uinput_gamepad_profile(GamepadProfileKind kind) { @@ -263,6 +566,84 @@ namespace lvh::detail { return false; } +#if defined(__linux__) + bool prefers_uhid_gamepad_profile(GamepadProfileKind kind) { + switch (kind) { + using enum GamepadProfileKind; + + case switch_pro: + case dualshock4: + case dualsense: + case xbox_one: + case xbox_series: + return true; + case generic: + case xbox_360: + return false; + } + + return false; + } + + DeviceProfile uhid_transport_profile(const DeviceProfile &requested_profile) { + auto transport_profile = requested_profile; + if (is_xbox_uhid_profile(requested_profile.gamepad_kind)) { + const auto series = requested_profile.gamepad_kind == GamepadProfileKind::xbox_series; + transport_profile.bus_type = BusType::bluetooth; + transport_profile.product_id = series ? xbox_series_uinput_product_id : xbox_wireless_uinput_product_id; + transport_profile.version = xbox_bluetooth_version; + transport_profile.report_id = xbox_bluetooth_input_report_id; + transport_profile.input_report_size = xbox_bluetooth_input_report_size; + transport_profile.output_report_size = xbox_bluetooth_rumble_report_size; + transport_profile.report_descriptor = make_xbox_bluetooth_report_descriptor(); + } + return transport_profile; + } + + std::uint16_t read_u16_le(std::span report, std::size_t offset) { + return static_cast(report[offset]) | + static_cast(static_cast(report[offset + 1U]) << 8U); + } + + void write_u16_le(std::vector &report, std::size_t offset, std::uint16_t value) { + report[offset] = static_cast(value & 0xFFU); + report[offset + 1U] = static_cast((value >> 8U) & 0xFFU); + } + + std::vector make_xbox_bluetooth_input_report( + const GamepadState &state, + std::span packed_report, + bool include_share_button + ) { + if (packed_report.size() < 17U) { + return {}; + } + + using enum GamepadButton; + std::vector report(xbox_bluetooth_input_report_size, 0); + report[0] = xbox_bluetooth_input_report_id; + std::copy_n(packed_report.begin(), 8U, report.begin() + 1U); + write_u16_le(report, 9U, read_u16_le(packed_report, 8U)); + write_u16_le(report, 11U, read_u16_le(packed_report, 10U)); + report[13] = packed_report[14]; + report[14] = static_cast( + (state.buttons.test(a) ? 0x01U : 0U) | (state.buttons.test(b) ? 0x02U : 0U) | + (state.buttons.test(x) ? 0x08U : 0U) | (state.buttons.test(y) ? 0x10U : 0U) | + (state.buttons.test(left_shoulder) ? 0x40U : 0U) | (state.buttons.test(right_shoulder) ? 0x80U : 0U) + ); + report[15] = static_cast( + (state.buttons.test(back) ? 0x04U : 0U) | (state.buttons.test(start) ? 0x08U : 0U) | + (state.buttons.test(guide) ? 0x10U : 0U) | (state.buttons.test(left_stick) ? 0x20U : 0U) | + (state.buttons.test(right_stick) ? 0x40U : 0U) + ); + if (include_share_button && state.buttons.test(misc1)) { + report[16] = 0x01; + } + return report; + } + +#endif + std::optional uinput_misc1_button(GamepadProfileKind kind) { switch (kind) { using enum GamepadProfileKind; @@ -303,9 +684,10 @@ namespace lvh::detail { #if defined(__linux__) std::uint16_t to_uhid_bus(const DeviceProfile &profile) { // Linux SDL2 HIDAPI requires BUS_USB hidraw devices to have a physical USB - // parent in sysfs. UHID devices do not, so expose Switch Pro through the - // Bluetooth HID path that accepts descriptor-driven virtual devices. - if (profile.gamepad_kind == GamepadProfileKind::switch_pro) { + // parent in sysfs. UHID devices do not, so expose transports handled by + // HIDAPI through the Bluetooth HID enumeration path. Xbox transport + // profiles use the corresponding native Bluetooth identity and framing. + if (profile.gamepad_kind == GamepadProfileKind::switch_pro || is_xbox_uhid_profile(profile.gamepad_kind)) { return BUS_BLUETOOTH; } return to_uhid_bus(profile.bus_type); @@ -320,6 +702,13 @@ namespace lvh::detail { } return profile.name; } + + std::string uhid_profile_scoped_id( + const DeviceProfile &profile, + std::string_view stable_id + ) { + return std::format("{:04x}:{:04x}/{}", profile.vendor_id, profile.product_id, stable_id); + } #endif std::uint16_t to_uinput_bus(BusType bus_type) { @@ -2794,29 +3183,40 @@ namespace lvh::detail { OperationStatus create(DeviceId id, const CreateGamepadOptions &options) { uhid_event event {}; auto &request = event.u.create2; + const auto transport_profile = uhid_transport_profile(options.profile); - if (options.profile.report_descriptor.size() > sizeof(request.rd_data)) { + if (transport_profile.report_descriptor.size() > sizeof(request.rd_data)) { return OperationStatus::failure(ErrorCode::unsupported_profile, "HID report descriptor is too large for UHID"); } event.type = UHID_CREATE2; - unique_id_ = options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id; - if (is_playstation_profile(options.profile.gamepad_kind)) { + const auto stable_id = options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id; + unique_id_ = uhid_profile_scoped_id(transport_profile, stable_id); + if (is_playstation_profile(transport_profile.gamepad_kind)) { playstation_mac_address_ = parse_mac_address(options.metadata.stable_id).value_or(generated_mac_address(id)); unique_id_ = format_mac_address(playstation_mac_address_); } - physical_id_ = std::format("libvirtualhid/uhid/{}", id); + physical_id_ = std::format( + "libvirtualhid/uhid/{:04x}:{:04x}/{}", + transport_profile.vendor_id, + transport_profile.product_id, + id + ); - device_name_ = uhid_gamepad_name(options.profile); + device_name_ = uhid_gamepad_name(transport_profile); copy_string(request.name, device_name_); copy_string(request.phys, physical_id_); copy_string(request.uniq, unique_id_); - request.rd_size = static_cast(options.profile.report_descriptor.size()); - request.bus = to_uhid_bus(options.profile); - request.vendor = options.profile.vendor_id; - request.product = options.profile.product_id; - request.version = options.profile.version; - std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size()); + request.rd_size = static_cast(transport_profile.report_descriptor.size()); + request.bus = to_uhid_bus(transport_profile); + request.vendor = transport_profile.vendor_id; + request.product = transport_profile.product_id; + request.version = transport_profile.version; + std::memcpy( + request.rd_data, + transport_profile.report_descriptor.data(), + transport_profile.report_descriptor.size() + ); profile_ = options.profile; { std::lock_guard lock {state_mutex_}; @@ -2856,7 +3256,14 @@ namespace lvh::detail { const std::vector &report ) override { std::lock_guard lock {state_mutex_}; - auto status = write_input_report(report); + const auto transport_report = is_xbox_uhid_profile(profile_.gamepad_kind) ? + make_xbox_bluetooth_input_report( + state, + report, + profile_.gamepad_kind == GamepadProfileKind::xbox_series + ) : + report; + auto status = write_input_report(transport_report); if (status.ok()) { last_state_ = state; } @@ -3009,6 +3416,8 @@ namespace lvh::detail { } lifecycle_condition_.notify_all(); break; + case UHID_OPEN: + break; case UHID_OUTPUT: dispatch_output_report(event.u.output.data, event.u.output.size); break; @@ -3206,9 +3615,10 @@ namespace lvh::detail { #endif std::optional effective_uinput_profile(const DeviceProfile &requested_profile) { -#if defined(__FreeBSD__) auto effective_profile = requested_profile; +#if defined(__FreeBSD__) effective_profile.output_report_size = 0; + effective_profile.capabilities.supports_trigger_rumble = false; effective_profile.capabilities.supports_motion = false; effective_profile.capabilities.supports_touchpad = false; effective_profile.capabilities.supports_rgb_led = false; @@ -3217,8 +3627,11 @@ namespace lvh::detail { effective_profile.capabilities.supports_player_leds = false; return effective_profile; #else - static_cast(requested_profile); - return std::nullopt; + if (!effective_profile.capabilities.supports_trigger_rumble) { + return std::nullopt; + } + effective_profile.capabilities.supports_trigger_rumble = false; + return effective_profile; #endif } @@ -3252,6 +3665,24 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { +#if defined(__linux__) + if (prefers_uhid_gamepad_profile(options.profile.gamepad_kind)) { + const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd >= 0) { + auto gamepad = std::make_unique(fd); + if (const auto status = gamepad->create(id, options); status.ok()) { + return {OperationStatus::success(), std::move(gamepad)}; + } else if (!uses_uinput_gamepad_profile(options.profile.gamepad_kind)) { + static_cast(gamepad->close()); + return {status, nullptr}; + } + static_cast(gamepad->close()); + } else if (!uses_uinput_gamepad_profile(options.profile.gamepad_kind)) { + return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; + } + } +#endif + if (uses_uinput_gamepad_profile(options.profile.gamepad_kind)) { const auto fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { @@ -3270,24 +3701,13 @@ namespace lvh::detail { }; } -#if defined(__linux__) - 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}; - } - - 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)}; -#else +#if defined(__FreeBSD__) return { OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile requires Linux UHID"), nullptr, }; +#else + return {OperationStatus::failure(ErrorCode::unsupported_profile, "unsupported gamepad profile"), nullptr}; #endif } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d588464..73ff209 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -44,6 +44,11 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") pkg_check_modules(LIBEVDEV REQUIRED IMPORTED_TARGET libevdev) pkg_check_modules(LIBINPUT REQUIRED IMPORTED_TARGET libinput) pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) + if(NOT TARGET SDL3::SDL3 AND NOT TARGET SDL3::SDL3-static) + include("${PROJECT_SOURCE_DIR}/cmake/cpm/CPM.cmake") + CPMUsePackageLock("${PROJECT_SOURCE_DIR}/package-lock.cmake") + CPMGetPackage(SDL3) + endif() list(APPEND LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/linux_backend_test_hooks.cpp" @@ -108,6 +113,21 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") PkgConfig::LIBEVDEV PkgConfig::LIBINPUT PkgConfig::SDL2) + + set(LIBVIRTUALHID_TEST_SDL3_TARGET SDL3::SDL3) + if(TARGET SDL3::SDL3-static) + set(LIBVIRTUALHID_TEST_SDL3_TARGET SDL3::SDL3-static) + endif() + add_executable(test_linux_sdl3_xbox_consumer + "${CMAKE_CURRENT_SOURCE_DIR}/consumers/linux_sdl3_xbox_consumer.cpp") + target_link_libraries(test_linux_sdl3_xbox_consumer + PRIVATE + libvirtualhid::libvirtualhid + ${LIBVIRTUALHID_TEST_SDL3_TARGET}) + add_dependencies(${TEST_BINARY} test_linux_sdl3_xbox_consumer) + target_compile_definitions(${TEST_BINARY} + PRIVATE + LIBVIRTUALHID_TEST_SDL3_XBOX_CONSUMER_PATH="$") elseif(APPLE) target_link_libraries(${TEST_BINARY} PRIVATE diff --git a/tests/consumers/linux_sdl3_xbox_consumer.cpp b/tests/consumers/linux_sdl3_xbox_consumer.cpp new file mode 100644 index 0000000..357d1e7 --- /dev/null +++ b/tests/consumers/linux_sdl3_xbox_consumer.cpp @@ -0,0 +1,492 @@ +/** + * @file tests/consumers/linux_sdl3_xbox_consumer.cpp + * @brief SDL3 consumer probe for Linux Xbox UHID gamepads. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include + +// lib includes +#include +#include +#include + +namespace { + + using namespace std::chrono_literals; + using SdlGamepad = std::unique_ptr; + + struct ProfileCase { + lvh::DeviceProfile profile; + std::uint16_t product_id; + std::string_view identity_token; + }; + + struct OutputCapture { + std::mutex mutex; + std::atomic_bool ordinary_rumble = false; + std::atomic_bool trigger_rumble = false; + std::uint16_t low_frequency = 0; + std::uint16_t high_frequency = 0; + std::uint16_t left_trigger = 0; + std::uint16_t right_trigger = 0; + }; + + struct ButtonCase { + lvh::GamepadButton logical_button; + SDL_GamepadButton sdl_button; + }; + + using enum lvh::GamepadButton; + constexpr std::array button_cases { + ButtonCase {a, SDL_GAMEPAD_BUTTON_SOUTH}, + ButtonCase {b, SDL_GAMEPAD_BUTTON_EAST}, + ButtonCase {x, SDL_GAMEPAD_BUTTON_WEST}, + ButtonCase {y, SDL_GAMEPAD_BUTTON_NORTH}, + ButtonCase {left_shoulder, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, + ButtonCase {right_shoulder, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, + ButtonCase {back, SDL_GAMEPAD_BUTTON_BACK}, + ButtonCase {start, SDL_GAMEPAD_BUTTON_START}, + ButtonCase {guide, SDL_GAMEPAD_BUTTON_GUIDE}, + ButtonCase {left_stick, SDL_GAMEPAD_BUTTON_LEFT_STICK}, + ButtonCase {right_stick, SDL_GAMEPAD_BUTTON_RIGHT_STICK}, + ButtonCase {dpad_up, SDL_GAMEPAD_BUTTON_DPAD_UP}, + ButtonCase {dpad_down, SDL_GAMEPAD_BUTTON_DPAD_DOWN}, + ButtonCase {dpad_left, SDL_GAMEPAD_BUTTON_DPAD_LEFT}, + ButtonCase {dpad_right, SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, + }; + + class SdlSubsystem { + public: + SdlSubsystem() { + SDL_SetLogPriority(SDL_LOG_CATEGORY_INPUT, SDL_LOG_PRIORITY_DEBUG); + SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_XBOX, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_XBOX_ONE, "1"); + initialized_ = SDL_Init(SDL_INIT_GAMEPAD | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS); + } + + SdlSubsystem(const SdlSubsystem &) = delete; + SdlSubsystem &operator=(const SdlSubsystem &) = delete; + + ~SdlSubsystem() { + if (initialized_) { + SDL_Quit(); + } + } + + bool initialized() const { + return initialized_; + } + + private: + bool initialized_ = false; + }; + + int fail(std::string_view message) { + std::cerr << message; + if (const auto *error = SDL_GetError(); error != nullptr && *error != '\0') { + std::cerr << ": " << error; + } + std::cerr << '\n'; + return EXIT_FAILURE; + } + + std::string_view nullable_text(const char *text) { + return text == nullptr ? "" : text; + } + + void log_discovery_state() { + int joystick_count = 0; + auto *joysticks = SDL_GetJoysticks(&joystick_count); + std::cerr << "SDL3 joystick count: " << joystick_count << '\n'; + for (int index = 0; index < joystick_count; ++index) { + const auto id = joysticks[index]; + std::cerr << std::format( + " id={} {:04x}:{:04x} gamepad={} name={} path={}\n", + id, + SDL_GetJoystickVendorForID(id), + SDL_GetJoystickProductForID(id), + SDL_IsGamepad(id), + nullable_text(SDL_GetJoystickNameForID(id)), + nullable_text(SDL_GetJoystickPathForID(id)) + ); + } + SDL_free(joysticks); + + auto *devices = SDL_hid_enumerate(0x045E, 0); + std::cerr << "SDL3 Microsoft HIDAPI devices:\n"; + for (auto *device = devices; device != nullptr; device = device->next) { + std::cerr << std::format( + " {:04x}:{:04x} bus={} usage={:04x}:{:04x} path={}\n", + device->vendor_id, + device->product_id, + std::to_underlying(device->bus_type), + device->usage_page, + device->usage, + nullable_text(device->path) + ); + } + SDL_hid_free_enumeration(devices); + } + + void log_device_nodes(const lvh::Gamepad &gamepad) { + std::cerr << "libvirtualhid device nodes:\n"; + for (const auto &node : gamepad.device_nodes()) { + errno = 0; + const auto accessible = ::access(node.path.c_str(), R_OK | W_OK) == 0; + const auto access_error = errno; + std::cerr << std::format( + " kind={} path={} read-write={} errno={}\n", + std::to_underlying(node.kind), + node.path, + accessible, + access_error + ); + } + } + + void log_sysfs_files( + std::string_view heading, + const std::filesystem::path &root, + const std::filesystem::path &relative_path + ) { + std::cerr << heading << ":\n"; + std::error_code error; + for (std::filesystem::directory_iterator it {root, error}, end; !error && it != end; it.increment(error)) { + const auto path = it->path() / relative_path; + std::ifstream file {path}; + if (!file) { + continue; + } + + std::cerr << " " << path << ":\n"; + std::string line; + while (std::getline(file, line)) { + std::cerr << " " << line << '\n'; + } + } + if (error) { + std::cerr << " scan failed: " << error.message() << '\n'; + } + } + + void log_kernel_hid_state() { + log_sysfs_files("Kernel HID devices", "/sys/bus/hid/devices", "uevent"); + log_sysfs_files("Kernel hidraw devices", "/sys/class/hidraw", "device/uevent"); + log_sysfs_files("Kernel input device names", "/sys/class/input", "device/name"); + } + + bool wait_for_accessible_hidraw_node(const lvh::Gamepad &gamepad) { + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (std::chrono::steady_clock::now() < deadline) { + for (const auto &node : gamepad.device_nodes()) { + if ( + node.kind == lvh::DeviceNodeKind::hidraw && + ::access(node.path.c_str(), R_OK | W_OK) == 0 + ) { + return true; + } + } + std::this_thread::sleep_for(50ms); + } + return false; + } + + SDL_JoystickID wait_for_gamepad(const ProfileCase &test_case) { + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (std::chrono::steady_clock::now() < deadline) { + SDL_UpdateGamepads(); + SDL_PumpEvents(); + + int count = 0; + auto *gamepads = SDL_GetGamepads(&count); + for (int index = 0; index < count; ++index) { + const auto id = gamepads[index]; + if ( + SDL_GetGamepadVendorForID(id) == 0x045EU && + SDL_GetGamepadProductForID(id) == test_case.product_id + ) { + SDL_free(gamepads); + return id; + } + } + SDL_free(gamepads); + std::this_thread::sleep_for(50ms); + } + return 0; + } + + bool wait_for_button(SDL_Gamepad *gamepad, SDL_GamepadButton button, bool pressed) { + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (std::chrono::steady_clock::now() < deadline) { + SDL_UpdateGamepads(); + SDL_PumpEvents(); + if (SDL_GetGamepadButton(gamepad, button) == pressed) { + return true; + } + std::this_thread::sleep_for(20ms); + } + return false; + } + + bool wait_for_axis( + SDL_Gamepad *gamepad, + SDL_GamepadAxis axis, + std::int16_t expected, + std::int16_t tolerance + ) { + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (std::chrono::steady_clock::now() < deadline) { + SDL_UpdateGamepads(); + SDL_PumpEvents(); + if (std::abs(static_cast(SDL_GetGamepadAxis(gamepad, axis)) - expected) <= tolerance) { + return true; + } + std::this_thread::sleep_for(20ms); + } + return false; + } + + bool wait_for_output(const std::atomic_bool &observed) { + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (std::chrono::steady_clock::now() < deadline) { + SDL_UpdateGamepads(); + SDL_PumpEvents(); + if (observed.load()) { + return true; + } + std::this_thread::sleep_for(20ms); + } + return false; + } + + void capture_output(const std::shared_ptr &output, const lvh::GamepadOutput &gamepad_output) { + std::lock_guard lock {output->mutex}; + if ( + gamepad_output.kind == lvh::GamepadOutputKind::rumble && + gamepad_output.low_frequency_rumble > 0U && gamepad_output.high_frequency_rumble > 0U + ) { + output->low_frequency = gamepad_output.low_frequency_rumble; + output->high_frequency = gamepad_output.high_frequency_rumble; + output->ordinary_rumble = true; + } else if ( + gamepad_output.kind == lvh::GamepadOutputKind::trigger_rumble && + gamepad_output.left_trigger_rumble > 0U && gamepad_output.right_trigger_rumble > 0U + ) { + output->left_trigger = gamepad_output.left_trigger_rumble; + output->right_trigger = gamepad_output.right_trigger_rumble; + output->trigger_rumble = true; + } + } + + int validate_identity(SDL_JoystickID gamepad_id, const ProfileCase &test_case) { + // Linux hidapi leaves the release number unset for Bluetooth devices. + // Validate it when the SDL backend exposes the UHID version. + if ( + const auto product_version = SDL_GetGamepadProductVersionForID(gamepad_id); + product_version != 0U && product_version != 0x0513U + ) { + return fail("SDL3 observed the wrong Xbox Bluetooth product version"); + } + if ( + const auto *name = SDL_GetGamepadNameForID(gamepad_id); + name == nullptr || !std::string_view {name}.contains(test_case.identity_token) + ) { + return fail(std::format("SDL3 exposed the wrong Xbox identity name: {}", name == nullptr ? "" : name)); + } + return EXIT_SUCCESS; + } + + int validate_capabilities(SDL_Gamepad *gamepad) { + if (SDL_GetGamepadType(gamepad) != SDL_GAMEPAD_TYPE_XBOXONE) { + return fail("SDL3 did not classify the device as an Xbox One-family gamepad"); + } + + const auto properties = SDL_GetGamepadProperties(gamepad); + if (properties == 0U) { + return fail("SDL3 did not expose gamepad properties"); + } + if (!SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false)) { + return fail("SDL3 did not advertise ordinary rumble"); + } + if (!SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN, false)) { + return fail("SDL3 did not advertise independent trigger rumble"); + } + return EXIT_SUCCESS; + } + + int validate_buttons(lvh::Gamepad &virtual_gamepad, SDL_Gamepad *gamepad, const ProfileCase &test_case) { + for (const auto &[logical_button, sdl_button] : button_cases) { + lvh::GamepadState state; + state.buttons.set(logical_button); + if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, sdl_button, true)) { + return fail(std::format("SDL3 did not map logical button {}", std::to_underlying(logical_button))); + } + for (const auto &[other_logical_button, other_sdl_button] : button_cases) { + if (other_logical_button != logical_button && SDL_GetGamepadButton(gamepad, other_sdl_button)) { + return fail(std::format("SDL3 mapped logical button {} to multiple controls", std::to_underlying(logical_button))); + } + } + } + + if (test_case.profile.gamepad_kind == lvh::GamepadProfileKind::xbox_series) { + lvh::GamepadState state; + state.buttons.set(misc1); + if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, SDL_GAMEPAD_BUTTON_MISC1, true)) { + return fail("SDL3 did not map the Xbox Series Share button"); + } + } + return EXIT_SUCCESS; + } + + int validate_trigger_inputs(lvh::Gamepad &virtual_gamepad, SDL_Gamepad *gamepad) { + constexpr auto trigger_tolerance = std::int16_t {256}; + constexpr auto left_trigger_expected = std::int16_t {SDL_JOYSTICK_AXIS_MAX / 4}; + constexpr auto right_trigger_expected = std::int16_t {(SDL_JOYSTICK_AXIS_MAX * 3) / 4}; + lvh::GamepadState trigger_state; + trigger_state.left_trigger = 0.25F; + trigger_state.right_trigger = 0.75F; + if (!virtual_gamepad.submit(trigger_state).ok()) { + return fail("Submitting Xbox trigger input failed"); + } + if (!wait_for_axis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, left_trigger_expected, trigger_tolerance)) { + return fail(std::format("SDL3 mapped the left trigger to an unexpected value: {}", SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER))); + } + if (!wait_for_axis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, right_trigger_expected, trigger_tolerance)) { + return fail(std::format("SDL3 mapped the right trigger to an unexpected value: {}", SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER))); + } + return EXIT_SUCCESS; + } + + int validate_rumble(SDL_Gamepad *gamepad, const std::shared_ptr &output) { + if (!SDL_RumbleGamepad(gamepad, 0x5678U, 0x1234U, 1000U)) { + return fail("SDL3 ordinary rumble request failed"); + } + if (!wait_for_output(output->ordinary_rumble)) { + return fail("SDL3 ordinary rumble did not reach the normalized output callback"); + } + if (!SDL_RumbleGamepadTriggers(gamepad, 0x3456U, 0x789AU, 1000U)) { + return fail("SDL3 trigger-rumble request failed"); + } + if (!wait_for_output(output->trigger_rumble)) { + return fail("SDL3 trigger rumble did not reach the normalized output callback"); + } + return EXIT_SUCCESS; + } + + int exercise_sdl_profile( + const ProfileCase &test_case, + lvh::Gamepad &virtual_gamepad, + const std::shared_ptr &output + ) { + const auto gamepad_id = wait_for_gamepad(test_case); + if (gamepad_id == 0) { + log_discovery_state(); + return fail(std::format("SDL3 did not discover product 045e:{:04x}", test_case.product_id)); + } + if (validate_identity(gamepad_id, test_case) != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + + SdlGamepad gamepad {SDL_OpenGamepad(gamepad_id), &SDL_CloseGamepad}; + if (gamepad == nullptr) { + return fail("SDL3 could not open the Xbox gamepad"); + } + if (validate_capabilities(gamepad.get()) != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (validate_buttons(virtual_gamepad, gamepad.get(), test_case) != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (validate_trigger_inputs(virtual_gamepad, gamepad.get()) != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return validate_rumble(gamepad.get(), output); + } + + int run_sdl_profile( + const ProfileCase &test_case, + lvh::Gamepad &virtual_gamepad, + const std::shared_ptr &output + ) { + // Initialize SDL only after udev has applied the hidraw access rules. If + // SDL3 sees the add event before that point, its HIDAPI driver can fail the + // initial open while still suppressing the duplicate evdev joystick. + if (SdlSubsystem sdl; sdl.initialized()) { + return exercise_sdl_profile(test_case, virtual_gamepad, output); + } + return fail("SDL3 initialization failed"); + } + + int exercise_profile(const ProfileCase &test_case) { + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + if (runtime == nullptr || !runtime->capabilities().supports_gamepad) { + return fail("The Linux backend does not report gamepad support"); + } + + lvh::CreateGamepadOptions options; + options.profile = test_case.profile; + options.profile.name = std::format("libvirtualhid SDL3 {} {}", test_case.identity_token, ::getpid()); + options.metadata.stable_id = std::format("libvirtualhid-sdl3-xbox-{:04x}-{}", test_case.product_id, ::getpid()); + auto created = runtime->create_gamepad(options); + if (!created) { + return fail(std::format("Gamepad creation failed: {}", created.status.message())); + } + if (!created.gamepad->profile().capabilities.supports_trigger_rumble) { + return fail("Xbox creation did not select the trigger-rumble-capable UHID backend"); + } + if (!wait_for_accessible_hidraw_node(*created.gamepad)) { + log_device_nodes(*created.gamepad); + log_kernel_hid_state(); + return fail("The Xbox UHID device did not expose an accessible hidraw node"); + } + + const auto output = std::make_shared(); + created.gamepad->set_output_callback([output](const lvh::GamepadOutput &gamepad_output) { + capture_output(output, gamepad_output); + }); + return run_sdl_profile(test_case, *created.gamepad, output); + } + +} // namespace + +int main(int argc, char **argv) { + if (argc != 2) { + std::cerr << "Usage: linux_sdl3_xbox_consumer \n"; + return EXIT_FAILURE; + } + + const std::string_view profile_name {argv[1]}; + if (profile_name == "xone") { + return exercise_profile({lvh::profiles::xbox_one(), 0x0B20, "Xbox One"}); + } + if (profile_name == "xseries") { + return exercise_profile({lvh::profiles::xbox_series(), 0x0B13, "Xbox Series"}); + } + + std::cerr << "Unknown profile: " << profile_name << '\n'; + return EXIT_FAILURE; +} diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 786f3e8..5249147 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -116,10 +116,40 @@ namespace lvh::detail::test { */ bool waited_for_start = false; + /** + * @brief Linux bus type carried by the observed create event. + */ + std::uint16_t bus = 0; + + /** + * @brief USB vendor id carried by the observed create event. + */ + std::uint16_t vendor_id = 0; + + /** + * @brief USB product id carried by the observed create event. + */ + std::uint16_t product_id = 0; + + /** + * @brief Device version carried by the observed create event. + */ + std::uint16_t version = 0; + /** * @brief Product name carried by the observed create event. */ std::string name; + + /** + * @brief Physical path carried by the observed create event. + */ + std::string physical_id; + + /** + * @brief Unique identity carried by the observed create event. + */ + std::string unique_id; }; /** @@ -135,6 +165,56 @@ namespace lvh::detail::test { * @brief Last output callback payload. */ GamepadOutput last; + + /** + * @brief Ordinary-rumble output observed during the round trip. + */ + std::optional rumble; + + /** + * @brief Independent trigger-rumble output observed during the round trip. + */ + std::optional trigger_rumble; + }; + + /** + * @brief Xbox Bluetooth transport observations from a socketpair-backed UHID test. + */ + struct LinuxUhidXboxObservation { + /** + * @brief Whether UHID creation used the 283-byte Linux Xbox BLE transport descriptor. + */ + bool saw_transport_descriptor = false; + + /** + * @brief Whether the descriptor maps sticks and triggers to canonical evdev axes. + */ + bool saw_canonical_evdev_axes = false; + + /** + * @brief Whether the transport advertises a HIDAPI-visible Game Pad application usage. + */ + bool saw_gamepad_application_usage = false; + + /** + * @brief Whether the UHID identity uses the canonical Bluetooth product and version. + */ + bool saw_bluetooth_identity = false; + + /** + * @brief Whether submitted normalized state was carried in a native Bluetooth input report. + */ + bool saw_input = false; + + /** + * @brief Whether the Guide button was carried in the native button bitmap. + */ + bool saw_guide = false; + + /** + * @brief Whether the profile-specific Consumer field matched the submitted Share state. + */ + bool saw_profile_consumer_button = false; }; /** @@ -281,6 +361,11 @@ namespace lvh::detail::test { */ LinuxUhidSwitchProObservation switch_pro; + /** + * @brief Xbox Bluetooth protocol observations. + */ + LinuxUhidXboxObservation xbox; + /** * @brief Whether the peer observed a set-report reply. */ @@ -879,6 +964,38 @@ namespace lvh::detail::test { */ LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip(); + /** + * @brief Exercise an Xbox profile's Bluetooth input and four-motor output through a socketpair. + * + * @param kind Xbox One or Xbox Series profile kind. + * @return Captured lifecycle and protocol observations. + */ + LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(GamepadProfileKind kind); + + /** + * @brief Check whether the Linux backend prefers UHID for a gamepad profile. + * + * @param kind Gamepad profile kind. + * @return `true` when Linux attempts UHID before any fallback. + */ + bool linux_gamepad_prefers_uhid(GamepadProfileKind kind); + + /** + * @brief Check whether the Linux backend supports a gamepad profile through uinput. + * + * @param kind Gamepad profile kind. + * @return `true` when the profile can be created through uinput. + */ + bool linux_gamepad_uses_uinput(GamepadProfileKind kind); + + /** + * @brief Get the effective profile exposed by a Linux uinput fallback. + * + * @param kind Gamepad profile kind. + * @return Effective uinput profile. + */ + DeviceProfile linux_uinput_effective_gamepad_profile(GamepadProfileKind kind); + /** * @brief Exercise Switch Pro UHID input, output, and subcommand replies over a socketpair. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 502a4c8..e02c767 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -100,6 +100,19 @@ namespace lvh::detail::test { return opaque_test_handle(device); } + std::uint16_t xbox_descriptor_crc16(std::span descriptor) { + std::uint16_t crc = 0; + for (const auto byte : descriptor) { + crc = static_cast(crc ^ byte); + for (auto bit = 0; bit < 8; ++bit) { + crc = static_cast( + (crc & 1U) != 0U ? (crc >> 1U) ^ 0xA001U : crc >> 1U + ); + } + } + return crc; + } + libevdev_uinput *libevdev_uinput_handle(FakeLibevdevUinput *device) noexcept { return opaque_test_handle(device); } @@ -734,10 +747,18 @@ namespace lvh::detail::test { result.creation.waited_for_start ); if (result.creation.saw_create) { - result.creation.saw_create = event.u.create2.vendor == options.profile.vendor_id && - event.u.create2.product == options.profile.product_id && + const auto transport_profile = uhid_transport_profile(options.profile); + result.creation.saw_create = event.u.create2.vendor == transport_profile.vendor_id && + event.u.create2.product == transport_profile.product_id && + event.u.create2.version == transport_profile.version && event.u.create2.bus == expected_bus; + result.creation.bus = event.u.create2.bus; + result.creation.vendor_id = event.u.create2.vendor; + result.creation.product_id = event.u.create2.product; + result.creation.version = event.u.create2.version; result.creation.name = reinterpret_cast(event.u.create2.name); + result.creation.physical_id = reinterpret_cast(event.u.create2.phys); + result.creation.unique_id = reinterpret_cast(event.u.create2.uniq); } return event; } @@ -1586,6 +1607,160 @@ namespace lvh::detail::test { return result; } + LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(GamepadProfileKind kind) { + LinuxUhidRoundTripResult result; + std::array descriptors {-1, -1}; + if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors.data()) != 0) { + result.create_status = system_error_status(ErrorCode::backend_failure, "failed to create socketpair", errno); + result.submit_status = result.create_status; + result.close_status = result.create_status; + return result; + } + + const auto profile = profiles::gamepad_profile(kind).value(); + CreateGamepadOptions options; + options.profile = profile; + options.metadata.stable_id = "xbox-bluetooth-uhid-roundtrip"; + + UhidGamepad gamepad {descriptors[0]}; + auto event = create_started_profile_uhid_gamepad(gamepad, 13, options, descriptors[1], BUS_BLUETOOTH, result); + if (result.creation.saw_create) { + const auto series = kind == GamepadProfileKind::xbox_series; + const auto descriptor = std::span { + event.u.create2.rd_data, + event.u.create2.rd_size, + }; + result.xbox.saw_transport_descriptor = + descriptor.size() == 283U && xbox_descriptor_crc16(descriptor) == 0xDB7DU; + constexpr std::array right_stick_usages { + 0x09U, + 0x33U, + 0x09U, + 0x34U, + }; + constexpr std::array left_trigger_usage { + 0x05U, + 0x01U, + 0x09U, + 0x32U, + 0x15U, + 0x00U, + }; + constexpr std::array right_trigger_usage { + 0x05U, + 0x01U, + 0x09U, + 0x35U, + 0x15U, + 0x00U, + }; + result.xbox.saw_canonical_evdev_axes = + std::ranges::search(descriptor, right_stick_usages).begin() != descriptor.end() && + std::ranges::search(descriptor, left_trigger_usage).begin() != descriptor.end() && + std::ranges::search(descriptor, right_trigger_usage).begin() != descriptor.end(); + result.xbox.saw_gamepad_application_usage = + event.u.create2.rd_size >= 4U && event.u.create2.rd_data[0] == 0x05U && + event.u.create2.rd_data[1] == 0x01U && event.u.create2.rd_data[2] == 0x09U && + event.u.create2.rd_data[3] == 0x05U; + result.xbox.saw_bluetooth_identity = + event.u.create2.vendor == 0x045EU && + event.u.create2.product == (series ? xbox_series_uinput_product_id : xbox_wireless_uinput_product_id) && + event.u.create2.version == xbox_bluetooth_version && event.u.create2.bus == BUS_BLUETOOTH; + } + + std::atomic_size_t callback_count {0}; + gamepad.set_output_callback([&result, &callback_count](const GamepadOutput &output) { + result.output.last = output; + if (output.kind == GamepadOutputKind::rumble) { + result.output.rumble = output; + } else if (output.kind == GamepadOutputKind::trigger_rumble) { + result.output.trigger_rumble = output; + } + ++callback_count; + }); + + event = {}; + event.type = UHID_OPEN; + static_cast(write_uhid_event(descriptors[1], event)); + + GamepadState state; + state.buttons.set(GamepadButton::a); + state.buttons.set(GamepadButton::start); + state.buttons.set(GamepadButton::dpad_right); + state.buttons.set(GamepadButton::left_shoulder); + state.buttons.set(GamepadButton::left_stick); + state.buttons.set(GamepadButton::guide); + if (kind == GamepadProfileKind::xbox_series) { + state.buttons.set(GamepadButton::misc1); + } + state.left_stick = {.x = 1.0F, .y = -1.0F}; + state.right_stick = {.x = 0.5F, .y = -0.5F}; + state.left_trigger = 0.25F; + state.right_trigger = 0.75F; + const auto report = reports::pack_input_report(profile, state); + result.submit_status = gamepad.submit(state, report); + + if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) { + const auto input = std::span {event.u.input2.data, event.u.input2.size}; + result.xbox.saw_input = + input.size() == xbox_bluetooth_input_report_size && input[0] == xbox_bluetooth_input_report_id && + std::equal(report.begin(), report.begin() + 8, input.begin() + 1) && + read_u16_le(input, 9U) == read_u16_le(report, 8U) && + read_u16_le(input, 11U) == read_u16_le(report, 10U) && input[13] == report[14] && + input[14] == 0x41U && input[15] == 0x38U; + result.xbox.saw_guide = input.size() == xbox_bluetooth_input_report_size && (input[15] & 0x10U) != 0U; + result.xbox.saw_profile_consumer_button = + input.size() == xbox_bluetooth_input_report_size && + input[16] == (kind == GamepadProfileKind::xbox_series ? 0x01U : 0x00U); + } + result.saw_input = result.xbox.saw_input; + + constexpr std::array motor_report { + xbox_bluetooth_rumble_report_id, + 0x0F, + 25, + 50, + 75, + 100, + 10, + 0, + 0, + }; + event = {}; + event.type = UHID_OUTPUT; + event.u.output.rtype = UHID_OUTPUT_REPORT; + event.u.output.size = static_cast<__u16>(motor_report.size()); + std::copy(motor_report.begin(), motor_report.end(), event.u.output.data); + static_cast(write_uhid_event(descriptors[1], event)); + + const auto output_deadline = std::chrono::steady_clock::now() + std::chrono::seconds {1}; + while (std::chrono::steady_clock::now() < output_deadline && callback_count.load() < 2U) { + std::this_thread::sleep_for(std::chrono::milliseconds {10}); + } + result.output.callback_count = callback_count.load(); + + result.close_status = gamepad.close(); + if (read_uhid_event_type(descriptors[1], UHID_DESTROY, event)) { + result.saw_destroy = true; + } + + static_cast(::close(descriptors[1])); + return result; + } + + bool linux_gamepad_prefers_uhid(GamepadProfileKind kind) { + return prefers_uhid_gamepad_profile(kind); + } + + bool linux_gamepad_uses_uinput(GamepadProfileKind kind) { + return uses_uinput_gamepad_profile(kind); + } + + DeviceProfile linux_uinput_effective_gamepad_profile(GamepadProfileKind kind) { + const auto requested_profile = profiles::gamepad_profile(kind).value(); + return effective_uinput_profile(requested_profile).value_or(requested_profile); + } + LinuxUhidRoundTripResult linux_switch_pro_uhid_socketpair_reports() { LinuxUhidRoundTripResult result; std::array descriptors {-1, -1}; @@ -1598,7 +1773,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::switch_pro(); - options.metadata.stable_id = "libvirtualhid-switch-pro-roundtrip"; + options.metadata.stable_id = "streaming-host-gamepad-0"; UhidGamepad gamepad {descriptors[0]}; auto event = create_started_profile_uhid_gamepad(gamepad, 12, options, descriptors[1], BUS_BLUETOOTH, result); @@ -1671,7 +1846,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::dualsense_usb(); - options.profile.name = "Sunshine (libvirtualhid) PS5 Controller"; + options.profile.name = "libvirtualhid PS5 Controller"; options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; @@ -1765,7 +1940,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::dualsense_bluetooth(); - options.profile.name = "Sunshine (libvirtualhid) PS5 Controller"; + options.profile.name = "libvirtualhid PS5 Controller"; options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; @@ -1849,7 +2024,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::dualshock4_usb(); - options.profile.name = "Sunshine (libvirtualhid) PS4 Controller"; + options.profile.name = "libvirtualhid PS4 Controller"; options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; @@ -1933,7 +2108,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::dualshock4_bluetooth(); - options.profile.name = "Sunshine (libvirtualhid) PS4 Controller"; + options.profile.name = "libvirtualhid PS4 Controller"; options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; @@ -2001,7 +2176,7 @@ namespace lvh::detail::test { result.capabilities = backend.capabilities(); CreateGamepadOptions gamepad_options; - gamepad_options.profile = profiles::xbox_series(); + gamepad_options.profile = profiles::xbox_360(); gamepad_options.metadata.stable_id = "fake-linux-gamepad"; auto gamepad = backend.create_gamepad(1, gamepad_options); result.gamepad_status = gamepad.status; diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp index d737a0c..f94c24b 100644 --- a/tests/unit/test_gamepad_adapter.cpp +++ b/tests/unit/test_gamepad_adapter.cpp @@ -29,6 +29,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_FALSE(lvh::gamepad_profile_support(xbox_360).supports_misc1_button); EXPECT_FALSE(lvh::gamepad_profile_support(xbox_one).supports_misc1_button); + EXPECT_TRUE(lvh::gamepad_profile_support(xbox_one).supports_trigger_rumble); const auto dualshock4_support = lvh::gamepad_profile_support(dualshock4); EXPECT_TRUE(dualshock4_support.supports_rumble); @@ -80,6 +81,7 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) { EXPECT_TRUE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::guide)); EXPECT_TRUE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::misc1)); + EXPECT_TRUE(lvh::supports_gamepad_output(xbox, lvh::GamepadOutputKind::trigger_rumble)); EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::touchpad)); EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::paddle1)); EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::paddle2)); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 6e92299..f5018b2 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -122,7 +122,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::xbox_one), BUS_BLUETOOTH); + EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::xbox_series), BUS_BLUETOOTH); EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::switch_pro), BUS_BLUETOOTH); EXPECT_EQ(lvh::detail::test::linux_uinput_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); @@ -782,6 +783,46 @@ TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { EXPECT_EQ(result.output.last.high_frequency_rumble, 0x1234); } +TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback) { + using enum lvh::GamepadProfileKind; + + EXPECT_FALSE(lvh::detail::test::linux_gamepad_prefers_uhid(generic)); + EXPECT_FALSE(lvh::detail::test::linux_gamepad_prefers_uhid(xbox_360)); + EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(switch_pro)); + EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(dualshock4)); + EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(dualsense)); + + for (const auto kind : {xbox_one, xbox_series}) { + EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(kind)); + EXPECT_TRUE(lvh::detail::test::linux_gamepad_uses_uinput(kind)); + + const auto effective_profile = lvh::detail::test::linux_uinput_effective_gamepad_profile(kind); + EXPECT_TRUE(effective_profile.capabilities.supports_rumble); + EXPECT_FALSE(effective_profile.capabilities.supports_trigger_rumble); + + const auto result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind); + EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + EXPECT_TRUE(result.creation.saw_create); + EXPECT_TRUE(result.creation.waited_for_start); + EXPECT_TRUE(result.xbox.saw_transport_descriptor); + EXPECT_TRUE(result.xbox.saw_canonical_evdev_axes); + EXPECT_TRUE(result.xbox.saw_gamepad_application_usage); + EXPECT_TRUE(result.xbox.saw_bluetooth_identity); + EXPECT_TRUE(result.xbox.saw_input); + EXPECT_TRUE(result.xbox.saw_guide); + EXPECT_TRUE(result.xbox.saw_profile_consumer_button); + EXPECT_TRUE(result.saw_destroy); + ASSERT_TRUE(result.output.rumble.has_value()); + EXPECT_EQ(result.output.rumble->low_frequency_rumble, 49151U); + EXPECT_EQ(result.output.rumble->high_frequency_rumble, 65535U); + ASSERT_TRUE(result.output.trigger_rumble.has_value()); + EXPECT_EQ(result.output.trigger_rumble->left_trigger_rumble, 16384U); + EXPECT_EQ(result.output.trigger_rumble->right_trigger_rumble, 32768U); + } +} + TEST_F(LinuxBackendTest, SocketpairBackedSwitchProUsesNativeUhidProtocol) { const auto result = lvh::detail::test::linux_switch_pro_uhid_socketpair_reports(); EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); @@ -790,6 +831,8 @@ TEST_F(LinuxBackendTest, SocketpairBackedSwitchProUsesNativeUhidProtocol) { EXPECT_TRUE(result.creation.saw_create); EXPECT_TRUE(result.creation.waited_for_start); EXPECT_EQ(result.creation.name, lvh::profiles::switch_pro().name); + EXPECT_EQ(result.creation.physical_id, "libvirtualhid/uhid/057e:2009/12"); + EXPECT_EQ(result.creation.unique_id, "057e:2009/streaming-host-gamepad-0"); EXPECT_TRUE(result.switch_pro.saw_subcommand_reply); EXPECT_TRUE(result.switch_pro.saw_motion_input); ASSERT_TRUE(result.switch_pro.subcommand_reply_packet_timer.has_value()); diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 5ac5de7..80c1b1b 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -1,6 +1,6 @@ /** * @file tests/unit/test_linux_consumers.cpp - * @brief Linux integration tests for SDL2 and libinput consumers. + * @brief Linux integration tests for SDL2, SDL3, and libinput consumers. */ // standard includes @@ -33,6 +33,7 @@ #include #include #include +#include #include // lib includes @@ -67,6 +68,7 @@ namespace { int minimum_buttons = 1; int minimum_axes = 2; bool require_sdl_rumble = false; + bool require_trigger_rumble = false; bool require_motion = false; bool expect_live_input = true; }; @@ -202,7 +204,10 @@ namespace { struct RumbleState { std::atomic_uint16_t low_frequency {0}; std::atomic_uint16_t high_frequency {0}; - std::atomic_bool observed {false}; + std::atomic_uint16_t left_trigger {0}; + std::atomic_uint16_t right_trigger {0}; + std::atomic_bool ordinary_observed {false}; + std::atomic_bool trigger_observed {false}; }; std::shared_ptr observe_rumble(lvh::Gamepad &gamepad) { @@ -214,21 +219,31 @@ namespace { ) { rumble->low_frequency = output.low_frequency_rumble; rumble->high_frequency = output.high_frequency_rumble; - rumble->observed = true; + rumble->ordinary_observed = true; + } else if ( + output.kind == lvh::GamepadOutputKind::trigger_rumble && + output.left_trigger_rumble > 0 && output.right_trigger_rumble > 0 + ) { + rumble->left_trigger = output.left_trigger_rumble; + rumble->right_trigger = output.right_trigger_rumble; + rumble->trigger_observed = true; } }); return rumble; } - bool wait_for_rumble(const std::shared_ptr &rumble, bool pump_sdl) { + bool wait_for_rumble(const std::shared_ptr &rumble, bool pump_sdl, bool trigger = false) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; - while (std::chrono::steady_clock::now() < deadline && !rumble->observed.load()) { + const auto observed = [&rumble, trigger]() { + return trigger ? rumble->trigger_observed.load() : rumble->ordinary_observed.load(); + }; + while (std::chrono::steady_clock::now() < deadline && !observed()) { if (pump_sdl) { pump_sdl_events(); } std::this_thread::sleep_for(std::chrono::milliseconds {20}); } - return rumble->observed.load(); + return observed(); } std::optional wait_for_hidraw_node(const lvh::Gamepad &gamepad) { @@ -477,6 +492,8 @@ namespace { void configure_sdl_hidapi_hints() { SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_XBOX", "1"); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_XBOX_ONE", "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4", "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", "1"); @@ -562,13 +579,28 @@ namespace { } } - void expect_sdl_rumble_callback(SDL_GameController *controller, lvh::Gamepad &gamepad) { + void expect_sdl_rumble_callback( + SDL_GameController *controller, + lvh::Gamepad &gamepad, + bool require_trigger_rumble = false + ) { const auto rumble = observe_rumble(gamepad); ASSERT_EQ(SDL_GameControllerRumble(controller, 0x5678, 0x1234, 1000), 0) << SDL_GetError(); EXPECT_TRUE(wait_for_rumble(rumble, true)); EXPECT_GT(rumble->low_frequency.load(), 0); EXPECT_GT(rumble->high_frequency.load(), 0); + + if (require_trigger_rumble) { + auto *joystick = SDL_GameControllerGetJoystick(controller); + ASSERT_NE(joystick, nullptr); + ASSERT_EQ(SDL_JoystickHasRumbleTriggers(joystick), SDL_TRUE) + << "SDL did not expose the Xbox impulse motors"; + ASSERT_EQ(SDL_GameControllerRumbleTriggers(controller, 0x3456, 0x789A, 1000), 0) << SDL_GetError(); + EXPECT_TRUE(wait_for_rumble(rumble, true, true)); + EXPECT_GT(rumble->left_trigger.load(), 0); + EXPECT_GT(rumble->right_trigger.load(), 0); + } } void expect_sdl_motion_input(SDL_GameController *controller, lvh::Gamepad &gamepad) { @@ -772,7 +804,7 @@ namespace { if (test_case.require_motion) { expect_sdl_motion_input(controller.get(), gamepad); } - expect_sdl_rumble_callback(controller.get(), gamepad); + expect_sdl_rumble_callback(controller.get(), gamepad, test_case.require_trigger_rumble); } void run_sdl_canonical_gamepad_test(const SdlGamepadConsumerCase &test_case) { @@ -785,6 +817,63 @@ namespace { ); } +#if defined(LIBVIRTUALHID_TEST_SDL3_XBOX_CONSUMER_PATH) + struct ConsumerProcessResult { + int exit_code = EXIT_FAILURE; + std::string output; + }; + + ConsumerProcessResult run_sdl3_xbox_consumer(std::string_view profile) { + std::array output_pipe {-1, -1}; + if (::pipe(output_pipe.data()) != 0) { + return {.exit_code = EXIT_FAILURE, .output = "Failed to start the SDL3 Xbox consumer probe"}; + } + + const std::string executable {LIBVIRTUALHID_TEST_SDL3_XBOX_CONSUMER_PATH}; + const std::string argument {profile}; + const auto child = ::fork(); + if (child < 0) { + static_cast(::close(output_pipe[0])); + static_cast(::close(output_pipe[1])); + return {.exit_code = EXIT_FAILURE, .output = "Failed to fork the SDL3 Xbox consumer probe"}; + } + if (child == 0) { + static_cast(::close(output_pipe[0])); + static_cast(::dup2(output_pipe[1], STDOUT_FILENO)); + static_cast(::dup2(output_pipe[1], STDERR_FILENO)); + static_cast(::close(output_pipe[1])); + ::execl( + executable.c_str(), + executable.c_str(), + argument.c_str(), + static_cast(nullptr) + ); + ::_exit(127); + } + + static_cast(::close(output_pipe[1])); + ConsumerProcessResult result; + std::array buffer {}; + auto count = ::read(output_pipe[0], buffer.data(), buffer.size()); + while (count > 0) { + result.output.append(buffer.data(), static_cast(count)); + count = ::read(output_pipe[0], buffer.data(), buffer.size()); + } + static_cast(::close(output_pipe[0])); + + if (int status = 0; ::waitpid(child, &status, 0) < 0) { + result.output.append("Consumer probe process could not be reaped\n"); + } else if (WIFEXITED(status)) { + result.exit_code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + result.output.append(std::format("Consumer probe terminated by signal {}\n", WTERMSIG(status))); + } else { + result.output.append("Consumer probe did not return a decodable process status\n"); + } + return result; + } +#endif + void destroy_libinput_event(libinput_event *event) { if (event != nullptr) { libinput_event_destroy(event); @@ -883,7 +972,7 @@ TEST_F(LinuxConsumerTest, SdlSeesXbox360CanonicalButtons) { } TEST_F(LinuxConsumerTest, SdlSeesXboxOneCanonicalButtons) { - ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); run_sdl_canonical_gamepad_test({ .profile = lvh::profiles::xbox_one(), @@ -892,11 +981,13 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxOneCanonicalButtons) { .expected_product_id = 0x0B20, .minimum_buttons = 15, .minimum_axes = 6, + .require_sdl_rumble = true, + .require_trigger_rumble = true, }); } TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) { - ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); const SdlGamepadConsumerCase test_case { .profile = lvh::profiles::xbox_series(), @@ -905,10 +996,26 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) { .expected_product_id = 0x0B13, .minimum_buttons = 16, .minimum_axes = 6, + .require_sdl_rumble = true, + .require_trigger_rumble = true, }; run_sdl_canonical_gamepad_test(test_case); } +#if defined(LIBVIRTUALHID_TEST_SDL3_XBOX_CONSUMER_PATH) +TEST_F(LinuxConsumerTest, Sdl3SeesXboxOneIdentityMappingsAndFourMotorRumble) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); + const auto result = run_sdl3_xbox_consumer("xone"); + EXPECT_EQ(result.exit_code, 0) << result.output; +} + +TEST_F(LinuxConsumerTest, Sdl3SeesXboxSeriesIdentityMappingsAndFourMotorRumble) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); + const auto result = run_sdl3_xbox_consumer("xseries"); + EXPECT_EQ(result.exit_code, 0) << result.output; +} +#endif + TEST_F(LinuxConsumerTest, SdlSeesSwitchProCanonicalButtons) { ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index ea5bbe2..0a7c22c 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -98,6 +98,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_one.bus_type, lvh::BusType::usb); EXPECT_EQ(xbox_one.manufacturer, "Microsoft"); EXPECT_TRUE(xbox_one.capabilities.supports_rumble); + EXPECT_TRUE(xbox_one.capabilities.supports_trigger_rumble); EXPECT_EQ(xbox_one.report_id, 0); EXPECT_EQ(xbox_one.input_report_size, 17U); EXPECT_EQ(xbox_one.output_report_size, 8U); @@ -110,6 +111,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_series.report_id, 0); EXPECT_EQ(xbox_series.input_report_size, 17U); EXPECT_EQ(xbox_series.output_report_size, 8U); + EXPECT_TRUE(xbox_series.capabilities.supports_trigger_rumble); const std::array xbox_gip_stick_axis_descriptor { 0x15, diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index 55cc015..d51fc66 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -443,9 +443,10 @@ TEST(ReportTest, ParsesPidRumbleReports) { expect_outputs(profile, prefixed_report); } - const auto series = lvh::profiles::xbox_series(); - const std::vector series_bluetooth_report {0x03, 0x0F, 25, 50, 75, 100, 10, 0, 0}; - expect_outputs(series, series_bluetooth_report); + const std::vector bluetooth_report {0x03, 0x0F, 25, 50, 75, 100, 10, 0, 0}; + for (const auto &profile : {lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) { + expect_outputs(profile, bluetooth_report); + } } TEST(ReportTest, PidRumbleHonorsEnableMaskAndDuration) {