From ed138c9fc9df410617a06e8522371d726246b56b Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Mon, 17 Aug 2026 17:55:17 -0400
Subject: [PATCH 1/6] feat(profiles): add Valve Steam Deck gamepad
Introduces a native Steam Deck controller profile (VID 0x28DE, PID 0x1205) across all supported backends:
- New `GamepadProfileKind::steam_deck` enum value and `profiles::steam_deck()` factory
- 64-byte vendor HID descriptor, native state packet packing (buttons, sticks, triggers, touch, motion)
- `SteamDeckFeatureReportState` shared helper for feature-report initialization (serial query, desktop-mapping commands, rumble via `0xEB`)
- Linux uhid backend: 4 ms periodic reports, SET/GET feature handling, serial seeding from stable_id
- Windows backend: periodic state re-submission thread, feature-report responder, VHF queue dedup on button bytes
- FreeBSD uses uinput path for Steam Deck (same as Xbox/Switch)
- Four rear paddles (L4/R4/L5/R5) exposed via `supported_rear_paddle_count = 4`, Quick Access mapped to `misc1`
- Protocol constant `LVH_WINDOWS_GAMEPAD_STEAM_DECK = 7`, broker validation updated
- Full unit and consumer tests on Linux, Windows, and FreeBSD backends
---
README.md | 5 +-
docs/platform-support.md | 25 +++-
docs/streaming-host-integration.md | 6 +-
docs/usage.md | 9 +-
docs/windows-driver.md | 5 +
examples/gamepad_adapter.cpp | 4 +
src/core/gamepad_adapter.cpp | 4 +
src/core/profiles.cpp | 67 ++++++++++
src/core/report.cpp | 126 ++++++++++++++++++
src/include/libvirtualhid/profiles.hpp | 7 +
src/include/libvirtualhid/types.hpp | 1 +
src/platform/linux/uhid_backend.cpp | 34 ++++-
.../broker/broker_request_validation.hpp | 2 +-
src/platform/windows/control_protocol.hpp | 2 +
.../windows/driver/libvirtualhid_umdf.cpp | 22 +++
.../windows/shared/lvh_windows_protocol.h | 4 +
.../windows/shared/vhf_input_report_queue.hpp | 3 +
src/platform/windows/windows_backend.cpp | 53 +++++++-
src/shared/steam_deck_feature_reports.hpp | 101 ++++++++++++++
.../fixtures/linux_backend_test_hooks.hpp | 17 +++
.../fixtures/windows_backend_test_hooks.hpp | 10 ++
tests/fixtures/linux_backend_test_hooks.cpp | 86 ++++++++++++
tests/fixtures/windows_backend_test_hooks.cpp | 27 ++++
tests/unit/test_freebsd_backend.cpp | 1 +
tests/unit/test_gamepad_adapter.cpp | 13 ++
tests/unit/test_linux_backend.cpp | 16 +++
tests/unit/test_linux_consumers.cpp | 25 +++-
tests/unit/test_profiles.cpp | 28 ++++
tests/unit/test_report.cpp | 68 ++++++++++
tests/unit/test_windows_backend.cpp | 10 ++
tests/unit/test_windows_consumers.cpp | 4 +-
tests/unit/test_windows_driver_protocol.cpp | 34 +++++
tests/unit/test_windows_protocol.cpp | 4 +
.../test_windows_vhf_input_report_queue.cpp | 2 +
tools/virtualhid_control_model.cpp | 2 +
tools/virtualhid_control_model.hpp | 1 +
36 files changed, 804 insertions(+), 24 deletions(-)
create mode 100644 src/shared/steam_deck_feature_reports.hpp
diff --git a/README.md b/README.md
index d30d303..53f756f 100644
--- a/README.md
+++ b/README.md
@@ -43,8 +43,8 @@ behind backend implementations.
## 🎮 Capabilities
- 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,
+ DualShock 4, DualSense, Nintendo Switch Pro-style, and Steam Deck controllers.
+- Descriptor-driven PlayStation and Steam Deck gamepads through Linux `uhid`; Generic, Xbox,
and Switch Pro gamepads plus keyboard, mouse, touchscreen, trackpad, and pen
tablet devices through `uinput`.
- Windows gamepads through a user-mode UMDF2 control driver backed by Virtual
@@ -149,6 +149,7 @@ Alternatives exist if `libvirtualhid` does not meet your needs.
| DualShock 4 gamepad | ✅ | ✅ | ✅ | ❌ | ✅4 |
| DualSense gamepad | ✅ | ❌ | ✅ | ✅ | ✅4 |
| Nintendo Switch Pro-style gamepad | ✅ | ❌ | ✅ | ✅ | ✅4 |
+| Steam Deck gamepad | ✅ | ❌ | ❌ | ❌ | ❌ |
| Rumble or output callbacks | ✅ | ❌ | ✅ | ✅ | ✅4 |
| Data-driven profiles | ❌ | ❌ | ✅ | ❌ | ❌ |
| Actively developed | ✅ | ❌ | ✅ | ✅ | ✅ |
diff --git a/docs/platform-support.md b/docs/platform-support.md
index af11b97..f1a69cc 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -75,7 +75,11 @@ 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 Steam Deck profile
+uses Valve's `0x28DE:0x1205` USB identity and native 64-byte state and feature
+reports. SDL/HIDAPI can therefore recognize it as a Steam Deck, disable its
+desktop mappings, and send native rumble without exposing Valve protocol details
+through the public C++ API.
See [Windows driver package](windows-driver.md) for build, install, validation,
and signing details.
@@ -84,7 +88,7 @@ and signing details.
The Linux backend uses standard user-space kernel interfaces:
-- `uhid` for descriptor-driven HID gamepads.
+- `uhid` for descriptor-driven HID gamepads, including Steam Deck.
- `uinput` for Generic, Xbox 360, Xbox One, Xbox Series, and Switch Pro
gamepads, plus keyboard, mouse, touchscreen, trackpad, and pen tablet
devices.
@@ -131,13 +135,20 @@ buttons, Guide, L3, and R3 at their expected indices. D-pad directions are
reported through the hat axes and exposed as logical buttons by standard
gamepad consumers.
-DualShock 4 and DualSense remain on `uhid` so their descriptors, motion,
+DualShock 4, DualSense, and Steam Deck remain on `uhid` so their descriptors, motion,
touchpad, battery, feature reports, and profile-specific output reports stay
available. The backend accepts PlayStation output through both UHID interrupt
and control channels. Numbered control-channel output is normalized before
parsing, whether the kernel includes the report number in the payload or
provides it separately on the UHID event.
+Steam Deck retains Valve's native USB identity and emits the 64-byte Deck state
+packet periodically so both the kernel `hid-steam` driver and SDL's direct
+HIDAPI path can initialize before the first client input arrives. The backend
+answers the unit-serial feature query used during Linux registration, accepts
+the desktop-mapping/settings commands used by SDL and `hid-steam`, and forwards
+native `0xEB` rumble requests through the portable output callback.
+
The backend opens `/dev/uhid` in nonblocking mode, matching the original
asynchronous gamepad registration path. Its event reader is active before
device registration begins, and creation does not report success until the
@@ -246,8 +257,8 @@ The FreeBSD backend uses the native evdev compatibility stack through
FreeBSD path, and `/dev/uinput` for environments that provide the Linux-style
alias. It supports the same uinput device categories as the Linux backend:
-- Generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Switch
- Pro gamepads.
+- Generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, Switch Pro,
+ and Steam Deck gamepads.
- Keyboard and mouse devices, with X11/XTest available as a fallback.
- Touchscreen, trackpad, and pen tablet devices.
@@ -259,8 +270,8 @@ with the kernel HID bus. FreeBSD CUSE applications such as
a `uhid(4)`-compatible character device for direct consumers, but that is a
different integration surface and is not used by the current backend.
-Generic, Xbox-family, Switch Pro, DualShock 4, and DualSense behavior therefore
-uses uinput. Ordinary buttons, sticks, analog triggers, and rumble are available,
+Generic, Xbox-family, Switch Pro, DualShock 4, DualSense, and Steam Deck behavior
+therefore uses uinput. Ordinary buttons, sticks, analog triggers, and rumble are available,
but raw HID reports and descriptor-driven features are not.
For each created gamepad, `Gamepad::profile()` reports the effective FreeBSD
diff --git a/docs/streaming-host-integration.md b/docs/streaming-host-integration.md
index 16d8c32..078acc5 100644
--- a/docs/streaming-host-integration.md
+++ b/docs/streaming-host-integration.md
@@ -51,9 +51,11 @@ 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 Steam Deck gamepads through `uhid`, Generic/Xbox/Switch
+ Pro gamepads through `uinput`, and `uinput` keyboard/pointer devices.
- Linux DualSense and DualShock 4 USB/Bluetooth report handling.
+- Native Steam Deck identity, input, feature-report initialization, touch/motion,
+ rear-button, Quick Access, and rumble handling on Windows and Linux.
- Linux touchscreen, trackpad, and pen tablet device types.
- FreeBSD uinput gamepads and pointer devices, with basic PlayStation input and
rumble but without Linux UHID-only PlayStation features.
diff --git a/docs/usage.md b/docs/usage.md
index 9de3c37..991d79a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -212,6 +212,7 @@ Built-in gamepad profiles and their platform-neutral default device names are:
| DualShock 4 USB and Bluetooth | `(libvirtualhid) PS4 Controller` |
| DualSense USB and Bluetooth | `(libvirtualhid) PS5 Controller` |
| Nintendo Switch Pro | `(libvirtualhid) Nintendo Pro Controller` |
+| Steam Deck | `(libvirtualhid) Steam Deck Controller` |
Consumers may replace `DeviceProfile::name` before creating a gamepad, for
example, to prepend an application name while preserving the default controller
@@ -226,6 +227,8 @@ Profiles advertise support for features such as rumble, trigger rumble, RGB
LEDs, adaptive triggers, motion sensors, touchpads, battery state,
profile-specific buttons, and raw output reports. Consumers should query profile and
backend capabilities before warning users about unsupported client features.
-The `misc1` button represents Share/Capture/Mic Mute-style controls and is
-available on the generic, Xbox Series, DualSense, and Switch Pro profiles; Xbox
-360 and Xbox One do not advertise that extra button.
+The `misc1` button represents Share/Capture/Mic Mute/Quick Access-style controls
+and is available on the generic, Xbox Series, DualSense, Switch Pro, and Steam
+Deck profiles; Xbox 360 and Xbox One do not advertise that extra button. Steam
+Deck also exposes its two trackpads through the two portable touch contacts and
+maps its L4/R4/L5/R5 rear controls to the four paddle buttons.
diff --git a/docs/windows-driver.md b/docs/windows-driver.md
index 4fdf57d..d8edb59 100644
--- a/docs/windows-driver.md
+++ b/docs/windows-driver.md
@@ -381,6 +381,11 @@ 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.
+Steam Deck uses Valve's `VID_28DE&PID_1205` identity, responds to the unnumbered
+feature-report sequence used by SDL/HIDAPI, submits native 64-byte Deck state
+reports, and normalizes native `0xEB` rumble requests into the public callback.
+The driver queues a neutral Deck state before starting VHF so already-running
+consumers can receive the first packet within SDL's short endpoint-probe window.
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
diff --git a/examples/gamepad_adapter.cpp b/examples/gamepad_adapter.cpp
index d34f694..6e66074 100644
--- a/examples/gamepad_adapter.cpp
+++ b/examples/gamepad_adapter.cpp
@@ -39,6 +39,9 @@ namespace {
if (name == "switch") {
return lvh::profiles::switch_pro();
}
+ if (name == "steamdeck") {
+ return lvh::profiles::steam_deck();
+ }
return std::nullopt;
}
@@ -58,6 +61,7 @@ namespace {
case switch_pro:
return nintendo;
case generic:
+ case steam_deck:
return unknown;
}
diff --git a/src/core/gamepad_adapter.cpp b/src/core/gamepad_adapter.cpp
index d7360e5..cb83feb 100644
--- a/src/core/gamepad_adapter.cpp
+++ b/src/core/gamepad_adapter.cpp
@@ -47,6 +47,7 @@ namespace lvh {
case xbox_series:
case dualsense:
case switch_pro:
+ case steam_deck:
return true;
case xbox_360:
case xbox_one:
@@ -88,6 +89,9 @@ namespace lvh {
support.supports_battery = profile.capabilities.supports_battery;
support.supports_misc1_button = supports_common_misc1_button(profile.gamepad_kind);
support.supports_touchpad_button = profile.capabilities.supports_touchpad;
+ if (profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ support.supported_rear_paddle_count = 4U;
+ }
return support;
}
diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp
index 598da10..fc56c75 100644
--- a/src/core/profiles.cpp
+++ b/src/core/profiles.cpp
@@ -41,6 +41,10 @@ namespace lvh::profiles {
constexpr std::size_t switch_pro_output_report_size = 64;
+ constexpr std::size_t steam_deck_input_report_size = 64;
+
+ constexpr std::size_t steam_deck_feature_report_size = 64;
+
constexpr std::size_t dualshock4_usb_input_report_size = 64;
constexpr std::size_t dualshock4_usb_output_report_size = 32;
@@ -269,6 +273,40 @@ namespace lvh::profiles {
return bytes_from_hex(descriptor);
}
+ std::vector make_steam_deck_report_descriptor() {
+ // The native Steam Deck controller endpoint carries an unnumbered
+ // 64-byte vendor input report and an unnumbered 64-byte feature report.
+ return {
+ 0x06,
+ 0x00,
+ 0xFF, // Usage Page (Vendor Defined 0xFF00)
+ 0x09,
+ 0x01, // Usage (Vendor Usage 1)
+ 0xA1,
+ 0x01, // Collection (Application)
+ 0x15,
+ 0x00, // Logical Minimum (0)
+ 0x26,
+ 0xFF,
+ 0x00, // Logical Maximum (255)
+ 0x75,
+ 0x08, // Report Size (8)
+ 0x95,
+ 0x40, // Report Count (64)
+ 0x09,
+ 0x01, // Usage (Vendor Usage 1)
+ 0x81,
+ 0x02, // Input (Data,Var,Abs)
+ 0x09,
+ 0x02, // Usage (Vendor Usage 2)
+ 0x95,
+ 0x40, // Report Count (64)
+ 0xB1,
+ 0x02, // Feature (Data,Var,Abs)
+ 0xC0, // End Collection
+ };
+ }
+
std::vector make_gamepad_report_descriptor(std::uint8_t report_id, bool supports_rumble) {
std::vector descriptor {
0x05,
@@ -2036,6 +2074,28 @@ namespace lvh::profiles {
return profile;
}
+ DeviceProfile make_steam_deck_profile() {
+ DeviceProfile profile;
+ profile.device_type = DeviceType::gamepad;
+ profile.gamepad_kind = GamepadProfileKind::steam_deck;
+ profile.bus_type = BusType::usb;
+ profile.vendor_id = 0x28DE;
+ profile.product_id = 0x1205;
+ profile.version = 0x0100;
+ profile.report_id = 0;
+ profile.input_report_size = steam_deck_input_report_size;
+ profile.output_report_size = steam_deck_feature_report_size;
+ profile.name = "(libvirtualhid) Steam Deck Controller";
+ profile.manufacturer = "Valve Software";
+ profile.capabilities = {
+ .supports_rumble = true,
+ .supports_motion = true,
+ .supports_touchpad = true,
+ };
+ profile.report_descriptor = make_steam_deck_report_descriptor();
+ return profile;
+ }
+
DeviceProfile make_simple_profile(DeviceType device_type, std::string name, std::uint16_t product_id) {
DeviceProfile profile;
profile.device_type = device_type;
@@ -2124,6 +2184,10 @@ namespace lvh::profiles {
return make_switch_pro_profile();
}
+ DeviceProfile steam_deck() {
+ return make_steam_deck_profile();
+ }
+
DeviceProfile keyboard() {
return make_simple_profile(DeviceType::keyboard, "libvirtualhid Keyboard", 0x0002);
}
@@ -2160,6 +2224,8 @@ namespace lvh::profiles {
return dualsense();
case GamepadProfileKind::switch_pro:
return switch_pro();
+ case GamepadProfileKind::steam_deck:
+ return steam_deck();
}
return std::nullopt;
@@ -2174,6 +2240,7 @@ namespace lvh::profiles {
dualshock4(),
dualsense(),
switch_pro(),
+ steam_deck(),
};
}
diff --git a/src/core/report.cpp b/src/core/report.cpp
index 318cf5c..744867d 100644
--- a/src/core/report.cpp
+++ b/src/core/report.cpp
@@ -67,6 +67,14 @@ namespace lvh::reports {
constexpr std::size_t switch_rumble_output_report_size = 10;
+ constexpr std::uint8_t steam_deck_input_report_version = 0x01;
+
+ constexpr std::uint8_t steam_deck_state_report_type = 0x09;
+
+ constexpr std::uint8_t steam_deck_rumble_feature_type = 0xEB;
+
+ constexpr std::size_t steam_deck_report_size = 64;
+
// 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
@@ -289,6 +297,16 @@ namespace lvh::reports {
return static_cast(std::lround((static_cast(value) / 255.0F) * 65535.0F));
}
+ std::optional steam_deck_feature_payload_offset(const std::vector &report) {
+ if (report.size() >= 9U && report[0] == steam_deck_rumble_feature_type) {
+ return 0U;
+ }
+ if (report.size() >= 10U && report[0] == 0U && report[1] == steam_deck_rumble_feature_type) {
+ return 1U;
+ }
+ return std::nullopt;
+ }
+
constexpr std::size_t pid_rumble_payload_size = 8;
constexpr std::size_t pid_rumble_report_size = pid_rumble_payload_size + 1U;
@@ -1060,6 +1078,100 @@ namespace lvh::reports {
return static_cast(std::lround((static_cast(battery->percentage) / 100.0F) * 255.0F));
}
+ std::vector pack_steam_deck_input_report(const DeviceProfile &profile, const GamepadState &state) {
+ if (profile.input_report_size < steam_deck_report_size) {
+ return {};
+ }
+
+ const auto normalized = normalize_state(state);
+ const auto acceleration = normalized.acceleration.value_or(Vector3 {.x = 0.0F, .y = 9.80665F, .z = 0.0F});
+ const auto gyroscope = normalized.gyroscope.value_or(Vector3 {});
+
+ ByteReport report(profile.input_report_size, zero_byte);
+ write_u16(report, 0U, steam_deck_input_report_version);
+ report[2] = to_byte(steam_deck_state_report_type);
+ report[3] = to_byte(static_cast(steam_deck_report_size));
+
+ std::uint32_t buttons_low = 0U;
+ std::uint32_t buttons_high = 0U;
+ const auto set_low = [&](GamepadButton button, std::uint32_t mask) {
+ if (normalized.buttons.test(button)) {
+ buttons_low |= mask;
+ }
+ };
+ const auto set_high = [&](GamepadButton button, std::uint32_t mask) {
+ if (normalized.buttons.test(button)) {
+ buttons_high |= mask;
+ }
+ };
+
+ set_low(GamepadButton::a, 0x00000080U);
+ set_low(GamepadButton::b, 0x00000020U);
+ set_low(GamepadButton::x, 0x00000040U);
+ set_low(GamepadButton::y, 0x00000010U);
+ set_low(GamepadButton::left_shoulder, 0x00000008U);
+ set_low(GamepadButton::right_shoulder, 0x00000004U);
+ set_low(GamepadButton::dpad_up, 0x00000100U);
+ set_low(GamepadButton::dpad_right, 0x00000200U);
+ set_low(GamepadButton::dpad_left, 0x00000400U);
+ set_low(GamepadButton::dpad_down, 0x00000800U);
+ set_low(GamepadButton::back, 0x00001000U);
+ set_low(GamepadButton::guide, 0x00002000U);
+ set_low(GamepadButton::start, 0x00004000U);
+ set_low(GamepadButton::left_stick, 0x00400000U);
+ set_low(GamepadButton::right_stick, 0x04000000U);
+ set_low(GamepadButton::touchpad, 0x00020000U); // Left trackpad click.
+ set_low(GamepadButton::paddle3, 0x00010000U); // R5.
+ set_low(GamepadButton::paddle4, 0x00008000U); // L5.
+ set_high(GamepadButton::misc1, 0x00040000U); // Quick Access Menu.
+ set_high(GamepadButton::paddle1, 0x00000400U); // R4.
+ set_high(GamepadButton::paddle2, 0x00000200U); // L4.
+
+ if (normalized.right_trigger > 0.0F) {
+ buttons_low |= 0x00000001U;
+ }
+ if (normalized.left_trigger > 0.0F) {
+ buttons_low |= 0x00000002U;
+ }
+ if (normalized.touchpad_contacts[0].active) {
+ buttons_low |= 0x00080000U;
+ }
+ if (normalized.touchpad_contacts[1].active) {
+ buttons_low |= 0x00100000U;
+ }
+
+ write_u32(report, 8U, buttons_low);
+ write_u32(report, 12U, buttons_high);
+
+ const auto write_touch_contact = [&](std::size_t offset, const GamepadTouchContact &contact) {
+ write_i16(report, offset, normalize_axis(contact.x * 2.0F - 1.0F));
+ write_i16(report, offset + 2U, normalize_axis(1.0F - contact.y * 2.0F));
+ };
+ write_touch_contact(16U, normalized.touchpad_contacts[0]);
+ write_touch_contact(20U, normalized.touchpad_contacts[1]);
+
+ constexpr auto acceleration_scale = 16384.0F / 9.80665F;
+ write_i16(report, 24U, scale_i16(acceleration.x, acceleration_scale));
+ write_i16(report, 26U, scale_i16(-acceleration.z, acceleration_scale));
+ write_i16(report, 28U, scale_i16(acceleration.y, acceleration_scale));
+
+ constexpr auto gyroscope_scale = 32768.0F / 2000.0F;
+ write_i16(report, 30U, scale_i16(gyroscope.x, gyroscope_scale));
+ write_i16(report, 32U, scale_i16(-gyroscope.z, gyroscope_scale));
+ write_i16(report, 34U, scale_i16(gyroscope.y, gyroscope_scale));
+
+ write_u16(report, 44U, static_cast(std::lround(normalized.left_trigger * 32767.0F)));
+ write_u16(report, 46U, static_cast(std::lround(normalized.right_trigger * 32767.0F)));
+ write_i16(report, 48U, normalize_axis(normalized.left_stick.x));
+ write_i16(report, 50U, normalize_axis(normalized.left_stick.y));
+ write_i16(report, 52U, normalize_axis(normalized.right_stick.x));
+ write_i16(report, 54U, normalize_axis(normalized.right_stick.y));
+ write_u16(report, 56U, normalized.touchpad_contacts[0].active ? 0x7FFFU : 0U);
+ write_u16(report, 58U, normalized.touchpad_contacts[1].active ? 0x7FFFU : 0U);
+
+ return to_uint8_report(report);
+ }
+
std::uint16_t dpad_hat_bits(const ButtonSet &buttons) {
const auto hat = to_byte(hat_from_buttons(buttons));
return static_cast(std::to_integer(hat) << 12U);
@@ -1213,6 +1325,8 @@ namespace lvh::reports {
return pack_dualsense_input_report(profile, state);
case switch_pro:
return pack_switch_pro_input_report(profile, state);
+ case steam_deck:
+ return pack_steam_deck_input_report(profile, state);
case generic:
return pack_standard_gamepad_input_report(profile, state);
case xbox_360:
@@ -1287,6 +1401,18 @@ namespace lvh::reports {
}
}
+ if (profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ if (const auto offset = steam_deck_feature_payload_offset(report); offset.has_value()) {
+ GamepadOutput output;
+ output.kind = GamepadOutputKind::rumble;
+ output.low_frequency_rumble = read_u16(report, *offset + 5U);
+ output.high_frequency_rumble = read_u16(report, *offset + 7U);
+ output.raw_report = report;
+ outputs.push_back(std::move(output));
+ return outputs;
+ }
+ }
+
if (const auto rumble = decode_pid_rumble_report(profile, report); rumble.has_value()) {
GamepadOutput motor_output;
motor_output.kind = GamepadOutputKind::rumble;
diff --git a/src/include/libvirtualhid/profiles.hpp b/src/include/libvirtualhid/profiles.hpp
index 0ca6a4e..b71d089 100644
--- a/src/include/libvirtualhid/profiles.hpp
+++ b/src/include/libvirtualhid/profiles.hpp
@@ -90,6 +90,13 @@ namespace lvh::profiles {
*/
DeviceProfile switch_pro();
+ /**
+ * @brief Create the Valve Steam Deck-compatible gamepad profile.
+ *
+ * @return Steam Deck-compatible device profile.
+ */
+ DeviceProfile steam_deck();
+
/**
* @brief Create the generic keyboard profile.
*
diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp
index 260fca5..1f3e563 100644
--- a/src/include/libvirtualhid/types.hpp
+++ b/src/include/libvirtualhid/types.hpp
@@ -235,6 +235,7 @@ namespace lvh {
dualsense, ///< PlayStation DualSense-compatible profile.
switch_pro, ///< Nintendo Switch Pro-compatible profile.
dualshock4, ///< PlayStation DualShock 4-compatible profile.
+ steam_deck, ///< Valve Steam Deck-compatible profile.
};
/**
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index d64f5ab..ff37d89 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -65,6 +65,7 @@
#include "core/backend.hpp"
#if defined(__linux__)
#include "shared/playstation_feature_reports.hpp"
+ #include "shared/steam_deck_feature_reports.hpp"
#endif
#include
@@ -238,6 +239,16 @@ namespace lvh::detail {
bool is_playstation_profile(GamepadProfileKind kind) {
return kind == GamepadProfileKind::dualshock4 || kind == GamepadProfileKind::dualsense;
}
+
+ bool uses_periodic_uhid_reports(GamepadProfileKind kind) {
+ return is_playstation_profile(kind) || kind == GamepadProfileKind::steam_deck;
+ }
+
+ std::chrono::milliseconds uhid_periodic_report_interval(GamepadProfileKind kind) {
+ return std::chrono::milliseconds {
+ kind == GamepadProfileKind::steam_deck ? 4 : playstation_periodic_report_ms
+ };
+ }
#endif
bool uses_uinput_gamepad_profile(GamepadProfileKind kind) {
@@ -250,6 +261,12 @@ namespace lvh::detail {
case xbox_series:
case switch_pro:
return true;
+ case steam_deck:
+#if defined(__FreeBSD__)
+ return true;
+#else
+ return false;
+#endif
case dualshock4:
case dualsense:
#if defined(__FreeBSD__)
@@ -268,6 +285,7 @@ namespace lvh::detail {
case generic:
case xbox_series:
+ case steam_deck:
#if defined(__FreeBSD__)
case dualsense:
#endif
@@ -314,6 +332,9 @@ namespace lvh::detail {
if (is_playstation_profile(profile.gamepad_kind)) {
return "Wireless Controller";
}
+ if (profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ return "Steam Deck Controller";
+ }
return profile.name;
}
#endif
@@ -2814,6 +2835,7 @@ namespace lvh::detail {
request.version = options.profile.version;
std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size());
profile_ = options.profile;
+ steam_deck_feature_state_.set_serial(unique_id_);
{
std::lock_guard lock {report_mutex_};
last_report_ = reports::pack_input_report(profile_, {});
@@ -2839,7 +2861,7 @@ namespace lvh::detail {
return status;
}
- if (is_playstation_profile(profile_.gamepad_kind)) {
+ if (uses_periodic_uhid_reports(profile_.gamepad_kind)) {
periodic_reporter_ = std::jthread {[this](std::stop_token stop_token) {
periodic_report_loop(stop_token);
}};
@@ -3037,7 +3059,7 @@ namespace lvh::detail {
void periodic_report_loop(std::stop_token stop_token) {
while (!stop_token.stop_requested() && running_) {
- std::this_thread::sleep_for(std::chrono::milliseconds {playstation_periodic_report_ms});
+ std::this_thread::sleep_for(uhid_periodic_report_interval(profile_.gamepad_kind));
if (stop_token.stop_requested() || !running_ || !open_) {
break;
}
@@ -3065,6 +3087,9 @@ namespace lvh::detail {
if (report_number != 0 && (report.empty() || report.front() != report_number)) {
report.insert(report.begin(), report_number);
}
+ if (profile_.gamepad_kind == GamepadProfileKind::steam_deck) {
+ static_cast(steam_deck_feature_state_.handle_set_feature(report));
+ }
dispatch_output_report(report);
}
@@ -3137,6 +3162,10 @@ namespace lvh::detail {
event.u.get_report_reply.err = EINVAL;
break;
}
+ } else if (profile_.gamepad_kind == GamepadProfileKind::steam_deck && report_number == 0U) {
+ event.u.get_report_reply.err = 0;
+ const auto report = steam_deck_feature_state_.get_feature_report();
+ copy_get_report_payload(event, report);
}
if (
@@ -3173,6 +3202,7 @@ namespace lvh::detail {
std::string physical_id_;
std::string unique_id_;
std::array playstation_mac_address_ {};
+ SteamDeckFeatureReportState steam_deck_feature_state_;
std::vector last_report_;
std::atomic_bool open_ = true;
std::atomic_bool running_ = false;
diff --git a/src/platform/windows/broker/broker_request_validation.hpp b/src/platform/windows/broker/broker_request_validation.hpp
index 155c97f..9a0da1f 100644
--- a/src/platform/windows/broker/broker_request_validation.hpp
+++ b/src/platform/windows/broker/broker_request_validation.hpp
@@ -80,7 +80,7 @@ namespace lvh::windows::broker_validation {
const auto known_bus = request.bus_type == LVH_WINDOWS_BUS_UNKNOWN ||
request.bus_type == LVH_WINDOWS_BUS_USB ||
request.bus_type == LVH_WINDOWS_BUS_BLUETOOTH;
- const auto known_profile = request.gamepad_kind <= LVH_WINDOWS_GAMEPAD_DUALSHOCK4;
+ const auto known_profile = request.gamepad_kind <= LVH_WINDOWS_GAMEPAD_STEAM_DECK;
return request.version == LVH_WINDOWS_CONTROL_PROTOCOL_VERSION &&
request.size == sizeof(request) &&
diff --git a/src/platform/windows/control_protocol.hpp b/src/platform/windows/control_protocol.hpp
index c401022..f960fa5 100644
--- a/src/platform/windows/control_protocol.hpp
+++ b/src/platform/windows/control_protocol.hpp
@@ -89,6 +89,8 @@ namespace lvh::detail::windows {
return LVH_WINDOWS_GAMEPAD_DUALSENSE;
case switch_pro:
return LVH_WINDOWS_GAMEPAD_SWITCH_PRO;
+ case steam_deck:
+ return LVH_WINDOWS_GAMEPAD_STEAM_DECK;
}
return LVH_WINDOWS_GAMEPAD_GENERIC;
diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp
index 76e3b84..7f31ea3 100644
--- a/src/platform/windows/driver/libvirtualhid_umdf.cpp
+++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp
@@ -55,6 +55,7 @@
#include "lvh_windows_protocol.h"
#include "playstation_feature_protocol.hpp"
#include "rotating_trace_log.hpp"
+#include "shared/steam_deck_feature_reports.hpp"
#include "switch_pro_protocol.hpp"
#include "unique_win32_handle.hpp"
#include "vhf_input_report_queue.hpp"
@@ -95,6 +96,9 @@ namespace {
create_request.bus_type,
create_request.hardware_ids.report_id,
} {
+ steam_deck_feature_state.set_serial(
+ std::string_view {create_request.stable_id.data(), create_request.report_sizes.stable_id_size}
+ );
}
std::mutex mutex;
@@ -109,6 +113,7 @@ namespace {
std::vector report_descriptor;
std::wstring hardware_ids;
lvh::detail::windows::GenericPidFeatureState generic_pid_feature_state;
+ lvh::detail::SteamDeckFeatureReportState steam_deck_feature_state;
lvh::detail::windows::VhfInputReportQueue pending_input_reports;
std::shared_ptr> in_flight_input_report;
std::size_t active_input_submissions {};
@@ -555,6 +560,15 @@ namespace {
return status;
}
+ if (record->request.gamepad_kind == LVH_WINDOWS_GAMEPAD_STEAM_DECK) {
+ status = queue_vhf_input_report(*record, lvh::detail::make_steam_deck_neutral_input_report());
+ trace_status("create_vhf_device queue Steam Deck initial report", status);
+ if (!NT_SUCCESS(status)) {
+ delete_vhf_device(record);
+ return status;
+ }
+ }
+
status = VhfStart(record->vhf_handle);
trace_status("create_vhf_device VhfStart", status);
if (!NT_SUCCESS(status)) {
@@ -846,6 +860,9 @@ namespace {
if (record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_GENERIC) {
std::lock_guard lock {record.mutex};
report = record.generic_pid_feature_state.get_feature_report(report_number);
+ } else if (record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_STEAM_DECK && report_number == 0U) {
+ std::lock_guard lock {record.mutex};
+ report = record.steam_deck_feature_state.get_feature_report();
} else {
report = lvh::detail::windows::make_playstation_feature_report(record.request, report_number);
}
@@ -871,6 +888,11 @@ namespace {
{event.report.data(), event.report_size}
);
}
+ if (record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_STEAM_DECK) {
+ const auto event = make_output_event(record, packet);
+ std::lock_guard lock {record.mutex};
+ return record.steam_deck_feature_state.handle_set_feature({event.report.data(), event.report_size});
+ }
return lvh::detail::windows::is_playstation_gamepad(record.request.gamepad_kind);
}
diff --git a/src/platform/windows/shared/lvh_windows_protocol.h b/src/platform/windows/shared/lvh_windows_protocol.h
index f2a826b..bb821eb 100644
--- a/src/platform/windows/shared/lvh_windows_protocol.h
+++ b/src/platform/windows/shared/lvh_windows_protocol.h
@@ -95,6 +95,7 @@ enum class LvhWindowsGamepadProfileKind : uint32_t {
dualsense = 4,
switch_pro = 5,
dualshock4 = 6,
+ steam_deck = 7,
};
namespace lvh_windows_protocol_detail {
@@ -122,6 +123,7 @@ namespace lvh_windows_protocol_detail {
inline constexpr uint32_t gamepad_dualsense = to_uint32(dualsense);
inline constexpr uint32_t gamepad_switch_pro = to_uint32(switch_pro);
inline constexpr uint32_t gamepad_dualshock4 = to_uint32(dualshock4);
+ inline constexpr uint32_t gamepad_steam_deck = to_uint32(steam_deck);
} // namespace lvh_windows_protocol_detail
inline constexpr uint32_t LVH_WINDOWS_STATUS_SUCCESS = lvh_windows_protocol_detail::status_success;
@@ -144,6 +146,8 @@ inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_DUALSENSE = lvh_windows_protocol_d
inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_SWITCH_PRO = lvh_windows_protocol_detail::gamepad_switch_pro;
inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_DUALSHOCK4 =
lvh_windows_protocol_detail::gamepad_dualshock4;
+inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_STEAM_DECK =
+ lvh_windows_protocol_detail::gamepad_steam_deck;
#pragma pack(push, 1)
diff --git a/src/platform/windows/shared/vhf_input_report_queue.hpp b/src/platform/windows/shared/vhf_input_report_queue.hpp
index ee3d48e..a610d91 100644
--- a/src/platform/windows/shared/vhf_input_report_queue.hpp
+++ b/src/platform/windows/shared/vhf_input_report_queue.hpp
@@ -137,6 +137,9 @@ namespace lvh::detail::windows {
if (gamepad_kind_ == LVH_WINDOWS_GAMEPAD_SWITCH_PRO) {
return equal_at(left, right, {0U, 3U, 4U, 5U});
}
+ if (gamepad_kind_ == LVH_WINDOWS_GAMEPAD_STEAM_DECK) {
+ return equal_at(left, right, {8U, 9U, 10U, 11U, 12U, 13U, 14U, 15U});
+ }
const auto is_bluetooth = bus_type_ == LVH_WINDOWS_BUS_BLUETOOTH;
if (gamepad_kind_ == LVH_WINDOWS_GAMEPAD_DUALSHOCK4) {
diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp
index d129fa4..083e613 100644
--- a/src/platform/windows/windows_backend.cpp
+++ b/src/platform/windows/windows_backend.cpp
@@ -932,6 +932,7 @@ namespace lvh::detail {
LvhWindowsSessionToken token {};
DeviceProfile profile;
std::string path;
+ std::vector last_input_report;
bool open = true;
OutputCallback output_callback;
bool uses_generic_pid = false;
@@ -942,7 +943,17 @@ namespace lvh::detail {
public:
WindowsGamepad(std::shared_ptr context, std::shared_ptr state):
context_ {std::move(context)},
- state_ {std::move(state)} {}
+ state_ {std::move(state)} {
+ if (state_->profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ periodic_reporter_ = std::jthread {[this](std::stop_token stop_token) {
+ periodic_report_loop(stop_token);
+ }};
+ }
+ }
+
+ ~WindowsGamepad() override {
+ static_cast(close());
+ }
OperationStatus submit(
const GamepadState &state,
@@ -953,8 +964,11 @@ namespace lvh::detail {
OperationStatus close() override;
private:
+ void periodic_report_loop(std::stop_token stop_token);
+
std::shared_ptr context_;
std::shared_ptr state_;
+ std::jthread periodic_reporter_;
};
class WindowsBackendContext: public std::enable_shared_from_this {
@@ -1224,6 +1238,7 @@ namespace lvh::detail {
) {
using enum ErrorCode;
+ auto submitted_report = report;
{
std::lock_guard lock {state_->mutex_};
if (!state_->open) {
@@ -1235,11 +1250,39 @@ namespace lvh::detail {
}
if (state_->uses_generic_pid) {
- return context_->submit_gamepad_report(state_, windows::make_generic_windows_input_report(report));
+ submitted_report = windows::make_generic_windows_input_report(report);
}
}
- return context_->submit_gamepad_report(state_, report);
+ auto status = context_->submit_gamepad_report(state_, submitted_report);
+ if (status.ok() && state_->profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ std::lock_guard lock {state_->mutex_};
+ state_->last_input_report = std::move(submitted_report);
+ }
+ return status;
+ }
+
+ void WindowsGamepad::periodic_report_loop(std::stop_token stop_token) {
+ using namespace std::chrono_literals;
+
+ while (!stop_token.stop_requested()) {
+ std::this_thread::sleep_for(4ms);
+ if (stop_token.stop_requested()) {
+ break;
+ }
+
+ std::vector report;
+ {
+ std::lock_guard lock {state_->mutex_};
+ if (!state_->open) {
+ break;
+ }
+ report = state_->last_input_report;
+ }
+ if (!report.empty()) {
+ static_cast(context_->submit_gamepad_report(state_, report));
+ }
+ }
}
void WindowsGamepad::set_output_callback(OutputCallback callback) {
@@ -1257,6 +1300,10 @@ namespace lvh::detail {
}
OperationStatus WindowsGamepad::close() {
+ if (periodic_reporter_.joinable()) {
+ periodic_reporter_.request_stop();
+ periodic_reporter_.join();
+ }
return context_->close_gamepad(state_);
}
diff --git a/src/shared/steam_deck_feature_reports.hpp b/src/shared/steam_deck_feature_reports.hpp
new file mode 100644
index 0000000..2bb9e5a
--- /dev/null
+++ b/src/shared/steam_deck_feature_reports.hpp
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC
+// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0
+
+/**
+ * @file src/shared/steam_deck_feature_reports.hpp
+ * @brief Valve Steam Deck feature-report state shared by descriptor-driven backends.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace lvh::detail {
+
+ inline constexpr std::size_t steam_deck_input_report_size = 64U;
+
+ inline constexpr std::size_t steam_deck_feature_report_size = 64U;
+
+ /**
+ * @brief Create the native neutral report queued before VHF enumeration.
+ *
+ * SDL probes a newly arrived Steam Deck endpoint by waiting only 16 ms for
+ * its first state packet. Queueing this report before VhfStart lets an
+ * already-running consumer complete that probe while the create request is
+ * still in progress.
+ */
+ inline std::vector make_steam_deck_neutral_input_report() {
+ auto report = std::vector(steam_deck_input_report_size, 0U);
+ report[0] = 0x01U; // Valve input report version 1 (little endian).
+ report[2] = 0x09U; // ID_CONTROLLER_DECK_STATE.
+ report[3] = static_cast(steam_deck_input_report_size);
+ return report;
+ }
+
+ /**
+ * @brief Minimal native Steam Deck feature-report responder.
+ *
+ * Steam and SDL send Valve feature commands to disable the controller's
+ * desktop mappings before consuming native state reports. A virtual device
+ * has no lizard mode to change, but it must accept those commands and return
+ * a feature payload. The Linux hid-steam driver additionally requests a
+ * stable unit serial number during registration.
+ */
+ class SteamDeckFeatureReportState {
+ public:
+ explicit SteamDeckFeatureReportState(std::string_view serial = "libvirtualhid") {
+ set_serial(serial);
+ }
+
+ void set_serial(std::string_view serial) {
+ constexpr std::size_t maximum_serial_size = 20U;
+ serial_size_ = std::min(serial.size(), maximum_serial_size);
+ std::ranges::fill(serial_, '\0');
+ std::copy_n(serial.begin(), serial_size_, serial_.begin());
+ }
+
+ bool handle_set_feature(std::span report) {
+ constexpr std::uint8_t get_string_attribute = 0xAE;
+ constexpr std::uint8_t unit_serial_attribute = 0x01;
+
+ if (report.size() > 1U && report[0] == 0U && report[1] >= 0x80U) {
+ report = report.subspan(1U);
+ }
+ if (report.empty()) {
+ return false;
+ }
+
+ reply_.fill(0U);
+ std::copy_n(report.begin(), std::min(report.size(), reply_.size()), reply_.begin());
+ if (report[0] == get_string_attribute && report.size() >= 3U && report[2] == unit_serial_attribute) {
+ reply_.fill(0U);
+ reply_[0] = get_string_attribute;
+ reply_[1] = static_cast(serial_size_ + 1U);
+ reply_[2] = unit_serial_attribute;
+ std::copy_n(serial_.begin(), serial_size_, reply_.begin() + 3U);
+ }
+
+ return true;
+ }
+
+ [[nodiscard]] std::vector get_feature_report() const {
+ // HID feature APIs carry the unnumbered report ID as a leading zero,
+ // followed by the controller's 64-byte Valve payload.
+ auto report = std::vector(steam_deck_feature_report_size + 1U, 0U);
+ std::copy(reply_.begin(), reply_.end(), report.begin() + 1U);
+ return report;
+ }
+
+ private:
+ std::array serial_ {};
+ std::size_t serial_size_ {};
+ std::array reply_ {};
+ };
+
+} // namespace lvh::detail
diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
index d52fafd..7872e5a 100644
--- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
@@ -226,6 +226,16 @@ namespace lvh::detail::test {
*/
bool saw_dualshock4_usb_input = false;
+ /**
+ * @brief Whether the peer observed a native Steam Deck input report.
+ */
+ bool saw_steam_deck_input = false;
+
+ /**
+ * @brief Whether the peer observed the Steam Deck unit-serial feature reply.
+ */
+ bool saw_steam_deck_serial = false;
+
/**
* @brief Whether the peer observed a set-report reply.
*/
@@ -831,6 +841,13 @@ namespace lvh::detail::test {
*/
LinuxUhidRoundTripResult linux_dualsense_uhid_socketpair_reports();
+ /**
+ * @brief Exercise Steam Deck UHID input, feature, and rumble reports over a socketpair.
+ *
+ * @return Round-trip result with native Deck protocol observations.
+ */
+ LinuxUhidRoundTripResult linux_steam_deck_uhid_socketpair_reports();
+
/**
* @brief Exercise Bluetooth DualSense UHID framing and signed feature replies over a socketpair.
*
diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
index 4967caa..05b7333 100644
--- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
@@ -51,6 +51,14 @@ namespace lvh::detail::test {
std::vector strengths;
};
+ struct WindowsSteamDeckPeriodicReportResult {
+ OperationStatus create_status;
+ OperationStatus submit_status;
+ OperationStatus close_status;
+ std::size_t reports_before_close = 0;
+ std::size_t reports_after_close = 0;
+ };
+
struct WindowsBackendUtilityResult {
std::vector default_device_paths;
std::vector custom_device_paths;
@@ -164,6 +172,8 @@ namespace lvh::detail::test {
};
WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle();
+
+ WindowsSteamDeckPeriodicReportResult windows_backend_steam_deck_periodic_reports();
WindowsGenericPidOrderingResult windows_backend_generic_pid_callback_ordering();
WindowsBackendFailureResult windows_backend_fake_channel_failures();
WindowsBackendUtilityResult windows_backend_fake_channel_utilities();
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index d9b0f31..c65a286 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -1680,6 +1680,92 @@ namespace lvh::detail::test {
return result;
}
+ LinuxUhidRoundTripResult linux_steam_deck_uhid_socketpair_reports() {
+ LinuxUhidRoundTripResult result;
+ std::array descriptors {-1, -1};
+ if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors.data()) != 0) {
+ result.create_status = system_error_status(ErrorCode::backend_failure, "failed to create socketpair", errno);
+ result.submit_status = result.create_status;
+ result.close_status = result.create_status;
+ return result;
+ }
+
+ CreateGamepadOptions options;
+ options.profile = profiles::steam_deck();
+ options.metadata.stable_id = "linux-steam-deck";
+
+ UhidGamepad gamepad {descriptors[0]};
+ auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_USB, result);
+ gamepad.set_output_callback([&result](const GamepadOutput &output) {
+ if (output.kind == GamepadOutputKind::rumble) {
+ ++result.output.callback_count;
+ result.output.last = output;
+ }
+ });
+
+ if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
+ result.saw_steam_deck_input = event.u.input2.size == 64U &&
+ event.u.input2.data[0] == 0x01U &&
+ event.u.input2.data[1] == 0x00U &&
+ event.u.input2.data[2] == 0x09U &&
+ event.u.input2.data[3] == 64U;
+ }
+
+ event = {};
+ event.type = UHID_SET_REPORT;
+ event.u.set_report.id = 30;
+ event.u.set_report.rnum = 0;
+ event.u.set_report.rtype = UHID_FEATURE_REPORT;
+ event.u.set_report.size = 64U;
+ event.u.set_report.data[0] = 0xAEU;
+ event.u.set_report.data[1] = 0x15U;
+ event.u.set_report.data[2] = 0x01U;
+ static_cast(write_uhid_event(descriptors[1], event));
+ if (read_uhid_event_type(descriptors[1], UHID_SET_REPORT_REPLY, event)) {
+ result.saw_set_report_reply = event.u.set_report_reply.id == 30 && event.u.set_report_reply.err == 0;
+ }
+
+ event = {};
+ event.type = UHID_GET_REPORT;
+ event.u.get_report.id = 31;
+ event.u.get_report.rnum = 0;
+ event.u.get_report.rtype = UHID_FEATURE_REPORT;
+ static_cast(write_uhid_event(descriptors[1], event));
+ if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
+ constexpr std::string_view expected_serial = "linux-steam-deck";
+ result.saw_steam_deck_serial = event.u.get_report_reply.err == 0 &&
+ event.u.get_report_reply.size == 65U &&
+ event.u.get_report_reply.data[0] == 0U &&
+ event.u.get_report_reply.data[1] == 0xAEU &&
+ event.u.get_report_reply.data[2] == expected_serial.size() + 1U &&
+ event.u.get_report_reply.data[3] == 0x01U &&
+ std::equal(
+ expected_serial.begin(),
+ expected_serial.end(),
+ event.u.get_report_reply.data + 4U
+ );
+ }
+
+ event = {};
+ event.type = UHID_SET_REPORT;
+ event.u.set_report.id = 32;
+ event.u.set_report.rnum = 0;
+ event.u.set_report.rtype = UHID_FEATURE_REPORT;
+ event.u.set_report.size = 64U;
+ event.u.set_report.data[0] = 0xEBU;
+ event.u.set_report.data[5] = 0x78U;
+ event.u.set_report.data[6] = 0x56U;
+ event.u.set_report.data[7] = 0x34U;
+ event.u.set_report.data[8] = 0x12U;
+ static_cast(write_uhid_event(descriptors[1], event));
+ static_cast(read_uhid_event_type(descriptors[1], UHID_SET_REPORT_REPLY, event));
+
+ result.close_status = gamepad.close();
+ static_cast(::close(descriptors[1]));
+ result.submit_status = OperationStatus::success();
+ return result;
+ }
+
LinuxUhidRoundTripResult linux_dualsense_bluetooth_uhid_socketpair_reports() {
LinuxUhidRoundTripResult result;
std::array descriptors {-1, -1};
diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp
index e7381f6..77376a9 100644
--- a/tests/fixtures/windows_backend_test_hooks.cpp
+++ b/tests/fixtures/windows_backend_test_hooks.cpp
@@ -494,6 +494,33 @@ namespace lvh::detail {
return result;
}
+ WindowsSteamDeckPeriodicReportResult windows_backend_steam_deck_periodic_reports() {
+ WindowsSteamDeckPeriodicReportResult result;
+ auto command_state = std::make_shared();
+ auto event_state = std::make_shared();
+ auto backend = make_fake_windows_backend(command_state, event_state);
+
+ CreateGamepadOptions options;
+ options.profile = profiles::steam_deck();
+ auto created = backend->create_gamepad(31, options);
+ result.create_status = created.status;
+ if (!created) {
+ return result;
+ }
+
+ const auto report = reports::pack_input_report(options.profile, {});
+ result.submit_status = created.gamepad->submit({}, report);
+ static_cast(wait_until([&command_state] {
+ return command_state->submit_report_count() >= 2U;
+ }));
+
+ result.close_status = created.gamepad->close();
+ result.reports_before_close = command_state->submit_report_count();
+ std::this_thread::sleep_for(std::chrono::milliseconds {12});
+ result.reports_after_close = command_state->submit_report_count();
+ return result;
+ }
+
WindowsGenericPidOrderingResult windows_backend_generic_pid_callback_ordering() {
WindowsGenericPidOrderingResult result;
auto command_state = std::make_shared();
diff --git a/tests/unit/test_freebsd_backend.cpp b/tests/unit/test_freebsd_backend.cpp
index 37391d3..3e5f01b 100644
--- a/tests/unit/test_freebsd_backend.cpp
+++ b/tests/unit/test_freebsd_backend.cpp
@@ -64,6 +64,7 @@ TEST(FreeBsdBackendTest, CreatesEveryGamepadWithTheExpectedPlayStationSubset) {
lvh::profiles::dualshock4(),
lvh::profiles::dualsense(),
lvh::profiles::switch_pro(),
+ lvh::profiles::steam_deck(),
};
for (const auto &profile : profiles) {
diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp
index b94d1fe..2bd21af 100644
--- a/tests/unit/test_gamepad_adapter.cpp
+++ b/tests/unit/test_gamepad_adapter.cpp
@@ -19,6 +19,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) {
const auto dualshock4 = lvh::profiles::dualshock4();
const auto dualsense = lvh::profiles::dualsense();
const auto switch_pro = lvh::profiles::switch_pro();
+ const auto steam_deck = lvh::profiles::steam_deck();
const auto keyboard = lvh::profiles::keyboard();
const auto generic_support = lvh::gamepad_profile_support(generic);
@@ -56,6 +57,14 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) {
EXPECT_TRUE(switch_pro_support.supports_battery);
EXPECT_TRUE(switch_pro_support.supports_misc1_button);
+ const auto steam_deck_support = lvh::gamepad_profile_support(steam_deck);
+ EXPECT_TRUE(steam_deck_support.supports_rumble);
+ EXPECT_TRUE(steam_deck_support.supports_motion);
+ EXPECT_TRUE(steam_deck_support.supports_touchpad);
+ EXPECT_TRUE(steam_deck_support.supports_touchpad_button);
+ EXPECT_TRUE(steam_deck_support.supports_misc1_button);
+ EXPECT_EQ(steam_deck_support.supported_rear_paddle_count, 4U);
+
const auto keyboard_support = lvh::gamepad_profile_support(keyboard);
EXPECT_FALSE(keyboard_support.supports_rumble);
EXPECT_FALSE(keyboard_support.supports_motion);
@@ -74,6 +83,7 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) {
const auto dualshock4 = lvh::profiles::dualshock4();
const auto dualsense = lvh::profiles::dualsense();
const auto switch_pro = lvh::profiles::switch_pro();
+ const auto steam_deck = lvh::profiles::steam_deck();
const auto keyboard = lvh::profiles::keyboard();
EXPECT_TRUE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::guide));
@@ -98,6 +108,9 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) {
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::raw_report));
+ EXPECT_TRUE(lvh::supports_gamepad_button(steam_deck, lvh::GamepadButton::touchpad));
+ EXPECT_TRUE(lvh::supports_gamepad_button(steam_deck, lvh::GamepadButton::paddle4));
+ EXPECT_TRUE(lvh::supports_gamepad_output(steam_deck, lvh::GamepadOutputKind::rumble));
EXPECT_TRUE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report));
EXPECT_FALSE(lvh::supports_gamepad_output(keyboard, lvh::GamepadOutputKind::rumble));
EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast(255)));
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index 9342c2f..70bdfe6 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -819,6 +819,22 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {
EXPECT_EQ(result.output.last.high_frequency_rumble, 0x1212);
}
+TEST_F(LinuxBackendTest, SocketpairBackedSteamDeckUsesNativeProtocol) {
+ const auto result = lvh::detail::test::linux_steam_deck_uhid_socketpair_reports();
+ EXPECT_TRUE(result.create_status.ok()) << result.create_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, "Steam Deck Controller");
+ EXPECT_TRUE(result.saw_steam_deck_input);
+ EXPECT_TRUE(result.saw_steam_deck_serial);
+ EXPECT_TRUE(result.saw_set_report_reply);
+ ASSERT_EQ(result.output.callback_count, 1U);
+ EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::rumble);
+ EXPECT_EQ(result.output.last.low_frequency_rumble, 0x5678U);
+ EXPECT_EQ(result.output.last.high_frequency_rumble, 0x1234U);
+}
+
TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) {
const auto result = lvh::detail::test::linux_dualsense_bluetooth_uhid_socketpair_reports();
EXPECT_TRUE(result.create_status.ok()) << result.create_status.message();
diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp
index 675fa4f..89d220e 100644
--- a/tests/unit/test_linux_consumers.cpp
+++ b/tests/unit/test_linux_consumers.cpp
@@ -54,6 +54,7 @@ namespace {
using LibinputEvent = std::unique_ptr;
using SdlGameController = std::unique_ptr;
constexpr std::string_view playstation_uhid_name = "Wireless Controller";
+ constexpr std::string_view steam_deck_uhid_name = "Steam Deck Controller";
/**
* @brief SDL-visible gamepad case.
@@ -479,6 +480,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_STEAMDECK", "1");
}
lvh::GamepadCreationResult create_sdl_gamepad(lvh::Runtime &runtime, const SdlGamepadConsumerCase &test_case) {
@@ -505,9 +507,13 @@ namespace {
const auto expected_profile = [&test_case]() {
auto profile = test_case.profile;
- profile.name = is_playstation_profile(profile.gamepad_kind) ?
- std::string {playstation_uhid_name} :
- unique_device_name(test_case.name_suffix);
+ if (is_playstation_profile(profile.gamepad_kind)) {
+ profile.name = playstation_uhid_name;
+ } else if (profile.gamepad_kind == lvh::GamepadProfileKind::steam_deck) {
+ profile.name = steam_deck_uhid_name;
+ } else {
+ profile.name = unique_device_name(test_case.name_suffix);
+ }
if (test_case.expected_vendor_id.has_value()) {
profile.vendor_id = *test_case.expected_vendor_id;
}
@@ -862,6 +868,19 @@ TEST_F(LinuxConsumerTest, SdlSeesSwitchProCanonicalButtons) {
});
}
+TEST_F(LinuxConsumerTest, SdlSeesSteamDeckCanonicalButtonsAndRumble) {
+ ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));
+
+ run_sdl_canonical_gamepad_test({
+ .profile = lvh::profiles::steam_deck(),
+ .name_suffix = "SDL Steam Deck",
+ .stable_id = "libvirtualhid-sdl-steam-deck-test",
+ .minimum_buttons = 15,
+ .minimum_axes = 6,
+ .require_sdl_rumble = true,
+ });
+}
+
TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) {
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));
diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp
index 040f0bd..e9aa15d 100644
--- a/tests/unit/test_profiles.cpp
+++ b/tests/unit/test_profiles.cpp
@@ -85,6 +85,7 @@ TEST(ProfileTest, BuiltInProfilesUseDefaultDeviceNames) {
EXPECT_EQ(lvh::profiles::dualshock4().name, "(libvirtualhid) PS4 Controller");
EXPECT_EQ(lvh::profiles::dualsense().name, "(libvirtualhid) PS5 Controller");
EXPECT_EQ(lvh::profiles::switch_pro().name, "(libvirtualhid) Nintendo Pro Controller");
+ EXPECT_EQ(lvh::profiles::steam_deck().name, "(libvirtualhid) Steam Deck Controller");
}
TEST(ProfileTest, StreamingControllerProfilesArePresent) {
@@ -92,6 +93,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
const auto dualshock4 = lvh::profiles::dualshock4();
const auto dualsense = lvh::profiles::dualsense();
const auto switch_pro = lvh::profiles::switch_pro();
+ const auto steam_deck = lvh::profiles::steam_deck();
EXPECT_EQ(xbox_one.vendor_id, 0x045E);
EXPECT_EQ(xbox_one.product_id, 0x02EA);
@@ -222,6 +224,24 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
EXPECT_TRUE(switch_pro.capabilities.supports_motion);
EXPECT_TRUE(switch_pro.capabilities.supports_battery);
+ EXPECT_EQ(steam_deck.vendor_id, 0x28DE);
+ EXPECT_EQ(steam_deck.product_id, 0x1205);
+ EXPECT_EQ(steam_deck.version, 0x0100);
+ EXPECT_EQ(steam_deck.bus_type, lvh::BusType::usb);
+ EXPECT_EQ(steam_deck.manufacturer, "Valve Software");
+ EXPECT_EQ(steam_deck.report_id, 0U);
+ EXPECT_EQ(steam_deck.input_report_size, 64U);
+ EXPECT_EQ(steam_deck.output_report_size, 64U);
+ EXPECT_TRUE(steam_deck.capabilities.supports_rumble);
+ EXPECT_TRUE(steam_deck.capabilities.supports_motion);
+ EXPECT_TRUE(steam_deck.capabilities.supports_touchpad);
+
+ constexpr std::array steam_deck_input_descriptor {0x75, 0x08, 0x95, 0x40, 0x09, 0x01};
+ constexpr std::array steam_deck_feature_descriptor {0x09, 0x02, 0x95, 0x40, 0xB1, 0x02};
+ expect_descriptor_contains(steam_deck, steam_deck_input_descriptor);
+ expect_descriptor_contains(steam_deck, steam_deck_feature_descriptor);
+ EXPECT_EQ(std::ranges::find(steam_deck.report_descriptor, 0x85), steam_deck.report_descriptor.end());
+
const auto generic = lvh::profiles::generic_gamepad();
const std::array standard_button_descriptor {
0x05,
@@ -512,6 +532,14 @@ TEST(ProfileTest, CanFindProfileByKind) {
EXPECT_EQ(profile->gamepad_kind, lvh::GamepadProfileKind::xbox_series);
}
+TEST(ProfileTest, CanFindSteamDeckProfileByKind) {
+ const auto profile = lvh::profiles::gamepad_profile(lvh::GamepadProfileKind::steam_deck);
+
+ ASSERT_TRUE(profile.has_value());
+ EXPECT_EQ(profile->vendor_id, 0x28DE);
+ EXPECT_EQ(profile->product_id, 0x1205);
+}
+
TEST(ProfileTest, PointerProfilesArePresent) {
const auto keyboard = lvh::profiles::keyboard();
const auto mouse = lvh::profiles::mouse();
diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp
index a8f3037..8da353e 100644
--- a/tests/unit/test_report.cpp
+++ b/tests/unit/test_report.cpp
@@ -191,6 +191,51 @@ TEST(ReportTest, PacksSwitchProReport) {
EXPECT_EQ(report[11], 0x40U);
}
+TEST(ReportTest, PacksSteamDeckNativeReport) {
+ using enum lvh::GamepadButton;
+
+ const auto profile = lvh::profiles::steam_deck();
+ lvh::GamepadState state;
+ for (const auto button : {a, b, x, y, left_shoulder, right_shoulder, dpad_up, dpad_right, dpad_left, dpad_down, back, guide, start, left_stick, right_stick, touchpad, misc1, paddle1, paddle2, paddle3, paddle4}) {
+ state.buttons.set(button);
+ }
+ state.left_stick = {1.0F, -1.0F};
+ state.right_stick = {0.5F, -0.5F};
+ state.left_trigger = 0.25F;
+ state.right_trigger = 1.0F;
+ state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 0.0F, .z = -9.80665F};
+ state.gyroscope = lvh::Vector3 {.x = 1000.0F, .y = -500.0F, .z = 250.0F};
+ state.touchpad_contacts[0] = {.id = 1U, .active = true, .x = 0.75F, .y = 0.25F};
+ state.touchpad_contacts[1] = {.id = 2U, .active = true, .x = 0.25F, .y = 0.75F};
+
+ const auto report = lvh::reports::pack_input_report(profile, state);
+
+ ASSERT_EQ(report.size(), 64U);
+ EXPECT_EQ(read_u16_le(report, 0U), 1U);
+ EXPECT_EQ(report[2], 9U);
+ EXPECT_EQ(report[3], 64U);
+ EXPECT_EQ(read_u32_le(report, 8U), 0x045BFFFFU);
+ EXPECT_EQ(read_u32_le(report, 12U), 0x00040600U);
+ EXPECT_EQ(read_u16_le(report, 16U), 0x4000U);
+ EXPECT_EQ(read_u16_le(report, 18U), 0x4000U);
+ EXPECT_EQ(read_u16_le(report, 20U), 0xC000U);
+ EXPECT_EQ(read_u16_le(report, 22U), 0xC000U);
+ EXPECT_EQ(read_u16_le(report, 24U), 0x4000U);
+ EXPECT_EQ(read_u16_le(report, 26U), 0x4000U);
+ EXPECT_EQ(read_u16_le(report, 28U), 0U);
+ EXPECT_EQ(read_u16_le(report, 30U), 0x4000U);
+ EXPECT_EQ(read_u16_le(report, 32U), 0xF000U);
+ EXPECT_EQ(read_u16_le(report, 34U), 0xE000U);
+ EXPECT_EQ(read_u16_le(report, 44U), 8192U);
+ EXPECT_EQ(read_u16_le(report, 46U), 32767U);
+ EXPECT_EQ(read_u16_le(report, 48U), 0x7FFFU);
+ EXPECT_EQ(read_u16_le(report, 50U), 0x8000U);
+ EXPECT_EQ(read_u16_le(report, 52U), 0x4000U);
+ EXPECT_EQ(read_u16_le(report, 54U), 0xC000U);
+ EXPECT_EQ(read_u16_le(report, 56U), 0x7FFFU);
+ EXPECT_EQ(read_u16_le(report, 58U), 0x7FFFU);
+}
+
TEST(ReportTest, PacksXboxGipNeutralReport) {
const auto profile = lvh::profiles::xbox_series();
@@ -349,6 +394,29 @@ TEST(ReportTest, ParsesRumbleOutputReport) {
EXPECT_EQ(output.raw_report, report);
}
+TEST(ReportTest, ParsesSteamDeckRumbleFeatureReport) {
+ const auto profile = lvh::profiles::steam_deck();
+ std::vector report(profile.output_report_size, 0U);
+ report[0] = 0xEBU;
+ report[5] = 0x34U;
+ report[6] = 0x12U;
+ report[7] = 0xCDU;
+ report[8] = 0xABU;
+
+ const auto output = lvh::reports::parse_output_report(profile, report);
+
+ EXPECT_EQ(output.kind, lvh::GamepadOutputKind::rumble);
+ EXPECT_EQ(output.low_frequency_rumble, 0x1234U);
+ EXPECT_EQ(output.high_frequency_rumble, 0xABCDU);
+ EXPECT_EQ(output.raw_report, report);
+
+ report.insert(report.begin(), 0U);
+ const auto report_id_prefixed_output = lvh::reports::parse_output_report(profile, report);
+ EXPECT_EQ(report_id_prefixed_output.kind, lvh::GamepadOutputKind::rumble);
+ EXPECT_EQ(report_id_prefixed_output.low_frequency_rumble, 0x1234U);
+ EXPECT_EQ(report_id_prefixed_output.high_frequency_rumble, 0xABCDU);
+}
+
TEST(ReportTest, ParsesPidRumbleReports) {
const auto expect_outputs = [](const lvh::DeviceProfile &profile, const std::vector &report) {
const auto outputs = lvh::reports::parse_output_reports(profile, report);
diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp
index 993a710..4020ecd 100644
--- a/tests/unit/test_windows_backend.cpp
+++ b/tests/unit/test_windows_backend.cpp
@@ -94,6 +94,16 @@ TEST_F(WindowsBackendTest, FakeChannelExercisesLifecycleSubmitCloseAndOutput) {
EXPECT_EQ(result.last_output.raw_report[0], 0x03U);
}
+TEST_F(WindowsBackendTest, SteamDeckRepeatsNativeStateUntilClose) {
+ const auto result = lvh::detail::test::windows_backend_steam_deck_periodic_reports();
+
+ ASSERT_TRUE(result.create_status.ok()) << result.create_status.message();
+ ASSERT_TRUE(result.submit_status.ok()) << result.submit_status.message();
+ ASSERT_TRUE(result.close_status.ok()) << result.close_status.message();
+ EXPECT_GE(result.reports_before_close, 2U);
+ EXPECT_EQ(result.reports_after_close, result.reports_before_close);
+}
+
TEST_F(WindowsBackendTest, GenericPidTimerCannotDeliverStaleStopAfterNewStart) {
const auto result = lvh::detail::test::windows_backend_generic_pid_callback_ordering();
diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp
index a451e05..ac7424a 100644
--- a/tests/unit/test_windows_consumers.cpp
+++ b/tests/unit/test_windows_consumers.cpp
@@ -387,6 +387,7 @@ namespace {
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5, "1");
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAMDECK, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_ENHANCED_REPORTS, "1");
initialized_ = SDL_Init(SDL_INIT_GAMEPAD | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS);
}
@@ -450,7 +451,7 @@ namespace {
} // namespace
#if defined(LIBVIRTUALHID_TEST_HAS_SDL3)
-TEST_F(WindowsConsumerTest, SdlHidapiRumbleReachesPlayStationAndSwitchCallbacks) {
+TEST_F(WindowsConsumerTest, SdlHidapiRumbleReachesNativeControllerCallbacks) {
SdlGamepadSubsystem sdl;
ASSERT_TRUE(sdl.initialized()) << SDL_GetError();
@@ -465,6 +466,7 @@ TEST_F(WindowsConsumerTest, SdlHidapiRumbleReachesPlayStationAndSwitchCallbacks)
lvh::profiles::dualshock4_usb(),
lvh::profiles::dualsense_usb(),
lvh::profiles::switch_pro(),
+ lvh::profiles::steam_deck(),
};
for (const auto &profile : profiles) {
SCOPED_TRACE(profile.name);
diff --git a/tests/unit/test_windows_driver_protocol.cpp b/tests/unit/test_windows_driver_protocol.cpp
index 0b3bbea..410750e 100644
--- a/tests/unit/test_windows_driver_protocol.cpp
+++ b/tests/unit/test_windows_driver_protocol.cpp
@@ -6,6 +6,7 @@
// local includes
#include "fixtures/fixtures.hpp"
#include "playstation_feature_protocol.hpp"
+#include "shared/steam_deck_feature_reports.hpp"
#include "switch_pro_protocol.hpp"
#include "windows_device_identity.hpp"
@@ -278,3 +279,36 @@ TEST_F(WindowsDriverProtocolTest, BluetoothPlayStationFeaturesCarryCrcAndGenerat
return value != 0U;
}));
}
+
+TEST_F(WindowsDriverProtocolTest, RespondsToSteamDeckFeatureReports) {
+ lvh::detail::SteamDeckFeatureReportState state {"deck-serial"};
+ const std::array serial_request {0xAEU, 0x15U, 0x01U};
+
+ EXPECT_TRUE(state.handle_set_feature(serial_request));
+ const auto serial_reply = state.get_feature_report();
+ ASSERT_EQ(serial_reply.size(), lvh::detail::steam_deck_feature_report_size + 1U);
+ EXPECT_EQ(serial_reply[0], 0U);
+ EXPECT_EQ(serial_reply[1], 0xAEU);
+ EXPECT_EQ(serial_reply[2], 12U);
+ EXPECT_EQ(serial_reply[3], 0x01U);
+ EXPECT_EQ(std::string(serial_reply.begin() + 4U, serial_reply.begin() + 15U), "deck-serial");
+
+ std::array report_id_prefixed_request {};
+ report_id_prefixed_request[1] = 0x81U;
+ EXPECT_TRUE(state.handle_set_feature(report_id_prefixed_request));
+ EXPECT_EQ(state.get_feature_report()[1], 0x81U);
+ EXPECT_FALSE(state.handle_set_feature({}));
+}
+
+TEST_F(WindowsDriverProtocolTest, QueuesNativeSteamDeckStateForImmediateConsumerDiscovery) {
+ const auto report = lvh::detail::make_steam_deck_neutral_input_report();
+
+ ASSERT_EQ(report.size(), lvh::detail::steam_deck_input_report_size);
+ EXPECT_EQ(report[0], 0x01U);
+ EXPECT_EQ(report[1], 0x00U);
+ EXPECT_EQ(report[2], 0x09U);
+ EXPECT_EQ(report[3], 64U);
+ EXPECT_TRUE(std::ranges::all_of(report.begin() + 4U, report.end(), [](const auto value) {
+ return value == 0U;
+ }));
+}
diff --git a/tests/unit/test_windows_protocol.cpp b/tests/unit/test_windows_protocol.cpp
index 3f60f00..2f67a00 100644
--- a/tests/unit/test_windows_protocol.cpp
+++ b/tests/unit/test_windows_protocol.cpp
@@ -112,6 +112,10 @@ TEST(WindowsProtocolTest, MapsBusTypesAndGamepadKinds) {
lvh::detail::windows::protocol_gamepad_kind(lvh::GamepadProfileKind::switch_pro),
LVH_WINDOWS_GAMEPAD_SWITCH_PRO
);
+ EXPECT_EQ(
+ lvh::detail::windows::protocol_gamepad_kind(lvh::GamepadProfileKind::steam_deck),
+ LVH_WINDOWS_GAMEPAD_STEAM_DECK
+ );
EXPECT_EQ(
lvh::detail::windows::protocol_gamepad_kind(static_cast(255)),
LVH_WINDOWS_GAMEPAD_GENERIC
diff --git a/tests/unit/test_windows_vhf_input_report_queue.cpp b/tests/unit/test_windows_vhf_input_report_queue.cpp
index 716e7c9..bb4419c 100644
--- a/tests/unit/test_windows_vhf_input_report_queue.cpp
+++ b/tests/unit/test_windows_vhf_input_report_queue.cpp
@@ -82,6 +82,7 @@ namespace {
ProfileCase {lvh::profiles::xbox_one(), LVH_WINDOWS_GAMEPAD_XBOX_ONE, LVH_WINDOWS_BUS_USB, false},
ProfileCase {lvh::profiles::xbox_series(), LVH_WINDOWS_GAMEPAD_XBOX_SERIES, LVH_WINDOWS_BUS_USB, false},
ProfileCase {lvh::profiles::switch_pro(), LVH_WINDOWS_GAMEPAD_SWITCH_PRO, LVH_WINDOWS_BUS_USB, false},
+ ProfileCase {lvh::profiles::steam_deck(), LVH_WINDOWS_GAMEPAD_STEAM_DECK, LVH_WINDOWS_BUS_USB, true},
ProfileCase {lvh::profiles::dualshock4_usb(), LVH_WINDOWS_GAMEPAD_DUALSHOCK4, LVH_WINDOWS_BUS_USB, true},
ProfileCase {lvh::profiles::dualshock4_bluetooth(), LVH_WINDOWS_GAMEPAD_DUALSHOCK4, LVH_WINDOWS_BUS_BLUETOOTH, true},
ProfileCase {lvh::profiles::dualsense_usb(), LVH_WINDOWS_GAMEPAD_DUALSENSE, LVH_WINDOWS_BUS_USB, true},
@@ -142,6 +143,7 @@ namespace {
TriggerCase {lvh::profiles::xbox_one(), LVH_WINDOWS_GAMEPAD_XBOX_ONE, LVH_WINDOWS_BUS_USB, false},
TriggerCase {lvh::profiles::xbox_series(), LVH_WINDOWS_GAMEPAD_XBOX_SERIES, LVH_WINDOWS_BUS_USB, false},
TriggerCase {lvh::profiles::switch_pro(), LVH_WINDOWS_GAMEPAD_SWITCH_PRO, LVH_WINDOWS_BUS_USB, true},
+ TriggerCase {lvh::profiles::steam_deck(), LVH_WINDOWS_GAMEPAD_STEAM_DECK, LVH_WINDOWS_BUS_USB, true},
TriggerCase {lvh::profiles::dualshock4_usb(), LVH_WINDOWS_GAMEPAD_DUALSHOCK4, LVH_WINDOWS_BUS_USB, true},
TriggerCase {lvh::profiles::dualshock4_bluetooth(), LVH_WINDOWS_GAMEPAD_DUALSHOCK4, LVH_WINDOWS_BUS_BLUETOOTH, true},
TriggerCase {lvh::profiles::dualsense_usb(), LVH_WINDOWS_GAMEPAD_DUALSENSE, LVH_WINDOWS_BUS_USB, true},
diff --git a/tools/virtualhid_control_model.cpp b/tools/virtualhid_control_model.cpp
index d5eb8af..db1bb6c 100644
--- a/tools/virtualhid_control_model.cpp
+++ b/tools/virtualhid_control_model.cpp
@@ -139,6 +139,8 @@ namespace lvh::tools::virtualhid_control {
return profiles::dualsense();
case switch_pro:
return profiles::switch_pro();
+ case steam_deck:
+ return profiles::steam_deck();
}
return std::nullopt;
}
diff --git a/tools/virtualhid_control_model.hpp b/tools/virtualhid_control_model.hpp
index 502a160..9f8db8e 100644
--- a/tools/virtualhid_control_model.hpp
+++ b/tools/virtualhid_control_model.hpp
@@ -67,6 +67,7 @@ namespace lvh::tools::virtualhid_control {
ProfileChoice {L"ds4", L"DualShock 4", GamepadProfileKind::dualshock4, ClientControllerType::playstation},
ProfileChoice {L"ds5", L"DualSense", GamepadProfileKind::dualsense, ClientControllerType::playstation},
ProfileChoice {L"switch", L"Switch Pro", GamepadProfileKind::switch_pro, ClientControllerType::nintendo},
+ ProfileChoice {L"steamdeck", L"Steam Deck", GamepadProfileKind::steam_deck, ClientControllerType::unknown},
};
inline constexpr std::array button_choices {
From 78b26b1db863f73c4467bf3ef38f19e72a23ae3f Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:29:40 -0400
Subject: [PATCH 2/6] fix(steam-deck): restore native consumer discovery
---
docs/platform-support.md | 25 ++-
docs/windows-driver.md | 10 +-
src/core/profiles.cpp | 191 ++++++++++++++++--
src/platform/linux/uhid_backend.cpp | 27 ++-
.../windows/driver/libvirtualhid_umdf.cpp | 8 +-
src/platform/windows/windows_backend.cpp | 35 ++--
src/shared/steam_deck_feature_reports.hpp | 30 ++-
.../fixtures/windows_backend_test_hooks.hpp | 2 +
tests/fixtures/linux_backend_test_hooks.cpp | 2 +-
tests/fixtures/windows_backend_test_hooks.cpp | 37 +++-
tests/unit/test_linux_backend.cpp | 1 +
tests/unit/test_profiles.cpp | 26 ++-
tests/unit/test_windows_backend.cpp | 4 +-
tests/unit/test_windows_driver_protocol.cpp | 11 +-
14 files changed, 355 insertions(+), 54 deletions(-)
diff --git a/docs/platform-support.md b/docs/platform-support.md
index f1a69cc..d817eae 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -77,9 +77,14 @@ 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. The Steam Deck profile
uses Valve's `0x28DE:0x1205` USB identity and native 64-byte state and feature
-reports. SDL/HIDAPI can therefore recognize it as a Steam Deck, disable its
+reports. Its single top-level collection is identified as a Generic Desktop
+Game Pad while retaining the Valve-native report bytes, so Windows Game
+Controllers and generic HID clients can enumerate it even if the Valve-specific
+path is unavailable. SDL/HIDAPI can recognize it as a Steam Deck, disable its
desktop mappings, and send native rumble without exposing Valve protocol details
-through the public C++ API.
+through the public C++ API. A neutral state is submitted continuously from the
+moment the backend is created, and each packet carries an advancing native
+sequence number.
See [Windows driver package](windows-driver.md) for build, install, validation,
and signing details.
@@ -142,12 +147,16 @@ and control channels. Numbered control-channel output is normalized before
parsing, whether the kernel includes the report number in the payload or
provides it separately on the UHID event.
-Steam Deck retains Valve's native USB identity and emits the 64-byte Deck state
-packet periodically so both the kernel `hid-steam` driver and SDL's direct
-HIDAPI path can initialize before the first client input arrives. The backend
-answers the unit-serial feature query used during Linux registration, accepts
-the desktop-mapping/settings commands used by SDL and `hid-steam`, and forwards
-native `0xEB` rumble requests through the portable output callback.
+Steam Deck retains Valve's native identity and emits the 64-byte Deck state
+packet periodically so SDL's direct HIDAPI path can initialize before the first
+client input arrives. Its UHID endpoint uses the virtual bus, preventing the
+hardware-specific `hid-steam` driver from suppressing input behind the physical
+Deck's lizard-mode gate; the Generic Desktop/Game Pad descriptor remains
+available as an evdev fallback. The backend answers the unit-serial feature
+query used during Linux registration, accepts the desktop-mapping/settings
+commands used by SDL, and forwards native `0xEB` rumble requests through the
+portable output callback. Each submitted native packet carries an advancing
+sequence number.
The backend opens `/dev/uhid` in nonblocking mode, matching the original
asynchronous gamepad registration path. Its event reader is active before
diff --git a/docs/windows-driver.md b/docs/windows-driver.md
index d8edb59..99c5649 100644
--- a/docs/windows-driver.md
+++ b/docs/windows-driver.md
@@ -384,8 +384,14 @@ the native USB and subcommand handshake and submits native `0x30` input reports.
Steam Deck uses Valve's `VID_28DE&PID_1205` identity, responds to the unnumbered
feature-report sequence used by SDL/HIDAPI, submits native 64-byte Deck state
reports, and normalizes native `0xEB` rumble requests into the public callback.
-The driver queues a neutral Deck state before starting VHF so already-running
-consumers can receive the first packet within SDL's short endpoint-probe window.
+The same native report is described by a Generic Desktop/Game Pad top-level
+collection, allowing Windows' Game Controllers control panel and generic HID
+clients to classify the VHF child as a gamepad. The backend begins sending a
+sequenced neutral Deck state immediately after creation and repeats the latest
+state every four milliseconds, keeping a packet available throughout SDL's
+short endpoint-probe window. The driver also queues a neutral state before
+starting VHF and handles Deck feature requests as unnumbered reports regardless
+of the initial contents of the transfer buffer.
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
diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp
index fc56c75..c6d8499 100644
--- a/src/core/profiles.cpp
+++ b/src/core/profiles.cpp
@@ -274,33 +274,198 @@ namespace lvh::profiles {
}
std::vector make_steam_deck_report_descriptor() {
- // The native Steam Deck controller endpoint carries an unnumbered
- // 64-byte vendor input report and an unnumbered 64-byte feature report.
+ // Keep Valve's unnumbered 64-byte native packet layout, but expose its
+ // controls from a Generic Desktop/Game Pad top-level collection. This
+ // lets generic HID consumers (including the Windows game-controller
+ // control panel) classify the endpoint when Valve-specific HIDAPI
+ // handling is unavailable.
return {
- 0x06,
- 0x00,
- 0xFF, // Usage Page (Vendor Defined 0xFF00)
+ 0x05,
+ 0x01, // Usage Page (Generic Desktop)
0x09,
- 0x01, // Usage (Vendor Usage 1)
+ 0x05, // Usage (Game Pad)
0xA1,
0x01, // Collection (Application)
+
+ // Native header and packet sequence (bytes 0-7).
+ 0x75,
+ 0x08, // Report Size (8)
+ 0x95,
+ 0x08, // Report Count (8)
+ 0x81,
+ 0x03, // Input (Const,Var,Abs)
+
+ // Native button bitfields (bytes 8-15). Constants preserve every
+ // native bit offset while named Button usages provide a generic view.
+ 0x05,
+ 0x09, // Usage Page (Button)
+ 0x75,
+ 0x01, // Report Size (1)
+ 0x95,
+ 0x02,
+ 0x81,
+ 0x03, // Input (Const,Var,Abs)
+ 0x09,
+ 0x06, // Right shoulder
+ 0x09,
+ 0x05, // Left shoulder
+ 0x09,
+ 0x04, // Y
+ 0x09,
+ 0x02, // B
+ 0x09,
+ 0x03, // X
+ 0x09,
+ 0x01, // A
+ 0x95,
+ 0x06,
+ 0x81,
+ 0x02, // Input (Data,Var,Abs)
+ 0x09,
+ 0x0C, // D-pad up
+ 0x09,
+ 0x0F, // D-pad right
+ 0x09,
+ 0x0E, // D-pad left
+ 0x09,
+ 0x0D, // D-pad down
+ 0x09,
+ 0x07, // Back
+ 0x09,
+ 0x0B, // Guide
+ 0x09,
+ 0x08, // Start
+ 0x09,
+ 0x14, // L5
+ 0x95,
+ 0x08,
+ 0x81,
+ 0x02,
+ 0x09,
+ 0x13, // R5
+ 0x09,
+ 0x10, // Left pad click
+ 0x95,
+ 0x02,
+ 0x81,
+ 0x02,
+ 0x95,
+ 0x04,
+ 0x81,
+ 0x03,
+ 0x09,
+ 0x09, // Left stick click
+ 0x95,
+ 0x01,
+ 0x81,
+ 0x02,
+ 0x95,
+ 0x03,
+ 0x81,
+ 0x03,
+ 0x09,
+ 0x0A, // Right stick click
+ 0x95,
+ 0x01,
+ 0x81,
+ 0x02,
+ 0x95,
+ 0x0E,
+ 0x81,
+ 0x03,
+ 0x09,
+ 0x12, // L4
+ 0x09,
+ 0x11, // R4
+ 0x95,
+ 0x02,
+ 0x81,
+ 0x02,
+ 0x95,
+ 0x07,
+ 0x81,
+ 0x03,
+ 0x09,
+ 0x15, // Quick Access
+ 0x95,
+ 0x01,
+ 0x81,
+ 0x02,
+ 0x95,
+ 0x0D,
+ 0x81,
+ 0x03,
+
+ // Native touch and motion fields (bytes 16-43).
+ 0x75,
+ 0x08,
+ 0x95,
+ 0x1C,
+ 0x81,
+ 0x03,
+
+ // Full-range native trigger values (bytes 44-47).
+ 0x05,
+ 0x01, // Usage Page (Generic Desktop)
0x15,
0x00, // Logical Minimum (0)
0x26,
0xFF,
- 0x00, // Logical Maximum (255)
+ 0x7F, // Logical Maximum (32767)
0x75,
- 0x08, // Report Size (8)
+ 0x10, // Report Size (16)
0x95,
- 0x40, // Report Count (64)
+ 0x02,
0x09,
- 0x01, // Usage (Vendor Usage 1)
+ 0x32, // Usage (Z)
+ 0x09,
+ 0x35, // Usage (Rz)
0x81,
- 0x02, // Input (Data,Var,Abs)
+ 0x02,
+
+ // Native signed stick values (bytes 48-55).
+ 0x16,
+ 0x00,
+ 0x80, // Logical Minimum (-32768)
+ 0x26,
+ 0xFF,
+ 0x7F, // Logical Maximum (32767)
+ 0x95,
+ 0x04,
0x09,
- 0x02, // Usage (Vendor Usage 2)
+ 0x30, // Usage (X)
+ 0x09,
+ 0x31, // Usage (Y)
+ 0x09,
+ 0x33, // Usage (Rx)
+ 0x09,
+ 0x34, // Usage (Ry)
+ 0x81,
+ 0x02,
+
+ // Native pressure values and spare bytes (bytes 56-63).
+ 0x75,
+ 0x08,
0x95,
- 0x40, // Report Count (64)
+ 0x08,
+ 0x81,
+ 0x03,
+
+ // Valve's unnumbered 64-byte feature report.
+ 0x06,
+ 0x00,
+ 0xFF, // Usage Page (Vendor Defined 0xFF00)
+ 0x15,
+ 0x00,
+ 0x26,
+ 0xFF,
+ 0x00,
+ 0x75,
+ 0x08,
+ 0x95,
+ 0x40,
+ 0x09,
+ 0x02, // Usage (Vendor Usage 2)
0xB1,
0x02, // Feature (Data,Var,Abs)
0xC0, // End Collection
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index ff37d89..07109b0 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -319,7 +319,13 @@ namespace lvh::detail {
#if defined(__linux__)
std::uint16_t to_uhid_bus(const DeviceProfile &profile) {
- if (profile.gamepad_kind == GamepadProfileKind::switch_pro) {
+ if (
+ profile.gamepad_kind == GamepadProfileKind::switch_pro ||
+ profile.gamepad_kind == GamepadProfileKind::steam_deck
+ ) {
+ // Prevent hardware-specific kernel drivers from taking over these
+ // virtual endpoints. In particular, hid-steam suppresses Steam Deck
+ // gamepad input while its hardware-only lizard-mode gate is active.
return BUS_VIRTUAL;
}
return to_uhid_bus(profile.bus_type);
@@ -2873,22 +2879,31 @@ namespace lvh::detail {
const GamepadState & /*state*/,
const std::vector &report
) override {
+ std::lock_guard submit_lock {submit_mutex_};
if (!open_) {
return OperationStatus::failure(ErrorCode::device_closed, "UHID gamepad is closed");
}
+ auto submitted_report = report;
+ if (profile_.gamepad_kind == GamepadProfileKind::steam_deck) {
+ static_cast(stamp_steam_deck_packet_number(
+ submitted_report,
+ steam_deck_packet_number_.fetch_add(1U) + 1U
+ ));
+ }
+
uhid_event event {};
- if (report.size() > sizeof(event.u.input2.data)) {
+ if (submitted_report.size() > sizeof(event.u.input2.data)) {
return OperationStatus::failure(ErrorCode::invalid_argument, "HID input report is too large for UHID");
}
event.type = UHID_INPUT2;
- event.u.input2.size = static_cast(report.size());
- std::memcpy(event.u.input2.data, report.data(), report.size());
+ event.u.input2.size = static_cast(submitted_report.size());
+ std::memcpy(event.u.input2.data, submitted_report.data(), submitted_report.size());
auto status = write_event(event);
if (status.ok()) {
std::lock_guard lock {report_mutex_};
- last_report_ = report;
+ last_report_ = std::move(submitted_report);
}
return status;
}
@@ -3204,10 +3219,12 @@ namespace lvh::detail {
std::array playstation_mac_address_ {};
SteamDeckFeatureReportState steam_deck_feature_state_;
std::vector last_report_;
+ std::atomic_uint32_t steam_deck_packet_number_ = 0;
std::atomic_bool open_ = true;
std::atomic_bool running_ = false;
std::jthread reader_;
std::jthread periodic_reporter_;
+ std::mutex submit_mutex_;
std::mutex lifecycle_mutex_;
std::condition_variable lifecycle_condition_;
bool started_ = false;
diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp
index 7f31ea3..5a375f3 100644
--- a/src/platform/windows/driver/libvirtualhid_umdf.cpp
+++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp
@@ -852,7 +852,13 @@ namespace {
NTSTATUS copy_vhf_feature_report(DeviceRecord &record, HID_XFER_PACKET &packet) {
auto report_number = packet.reportId;
- if (report_number == 0U && packet.reportBufferLen > 0U) {
+ // The Steam Deck feature report is unnumbered. VHF does not guarantee the
+ // contents of an input buffer for GET_FEATURE, so its first payload byte
+ // must never be interpreted as a report ID.
+ if (
+ record.request.gamepad_kind != LVH_WINDOWS_GAMEPAD_STEAM_DECK &&
+ report_number == 0U && packet.reportBufferLen > 0U
+ ) {
report_number = packet.reportBuffer[0];
}
diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp
index 083e613..2432670 100644
--- a/src/platform/windows/windows_backend.cpp
+++ b/src/platform/windows/windows_backend.cpp
@@ -31,6 +31,7 @@
#include "platform/windows/keylayout.hpp"
#include "platform/windows/shared/generic_pid_rumble.hpp"
#include "platform/windows/windows_broker_client.hpp"
+#include "shared/steam_deck_feature_reports.hpp"
#include
#include
@@ -918,6 +919,8 @@ namespace lvh::detail {
path {std::move(device_path)} {
if (profile.gamepad_kind == GamepadProfileKind::generic && profile.capabilities.supports_rumble) {
uses_generic_pid = !windows::make_generic_pid_report_descriptor(profile.report_descriptor).empty();
+ } else if (profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ last_input_report = make_steam_deck_neutral_input_report();
}
}
@@ -933,6 +936,7 @@ namespace lvh::detail {
DeviceProfile profile;
std::string path;
std::vector last_input_report;
+ std::uint32_t steam_deck_packet_number = 0;
bool open = true;
OutputCallback output_callback;
bool uses_generic_pid = false;
@@ -968,6 +972,7 @@ namespace lvh::detail {
std::shared_ptr context_;
std::shared_ptr state_;
+ std::mutex submit_mutex_;
std::jthread periodic_reporter_;
};
@@ -1238,6 +1243,7 @@ namespace lvh::detail {
) {
using enum ErrorCode;
+ std::lock_guard submit_lock {submit_mutex_};
auto submitted_report = report;
{
std::lock_guard lock {state_->mutex_};
@@ -1251,6 +1257,8 @@ namespace lvh::detail {
if (state_->uses_generic_pid) {
submitted_report = windows::make_generic_windows_input_report(report);
+ } else if (state_->profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ static_cast(stamp_steam_deck_packet_number(submitted_report, ++state_->steam_deck_packet_number));
}
}
@@ -1266,22 +1274,23 @@ namespace lvh::detail {
using namespace std::chrono_literals;
while (!stop_token.stop_requested()) {
- std::this_thread::sleep_for(4ms);
- if (stop_token.stop_requested()) {
- break;
- }
-
- std::vector report;
{
- std::lock_guard lock {state_->mutex_};
- if (!state_->open) {
- break;
+ std::lock_guard submit_lock {submit_mutex_};
+ std::vector report;
+ {
+ std::lock_guard lock {state_->mutex_};
+ if (!state_->open) {
+ break;
+ }
+ report = state_->last_input_report;
+ static_cast(stamp_steam_deck_packet_number(report, ++state_->steam_deck_packet_number));
+ }
+ if (!report.empty()) {
+ static_cast(context_->submit_gamepad_report(state_, report));
}
- report = state_->last_input_report;
- }
- if (!report.empty()) {
- static_cast(context_->submit_gamepad_report(state_, report));
}
+
+ std::this_thread::sleep_for(4ms);
}
}
diff --git a/src/shared/steam_deck_feature_reports.hpp b/src/shared/steam_deck_feature_reports.hpp
index 2bb9e5a..b769099 100644
--- a/src/shared/steam_deck_feature_reports.hpp
+++ b/src/shared/steam_deck_feature_reports.hpp
@@ -22,13 +22,35 @@ namespace lvh::detail {
inline constexpr std::size_t steam_deck_feature_report_size = 64U;
+ inline constexpr std::size_t steam_deck_packet_number_offset = 4U;
+
+ /**
+ * @brief Stamp a Valve-native Steam Deck input report with its sequence.
+ * @param report Mutable native input-report bytes.
+ * @param packet_number Packet sequence value.
+ * @return Whether the report was large enough to stamp.
+ */
+ inline bool stamp_steam_deck_packet_number(
+ std::span report,
+ std::uint32_t packet_number
+ ) {
+ if (report.size() < steam_deck_packet_number_offset + sizeof(packet_number)) {
+ return false;
+ }
+
+ for (std::size_t index = 0; index < sizeof(packet_number); ++index) {
+ report[steam_deck_packet_number_offset + index] =
+ static_cast((packet_number >> (index * 8U)) & 0xFFU);
+ }
+ return true;
+ }
+
/**
- * @brief Create the native neutral report queued before VHF enumeration.
+ * @brief Create a native neutral report for immediate consumer discovery.
*
* SDL probes a newly arrived Steam Deck endpoint by waiting only 16 ms for
- * its first state packet. Queueing this report before VhfStart lets an
- * already-running consumer complete that probe while the create request is
- * still in progress.
+ * its first state packet. Backends seed their periodic state with this
+ * packet so an already-running consumer can complete that probe immediately.
*/
inline std::vector make_steam_deck_neutral_input_report() {
auto report = std::vector(steam_deck_input_report_size, 0U);
diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
index 05b7333..c382c9b 100644
--- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
@@ -55,8 +55,10 @@ namespace lvh::detail::test {
OperationStatus create_status;
OperationStatus submit_status;
OperationStatus close_status;
+ std::size_t reports_before_submit = 0;
std::size_t reports_before_close = 0;
std::size_t reports_after_close = 0;
+ bool packet_numbers_advance = false;
};
struct WindowsBackendUtilityResult {
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index c65a286..67637c6 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -1695,7 +1695,7 @@ namespace lvh::detail::test {
options.metadata.stable_id = "linux-steam-deck";
UhidGamepad gamepad {descriptors[0]};
- auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_USB, result);
+ auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_VIRTUAL, result);
gamepad.set_output_callback([&result](const GamepadOutput &output) {
if (output.kind == GamepadOutputKind::rumble) {
++result.output.callback_count;
diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp
index 77376a9..52c7935 100644
--- a/tests/fixtures/windows_backend_test_hooks.cpp
+++ b/tests/fixtures/windows_backend_test_hooks.cpp
@@ -126,6 +126,11 @@ namespace lvh::detail {
return submit_reports_.size();
}
+ std::vector> submit_reports() const {
+ std::lock_guard lock {mutex_};
+ return submit_reports_;
+ }
+
std::size_t destroy_request_count() const {
std::lock_guard lock {mutex_};
return destroyed_ids_.size();
@@ -508,14 +513,42 @@ namespace lvh::detail {
return result;
}
- const auto report = reports::pack_input_report(options.profile, {});
- result.submit_status = created.gamepad->submit({}, report);
static_cast(wait_until([&command_state] {
return command_state->submit_report_count() >= 2U;
}));
+ result.reports_before_submit = command_state->submit_report_count();
+
+ const auto report = reports::pack_input_report(options.profile, {});
+ result.submit_status = created.gamepad->submit({}, report);
+ static_cast(wait_until([&command_state, &result] {
+ return command_state->submit_report_count() >= result.reports_before_submit + 2U;
+ }));
result.close_status = created.gamepad->close();
result.reports_before_close = command_state->submit_report_count();
+ const auto submitted_reports = command_state->submit_reports();
+ result.packet_numbers_advance = submitted_reports.size() >= 2U;
+ auto previous_packet_number = std::uint32_t {};
+ for (std::size_t report_index = 0; report_index < submitted_reports.size(); ++report_index) {
+ const auto &submitted_report = submitted_reports[report_index];
+ if (submitted_report.size() < steam_deck_packet_number_offset + sizeof(std::uint32_t)) {
+ result.packet_numbers_advance = false;
+ break;
+ }
+
+ auto packet_number = std::uint32_t {};
+ for (std::size_t byte_index = 0; byte_index < sizeof(packet_number); ++byte_index) {
+ packet_number |= static_cast(
+ submitted_report[steam_deck_packet_number_offset + byte_index]
+ )
+ << (byte_index * 8U);
+ }
+ if (report_index > 0U && packet_number <= previous_packet_number) {
+ result.packet_numbers_advance = false;
+ break;
+ }
+ previous_packet_number = packet_number;
+ }
std::this_thread::sleep_for(std::chrono::milliseconds {12});
result.reports_after_close = command_state->submit_report_count();
return result;
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index 70bdfe6..aaf51bb 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -124,6 +124,7 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {
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::steam_deck), BUS_VIRTUAL);
EXPECT_EQ(lvh::detail::test::linux_uinput_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH);
EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::pen), BTN_TOOL_PEN);
diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp
index e9aa15d..895e9d1 100644
--- a/tests/unit/test_profiles.cpp
+++ b/tests/unit/test_profiles.cpp
@@ -236,9 +236,29 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
EXPECT_TRUE(steam_deck.capabilities.supports_motion);
EXPECT_TRUE(steam_deck.capabilities.supports_touchpad);
- constexpr std::array steam_deck_input_descriptor {0x75, 0x08, 0x95, 0x40, 0x09, 0x01};
- constexpr std::array steam_deck_feature_descriptor {0x09, 0x02, 0x95, 0x40, 0xB1, 0x02};
- expect_descriptor_contains(steam_deck, steam_deck_input_descriptor);
+ constexpr std::array steam_deck_gamepad_collection {0x05, 0x01, 0x09, 0x05, 0xA1, 0x01};
+ constexpr std::array steam_deck_axis_descriptor {
+ 0x09,
+ 0x30,
+ 0x09,
+ 0x31,
+ 0x09,
+ 0x33,
+ 0x09,
+ 0x34,
+ 0x81,
+ 0x02,
+ 0x75,
+ 0x08,
+ };
+ constexpr std::array steam_deck_feature_descriptor {0x95, 0x40, 0x09, 0x02, 0xB1, 0x02};
+ ASSERT_GE(steam_deck.report_descriptor.size(), steam_deck_gamepad_collection.size());
+ EXPECT_TRUE(std::equal(
+ steam_deck_gamepad_collection.begin(),
+ steam_deck_gamepad_collection.end(),
+ steam_deck.report_descriptor.begin()
+ ));
+ expect_descriptor_contains(steam_deck, steam_deck_axis_descriptor);
expect_descriptor_contains(steam_deck, steam_deck_feature_descriptor);
EXPECT_EQ(std::ranges::find(steam_deck.report_descriptor, 0x85), steam_deck.report_descriptor.end());
diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp
index 4020ecd..b6d2bb7 100644
--- a/tests/unit/test_windows_backend.cpp
+++ b/tests/unit/test_windows_backend.cpp
@@ -100,7 +100,9 @@ TEST_F(WindowsBackendTest, SteamDeckRepeatsNativeStateUntilClose) {
ASSERT_TRUE(result.create_status.ok()) << result.create_status.message();
ASSERT_TRUE(result.submit_status.ok()) << result.submit_status.message();
ASSERT_TRUE(result.close_status.ok()) << result.close_status.message();
- EXPECT_GE(result.reports_before_close, 2U);
+ EXPECT_GE(result.reports_before_submit, 2U);
+ EXPECT_GT(result.reports_before_close, result.reports_before_submit);
+ EXPECT_TRUE(result.packet_numbers_advance);
EXPECT_EQ(result.reports_after_close, result.reports_before_close);
}
diff --git a/tests/unit/test_windows_driver_protocol.cpp b/tests/unit/test_windows_driver_protocol.cpp
index 410750e..f0c0f20 100644
--- a/tests/unit/test_windows_driver_protocol.cpp
+++ b/tests/unit/test_windows_driver_protocol.cpp
@@ -301,7 +301,7 @@ TEST_F(WindowsDriverProtocolTest, RespondsToSteamDeckFeatureReports) {
}
TEST_F(WindowsDriverProtocolTest, QueuesNativeSteamDeckStateForImmediateConsumerDiscovery) {
- const auto report = lvh::detail::make_steam_deck_neutral_input_report();
+ auto report = lvh::detail::make_steam_deck_neutral_input_report();
ASSERT_EQ(report.size(), lvh::detail::steam_deck_input_report_size);
EXPECT_EQ(report[0], 0x01U);
@@ -311,4 +311,13 @@ TEST_F(WindowsDriverProtocolTest, QueuesNativeSteamDeckStateForImmediateConsumer
EXPECT_TRUE(std::ranges::all_of(report.begin() + 4U, report.end(), [](const auto value) {
return value == 0U;
}));
+
+ EXPECT_TRUE(lvh::detail::stamp_steam_deck_packet_number(report, 0x78563412U));
+ EXPECT_EQ(report[4], 0x12U);
+ EXPECT_EQ(report[5], 0x34U);
+ EXPECT_EQ(report[6], 0x56U);
+ EXPECT_EQ(report[7], 0x78U);
+
+ std::array short_report {};
+ EXPECT_FALSE(lvh::detail::stamp_steam_deck_packet_number(short_report, 1U));
}
From d1bc01f75d556df1c7c578be7189714a411ce40e Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:06:36 -0400
Subject: [PATCH 3/6] fix(steam-deck): harden native discovery timing
---
docs/platform-support.md | 21 ++++++++++---------
src/platform/linux/uhid_backend.cpp | 16 +++++++-------
src/platform/windows/windows_backend.cpp | 10 +++++++++
.../fixtures/windows_backend_test_hooks.hpp | 1 +
tests/fixtures/linux_backend_test_hooks.cpp | 2 +-
tests/fixtures/windows_backend_test_hooks.cpp | 1 +
tests/unit/test_linux_backend.cpp | 2 +-
tests/unit/test_windows_backend.cpp | 1 +
8 files changed, 35 insertions(+), 19 deletions(-)
diff --git a/docs/platform-support.md b/docs/platform-support.md
index d817eae..f9ed5ee 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -82,8 +82,9 @@ Game Pad while retaining the Valve-native report bytes, so Windows Game
Controllers and generic HID clients can enumerate it even if the Valve-specific
path is unavailable. SDL/HIDAPI can recognize it as a Steam Deck, disable its
desktop mappings, and send native rumble without exposing Valve protocol details
-through the public C++ API. A neutral state is submitted continuously from the
-moment the backend is created, and each packet carries an advancing native
+through the public C++ API. The driver seeds a neutral report before exposing the
+device, the backend submits another synchronously during creation, and periodic
+updates continue afterward. Each backend packet carries an advancing native
sequence number.
See [Windows driver package](windows-driver.md) for build, install, validation,
@@ -149,14 +150,14 @@ provides it separately on the UHID event.
Steam Deck retains Valve's native identity and emits the 64-byte Deck state
packet periodically so SDL's direct HIDAPI path can initialize before the first
-client input arrives. Its UHID endpoint uses the virtual bus, preventing the
-hardware-specific `hid-steam` driver from suppressing input behind the physical
-Deck's lizard-mode gate; the Generic Desktop/Game Pad descriptor remains
-available as an evdev fallback. The backend answers the unit-serial feature
-query used during Linux registration, accepts the desktop-mapping/settings
-commands used by SDL, and forwards native `0xEB` rumble requests through the
-portable output callback. Each submitted native packet carries an advancing
-sequence number.
+client input arrives. Its UHID endpoint uses the Bluetooth HID transport tag:
+this remains visible to HIDAPI while avoiding the USB-only `hid-steam` match
+that otherwise suppresses the virtual endpoint's reports when a direct HID
+client opens it. The Generic Desktop/Game Pad descriptor remains available as
+an evdev fallback. The backend answers the unit-serial feature query used during
+Linux registration, accepts the desktop-mapping/settings commands used by SDL,
+and forwards native `0xEB` rumble requests through the portable output callback.
+Each submitted native packet carries an advancing sequence number.
The backend opens `/dev/uhid` in nonblocking mode, matching the original
asynchronous gamepad registration path. Its event reader is active before
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index 07109b0..a69e7f0 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -319,15 +319,17 @@ namespace lvh::detail {
#if defined(__linux__)
std::uint16_t to_uhid_bus(const DeviceProfile &profile) {
- if (
- profile.gamepad_kind == GamepadProfileKind::switch_pro ||
- profile.gamepad_kind == GamepadProfileKind::steam_deck
- ) {
- // Prevent hardware-specific kernel drivers from taking over these
- // virtual endpoints. In particular, hid-steam suppresses Steam Deck
- // gamepad input while its hardware-only lizard-mode gate is active.
+ if (profile.gamepad_kind == GamepadProfileKind::switch_pro) {
+ // Prevent hardware-specific kernel drivers from taking over this
+ // virtual endpoint.
return BUS_VIRTUAL;
}
+ if (profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ // hid-steam matches the Deck's USB identity and suppresses its input
+ // while a hidraw client is open. BUS_VIRTUAL avoids that driver but is
+ // filtered out by hidapi, so use its other supported HID transport.
+ return BUS_BLUETOOTH;
+ }
return to_uhid_bus(profile.bus_type);
}
diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp
index 2432670..b9259f3 100644
--- a/src/platform/windows/windows_backend.cpp
+++ b/src/platform/windows/windows_backend.cpp
@@ -1048,6 +1048,16 @@ namespace lvh::detail {
response.device_path[0] == '\0' ? command_channel_->path() : std::string {response.device_path.data()}
);
+ if (state->profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ static_cast(
+ stamp_steam_deck_packet_number(state->last_input_report, ++state->steam_deck_packet_number)
+ );
+ if (const auto status = submit_gamepad_report(state, state->last_input_report); !status.ok()) {
+ static_cast(command_channel_->destroy_device(state->driver_id, state->token));
+ return {status, nullptr};
+ }
+ }
+
{
std::lock_guard lock {devices_mutex_};
gamepads_[state->driver_id] = state;
diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
index c382c9b..1f5da44 100644
--- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
@@ -55,6 +55,7 @@ namespace lvh::detail::test {
OperationStatus create_status;
OperationStatus submit_status;
OperationStatus close_status;
+ std::size_t reports_immediately_after_create = 0;
std::size_t reports_before_submit = 0;
std::size_t reports_before_close = 0;
std::size_t reports_after_close = 0;
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index 67637c6..6144602 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -1695,7 +1695,7 @@ namespace lvh::detail::test {
options.metadata.stable_id = "linux-steam-deck";
UhidGamepad gamepad {descriptors[0]};
- auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_VIRTUAL, result);
+ auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_BLUETOOTH, result);
gamepad.set_output_callback([&result](const GamepadOutput &output) {
if (output.kind == GamepadOutputKind::rumble) {
++result.output.callback_count;
diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp
index 52c7935..e37a3c6 100644
--- a/tests/fixtures/windows_backend_test_hooks.cpp
+++ b/tests/fixtures/windows_backend_test_hooks.cpp
@@ -512,6 +512,7 @@ namespace lvh::detail {
if (!created) {
return result;
}
+ result.reports_immediately_after_create = command_state->submit_report_count();
static_cast(wait_until([&command_state] {
return command_state->submit_report_count() >= 2U;
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index aaf51bb..69ae37c 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -124,7 +124,7 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {
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::steam_deck), BUS_VIRTUAL);
+ EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::steam_deck), 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);
diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp
index b6d2bb7..3748299 100644
--- a/tests/unit/test_windows_backend.cpp
+++ b/tests/unit/test_windows_backend.cpp
@@ -100,6 +100,7 @@ TEST_F(WindowsBackendTest, SteamDeckRepeatsNativeStateUntilClose) {
ASSERT_TRUE(result.create_status.ok()) << result.create_status.message();
ASSERT_TRUE(result.submit_status.ok()) << result.submit_status.message();
ASSERT_TRUE(result.close_status.ok()) << result.close_status.message();
+ EXPECT_GE(result.reports_immediately_after_create, 1U);
EXPECT_GE(result.reports_before_submit, 2U);
EXPECT_GT(result.reports_before_close, result.reports_before_submit);
EXPECT_TRUE(result.packet_numbers_advance);
From 741b708b2f27cd3fc65ca8978fbf39c095c7a89b Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:09:37 -0400
Subject: [PATCH 4/6] style: format Steam Deck profile assertion
---
tests/unit/test_profiles.cpp | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp
index 895e9d1..6e87cb1 100644
--- a/tests/unit/test_profiles.cpp
+++ b/tests/unit/test_profiles.cpp
@@ -253,11 +253,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
};
constexpr std::array steam_deck_feature_descriptor {0x95, 0x40, 0x09, 0x02, 0xB1, 0x02};
ASSERT_GE(steam_deck.report_descriptor.size(), steam_deck_gamepad_collection.size());
- EXPECT_TRUE(std::equal(
- steam_deck_gamepad_collection.begin(),
- steam_deck_gamepad_collection.end(),
- steam_deck.report_descriptor.begin()
- ));
+ EXPECT_TRUE(std::equal(steam_deck_gamepad_collection.begin(), steam_deck_gamepad_collection.end(), steam_deck.report_descriptor.begin()));
expect_descriptor_contains(steam_deck, steam_deck_axis_descriptor);
expect_descriptor_contains(steam_deck, steam_deck_feature_descriptor);
EXPECT_EQ(std::ranges::find(steam_deck.report_descriptor, 0x85), steam_deck.report_descriptor.end());
From e48fc2e582c23a26a60c18f397ba29c5cfa67160 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:35:48 -0400
Subject: [PATCH 5/6] fix(steam-deck): use native USB hidraw path on Linux
---
docs/platform-support.md | 17 +++++++++--------
src/platform/linux/uhid_backend.cpp | 6 ------
tests/fixtures/linux_backend_test_hooks.cpp | 2 +-
tests/unit/test_linux_backend.cpp | 2 +-
4 files changed, 11 insertions(+), 16 deletions(-)
diff --git a/docs/platform-support.md b/docs/platform-support.md
index f9ed5ee..081dc44 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -150,14 +150,15 @@ provides it separately on the UHID event.
Steam Deck retains Valve's native identity and emits the 64-byte Deck state
packet periodically so SDL's direct HIDAPI path can initialize before the first
-client input arrives. Its UHID endpoint uses the Bluetooth HID transport tag:
-this remains visible to HIDAPI while avoiding the USB-only `hid-steam` match
-that otherwise suppresses the virtual endpoint's reports when a direct HID
-client opens it. The Generic Desktop/Game Pad descriptor remains available as
-an evdev fallback. The backend answers the unit-serial feature query used during
-Linux registration, accepts the desktop-mapping/settings commands used by SDL,
-and forwards native `0xEB` rumble requests through the portable output callback.
-Each submitted native packet carries an advancing sequence number.
+client input arrives. Its UHID endpoint keeps Valve's USB transport identity so
+Linux `hid-steam` can expose the dedicated hidraw client expected by SDL. When
+that client opens, `hid-steam` hands the native report stream to userspace; the
+Generic Desktop/Game Pad descriptor remains available as an evdev fallback when
+no direct HID client owns it. The backend answers the unit-serial feature query
+used during Linux registration, accepts the desktop-mapping/settings commands
+used by SDL, and forwards native `0xEB` rumble requests through the portable
+output callback. Each submitted native packet carries an advancing sequence
+number.
The backend opens `/dev/uhid` in nonblocking mode, matching the original
asynchronous gamepad registration path. Its event reader is active before
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index a69e7f0..5c9fa0a 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -324,12 +324,6 @@ namespace lvh::detail {
// virtual endpoint.
return BUS_VIRTUAL;
}
- if (profile.gamepad_kind == GamepadProfileKind::steam_deck) {
- // hid-steam matches the Deck's USB identity and suppresses its input
- // while a hidraw client is open. BUS_VIRTUAL avoids that driver but is
- // filtered out by hidapi, so use its other supported HID transport.
- return BUS_BLUETOOTH;
- }
return to_uhid_bus(profile.bus_type);
}
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index 6144602..c65a286 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -1695,7 +1695,7 @@ namespace lvh::detail::test {
options.metadata.stable_id = "linux-steam-deck";
UhidGamepad gamepad {descriptors[0]};
- auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_BLUETOOTH, result);
+ auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_USB, result);
gamepad.set_output_callback([&result](const GamepadOutput &output) {
if (output.kind == GamepadOutputKind::rumble) {
++result.output.callback_count;
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index 69ae37c..249728a 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -124,7 +124,7 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {
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::steam_deck), BUS_BLUETOOTH);
+ EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::steam_deck), BUS_USB);
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);
From 3c5f1f91e8821c14d1867ff5b37c21ce64287996 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Mon, 17 Aug 2026 22:01:37 -0400
Subject: [PATCH 6/6] fix(steam-deck): preserve native SDL ownership
---
docs/platform-support.md | 17 ++++++++---------
src/platform/linux/uhid_backend.cpp | 6 ++++++
tests/fixtures/linux_backend_test_hooks.cpp | 2 +-
tests/unit/test_linux_backend.cpp | 2 +-
tests/unit/test_windows_consumers.cpp | 6 ++++--
5 files changed, 20 insertions(+), 13 deletions(-)
diff --git a/docs/platform-support.md b/docs/platform-support.md
index 081dc44..5409842 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -150,15 +150,14 @@ provides it separately on the UHID event.
Steam Deck retains Valve's native identity and emits the 64-byte Deck state
packet periodically so SDL's direct HIDAPI path can initialize before the first
-client input arrives. Its UHID endpoint keeps Valve's USB transport identity so
-Linux `hid-steam` can expose the dedicated hidraw client expected by SDL. When
-that client opens, `hid-steam` hands the native report stream to userspace; the
-Generic Desktop/Game Pad descriptor remains available as an evdev fallback when
-no direct HID client owns it. The backend answers the unit-serial feature query
-used during Linux registration, accepts the desktop-mapping/settings commands
-used by SDL, and forwards native `0xEB` rumble requests through the portable
-output callback. Each submitted native packet carries an advancing sequence
-number.
+client input arrives. Linux exposes that endpoint as Bluetooth HID so SDL can
+own the native hidraw stream directly; advertising it as USB would make the
+kernel's `hid-steam` driver claim the virtual endpoint and substitute an evdev
+controller. The Generic Desktop/Game Pad descriptor remains available as an
+evdev fallback. The backend answers the unit-serial feature query, accepts the
+desktop-mapping/settings commands used by SDL, and forwards native `0xEB`
+rumble requests through the portable output callback. Each submitted native
+packet carries an advancing sequence number.
The backend opens `/dev/uhid` in nonblocking mode, matching the original
asynchronous gamepad registration path. Its event reader is active before
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index 5c9fa0a..c881d71 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -324,6 +324,12 @@ namespace lvh::detail {
// virtual endpoint.
return BUS_VIRTUAL;
}
+ if (profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ // hid-steam claims USB endpoints with Valve's Deck VID/PID and
+ // publishes its own evdev device. Use a HID transport SDL accepts but
+ // hid-steam does not match so SDL can own the native hidraw stream.
+ return BUS_BLUETOOTH;
+ }
return to_uhid_bus(profile.bus_type);
}
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index c65a286..6144602 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -1695,7 +1695,7 @@ namespace lvh::detail::test {
options.metadata.stable_id = "linux-steam-deck";
UhidGamepad gamepad {descriptors[0]};
- auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_USB, result);
+ auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_BLUETOOTH, result);
gamepad.set_output_callback([&result](const GamepadOutput &output) {
if (output.kind == GamepadOutputKind::rumble) {
++result.output.callback_count;
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index 249728a..69ae37c 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -124,7 +124,7 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {
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::steam_deck), BUS_USB);
+ EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::steam_deck), 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);
diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp
index ac7424a..644e195 100644
--- a/tests/unit/test_windows_consumers.cpp
+++ b/tests/unit/test_windows_consumers.cpp
@@ -437,8 +437,10 @@ namespace {
SDL_GetGamepadProductForID(gamepad_id) == product_id
) {
auto *opened = SDL_OpenGamepad(gamepad_id);
- SDL_free(gamepads);
- return {opened, &SDL_CloseGamepad};
+ if (opened != nullptr) {
+ SDL_free(gamepads);
+ return {opened, &SDL_CloseGamepad};
+ }
}
}
SDL_free(gamepads);