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..5409842 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -75,7 +75,17 @@ 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. 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. 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,
and signing details.
@@ -84,7 +94,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 +141,24 @@ 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 identity and emits the 64-byte Deck state
+packet periodically so SDL's direct HIDAPI path can initialize before the first
+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
device registration begins, and creation does not report success until the
@@ -246,8 +267,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 +280,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..99c5649 100644
--- a/docs/windows-driver.md
+++ b/docs/windows-driver.md
@@ -381,6 +381,17 @@ 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 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/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..c6d8499 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,205 @@ namespace lvh::profiles {
return bytes_from_hex(descriptor);
}
+ std::vector make_steam_deck_report_descriptor() {
+ // 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 {
+ 0x05,
+ 0x01, // Usage Page (Generic Desktop)
+ 0x09,
+ 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,
+ 0x7F, // Logical Maximum (32767)
+ 0x75,
+ 0x10, // Report Size (16)
+ 0x95,
+ 0x02,
+ 0x09,
+ 0x32, // Usage (Z)
+ 0x09,
+ 0x35, // Usage (Rz)
+ 0x81,
+ 0x02,
+
+ // Native signed stick values (bytes 48-55).
+ 0x16,
+ 0x00,
+ 0x80, // Logical Minimum (-32768)
+ 0x26,
+ 0xFF,
+ 0x7F, // Logical Maximum (32767)
+ 0x95,
+ 0x04,
+ 0x09,
+ 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,
+ 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
+ };
+ }
+
std::vector make_gamepad_report_descriptor(std::uint8_t report_id, bool supports_rumble) {
std::vector descriptor {
0x05,
@@ -2036,6 +2239,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 +2349,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 +2389,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 +2405,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..c881d71 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
@@ -302,8 +320,16 @@ namespace lvh::detail {
#if defined(__linux__)
std::uint16_t to_uhid_bus(const DeviceProfile &profile) {
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 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);
}
@@ -314,6 +340,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 +2843,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 +2869,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);
}};
@@ -2851,22 +2881,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;
}
@@ -3037,7 +3076,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 +3104,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 +3179,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,11 +3219,14 @@ 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_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/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..5a375f3 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)) {
@@ -838,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];
}
@@ -846,6 +866,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 +894,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..b9259f3 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();
}
}
@@ -932,6 +935,8 @@ namespace lvh::detail {
LvhWindowsSessionToken token {};
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;
@@ -942,7 +947,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 +968,12 @@ 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::mutex submit_mutex_;
+ std::jthread periodic_reporter_;
};
class WindowsBackendContext: public std::enable_shared_from_this {
@@ -1029,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;
@@ -1224,6 +1253,8 @@ namespace lvh::detail {
) {
using enum ErrorCode;
+ std::lock_guard submit_lock {submit_mutex_};
+ auto submitted_report = report;
{
std::lock_guard lock {state_->mutex_};
if (!state_->open) {
@@ -1235,11 +1266,42 @@ 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);
+ } else if (state_->profile.gamepad_kind == GamepadProfileKind::steam_deck) {
+ static_cast(stamp_steam_deck_packet_number(submitted_report, ++state_->steam_deck_packet_number));
}
}
- 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::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));
+ }
+ }
+
+ std::this_thread::sleep_for(4ms);
+ }
}
void WindowsGamepad::set_output_callback(OutputCallback callback) {
@@ -1257,6 +1319,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..b769099
--- /dev/null
+++ b/src/shared/steam_deck_feature_reports.hpp
@@ -0,0 +1,123 @@
+// 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;
+
+ 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 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. 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);
+ 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..1f5da44 100644
--- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
@@ -51,6 +51,17 @@ namespace lvh::detail::test {
std::vector strengths;
};
+ struct WindowsSteamDeckPeriodicReportResult {
+ 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;
+ bool packet_numbers_advance = false;
+ };
+
struct WindowsBackendUtilityResult {
std::vector default_device_paths;
std::vector custom_device_paths;
@@ -164,6 +175,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..6144602 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_BLUETOOTH, 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..e37a3c6 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();
@@ -494,6 +499,62 @@ 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;
+ }
+ result.reports_immediately_after_create = command_state->submit_report_count();
+
+ 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;
+ }
+
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..69ae37c 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_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);
@@ -819,6 +820,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..6e87cb1 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,40 @@ 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_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());
+
const auto generic = lvh::profiles::generic_gamepad();
const std::array standard_button_descriptor {
0x05,
@@ -512,6 +548,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..3748299 100644
--- a/tests/unit/test_windows_backend.cpp
+++ b/tests/unit/test_windows_backend.cpp
@@ -94,6 +94,19 @@ 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_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);
+ 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..644e195 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);
}
@@ -436,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);
@@ -450,7 +453,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 +468,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..f0c0f20 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,45 @@ 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) {
+ 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;
+ }));
+
+ 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));
+}
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 {