diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md
index aa1a04e..a37148b 100644
--- a/docs/end-user-gamepad-guide.md
+++ b/docs/end-user-gamepad-guide.md
@@ -96,12 +96,14 @@ The feature rows include the controller capabilities relevant to streaming,
including manufacturer-specific features that are not yet implemented end to
end. ✅ means that the complete path was observed working, ❌ means that it
did not work, ❓ means that it was not tested, and ➖ means that the client
-does not support that virtual profile.
+does not support that virtual profile. A 🟡 means the feature may be supported
+in a dev build or future release of the client.
### Compatibility Matrix
-The backend columns use Moonlight Qt as the common test client. The client
-columns compare the existing Windows Virtual HID Driver results.
+The backend columns summarize host-backend capability across the recorded
+tests. The client columns record client-specific results; footnotes identify
+platform, version, connection, or host-backend qualifiers.
| Feature | Backend: Windows via Virtual HID Driver | Backend: Linux via `libvirtualhid` | Client: [Moonlight Qt](https://github.com/moonlight-stream/moonlight-qt) | Client: [Moonlight Android](https://github.com/moonlight-stream/moonlight-android) | Client: [Moonlight iOS](https://github.com/moonlight-stream/moonlight-ios) | Client: [Moonlight Xbox](https://github.com/TheElixZammuto/moonlight-xbox) |
|-------------------------------------|------------------------------------------------------|------------------------------------------------------|--------------------------------------------------------------------------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------------|
@@ -113,68 +115,77 @@ columns compare the existing Windows Virtual HID Driver results.
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Impulse Triggers | ✅ | ✅ | ✅ | ✅15 | ✅13 | ✅ |
-| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 |
+| Impulse Triggers | ✅ | ✅ | ✅ | ✅1 | ✅2 | ✅ |
+| Battery state | ❌8 | ✅3 | ❌9 | ❌9 | ❌10 | 🟡20 |
| **Xbox Series** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Impulse Triggers | ✅ | ✅ | ✅ | ✅15 | ✅13 | ✅ |
-| Battery state | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
-| Share button | ❌3 | ❌8 | ❌3 | ❌3 | ❌3 | ❌3 |
+| Impulse Triggers | ✅ | ✅ | ✅ | ✅1 | ✅2 | ✅ |
+| Battery state | ❌8 | ✅3 | ❌9 | ❌9 | ❌10 | 🟡20 |
+| Share button | ❌11 | ✅ | 🟡21 | ✅ | ✅ | 🟡22 |
| **DualShock 4** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Basic rumble | ✅ | ✅ | ✅ | ✅5 | ✅ | ➖ |
-| Motion/gyro | ✅ | ✅ | ✅ | ✅1 | ✅13 | ➖ |
-| Touchpad position | ✅ | ✅ | ✅ | ❌2 | ✅13 | ➖ |
-| Touchpad click | ✅ | ✅ | ✅ | ❌2 | ✅13 | ➖ |
-| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅6 | ✅13 | ➖ |
-| Battery state | ❌ | ✅ | ✅ | ✅ | ✅ | ➖ |
+| Basic rumble | ✅4 | ✅4 | ✅ | ✅5 | ✅ | ➖ |
+| Motion/gyro | ✅ | ✅ | ✅ | ✅6 | ✅2 | ➖ |
+| Touchpad position | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ |
+| Touchpad click | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ |
+| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅7 | ✅2 | ➖ |
+| Battery state | ❌8 | ✅ | ✅ | ✅ | ✅ | ➖ |
| **DualSense** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Basic rumble | ✅ | ✅ | ✅ | ✅5 | ✅ | ➖ |
-| Motion/gyro | ✅ | ✅ | ✅ | ✅1 | ❌ | ➖ |
-| Touchpad position | ✅ | ✅ | ✅ | ❌2 | ✅13 | ➖ |
-| Touchpad click | ✅ | ✅ | ✅ | ❌2 | ✅13 | ➖ |
-| Light bar (RGB) | ✅ | ✅ | ✅ | ✅6 | ✅13 | ➖ |
-| Battery state | ❌ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Adaptive triggers | ❓10 | ✅ | ❓10 | ❌2 | ❓ | ➖ |
-| Player indicator | ❌7 | ❌7 | ❌7 | ❌7 | ❌ | ➖ |
+| Basic rumble | ✅4 | ✅4 | ✅ | ✅5 | ✅ | ➖ |
+| Motion/gyro | ✅ | ✅ | ✅ | ✅6 | ❌ | ➖ |
+| Touchpad position | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ |
+| Touchpad click | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ |
+| Light bar (RGB) | ✅ | ✅ | ✅ | ✅7 | ✅2 | ➖ |
+| Battery state | ❌8 | ✅ | ✅ | ✅ | ✅ | ➖ |
+| Adaptive triggers | ❓24 | ✅ | 🟡23 | ❌12 | ❓ | ➖ |
+| Player indicator | ❌13 | ❌13 | ❌ | ❌ | ❌ | ➖ |
| MUTE button | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
-| MUTE button LED | ❌7 | ❌7 | ❌7 | ❌7 | ❌ | ➖ |
+| MUTE button LED | ❌13 | ❌13 | ❌ | ❌14 | ❌ | ➖ |
| **Nintendo Switch Pro Controller** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Digital trigger input (0 or 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
| Motion/gyro | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Battery state | ❌ | ✅ | ✅ | ✅ | ❌ | ➖ |
-| HOME LED | ❌14 | ❌14 | ❌14 | ❌ | ❌ | ➖ |
-| Player LED | ❌12 | ❌12 | ❌12 | ❌12 | ❌12 | ➖ |
-| Capture button | ✅ | ✅ | ✅ | ❌9 | ✅13 | ➖ |
+| Battery state | ❌8 | ✅ | ✅ | ✅ | ❌ | ➖ |
+| HOME LED | ❌15 | ❌15 | ❌16 | ❌ | ❌ | ➖ |
+| Player LED | ❌17 | ❌17 | ❌18 | ❌18 | ❌18 | ➖ |
+| Capture button | ✅ | ✅ | ✅ | ❌19 | ✅2 | ➖ |
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 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 | 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) |
-| 11 | 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) |
-| 12 | 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) |
-| 13 | 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) |
-| 14 | 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) |
-| 15 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
+| Note | Owner | Limitation or status | Tracker or reference |
+|-------------------------------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 1 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
+| 2 | 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) |
+| 3 | Linux host backend and external consumer | Testing with the battery-capable Moonlight Xbox build through Sunshine on Linux confirmed that the virtual Xbox controller exposes the reported battery level through the Linux power-supply interface. The tested Steam client still did not display that battery level. | |
+| 4 | Host and external consumer | Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [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) |
+| 5 | Client platform | Android rumble depends on device vibration APIs and compatible motors. | [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) |
+| 6 | 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) |
+| 7 | 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) |
+| 8 | Windows host backend and external consumers | Current source carries battery updates through Windows HID input reports, and SDL3 consumer tests receive the Windows DualShock 4, DualSense, and Switch Pro values. The current Steam client hides its battery indicator for Windows VHF devices because it only renders the indicator for controllers classified as Bluetooth or wireless, while VHF exposes a wired virtual transport. For Xbox, a tested Windows desktop returned disconnected/empty from `XInputGetBatteryInformation` even while `XInputGetState` received the controller, while headless Windows CI did not expose an XInput slot for the same device; neither path exposes the submitted remote battery through XInput. Steam also did not show battery for the tested physical Xbox Series controller; Windows Game Bar showed its Bluetooth battery but not the virtual controller. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107), [XInput battery information](https://learn.microsoft.com/en-us/windows/win32/api/xinput/nf-xinput-xinputgetbatteryinformation), and [Virtual HID Framework configuration](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/vhf/ns-vhf-_vhf_config) |
+| 9 | Client battery reporting | Moonlight Qt and Android did not forward Xbox One or Xbox Series battery state in the recorded tests. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
+| 10 | Client platform | In the recorded iOS tests, Xbox One and Xbox Series battery reporting remained at 100% even when the physical controller's actual charge was substantially lower. | [Moonlight iOS battery reporting](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L403-L440) |
+| 11 | 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) |
+| 12 | 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) |
+| 13 | 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. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
+| 14 | Client platform | 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) |
+| 15 | Host output callback | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. End-to-end output remains unavailable because no tested client advertised compatible HOME-light support to Sunshine. | [libvirtualhid Switch Pro output handling](platform-support.md#linux) |
+| 16 | Client capability and output pipeline | 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) |
+| 17 | Host 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. The full host-to-client path is not available in released software. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
+| 18 | Client output protocol | Released moonlight-common-c and Moonlight clients do not consume the Switch Pro player-light extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
+| 19 | 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) |
+| 20 | Client battery reporting | The changes in Moonlight Xbox pull request #291 forward the physical controller's battery capability and charge updates. The released client does not yet include this support. | [Moonlight Xbox pull request #291](https://github.com/TheElixZammuto/moonlight-xbox/pull/291) |
+| 21 | Client platform, version, and connection | Moonlight Qt v6.1.0 on Windows did not forward Share from the tested Xbox Series controller to a Linux host, while Moonlight Android forwarded Share through the same host backend. The Bluetooth test log identified the controller as `045e:0b13`, but its SDL mapping omitted `misc1`. Moonlight Qt already maps `SDL_CONTROLLER_BUTTON_MISC1` to the Moonlight `MISC_FLAG`, so the observed failure is in the released client's SDL2 Windows controller mapping or input path, not in the streaming protocol, Sunshine, or the Linux `libvirtualhid` backend. Moonlight Qt master has moved to SDL3 through sdl2-compat, but that unreleased path has not been tested here. | [Moonlight Qt v6.1.0 gamepad mapping](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp#L26-L35), [Moonlight Qt v6.1.0 release](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0), and [SDL Xbox Series Share mapping issue](https://github.com/libsdl-org/SDL/issues/5101) |
+| 22 | Client platform | On the tested Xbox console, the Xbox operating system captured the Share button instead of delivering it to Moonlight Xbox. The client reads `Windows.Gaming.Input.GamepadReading`, whose `GamepadButtons` bitfield has no Share value, so Moonlight Xbox cannot forward this input to Sunshine. | [Moonlight Xbox pull request #289](https://github.com/TheElixZammuto/moonlight-xbox/pull/289), [Moonlight Xbox gamepad-reading path](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L529-L682), and [Windows GamepadButtons](https://learn.microsoft.com/en-us/uwp/api/windows.gaming.input.gamepadbuttons) |
+| 23 | Client release | 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) |
+| 24 | Windows host backend | Adaptive triggers have not been validated end to end through the Windows backend with the merged Moonlight and Sunshine protocol path. | [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), and [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738) |
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 078bed7..f3efa99 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -91,6 +91,28 @@ streams native `0x30` reports every 15 milliseconds. This coalesces separate
acceleration and gyroscope API updates into the three-sample report cadence used
by a physical USB controller.
+For every gamepad report ID, the VHF driver caches the newest complete input
+report and answers synchronous `GetInputReport` requests from that cache. This
+lets Windows HID consumers retrieve the current battery state for Xbox One,
+Xbox Series, DualShock 4, DualSense, and Switch Pro instead of relying only on
+the asynchronous input stream.
+
+That HID report does not change the XInput battery classification of the VHF
+device. On a Windows desktop where XInput enumerated the virtual Xbox
+controller, `XInputGetBatteryInformation` returned `BATTERY_TYPE_DISCONNECTED`
+and `BATTERY_LEVEL_EMPTY` even while its input was available through
+`XInputGetState`. Headless Windows CI did not expose an XInput slot for the same
+device. Neither path exposes the remote battery through XInput. Consumers that
+prefer XInput, including SDL's correlated Windows Xbox path and Windows Game
+Bar, therefore do not receive the remote Xbox battery value. DualShock 4,
+DualSense, and Switch Pro battery state is independently covered through SDL's
+HID path.
+
+The current Steam client displays its controller battery indicator only when it
+classifies the device as Bluetooth or wireless. Because VHF exposes a wired
+virtual transport, Steam can hide the battery indicator for every Windows
+profile even when another HID consumer can retrieve the submitted value.
+
Windows VHF devices do not expose a Bluetooth transport identity to HIDAPI.
The Windows backend therefore reports DualShock 4 and DualSense requests as
effective USB profiles through `Gamepad::profile()` and uses the matching USB
@@ -127,8 +149,14 @@ reserving the physical middle button for button scrolling.
Gamepad support normally prefers `uhid` because descriptors, raw HID identity,
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
+One and Xbox Series use backend-only Bluetooth identities with a BLE descriptor,
+sparse input bitmap, four-motor output framing, and the native report-ID `0x04`
+battery notification. When `CreateGamepadOptions::metadata.has_battery` is true,
+the descriptor exposes the notification's four categorical wireless charge
+levels through a byte-aligned standard HID Battery Strength field and the
+backend emits it only when the submitted state contains battery data. Clients
+without battery support therefore do not create a phantom Linux power device
+or receive a fabricated charge level. The normal input 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,
@@ -171,8 +199,10 @@ Xbox 360 retains its `0x045E:0x028E` identity, while its Linux uinput device use
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.
+carry canonical gamepad input, coarse battery levels, and four-motor output,
+which the backend decodes into ordinary and independent trigger-rumble
+callbacks. The backend maps the continuous percentage to the nearest native
+Xbox level exposed by SDL: 10, 40, 70, or 100 percent.
If UHID is unavailable, the Xbox One and Xbox Series uinput fallbacks use the
corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, respectively),
@@ -183,8 +213,9 @@ 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.
+Linux uinput cannot expose independent trigger motors or native Xbox battery
+notifications, so its effective profile clears trigger-rumble and battery
+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/usage.md b/docs/usage.md
index a2f34ba..d8a9ff4 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -233,9 +233,18 @@ 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. 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.
+features. Xbox One and Xbox Series advertise `supports_trigger_rumble` and
+`supports_battery`; the Linux UHID Bluetooth transport preserves both
+capabilities, while the uinput fallback clears them and retains ordinary
+rumble. The Linux Xbox transport includes its battery descriptor only when
+`CreateGamepadOptions::metadata.has_battery` is true, and it emits battery
+reports only for submitted states that contain battery data. On Windows, the
+Xbox HID report carries battery strength, but consumers
+that prefer XInput do not receive the submitted remote value. The current Steam
+client also hides its controller battery indicator for devices it does not
+classify as Bluetooth or wireless; Windows VHF exposes a wired virtual transport
+for every profile.
+
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/docs/windows-driver.md b/docs/windows-driver.md
index 5420216..b059c32 100644
--- a/docs/windows-driver.md
+++ b/docs/windows-driver.md
@@ -93,6 +93,28 @@ backpressure does not turn relative movement into a replaceable absolute state.
Profile initialization replies are prioritized over pending controller states
so the Switch Pro handshake remains responsive.
+The driver also caches the newest complete input report for each report ID and
+answers VHF `GetInputReport` requests from that cache. Synchronous HID consumers
+can therefore query the current controller and battery state even when they do
+not consume the streaming read queue. Unnumbered reports are returned with the
+leading zero report-ID byte expected by Windows HID APIs.
+
+For Xbox profiles, this HID input value is separate from the battery result
+returned by XInput. On a Windows desktop where XInput enumerated the VHF Xbox
+device, `XInputGetBatteryInformation` returned `BATTERY_TYPE_DISCONNECTED` and
+`BATTERY_LEVEL_EMPTY` even while `XInputGetState` received its input and
+`GetInputReport` contained the submitted value. Headless Windows CI did not
+expose an XInput slot for the same device. Neither path exposes the remote
+battery through XInput. SDL's Windows Xbox path and Windows Game Bar therefore
+have no XInput battery value to display. VHF does not expose a
+wireless-transport or XInput battery-type setting in `VHF_CONFIG`.
+
+The current Steam client also renders its controller battery indicator only for
+devices it classifies as Bluetooth or wireless. All Windows VHF profiles use a
+wired virtual transport, so this UI policy can hide battery values that remain
+available to HID consumers. SDL's HID path independently receives battery state
+for the Windows DualShock 4, DualSense, and Switch Pro profiles.
+
The driver rejects virtual HID create, destroy, and broker-instance reset IOCTLs
unless the requestor token contains the `NT SERVICE\libvirtualhid_broker`
service SID. On the first boot after installation, before Windows applies a
@@ -420,10 +442,10 @@ Xbox Series profile remains `VID_045E&PID_0B12`; the Windows transport presents
it with release `0x0509` and the `VID_045E&PID_0B12&IG_00` XInputHID match ID
observed from physical Xbox Series USB and Xbox Wireless Adapter connections.
The VHF child preserves the native 17-byte GIP-shaped input report, and the
-report parser accepts the native eight-byte four-motor Xbox payload when a
-consumer delivers it. The Xbox 360 profile is rejected by the UMDF/VHF backend
-because a real Xbox 360 controller is an XUSB device rather than a VHF HID
-gamepad.
+last byte carries battery strength for both Xbox One and Xbox Series. The report
+parser accepts the native eight-byte four-motor Xbox payload when a consumer
+delivers it. The Xbox 360 profile is rejected by the UMDF/VHF backend because a
+real Xbox 360 controller is an XUSB device rather than a VHF HID gamepad.
DualShock 4 and DualSense answer the calibration, pairing, and firmware feature
requests used by their Windows HIDAPI initialization paths. Switch Pro answers
diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp
index abb71ed..6b08624 100644
--- a/src/core/profiles.cpp
+++ b/src/core/profiles.cpp
@@ -1961,7 +1961,7 @@ namespace lvh::profiles {
profile.capabilities = {
.supports_rumble = true,
.supports_trigger_rumble = true,
- .supports_battery = include_share_button,
+ .supports_battery = true,
};
profile.report_descriptor = make_xbox_gip_report_descriptor(include_share_button);
return profile;
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index 1192447..8f01d32 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -120,13 +120,17 @@ namespace lvh::detail {
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;
+ constexpr std::uint8_t xbox_bluetooth_battery_report_id = 0x04;
+ constexpr std::size_t xbox_bluetooth_battery_report_size = 2;
- 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.
+ std::vector make_xbox_bluetooth_report_descriptor(bool include_battery) {
+ // Preserve the native 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 {
+ // these usages to ABS_RX/ABS_RY and ABS_Z/ABS_RZ for Steam. Battery-capable
+ // clients add the native two-byte Xbox Bluetooth battery notification as
+ // report 4 through the standard Battery Strength usage.
+ std::vector descriptor {
0x05,
0x01, // Usage Page (Generic Desktop)
0x09,
@@ -409,9 +413,36 @@ namespace lvh::detail {
0x91,
0x02, // Output (Data, Variable, Absolute)
0xC0, // End Collection
- 0xC0, // End Collection
};
- return {descriptor.begin(), descriptor.end()};
+
+ if (include_battery) {
+ // SDL's Linux Xbox descriptor reader requires byte-sized fields. Keep
+ // the native wireless/source flag in the byte while narrowing its
+ // logical range to the four values this backend emits, so Linux power
+ // consumers can scale the categorical levels without breaking HIDAPI.
+ constexpr std::array battery_descriptor {
+ 0x05,
+ 0x06, // Usage Page (Generic Device Controls)
+ 0x09,
+ 0x20, // Usage (Battery Strength)
+ 0x85,
+ xbox_bluetooth_battery_report_id, // Report ID (4)
+ 0x15,
+ 0x04, // Logical Minimum (wireless, empty)
+ 0x25,
+ 0x07, // Logical Maximum (wireless, full)
+ 0x75,
+ 0x08, // Report Size (8)
+ 0x95,
+ 0x01, // Report Count (1)
+ 0x81,
+ 0x02, // Input (Data, Variable, Absolute)
+ };
+ descriptor.insert(descriptor.end(), battery_descriptor.begin(), battery_descriptor.end());
+ }
+
+ descriptor.push_back(0xC0); // End Collection
+ return descriptor;
}
#endif
@@ -596,7 +627,7 @@ namespace lvh::detail {
return false;
}
- DeviceProfile uhid_transport_profile(const DeviceProfile &requested_profile) {
+ DeviceProfile uhid_transport_profile(const DeviceProfile &requested_profile, bool include_battery) {
auto transport_profile = requested_profile;
if (is_xbox_uhid_profile(requested_profile.gamepad_kind)) {
const auto series = requested_profile.gamepad_kind == GamepadProfileKind::xbox_series;
@@ -606,7 +637,7 @@ namespace lvh::detail {
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();
+ transport_profile.report_descriptor = make_xbox_bluetooth_report_descriptor(include_battery);
}
return transport_profile;
}
@@ -653,6 +684,29 @@ namespace lvh::detail {
return report;
}
+ std::vector make_xbox_bluetooth_battery_report(const GamepadBattery &battery) {
+ constexpr auto wireless_battery_source = std::byte {0x04};
+ constexpr std::uint8_t ten_percent_battery_level = 0;
+ constexpr std::uint8_t forty_percent_battery_level = 1;
+ constexpr std::uint8_t seventy_percent_battery_level = 2;
+ constexpr std::uint8_t full_battery_level = 3;
+
+ const auto percentage = std::min(battery.percentage, 100U);
+ auto level = full_battery_level;
+ if (percentage <= 25U) {
+ level = ten_percent_battery_level;
+ } else if (percentage <= 55U) {
+ level = forty_percent_battery_level;
+ } else if (percentage <= 85U) {
+ level = seventy_percent_battery_level;
+ }
+
+ std::vector report(xbox_bluetooth_battery_report_size);
+ report[0] = xbox_bluetooth_battery_report_id;
+ report[1] = std::to_integer(wireless_battery_source | static_cast(level));
+ return report;
+ }
+
#endif
std::optional uinput_misc1_button(GamepadProfileKind kind) {
@@ -3213,7 +3267,7 @@ 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);
+ const auto transport_profile = uhid_transport_profile(options.profile, options.metadata.has_battery);
if (transport_profile.report_descriptor.size() > sizeof(request.rd_data)) {
return OperationStatus::failure(ErrorCode::unsupported_profile, "HID report descriptor is too large for UHID");
@@ -3248,6 +3302,7 @@ namespace lvh::detail {
transport_profile.report_descriptor.size()
);
profile_ = options.profile;
+ supports_battery_ = options.metadata.has_battery;
{
std::lock_guard lock {state_mutex_};
last_state_ = {};
@@ -3294,6 +3349,9 @@ namespace lvh::detail {
) :
report;
auto status = write_input_report(transport_report);
+ if (status.ok() && supports_battery_ && state.battery && is_xbox_uhid_profile(profile_.gamepad_kind)) {
+ status = write_input_report(make_xbox_bluetooth_battery_report(*state.battery));
+ }
if (status.ok()) {
last_state_ = state;
}
@@ -3629,6 +3687,7 @@ namespace lvh::detail {
std::string unique_id_;
std::array playstation_mac_address_ {};
GamepadState last_state_;
+ bool supports_battery_ = false;
std::atomic_bool open_ = true;
std::atomic_bool running_ = false;
std::jthread reader_;
@@ -3661,6 +3720,7 @@ namespace lvh::detail {
return std::nullopt;
}
effective_profile.capabilities.supports_trigger_rumble = false;
+ effective_profile.capabilities.supports_battery = false;
return effective_profile;
#endif
}
diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp
index 5c1fe8a..74abfc1 100644
--- a/src/platform/windows/driver/libvirtualhid_umdf.cpp
+++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp
@@ -73,6 +73,7 @@ EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL LvhEvtIoDeviceControl;
EVT_WDF_OBJECT_CONTEXT_CLEANUP LvhEvtDeviceCleanup;
EVT_WDF_REQUEST_CANCEL LvhEvtOutputReadCanceled;
EVT_VHF_ASYNC_OPERATION LvhEvtVhfGetFeature;
+EVT_VHF_ASYNC_OPERATION LvhEvtVhfGetInputReport;
EVT_VHF_READY_FOR_NEXT_READ_REPORT LvhEvtVhfReadyForNextReadReport;
EVT_VHF_ASYNC_OPERATION LvhEvtVhfSetFeature;
EVT_VHF_ASYNC_OPERATION LvhEvtVhfWriteReport;
@@ -120,6 +121,7 @@ namespace {
std::wstring hardware_ids;
lvh::detail::windows::GenericPidFeatureState generic_pid_feature_state;
lvh::detail::windows::VhfInputReportQueue pending_input_reports;
+ std::map> latest_input_reports;
std::shared_ptr> in_flight_input_report;
std::size_t active_input_submissions {};
std::uint8_t switch_pro_packet_timer {};
@@ -307,6 +309,9 @@ namespace {
if (record.shutting_down || record.vhf_handle == nullptr) {
return STATUS_OBJECT_NAME_NOT_FOUND;
}
+ const auto configured_report_id = record.request.hardware_ids.report_id;
+ const auto report_id = configured_report_id == 0U || report.empty() ? configured_report_id : report.front();
+ record.latest_input_reports.insert_or_assign(report_id, report);
record.pending_input_reports.push(std::move(report));
}
@@ -421,6 +426,7 @@ namespace {
record->vhf_handle = nullptr;
record->vhf_ready_for_input_report = false;
record->pending_input_reports.clear();
+ record->latest_input_reports.clear();
record->submissions_drained.wait(lock, [record] {
return record->active_input_submissions == 0U;
});
@@ -561,6 +567,7 @@ namespace {
vhf_config.HardwareIDs = record->hardware_ids.data();
vhf_config.EvtVhfReadyForNextReadReport = LvhEvtVhfReadyForNextReadReport;
vhf_config.EvtVhfAsyncOperationGetFeature = LvhEvtVhfGetFeature;
+ vhf_config.EvtVhfAsyncOperationGetInputReport = LvhEvtVhfGetInputReport;
vhf_config.EvtVhfAsyncOperationSetFeature = LvhEvtVhfSetFeature;
vhf_config.EvtVhfAsyncOperationWriteReport = LvhEvtVhfWriteReport;
@@ -923,6 +930,36 @@ namespace {
return STATUS_SUCCESS;
}
+ NTSTATUS copy_vhf_input_report(DeviceRecord &record, HID_XFER_PACKET &packet) {
+ auto report_number = packet.reportId;
+ if (
+ report_number == 0U && record.request.hardware_ids.report_id != 0U &&
+ packet.reportBufferLen > 0U
+ ) {
+ report_number = packet.reportBuffer[0];
+ }
+
+ auto report = std::vector {};
+ {
+ std::lock_guard lock {record.mutex};
+ const auto iter = record.latest_input_reports.find(report_number);
+ if (iter == record.latest_input_reports.end()) {
+ return STATUS_NOT_SUPPORTED;
+ }
+ report = iter->second;
+ }
+
+ const auto unnumbered_report = report_number == 0U;
+ const auto leading_report_id_size = unnumbered_report && packet.reportBufferLen > report.size() ? 1U : 0U;
+ if (packet.reportBufferLen < report.size() + leading_report_id_size) {
+ return STATUS_BUFFER_TOO_SMALL;
+ }
+
+ std::fill_n(packet.reportBuffer, packet.reportBufferLen, UCHAR {});
+ std::copy(report.begin(), report.end(), packet.reportBuffer + leading_report_id_size);
+ return STATUS_SUCCESS;
+ }
+
bool handle_vhf_set_feature(DeviceRecord &record, const HID_XFER_PACKET &packet) {
if (
record.request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD &&
@@ -1326,6 +1363,27 @@ void LvhEvtVhfGetFeature(
static_cast(VhfAsyncOperationComplete(vhf_operation_handle, status));
}
+void LvhEvtVhfGetInputReport(
+ VhfContext vhf_client_context,
+ VHFOPERATIONHANDLE vhf_operation_handle,
+ VhfContext vhf_operation_context,
+ PHID_XFER_PACKET hid_transfer_packet
+) {
+ UNREFERENCED_PARAMETER(vhf_operation_context);
+
+ auto *record = static_cast(vhf_client_context);
+ if (record == nullptr || hid_transfer_packet == nullptr || hid_transfer_packet->reportBuffer == nullptr) {
+ static_cast(VhfAsyncOperationComplete(vhf_operation_handle, STATUS_INVALID_PARAMETER));
+ return;
+ }
+
+ const auto status = copy_vhf_input_report(*record, *hid_transfer_packet);
+ if (!NT_SUCCESS(status)) {
+ trace_status("EvtVhfGetInputReport complete", status);
+ }
+ static_cast(VhfAsyncOperationComplete(vhf_operation_handle, status));
+}
+
void LvhEvtVhfSetFeature(
VhfContext vhf_client_context,
VHFOPERATIONHANDLE vhf_operation_handle,
diff --git a/tests/consumers/linux_sdl3_xbox_consumer.cpp b/tests/consumers/linux_sdl3_xbox_consumer.cpp
index 357d1e7..8a7f0b8 100644
--- a/tests/consumers/linux_sdl3_xbox_consumer.cpp
+++ b/tests/consumers/linux_sdl3_xbox_consumer.cpp
@@ -281,6 +281,32 @@ namespace {
return false;
}
+ bool validate_battery(lvh::Gamepad &virtual_gamepad, SDL_Gamepad *gamepad) {
+ lvh::GamepadState state;
+ state.battery = lvh::GamepadBattery {
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ };
+ if (!virtual_gamepad.submit(state).ok()) {
+ return false;
+ }
+
+ const auto deadline = std::chrono::steady_clock::now() + 3s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ SDL_UpdateGamepads();
+ SDL_PumpEvents();
+ if (
+ auto percentage = -1;
+ SDL_GetGamepadPowerInfo(gamepad, &percentage) == SDL_POWERSTATE_ON_BATTERY &&
+ percentage == 40
+ ) {
+ 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 (
@@ -422,6 +448,9 @@ namespace {
if (validate_trigger_inputs(virtual_gamepad, gamepad.get()) != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
+ if (!validate_battery(virtual_gamepad, gamepad.get())) {
+ return fail("SDL3 did not expose the Xbox battery state");
+ }
return validate_rumble(gamepad.get(), output);
}
@@ -450,6 +479,7 @@ namespace {
lvh::CreateGamepadOptions options;
options.profile = test_case.profile;
options.profile.name = std::format("libvirtualhid SDL3 {} {}", test_case.identity_token, ::getpid());
+ options.metadata.has_battery = true;
options.metadata.stable_id = std::format("libvirtualhid-sdl3-xbox-{:04x}-{}", test_case.product_id, ::getpid());
auto created = runtime->create_gamepad(options);
if (!created) {
@@ -458,6 +488,9 @@ namespace {
if (!created.gamepad->profile().capabilities.supports_trigger_rumble) {
return fail("Xbox creation did not select the trigger-rumble-capable UHID backend");
}
+ if (!created.gamepad->profile().capabilities.supports_battery) {
+ return fail("Xbox creation did not select the battery-capable UHID backend");
+ }
if (!wait_for_accessible_hidraw_node(*created.gamepad)) {
log_device_nodes(*created.gamepad);
log_kernel_hid_state();
diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
index 179fbaf..f26ea1c 100644
--- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
@@ -182,10 +182,15 @@ namespace lvh::detail::test {
*/
struct LinuxUhidXboxObservation {
/**
- * @brief Whether UHID creation used the 283-byte Linux Xbox BLE transport descriptor.
+ * @brief Whether UHID creation used the Linux Xbox BLE transport descriptor.
*/
bool saw_transport_descriptor = false;
+ /**
+ * @brief Whether the transport descriptor advertised a two-bit Xbox battery level.
+ */
+ bool saw_battery_descriptor = false;
+
/**
* @brief Whether the descriptor maps sticks and triggers to canonical evdev axes.
*/
@@ -206,6 +211,11 @@ namespace lvh::detail::test {
*/
bool saw_input = false;
+ /**
+ * @brief Whether submitted battery state was carried in a native Bluetooth battery report.
+ */
+ bool saw_battery_input = false;
+
/**
* @brief Whether the Guide button was carried in the native button bitmap.
*/
@@ -976,9 +986,15 @@ namespace lvh::detail::test {
* @brief Exercise an Xbox profile's Bluetooth input and four-motor output through a socketpair.
*
* @param kind Xbox One or Xbox Series profile kind.
+ * @param advertises_battery Whether client metadata advertises battery support.
+ * @param submits_battery Whether submitted state includes battery data.
* @return Captured lifecycle and protocol observations.
*/
- LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(GamepadProfileKind kind);
+ LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(
+ GamepadProfileKind kind,
+ bool advertises_battery,
+ bool submits_battery
+ );
/**
* @brief Check whether the Linux backend prefers UHID for a gamepad profile.
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index 7686553..48639db 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -100,19 +100,6 @@ 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);
}
@@ -747,7 +734,7 @@ namespace lvh::detail::test {
result.creation.waited_for_start
);
if (result.creation.saw_create) {
- const auto transport_profile = uhid_transport_profile(options.profile);
+ const auto transport_profile = uhid_transport_profile(options.profile, options.metadata.has_battery);
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 &&
@@ -1617,7 +1604,11 @@ namespace lvh::detail::test {
return result;
}
- LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(GamepadProfileKind kind) {
+ LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(
+ GamepadProfileKind kind,
+ bool advertises_battery,
+ bool submits_battery
+ ) {
LinuxUhidRoundTripResult result;
std::array descriptors {-1, -1};
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors.data()) != 0) {
@@ -1631,6 +1622,7 @@ namespace lvh::detail::test {
CreateGamepadOptions options;
options.profile = profile;
options.metadata.stable_id = "xbox-bluetooth-uhid-roundtrip";
+ options.metadata.has_battery = advertises_battery;
UhidGamepad gamepad {descriptors[0]};
auto event = create_started_profile_uhid_gamepad(gamepad, 13, options, descriptors[1], BUS_BLUETOOTH, result);
@@ -1640,8 +1632,27 @@ namespace lvh::detail::test {
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 battery_report_descriptor {
+ 0x05U,
+ 0x06U,
+ 0x09U,
+ 0x20U,
+ 0x85U,
+ xbox_bluetooth_battery_report_id,
+ 0x15U,
+ 0x04U,
+ 0x25U,
+ 0x07U,
+ 0x75U,
+ 0x08U,
+ 0x95U,
+ 0x01U,
+ 0x81U,
+ 0x02U,
+ };
+ result.xbox.saw_transport_descriptor = descriptor.size() == (advertises_battery ? 299U : 283U);
+ result.xbox.saw_battery_descriptor =
+ std::ranges::search(descriptor, battery_report_descriptor).begin() != descriptor.end();
constexpr std::array right_stick_usages {
0x09U,
0x33U,
@@ -1707,6 +1718,12 @@ namespace lvh::detail::test {
state.right_stick = {.x = 0.5F, .y = -0.5F};
state.left_trigger = 0.25F;
state.right_trigger = 0.75F;
+ if (submits_battery) {
+ state.battery = GamepadBattery {
+ .state = GamepadBatteryState::discharging,
+ .percentage = 50,
+ };
+ }
const auto report = reports::pack_input_report(profile, state);
result.submit_status = gamepad.submit(state, report);
@@ -1725,6 +1742,13 @@ namespace lvh::detail::test {
}
result.saw_input = result.xbox.saw_input;
+ 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_battery_input =
+ input.size() == xbox_bluetooth_battery_report_size && input[0] == xbox_bluetooth_battery_report_id &&
+ input[1] == 0x05U;
+ }
+
constexpr std::array motor_report {
xbox_bluetooth_rumble_report_id,
0x0F,
diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp
index f94c24b..c792b61 100644
--- a/tests/unit/test_gamepad_adapter.cpp
+++ b/tests/unit/test_gamepad_adapter.cpp
@@ -16,6 +16,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) {
const auto generic = lvh::profiles::generic_gamepad();
const auto xbox_360 = lvh::profiles::xbox_360();
const auto xbox_one = lvh::profiles::xbox_one();
+ const auto xbox_series = lvh::profiles::xbox_series();
const auto dualshock4 = lvh::profiles::dualshock4();
const auto dualsense = lvh::profiles::dualsense();
const auto switch_pro = lvh::profiles::switch_pro();
@@ -30,6 +31,8 @@ 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);
+ EXPECT_TRUE(lvh::gamepad_profile_support(xbox_one).supports_battery);
+ EXPECT_TRUE(lvh::gamepad_profile_support(xbox_series).supports_battery);
const auto dualshock4_support = lvh::gamepad_profile_support(dualshock4);
EXPECT_TRUE(dualshock4_support.supports_rumble);
@@ -110,6 +113,34 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) {
EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast(255)));
}
+TEST(GamepadAdapterTest, XboxProfilesAcceptAndClearBatteryUpdates) {
+ auto runtime = lvh::Runtime::create();
+ ASSERT_NE(runtime, nullptr);
+
+ for (const auto &profile : {lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) {
+ SCOPED_TRACE(profile.name);
+ lvh::CreateGamepadOptions options;
+ options.profile = profile;
+ options.metadata.has_battery = true;
+ options.metadata.stable_id = profile.name;
+
+ auto created = lvh::GamepadStateAdapter::create(*runtime, options);
+ ASSERT_TRUE(created) << created.status.message();
+ ASSERT_TRUE(created.adapter->set_battery({
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ })
+ .ok());
+ ASSERT_EQ(created.adapter->gamepad()->last_input_report().size(), 17U);
+ EXPECT_EQ(created.adapter->gamepad()->last_input_report()[16], 128U);
+
+ ASSERT_TRUE(created.adapter->clear_battery().ok());
+ ASSERT_EQ(created.adapter->gamepad()->last_input_report().size(), 17U);
+ EXPECT_EQ(created.adapter->gamepad()->last_input_report()[16], 0xFFU);
+ EXPECT_TRUE(created.adapter->close().ok());
+ }
+}
+
TEST(GamepadAdapterTest, CachesAndSubmitsPartialUpdates) {
auto runtime = lvh::Runtime::create();
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index ac00bd0..e3761f3 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -845,18 +845,21 @@ TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback)
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);
+ EXPECT_FALSE(effective_profile.capabilities.supports_battery);
- const auto result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind);
+ const auto result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind, true, true);
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_battery_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_battery_input);
EXPECT_TRUE(result.xbox.saw_guide);
EXPECT_TRUE(result.xbox.saw_profile_consumer_button);
EXPECT_TRUE(result.saw_destroy);
@@ -866,6 +869,28 @@ TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback)
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);
+
+ const auto no_battery_result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind, false, true);
+ EXPECT_TRUE(no_battery_result.create_status.ok()) << no_battery_result.create_status.message();
+ EXPECT_TRUE(no_battery_result.submit_status.ok()) << no_battery_result.submit_status.message();
+ EXPECT_TRUE(no_battery_result.close_status.ok()) << no_battery_result.close_status.message();
+ EXPECT_TRUE(no_battery_result.creation.saw_create);
+ EXPECT_TRUE(no_battery_result.xbox.saw_transport_descriptor);
+ EXPECT_FALSE(no_battery_result.xbox.saw_battery_descriptor);
+ EXPECT_TRUE(no_battery_result.xbox.saw_input);
+ EXPECT_FALSE(no_battery_result.xbox.saw_battery_input);
+ EXPECT_TRUE(no_battery_result.saw_destroy);
+
+ const auto missing_battery_result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind, true, false);
+ EXPECT_TRUE(missing_battery_result.create_status.ok()) << missing_battery_result.create_status.message();
+ EXPECT_TRUE(missing_battery_result.submit_status.ok()) << missing_battery_result.submit_status.message();
+ EXPECT_TRUE(missing_battery_result.close_status.ok()) << missing_battery_result.close_status.message();
+ EXPECT_TRUE(missing_battery_result.creation.saw_create);
+ EXPECT_TRUE(missing_battery_result.xbox.saw_transport_descriptor);
+ EXPECT_TRUE(missing_battery_result.xbox.saw_battery_descriptor);
+ EXPECT_TRUE(missing_battery_result.xbox.saw_input);
+ EXPECT_FALSE(missing_battery_result.xbox.saw_battery_input);
+ EXPECT_TRUE(missing_battery_result.saw_destroy);
}
}
diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp
index 461e518..8ef7337 100644
--- a/tests/unit/test_linux_consumers.cpp
+++ b/tests/unit/test_linux_consumers.cpp
@@ -69,6 +69,7 @@ namespace {
int minimum_axes = 2;
bool require_sdl_rumble = false;
bool require_trigger_rumble = false;
+ bool require_battery = false;
bool require_motion = false;
bool expect_live_input = true;
};
@@ -505,6 +506,7 @@ namespace {
lvh::CreateGamepadOptions options;
options.profile = test_case.profile;
options.profile.name = unique_device_name(test_case.name_suffix);
+ options.metadata.has_battery = test_case.require_battery;
options.metadata.stable_id = std::string {test_case.stable_id};
return runtime.create_gamepad(options);
@@ -638,6 +640,25 @@ namespace {
<< acceleration[2] << " gyro=" << gyroscope[0] << "," << gyroscope[1] << "," << gyroscope[2];
}
+ void expect_sdl_battery_input(SDL_Joystick *joystick, lvh::Gamepad &gamepad) {
+ lvh::GamepadState state;
+ state.battery = lvh::GamepadBattery {
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ };
+ ASSERT_TRUE(gamepad.submit(state).ok());
+
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3};
+ while (
+ std::chrono::steady_clock::now() < deadline &&
+ SDL_JoystickCurrentPowerLevel(joystick) != SDL_JOYSTICK_POWER_MEDIUM) {
+ SDL_GameControllerUpdate();
+ pump_sdl_events();
+ std::this_thread::sleep_for(std::chrono::milliseconds {20});
+ }
+ EXPECT_EQ(SDL_JoystickCurrentPowerLevel(joystick), SDL_JOYSTICK_POWER_MEDIUM);
+ }
+
void expect_hidraw_rumble_callback(const lvh::DeviceProfile &profile, lvh::Gamepad &gamepad) {
const auto rumble = observe_rumble(gamepad);
const auto hidraw_node = wait_for_hidraw_node(gamepad);
@@ -804,6 +825,9 @@ namespace {
if (test_case.require_motion) {
expect_sdl_motion_input(controller.get(), gamepad);
}
+ if (test_case.require_battery) {
+ expect_sdl_battery_input(joystick, gamepad);
+ }
expect_sdl_rumble_callback(controller.get(), gamepad, test_case.require_trigger_rumble);
}
@@ -983,6 +1007,7 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxOneCanonicalButtons) {
.minimum_axes = 6,
.require_sdl_rumble = true,
.require_trigger_rumble = true,
+ .require_battery = true,
});
}
@@ -998,6 +1023,7 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) {
.minimum_axes = 6,
.require_sdl_rumble = true,
.require_trigger_rumble = true,
+ .require_battery = true,
};
run_sdl_canonical_gamepad_test(test_case);
}
diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp
index 0a7c22c..9b6b208 100644
--- a/tests/unit/test_profiles.cpp
+++ b/tests/unit/test_profiles.cpp
@@ -99,6 +99,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
EXPECT_EQ(xbox_one.manufacturer, "Microsoft");
EXPECT_TRUE(xbox_one.capabilities.supports_rumble);
EXPECT_TRUE(xbox_one.capabilities.supports_trigger_rumble);
+ EXPECT_TRUE(xbox_one.capabilities.supports_battery);
EXPECT_EQ(xbox_one.report_id, 0);
EXPECT_EQ(xbox_one.input_report_size, 17U);
EXPECT_EQ(xbox_one.output_report_size, 8U);
@@ -112,6 +113,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
EXPECT_EQ(xbox_series.input_report_size, 17U);
EXPECT_EQ(xbox_series.output_report_size, 8U);
EXPECT_TRUE(xbox_series.capabilities.supports_trigger_rumble);
+ EXPECT_TRUE(xbox_series.capabilities.supports_battery);
const std::array xbox_gip_stick_axis_descriptor {
0x15,
diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp
index cd0f9e9..2b99db7 100644
--- a/tests/unit/test_windows_consumers.cpp
+++ b/tests/unit/test_windows_consumers.cpp
@@ -20,6 +20,7 @@
#include
#include
#include
+#include
#if defined(LIBVIRTUALHID_TEST_HAS_SDL3)
#include
@@ -28,6 +29,7 @@
// standard includes
#include
#include
+#include
#include
#include
#include
@@ -115,6 +117,79 @@ namespace {
HANDLE value_;
};
+ class XInputApi {
+ public:
+ using GetState = DWORD(WINAPI *)(DWORD, XINPUT_STATE *);
+ using GetBatteryInformation = DWORD(WINAPI *)(DWORD, BYTE, XINPUT_BATTERY_INFORMATION *);
+
+ XInputApi():
+ module_ {LoadLibraryW(L"xinput1_4.dll")} {
+ if (module_ != nullptr) {
+ get_state_ = std::bit_cast(GetProcAddress(module_, "XInputGetState"));
+ get_battery_information_ = std::bit_cast(
+ GetProcAddress(module_, "XInputGetBatteryInformation")
+ );
+ }
+ }
+
+ XInputApi(const XInputApi &) = delete;
+ XInputApi &operator=(const XInputApi &) = delete;
+
+ ~XInputApi() {
+ if (module_ != nullptr) {
+ static_cast(FreeLibrary(module_));
+ }
+ }
+
+ explicit operator bool() const {
+ return get_state_ != nullptr && get_battery_information_ != nullptr;
+ }
+
+ DWORD state(DWORD slot, XINPUT_STATE *state) const {
+ return get_state_(slot, state);
+ }
+
+ DWORD battery_information(
+ DWORD slot,
+ BYTE device_type,
+ XINPUT_BATTERY_INFORMATION *battery
+ ) const {
+ return get_battery_information_(slot, device_type, battery);
+ }
+
+ private:
+ HMODULE module_ = nullptr;
+ GetState get_state_ = nullptr;
+ GetBatteryInformation get_battery_information_ = nullptr;
+ };
+
+ std::set current_xinput_slots(const XInputApi &xinput) {
+ std::set result;
+ for (auto slot = DWORD {0}; slot < XUSER_MAX_COUNT; ++slot) {
+ XINPUT_STATE state {};
+ if (xinput.state(slot, &state) == ERROR_SUCCESS) {
+ result.insert(slot);
+ }
+ }
+ return result;
+ }
+
+ std::optional wait_for_new_xinput_slot(
+ const XInputApi &xinput,
+ const std::set &previous_slots
+ ) {
+ const auto deadline = std::chrono::steady_clock::now() + 10s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ for (const auto slot : current_xinput_slots(xinput)) {
+ if (!previous_slots.contains(slot)) {
+ return slot;
+ }
+ }
+ std::this_thread::sleep_for(20ms);
+ }
+ return std::nullopt;
+ }
+
template
struct ComReleaser {
void operator()(Interface *value) const {
@@ -517,6 +592,19 @@ namespace {
return {nullptr, &SDL_CloseGamepad};
}
+ bool wait_for_sdl_gamepad_removal(SDL_JoystickID gamepad_id) {
+ const auto deadline = std::chrono::steady_clock::now() + 10s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ SDL_UpdateGamepads();
+ SDL_PumpEvents();
+ if (!current_sdl_gamepads().contains(gamepad_id)) {
+ return true;
+ }
+ std::this_thread::sleep_for(20ms);
+ }
+ return false;
+ }
+
bool wait_for_switch_pro_motion(SDL_Gamepad *gamepad) {
std::array acceleration {};
std::array gyroscope {};
@@ -626,8 +714,110 @@ TEST_F(WindowsConsumerTest, SdlHidapiOutputReachesDefaultPlayStationAndSwitchCal
ASSERT_TRUE(created.adapter->close().ok());
}
}
+
+TEST_F(WindowsConsumerTest, SdlExposesSubmittedBatteryStateForNonXboxProfiles) {
+ SdlGamepadSubsystem sdl;
+ ASSERT_TRUE(sdl.initialized()) << SDL_GetError();
+
+ lvh::RuntimeOptions runtime_options;
+ runtime_options.backend = lvh::BackendKind::platform_default;
+ auto runtime = lvh::Runtime::create(runtime_options);
+ ASSERT_NE(runtime, nullptr);
+ ASSERT_TRUE(runtime->capabilities().supports_gamepad)
+ << "The installed libvirtualhid Windows driver is required for this integration test";
+
+ struct BatteryTestCase {
+ lvh::DeviceProfile profile;
+ int expected_percentage;
+ };
+
+ const std::array test_cases {
+ BatteryTestCase {lvh::profiles::dualshock4(), 55},
+ BatteryTestCase {lvh::profiles::dualsense(), 55},
+ BatteryTestCase {lvh::profiles::switch_pro(), 50},
+ };
+ for (const auto &[profile, expected_percentage] : test_cases) {
+ SCOPED_TRACE(profile.name);
+ const auto previous_gamepads = current_sdl_gamepads();
+
+ lvh::CreateGamepadOptions options;
+ options.profile = profile;
+ options.metadata.stable_id = "02:11:22:33:44:55";
+ options.metadata.has_battery = true;
+ auto created = lvh::GamepadStateAdapter::create(*runtime, options);
+ ASSERT_TRUE(created) << created.status.message();
+ ASSERT_NE(created.adapter->gamepad(), nullptr);
+
+ auto gamepad = wait_for_new_sdl_gamepad(previous_gamepads, profile.vendor_id, profile.product_id);
+ ASSERT_NE(gamepad.get(), nullptr) << SDL_GetError();
+ ASSERT_TRUE(created.adapter->set_battery({
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ })
+ .ok());
+
+ auto power_state = SDL_POWERSTATE_UNKNOWN;
+ auto percentage = -1;
+ const auto deadline = std::chrono::steady_clock::now() + 3s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ SDL_UpdateGamepads();
+ SDL_PumpEvents();
+ power_state = SDL_GetGamepadPowerInfo(gamepad.get(), &percentage);
+ if (power_state != SDL_POWERSTATE_UNKNOWN && percentage >= 0) {
+ break;
+ }
+ std::this_thread::sleep_for(20ms);
+ }
+
+ EXPECT_EQ(power_state, SDL_POWERSTATE_ON_BATTERY) << "percentage=" << percentage;
+ EXPECT_EQ(percentage, expected_percentage);
+ const auto gamepad_id = SDL_GetGamepadID(gamepad.get());
+ gamepad.reset();
+ ASSERT_TRUE(created.adapter->close().ok());
+ ASSERT_TRUE(wait_for_sdl_gamepad_removal(gamepad_id));
+ }
+}
#endif
+TEST_F(WindowsConsumerTest, XInputDoesNotExposeSubmittedXboxBattery) {
+ XInputApi xinput;
+ ASSERT_TRUE(xinput);
+ const auto previous_slots = current_xinput_slots(xinput);
+
+ lvh::RuntimeOptions runtime_options;
+ runtime_options.backend = lvh::BackendKind::platform_default;
+ auto runtime = lvh::Runtime::create(runtime_options);
+ ASSERT_NE(runtime, nullptr);
+ ASSERT_TRUE(runtime->capabilities().supports_gamepad)
+ << "The installed libvirtualhid Windows driver is required for this integration test";
+
+ lvh::CreateGamepadOptions options;
+ options.profile = lvh::profiles::xbox_series();
+ options.metadata.stable_id = "xinput-battery-test";
+ options.metadata.has_battery = true;
+ auto created = lvh::GamepadStateAdapter::create(*runtime, options);
+ ASSERT_TRUE(created) << created.status.message();
+ ASSERT_TRUE(created.adapter->set_battery({
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ })
+ .ok());
+ ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::a, true).ok());
+
+ const auto slot = wait_for_new_xinput_slot(xinput, previous_slots);
+ if (!slot.has_value()) {
+ ASSERT_TRUE(created.adapter->close().ok());
+ GTEST_SKIP() << "XInput did not enumerate the VHF gamepad in this Windows environment";
+ }
+
+ XINPUT_BATTERY_INFORMATION battery {};
+ ASSERT_EQ(xinput.battery_information(*slot, BATTERY_DEVTYPE_GAMEPAD, &battery), ERROR_SUCCESS);
+ EXPECT_EQ(battery.BatteryType, BATTERY_TYPE_DISCONNECTED);
+ EXPECT_EQ(battery.BatteryLevel, BATTERY_LEVEL_EMPTY);
+
+ ASSERT_TRUE(created.adapter->close().ok());
+}
+
TEST_F(WindowsConsumerTest, NativePlayStationFeatureAndOutputReportsReachOwningRuntime) {
struct NativePlayStationCase {
lvh::DeviceProfile profile;
@@ -1030,6 +1220,87 @@ TEST_F(WindowsConsumerTest, NativeXboxPidRumbleWritesAreNormalized) {
ASSERT_TRUE(created.adapter->close().ok());
}
+TEST_F(WindowsConsumerTest, BatteryStateIsAvailableThroughGetInputReport) {
+ const std::array profiles {
+ lvh::profiles::xbox_one(),
+ lvh::profiles::xbox_series(),
+ lvh::profiles::dualshock4(),
+ lvh::profiles::dualsense(),
+ lvh::profiles::switch_pro(),
+ };
+
+ lvh::RuntimeOptions runtime_options;
+ runtime_options.backend = lvh::BackendKind::platform_default;
+ auto runtime = lvh::Runtime::create(runtime_options);
+ ASSERT_NE(runtime, nullptr);
+ ASSERT_TRUE(runtime->capabilities().supports_gamepad)
+ << "The installed libvirtualhid Windows driver is required for this integration test";
+
+ for (const auto &profile : profiles) {
+ SCOPED_TRACE(profile.name);
+ const auto previous_paths = current_gamepad_interface_paths();
+
+ lvh::CreateGamepadOptions options;
+ options.profile = profile;
+ options.metadata.has_battery = true;
+ options.metadata.stable_id = profile.name;
+ auto created = lvh::GamepadStateAdapter::create(*runtime, options);
+ ASSERT_TRUE(created) << created.status.message();
+ ASSERT_TRUE(created.adapter->set_battery({
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ })
+ .ok());
+
+ const auto &effective_profile = created.adapter->gamepad()->profile();
+ const auto hid_interface =
+ wait_for_new_interface(previous_paths, effective_profile.vendor_id, effective_profile.product_id);
+ ASSERT_TRUE(hid_interface.has_value()) << "The VHF " << profile.name << " HID interface was not enumerated";
+
+ Handle hid {CreateFileW(
+ hid_interface->path.c_str(),
+ GENERIC_READ | GENERIC_WRITE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ nullptr,
+ OPEN_EXISTING,
+ FILE_ATTRIBUTE_NORMAL,
+ nullptr
+ )};
+ ASSERT_TRUE(hid) << "Unable to open the VHF " << profile.name << " HID interface: " << GetLastError();
+
+ auto expected_report = created.adapter->gamepad()->last_input_report();
+ if (effective_profile.report_id == 0U) {
+ expected_report.insert(expected_report.begin(), 0U);
+ }
+
+ std::vector queried_report(hid_interface->input_report_size, 0);
+ DWORD last_get_input_report_error = ERROR_SUCCESS;
+ auto received_expected_report = false;
+ const auto deadline = std::chrono::steady_clock::now() + 3s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ std::ranges::fill(queried_report, std::uint8_t {});
+ queried_report[0] = effective_profile.report_id;
+ if (HidD_GetInputReport(hid.get(), queried_report.data(), static_cast(queried_report.size())) == FALSE) {
+ last_get_input_report_error = GetLastError();
+ std::this_thread::sleep_for(10ms);
+ continue;
+ }
+
+ last_get_input_report_error = ERROR_SUCCESS;
+ if (queried_report == expected_report) {
+ received_expected_report = true;
+ break;
+ }
+ std::this_thread::sleep_for(10ms);
+ }
+
+ ASSERT_TRUE(received_expected_report)
+ << profile.name << " did not return the current input report before the deadline; last error: "
+ << last_get_input_report_error;
+ EXPECT_TRUE(created.adapter->close().ok());
+ }
+}
+
TEST_F(WindowsConsumerTest, XboxSeriesNativeInputCarriesShareButton) {
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;