feat(gamepad): improve Switch Pro motion and LED support (#114)

This commit is contained in:
Dave Lane authored and GitHub committed 2026-08-28 11:44:43 -04:00
1 parent 61c0c19721
commit 70f98435ba
33 files changed
+1166 -264

No files matched your search

+1 -2
View File
@@ -181,9 +181,8 @@ jobs:
echo "::warning::${kernel_modules_package} is unavailable; relying on the runner image kernel modules."
fi
sudo tee /etc/udev/rules.d/99-libvirtualhid-ci.rules >/dev/null <<'EOF'
SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{phys}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="hidraw", KERNEL=="hidraw*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess"
EOF
+1 -1
View File
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="docs" type="CMakeRunConfiguration" factoryName="Application" PROGRAM_PARAMS="file:///$PROJECT_DIR$/../tray/cmake-build-release/docs/build/html/index.html" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="libvirtualhid" TARGET_NAME="docs" CONFIG_NAME="Release-MinGW (ucrt64-ninja)" RUN_PATH="$PROJECT_DIR$/../../../../../Windows/explorer.exe">
<configuration default="false" name="docs" type="CMakeRunConfiguration" factoryName="Application" PROGRAM_PARAMS="file:///$PROJECT_DIR$/cmake-build-release-mingw-ucrt64-ninja/docs/build/html/index.html" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="libvirtualhid" TARGET_NAME="docs" CONFIG_NAME="Release-MinGW (ucrt64-ninja)" RUN_PATH="$PROJECT_DIR$/../../../../../Windows/explorer.exe">
<method v="2">
<option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" />
</method>
+5 -5
View File
@@ -45,14 +45,14 @@ 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 gamepads through Linux `uhid`; Generic, Xbox,
and Switch Pro gamepads plus keyboard, mouse, touchscreen, trackpad, and pen
tablet devices through `uinput`.
- 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`.
- 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, LEDs,
adaptive triggers, and raw HID output reports when available.
- Output callbacks for profile-specific feedback such as 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,
+39 -32
View File
@@ -113,34 +113,34 @@ 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 | ✅ | ❌<sup><a href="#compatibility-note-11">11</a></sup> | ✅ | ❌ | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ✅ |
| Impulse Triggers | ✅ | ❌<sup><a href="#compatibility-note-10">10</a></sup> | ✅ | ❌ | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ✅ |
| Battery state | ❌<sup><a href="#compatibility-note-4">4</a></sup> | ❌<sup><a href="#compatibility-note-4">4</a></sup> | ❌<sup><a href="#compatibility-note-4">4</a></sup> | ❌<sup><a href="#compatibility-note-4">4</a></sup> | ❌<sup><a href="#compatibility-note-4">4</a></sup> | ❌<sup><a href="#compatibility-note-4">4</a></sup> |
| **Xbox Series** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Impulse Triggers | ✅ | ❌<sup><a href="#compatibility-note-11">11</a></sup> | ✅ | ❌ | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ✅ |
| Impulse Triggers | ✅ | ❌<sup><a href="#compatibility-note-10">10</a></sup> | ✅ | ❌ | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ✅ |
| Battery state | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Share button | ❌<sup><a href="#compatibility-note-3">3</a></sup> | ❌<sup><a href="#compatibility-note-8">8</a></sup> | ❌<sup><a href="#compatibility-note-3">3</a></sup> | ❌<sup><a href="#compatibility-note-3">3</a></sup> | ❌<sup><a href="#compatibility-note-3">3</a></sup> | ❌<sup><a href="#compatibility-note-3">3</a></sup> |
| **DualShock 4** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-5">5</a></sup> | ✅ | ➖ |
| Motion/gyro | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-1">1</a></sup> | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ➖ |
| Touchpad position | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ➖ |
| Touchpad click | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ➖ |
| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-6">6</a></sup> | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ➖ |
| Motion/gyro | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-1">1</a></sup> | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
| Touchpad position | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
| Touchpad click | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-6">6</a></sup> | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
| Battery state | ❌ | ✅ | ✅ | ✅ | ✅ | ➖ |
| **DualSense** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-5">5</a></sup> | ✅ | ➖ |
| Motion/gyro | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-1">1</a></sup> | ❌ | ➖ |
| Touchpad position | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ➖ |
| Touchpad click | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ➖ |
| Light bar (RGB) | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-6">6</a></sup> | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ➖ |
| Touchpad position | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
| Touchpad click | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
| Light bar (RGB) | ✅ | ✅ | ✅ | ✅<sup><a href="#compatibility-note-6">6</a></sup> | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
| Battery state | ❌ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Adaptive triggers | ❓<sup><a href="#compatibility-note-12">12</a></sup> | ❓<sup><a href="#compatibility-note-12">12</a></sup> | ❓<sup><a href="#compatibility-note-12">12</a></sup> | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ❓ | ➖ |
| Adaptive triggers | ❓<sup><a href="#compatibility-note-11">11</a></sup> | ✅ | ❓<sup><a href="#compatibility-note-11">11</a></sup> | ❌<sup><a href="#compatibility-note-2">2</a></sup> | ❓ | ➖ |
| Player indicator | ❌<sup><a href="#compatibility-note-7">7</a></sup> | ❌<sup><a href="#compatibility-note-7">7</a></sup> | ❌<sup><a href="#compatibility-note-7">7</a></sup> | ❌<sup><a href="#compatibility-note-7">7</a></sup> | ❌ | ➖ |
| MUTE button | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
| MUTE button LED | ❌<sup><a href="#compatibility-note-7">7</a></sup> | ❌<sup><a href="#compatibility-note-7">7</a></sup> | ❌<sup><a href="#compatibility-note-7">7</a></sup> | ❌<sup><a href="#compatibility-note-7">7</a></sup> | ❌ | ➖ |
@@ -148,32 +148,33 @@ columns compare the existing Windows Virtual HID Driver results.
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Digital trigger input (0 or 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
| Motion/gyro | ❌<sup><a href="#compatibility-note-9">9</a></sup> | ❌<sup><a href="#compatibility-note-13">13</a></sup> | ❌<sup><a href="#compatibility-note-9">9</a></sup> | ❌<sup><a href="#compatibility-note-9">9</a></sup> | ❌<sup><a href="#compatibility-note-9">9</a></sup> | ➖ |
| Battery state | ❌ | ❌ | ❌ | ❌ | ❌ | ➖ |
| Player LED | ❌<sup><a href="#compatibility-note-14">14</a></sup> | ❌<sup><a href="#compatibility-note-14">14</a></sup> | ❌<sup><a href="#compatibility-note-14">14</a></sup> | ❌<sup><a href="#compatibility-note-14">14</a></sup> | ❌<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
| Capture button | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-10">10</a></sup> | ✅<sup><a href="#compatibility-note-15">15</a></sup> | ➖ |
| Motion/gyro | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Battery state | ❌ | ✅ | ✅ | ✅ | ❌ | ➖ |
| HOME LED | ❌<sup><a href="#compatibility-note-15">15</a></sup> | ❌<sup><a href="#compatibility-note-15">15</a></sup> | ❌<sup><a href="#compatibility-note-15">15</a></sup> | ❌ | ❌ | ➖ |
| Player LED | ❌<sup><a href="#compatibility-note-13">13</a></sup> | ❌<sup><a href="#compatibility-note-13">13</a></sup> | ❌<sup><a href="#compatibility-note-13">13</a></sup> | ❌<sup><a href="#compatibility-note-13">13</a></sup> | ❌<sup><a href="#compatibility-note-13">13</a></sup> | ➖ |
| Capture button | ✅ | ✅ | ✅ | ❌<sup><a href="#compatibility-note-9">9</a></sup> | ✅<sup><a href="#compatibility-note-14">14</a></sup> | ➖ |
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 |
|-------------------------------------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <a id="compatibility-note-1"></a><sup>1</sup> | 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) |
| <a id="compatibility-note-2"></a><sup>2</sup> | 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) |
| <a id="compatibility-note-3"></a><sup>3</sup> | 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) |
| <a id="compatibility-note-4"></a><sup>4</sup> | Host profile | The Xbox One profile rejects battery updates independently of the client. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
| <a id="compatibility-note-5"></a><sup>5</sup> | 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) |
| <a id="compatibility-note-6"></a><sup>6</sup> | 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) |
| <a id="compatibility-note-7"></a><sup>7</sup> | 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) |
| <a id="compatibility-note-8"></a><sup>8</sup> | 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) |
| <a id="compatibility-note-9"></a><sup>9</sup> | Windows host backend and Android client | On Windows, Steam exposes Switch Pro gyro input but its values remain static. The host behavior is tracked in libvirtualhid; the Android client separately tracks exposing Switch Pro with gyro. | [libvirtualhid issue #111](https://github.com/LizardByte/libvirtualhid/issues/111) and [Moonlight Android issue #1497](https://github.com/moonlight-stream/moonlight-android/issues/1497) |
| <a id="compatibility-note-10"></a><sup>10</sup> | 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) |
| <a id="compatibility-note-11"></a><sup>11</sup> | 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) |
| <a id="compatibility-note-12"></a><sup>12</sup> | 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. The feature has not yet been validated with these backends. | [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) |
| <a id="compatibility-note-13"></a><sup>13</sup> | Linux host backend | Steam does not expose Switch Pro gyro because the current Linux uinput route cannot carry the profile's native motion reports. The libvirtualhid issue supersedes the closed, pre-libvirtualhid Sunshine request. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) |
| <a id="compatibility-note-14"></a><sup>14</sup> | Host output pipeline | Switch Pro Player LED output is not decoded and forwarded by either host backend. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) |
| <a id="compatibility-note-15"></a><sup>15</sup> | 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) |
| Note | Owner | Limitation or status | Tracker or reference |
|-------------------------------------------------|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <a id="compatibility-note-1"></a><sup>1</sup> | 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) |
| <a id="compatibility-note-2"></a><sup>2</sup> | 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) |
| <a id="compatibility-note-3"></a><sup>3</sup> | 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) |
| <a id="compatibility-note-4"></a><sup>4</sup> | Host profile | The Xbox One profile rejects battery updates independently of the client. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
| <a id="compatibility-note-5"></a><sup>5</sup> | 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) |
| <a id="compatibility-note-6"></a><sup>6</sup> | 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) |
| <a id="compatibility-note-7"></a><sup>7</sup> | 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) |
| <a id="compatibility-note-8"></a><sup>8</sup> | 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) |
| <a id="compatibility-note-9"></a><sup>9</sup> | 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) |
| <a id="compatibility-note-10"></a><sup>10</sup> | 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) |
| <a id="compatibility-note-11"></a><sup>11</sup> | 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) |
| <a id="compatibility-note-12"></a><sup>12</sup> | 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) |
| <a id="compatibility-note-13"></a><sup>13</sup> | 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) |
| <a id="compatibility-note-14"></a><sup>14</sup> | 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) |
| <a id="compatibility-note-15"></a><sup>15</sup> | 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
@@ -228,7 +229,7 @@ and verify that the Sunshine virtual-gamepad selection matches a controller the
game supports. Disconnect unused host-side controllers if the game always
opens the first controller slot.
### PlayStation gyro, light bar, or rumble does not work in Steam
### Gyro, LEDs, or rumble do not work in Steam
Steam may need a one-time gyro calibration before its controller tester or
Steam Input fully initializes a virtual DualShock 4 or DualSense controller's
@@ -239,6 +240,12 @@ be a Steam bug rather than a remaining controller-protocol failure. See
[ValveSoftware/steam-for-linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435)
for a related DualSense rumble report.
For Switch Pro, first confirm that rumble is enabled in the game and complete
Steam's controller setup or calibration once. A reported Linux test began
receiving rumble after the game-side rumble option was enabled; that observation
does not establish that every game or Steam configuration uses the same output
path.
If Steam repeatedly treats the virtual controller as a new device, disabling
Sunshine's **Randomize virtual controller MAC** option may help it retain the
controller's calibration and settings. Restart Sunshine and reconnect the
+30 -15
View File
@@ -81,7 +81,15 @@ The VHF driver answers the calibration, pairing, and firmware feature reports
used to initialize DualShock 4 and DualSense HIDAPI output. It also answers the
Switch Pro USB and subcommand initialization sequence and accepts the native
`0x30` input layout, so descriptor-aware consumers can initialize those
controllers before sending their native output reports.
controllers before sending their native output reports. The Switch Pro profile
uses the `0x0210` hardware revision reported by a physical Nintendo controller,
and the Windows VHF device exposes that revision to HID consumers. Full-state
and subcommand-reply reports use the same per-device packet counter on Windows,
matching the counter that a native controller advances for every input report.
The Windows client backend caches the newest complete Switch Pro state and
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.
Windows VHF devices do not expose a Bluetooth transport identity to HIDAPI.
The Windows backend therefore reports DualShock 4 and DualSense requests as
@@ -101,26 +109,23 @@ and signing details.
The Linux backend uses standard user-space kernel interfaces:
- `uhid` for descriptor-driven HID gamepads.
- `uinput` for Generic, Xbox 360, Xbox One, Xbox Series, and Switch Pro
gamepads, plus keyboard, mouse, touchscreen, trackpad, and pen tablet
devices.
- `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.
- `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, Xbox-family, and Switch Pro profiles instead use `uinput` so SDL,
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.
Switch Pro uses the Nintendo face-button
positions, button events for ZL/ZR, and `BTN_Z` for Capture. Profiles with rumble
support normalize rumble, constant, periodic, and ramp uinput force-feedback
effects back into the public callback. Each requested playback repetition
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
@@ -182,10 +187,16 @@ descriptor, and report framing remain unchanged. This transport-only name is
confined to the Linux backend; public profile names, Windows names, and VHF
behavior are unchanged.
Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows
the evdev layout used by Linux-native virtual-controller implementations and
allows standard `FF_RUMBLE` effects without emulating the physical controller's
proprietary initialization handshake.
Switch Pro uses Linux `uhid` with its native Nintendo descriptor and identity.
Its backend-only UHID identity advertises Bluetooth transport because SDL2's
Linux HIDAPI rejects virtual `BUS_USB` HIDRAW devices without a physical USB
parent in sysfs. The public profile remains USB and its report framing is
unchanged. The backend answers Nintendo subcommand initialization reports, and
native `0x30` input reports carry buttons, sticks, battery state, and three live
IMU samples.
The public acceleration and gyroscope units remain meters per second squared
and degrees per second; the packer converts them to Nintendo's coordinate
system and sensor scales.
Linux touchscreen and trackpad contacts use the lowest available multitouch
slot while they are active. A newly placed contact receives a new tracking ID,
@@ -194,7 +205,11 @@ finger cannot overwrite another active finger in standard evdev consumers.
On descriptor-driven backends, native Switch Pro output reports `0x01` and
`0x10` are decoded into the normalized low- and high-frequency rumble callback.
The original native report remains available in `GamepadOutput::raw_report`.
Set Player Lights subcommand `0x30` additionally produces a `player_leds`
callback with separate solid and flashing states for the four indicators. The
Set HOME Light subcommand `0x38` produces a grayscale `rgb_led` callback whose
equal channels preserve the requested monochrome intensity. The original native
report remains available in `GamepadOutput::raw_report`.
The optional `virtualhid_control` diagnostic UI uses SDL3 and Dear ImGui through
the repository CPM lockfile. It is intended to stay on the same UI framework for
+7 -4
View File
@@ -13,8 +13,9 @@ A streaming host should be able to:
indexes.
- Submit incremental button, axis, trigger, touchpad, motion, and battery
updates without recreating a device.
- Receive output callbacks for rumble, LEDs, adaptive triggers, trigger rumble,
and raw output reports where the selected profile supports them.
- Receive output callbacks for rumble, RGB and player LEDs, adaptive triggers,
trigger rumble, and raw output reports where the selected profile supports
them.
- Query profile and backend capabilities before warning users about unsupported
client features.
- Read device nodes and platform paths when a downstream consumer or diagnostic
@@ -51,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 gamepads through `uhid`, Generic/Xbox/Switch Pro gamepads
through `uinput`, and `uinput` keyboard/pointer devices.
- Linux PlayStation and Switch Pro gamepads through `uhid`, Generic/Xbox
gamepads through `uinput`, 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.
- Linux touchscreen, trackpad, and pen tablet device types.
- FreeBSD uinput gamepads and pointer devices, with basic PlayStation input and
+7 -6
View File
@@ -105,8 +105,8 @@ default when a static target is available.
The UI can create and remove gamepads from the built-in profiles, submit
buttons, sticks, triggers, and battery state, show backend and profile
capabilities, list device nodes reported for UI-created devices, and display
normalized gamepad output such as rumble, RGB LED, adaptive trigger, trigger
rumble, and raw report events delivered through the normal callback path. Button
normalized gamepad output such as rumble, RGB LED, player LED, adaptive trigger,
trigger rumble, and raw report events delivered through the normal callback path. Button
controls are momentary by default, so they behave like physical gamepad buttons;
on Windows, the UI also displays broker license status and can activate,
refresh, or deactivate a machine license without elevation. Windows UMDF
@@ -229,10 +229,11 @@ through 16 in the input report. Linux may still route that profile through
`uinput`, where the backend exposes those same logical directions through the
standard `ABS_HAT0X` and `ABS_HAT0Y` axes.
Profiles advertise support for features such as rumble, trigger rumble, RGB
LEDs, adaptive triggers, motion sensors, touchpads, battery state,
profile-specific buttons, and raw output reports. Consumers should query profile and
backend capabilities before warning users about unsupported client features.
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.
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.
+8 -1
View File
@@ -427,7 +427,14 @@ gamepad.
DualShock 4 and DualSense answer the calibration, pairing, and firmware feature
requests used by their Windows HIDAPI initialization paths. Switch Pro answers
the native USB and subcommand handshake and submits native `0x30` input reports.
the native USB and subcommand handshake and submits native `0x30` input reports
with three live IMU samples. The client backend caches the newest complete
Switch state and submits it every 15 milliseconds, matching a physical USB
controller's report cadence while coalescing separate acceleration and
gyroscope updates. Its Set Player Lights subcommand is normalized into solid
and flashing player-indicator output states for the creating runtime, and its
monochrome HOME light is normalized as equal RGB channels so existing streaming
LED feedback paths can preserve its intensity.
The built-in Generic profile is presented to Windows as a DirectInput PID
Joystick with the complete output-report set required for DirectInput
enumeration. Constant Force and Sine output is normalized to the portable
+3
View File
@@ -83,6 +83,7 @@ namespace lvh {
support.supports_rumble = profile.capabilities.supports_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;
support.supports_motion = profile.capabilities.supports_motion;
support.supports_touchpad = profile.capabilities.supports_touchpad;
support.supports_battery = profile.capabilities.supports_battery;
@@ -142,6 +143,8 @@ namespace lvh {
return support.supports_rgb_led;
case adaptive_triggers:
return support.supports_adaptive_triggers;
case player_leds:
return support.supports_player_leds;
case raw_report:
return profile.output_report_size > 0U;
}
+8 -2
View File
@@ -2025,13 +2025,19 @@ namespace lvh::profiles {
profile.bus_type = BusType::usb;
profile.vendor_id = 0x057E;
profile.product_id = 0x2009;
profile.version = 0x8111;
profile.version = 0x0210;
profile.report_id = switch_pro_report_id;
profile.input_report_size = switch_pro_input_report_size;
profile.output_report_size = switch_pro_output_report_size;
profile.name = "(libvirtualhid) Nintendo Pro Controller";
profile.manufacturer = "Nintendo Co., Ltd.";
profile.capabilities = {.supports_rumble = true, .supports_motion = true, .supports_battery = true};
profile.capabilities = {
.supports_rumble = true,
.supports_motion = true,
.supports_rgb_led = true,
.supports_battery = true,
.supports_player_leds = true,
};
profile.report_descriptor = make_switch_pro_report_descriptor();
return profile;
}
+91 -7
View File
@@ -18,6 +18,8 @@
#include <utility>
// local includes
#include "shared/switch_pro_protocol.hpp"
#include <libvirtualhid/report.hpp>
namespace lvh::reports {
@@ -75,6 +77,30 @@ namespace lvh::reports {
constexpr std::size_t switch_rumble_output_report_size = 10;
constexpr std::uint8_t switch_set_player_lights_subcommand = 0x30;
constexpr std::uint8_t switch_set_home_light_subcommand = 0x38;
constexpr float switch_acceleration_scale = 4096.0F / 9.80665F;
constexpr float switch_gyroscope_scale = 14.2842F;
std::uint8_t decode_switch_home_light_intensity(std::byte encoded_intensity) {
const auto intensity = std::to_integer<std::uint8_t>(encoded_intensity >> 4U);
if (intensity == 0U) {
return 0U;
}
if (intensity <= 6U) {
return static_cast<std::uint8_t>(std::lround(static_cast<float>(intensity) * 25.5F));
}
if (intensity == 15U) {
return 255U;
}
const auto normalized = (static_cast<float>(intensity) - 0.5F) / 15.0F;
return static_cast<std::uint8_t>(std::lround(std::pow(normalized, 1.0F / 2.13F) * 255.0F));
}
// SDL maps 16-bit rumble strengths to Nintendo's shared 101-step amplitude
// scale. This is the inverse table for the packed high- and low-band values:
// https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch.c
@@ -950,6 +976,47 @@ namespace lvh::reports {
}
}
void append_switch_pro_outputs(
const std::vector<std::uint8_t> &report,
std::vector<GamepadOutput> &outputs
) {
if (const auto rumble = decode_switch_rumble_report(report); rumble.has_value()) {
GamepadOutput output;
output.kind = GamepadOutputKind::rumble;
output.low_frequency_rumble = rumble->low_frequency;
output.high_frequency_rumble = rumble->high_frequency;
output.raw_report = report;
outputs.push_back(std::move(output));
}
if (
report.size() >= 12U && report[0] == switch_rumble_and_subcommand_output_report_id &&
report[10] == switch_set_player_lights_subcommand
) {
GamepadOutput output;
output.kind = GamepadOutputKind::player_leds;
const auto player_lights = std::byte {report[11]};
for (std::size_t index = 0; index < output.player_leds.size(); ++index) {
output.player_leds[index] = (player_lights & (std::byte {1} << index)) != zero_byte;
output.flashing_player_leds[index] =
(player_lights & (std::byte {1} << (index + 4U))) != zero_byte;
}
output.raw_report = report;
outputs.push_back(std::move(output));
}
if (
report.size() >= 15U && report[0] == switch_rumble_and_subcommand_output_report_id &&
report[10] == switch_set_home_light_subcommand
) {
GamepadOutput output;
output.kind = GamepadOutputKind::rgb_led;
output.red = decode_switch_home_light_intensity(std::byte {report[12]});
output.green = output.red;
output.blue = output.red;
output.raw_report = report;
outputs.push_back(std::move(output));
}
}
} // namespace
float clamp_axis(float value) {
@@ -1061,6 +1128,22 @@ namespace lvh::reports {
report[offset + 2U] = to_byte((y >> 4U) & 0xFFU);
}
void write_switch_imu_sample(
ByteReport &report,
std::size_t offset,
const Vector3 &acceleration,
const Vector3 &gyroscope
) {
// Nintendo's native coordinate system differs from the portable
// PlayStation-style coordinate system exposed by GamepadState.
write_i16(report, offset, scale_i16(-acceleration.z, switch_acceleration_scale));
write_i16(report, offset + 2U, scale_i16(-acceleration.x, switch_acceleration_scale));
write_i16(report, offset + 4U, scale_i16(acceleration.y, switch_acceleration_scale));
write_i16(report, offset + 6U, scale_i16(-gyroscope.z, switch_gyroscope_scale));
write_i16(report, offset + 8U, scale_i16(-gyroscope.x, switch_gyroscope_scale));
write_i16(report, offset + 10U, scale_i16(gyroscope.y, switch_gyroscope_scale));
}
std::byte switch_battery_and_connection(const std::optional<GamepadBattery> &battery) {
constexpr auto usb_connection = std::byte {0x01};
if (!battery.has_value()) {
@@ -1151,6 +1234,7 @@ namespace lvh::reports {
ByteReport report(profile.input_report_size, zero_byte);
report[0] = to_byte(profile.report_id);
report[1] = to_byte(detail::switch_pro_protocol::next_switch_pro_packet_timer());
report[2] = switch_battery_and_connection(normalized.battery);
if (normalized.buttons.test(x)) {
@@ -1222,6 +1306,11 @@ namespace lvh::reports {
normalize_switch_stick_axis(normalized.right_stick.x),
normalize_switch_stick_axis(normalized.right_stick.y)
);
const auto acceleration = normalized.acceleration.value_or(Vector3 {.y = 9.80665F});
const auto gyroscope = normalized.gyroscope.value_or(Vector3 {});
for (const auto offset : {13U, 25U, 37U}) {
write_switch_imu_sample(report, offset, acceleration, gyroscope);
}
return to_uint8_report(report);
}
@@ -1302,13 +1391,8 @@ namespace lvh::reports {
}
if (profile.gamepad_kind == GamepadProfileKind::switch_pro) {
if (const auto rumble = decode_switch_rumble_report(report); rumble.has_value()) {
GamepadOutput output;
output.kind = GamepadOutputKind::rumble;
output.low_frequency_rumble = rumble->low_frequency;
output.high_frequency_rumble = rumble->high_frequency;
output.raw_report = report;
outputs.push_back(std::move(output));
append_switch_pro_outputs(report, outputs);
if (!outputs.empty()) {
return outputs;
}
}
@@ -69,6 +69,11 @@ namespace lvh {
* @brief Number of rear paddle buttons exposed by the profile.
*/
std::uint8_t supported_rear_paddle_count = 0;
/**
* @brief Whether the profile supports player indicator LED output.
*/
bool supports_player_leds = false;
};
/**
+16
View File
@@ -270,6 +270,11 @@ namespace lvh {
* @brief Whether the profile supports adaptive trigger output.
*/
bool supports_adaptive_triggers = false;
/**
* @brief Whether the profile supports player indicator LED output.
*/
bool supports_player_leds = false;
};
/**
@@ -998,6 +1003,7 @@ namespace lvh {
adaptive_triggers, ///< Adaptive trigger output.
raw_report, ///< Raw output report bytes.
trigger_rumble, ///< Independent trigger rumble output.
player_leds, ///< Player indicator LED output.
};
/**
@@ -1073,6 +1079,16 @@ namespace lvh {
* @brief Raw output report payload.
*/
std::vector<std::uint8_t> raw_report;
/**
* @brief Solid player indicator LED states, ordered from player one through four.
*/
std::array<bool, 4> player_leds {};
/**
* @brief Flashing player indicator LED states, ordered from player one through four.
*/
std::array<bool, 4> flashing_player_leds {};
};
/**
+18 -2
View File
@@ -65,6 +65,7 @@
#include "core/backend.hpp"
#if defined(__linux__)
#include "shared/playstation_feature_reports.hpp"
#include "shared/switch_pro_protocol.hpp"
#endif
#include <libvirtualhid/profiles.hpp>
@@ -248,8 +249,8 @@ namespace lvh::detail {
case xbox_360:
case xbox_one:
case xbox_series:
case switch_pro:
return true;
case switch_pro:
case dualshock4:
case dualsense:
#if defined(__FreeBSD__)
@@ -301,8 +302,11 @@ 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) {
return BUS_VIRTUAL;
return BUS_BLUETOOTH;
}
return to_uhid_bus(profile.bus_type);
}
@@ -3057,9 +3061,20 @@ namespace lvh::detail {
void dispatch_output_report(const __u8 *data, std::size_t report_size) {
const auto size = std::min<std::size_t>(report_size, UHID_DATA_MAX);
std::vector<std::uint8_t> report(data, data + size);
send_switch_pro_reply(report);
dispatch_output_report(report);
}
void send_switch_pro_reply(const std::vector<std::uint8_t> &report) {
if (profile_.gamepad_kind != GamepadProfileKind::switch_pro) {
return;
}
if (const auto reply = switch_pro_protocol::make_switch_pro_reply(report); reply.has_value()) {
static_cast<void>(write_input_report({reply->begin(), reply->end()}));
}
}
void dispatch_set_report(std::uint8_t report_number, const __u8 *data, std::size_t report_size) {
const auto size = std::min<std::size_t>(report_size, UHID_DATA_MAX);
std::vector<std::uint8_t> report(data, data + size);
@@ -3199,6 +3214,7 @@ namespace lvh::detail {
effective_profile.capabilities.supports_rgb_led = false;
effective_profile.capabilities.supports_battery = false;
effective_profile.capabilities.supports_adaptive_triggers = false;
effective_profile.capabilities.supports_player_leds = false;
return effective_profile;
#else
static_cast<void>(requested_profile);
@@ -57,7 +57,7 @@
#include "mouse_protocol.hpp"
#include "playstation_feature_protocol.hpp"
#include "rotating_trace_log.hpp"
#include "switch_pro_protocol.hpp"
#include "shared/switch_pro_protocol.hpp"
#include "unique_win32_handle.hpp"
#include "vhf_input_report_queue.hpp"
#include "windows_device_identity.hpp"
@@ -122,6 +122,7 @@ namespace {
lvh::detail::windows::VhfInputReportQueue pending_input_reports;
std::shared_ptr<std::vector<std::uint8_t>> in_flight_input_report;
std::size_t active_input_submissions {};
std::uint8_t switch_pro_packet_timer {};
bool vhf_ready_for_input_report {};
bool shutting_down {};
};
@@ -254,6 +255,12 @@ namespace {
}
auto report = std::make_shared<std::vector<std::uint8_t>>(std::move(*pending));
if (
record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_SWITCH_PRO && report->size() > 1U &&
(report->at(0) == 0x21U || report->at(0) == 0x30U)
) {
report->at(1) = record.switch_pro_packet_timer++;
}
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();
@@ -871,7 +878,7 @@ namespace {
return;
}
auto reply = lvh::detail::windows::make_switch_pro_reply({event.report.data(), event.report_size});
auto reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply({event.report.data(), event.report_size});
if (!reply.has_value()) {
return;
}
+77 -3
View File
@@ -88,6 +88,8 @@ namespace lvh::detail {
using SendInputFunction = std::function<UINT(std::span<INPUT>)>;
using SyncThreadDesktopFunction = std::function<HDESK()>;
constexpr auto switch_pro_report_interval = std::chrono::milliseconds {15};
/**
* @brief Thread-local desktop identity used for SendInput retry decisions.
*/
@@ -956,13 +958,13 @@ namespace lvh::detail {
OutputCallback output_callback;
bool uses_generic_pid = false;
windows::GenericPidRumbleState generic_pid_rumble;
std::vector<std::uint8_t> switch_pro_input_report;
};
class WindowsGamepad final: public BackendGamepad {
public:
WindowsGamepad(std::shared_ptr<WindowsBackendContext> context, std::shared_ptr<WindowsVhfDeviceState> state):
context_ {std::move(context)},
state_ {std::move(state)} {}
WindowsGamepad(std::shared_ptr<WindowsBackendContext> context, std::shared_ptr<WindowsVhfDeviceState> state);
~WindowsGamepad() override;
OperationStatus submit(
const GamepadState &state,
@@ -973,8 +975,14 @@ namespace lvh::detail {
OperationStatus close() override;
private:
void stream_switch_pro_reports(std::stop_token stop_token);
void stop_switch_pro_report_stream();
std::shared_ptr<WindowsBackendContext> context_;
std::shared_ptr<WindowsVhfDeviceState> state_;
std::condition_variable switch_pro_report_ready_;
std::mutex switch_pro_report_mutex_;
std::jthread switch_pro_report_thread_;
};
class WindowsBackendContext: public std::enable_shared_from_this<WindowsBackendContext> {
@@ -1294,6 +1302,66 @@ namespace lvh::detail {
std::map<std::uint64_t, std::weak_ptr<WindowsVhfDeviceState>> devices_;
};
WindowsGamepad::WindowsGamepad(
std::shared_ptr<WindowsBackendContext> context,
std::shared_ptr<WindowsVhfDeviceState> state
):
context_ {std::move(context)},
state_ {std::move(state)} {
if (state_->profile.gamepad_kind == GamepadProfileKind::switch_pro) {
switch_pro_report_thread_ = std::jthread {[this](std::stop_token stop_token) {
stream_switch_pro_reports(stop_token);
}};
}
}
WindowsGamepad::~WindowsGamepad() {
stop_switch_pro_report_stream();
}
void WindowsGamepad::stream_switch_pro_reports(std::stop_token stop_token) {
auto next_report = std::chrono::steady_clock::now() + switch_pro_report_interval;
std::unique_lock report_lock {switch_pro_report_mutex_};
while (!stop_token.stop_requested()) {
static_cast<void>(switch_pro_report_ready_.wait_until(report_lock, next_report, [&stop_token] {
return stop_token.stop_requested();
}));
if (stop_token.stop_requested()) {
break;
}
report_lock.unlock();
std::vector<std::uint8_t> report;
{
std::lock_guard state_lock {state_->mutex_};
if (!state_->open) {
return;
}
report = state_->switch_pro_input_report;
}
if (!report.empty()) {
static_cast<void>(context_->submit_device_report(state_, report));
}
report_lock.lock();
next_report += switch_pro_report_interval;
const auto now = std::chrono::steady_clock::now();
if (next_report <= now) {
next_report = now + switch_pro_report_interval;
}
}
}
void WindowsGamepad::stop_switch_pro_report_stream() {
if (!switch_pro_report_thread_.joinable()) {
return;
}
switch_pro_report_thread_.request_stop();
switch_pro_report_ready_.notify_all();
switch_pro_report_thread_.join();
}
OperationStatus WindowsGamepad::submit(
const GamepadState & /*state*/,
const std::vector<std::uint8_t> &report
@@ -1310,6 +1378,11 @@ namespace lvh::detail {
return OperationStatus::failure(invalid_argument, "Windows gamepad input report exceeds protocol limit");
}
if (state_->profile.gamepad_kind == GamepadProfileKind::switch_pro) {
state_->switch_pro_input_report = report;
return OperationStatus::success();
}
if (state_->uses_generic_pid) {
return context_->submit_device_report(state_, windows::make_generic_windows_input_report(report));
}
@@ -1333,6 +1406,7 @@ namespace lvh::detail {
}
OperationStatus WindowsGamepad::close() {
stop_switch_pro_report_stream();
return context_->close_device(state_);
}
@@ -1,22 +1,28 @@
/**
* @file src/platform/windows/shared/switch_pro_protocol.hpp
* @brief Nintendo Switch Pro initialization replies used by the Windows VHF driver.
* @file src/shared/switch_pro_protocol.hpp
* @brief Nintendo Switch Pro initialization replies shared by native HID backends.
*/
#pragma once
// standard includes
#include <algorithm>
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
namespace lvh::detail::windows {
namespace lvh::detail::switch_pro_protocol {
inline constexpr std::size_t switch_pro_report_size = 64U;
using SwitchProReport = std::array<std::uint8_t, switch_pro_report_size>;
inline std::uint8_t next_switch_pro_packet_timer() {
static std::atomic_uint32_t packet_timer {0};
return static_cast<std::uint8_t>(packet_timer.fetch_add(1U, std::memory_order_relaxed));
}
namespace switch_pro_protocol_detail {
inline constexpr std::array<std::uint8_t, 6> controller_mac {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
@@ -149,7 +155,10 @@ namespace lvh::detail::windows {
} // namespace switch_pro_protocol_detail
inline std::optional<SwitchProReport> make_switch_pro_reply(std::span<const std::uint8_t> output_report) {
inline std::optional<SwitchProReport> make_switch_pro_reply(
std::span<const std::uint8_t> output_report,
std::optional<std::uint8_t> packet_timer = std::nullopt
) {
if (output_report.empty()) {
return std::nullopt;
}
@@ -184,7 +193,10 @@ namespace lvh::detail::windows {
SwitchProReport reply {};
reply[0] = 0x21;
switch_pro_protocol_detail::set_neutral_controller_state(reply, output_report[1]);
switch_pro_protocol_detail::set_neutral_controller_state(
reply,
packet_timer.has_value() ? *packet_timer : next_switch_pro_packet_timer()
);
auto acknowledgement = std::uint8_t {0x80};
if (subcommand == 0x10U) {
acknowledgement = 0x90;
@@ -201,4 +213,4 @@ namespace lvh::detail::windows {
return reply;
}
} // namespace lvh::detail::windows
} // namespace lvh::detail::switch_pro_protocol
+108 -46
View File
@@ -137,6 +137,101 @@ namespace lvh::detail::test {
GamepadOutput last;
};
/**
* @brief DualSense observations from a socketpair-backed UHID test.
*/
struct LinuxUhidDualSenseObservation {
/**
* @brief Whether the peer observed a calibration reply.
*/
bool saw_calibration = false;
/**
* @brief Whether the peer observed a pairing reply.
*/
bool saw_pairing = false;
/**
* @brief Whether the peer observed a firmware reply.
*/
bool saw_firmware = false;
/**
* @brief Whether the peer observed a signed Bluetooth feature reply.
*/
bool saw_feature_crc = false;
/**
* @brief Whether periodic Bluetooth reports preserved motion and advanced sensor metadata.
*/
bool saw_bluetooth_input_with_live_sensor_metadata = false;
};
/**
* @brief DualShock 4 observations from a socketpair-backed UHID test.
*/
struct LinuxUhidDualShock4Observation {
/**
* @brief Whether the peer observed a calibration reply.
*/
bool saw_calibration = false;
/**
* @brief Whether the peer observed a pairing reply.
*/
bool saw_pairing = false;
/**
* @brief Whether the peer observed a firmware reply.
*/
bool saw_firmware = false;
/**
* @brief Whether the peer observed a signed Bluetooth feature reply.
*/
bool saw_feature_crc = false;
/**
* @brief Whether the peer observed a Bluetooth-framed input report.
*/
bool saw_bluetooth_input = false;
/**
* @brief Whether the peer observed a USB-framed input report.
*/
bool saw_usb_input = false;
};
/**
* @brief Switch Pro observations from a socketpair-backed UHID test.
*/
struct LinuxUhidSwitchProObservation {
/**
* @brief Whether the peer observed a subcommand acknowledgement input report.
*/
bool saw_subcommand_reply = false;
/**
* @brief Packet timer carried by the observed subcommand reply.
*/
std::optional<std::uint8_t> subcommand_reply_packet_timer;
/**
* @brief Whether the peer observed live IMU samples in a submitted input report.
*/
bool saw_motion_input = false;
/**
* @brief Packet timer carried by the observed motion input report.
*/
std::optional<std::uint8_t> motion_input_packet_timer;
/**
* @brief Whether the output callback exposed player indicator LEDs.
*/
bool saw_player_leds = false;
};
/**
* @brief Result from a socketpair-backed UHID lifecycle test.
*/
@@ -172,59 +267,19 @@ namespace lvh::detail::test {
bool saw_get_report_reply = false;
/**
* @brief Whether the peer observed a DualSense calibration reply.
* @brief DualSense protocol observations.
*/
bool saw_dualsense_calibration = false;
LinuxUhidDualSenseObservation dualsense;
/**
* @brief Whether the peer observed a DualShock 4 calibration reply.
* @brief DualShock 4 protocol observations.
*/
bool saw_dualshock4_calibration = false;
LinuxUhidDualShock4Observation dualshock4;
/**
* @brief Whether the peer observed a DualSense pairing reply.
* @brief Switch Pro protocol observations.
*/
bool saw_dualsense_pairing = false;
/**
* @brief Whether the peer observed a DualShock 4 pairing reply.
*/
bool saw_dualshock4_pairing = false;
/**
* @brief Whether the peer observed a DualSense firmware reply.
*/
bool saw_dualsense_firmware = false;
/**
* @brief Whether the peer observed a DualShock 4 firmware reply.
*/
bool saw_dualshock4_firmware = false;
/**
* @brief Whether the peer observed a signed Bluetooth DualSense feature reply.
*/
bool saw_dualsense_feature_crc = false;
/**
* @brief Whether the peer observed a signed Bluetooth DualShock 4 feature reply.
*/
bool saw_dualshock4_feature_crc = false;
/**
* @brief Whether periodic Bluetooth DualSense reports preserved motion and advanced sensor metadata.
*/
bool saw_dualsense_bluetooth_input_with_live_sensor_metadata = false;
/**
* @brief Whether the peer observed a Bluetooth-framed DualShock 4 input report.
*/
bool saw_dualshock4_bluetooth_input = false;
/**
* @brief Whether the peer observed a USB-framed DualShock 4 input report.
*/
bool saw_dualshock4_usb_input = false;
LinuxUhidSwitchProObservation switch_pro;
/**
* @brief Whether the peer observed a set-report reply.
@@ -824,6 +879,13 @@ namespace lvh::detail::test {
*/
LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip();
/**
* @brief Exercise Switch Pro UHID input, output, and subcommand replies over a socketpair.
*
* @return Round-trip result with motion and player-light observations.
*/
LinuxUhidRoundTripResult linux_switch_pro_uhid_socketpair_reports();
/**
* @brief Exercise DualSense UHID feature-report replies over a socketpair.
*
@@ -30,6 +30,15 @@ namespace lvh::detail::test {
std::size_t destroy_requests = 0;
};
struct WindowsSwitchReportStreamResult {
OperationStatus create_status;
OperationStatus submit_status;
OperationStatus close_status;
bool repeated_report = false;
std::vector<std::uint8_t> expected_report;
std::vector<std::vector<std::uint8_t>> submitted_reports;
};
struct WindowsBackendFailureResult {
OperationStatus invalid_argument_status;
OperationStatus unsupported_profile_status;
@@ -239,6 +248,7 @@ namespace lvh::detail::test {
};
WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle();
WindowsSwitchReportStreamResult windows_backend_switch_report_stream();
WindowsPlayStationTransportResult windows_backend_playstation_transport();
WindowsGenericPidOrderingResult windows_backend_generic_pid_callback_ordering();
WindowsHidKeyboardResult windows_backend_hid_keyboard();
+87 -14
View File
@@ -1586,6 +1586,79 @@ namespace lvh::detail::test {
return result;
}
LinuxUhidRoundTripResult linux_switch_pro_uhid_socketpair_reports() {
LinuxUhidRoundTripResult result;
std::array<int, 2> 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;
}
CreateGamepadOptions options;
options.profile = profiles::switch_pro();
options.metadata.stable_id = "libvirtualhid-switch-pro-roundtrip";
UhidGamepad gamepad {descriptors[0]};
auto event = create_started_profile_uhid_gamepad(gamepad, 12, options, descriptors[1], BUS_BLUETOOTH, result);
result.creation.saw_create = result.creation.saw_create &&
event.u.create2.rd_size == options.profile.report_descriptor.size();
gamepad.set_output_callback([&result](const GamepadOutput &output) {
++result.output.callback_count;
result.output.last = output;
if (
output.kind == GamepadOutputKind::player_leds &&
output.player_leds == std::array {true, false, true, false} &&
output.flashing_player_leds == std::array {false, true, false, true}
) {
result.switch_pro.saw_player_leds = true;
}
});
event = {};
event.type = UHID_OUTPUT;
event.u.output.rtype = UHID_OUTPUT_REPORT;
event.u.output.size = static_cast<__u16>(options.profile.output_report_size);
event.u.output.data[0] = 0x01;
event.u.output.data[1] = 0x07;
const std::array<std::uint8_t, 8> neutral_rumble {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40};
std::ranges::copy(neutral_rumble, event.u.output.data + 2U);
event.u.output.data[10] = 0x30;
event.u.output.data[11] = 0xA5;
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
if (event.u.input2.size > 1U) {
result.switch_pro.subcommand_reply_packet_timer = static_cast<std::uint8_t>(event.u.input2.data[1]);
}
result.switch_pro.saw_subcommand_reply = event.u.input2.size == options.profile.input_report_size &&
event.u.input2.data[0] == 0x21 &&
event.u.input2.data[13] == 0x80 &&
event.u.input2.data[14] == 0x30;
}
GamepadState state;
state.buttons.set(GamepadButton::a);
state.acceleration = Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
state.gyroscope = Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F};
const auto report = reports::pack_input_report(options.profile, state);
result.submit_status = gamepad.submit(state, report);
if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
if (event.u.input2.size > 1U) {
result.switch_pro.motion_input_packet_timer = static_cast<std::uint8_t>(event.u.input2.data[1]);
}
result.switch_pro.saw_motion_input = event.u.input2.size == report.size() &&
event.u.input2.data[0] == 0x30 &&
(event.u.input2.data[3] & 0x08U) != 0U &&
std::equal(report.begin() + 13, report.begin() + 49, event.u.input2.data + 13U);
}
result.close_status = gamepad.close();
static_cast<void>(::close(descriptors[1]));
return result;
}
LinuxUhidRoundTripResult linux_dualsense_uhid_socketpair_reports() {
LinuxUhidRoundTripResult result;
std::array<int, 2> descriptors {-1, -1};
@@ -1641,7 +1714,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x05;
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
result.saw_dualsense_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 41U &&
result.dualsense.saw_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 41U &&
event.u.get_report_reply.data[0] == 0x05;
}
@@ -1651,7 +1724,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x09;
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
result.saw_dualsense_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 7 &&
result.dualsense.saw_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 7 &&
event.u.get_report_reply.data[0] == 0x09 &&
event.u.get_report_reply.data[1] == 0x07 &&
event.u.get_report_reply.data[6] == 0x02;
@@ -1663,7 +1736,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x20;
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
result.saw_dualsense_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 0 &&
result.dualsense.saw_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 0 &&
event.u.get_report_reply.data[0] == 0x20;
}
@@ -1731,7 +1804,7 @@ namespace lvh::detail::test {
const auto crc_offset = report_size - 4U;
const auto expected_crc = crc32(std::span<const std::uint8_t> {event.u.input2.data, crc_offset}, playstation_crc_seed(0xA1));
const auto actual_crc = read_u32_le(event.u.input2.data + crc_offset);
result.saw_dualsense_bluetooth_input_with_live_sensor_metadata =
result.dualsense.saw_bluetooth_input_with_live_sensor_metadata =
first_input_report_valid && expected_crc == actual_crc && event.u.input2.data[8] != first_sequence &&
read_u32_le(event.u.input2.data + 29U) != first_sensor_timestamp && std::equal(first_sensor_values.begin(), first_sensor_values.end(), event.u.input2.data + 17U);
}
@@ -1744,7 +1817,7 @@ namespace lvh::detail::test {
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
const auto report_size = static_cast<std::size_t>(event.u.get_report_reply.size);
result.saw_dualsense_pairing = event.u.get_report_reply.err == 0 && report_size > 7U &&
result.dualsense.saw_pairing = event.u.get_report_reply.err == 0 && report_size > 7U &&
event.u.get_report_reply.data[0] == 0x09 &&
event.u.get_report_reply.data[1] == 0x07 &&
event.u.get_report_reply.data[6] == 0x02;
@@ -1755,7 +1828,7 @@ namespace lvh::detail::test {
playstation_crc_seed(playstation_feature_reports::playstation_feature_crc_seed)
);
const auto actual_crc = read_u32_le(event.u.get_report_reply.data + crc_offset);
result.saw_dualsense_feature_crc = expected_crc == actual_crc;
result.dualsense.saw_feature_crc = expected_crc == actual_crc;
}
}
@@ -1785,7 +1858,7 @@ namespace lvh::detail::test {
event.u.create2.rd_size == options.profile.report_descriptor.size();
if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
result.saw_dualshock4_usb_input =
result.dualshock4.saw_usb_input =
event.u.input2.size == options.profile.input_report_size && event.u.input2.data[0] == options.profile.report_id;
}
@@ -1816,7 +1889,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x02;
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
result.saw_dualshock4_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 37 &&
result.dualshock4.saw_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 37 &&
event.u.get_report_reply.data[0] == 0x02;
}
@@ -1826,7 +1899,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x12;
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
result.saw_dualshock4_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 16 &&
result.dualshock4.saw_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 16 &&
event.u.get_report_reply.data[0] == 0x12 &&
event.u.get_report_reply.data[1] == 0x07 &&
event.u.get_report_reply.data[6] == 0x02;
@@ -1838,7 +1911,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0xA3;
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
result.saw_dualshock4_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 49 &&
result.dualshock4.saw_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 49 &&
event.u.get_report_reply.data[0] == 0xA3;
}
@@ -1876,7 +1949,7 @@ namespace lvh::detail::test {
const auto crc_offset = report_size - 4U;
const auto expected_crc = crc32(std::span<const std::uint8_t> {event.u.input2.data, crc_offset}, playstation_crc_seed(0xA1));
const auto actual_crc = read_u32_le(event.u.input2.data + crc_offset);
result.saw_dualshock4_bluetooth_input = expected_crc == actual_crc;
result.dualshock4.saw_bluetooth_input = expected_crc == actual_crc;
}
}
@@ -1887,7 +1960,7 @@ namespace lvh::detail::test {
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
const auto report_size = static_cast<std::size_t>(event.u.get_report_reply.size);
result.saw_dualshock4_calibration = event.u.get_report_reply.err == 0 && report_size == 41U &&
result.dualshock4.saw_calibration = event.u.get_report_reply.err == 0 && report_size == 41U &&
event.u.get_report_reply.data[0] == 0x05;
if (report_size >= 4U) {
const auto crc_offset = report_size - 4U;
@@ -1896,7 +1969,7 @@ namespace lvh::detail::test {
playstation_crc_seed(playstation_feature_reports::playstation_feature_crc_seed)
);
const auto actual_crc = read_u32_le(event.u.get_report_reply.data + crc_offset);
result.saw_dualshock4_feature_crc = expected_crc == actual_crc;
result.dualshock4.saw_feature_crc = expected_crc == actual_crc;
}
}
@@ -1906,7 +1979,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x12;
static_cast<void>(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
result.saw_dualshock4_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 16 &&
result.dualshock4.saw_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 16 &&
event.u.get_report_reply.data[0] == 0x12 &&
event.u.get_report_reply.data[1] == 0x07 &&
event.u.get_report_reply.data[6] == 0x02;
+23
View File
@@ -528,6 +528,29 @@ namespace lvh::detail {
return result;
}
WindowsSwitchReportStreamResult windows_backend_switch_report_stream() {
WindowsSwitchReportStreamResult result;
auto command_state = std::make_shared<FakeWindowsControlChannelState>();
auto backend = make_fake_windows_backend(command_state, std::make_shared<FakeWindowsControlChannelState>());
CreateGamepadOptions options;
options.profile = profiles::switch_pro();
auto created = backend->create_gamepad(8, options);
result.create_status = created.status;
if (created) {
result.expected_report.assign(options.profile.input_report_size, 0x5AU);
result.expected_report[0] = 0x30U;
result.submit_status = created.gamepad->submit({}, result.expected_report);
result.repeated_report = wait_until([&command_state] {
return command_state->submit_report_count() >= 2U;
});
result.close_status = created.gamepad->close();
result.submitted_reports = command_state->submit_reports();
}
return result;
}
WindowsHidMouseResult windows_backend_hid_mouse() {
using enum MouseEventKind;
+2
View File
@@ -79,7 +79,9 @@ TEST(FreeBsdBackendTest, CreatesEveryGamepadWithTheExpectedPlayStationSubset) {
EXPECT_FALSE(support.supports_battery);
EXPECT_FALSE(support.supports_rgb_led);
EXPECT_FALSE(support.supports_adaptive_triggers);
EXPECT_FALSE(support.supports_player_leds);
EXPECT_EQ(effective_profile.output_report_size, 0U);
EXPECT_FALSE(lvh::supports_gamepad_output(effective_profile, lvh::GamepadOutputKind::player_leds));
EXPECT_FALSE(lvh::supports_gamepad_output(effective_profile, lvh::GamepadOutputKind::raw_report));
if (
+5
View File
@@ -52,8 +52,10 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) {
const auto switch_pro_support = lvh::gamepad_profile_support(switch_pro);
EXPECT_TRUE(switch_pro_support.supports_rumble);
EXPECT_TRUE(switch_pro_support.supports_rgb_led);
EXPECT_TRUE(switch_pro_support.supports_motion);
EXPECT_TRUE(switch_pro_support.supports_battery);
EXPECT_TRUE(switch_pro_support.supports_player_leds);
EXPECT_TRUE(switch_pro_support.supports_misc1_button);
const auto keyboard_support = lvh::gamepad_profile_support(keyboard);
@@ -97,7 +99,10 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) {
EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::raw_report));
EXPECT_TRUE(lvh::supports_gamepad_output(dualsense, lvh::GamepadOutputKind::adaptive_triggers));
EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rumble));
EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rgb_led));
EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::player_leds));
EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::raw_report));
EXPECT_FALSE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::player_leds));
EXPECT_TRUE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report));
EXPECT_FALSE(lvh::supports_gamepad_output(keyboard, lvh::GamepadOutputKind::rumble));
EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast<lvh::GamepadOutputKind>(255)));
+63 -71
View File
@@ -123,7 +123,7 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {
EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::usb), BUS_USB);
EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH);
EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::xbox_series), BUS_USB);
EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::switch_pro), BUS_VIRTUAL);
EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::switch_pro), BUS_BLUETOOTH);
EXPECT_EQ(lvh::detail::test::linux_uinput_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH);
EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::pen), BTN_TOOL_PEN);
@@ -365,26 +365,25 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) {
struct ButtonCase {
lvh::GamepadButton button;
std::uint16_t linux_code;
std::uint16_t switch_code;
};
constexpr std::array button_cases {
ButtonCase {a, BTN_SOUTH, BTN_EAST},
ButtonCase {b, BTN_EAST, BTN_SOUTH},
ButtonCase {x, BTN_NORTH, BTN_NORTH},
ButtonCase {y, BTN_WEST, BTN_WEST},
ButtonCase {left_shoulder, BTN_TL, BTN_TL},
ButtonCase {right_shoulder, BTN_TR, BTN_TR},
ButtonCase {back, BTN_SELECT, BTN_SELECT},
ButtonCase {start, BTN_START, BTN_START},
ButtonCase {guide, BTN_MODE, BTN_MODE},
ButtonCase {left_stick, BTN_THUMBL, BTN_THUMBL},
ButtonCase {right_stick, BTN_THUMBR, BTN_THUMBR},
ButtonCase {a, BTN_SOUTH},
ButtonCase {b, BTN_EAST},
ButtonCase {x, BTN_NORTH},
ButtonCase {y, BTN_WEST},
ButtonCase {left_shoulder, BTN_TL},
ButtonCase {right_shoulder, BTN_TR},
ButtonCase {back, BTN_SELECT},
ButtonCase {start, BTN_START},
ButtonCase {guide, BTN_MODE},
ButtonCase {left_stick, BTN_THUMBL},
ButtonCase {right_stick, BTN_THUMBR},
};
constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series, switch_pro};
constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series};
for (const auto kind : profile_kinds) {
for (const auto &[button, linux_code, switch_code] : button_cases) {
for (const auto &[button, linux_code] : button_cases) {
lvh::GamepadState state;
state.buttons.set(button);
const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state);
@@ -394,7 +393,7 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) {
ASSERT_EQ(pressed_codes.size(), 1U)
<< "profile " << static_cast<int>(std::to_underlying(kind)) << " logical button "
<< static_cast<int>(std::to_underlying(button));
EXPECT_EQ(pressed_codes.front(), kind == switch_pro ? switch_code : linux_code)
EXPECT_EQ(pressed_codes.front(), linux_code)
<< "profile " << static_cast<int>(std::to_underlying(kind)) << " logical button "
<< static_cast<int>(std::to_underlying(button));
}
@@ -411,17 +410,6 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) {
EXPECT_NE(pressed, result.events.end());
}
{
lvh::GamepadState state;
state.buttons.set(misc1);
const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(switch_pro, state);
ASSERT_TRUE(result.status.ok()) << result.status.message();
const auto pressed = std::ranges::find_if(result.events, [](const auto &event) {
return event.type == EV_KEY && event.code == BTN_Z && event.value == 1;
});
EXPECT_NE(pressed, result.events.end());
}
for (const auto kind : profile_kinds) {
lvh::GamepadState state;
state.buttons.set(dpad_up);
@@ -451,15 +439,8 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) {
EXPECT_EQ(event_value(EV_ABS, ABS_Y), lvh::reports::normalize_axis(-0.25F));
EXPECT_EQ(event_value(EV_ABS, ABS_RX), lvh::reports::normalize_axis(0.75F));
EXPECT_EQ(event_value(EV_ABS, ABS_RY), lvh::reports::normalize_axis(1.0F));
if (kind == switch_pro) {
EXPECT_EQ(event_value(EV_ABS, ABS_Z), std::nullopt);
EXPECT_EQ(event_value(EV_ABS, ABS_RZ), std::nullopt);
EXPECT_EQ(event_value(EV_KEY, BTN_TL2), 1);
EXPECT_EQ(event_value(EV_KEY, BTN_TR2), 1);
} else {
EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F));
EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F));
}
EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F));
EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F));
}
}
@@ -471,7 +452,6 @@ TEST_F(LinuxBackendTest, UinputGamepadsNormalizeForceFeedback) {
xbox_360,
xbox_one,
xbox_series,
switch_pro,
}) {
for (const auto effect_type : {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_RAMP}) {
const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(kind, effect_type);
@@ -492,7 +472,7 @@ TEST_F(LinuxBackendTest, UinputGamepadsNormalizeForceFeedback) {
TEST_F(LinuxBackendTest, UinputGamepadKeepsInfiniteRumbleActiveUntilExplicitStop) {
const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(
lvh::GamepadProfileKind::switch_pro,
lvh::GamepadProfileKind::xbox_series,
FF_RUMBLE,
0,
true
@@ -509,7 +489,7 @@ TEST_F(LinuxBackendTest, UinputGamepadKeepsInfiniteRumbleActiveUntilExplicitStop
TEST_F(LinuxBackendTest, UinputGamepadRecalculatesActiveRumbleEndAfterReupload) {
const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(
lvh::GamepadProfileKind::switch_pro,
lvh::GamepadProfileKind::xbox_series,
FF_RUMBLE,
0,
false,
@@ -802,6 +782,27 @@ TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {
EXPECT_EQ(result.output.last.high_frequency_rumble, 0x1234);
}
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();
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_EQ(result.creation.name, lvh::profiles::switch_pro().name);
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());
ASSERT_TRUE(result.switch_pro.motion_input_packet_timer.has_value());
EXPECT_EQ(
*result.switch_pro.motion_input_packet_timer,
static_cast<std::uint8_t>(*result.switch_pro.subcommand_reply_packet_timer + 1U)
);
EXPECT_TRUE(result.switch_pro.saw_player_leds);
ASSERT_EQ(result.output.callback_count, 2U);
EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::player_leds);
}
TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {
const auto result = lvh::detail::test::linux_dualsense_uhid_socketpair_reports();
EXPECT_TRUE(result.create_status.ok()) << result.create_status.message();
@@ -809,9 +810,9 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
EXPECT_TRUE(result.saw_dualsense_calibration);
EXPECT_TRUE(result.saw_dualsense_pairing);
EXPECT_TRUE(result.saw_dualsense_firmware);
EXPECT_TRUE(result.dualsense.saw_calibration);
EXPECT_TRUE(result.dualsense.saw_pairing);
EXPECT_TRUE(result.dualsense.saw_firmware);
EXPECT_TRUE(result.saw_set_report_reply);
ASSERT_GE(result.output.callback_count, 1U);
EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::rumble);
@@ -827,9 +828,9 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
EXPECT_TRUE(result.saw_dualsense_bluetooth_input_with_live_sensor_metadata);
EXPECT_TRUE(result.saw_dualsense_pairing);
EXPECT_TRUE(result.saw_dualsense_feature_crc);
EXPECT_TRUE(result.dualsense.saw_bluetooth_input_with_live_sensor_metadata);
EXPECT_TRUE(result.dualsense.saw_pairing);
EXPECT_TRUE(result.dualsense.saw_feature_crc);
}
TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) {
@@ -839,10 +840,10 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
EXPECT_TRUE(result.saw_dualshock4_usb_input);
EXPECT_TRUE(result.saw_dualshock4_calibration);
EXPECT_TRUE(result.saw_dualshock4_pairing);
EXPECT_TRUE(result.saw_dualshock4_firmware);
EXPECT_TRUE(result.dualshock4.saw_usb_input);
EXPECT_TRUE(result.dualshock4.saw_calibration);
EXPECT_TRUE(result.dualshock4.saw_pairing);
EXPECT_TRUE(result.dualshock4.saw_firmware);
EXPECT_TRUE(result.saw_set_report_reply);
ASSERT_GE(result.output.callback_count, 1U);
EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::rumble);
@@ -857,10 +858,10 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
EXPECT_TRUE(result.saw_dualshock4_bluetooth_input);
EXPECT_TRUE(result.saw_dualshock4_calibration);
EXPECT_TRUE(result.saw_dualshock4_pairing);
EXPECT_TRUE(result.saw_dualshock4_feature_crc);
EXPECT_TRUE(result.dualshock4.saw_bluetooth_input);
EXPECT_TRUE(result.dualshock4.saw_calibration);
EXPECT_TRUE(result.dualshock4.saw_pairing);
EXPECT_TRUE(result.dualshock4.saw_feature_crc);
}
TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) {
@@ -984,15 +985,13 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc
std::uint16_t product_id;
bool key_record;
bool sparse_button_slots;
bool switch_controls;
};
constexpr std::array gamepad_cases {
GamepadCase {generic, BUS_USB, 0x1209, 0x0001, true, false, false},
GamepadCase {xbox_360, BUS_BLUETOOTH, 0x045E, 0x028E, false, true, false},
GamepadCase {xbox_one, BUS_BLUETOOTH, 0x045E, 0x0B20, false, true, false},
GamepadCase {xbox_series, BUS_BLUETOOTH, 0x045E, 0x0B13, true, true, false},
GamepadCase {switch_pro, BUS_USB, 0x057E, 0x2009, false, false, true},
GamepadCase {generic, BUS_USB, 0x1209, 0x0001, true, false},
GamepadCase {xbox_360, BUS_BLUETOOTH, 0x045E, 0x028E, false, true},
GamepadCase {xbox_one, BUS_BLUETOOTH, 0x045E, 0x0B20, false, true},
GamepadCase {xbox_series, BUS_BLUETOOTH, 0x045E, 0x0B13, true, true},
};
constexpr std::array active_buttons {
BTN_SOUTH,
@@ -1012,7 +1011,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc
constexpr std::array feedback_codes {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_SINE, FF_RAMP, FF_GAIN};
for (const auto &[kind, bustype, vendor_id, product_id, key_record, sparse_button_slots, switch_controls] : gamepad_cases) {
for (const auto &[kind, bustype, vendor_id, product_id, key_record, sparse_button_slots] : gamepad_cases) {
const auto expected_profile = lvh::profiles::gamepad_profile(kind);
ASSERT_TRUE(expected_profile.has_value());
const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind);
@@ -1029,7 +1028,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc
EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing canonical gamepad button " << button;
}
for (const auto button : reserved_buttons) {
const auto expected = sparse_button_slots || (switch_controls && button != BTN_C);
const auto expected = sparse_button_slots;
EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, expected)
<< "unexpected reserved gamepad button slot state for " << button;
}
@@ -1041,16 +1040,9 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc
EXPECT_NE(find_code(gamepad, EV_ABS, ABS_HAT0Y), nullptr);
EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, key_record);
const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z);
if (switch_controls) {
EXPECT_EQ(left_trigger, nullptr);
EXPECT_NE(find_code(gamepad, EV_KEY, BTN_TL2), nullptr);
EXPECT_NE(find_code(gamepad, EV_KEY, BTN_TR2), nullptr);
EXPECT_NE(find_code(gamepad, EV_KEY, BTN_Z), nullptr);
} else {
ASSERT_NE(left_trigger, nullptr);
EXPECT_EQ(left_trigger->minimum, 0);
EXPECT_EQ(left_trigger->maximum, 255);
}
ASSERT_NE(left_trigger, nullptr);
EXPECT_EQ(left_trigger->minimum, 0);
EXPECT_EQ(left_trigger->maximum, 255);
for (const auto code : feedback_codes) {
EXPECT_EQ(find_code(gamepad, EV_FF, code) != nullptr, expected_profile->capabilities.supports_rumble);
}
+65 -4
View File
@@ -67,6 +67,7 @@ namespace {
int minimum_buttons = 1;
int minimum_axes = 2;
bool require_sdl_rumble = false;
bool require_motion = false;
bool expect_live_input = true;
};
@@ -335,7 +336,7 @@ namespace {
}
}
int wait_for_sdl_joystick(const lvh::DeviceProfile &profile, bool require_rumble) {
int wait_for_sdl_game_controller(const lvh::DeviceProfile &profile, bool require_rumble) {
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3};
while (std::chrono::steady_clock::now() < deadline) {
@@ -345,6 +346,7 @@ namespace {
for (int index = 0; index < joystick_count; ++index) {
if (
sdl_joystick_matches_profile(index, profile) &&
SDL_IsGameController(index) == SDL_TRUE &&
(!require_rumble || sdl_joystick_supports_rumble(index))
) {
return index;
@@ -479,6 +481,7 @@ namespace {
SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1");
SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", "1");
SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5_RUMBLE", "1");
SDL_SetHint("SDL_JOYSTICK_HIDAPI_SWITCH", "1");
}
lvh::GamepadCreationResult create_sdl_gamepad(lvh::Runtime &runtime, const SdlGamepadConsumerCase &test_case) {
@@ -520,7 +523,25 @@ namespace {
auto created = create_sdl_gamepad(*runtime, test_case);
ASSERT_TRUE(created) << created.status.message();
const auto joystick_index = wait_for_sdl_joystick(expected_profile, test_case.require_sdl_rumble);
if (test_case.require_sdl_rumble) {
const auto hidraw_node = wait_for_hidraw_node(*created.gamepad);
ASSERT_TRUE(hidraw_node.has_value())
<< "No hidraw node was discovered. Reported device nodes: "
<< describe_device_nodes(created.gamepad->device_nodes());
const auto access_error = wait_for_write_access(*hidraw_node);
ASSERT_EQ(access_error, 0)
<< "hidraw node is not writable: " << errno_message(access_error)
<< " (errno=" << access_error << "); " << describe_node_permissions(*hidraw_node);
errno = 0;
const auto read_result = ::access(hidraw_node->c_str(), R_OK);
const auto read_error = errno;
ASSERT_EQ(read_result, 0)
<< "hidraw node is not readable: " << errno_message(read_error)
<< " (errno=" << read_error << "); " << describe_node_permissions(*hidraw_node);
}
const auto joystick_index = wait_for_sdl_game_controller(expected_profile, test_case.require_sdl_rumble);
ASSERT_GE(joystick_index, 0);
test_body(expected_profile, joystick_index, *created.gamepad);
@@ -550,6 +571,41 @@ namespace {
EXPECT_GT(rumble->high_frequency.load(), 0);
}
void expect_sdl_motion_input(SDL_GameController *controller, lvh::Gamepad &gamepad) {
ASSERT_EQ(SDL_GameControllerHasSensor(controller, SDL_SENSOR_ACCEL), SDL_TRUE);
ASSERT_EQ(SDL_GameControllerHasSensor(controller, SDL_SENSOR_GYRO), SDL_TRUE);
ASSERT_EQ(SDL_GameControllerSetSensorEnabled(controller, SDL_SENSOR_ACCEL, SDL_TRUE), 0) << SDL_GetError();
ASSERT_EQ(SDL_GameControllerSetSensorEnabled(controller, SDL_SENSOR_GYRO, SDL_TRUE), 0) << SDL_GetError();
lvh::GamepadState state;
state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
state.gyroscope = lvh::Vector3 {.x = 10.0F, .y = 20.0F, .z = -30.0F};
ASSERT_TRUE(gamepad.submit(state).ok());
std::array<float, 3> acceleration {};
std::array<float, 3> gyroscope {};
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3};
while (std::chrono::steady_clock::now() < deadline) {
SDL_GameControllerUpdate();
pump_sdl_events();
ASSERT_EQ(
SDL_GameControllerGetSensorData(controller, SDL_SENSOR_ACCEL, acceleration.data(), acceleration.size()),
0
) << SDL_GetError();
ASSERT_EQ(
SDL_GameControllerGetSensorData(controller, SDL_SENSOR_GYRO, gyroscope.data(), gyroscope.size()),
0
) << SDL_GetError();
if (std::abs(acceleration[1]) > 15.0F && std::abs(gyroscope[2]) > 0.25F) {
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds {20});
}
ADD_FAILURE() << "SDL motion remained static: accel=" << acceleration[0] << "," << acceleration[1] << ","
<< acceleration[2] << " gyro=" << gyroscope[0] << "," << gyroscope[1] << "," << gyroscope[2];
}
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);
@@ -713,6 +769,9 @@ namespace {
}
EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERLEFT), 0);
EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT), 16000);
if (test_case.require_motion) {
expect_sdl_motion_input(controller.get(), gamepad);
}
expect_sdl_rumble_callback(controller.get(), gamepad);
}
@@ -851,14 +910,16 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) {
}
TEST_F(LinuxConsumerTest, SdlSeesSwitchProCanonicalButtons) {
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput"));
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));
run_sdl_canonical_gamepad_test({
.profile = lvh::profiles::switch_pro(),
.name_suffix = "SDL Switch Pro",
.stable_id = "libvirtualhid-sdl-switch-pro-test",
.stable_id = "02:00:00:00:00:05",
.minimum_buttons = 14,
.minimum_axes = 4,
.require_sdl_rumble = true,
.require_motion = true,
});
}
+2
View File
@@ -234,12 +234,14 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
EXPECT_EQ(switch_pro.vendor_id, 0x057E);
EXPECT_EQ(switch_pro.product_id, 0x2009);
EXPECT_EQ(switch_pro.version, 0x0210);
EXPECT_EQ(switch_pro.manufacturer, "Nintendo Co., Ltd.");
EXPECT_EQ(switch_pro.report_id, 0x30);
EXPECT_EQ(switch_pro.input_report_size, 64U);
EXPECT_EQ(switch_pro.output_report_size, 64U);
EXPECT_TRUE(switch_pro.capabilities.supports_rumble);
EXPECT_TRUE(switch_pro.capabilities.supports_motion);
EXPECT_TRUE(switch_pro.capabilities.supports_rgb_led);
EXPECT_TRUE(switch_pro.capabilities.supports_battery);
const auto generic = lvh::profiles::generic_gamepad();
+194 -1
View File
@@ -180,7 +180,6 @@ TEST(ReportTest, PacksSwitchProReport) {
ASSERT_EQ(report.size(), profile.input_report_size);
EXPECT_EQ(report[0], 0x30U);
EXPECT_EQ(report[1], 0x00U); // Packet timer.
EXPECT_EQ(report[2], 0x81U); // Full battery and USB connection when battery state is unknown.
EXPECT_EQ(report[3], 0x0CU); // B and A.
EXPECT_EQ(report[4], 0x32U); // Plus, Home, and Capture.
@@ -193,6 +192,48 @@ TEST(ReportTest, PacksSwitchProReport) {
EXPECT_EQ(report[9], 0xFFU);
EXPECT_EQ(report[10], 0x0BU);
EXPECT_EQ(report[11], 0x40U);
// Each full report carries three native IMU samples. A missing sensor value
// represents a stationary controller under one g of gravity.
for (const auto offset : {13U, 25U, 37U}) {
EXPECT_EQ(read_i16_le(report, offset), 0);
EXPECT_EQ(read_i16_le(report, offset + 2U), 0);
EXPECT_EQ(read_i16_le(report, offset + 4U), 4096);
EXPECT_EQ(read_i16_le(report, offset + 6U), 0);
EXPECT_EQ(read_i16_le(report, offset + 8U), 0);
EXPECT_EQ(read_i16_le(report, offset + 10U), 0);
}
}
TEST(ReportTest, AdvancesSwitchProPacketTimerForEachInputReport) {
const auto profile = lvh::profiles::switch_pro();
const auto first = lvh::reports::pack_input_report(profile, {});
const auto second = lvh::reports::pack_input_report(profile, {});
ASSERT_EQ(first.size(), profile.input_report_size);
ASSERT_EQ(second.size(), profile.input_report_size);
EXPECT_EQ(second[1], static_cast<std::uint8_t>(first[1] + 1U));
}
TEST(ReportTest, PacksSwitchProMotionInEveryImuSample) {
const auto profile = lvh::profiles::switch_pro();
lvh::GamepadState state;
state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
state.gyroscope = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F};
const auto report = lvh::reports::pack_input_report(profile, state);
ASSERT_EQ(report.size(), profile.input_report_size);
for (const auto offset : {13U, 25U, 37U}) {
EXPECT_EQ(read_i16_le(report, offset), 4096);
EXPECT_EQ(read_i16_le(report, offset + 2U), -4096);
EXPECT_EQ(read_i16_le(report, offset + 4U), 8192);
EXPECT_EQ(read_i16_le(report, offset + 6U), 43);
EXPECT_EQ(read_i16_le(report, offset + 8U), -14);
EXPECT_EQ(read_i16_le(report, offset + 10U), 29);
}
}
TEST(ReportTest, PacksXboxGipNeutralReport) {
@@ -665,6 +706,158 @@ TEST(ReportTest, ParsesSwitchProRumbleFromSubcommandReport) {
EXPECT_EQ(output.raw_report, report);
}
TEST(ReportTest, ParsesSwitchProPlayerLightsAlongsideRumble) {
const auto profile = lvh::profiles::switch_pro();
const std::vector<std::uint8_t> report {
0x01,
0x07,
0x00,
0x01,
0x40,
0x40,
0x00,
0x01,
0x40,
0x40,
0x30,
0xA5,
};
const auto outputs = lvh::reports::parse_output_reports(profile, report);
ASSERT_EQ(outputs.size(), 2U);
EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble);
EXPECT_EQ(outputs[0].low_frequency_rumble, 0U);
EXPECT_EQ(outputs[0].high_frequency_rumble, 0U);
EXPECT_EQ(outputs[1].kind, lvh::GamepadOutputKind::player_leds);
EXPECT_EQ(outputs[1].player_leds, (std::array {true, false, true, false}));
EXPECT_EQ(outputs[1].flashing_player_leds, (std::array {false, true, false, true}));
EXPECT_EQ(outputs[1].raw_report, report);
}
TEST(ReportTest, ParsesSwitchProPlayerLightsWithMalformedRumbleData) {
const auto profile = lvh::profiles::switch_pro();
const std::vector<std::uint8_t> report {
0x01,
0x00,
0x74,
0x1A,
0x3D,
0x20,
0x74,
0x1A,
0x3D,
0x59,
0x30,
0x03,
};
const auto outputs = lvh::reports::parse_output_reports(profile, report);
ASSERT_EQ(outputs.size(), 1U);
EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::player_leds);
EXPECT_EQ(outputs[0].player_leds, (std::array {true, true, false, false}));
EXPECT_EQ(outputs[0].flashing_player_leds, (std::array {false, false, false, false}));
}
TEST(ReportTest, ParsesSwitchProHomeLightAlongsideRumble) {
const auto profile = lvh::profiles::switch_pro();
const std::vector<std::uint8_t> report {
0x01,
0x09,
0x00,
0x01,
0x40,
0x40,
0x00,
0x01,
0x40,
0x40,
0x38,
0x01,
0xF0,
0xF0,
0x00,
};
const auto outputs = lvh::reports::parse_output_reports(profile, report);
ASSERT_EQ(outputs.size(), 2U);
EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble);
EXPECT_EQ(outputs[1].kind, lvh::GamepadOutputKind::rgb_led);
EXPECT_EQ(outputs[1].red, 255U);
EXPECT_EQ(outputs[1].green, 255U);
EXPECT_EQ(outputs[1].blue, 255U);
EXPECT_EQ(outputs[1].raw_report, report);
}
TEST(ReportTest, ParsesSwitchProHomeLightWithMalformedRumbleData) {
const auto profile = lvh::profiles::switch_pro();
const std::vector<std::uint8_t> report {
0x01,
0x00,
0x74,
0x1A,
0x3D,
0x20,
0x74,
0x1A,
0x3D,
0x59,
0x38,
0x01,
0x30,
0x30,
0x00,
};
const auto outputs = lvh::reports::parse_output_reports(profile, report);
ASSERT_EQ(outputs.size(), 1U);
EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rgb_led);
EXPECT_EQ(outputs[0].red, 77U);
EXPECT_EQ(outputs[0].green, 77U);
EXPECT_EQ(outputs[0].blue, 77U);
}
TEST(ReportTest, ParsesSwitchProHomeLightIntensityCurve) {
const auto profile = lvh::profiles::switch_pro();
std::vector<std::uint8_t> report {
0x01,
0x00,
0x74,
0x1A,
0x3D,
0x20,
0x74,
0x1A,
0x3D,
0x59,
0x38,
0x01,
0x00,
0x00,
0x00,
};
const std::array intensity_cases {
std::array<std::uint8_t, 2> {0x00, 0},
std::array<std::uint8_t, 2> {0x80, 184},
};
for (const auto &[encoded_intensity, expected_intensity] : intensity_cases) {
report[12] = encoded_intensity;
report[13] = encoded_intensity;
const auto outputs = lvh::reports::parse_output_reports(profile, report);
ASSERT_EQ(outputs.size(), 1U);
EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rgb_led);
EXPECT_EQ(outputs[0].red, expected_intensity);
EXPECT_EQ(outputs[0].green, expected_intensity);
EXPECT_EQ(outputs[0].blue, expected_intensity);
}
}
TEST(ReportTest, ParsesSwitchProNeutralRumbleReport) {
const auto profile = lvh::profiles::switch_pro();
const std::vector<std::uint8_t> report {
+16 -6
View File
@@ -58,6 +58,7 @@ TEST(VirtualHidControlModelTest, NamesKnownAndFallbackEnumValues) {
EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::adaptive_triggers), L"adaptive triggers");
EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::raw_report), L"raw report");
EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::trigger_rumble), L"trigger rumble");
EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::player_leds), L"player leds");
EXPECT_EQ(control::output_kind_name(static_cast<lvh::GamepadOutputKind>(255)), L"raw report");
EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::unknown), L"unknown");
@@ -191,11 +192,11 @@ TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) {
EXPECT_EQ(
control::profile_feature_summary(generic),
L"Features: battery no | rumble yes | trigger rumble no | RGB LED no | adaptive triggers no | raw output yes"
L"Features: battery no | rumble yes | trigger rumble no | RGB LED no | player LEDs no | adaptive triggers no | raw output yes"
);
EXPECT_EQ(
control::profile_feature_summary(dualsense),
L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | adaptive triggers yes | raw output yes"
L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | player LEDs no | adaptive triggers yes | raw output yes"
);
EXPECT_EQ(
control::device_feature_summary(mouse),
@@ -256,10 +257,13 @@ TEST(VirtualHidControlModelTest, SummarizesOutputState) {
state.latest_rgb_led->blue = 3;
state.latest_adaptive_triggers = output(adaptive_triggers);
state.latest_adaptive_triggers->adaptive_trigger_flags = 4;
state.latest_player_leds = output(player_leds);
state.latest_player_leds->player_leds = {true, false, true, false};
state.latest_player_leds->flashing_player_leds = {false, true, false, true};
EXPECT_EQ(
control::output_summary(state, dualsense),
L"Output: rumble low=10 high=20 | trigger rumble L=30 R=40 | RGB 1,2,3 | adaptive flags=4"
L"Output: rumble low=10 high=20 | trigger rumble L=30 R=40 | RGB 1,2,3 | adaptive flags=4 | player LEDs solid=1010 flashing=0101"
);
}
@@ -287,14 +291,18 @@ TEST(VirtualHidControlModelTest, RecordsOutputsAndMaintainsLatestSummaryFields)
adaptive.adaptive_trigger_flags = 8;
control::record_output(state, adaptive, next_sequence, 3);
auto player_leds = output(lvh::GamepadOutputKind::player_leds);
player_leds.player_leds = {true, false, true, false};
control::record_output(state, player_leds, next_sequence, 3);
auto raw = output(lvh::GamepadOutputKind::raw_report);
raw.raw_report = {0x12, 0x34};
control::record_output(state, raw, next_sequence, 3);
ASSERT_EQ(state.outputs.size(), 3U);
EXPECT_EQ(state.outputs.front().sequence, 9U);
EXPECT_EQ(state.outputs.back().sequence, 11U);
EXPECT_EQ(next_sequence, 12U);
EXPECT_EQ(state.outputs.front().sequence, 10U);
EXPECT_EQ(state.outputs.back().sequence, 12U);
EXPECT_EQ(next_sequence, 13U);
ASSERT_TRUE(state.latest_rumble.has_value());
EXPECT_EQ(state.latest_rumble->low_frequency_rumble, 100);
@@ -304,6 +312,8 @@ TEST(VirtualHidControlModelTest, RecordsOutputsAndMaintainsLatestSummaryFields)
EXPECT_EQ(state.latest_rgb_led->blue, 7);
ASSERT_TRUE(state.latest_adaptive_triggers.has_value());
EXPECT_EQ(state.latest_adaptive_triggers->adaptive_trigger_flags, 8);
ASSERT_TRUE(state.latest_player_leds.has_value());
EXPECT_EQ(state.latest_player_leds->player_leds, (std::array {true, false, true, false}));
ASSERT_TRUE(state.latest_raw_report.has_value());
EXPECT_EQ(state.latest_raw_report->raw_report, (std::vector<std::uint8_t> {0x12, 0x34}));
}
+13
View File
@@ -105,6 +105,19 @@ TEST_F(WindowsBackendTest, FakeChannelExercisesLifecycleSubmitCloseAndOutput) {
EXPECT_EQ(result.last_output.raw_report[0], 0x03U);
}
TEST_F(WindowsBackendTest, SwitchReportsStreamAtTheNativeCadence) {
const auto result = lvh::detail::test::windows_backend_switch_report_stream();
expect_ok(result.create_status);
expect_ok(result.submit_status);
ASSERT_TRUE(result.repeated_report);
expect_ok(result.close_status);
ASSERT_GE(result.submitted_reports.size(), 2U);
EXPECT_TRUE(std::ranges::all_of(result.submitted_reports, [&result](const auto &report) {
return report == result.expected_report;
}));
}
TEST_F(WindowsBackendTest, PlayStationDefaultsUseEffectiveUsbProfiles) {
const auto result = lvh::detail::test::windows_backend_playstation_transport();
const auto dualshock4_usb = lvh::profiles::dualshock4_usb();
+201 -20
View File
@@ -29,6 +29,7 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
@@ -59,6 +60,7 @@ namespace {
std::wstring path;
std::uint16_t vendor_id = 0;
std::uint16_t product_id = 0;
std::uint16_t version_number = 0;
std::uint16_t usage = 0;
std::uint16_t input_report_size = 0;
std::uint16_t output_report_size = 0;
@@ -256,6 +258,7 @@ namespace {
.path = detail->DevicePath,
.vendor_id = attributes.VendorID,
.product_id = attributes.ProductID,
.version_number = attributes.VersionNumber,
.usage = capabilities.Usage,
.input_report_size = capabilities.InputReportByteLength,
.output_report_size = capabilities.OutputReportByteLength,
@@ -317,6 +320,59 @@ namespace {
return report;
}
template<typename Predicate>
std::optional<std::vector<std::uint8_t>> read_hid_report_matching(
HANDLE hid,
std::size_t report_size,
std::chrono::milliseconds timeout,
Predicate predicate
) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(
deadline - std::chrono::steady_clock::now()
);
auto report = read_hid_report_with_timeout(hid, report_size, std::max(remaining, 1ms));
if (!report.has_value()) {
return std::nullopt;
}
if (predicate(*report)) {
return report;
}
}
return std::nullopt;
}
std::optional<std::vector<std::uint8_t>> send_switch_proprietary_command(
const HidGamepadInterface &hid_interface,
HANDLE reader,
HANDLE writer,
std::uint8_t command
) {
std::vector<std::uint8_t> output(hid_interface.output_report_size, 0);
output[0] = 0x80;
output[1] = command;
DWORD bytes_written = 0;
EXPECT_TRUE(WriteFile(
writer,
output.data(),
static_cast<DWORD>(output.size()),
&bytes_written,
nullptr
)) << "Switch proprietary WriteFile failed: "
<< GetLastError();
EXPECT_EQ(bytes_written, output.size());
return read_hid_report_matching(
reader,
hid_interface.input_report_size,
5s,
[command](const auto &report) {
return report.size() >= 2U && report[0] == 0x81U && report[1] == command;
}
);
}
HidInterfacePaths current_gamepad_interface_paths() {
HidInterfacePaths paths;
for (const auto &hid_interface : enumerate_gamepad_interfaces()) {
@@ -355,6 +411,16 @@ namespace {
});
}
std::optional<lvh::GamepadOutput> wait_for_player_leds(
const std::array<bool, 4> &solid,
const std::array<bool, 4> &flashing
) {
return wait_for([&solid, &flashing](const lvh::GamepadOutput &output) {
return output.kind == lvh::GamepadOutputKind::player_leds && output.player_leds == solid &&
output.flashing_player_leds == flashing;
});
}
private:
template<typename Predicate>
std::optional<lvh::GamepadOutput> wait_for(Predicate matches) {
@@ -450,6 +516,25 @@ namespace {
}
return {nullptr, &SDL_CloseGamepad};
}
bool wait_for_switch_pro_motion(SDL_Gamepad *gamepad) {
std::array<float, 3> acceleration {};
std::array<float, 3> gyroscope {};
const auto deadline = std::chrono::steady_clock::now() + 5s;
while (std::chrono::steady_clock::now() < deadline) {
SDL_UpdateGamepads();
SDL_PumpEvents();
if (
SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_ACCEL, acceleration.data(), acceleration.size()) &&
SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_GYRO, gyroscope.data(), gyroscope.size()) &&
std::abs(acceleration[1]) > 15.0F && std::abs(gyroscope[2]) > 0.25F
) {
return true;
}
std::this_thread::sleep_for(20ms);
}
return false;
}
#endif
} // namespace
@@ -505,6 +590,30 @@ TEST_F(WindowsConsumerTest, SdlHidapiOutputReachesDefaultPlayStationAndSwitchCal
const auto rgb_led = output_capture.wait_for_rgb_led(0x12U, 0x34U, 0x56U);
ASSERT_TRUE(rgb_led.has_value()) << "No normalized RGB LED callback followed SDL HIDAPI LED output";
}
if (profile.gamepad_kind == lvh::GamepadProfileKind::switch_pro) {
ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_MONO_LED_BOOLEAN, false)) << SDL_GetError();
ASSERT_TRUE(SDL_SetGamepadLED(gamepad.get(), 0xFFU, 0xFFU, 0xFFU)) << SDL_GetError();
const auto home_led = output_capture.wait_for_rgb_led(0xFFU, 0xFFU, 0xFFU);
ASSERT_TRUE(home_led.has_value()) << "No normalized HOME LED callback followed SDL HIDAPI LED output";
ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_ACCEL)) << SDL_GetError();
ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_GYRO)) << SDL_GetError();
ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_ACCEL, true)) << SDL_GetError();
ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_GYRO, true)) << SDL_GetError();
ASSERT_TRUE(created.adapter->set_motion(
lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F},
lvh::Vector3 {.x = 10.0F, .y = 20.0F, .z = -30.0F}
)
.ok());
ASSERT_TRUE(wait_for_switch_pro_motion(gamepad.get())) << "SDL HIDAPI did not receive live Switch Pro motion";
ASSERT_TRUE(SDL_SetGamepadPlayerIndex(gamepad.get(), 2)) << SDL_GetError();
const auto player_leds = output_capture.wait_for_player_leds(
std::array {true, true, true, false},
std::array {false, false, false, false}
);
ASSERT_TRUE(player_leds.has_value()) << "No normalized player LED callback followed SDL HIDAPI player-index output";
}
ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false)) << SDL_GetError();
ASSERT_TRUE(SDL_RumbleGamepad(gamepad.get(), 0x5678U, 0x1234U, 1000U)) << SDL_GetError();
@@ -677,12 +786,14 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
lvh::CreateGamepadOptions options;
options.profile = profile;
options.metadata.stable_id = "02:00:00:00:00:05";
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
GamepadOutputCapture output_capture;
output_capture.attach(*created.adapter);
const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF Switch Pro HID interface was not enumerated";
ASSERT_EQ(hid_interface->version_number, profile.version);
ASSERT_EQ(hid_interface->input_report_size, profile.input_report_size);
ASSERT_EQ(hid_interface->output_report_size, profile.output_report_size);
@@ -708,31 +819,14 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
)};
ASSERT_TRUE(reader) << "Unable to open the VHF Switch Pro HID interface for reads: " << GetLastError();
const auto send_proprietary_command = [&hid_interface, &reader, &writer](std::uint8_t command) {
std::vector<std::uint8_t> output(hid_interface->output_report_size, 0);
output[0] = 0x80;
output[1] = command;
DWORD bytes_written = 0;
EXPECT_TRUE(WriteFile(
writer.get(),
output.data(),
static_cast<DWORD>(output.size()),
&bytes_written,
nullptr
)) << "Switch proprietary WriteFile failed: "
<< GetLastError();
EXPECT_EQ(bytes_written, output.size());
return read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 5s);
};
const auto status_reply = send_proprietary_command(0x01);
const auto status_reply = send_switch_proprietary_command(*hid_interface, reader.get(), writer.get(), 0x01);
ASSERT_TRUE(status_reply.has_value()) << "No 0x81 Switch status reply reached the HID client";
ASSERT_GE(status_reply->size(), 10U);
EXPECT_EQ(status_reply->at(0), 0x81U);
EXPECT_EQ(status_reply->at(1), 0x01U);
EXPECT_EQ(status_reply->at(3), 0x03U); // Pro Controller.
const auto handshake_reply = send_proprietary_command(0x02);
const auto handshake_reply = send_switch_proprietary_command(*hid_interface, reader.get(), writer.get(), 0x02);
ASSERT_TRUE(handshake_reply.has_value()) << "No 0x81 Switch handshake reply reached the HID client";
ASSERT_GE(handshake_reply->size(), 2U);
EXPECT_EQ(handshake_reply->at(0), 0x81U);
@@ -769,6 +863,55 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
EXPECT_EQ(rumble->high_frequency_rumble, 5213U);
EXPECT_EQ(rumble->raw_report, rumble_report);
std::vector<std::uint8_t> player_lights_report(hid_interface->output_report_size, 0);
player_lights_report[0] = 0x01;
player_lights_report[1] = 0x08;
constexpr std::array neutral_rumble {
std::uint8_t {0x00},
std::uint8_t {0x01},
std::uint8_t {0x40},
std::uint8_t {0x40},
std::uint8_t {0x00},
std::uint8_t {0x01},
std::uint8_t {0x40},
std::uint8_t {0x40},
};
std::ranges::copy(neutral_rumble, player_lights_report.begin() + 2);
player_lights_report[10] = 0x30;
player_lights_report[11] = 0xA5;
bytes_written = 0;
ASSERT_TRUE(WriteFile(
writer.get(),
player_lights_report.data(),
static_cast<DWORD>(player_lights_report.size()),
&bytes_written,
nullptr
)) << "Switch player-light WriteFile failed: "
<< GetLastError();
ASSERT_EQ(bytes_written, player_lights_report.size());
const auto player_lights_reply = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[](const auto &report) {
return report.size() >= 15U && report[0] == 0x21U && report[14] == 0x30U;
}
);
ASSERT_TRUE(player_lights_reply.has_value()) << "No Switch player-light acknowledgement reached the HID client";
ASSERT_GE(player_lights_reply->size(), 15U);
EXPECT_EQ(player_lights_reply->at(0), 0x21U);
EXPECT_EQ(player_lights_reply->at(13), 0x80U);
EXPECT_EQ(player_lights_reply->at(14), 0x30U);
const auto player_lights = output_capture.wait_for_player_leds(
{true, false, true, false},
{false, true, false, true}
);
ASSERT_TRUE(player_lights.has_value())
<< "No normalized player-light callback followed the native Switch 0x30 output write";
EXPECT_EQ(player_lights->raw_report, player_lights_report);
lvh::GamepadState state;
state.buttons.set(lvh::GamepadButton::a);
state.buttons.set(lvh::GamepadButton::guide);
@@ -776,9 +919,18 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
state.buttons.set(lvh::GamepadButton::right_stick);
state.left_stick = {.x = 1.0F, .y = 0.0F};
state.right_stick = {.x = 0.0F, .y = -1.0F};
state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
state.gyroscope = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F};
ASSERT_TRUE(created.adapter->set_state(state).ok());
const auto input = read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 5s);
const auto input = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[](const auto &report) {
return report.size() >= 6U && report[0] == 0x30U && report[3] == 0x08U && report[4] == 0x14U && report[5] == 0x08U;
}
);
ASSERT_TRUE(input.has_value()) << "No native Switch 0x30 input report reached the HID client";
ASSERT_EQ(input->size(), profile.input_report_size);
EXPECT_EQ(input->at(0), 0x30U);
@@ -791,6 +943,35 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
EXPECT_EQ(input->at(9), 0x00U);
EXPECT_EQ(input->at(10), 0x08U);
EXPECT_EQ(input->at(11), 0x00U);
const auto read_i16 = [&input](std::size_t offset) {
return static_cast<std::int16_t>(
static_cast<std::uint16_t>(input->at(offset)) |
(static_cast<std::uint16_t>(input->at(offset + 1U)) << 8U)
);
};
for (const auto offset : {13U, 25U, 37U}) {
EXPECT_EQ(read_i16(offset), 4096);
EXPECT_EQ(read_i16(offset + 2U), -4096);
EXPECT_EQ(read_i16(offset + 4U), 8192);
EXPECT_EQ(read_i16(offset + 6U), 43);
EXPECT_EQ(read_i16(offset + 8U), -14);
EXPECT_EQ(read_i16(offset + 10U), 29);
}
state.gyroscope = lvh::Vector3 {.x = -4.0F, .y = 5.0F, .z = 6.0F};
ASSERT_TRUE(created.adapter->set_state(state).ok());
const auto next_input = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[&input](const auto &report) {
return report.size() > 19U && report[0] == 0x30U && report[19] != input->at(19);
}
);
ASSERT_TRUE(next_input.has_value()) << "No second native Switch motion report reached the HID client";
ASSERT_EQ(next_input->size(), profile.input_report_size);
EXPECT_NE(next_input->at(1), input->at(1));
EXPECT_NE(next_input->at(19), input->at(19));
ASSERT_TRUE(created.adapter->close().ok());
}
+13 -13
View File
@@ -6,7 +6,7 @@
// local includes
#include "fixtures/fixtures.hpp"
#include "playstation_feature_protocol.hpp"
#include "switch_pro_protocol.hpp"
#include "shared/switch_pro_protocol.hpp"
#include "windows_device_identity.hpp"
// standard includes
@@ -58,11 +58,11 @@ namespace {
return request;
}
std::array<std::uint8_t, lvh::detail::windows::switch_pro_report_size> switch_output_report(
std::array<std::uint8_t, lvh::detail::switch_pro_protocol::switch_pro_report_size> switch_output_report(
std::uint8_t report_id,
std::uint8_t command
) {
std::array<std::uint8_t, lvh::detail::windows::switch_pro_report_size> report {};
std::array<std::uint8_t, lvh::detail::switch_pro_protocol::switch_pro_report_size> report {};
report[0] = report_id;
if (report_id == 0x80U) {
report[1] = command;
@@ -116,7 +116,7 @@ TEST_F(WindowsDriverProtocolTest, NonSeriesXboxUsesItsPublicIdentityForMatching)
TEST_F(WindowsDriverProtocolTest, SwitchProRepliesToUsbStatusAndHandshakeCommands) {
auto status_report = switch_output_report(0x80, 0x01);
const auto status_reply = lvh::detail::windows::make_switch_pro_reply(status_report);
const auto status_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(status_report);
ASSERT_TRUE(status_reply.has_value());
EXPECT_EQ(status_reply->at(0), 0x81);
EXPECT_EQ(status_reply->at(1), 0x01);
@@ -127,23 +127,23 @@ TEST_F(WindowsDriverProtocolTest, SwitchProRepliesToUsbStatusAndHandshakeCommand
for (const auto command : {0x02U, 0x03U}) {
const auto report = switch_output_report(0x80, static_cast<std::uint8_t>(command));
const auto reply = lvh::detail::windows::make_switch_pro_reply(report);
const auto reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(report);
ASSERT_TRUE(reply.has_value());
EXPECT_EQ(reply->at(0), 0x81);
EXPECT_EQ(reply->at(1), command);
}
const auto force_usb = switch_output_report(0x80, 0x04);
EXPECT_FALSE(lvh::detail::windows::make_switch_pro_reply(force_usb).has_value());
EXPECT_FALSE(lvh::detail::switch_pro_protocol::make_switch_pro_reply(force_usb).has_value());
}
TEST_F(WindowsDriverProtocolTest, SwitchProAcknowledgesInitializationSubcommands) {
for (const auto subcommand : {0x03U, 0x30U, 0x38U, 0x40U, 0x41U, 0x48U}) {
const auto report = switch_output_report(0x01, static_cast<std::uint8_t>(subcommand));
const auto reply = lvh::detail::windows::make_switch_pro_reply(report);
const auto reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(report, 0x42U);
ASSERT_TRUE(reply.has_value());
EXPECT_EQ(reply->at(0), 0x21);
EXPECT_EQ(reply->at(1), 0x07);
EXPECT_EQ(reply->at(1), 0x42);
EXPECT_EQ(reply->at(2), 0x81);
EXPECT_EQ(reply->at(13), 0x80);
EXPECT_EQ(reply->at(14), subcommand);
@@ -156,12 +156,12 @@ TEST_F(WindowsDriverProtocolTest, SwitchProAcknowledgesInitializationSubcommands
}
const auto rumble_only = switch_output_report(0x10, 0x00);
EXPECT_FALSE(lvh::detail::windows::make_switch_pro_reply(rumble_only).has_value());
EXPECT_FALSE(lvh::detail::switch_pro_protocol::make_switch_pro_reply(rumble_only).has_value());
}
TEST_F(WindowsDriverProtocolTest, SwitchProReturnsDeviceInfoAndFactoryCalibration) {
auto device_info = switch_output_report(0x01, 0x02);
const auto device_reply = lvh::detail::windows::make_switch_pro_reply(device_info);
const auto device_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(device_info);
ASSERT_TRUE(device_reply.has_value());
EXPECT_EQ(device_reply->at(13), 0x82);
EXPECT_EQ(device_reply->at(14), 0x02);
@@ -170,7 +170,7 @@ TEST_F(WindowsDriverProtocolTest, SwitchProReturnsDeviceInfoAndFactoryCalibratio
auto stick_read = switch_output_report(0x01, 0x10);
write_u32(stick_read, 11U, 0x603D);
stick_read[15] = 18U;
const auto stick_reply = lvh::detail::windows::make_switch_pro_reply(stick_read);
const auto stick_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(stick_read);
ASSERT_TRUE(stick_reply.has_value());
EXPECT_EQ(stick_reply->at(13), 0x90);
EXPECT_EQ(stick_reply->at(14), 0x10);
@@ -200,7 +200,7 @@ TEST_F(WindowsDriverProtocolTest, SwitchProReturnsDeviceInfoAndFactoryCalibratio
auto imu_read = switch_output_report(0x01, 0x10);
write_u32(imu_read, 11U, 0x6020);
imu_read[15] = 24U;
const auto imu_reply = lvh::detail::windows::make_switch_pro_reply(imu_read);
const auto imu_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(imu_read);
ASSERT_TRUE(imu_reply.has_value());
EXPECT_TRUE(std::ranges::any_of(imu_reply->begin() + 20, imu_reply->begin() + 44, [](auto value) {
return value != 0U;
@@ -209,7 +209,7 @@ TEST_F(WindowsDriverProtocolTest, SwitchProReturnsDeviceInfoAndFactoryCalibratio
auto user_read = switch_output_report(0x01, 0x10);
write_u32(user_read, 11U, 0x8010);
user_read[15] = 22U;
const auto user_reply = lvh::detail::windows::make_switch_pro_reply(user_read);
const auto user_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(user_read);
ASSERT_TRUE(user_reply.has_value());
EXPECT_TRUE(std::ranges::all_of(user_reply->begin() + 20, user_reply->begin() + 42, [](auto value) {
return value == 0U;
+20 -1
View File
@@ -77,6 +77,8 @@ namespace lvh::tools::virtualhid_control {
return L"raw report";
case trigger_rumble:
return L"trigger rumble";
case player_leds:
return L"player leds";
}
return L"raw report";
}
@@ -223,7 +225,8 @@ namespace lvh::tools::virtualhid_control {
return supports_gamepad_output(profile, rumble) ||
supports_gamepad_output(profile, rgb_led) ||
supports_gamepad_output(profile, adaptive_triggers) ||
supports_gamepad_output(profile, trigger_rumble);
supports_gamepad_output(profile, trigger_rumble) ||
supports_gamepad_output(profile, player_leds);
}
std::wstring profile_feature_summary(const DeviceProfile &profile) {
@@ -235,6 +238,7 @@ namespace lvh::tools::virtualhid_control {
stream << L" | rumble " << yes_no(supports_gamepad_output(profile, rumble));
stream << L" | trigger rumble " << yes_no(supports_gamepad_output(profile, trigger_rumble));
stream << L" | RGB LED " << yes_no(supports_gamepad_output(profile, rgb_led));
stream << L" | player LEDs " << yes_no(supports_gamepad_output(profile, player_leds));
stream << L" | adaptive triggers " << yes_no(supports_gamepad_output(profile, adaptive_triggers));
stream << L" | raw output " << yes_no(supports_gamepad_output(profile, raw_report));
return stream.str();
@@ -272,6 +276,18 @@ namespace lvh::tools::virtualhid_control {
stream << L"adaptive flags=" << static_cast<unsigned>(state.latest_adaptive_triggers->adaptive_trigger_flags);
wrote = true;
}
if (state.latest_player_leds) {
append_summary_separator(stream, wrote);
stream << L"player LEDs solid=";
for (const auto active : state.latest_player_leds->player_leds) {
stream << (active ? L'1' : L'0');
}
stream << L" flashing=";
for (const auto active : state.latest_player_leds->flashing_player_leds) {
stream << (active ? L'1' : L'0');
}
wrote = true;
}
if (!wrote && state.latest_raw_report) {
stream << L"raw report";
wrote = true;
@@ -339,6 +355,9 @@ namespace lvh::tools::virtualhid_control {
case adaptive_triggers:
state.latest_adaptive_triggers = output;
break;
case player_leds:
state.latest_player_leds = output;
break;
case raw_report:
state.latest_raw_report = output;
break;
+1
View File
@@ -77,6 +77,7 @@ namespace lvh::tools::virtualhid_control {
std::optional<GamepadOutput> latest_trigger_rumble;
std::optional<GamepadOutput> latest_rgb_led;
std::optional<GamepadOutput> latest_adaptive_triggers;
std::optional<GamepadOutput> latest_player_leds;
std::optional<GamepadOutput> latest_raw_report;
};