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);