diff --git a/README.md b/README.md index 5328f7d0..5e13b6cf 100644 --- a/README.md +++ b/README.md @@ -44,18 +44,20 @@ companion stay 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, Switch Pro, Xbox One, and Xbox Series gamepads - through Linux `uhid`; Generic and Xbox 360 gamepads plus keyboard, mouse, - touchscreen, trackpad, and pen tablet devices through `uinput`. Xbox One and - Xbox Series fall back to `uinput` when `uhid` is unavailable. + DualShock 4, DualSense, Nintendo Switch Pro-style, and 2026 Steam + controllers. +- Descriptor-driven PlayStation, Switch Pro, Steam Controller, Xbox One, and + Xbox Series gamepads through Linux `uhid`; Generic and Xbox 360 gamepads plus + keyboard, mouse, touchscreen, trackpad, and pen tablet devices through + `uinput`. Xbox One and Xbox Series fall back to `uinput` when `uhid` is + unavailable. - Windows gamepads, keyboards, and Raw Input-visible mice through user-mode UMDF2 drivers. Xbox 360 uses a broker-owned XUSB software-device personality; the other profiles use Virtual HID Framework. Win32 keyboard and mouse fallbacks remain available when the licensed driver path is unavailable. - Output callbacks for profile-specific feedback such as ordinary and trigger - rumble, RGB and player LEDs, adaptive triggers, and raw HID output reports - when available. + rumble, addressable haptics, RGB and player LEDs, adaptive triggers, and raw + HID output reports when available. - An optional `virtualhid_control` native UI tool for creating, removing, controlling, and inspecting test gamepads and mice through the public C++ API. - CMake consumption through installed packages, vendored source, @@ -156,6 +158,7 @@ Alternatives exist if `libvirtualhid` does not meet your needs. | DualShock 4 gamepad | ✅ | ✅ | ✅ | ❌ | ✅4 | | DualSense gamepad | ✅ | ❌ | ✅ | ✅ | ✅4 | | Nintendo Switch Pro-style gamepad | ✅ | ❌ | ✅ | ✅ | ✅4 | +| Steam Controller (2026) | ✅ | ❌ | ✅ | ❌ | ✅4 | | Rumble or output callbacks | ✅ | ❌ | ✅ | ✅ | ✅4 | | Data-driven profiles | ❌ | ❌ | ✅ | ❌ | ❌ | | Actively developed | ✅ | ❌ | ✅ | ✅ | ✅ | diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md index a37148b2..a0b4b2a7 100644 --- a/docs/end-user-gamepad-guide.md +++ b/docs/end-user-gamepad-guide.md @@ -16,9 +16,9 @@ physical controller <- Moonlight client <- Sunshine <- libvirtualhid <- game ``` Buttons, sticks, triggers, touch, motion, and battery state travel toward the -host. Rumble, Xbox Impulse Triggers, DualSense adaptive-trigger effects, and -LEDs travel back toward the client. A feature works end to end only when every -layer in its direction supports it. +host. Rumble, Steam Controller pad haptics, Xbox Impulse Triggers, DualSense +adaptive-trigger effects, and LEDs travel back toward the client. A feature +works end to end only when every layer in its direction supports it. The capabilities advertised by a `libvirtualhid` profile describe what the host-side virtual controller can represent. They do not guarantee that a @@ -49,6 +49,10 @@ manufacturer's instructions: [pair, use, and troubleshoot the controller](https://www.nintendo.com/my/support/switch/controller/nintendoswitchpro.html) and [update the controller firmware](https://en-americas-support.nintendo.com/app/answers/detail/a_id/26321/~/how-to-update-the-controller-firmware). +- Steam Controller (2026): confirm both trackpads, their clicks, all four rear + buttons, trigger clicks, stick/grip touch sensors, and both haptic actuators + locally before testing the streaming path. A generic client mapping may omit + these extended controls even when its ordinary gamepad input works. A controller working locally proves only the physical controller-to-client part of the path. It does not prove that an extended feature is implemented by diff --git a/docs/platform-support.md b/docs/platform-support.md index 37b23585..ad735f14 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -104,11 +104,21 @@ streams native `0x30` reports every 15 milliseconds. This coalesces separate acceleration and gyroscope API updates into the three-sample report cadence used by a physical USB controller. +The 2026 Steam Controller profile uses Valve's native wired USB identity and HID +descriptor. The Windows client caches normalized controller state and streams +native `0x42` reports at the controller's 4.032-millisecond cadence so sequence +and sensor timestamps advance while controls are held. Separate `0x43` battery +reports carry charge state and percentage. The driver accepts both native +feature-report channels, including the lizard-mode settings request, and can +return native-shaped attribute and string replies when queried. Native ordinary +rumble and the controller's targeted pulse, command, tone, logarithmic-sweep, +and script outputs are normalized into the creating runtime's callbacks. + For every gamepad report ID, the VHF driver caches the newest complete input report and answers synchronous `GetInputReport` requests from that cache. This lets Windows HID consumers retrieve the current battery state for Xbox One, -Xbox Series, DualShock 4, DualSense, and Switch Pro instead of relying only on -the asynchronous input stream. +Xbox Series, DualShock 4, DualSense, Switch Pro, and Steam Controller instead of +relying only on the asynchronous input stream. That HID report does not change the XInput battery classification of the Xbox One and Xbox Series VHF devices. On a Windows desktop where XInput enumerated @@ -145,8 +155,8 @@ and signing details. The Linux backend uses standard user-space kernel interfaces: -- `uhid` for descriptor-driven PlayStation, Switch Pro, Xbox One, and Xbox - Series gamepads. +- `uhid` for descriptor-driven PlayStation, Switch Pro, Steam Controller, Xbox + One, and Xbox Series gamepads. - `uinput` for Generic and Xbox 360 gamepads, for Xbox One and Xbox Series when `uhid` is unavailable, and for keyboard, mouse, touchscreen, trackpad, and pen tablet devices. @@ -286,6 +296,22 @@ The public acceleration and gyroscope units remain meters per second squared and degrees per second; the packer converts them to Nintendo's coordinate system and sensor scales. +Steam Controller (2026) also remains on Linux `uhid` so the kernel exposes its +native Valve `0x28DE:0x1302` identity, full descriptor, raw feature channels, +separate battery report, and output reports. As with Switch Pro, the Linux-only +UHID identity uses the Bluetooth bus so SDL/HIDAPI does not require a physical +USB parent; the public profile and native wired report framing stay unchanged. +State report `0x42` +carries ordinary controls, four rear buttons, digital trigger clicks, +capacitive stick and grip touch, two pressure-sensitive trackpads and their +clicks, motion, sequence, and sensor timestamp fields. The backend republishes +the latest complete state every 4.032 milliseconds and serializes periodic and +application submissions. Report `0x80` becomes ordinary two-channel rumble; +reports `0x81` through `0x85` become targeted `haptics` callbacks while keeping +their raw bytes available. The layouts and initialization behavior follow +SDL's [Steam Controller report definitions](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/steam/controller_structs.h) +and [Triton HIDAPI driver](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_steam_triton.c). + Linux touchscreen and trackpad contacts use the lowest available multitouch slot while they are active. A newly placed contact receives a new tracking ID, including when it reuses a slot released by another contact, so replacing one @@ -315,6 +341,11 @@ Mouse buttons are momentary. A delayed browser-test mode queues an action long enough to switch focus to an external event tester, sending button actions as a single press-and-release click. +Gamepad navigation is disabled, and keyboard navigation is enabled only while +the mouse controls are selected. Losing focus or pointer presence releases any +momentary UI-controlled buttons so virtual input cannot remain held while a +system overlay is active. + ### Permissions Linux deployment requires both device-node permissions and the kernel modules @@ -401,8 +432,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 Controller gamepads. - Keyboard and mouse devices, with X11/XTest available as a fallback. - Touchscreen, trackpad, and pen tablet devices. @@ -414,16 +445,18 @@ 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, -but raw HID reports and descriptor-driven features are not. +Generic, Xbox-family, Switch Pro, Steam Controller, DualShock 4, and DualSense +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 uinput capability subset. Motion, touchpad contacts and click, battery state, RGB LED output, adaptive-trigger output, and raw HID output reports are disabled. -This includes Switch Pro motion and battery state as well as the -PlayStation-specific features. Streaming-host adapters can reject those -operations instead of silently accepting state that uinput cannot expose. +This includes Switch Pro and Steam Controller motion and battery state, Steam +Controller trackpads, rear buttons, trigger clicks, capacitive sensors and +haptics, and the PlayStation-specific features. Streaming-host adapters can +reject those operations instead of silently accepting state that uinput cannot +expose. The `uinput` kernel module and a writable uinput device node are required. diff --git a/docs/streaming-host-integration.md b/docs/streaming-host-integration.md index 22deb5c1..2d21b0d6 100644 --- a/docs/streaming-host-integration.md +++ b/docs/streaming-host-integration.md @@ -13,9 +13,9 @@ A streaming host should be able to: indexes. - Submit incremental button, axis, trigger, touchpad, motion, and battery updates without recreating a device. -- Receive output callbacks for rumble, RGB and player LEDs, adaptive triggers, - trigger rumble, and raw output reports where the selected profile supports - them. +- Receive output callbacks for rumble, addressable haptics, RGB and player + LEDs, adaptive triggers, trigger rumble, and raw output reports where the + selected profile supports them. - Query profile and backend capabilities before warning users about unsupported client features. - Read device nodes and platform paths when a downstream consumer or diagnostic @@ -57,14 +57,24 @@ 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, Switch Pro, Xbox One, and Xbox Series gamepads through - descriptor-driven `uhid`, Generic and Xbox 360 gamepads through `uinput`, - Xbox One and Xbox Series uinput fallbacks, and `uinput` keyboard/pointer - devices. +- Linux PlayStation, Switch Pro, Steam Controller, Xbox One, and Xbox Series + gamepads through descriptor-driven `uhid`, Generic and Xbox 360 gamepads + through `uinput`, Xbox One and Xbox Series uinput fallbacks, and `uinput` + keyboard/pointer devices. - Native Switch Pro motion, initialization replies, rumble, HOME-light, and player-light output handling on Linux and Windows descriptor-driven backends. +- Native 2026 Steam Controller state, battery, feature-report initialization, + ordinary rumble, and addressable pad-haptic handling on Linux and Windows. - Linux DualSense and DualShock 4 USB/Bluetooth report handling. - Linux touchscreen, trackpad, and pen tablet device types. - FreeBSD uinput gamepads and pointer devices, with basic PlayStation input and rumble but without Linux UHID-only PlayStation features. - Windows UMDF/VHF gamepad creation through an installed driver package. + +For Steam Controller clients, preserve both pad contacts and pressure values, +all four rear buttons, digital trigger clicks, and capacitive stick/grip touch +states instead of collapsing them into a generic controller packet. Forward +`GamepadOutputKind::haptics` separately from ordinary rumble so a client can +retain the controller's actuator target and effect parameters. A host that has +no extended haptic message may still forward `raw_report`, but should not +reinterpret addressable effects as a two-motor rumble packet. diff --git a/docs/usage.md b/docs/usage.md index d8a9ff43..710cf59d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -134,11 +134,14 @@ disabled, or mismatched entitlement invalidates the license and removes all licensed virtual HID devices. Purchase and account-management buttons use the compiled URLs in `src/platform/windows/shared/lvh_windows_broker_config.hpp`. -Enable `Lock buttons` to click-to-toggle behavior for held inputs. +Enable `Lock buttons` for click-to-toggle behavior for held inputs. Momentary +buttons are released if the control window loses focus or the pointer leaves it, +preventing a system-overlay transition from leaving a button held. The resizable window supports a compact width. Its device and control panels stack, and the button grid reflows to keep controls usable when it is narrowed. -The UI intentionally does not use gamepad navigation, so virtual devices created -by the tool cannot drive the tool's own controls. +The UI intentionally does not open gamepads for navigation and enables keyboard +navigation only for mouse controls, so virtual gamepads created by the tool +cannot drive the tool's own controls. External devices created by another process, such as Sunshine, are not enumerated yet. That requires backend protocol support, so the Windows driver or @@ -215,6 +218,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 Controller (2026) | `Steam Controller` | Consumers may replace `DeviceProfile::name` before creating a gamepad, for example, to prepend an application name while preserving the default controller @@ -230,11 +234,12 @@ through 16 in the input report. Linux may still route that profile through standard `ABS_HAT0X` and `ABS_HAT0Y` axes. Profiles advertise support for features such as rumble, trigger rumble, RGB and -player LEDs, adaptive triggers, motion sensors, touchpads, battery state, -profile-specific buttons, and raw output reports. Consumers should query -profile and backend capabilities before warning users about unsupported client -features. Xbox One and Xbox Series advertise `supports_trigger_rumble` and -`supports_battery`; the Linux UHID Bluetooth transport preserves both +player LEDs, adaptive triggers, addressable haptics, motion sensors, touchpads, +battery state, profile-specific buttons, and raw output reports. Consumers +should query profile and backend capabilities before warning users about +unsupported client features. Xbox One and Xbox Series advertise +`supports_trigger_rumble` and `supports_battery`; the Linux UHID Bluetooth +transport preserves both capabilities, while the uinput fallback clears them and retains ordinary rumble. The Linux Xbox transport includes its battery descriptor only when `CreateGamepadOptions::metadata.has_battery` is true, and it emits battery @@ -248,3 +253,22 @@ for every profile. The `misc1` button represents Share/Capture/Mic Mute-style controls and is available on the generic, Xbox Series, DualSense, and Switch Pro profiles; Xbox 360 and Xbox One do not advertise that extra button. + +`profiles::steam_controller_2026()` exposes Valve's wired `0x28DE:0x1302` +native profile. `touchpad_contacts[0]` is the left pad and +`touchpad_contacts[1]` is the right pad; each contact accepts normalized +position and pressure. The pad clicks use `left_touchpad` and +`right_touchpad`. `paddle1`/`paddle2` are R4/L4 and `paddle3`/`paddle4` are +R5/L5. The profile also accepts `left_trigger_click`, `right_trigger_click`, +the two stick-touch states, and the two grip-touch states in addition to the +ordinary gamepad buttons, sticks, analog triggers, motion, and battery fields. +Releasing a touchpad contact clears its active state and pressure while retaining +its last position, avoiding a spurious full-pad motion on the release report. + +Steam Controller output report `0x80` is normalized to the ordinary `rumble` +callback. Native reports `0x81` through `0x85` produce `haptics` callbacks with +the selected left, right, or paired actuator and the decoded pulse, tone, +command, sweep, or script parameters. The unchanged bytes remain available in +`GamepadOutput::raw_report`. Both Windows VHF and Linux UHID also answer the +controller's two 64-byte feature-report channels used for initialization and +lizard-mode settings. diff --git a/docs/windows-driver.md b/docs/windows-driver.md index ac714753..ecf614d7 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -153,6 +153,11 @@ can therefore query the current controller and battery state even when they do not consume the streaming read queue. Unnumbered reports are returned with the leading zero report-ID byte expected by Windows HID APIs. +Steam Controller battery report `0x43` is intentionally smaller than its main +`0x42` state report. The driver validates that alternate native length and +caches both report IDs independently, rather than padding the battery payload +into a state-sized packet. + For the Xbox One and Xbox Series VHF profiles, this HID input value is separate from the battery result returned by XInput. On a Windows desktop where XInput enumerated one of those VHF Xbox devices, `XInputGetBatteryInformation` returned @@ -327,7 +332,9 @@ held inputs. Mouse controls provide relative movement, five momentary buttons, vertical scrolling, and horizontal panning. Use Tab or the arrow keys to highlight a mouse control and Space or Enter to activate it, avoiding use of the physical mouse while testing the virtual device. A mouse button remains pressed -only while its activation key is held. +only while its activation key is held. The UI does not open gamepads for its own +navigation, and losing focus or pointer presence releases momentary buttons to +avoid leaving Guide or another input held while Steam displays an overlay. For an external mouse-event tester, enable **Delayed browser test**, choose a delay, and activate the desired action. Switch to the browser before the @@ -538,6 +545,16 @@ gyroscope updates. Its Set Player Lights subcommand is normalized into solid and flashing player-indicator output states for the creating runtime, and its monochrome HOME light is normalized as equal RGB channels so existing streaming LED feedback paths can preserve its intensity. + +The Steam Controller (2026) VHF profile exposes Valve's native wired +`VID_28DE&PID_1302` descriptor. The client backend submits a newly packed +`0x42` state report every 4.032 milliseconds, preserving held controls while +advancing its sequence and motion timestamp. It submits battery changes through +the native `0x43` report and the driver supports both 64-byte feature-report +channels used for controller attributes, strings, and settings. Output `0x80` +is decoded as ordinary rumble; targeted pulse, command, tone, sweep, and script +reports `0x81` through `0x85` are decoded as addressable haptic output without +losing their raw HID payload. 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 d34f6941..4e68f35d 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 == "steam") { + return lvh::profiles::steam_controller_2026(); + } return std::nullopt; } @@ -58,6 +61,7 @@ namespace { case switch_pro: return nintendo; case generic: + case steam_controller_2026: return unknown; } diff --git a/src/core/gamepad_adapter.cpp b/src/core/gamepad_adapter.cpp index 9be266c8..3b59b3f9 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_controller_2026: return true; case xbox_360: case xbox_one: @@ -87,9 +88,13 @@ namespace lvh { support.supports_player_leds = profile.capabilities.supports_player_leds; support.supports_motion = profile.capabilities.supports_motion; support.supports_touchpad = profile.capabilities.supports_touchpad; + support.supported_touchpad_count = profile.capabilities.supported_touchpad_count; 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; + support.supports_touchpad_button = + profile.capabilities.supports_touchpad && profile.gamepad_kind != GamepadProfileKind::steam_controller_2026; + support.supported_rear_paddle_count = profile.capabilities.supported_rear_paddle_count; + support.supports_haptics = profile.capabilities.supports_haptics; return support; } @@ -111,6 +116,20 @@ namespace lvh { if (button == touchpad) { return support.supports_touchpad_button; } + if (button == left_touchpad) { + return profile.gamepad_kind == GamepadProfileKind::steam_controller_2026 && + support.supported_touchpad_count >= 1U; + } + if (button == right_touchpad) { + return profile.gamepad_kind == GamepadProfileKind::steam_controller_2026 && + support.supported_touchpad_count >= 2U; + } + if ( + button == left_trigger_click || button == right_trigger_click || button == left_stick_touch || + button == right_stick_touch || button == left_grip_touch || button == right_grip_touch + ) { + return profile.gamepad_kind == GamepadProfileKind::steam_controller_2026 && support.supports_touchpad; + } const auto paddle_count = support.supported_rear_paddle_count; switch (button) { @@ -146,6 +165,8 @@ namespace lvh { return support.supports_adaptive_triggers; case player_leds: return support.supports_player_leds; + case haptics: + return support.supports_haptics; case raw_report: return profile.output_report_size > 0U; } @@ -348,7 +369,13 @@ namespace lvh { return OperationStatus::failure(ErrorCode::invalid_argument, "touchpad contact index is out of range"); } - state_.touchpad_contacts[index] = {}; + // Keep the last position when lifting a finger. A Steam Controller host + // interprets the release report's pad axes as motion before contact ends; + // resetting them to (0, 0) would create a large artificial final delta. + auto &contact = state_.touchpad_contacts[index]; + contact.id = 0; + contact.active = false; + contact.pressure = 0.0F; return submit(); } diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index 6b086246..8ec02cff 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -41,6 +41,12 @@ namespace lvh::profiles { constexpr std::size_t switch_pro_output_report_size = 64; + constexpr std::uint8_t steam_controller_2026_report_id = 0x42; + + constexpr std::size_t steam_controller_2026_input_report_size = 54; + + constexpr std::size_t steam_controller_2026_output_report_size = 10; + constexpr std::size_t dualshock4_usb_input_report_size = 64; constexpr std::size_t dualshock4_usb_output_report_size = 32; @@ -269,6 +275,22 @@ namespace lvh::profiles { return bytes_from_hex(descriptor); } + std::vector make_steam_controller_2026_report_descriptor() { + // Native Valve Triton wired descriptor. It includes the lizard-mode mouse + // and keyboard collections, native state/battery reports, six haptic + // output reports, and the two 64-byte feature-report channels. + return bytes_from_hex( + "05010902a10185400901a100050919012902150025017501950281027506950181010501093009311581257f750895028106" + "950109388106050c0a380295018106c0c005010906a1018541050719e029e715002501750195088102810119002965150025" + "65750895068100c00600ff0901a1018542150026ff0075089535094281028544150026ff0075089505094481028579150026" + "ff0075089501097981028543150026ff007508950e09438102857b150026ff007508950c097b81028545150026ff00750895" + "2d094581028580150026ff0075089509098091028581150026ff0075089507098191028582150026ff007508950309829102" + "8583150026ff0075089509098391028584150026ff0075089508098491028585150026ff0075089503098591028586150026" + "ff0075089503098691028587150026ff007508953f098791028589150026ff007508953f098991028588150026ff00750895" + "3f098891028501953f0901b1028502953f0901b102c0" + ); + } + std::vector make_gamepad_report_descriptor(std::uint8_t report_id, bool supports_rumble) { std::vector descriptor { 0x05, @@ -1988,6 +2010,7 @@ namespace lvh::profiles { .supports_touchpad = true, .supports_rgb_led = true, .supports_battery = true, + .supported_touchpad_count = 1, }; profile.report_descriptor = bus_type == BusType::bluetooth ? make_dualshock4_bluetooth_report_descriptor() : make_dualshock4_usb_report_descriptor(); @@ -2016,6 +2039,7 @@ namespace lvh::profiles { .supports_rgb_led = true, .supports_battery = true, .supports_adaptive_triggers = true, + .supported_touchpad_count = 1, }; profile.report_descriptor = bus_type == BusType::bluetooth ? make_dualsense_bluetooth_report_descriptor() : make_dualsense_usb_report_descriptor(); @@ -2046,6 +2070,32 @@ namespace lvh::profiles { return profile; } + DeviceProfile make_steam_controller_2026_profile() { + DeviceProfile profile; + profile.device_type = DeviceType::gamepad; + profile.gamepad_kind = GamepadProfileKind::steam_controller_2026; + profile.bus_type = BusType::usb; + profile.vendor_id = 0x28DE; + profile.product_id = 0x1302; + profile.version = 0x0001; + profile.report_id = steam_controller_2026_report_id; + profile.input_report_size = steam_controller_2026_input_report_size; + profile.output_report_size = steam_controller_2026_output_report_size; + profile.name = "Steam Controller"; + profile.manufacturer = "Valve Software"; + profile.capabilities = { + .supports_rumble = true, + .supports_motion = true, + .supports_touchpad = true, + .supports_battery = true, + .supports_haptics = true, + .supported_touchpad_count = 2, + .supported_rear_paddle_count = 4, + }; + profile.report_descriptor = make_steam_controller_2026_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; @@ -2134,6 +2184,10 @@ namespace lvh::profiles { return make_switch_pro_profile(); } + DeviceProfile steam_controller_2026() { + return make_steam_controller_2026_profile(); + } + DeviceProfile keyboard() { return make_simple_profile(DeviceType::keyboard, "libvirtualhid Keyboard", 0x0002); } @@ -2170,6 +2224,8 @@ namespace lvh::profiles { return dualsense(); case GamepadProfileKind::switch_pro: return switch_pro(); + case GamepadProfileKind::steam_controller_2026: + return steam_controller_2026(); } return std::nullopt; @@ -2184,6 +2240,7 @@ namespace lvh::profiles { dualshock4(), dualsense(), switch_pro(), + steam_controller_2026(), }; } diff --git a/src/core/report.cpp b/src/core/report.cpp index 9ba47aa1..5d5f2a01 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -85,6 +86,18 @@ namespace lvh::reports { constexpr float switch_gyroscope_scale = 14.2842F; + constexpr std::uint8_t steam_controller_state_report_id = 0x42; + + constexpr std::uint8_t steam_controller_battery_report_id = 0x43; + + constexpr std::size_t steam_controller_input_report_size = 54; + + constexpr std::size_t steam_controller_battery_report_size = 15; + + constexpr float steam_controller_acceleration_scale = 32768.0F / (2.0F * 9.80665F); + + constexpr float steam_controller_gyroscope_scale = 32768.0F / 2000.0F; + std::uint8_t decode_switch_home_light_intensity(std::byte encoded_intensity) { const auto intensity = std::to_integer(encoded_intensity >> 4U); if (intensity == 0U) { @@ -538,6 +551,90 @@ namespace lvh::reports { return bits; } + std::uint32_t steam_controller_button_bits(const ButtonSet &buttons) { + using enum GamepadButton; + + constexpr std::array mapping { + std::pair {a, 0x00000001U}, + std::pair {b, 0x00000002U}, + std::pair {x, 0x00000004U}, + std::pair {y, 0x00000008U}, + std::pair {misc1, 0x00000010U}, + std::pair {right_stick, 0x00000020U}, + std::pair {start, 0x00000040U}, + std::pair {paddle1, 0x00000080U}, + std::pair {paddle3, 0x00000100U}, + std::pair {right_shoulder, 0x00000200U}, + std::pair {dpad_down, 0x00000400U}, + std::pair {dpad_right, 0x00000800U}, + std::pair {dpad_left, 0x00001000U}, + std::pair {dpad_up, 0x00002000U}, + std::pair {back, 0x00004000U}, + std::pair {left_stick, 0x00008000U}, + std::pair {guide, 0x00010000U}, + std::pair {paddle2, 0x00020000U}, + std::pair {paddle4, 0x00040000U}, + std::pair {left_shoulder, 0x00080000U}, + std::pair {right_stick_touch, 0x00100000U}, + std::pair {right_touchpad, 0x00400000U}, + std::pair {right_trigger_click, 0x00800000U}, + std::pair {left_stick_touch, 0x01000000U}, + std::pair {left_touchpad, 0x04000000U}, + std::pair {left_trigger_click, 0x08000000U}, + std::pair {right_grip_touch, 0x10000000U}, + std::pair {left_grip_touch, 0x20000000U}, + }; + + auto value = std::uint32_t {}; + for (const auto &[button, bit] : mapping) { + if (buttons.test(button)) { + value |= bit; + } + } + return value; + } + + std::uint8_t steam_controller_sequence_number() { + static std::atomic_uint32_t sequence_number = 0; + return static_cast(sequence_number.fetch_add(1U, std::memory_order_relaxed)); + } + + std::uint32_t steam_controller_sensor_timestamp() { + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch() + ) + .count(); + return static_cast(elapsed); + } + + std::int16_t steam_controller_trackpad_axis(float coordinate, bool invert) { + const auto normalized = (std::clamp(coordinate, 0.0F, 1.0F) - 0.5F) * 2.0F; + return normalize_axis(invert ? -normalized : normalized); + } + + std::uint16_t steam_controller_pressure(float pressure) { + return static_cast(std::lround(std::clamp(pressure, 0.0F, 1.0F) * 32768.0F)); + } + + std::uint8_t steam_controller_charge_state(GamepadBatteryState state) { + switch (state) { + using enum GamepadBatteryState; + + case discharging: + return 1; + case charging: + return 2; + case full: + return 4; + case unknown: + case voltage_or_temperature_error: + case temperature_error: + case charging_error: + return 0; + } + return 0; + } + std::uint16_t common_button_bits(const ButtonSet &buttons) { return static_cast( button_bits(face_shoulder_button_map(), buttons) | @@ -1017,6 +1114,138 @@ namespace lvh::reports { } } + GamepadHapticTarget steam_controller_haptic_target(std::uint8_t value) { + using enum GamepadHapticTarget; + + switch (value) { + case 1: + return left; + case 2: + return right; + case 3: + return both; + default: + return none; + } + } + + GamepadHapticEffectKind steam_controller_haptic_effect_kind(std::uint8_t value) { + using enum GamepadHapticEffectKind; + + switch (value) { + case 1: + return tick; + case 2: + return click; + case 3: + return tone; + case 4: + return rumble; + case 5: + return noise; + case 6: + return script; + case 7: + return logarithmic_sweep; + default: + return off; + } + } + + std::optional decode_steam_controller_haptic_effect( + const std::vector &report + ) { + using enum GamepadHapticEffectKind; + + if (report.empty()) { + return std::nullopt; + } + + GamepadHapticEffect effect; + switch (report[0]) { + case 0x81U: + if (report.size() < 8U) { + return std::nullopt; + } + effect.target = steam_controller_haptic_target(report[1]); + effect.kind = pulse; + effect.duration_us = read_u16(report, 2U); + effect.interval_us = read_u16(report, 4U); + effect.repeat_count = read_u16(report, 6U); + break; + case 0x82U: + if (report.size() < 4U) { + return std::nullopt; + } + effect.target = steam_controller_haptic_target(report[1]); + effect.kind = steam_controller_haptic_effect_kind(report[2]); + effect.gain_db = static_cast(report[3]); + break; + case 0x83U: + if (report.size() < 10U) { + return std::nullopt; + } + effect.target = steam_controller_haptic_target(report[1]); + effect.kind = tone; + effect.gain_db = static_cast(report[2]); + effect.frequency_hz = read_u16(report, 3U); + effect.duration_us = static_cast(read_u16(report, 5U)) * 1000; + effect.lfo_frequency_hz = read_u16(report, 7U); + effect.lfo_depth_percent = report[9]; + break; + case 0x84U: + if (report.size() < 9U) { + return std::nullopt; + } + effect.target = steam_controller_haptic_target(report[1]); + effect.kind = logarithmic_sweep; + effect.gain_db = static_cast(report[2]); + effect.duration_us = static_cast(read_u16(report, 3U)) * 1000; + effect.start_frequency_hz = read_u16(report, 5U); + effect.end_frequency_hz = read_u16(report, 7U); + break; + case 0x85U: + if (report.size() < 4U) { + return std::nullopt; + } + effect.target = steam_controller_haptic_target(report[1]); + effect.kind = script; + effect.script_id = report[2]; + effect.gain_db = static_cast(report[3]); + break; + default: + return std::nullopt; + } + return effect; + } + + std::optional decode_steam_controller_output_report( + const std::vector &report + ) { + if (report.empty()) { + return std::nullopt; + } + + GamepadOutput output; + output.raw_report = report; + if (report[0] == 0x80U) { + if (report.size() < 10U) { + return std::nullopt; + } + output.kind = GamepadOutputKind::rumble; + output.low_frequency_rumble = read_u16(report, 4U); + output.high_frequency_rumble = read_u16(report, 7U); + return output; + } + + if (const auto effect = decode_steam_controller_haptic_effect(report); effect.has_value()) { + output.kind = GamepadOutputKind::haptics; + output.haptic_effect = *effect; + return output; + } + return std::nullopt; + } + } // namespace float clamp_axis(float value) { @@ -1064,6 +1293,7 @@ namespace lvh::reports { for (auto &contact : normalized.touchpad_contacts) { contact.x = std::clamp(contact.x, 0.0F, 1.0F); contact.y = std::clamp(contact.y, 0.0F, 1.0F); + contact.pressure = std::clamp(contact.pressure, 0.0F, 1.0F); } if (normalized.battery) { normalized.battery->percentage = std::min(100U, normalized.battery->percentage); @@ -1314,6 +1544,55 @@ namespace lvh::reports { return to_uint8_report(report); } + std::vector pack_steam_controller_input_report( + const DeviceProfile &profile, + const GamepadState &state + ) { + using enum GamepadButton; + + if (profile.input_report_size < steam_controller_input_report_size) { + return {}; + } + + const auto normalized = normalize_state(state); + const auto acceleration = normalized.acceleration.value_or(Vector3 {.y = 9.80665F}); + const auto gyroscope = normalized.gyroscope.value_or(Vector3 {}); + + auto buttons = steam_controller_button_bits(normalized.buttons); + if (normalized.touchpad_contacts[0].active) { + buttons |= 0x02000000U; + } + if (normalized.touchpad_contacts[1].active) { + buttons |= 0x00200000U; + } + + ByteReport report(profile.input_report_size, zero_byte); + report[0] = to_byte(steam_controller_state_report_id); + report[1] = to_byte(steam_controller_sequence_number()); + write_u32(report, 2U, buttons); + write_i16(report, 6U, static_cast(std::lround(normalized.left_trigger * 32767.0F))); + write_i16(report, 8U, static_cast(std::lround(normalized.right_trigger * 32767.0F))); + write_i16(report, 10U, normalize_axis(normalized.left_stick.x)); + write_i16(report, 12U, normalize_axis(normalized.left_stick.y)); + write_i16(report, 14U, normalize_axis(normalized.right_stick.x)); + write_i16(report, 16U, normalize_axis(normalized.right_stick.y)); + write_i16(report, 18U, steam_controller_trackpad_axis(normalized.touchpad_contacts[0].x, false)); + write_i16(report, 20U, steam_controller_trackpad_axis(normalized.touchpad_contacts[0].y, true)); + write_u16(report, 22U, steam_controller_pressure(normalized.touchpad_contacts[0].pressure)); + write_i16(report, 24U, steam_controller_trackpad_axis(normalized.touchpad_contacts[1].x, false)); + write_i16(report, 26U, steam_controller_trackpad_axis(normalized.touchpad_contacts[1].y, true)); + write_u16(report, 28U, steam_controller_pressure(normalized.touchpad_contacts[1].pressure)); + write_u32(report, 30U, steam_controller_sensor_timestamp()); + write_i16(report, 34U, scale_i16(acceleration.x, steam_controller_acceleration_scale)); + write_i16(report, 36U, scale_i16(-acceleration.z, steam_controller_acceleration_scale)); + write_i16(report, 38U, scale_i16(acceleration.y, steam_controller_acceleration_scale)); + write_i16(report, 40U, scale_i16(gyroscope.x, steam_controller_gyroscope_scale)); + write_i16(report, 42U, scale_i16(-gyroscope.z, steam_controller_gyroscope_scale)); + write_i16(report, 44U, scale_i16(gyroscope.y, steam_controller_gyroscope_scale)); + write_i16(report, 46U, std::numeric_limits::max()); + return to_uint8_report(report); + } + std::vector pack_input_report(const DeviceProfile &profile, const GamepadState &state) { if (profile.device_type == DeviceType::gamepad) { switch (profile.gamepad_kind) { @@ -1328,6 +1607,8 @@ namespace lvh::reports { return pack_dualsense_input_report(profile, state); case switch_pro: return pack_switch_pro_input_report(profile, state); + case steam_controller_2026: + return pack_steam_controller_input_report(profile, state); case generic: return pack_standard_gamepad_input_report(profile, state); case xbox_360: @@ -1357,6 +1638,24 @@ namespace lvh::reports { return report; } + std::optional> pack_battery_report( + const DeviceProfile &profile, + const GamepadBattery &battery + ) { + if ( + profile.device_type != DeviceType::gamepad || + profile.gamepad_kind != GamepadProfileKind::steam_controller_2026 + ) { + return std::nullopt; + } + + std::vector report(steam_controller_battery_report_size, 0U); + report[0] = steam_controller_battery_report_id; + report[1] = steam_controller_charge_state(battery.state); + report[2] = std::min(battery.percentage, 100U); + return report; + } + GamepadOutput parse_output_report(const DeviceProfile &profile, const std::vector &report) { if (const auto outputs = parse_output_reports(profile, report); !outputs.empty()) { return outputs.front(); @@ -1370,6 +1669,13 @@ namespace lvh::reports { std::vector parse_output_reports(const DeviceProfile &profile, const std::vector &report) { std::vector outputs; + if (profile.gamepad_kind == GamepadProfileKind::steam_controller_2026) { + if (auto output = decode_steam_controller_output_report(report); output.has_value()) { + outputs.push_back(std::move(*output)); + return outputs; + } + } + if (profile.gamepad_kind == GamepadProfileKind::dualshock4) { const auto byte_report = to_byte_report(report); if (const auto offset = dualshock4_common_output_offset(byte_report); offset.has_value()) { diff --git a/src/include/libvirtualhid/gamepad_adapter.hpp b/src/include/libvirtualhid/gamepad_adapter.hpp index 0e886a68..b9f89a9f 100644 --- a/src/include/libvirtualhid/gamepad_adapter.hpp +++ b/src/include/libvirtualhid/gamepad_adapter.hpp @@ -50,6 +50,11 @@ namespace lvh { */ bool supports_touchpad = false; + /** + * @brief Number of independently addressable touchpads exposed by the profile. + */ + std::uint8_t supported_touchpad_count = 0; + /** * @brief Whether the profile exposes battery state input. */ @@ -70,6 +75,11 @@ namespace lvh { */ std::uint8_t supported_rear_paddle_count = 0; + /** + * @brief Whether the profile supports addressable haptic effects. + */ + bool supports_haptics = false; + /** * @brief Whether the profile supports player indicator LED output. */ diff --git a/src/include/libvirtualhid/profiles.hpp b/src/include/libvirtualhid/profiles.hpp index 4db46a90..23b507b9 100644 --- a/src/include/libvirtualhid/profiles.hpp +++ b/src/include/libvirtualhid/profiles.hpp @@ -104,6 +104,18 @@ namespace lvh::profiles { */ DeviceProfile switch_pro(); + /** + * @brief Create the Valve Steam Controller (2026)-compatible gamepad profile. + * + * This wired USB profile exposes the controller's native report protocol, + * including both pressure-sensitive touchpads, motion sensors, battery + * reports, four rear buttons, capacitive sensors, rumble, and addressable + * touchpad haptics. + * + * @return Steam Controller (2026)-compatible device profile. + */ + DeviceProfile steam_controller_2026(); + /** * @brief Create the generic keyboard profile. * diff --git a/src/include/libvirtualhid/report.hpp b/src/include/libvirtualhid/report.hpp index 76b103d8..ef30e8ee 100644 --- a/src/include/libvirtualhid/report.hpp +++ b/src/include/libvirtualhid/report.hpp @@ -6,6 +6,7 @@ // standard includes #include +#include #include // local includes @@ -70,6 +71,18 @@ namespace lvh::reports { */ std::vector pack_input_report(const DeviceProfile &profile, const GamepadState &state); + /** + * @brief Pack a profile-specific battery input report when the profile uses one. + * + * @param profile Device profile used for report identity and layout. + * @param battery Battery metadata to pack. + * @return Packed report, or `std::nullopt` when the profile has no separate battery report. + */ + std::optional> pack_battery_report( + const DeviceProfile &profile, + const GamepadBattery &battery + ); + /** * @brief Parse a backend output report into the profile-neutral output model. * diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index 7f3bc1c5..5d1d3457 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_controller_2026, ///< Valve Steam Controller (2026)-compatible profile. }; /** @@ -280,6 +281,21 @@ namespace lvh { * @brief Whether the profile supports player indicator LED output. */ bool supports_player_leds = false; + + /** + * @brief Whether the profile supports profile-neutral haptic effects. + */ + bool supports_haptics = false; + + /** + * @brief Number of independently addressable touchpads exposed by the profile. + */ + std::uint8_t supported_touchpad_count = 0; + + /** + * @brief Number of rear paddle buttons exposed by the profile. + */ + std::uint8_t supported_rear_paddle_count = 0; }; /** @@ -527,6 +543,14 @@ namespace lvh { paddle2, ///< Second rear paddle button. paddle3, ///< Third rear paddle button. paddle4, ///< Fourth rear paddle button. + left_touchpad, ///< Left touchpad click button. + right_touchpad, ///< Right touchpad click button. + left_trigger_click, ///< Left trigger digital click. + right_trigger_click, ///< Right trigger digital click. + left_stick_touch, ///< Left stick capacitive touch state. + right_stick_touch, ///< Right stick capacitive touch state. + left_grip_touch, ///< Left grip capacitive touch state. + right_grip_touch, ///< Right grip capacitive touch state. }; /** @@ -659,6 +683,11 @@ namespace lvh { * @brief Normalized Y coordinate in the inclusive range `[0.0, 1.0]`. */ float y = 0.0F; + + /** + * @brief Normalized contact pressure in the inclusive range `[0.0, 1.0]`. + */ + float pressure = 0.0F; }; /** @@ -1009,6 +1038,51 @@ namespace lvh { raw_report, ///< Raw output report bytes. trigger_rumble, ///< Independent trigger rumble output. player_leds, ///< Player indicator LED output. + haptics, ///< Addressable haptic effect output. + }; + + /** + * @brief Addressable gamepad haptic actuator selection. + */ + enum class GamepadHapticTarget : std::uint8_t { + none = 0, ///< No actuator selected. + left = 1, ///< Left actuator or touchpad. + right = 2, ///< Right actuator or touchpad. + both = 3, ///< Both actuators or touchpads. + }; + + /** + * @brief Profile-neutral haptic effect category. + */ + enum class GamepadHapticEffectKind : std::uint8_t { + off, ///< Stop the selected haptic actuator. + tick, ///< Short tick effect. + click, ///< Click effect. + tone, ///< Constant-frequency tone. + rumble, ///< Haptic rumble effect. + noise, ///< Noise effect. + script, ///< Controller-defined scripted effect. + logarithmic_sweep, ///< Logarithmic frequency sweep. + pulse, ///< Repeated on/off pulse. + }; + + /** + * @brief Addressable haptic effect parameters. + */ + struct GamepadHapticEffect { + GamepadHapticTarget target = GamepadHapticTarget::none; ///< Target actuator selection. + GamepadHapticEffectKind kind = GamepadHapticEffectKind::off; ///< Effect category. + std::int8_t gain_db = 0; ///< Signed gain in decibels. + std::uint16_t intensity = 0; ///< Profile-defined intensity value. + std::uint16_t frequency_hz = 0; ///< Primary tone frequency in hertz. + std::int32_t duration_us = 0; ///< Effect duration in microseconds; negative means indefinite. + std::uint32_t interval_us = 0; ///< Off interval between pulses in microseconds. + std::uint16_t repeat_count = 0; ///< Pulse repeat count. + std::uint16_t lfo_frequency_hz = 0; ///< Low-frequency oscillator frequency in hertz. + std::uint8_t lfo_depth_percent = 0; ///< Low-frequency oscillator depth in percent. + std::uint16_t start_frequency_hz = 0; ///< Sweep start frequency in hertz. + std::uint16_t end_frequency_hz = 0; ///< Sweep end frequency in hertz. + std::uint8_t script_id = 0; ///< Controller-defined scripted effect identifier. }; /** @@ -1094,6 +1168,11 @@ namespace lvh { * @brief Flashing player indicator LED states, ordered from player one through four. */ std::array flashing_player_leds {}; + + /** + * @brief Parsed addressable haptic effect, when supplied by the profile. + */ + std::optional haptic_effect; }; /** diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index b2e0a2cf..d0c74e4e 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_controller_protocol.hpp" #include "shared/switch_pro_protocol.hpp" #endif @@ -598,6 +599,7 @@ namespace lvh::detail { case switch_pro: case dualshock4: case dualsense: + case steam_controller_2026: #if defined(__FreeBSD__) return true; #else @@ -616,6 +618,7 @@ namespace lvh::detail { case switch_pro: case dualshock4: case dualsense: + case steam_controller_2026: case xbox_one: case xbox_series: return true; @@ -721,6 +724,7 @@ namespace lvh::detail { return KEY_RECORD; case switch_pro: return BTN_Z; + case steam_controller_2026: case xbox_360: case xbox_one: case dualshock4: @@ -752,7 +756,11 @@ namespace lvh::detail { // parent in sysfs. UHID devices do not, so expose transports handled by // HIDAPI through the Bluetooth HID enumeration path. Xbox transport // profiles use the corresponding native Bluetooth identity and framing. - if (profile.gamepad_kind == GamepadProfileKind::switch_pro || is_xbox_uhid_profile(profile.gamepad_kind)) { + if ( + profile.gamepad_kind == GamepadProfileKind::switch_pro || + profile.gamepad_kind == GamepadProfileKind::steam_controller_2026 || + is_xbox_uhid_profile(profile.gamepad_kind) + ) { return BUS_BLUETOOTH; } return to_uhid_bus(profile.bus_type); @@ -3382,6 +3390,16 @@ namespace lvh::detail { }; #if defined(__linux__) + struct UhidOutputState { + std::mutex mutex; + OutputCallback callback; + }; + + struct UhidSteamFeatureState { + std::mutex mutex; + steam_controller_protocol::FeatureState state; + }; + /** * @brief Backend gamepad backed by one Linux UHID file descriptor. */ @@ -3463,7 +3481,10 @@ namespace lvh::detail { return status; } - if (is_playstation_profile(profile_.gamepad_kind)) { + if ( + is_playstation_profile(profile_.gamepad_kind) || + profile_.gamepad_kind == GamepadProfileKind::steam_controller_2026 + ) { periodic_reporter_ = std::jthread {[this](std::stop_token stop_token) { periodic_report_loop(stop_token); }}; @@ -3487,6 +3508,14 @@ namespace lvh::detail { if (status.ok() && supports_battery_ && state.battery && is_xbox_uhid_profile(profile_.gamepad_kind)) { status = write_input_report(make_xbox_bluetooth_battery_report(*state.battery)); } + if ( + status.ok() && supports_battery_ && state.battery && + profile_.gamepad_kind == GamepadProfileKind::steam_controller_2026 + ) { + if (const auto battery_report = reports::pack_battery_report(profile_, *state.battery); battery_report) { + status = write_input_report(*battery_report); + } + } if (status.ok()) { last_state_ = state; } @@ -3494,8 +3523,8 @@ namespace lvh::detail { } void set_output_callback(OutputCallback callback) override { - std::lock_guard lock {callback_mutex_}; - output_callback_ = std::move(callback); + std::lock_guard lock {output_.mutex}; + output_.callback = std::move(callback); } std::vector device_nodes() const override { @@ -3676,8 +3705,13 @@ namespace lvh::detail { } void periodic_report_loop(std::stop_token stop_token) { + const auto interval = profile_.gamepad_kind == GamepadProfileKind::steam_controller_2026 ? + std::chrono::microseconds {steam_controller_protocol::input_interval_us} : + std::chrono::duration_cast( + std::chrono::milliseconds {playstation_periodic_report_ms} + ); while (!stop_token.stop_requested() && running_) { - std::this_thread::sleep_for(std::chrono::milliseconds {playstation_periodic_report_ms}); + std::this_thread::sleep_for(interval); if (stop_token.stop_requested() || !running_ || !open_) { break; } @@ -3713,14 +3747,18 @@ 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_controller_2026) { + std::lock_guard lock {steam_feature_.mutex}; + static_cast(steam_feature_.state.handle_set_feature(report_number, report)); + } dispatch_output_report(report); } void dispatch_output_report(const std::vector &report) { OutputCallback callback; { - std::lock_guard lock {callback_mutex_}; - callback = output_callback_; + std::lock_guard lock {output_.mutex}; + callback = output_.callback; } if (!callback) { @@ -3785,6 +3823,14 @@ namespace lvh::detail { event.u.get_report_reply.err = EINVAL; break; } + } else if (profile_.gamepad_kind == GamepadProfileKind::steam_controller_2026) { + std::lock_guard lock {steam_feature_.mutex}; + if (const auto report = steam_feature_.state.get_feature_report(report_number); report) { + event.u.get_report_reply.err = 0; + copy_get_report_payload(event, *report); + } else { + event.u.get_report_reply.err = EINVAL; + } } if ( @@ -3821,6 +3867,7 @@ namespace lvh::detail { std::string physical_id_; std::string unique_id_; std::array playstation_mac_address_ {}; + UhidSteamFeatureState steam_feature_; GamepadState last_state_; bool supports_battery_ = false; std::atomic_bool open_ = true; @@ -3833,8 +3880,7 @@ namespace lvh::detail { bool reader_exited_ = false; std::mutex write_mutex_; std::mutex state_mutex_; - std::mutex callback_mutex_; - OutputCallback output_callback_; + UhidOutputState output_; }; #endif @@ -3849,6 +3895,9 @@ namespace lvh::detail { effective_profile.capabilities.supports_battery = false; effective_profile.capabilities.supports_adaptive_triggers = false; effective_profile.capabilities.supports_player_leds = false; + effective_profile.capabilities.supports_haptics = false; + effective_profile.capabilities.supported_touchpad_count = 0; + effective_profile.capabilities.supported_rear_paddle_count = 0; return effective_profile; #else if (!effective_profile.capabilities.supports_trigger_rumble) { diff --git a/src/platform/windows/broker/broker_request_validation.hpp b/src/platform/windows/broker/broker_request_validation.hpp index e6d4e57f..6aec18c4 100644 --- a/src/platform/windows/broker/broker_request_validation.hpp +++ b/src/platform/windows/broker/broker_request_validation.hpp @@ -29,7 +29,8 @@ namespace lvh::windows::broker_validation { LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_TOUCHPAD | LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RGB_LED | LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_BATTERY | - LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_ADAPTIVE_TRIGGERS; + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_ADAPTIVE_TRIGGERS | + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_HAPTICS; template bool all_zero(const std::array &values) { @@ -85,7 +86,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_CONTROLLER_2026; const auto valid_driver_descriptor = request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD || (request.device_type == LVH_WINDOWS_DEVICE_KEYBOARD && diff --git a/src/platform/windows/control_protocol.hpp b/src/platform/windows/control_protocol.hpp index 23f488ec..a42885e5 100644 --- a/src/platform/windows/control_protocol.hpp +++ b/src/platform/windows/control_protocol.hpp @@ -56,6 +56,9 @@ namespace lvh::detail::windows { if (capabilities.supports_adaptive_triggers) { flags |= LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_ADAPTIVE_TRIGGERS; } + if (capabilities.supports_haptics) { + flags |= LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_HAPTICS; + } return flags; } @@ -112,6 +115,8 @@ namespace lvh::detail::windows { return LVH_WINDOWS_GAMEPAD_DUALSENSE; case switch_pro: return LVH_WINDOWS_GAMEPAD_SWITCH_PRO; + case steam_controller_2026: + return LVH_WINDOWS_GAMEPAD_STEAM_CONTROLLER_2026; } return LVH_WINDOWS_GAMEPAD_GENERIC; diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp index 70d1562a..9d735569 100644 --- a/src/platform/windows/driver/libvirtualhid_umdf.cpp +++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp @@ -57,6 +57,7 @@ #include "mouse_protocol.hpp" #include "playstation_feature_protocol.hpp" #include "rotating_trace_log.hpp" +#include "shared/steam_controller_protocol.hpp" #include "shared/switch_pro_protocol.hpp" #include "unique_win32_handle.hpp" #include "vhf_input_report_queue.hpp" @@ -93,7 +94,8 @@ namespace { LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_TOUCHPAD | LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RGB_LED | LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_BATTERY | - LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_ADAPTIVE_TRIGGERS; + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_ADAPTIVE_TRIGGERS | + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_HAPTICS; using UniqueServiceHandle = std::unique_ptr< std::remove_pointer_t, @@ -122,6 +124,7 @@ namespace { std::vector report_descriptor; std::wstring hardware_ids; lvh::detail::windows::GenericPidFeatureState generic_pid_feature_state; + lvh::detail::steam_controller_protocol::FeatureState steam_controller_feature_state; lvh::detail::windows::VhfInputReportQueue pending_input_reports; std::map> latest_input_reports; std::shared_ptr> in_flight_input_report; @@ -601,7 +604,7 @@ namespace { 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_CONTROLLER_2026; const auto valid_driver_descriptor = (request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD && request.gamepad_kind != LVH_WINDOWS_GAMEPAD_XBOX_360) || @@ -843,7 +846,11 @@ namespace { const DeviceRecord &record, const LvhWindowsSubmitInputReportRequest &request ) { - if (request.report_size != record.request.report_sizes.input_report_size) { + const auto steam_battery_report = + record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_STEAM_CONTROLLER_2026 && + request.report_size == lvh::detail::steam_controller_protocol::battery_report_size && + request.report[0] == lvh::detail::steam_controller_protocol::battery_report_id; + if (request.report_size != record.request.report_sizes.input_report_size && !steam_battery_report) { return {}; } @@ -854,7 +861,7 @@ namespace { return {report_begin, report_end}; } - if (request.report[0] != report_id) { + if (request.report[0] != report_id && !steam_battery_report) { return {}; } @@ -918,6 +925,9 @@ namespace { ) { 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_CONTROLLER_2026) { + std::lock_guard lock {record.mutex}; + report = record.steam_controller_feature_state.get_feature_report(report_number); } else { report = lvh::detail::windows::make_playstation_feature_report(record.request, report_number); } @@ -976,6 +986,15 @@ namespace { {event.report.data(), event.report_size} ); } + if (record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_STEAM_CONTROLLER_2026) { + auto event = make_output_event(record, packet); + const auto report_id = packet.reportId == 0U && event.report_size > 0U ? event.report[0] : packet.reportId; + std::lock_guard lock {record.mutex}; + return record.steam_controller_feature_state.handle_set_feature( + static_cast(report_id), + {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 7d6be248..6d34f485 100644 --- a/src/platform/windows/shared/lvh_windows_protocol.h +++ b/src/platform/windows/shared/lvh_windows_protocol.h @@ -8,7 +8,7 @@ #include #include -inline constexpr uint32_t LVH_WINDOWS_CONTROL_PROTOCOL_VERSION = 5u; +inline constexpr uint32_t LVH_WINDOWS_CONTROL_PROTOCOL_VERSION = 6u; inline constexpr char LVH_WINDOWS_CONTROL_DEVICE_PATH[] = R"(\\.\LibVirtualHid)"; inline constexpr char LVH_WINDOWS_GLOBAL_CONTROL_DEVICE_PATH[] = R"(\\.\Global\LibVirtualHid)"; @@ -72,6 +72,7 @@ inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_TOUCHPAD = 0x0000000 inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RGB_LED = 0x00000008u; inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_BATTERY = 0x00000010u; inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_ADAPTIVE_TRIGGERS = 0x00000020u; +inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_HAPTICS = 0x00000040u; enum class LvhWindowsProtocolStatus : uint32_t { success = 0, @@ -101,6 +102,7 @@ enum class LvhWindowsGamepadProfileKind : uint32_t { dualsense = 4, switch_pro = 5, dualshock4 = 6, + steam_controller_2026 = 7, }; namespace lvh_windows_protocol_detail { @@ -133,6 +135,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_controller_2026 = to_uint32(steam_controller_2026); } // namespace lvh_windows_protocol_detail inline constexpr uint32_t LVH_WINDOWS_STATUS_SUCCESS = lvh_windows_protocol_detail::status_success; @@ -163,6 +166,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_CONTROLLER_2026 = + lvh_windows_protocol_detail::gamepad_steam_controller_2026; #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 cb2a6733..39ac6752 100644 --- a/src/platform/windows/shared/vhf_input_report_queue.hpp +++ b/src/platform/windows/shared/vhf_input_report_queue.hpp @@ -240,6 +240,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_CONTROLLER_2026) { + return equal_at(left, right, {0U, 2U, 3U, 4U, 5U}); + } 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 0d4dfbe4..ab644561 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -33,6 +33,7 @@ #include "platform/windows/shared/keyboard_protocol.hpp" #include "platform/windows/shared/mouse_protocol.hpp" #include "platform/windows/windows_broker_client.hpp" +#include "shared/steam_controller_protocol.hpp" #include #include @@ -1059,6 +1060,7 @@ namespace lvh::detail { bool uses_generic_pid = false; windows::GenericPidRumbleState generic_pid_rumble; std::vector switch_pro_input_report; + GamepadState steam_controller_state; }; class WindowsGamepad final: public BackendGamepad { @@ -1075,16 +1077,16 @@ namespace lvh::detail { OperationStatus close() override; private: - void stream_switch_pro_reports(std::stop_token stop_token); - void stop_switch_pro_report_stream(); + void stream_periodic_reports(std::stop_token stop_token); + void stop_periodic_report_stream(); std::shared_ptr context_; std::shared_ptr state_; std::mutex input_report_mutex_; std::optional> last_input_report_; - std::condition_variable switch_pro_report_ready_; - std::mutex switch_pro_report_mutex_; - std::jthread switch_pro_report_thread_; + std::condition_variable periodic_report_ready_; + std::mutex periodic_report_mutex_; + std::jthread periodic_report_thread_; UniqueHandle xbox360_output_stop_event_ {make_unique_handle(nullptr)}; std::jthread xbox360_output_thread_; @@ -1432,9 +1434,12 @@ namespace lvh::detail { ): context_ {std::move(context)}, state_ {std::move(state)} { - if (state_->profile.gamepad_kind == GamepadProfileKind::switch_pro) { - switch_pro_report_thread_ = std::jthread {[this](std::stop_token stop_token) { - stream_switch_pro_reports(stop_token); + if ( + state_->profile.gamepad_kind == GamepadProfileKind::switch_pro || + state_->profile.gamepad_kind == GamepadProfileKind::steam_controller_2026 + ) { + periodic_report_thread_ = std::jthread {[this](std::stop_token stop_token) { + stream_periodic_reports(stop_token); }}; } if (state_->xbox360_transport) { @@ -1448,15 +1453,18 @@ namespace lvh::detail { } WindowsGamepad::~WindowsGamepad() { - stop_switch_pro_report_stream(); + stop_periodic_report_stream(); stop_xbox360_output_stream(); } - void WindowsGamepad::stream_switch_pro_reports(std::stop_token stop_token) { - auto next_report = std::chrono::steady_clock::now() + switch_pro_report_interval; - std::unique_lock report_lock {switch_pro_report_mutex_}; + void WindowsGamepad::stream_periodic_reports(std::stop_token stop_token) { + const auto interval = state_->profile.gamepad_kind == GamepadProfileKind::steam_controller_2026 ? + std::chrono::microseconds {steam_controller_protocol::input_interval_us} : + std::chrono::duration_cast(switch_pro_report_interval); + auto next_report = std::chrono::steady_clock::now() + interval; + std::unique_lock report_lock {periodic_report_mutex_}; while (!stop_token.stop_requested()) { - static_cast(switch_pro_report_ready_.wait_until(report_lock, next_report, [&stop_token] { + static_cast(periodic_report_ready_.wait_until(report_lock, next_report, [&stop_token] { return stop_token.stop_requested(); })); if (stop_token.stop_requested()) { @@ -1470,29 +1478,31 @@ namespace lvh::detail { if (!state_->open) { return; } - report = state_->switch_pro_input_report; + report = state_->profile.gamepad_kind == GamepadProfileKind::steam_controller_2026 ? + reports::pack_input_report(state_->profile, state_->steam_controller_state) : + state_->switch_pro_input_report; } if (!report.empty()) { static_cast(context_->submit_device_report(state_, report)); } report_lock.lock(); - next_report += switch_pro_report_interval; + next_report += interval; const auto now = std::chrono::steady_clock::now(); if (next_report <= now) { - next_report = now + switch_pro_report_interval; + next_report = now + interval; } } } - void WindowsGamepad::stop_switch_pro_report_stream() { - if (!switch_pro_report_thread_.joinable()) { + void WindowsGamepad::stop_periodic_report_stream() { + if (!periodic_report_thread_.joinable()) { return; } - switch_pro_report_thread_.request_stop(); - switch_pro_report_ready_.notify_all(); - switch_pro_report_thread_.join(); + periodic_report_thread_.request_stop(); + periodic_report_ready_.notify_all(); + periodic_report_thread_.join(); } void WindowsGamepad::stream_xbox360_output(std::stop_token stop_token) { @@ -1550,6 +1560,7 @@ namespace lvh::detail { using enum ErrorCode; auto suppress_unchanged_report = false; + auto submit_steam_controller_report = false; { std::lock_guard lock {state_->mutex_}; @@ -1566,6 +1577,11 @@ namespace lvh::detail { return OperationStatus::success(); } + if (state_->profile.gamepad_kind == GamepadProfileKind::steam_controller_2026) { + state_->steam_controller_state = state; + submit_steam_controller_report = true; + } + if (state_->uses_generic_pid) { return context_->submit_device_report(state_, windows::make_generic_windows_input_report(report)); } @@ -1584,6 +1600,16 @@ namespace lvh::detail { state_->profile.gamepad_kind == GamepadProfileKind::xbox_series; } + if (submit_steam_controller_report) { + auto status = context_->submit_device_report(state_, report); + if (status.ok() && state.battery) { + if (const auto battery_report = reports::pack_battery_report(state_->profile, *state.battery); battery_report) { + status = context_->submit_device_report(state_, *battery_report); + } + } + return status; + } + if (suppress_unchanged_report) { // Xbox input reports have no sequence or timestamp field. Keep raw HID // consumers transition-driven when a streaming host repeats a held state. @@ -1617,7 +1643,7 @@ namespace lvh::detail { } OperationStatus WindowsGamepad::close() { - stop_switch_pro_report_stream(); + stop_periodic_report_stream(); stop_xbox360_output_stream(); return context_->close_device(state_); } diff --git a/src/shared/steam_controller_protocol.hpp b/src/shared/steam_controller_protocol.hpp new file mode 100644 index 00000000..999a7775 --- /dev/null +++ b/src/shared/steam_controller_protocol.hpp @@ -0,0 +1,109 @@ +/** + * @file src/shared/steam_controller_protocol.hpp + * @brief Shared Valve Steam Controller (2026) HID protocol helpers. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lvh::detail::steam_controller_protocol { + + inline constexpr std::uint8_t state_report_id = 0x42; + inline constexpr std::uint8_t battery_report_id = 0x43; + inline constexpr std::size_t state_report_size = 54; + inline constexpr std::size_t battery_report_size = 15; + inline constexpr std::size_t feature_report_size = 64; + inline constexpr auto input_interval_us = 4032; + + /** + * @brief Track the last feature command and synthesize native-shaped replies. + */ + class FeatureState { + public: + bool handle_set_feature(std::uint8_t report_id, std::span payload) { + const auto index = feature_index(report_id); + if (!index.has_value()) { + return false; + } + + auto normalized = std::vector {}; + normalized.reserve(std::min(feature_report_size, payload.size() + 1U)); + if (payload.empty() || payload.front() != report_id) { + normalized.push_back(report_id); + } + normalized.insert( + normalized.end(), + payload.begin(), + payload.begin() + std::min(payload.size(), feature_report_size - normalized.size()) + ); + normalized.resize(feature_report_size, 0U); + requests_[*index] = std::move(normalized); + return true; + } + + [[nodiscard]] std::optional> get_feature_report(std::uint8_t report_id) const { + const auto index = feature_index(report_id); + if (!index.has_value()) { + return std::nullopt; + } + + std::vector response(feature_report_size, 0U); + response[0] = report_id; + if (requests_[*index].size() < 2U) { + return response; + } + + const auto command = requests_[*index][1]; + response[1] = command; + if (command == 0x83U) { + // GET_ATTRIBUTES_VALUES: product ID, capabilities, and board revision. + constexpr std::array attributes { + 0x01, + 0x02, + 0x13, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x09, + 0x48, + 0x00, + 0x00, + 0x00, + }; + response[2] = static_cast(attributes.size()); + std::ranges::copy(attributes, response.begin() + 3U); + } else if (command == 0xAEU) { + // GET_STRING_ATTRIBUTE: echo the requested tag followed by a stable + // controller identifier in the protocol's 20-byte value field. + constexpr auto identifier = std::string_view {"LVH000000000001"}; + response[2] = 21U; + response[3] = requests_[*index].size() > 3U ? requests_[*index][3] : 1U; + std::ranges::copy(identifier, response.begin() + 4U); + } + return response; + } + + private: + static std::optional feature_index(std::uint8_t report_id) { + if (report_id == 1U || report_id == 2U) { + return static_cast(report_id - 1U); + } + return std::nullopt; + } + + std::array, 2> requests_; + }; + +} // namespace lvh::detail::steam_controller_protocol diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 84c2ceed..1fc3ec09 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -195,6 +195,11 @@ namespace lvh::detail::test { * @brief Independent trigger-rumble output observed during the round trip. */ std::optional trigger_rumble; + + /** + * @brief Addressable haptic output observed during the round trip. + */ + std::optional haptics; }; /** @@ -342,6 +347,26 @@ namespace lvh::detail::test { bool saw_player_leds = false; }; + /** + * @brief Steam Controller observations from a socketpair-backed UHID test. + */ + struct LinuxUhidSteamControllerObservation { + /** + * @brief Whether a submitted state arrived in native report `0x42`. + */ + bool saw_state_input = false; + + /** + * @brief Whether a submitted battery arrived in native report `0x43`. + */ + bool saw_battery_input = false; + + /** + * @brief Whether feature report 1 returned controller attributes. + */ + bool saw_attribute_feature_reply = false; + }; + /** * @brief Result from a socketpair-backed UHID lifecycle test. */ @@ -391,6 +416,11 @@ namespace lvh::detail::test { */ LinuxUhidSwitchProObservation switch_pro; + /** + * @brief Steam Controller protocol observations. + */ + LinuxUhidSteamControllerObservation steam_controller; + /** * @brief Xbox Bluetooth protocol observations. */ @@ -1055,6 +1085,13 @@ namespace lvh::detail::test { */ LinuxUhidRoundTripResult linux_switch_pro_uhid_socketpair_reports(); + /** + * @brief Exercise Steam Controller state, battery, feature, and output reports through a socketpair. + * + * @return Round-trip result with native protocol observations. + */ + LinuxUhidRoundTripResult linux_steam_controller_uhid_socketpair_reports(); + /** * @brief Exercise DualSense UHID feature-report 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 55a1dd8c..5f1a3b90 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -86,6 +86,18 @@ namespace lvh::detail::test { std::string stable_id; }; + struct WindowsSteamControllerReportStreamResult { + OperationStatus create_status; + OperationStatus submit_status; + OperationStatus close_status; + WindowsHidCreatedDevice device; + bool repeated_state_report = false; + bool saw_battery_report = false; + bool saw_haptic_output = false; + GamepadOutput haptic_output; + std::vector> submitted_reports; + }; + struct WindowsHidKeyboardResult { struct OperationResults { OperationStatus create_status; @@ -260,6 +272,7 @@ namespace lvh::detail::test { WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle(); WindowsSwitchReportStreamResult windows_backend_switch_report_stream(); + WindowsSteamControllerReportStreamResult windows_backend_steam_controller_report_stream(); WindowsXboxInputDeduplicationResult windows_backend_xbox_input_deduplication(GamepadProfileKind kind); WindowsPlayStationTransportResult windows_backend_playstation_transport(); WindowsGenericPidOrderingResult windows_backend_generic_pid_callback_ordering(); diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 0111bb27..8b8ae8b3 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -963,6 +963,27 @@ namespace lvh::detail::test { return {std::move(status), std::move(records)}; } + void finish_uhid_output_roundtrip( + UhidGamepad &gamepad, + int peer_fd, + std::atomic_size_t &callback_count, + LinuxUhidRoundTripResult &result, + uhid_event &event + ) { + const auto output_deadline = std::chrono::steady_clock::now() + std::chrono::seconds {1}; + while (std::chrono::steady_clock::now() < output_deadline && callback_count.load() < 2U) { + std::this_thread::sleep_for(std::chrono::milliseconds {10}); + } + result.output.callback_count = callback_count.load(); + + result.close_status = gamepad.close(); + if (read_uhid_event_type(peer_fd, UHID_DESTROY, event)) { + result.saw_destroy = true; + } + + static_cast(::close(peer_fd)); + } + } // namespace std::string linux_copy_string_char_buffer(const std::string &source) { @@ -1805,18 +1826,7 @@ namespace lvh::detail::test { std::copy(motor_report.begin(), motor_report.end(), event.u.output.data); static_cast(write_uhid_event(descriptors[1], event)); - const auto output_deadline = std::chrono::steady_clock::now() + std::chrono::seconds {1}; - while (std::chrono::steady_clock::now() < output_deadline && callback_count.load() < 2U) { - std::this_thread::sleep_for(std::chrono::milliseconds {10}); - } - result.output.callback_count = callback_count.load(); - - result.close_status = gamepad.close(); - if (read_uhid_event_type(descriptors[1], UHID_DESTROY, event)) { - result.saw_destroy = true; - } - - static_cast(::close(descriptors[1])); + finish_uhid_output_roundtrip(gamepad, descriptors[1], callback_count, result, event); return result; } @@ -1906,6 +1916,141 @@ namespace lvh::detail::test { return result; } + LinuxUhidRoundTripResult linux_steam_controller_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_controller_2026(); + options.metadata.stable_id = "steam-controller-2026-roundtrip"; + options.metadata.has_battery = true; + + UhidGamepad gamepad {descriptors[0]}; + auto event = create_started_profile_uhid_gamepad(gamepad, 14, options, descriptors[1], BUS_BLUETOOTH, result); + result.creation.saw_create = result.creation.saw_create && + event.u.create2.rd_size == options.profile.report_descriptor.size(); + + event = {}; + event.type = UHID_SET_REPORT; + event.u.set_report.id = 20; + event.u.set_report.rnum = 1; + event.u.set_report.rtype = UHID_FEATURE_REPORT; + event.u.set_report.size = static_cast<__u16>(steam_controller_protocol::feature_report_size); + event.u.set_report.data[0] = 1; + event.u.set_report.data[1] = 0x83; + 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 == 20 && event.u.set_report_reply.err == 0; + } + + event = {}; + event.type = UHID_GET_REPORT; + event.u.get_report.id = 21; + event.u.get_report.rnum = 1; + 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)) { + result.saw_get_report_reply = event.u.get_report_reply.id == 21 && event.u.get_report_reply.err == 0; + result.steam_controller.saw_attribute_feature_reply = + result.saw_get_report_reply && + event.u.get_report_reply.size == steam_controller_protocol::feature_report_size && + event.u.get_report_reply.data[0] == 1 && event.u.get_report_reply.data[1] == 0x83 && + event.u.get_report_reply.data[2] == 15; + } + + std::atomic_size_t callback_count {0}; + gamepad.set_output_callback([&result, &callback_count](const GamepadOutput &output) { + result.output.last = output; + if (output.kind == GamepadOutputKind::rumble) { + result.output.rumble = output; + } else if (output.kind == GamepadOutputKind::haptics) { + result.output.haptics = output; + } + ++callback_count; + }); + + event = {}; + event.type = UHID_OPEN; + static_cast(write_uhid_event(descriptors[1], event)); + + GamepadState state; + state.buttons.set(GamepadButton::a); + state.buttons.set(GamepadButton::paddle4); + state.buttons.set(GamepadButton::right_touchpad); + state.touchpad_contacts[0] = {.id = 1, .active = true, .x = 0.25F, .y = 0.75F, .pressure = 0.5F}; + state.touchpad_contacts[1] = {.id = 2, .active = true, .x = 0.75F, .y = 0.25F, .pressure = 1.0F}; + state.battery = GamepadBattery { + .state = GamepadBatteryState::charging, + .percentage = 73, + }; + const auto report = reports::pack_input_report(options.profile, state); + result.submit_status = gamepad.submit(state, report); + + const auto input_deadline = std::chrono::steady_clock::now() + std::chrono::seconds {1}; + while ( + std::chrono::steady_clock::now() < input_deadline && + (!result.steam_controller.saw_state_input || !result.steam_controller.saw_battery_input)) { + if (!read_uhid_event(descriptors[1], event) || event.type != UHID_INPUT2) { + continue; + } + const auto input = std::span {event.u.input2.data, event.u.input2.size}; + if (input.size() == steam_controller_protocol::state_report_size && input[0] == 0x42U) { + const auto buttons = read_u32_le(input.data() + 2U); + result.steam_controller.saw_state_input = + (buttons & 0x00000001U) != 0U && (buttons & 0x00040000U) != 0U && + (buttons & 0x00400000U) != 0U && (buttons & 0x02200000U) == 0x02200000U; + } else if (input.size() == steam_controller_protocol::battery_report_size && input[0] == 0x43U) { + result.steam_controller.saw_battery_input = input[1] == 2U && input[2] == 73U; + } + } + result.saw_input = result.steam_controller.saw_state_input; + + constexpr std::array rumble_report { + 0x80, + 0x00, + 0x00, + 0x00, + 0x34, + 0x12, + 0x00, + 0x78, + 0x56, + 0x00, + }; + event = {}; + event.type = UHID_OUTPUT; + event.u.output.rtype = UHID_OUTPUT_REPORT; + event.u.output.size = static_cast<__u16>(rumble_report.size()); + std::ranges::copy(rumble_report, event.u.output.data); + static_cast(write_uhid_event(descriptors[1], event)); + + constexpr std::array pulse_report { + 0x81, + 0x03, + 0x20, + 0x00, + 0x10, + 0x00, + 0x02, + 0x00, + }; + event = {}; + event.type = UHID_OUTPUT; + event.u.output.rtype = UHID_OUTPUT_REPORT; + event.u.output.size = static_cast<__u16>(pulse_report.size()); + std::ranges::copy(pulse_report, event.u.output.data); + static_cast(write_uhid_event(descriptors[1], event)); + + finish_uhid_output_roundtrip(gamepad, descriptors[1], callback_count, result, event); + return result; + } + LinuxUhidRoundTripResult linux_dualsense_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 8ab75ebf..66c2f324 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -556,6 +556,84 @@ namespace lvh::detail { return result; } + WindowsSteamControllerReportStreamResult windows_backend_steam_controller_report_stream() { + WindowsSteamControllerReportStreamResult 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_controller_2026(); + options.metadata.stable_id = "steam-controller-2026"; + options.metadata.has_battery = true; + auto created = backend->create_gamepad(10, options); + result.create_status = created.status; + capture_created_hid_device(command_state, result.device); + if (created) { + GamepadState state; + state.buttons.set(GamepadButton::a); + state.buttons.set(GamepadButton::paddle1); + state.touchpad_contacts[0] = {.id = 1, .active = true, .x = 0.25F, .y = 0.75F, .pressure = 0.5F}; + state.battery = GamepadBattery { + .state = GamepadBatteryState::discharging, + .percentage = 61, + }; + const auto report = reports::pack_input_report(options.profile, state); + result.submit_status = created.gamepad->submit(state, report); + result.repeated_state_report = wait_until([&command_state] { + const auto reports = command_state->submit_reports(); + return std::ranges::count_if(reports, [](const auto &submitted) { + if (submitted.size() != steam_controller_protocol::state_report_size || submitted[0] != 0x42U) { + return false; + } + const auto buttons = static_cast(submitted[2]) | + (static_cast(submitted[3]) << 8U) | + (static_cast(submitted[4]) << 16U) | + (static_cast(submitted[5]) << 24U); + return (buttons & 0x00000081U) == 0x00000081U; + }) >= 2; + }); + + std::atomic_bool haptic_output_seen {false}; + created.gamepad->set_output_callback([&result, &haptic_output_seen](const GamepadOutput &output) { + if (output.kind == GamepadOutputKind::haptics) { + result.saw_haptic_output = true; + result.haptic_output = output; + haptic_output_seen = true; + } + }); + LvhWindowsOutputReportEvent event {}; + event.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; + event.size = sizeof(event); + event.driver_device_id = last_created_driver_id(command_state); + event.report_size = 8; + constexpr std::array pulse_report { + 0x81, + 0x03, + 0x20, + 0x00, + 0x10, + 0x00, + 0x02, + 0x00, + }; + std::ranges::copy(pulse_report, event.report.begin()); + event_state->enqueue_output_event(event); + static_cast(wait_until([&haptic_output_seen] { + return haptic_output_seen.load(); + })); + + result.close_status = created.gamepad->close(); + result.submitted_reports = command_state->submit_reports(); + result.saw_battery_report = std::ranges::any_of(result.submitted_reports, [](const auto &submitted) { + return submitted.size() == steam_controller_protocol::battery_report_size && + submitted[0] == 0x43U && submitted[1] == 1U && submitted[2] == 61U; + }); + } + + return result; + } + WindowsXboxInputDeduplicationResult windows_backend_xbox_input_deduplication(GamepadProfileKind kind) { WindowsXboxInputDeduplicationResult result; auto command_state = std::make_shared(); diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp index c792b61f..9012a32f 100644 --- a/tests/unit/test_gamepad_adapter.cpp +++ b/tests/unit/test_gamepad_adapter.cpp @@ -20,6 +20,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_controller = lvh::profiles::steam_controller_2026(); const auto keyboard = lvh::profiles::keyboard(); const auto generic_support = lvh::gamepad_profile_support(generic); @@ -34,6 +35,35 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_TRUE(lvh::gamepad_profile_support(xbox_one).supports_battery); EXPECT_TRUE(lvh::gamepad_profile_support(xbox_series).supports_battery); + const auto steam_support = lvh::gamepad_profile_support(steam_controller); + EXPECT_TRUE(steam_support.supports_rumble); + EXPECT_TRUE(steam_support.supports_motion); + EXPECT_TRUE(steam_support.supports_touchpad); + EXPECT_TRUE(steam_support.supports_battery); + EXPECT_TRUE(steam_support.supports_haptics); + EXPECT_FALSE(steam_support.supports_touchpad_button); + EXPECT_EQ(steam_support.supported_touchpad_count, 2U); + EXPECT_EQ(steam_support.supported_rear_paddle_count, 4U); + EXPECT_TRUE(lvh::supports_gamepad_button(steam_controller, lvh::GamepadButton::paddle4)); + EXPECT_TRUE(lvh::supports_gamepad_button(steam_controller, lvh::GamepadButton::left_touchpad)); + EXPECT_TRUE(lvh::supports_gamepad_button(steam_controller, lvh::GamepadButton::right_touchpad)); + EXPECT_TRUE(lvh::supports_gamepad_button(steam_controller, lvh::GamepadButton::left_trigger_click)); + EXPECT_TRUE(lvh::supports_gamepad_button(steam_controller, lvh::GamepadButton::right_stick_touch)); + EXPECT_TRUE(lvh::supports_gamepad_button(steam_controller, lvh::GamepadButton::left_grip_touch)); + EXPECT_TRUE(lvh::supports_gamepad_output(steam_controller, lvh::GamepadOutputKind::haptics)); + + auto steam_fallback = steam_controller; + steam_fallback.capabilities.supports_touchpad = false; + steam_fallback.capabilities.supports_haptics = false; + steam_fallback.capabilities.supported_touchpad_count = 0; + steam_fallback.capabilities.supported_rear_paddle_count = 0; + EXPECT_FALSE(lvh::supports_gamepad_button(steam_fallback, lvh::GamepadButton::paddle1)); + EXPECT_FALSE(lvh::supports_gamepad_button(steam_fallback, lvh::GamepadButton::left_touchpad)); + EXPECT_FALSE(lvh::supports_gamepad_button(steam_fallback, lvh::GamepadButton::right_touchpad)); + EXPECT_FALSE(lvh::supports_gamepad_button(steam_fallback, lvh::GamepadButton::left_trigger_click)); + EXPECT_FALSE(lvh::supports_gamepad_button(steam_fallback, lvh::GamepadButton::right_grip_touch)); + EXPECT_FALSE(lvh::supports_gamepad_output(steam_fallback, lvh::GamepadOutputKind::haptics)); + const auto dualshock4_support = lvh::gamepad_profile_support(dualshock4); EXPECT_TRUE(dualshock4_support.supports_rumble); EXPECT_TRUE(dualshock4_support.supports_rgb_led); @@ -41,6 +71,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_TRUE(dualshock4_support.supports_touchpad); EXPECT_TRUE(dualshock4_support.supports_battery); EXPECT_TRUE(dualshock4_support.supports_touchpad_button); + EXPECT_EQ(dualshock4_support.supported_touchpad_count, 1U); EXPECT_FALSE(dualshock4_support.supports_adaptive_triggers); EXPECT_FALSE(dualshock4_support.supports_misc1_button); @@ -48,6 +79,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_TRUE(dualsense_support.supports_rumble); EXPECT_TRUE(dualsense_support.supports_rgb_led); EXPECT_TRUE(dualsense_support.supports_adaptive_triggers); + EXPECT_EQ(dualsense_support.supported_touchpad_count, 1U); EXPECT_TRUE(dualsense_support.supports_motion); EXPECT_TRUE(dualsense_support.supports_touchpad); EXPECT_TRUE(dualsense_support.supports_battery); @@ -339,6 +371,9 @@ TEST(GamepadAdapterTest, ReplacesStateAndClearsOptionalInputs) { EXPECT_EQ(adapter.clear_touchpad_contact(2).code(), lvh::ErrorCode::invalid_argument); EXPECT_TRUE(adapter.clear_touchpad_contact(1).ok()); EXPECT_FALSE(adapter.gamepad()->last_submitted_state().touchpad_contacts[1].active); + EXPECT_FLOAT_EQ(adapter.gamepad()->last_submitted_state().touchpad_contacts[1].x, contact.x); + EXPECT_FLOAT_EQ(adapter.gamepad()->last_submitted_state().touchpad_contacts[1].y, contact.y); + EXPECT_FLOAT_EQ(adapter.gamepad()->last_submitted_state().touchpad_contacts[1].pressure, 0.0F); } TEST(GamepadAdapterTest, MovesAdaptersAndClosesOwnedGamepadOnDestruction) { diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 5f39285f..90879141 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -125,6 +125,7 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::xbox_one), BUS_BLUETOOTH); EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::xbox_series), BUS_BLUETOOTH); EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::switch_pro), BUS_BLUETOOTH); + EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::steam_controller_2026), 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); @@ -880,6 +881,8 @@ TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback) EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(switch_pro)); EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(dualshock4)); EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(dualsense)); + EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(steam_controller_2026)); + EXPECT_FALSE(lvh::detail::test::linux_gamepad_uses_uinput(steam_controller_2026)); for (const auto kind : {xbox_one, xbox_series}) { EXPECT_TRUE(lvh::detail::test::linux_gamepad_prefers_uhid(kind)); @@ -960,6 +963,37 @@ TEST_F(LinuxBackendTest, SocketpairBackedSwitchProUsesNativeUhidProtocol) { EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::player_leds); } +TEST_F(LinuxBackendTest, SocketpairBackedSteamControllerUsesNativeUhidProtocol) { + const auto result = lvh::detail::test::linux_steam_controller_uhid_socketpair_reports(); + EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + EXPECT_TRUE(result.creation.saw_create); + EXPECT_TRUE(result.creation.waited_for_start); + EXPECT_EQ(result.creation.bus, BUS_BLUETOOTH); + EXPECT_EQ(result.creation.vendor_id, 0x28DE); + EXPECT_EQ(result.creation.product_id, 0x1302); + EXPECT_EQ(result.creation.name, "Steam Controller"); + EXPECT_TRUE(result.saw_set_report_reply); + EXPECT_TRUE(result.saw_get_report_reply); + EXPECT_TRUE(result.steam_controller.saw_attribute_feature_reply); + EXPECT_TRUE(result.steam_controller.saw_state_input); + EXPECT_TRUE(result.steam_controller.saw_battery_input); + EXPECT_TRUE(result.saw_destroy); + + ASSERT_TRUE(result.output.rumble.has_value()); + EXPECT_EQ(result.output.rumble->low_frequency_rumble, 0x1234); + EXPECT_EQ(result.output.rumble->high_frequency_rumble, 0x5678); + ASSERT_TRUE(result.output.haptics.has_value()); + EXPECT_EQ(result.output.haptics->kind, lvh::GamepadOutputKind::haptics); + ASSERT_TRUE(result.output.haptics->haptic_effect.has_value()); + EXPECT_EQ(result.output.haptics->haptic_effect->target, lvh::GamepadHapticTarget::both); + EXPECT_EQ(result.output.haptics->haptic_effect->kind, lvh::GamepadHapticEffectKind::pulse); + EXPECT_EQ(result.output.haptics->haptic_effect->duration_us, 32); + EXPECT_EQ(result.output.haptics->haptic_effect->interval_us, 16U); + EXPECT_EQ(result.output.haptics->haptic_effect->repeat_count, 2U); +} + TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) { const auto result = lvh::detail::test::linux_dualsense_uhid_socketpair_reports(); EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 9b6b2083..2b6082d6 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_controller_2026().name, "Steam 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_controller = lvh::profiles::steam_controller_2026(); EXPECT_EQ(xbox_one.vendor_id, 0x045E); EXPECT_EQ(xbox_one.product_id, 0x02EA); @@ -104,6 +106,32 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_one.input_report_size, 17U); EXPECT_EQ(xbox_one.output_report_size, 8U); + EXPECT_EQ(steam_controller.vendor_id, 0x28DE); + EXPECT_EQ(steam_controller.product_id, 0x1302); + EXPECT_EQ(steam_controller.bus_type, lvh::BusType::usb); + EXPECT_EQ(steam_controller.manufacturer, "Valve Software"); + EXPECT_EQ(steam_controller.report_id, 0x42U); + EXPECT_EQ(steam_controller.input_report_size, 54U); + EXPECT_EQ(steam_controller.output_report_size, 10U); + EXPECT_TRUE(steam_controller.capabilities.supports_rumble); + EXPECT_TRUE(steam_controller.capabilities.supports_motion); + EXPECT_TRUE(steam_controller.capabilities.supports_touchpad); + EXPECT_TRUE(steam_controller.capabilities.supports_battery); + EXPECT_TRUE(steam_controller.capabilities.supports_haptics); + EXPECT_EQ(steam_controller.capabilities.supported_touchpad_count, 2U); + EXPECT_EQ(steam_controller.capabilities.supported_rear_paddle_count, 4U); + expect_descriptor_contains(steam_controller, std::array {0x85, 0x42, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75}); + expect_descriptor_contains(steam_controller, std::array {0x85, 0x43, 0x15, 0x00}); + expect_descriptor_contains(steam_controller, std::array {0x85, 0x80, 0x15, 0x00}); + for (const auto report_id : {0x81U, 0x82U, 0x83U, 0x84U, 0x85U}) { + expect_descriptor_contains( + steam_controller, + std::array {0x85U, static_cast(report_id)} + ); + } + expect_descriptor_contains(steam_controller, std::array {0x85, 0x01, 0x95, 0x3F, 0x09}); + expect_descriptor_contains(steam_controller, std::array {0x85, 0x02, 0x95, 0x3F, 0x09}); + const auto xbox_series = lvh::profiles::xbox_series(); EXPECT_EQ(xbox_series.vendor_id, 0x045E); EXPECT_EQ(xbox_series.product_id, 0x0B12); diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index d51fc668..9a4a94fd 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include // local includes @@ -84,6 +85,181 @@ TEST(ReportTest, NormalizesAxesAndTriggers) { EXPECT_EQ(lvh::reports::normalize_trigger(2.0F), 255); } +TEST(ReportTest, PacksSteamControllerFullNativeStateAndBattery) { + using enum lvh::GamepadButton; + + const auto profile = lvh::profiles::steam_controller_2026(); + lvh::GamepadState state; + for (const auto button : { + a, + b, + x, + y, + back, + start, + guide, + left_stick, + right_stick, + left_shoulder, + right_shoulder, + dpad_up, + dpad_down, + dpad_left, + dpad_right, + misc1, + paddle1, + paddle2, + paddle3, + paddle4, + left_touchpad, + right_touchpad, + left_trigger_click, + right_trigger_click, + left_stick_touch, + right_stick_touch, + left_grip_touch, + right_grip_touch, + }) { + state.buttons.set(button); + } + state.left_stick = {.x = 1.0F, .y = -1.0F}; + state.right_stick = {.x = -1.0F, .y = 1.0F}; + state.left_trigger = 0.5F; + state.right_trigger = 1.0F; + state.touchpad_contacts[0] = {.id = 1, .active = true, .x = 0.0F, .y = 1.0F, .pressure = 0.25F}; + state.touchpad_contacts[1] = {.id = 2, .active = true, .x = 1.0F, .y = 0.0F, .pressure = 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}; + + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_EQ(report.size(), 54U); + EXPECT_EQ(report[0], 0x42U); + EXPECT_EQ(read_u32_le(report, 2U), 0x3FFFFFFFU); + EXPECT_EQ(read_i16_le(report, 6U), 16384); + EXPECT_EQ(read_i16_le(report, 8U), 32767); + EXPECT_EQ(read_i16_le(report, 10U), 32767); + EXPECT_EQ(read_i16_le(report, 12U), -32768); + EXPECT_EQ(read_i16_le(report, 14U), -32768); + EXPECT_EQ(read_i16_le(report, 16U), 32767); + EXPECT_EQ(read_i16_le(report, 18U), -32768); + EXPECT_EQ(read_i16_le(report, 20U), -32768); + EXPECT_EQ(read_u16_le(report, 22U), 8192U); + EXPECT_EQ(read_i16_le(report, 24U), 32767); + EXPECT_EQ(read_i16_le(report, 26U), 32767); + EXPECT_EQ(read_u16_le(report, 28U), 32768U); + EXPECT_EQ(read_i16_le(report, 34U), 16384); + EXPECT_EQ(read_i16_le(report, 36U), 16384); + EXPECT_EQ(read_i16_le(report, 38U), 0); + EXPECT_EQ(read_i16_le(report, 40U), 16384); + EXPECT_EQ(read_i16_le(report, 42U), -4096); + EXPECT_EQ(read_i16_le(report, 44U), -8192); + EXPECT_EQ(read_i16_le(report, 46U), 32767); + + const auto battery = lvh::reports::pack_battery_report( + profile, + {.state = lvh::GamepadBatteryState::charging, .percentage = 73} + ); + ASSERT_TRUE(battery.has_value()); + ASSERT_EQ(battery->size(), 15U); + EXPECT_EQ((*battery)[0], 0x43U); + EXPECT_EQ((*battery)[1], 2U); + EXPECT_EQ((*battery)[2], 73U); + EXPECT_FALSE( + lvh::reports::pack_battery_report( + lvh::profiles::generic_gamepad(), + {.state = lvh::GamepadBatteryState::full, .percentage = 100} + ) + .has_value() + ); +} + +TEST(ReportTest, PacksSteamControllerButtonsWithoutGuideAliasing) { + using enum lvh::GamepadButton; + + constexpr std::array button_cases { + std::pair {x, 0x00000004U}, + std::pair {dpad_down, 0x00000400U}, + std::pair {dpad_right, 0x00000800U}, + std::pair {dpad_left, 0x00001000U}, + std::pair {dpad_up, 0x00002000U}, + std::pair {guide, 0x00010000U}, + }; + + const auto profile = lvh::profiles::steam_controller_2026(); + for (const auto &[button, expected_mask] : button_cases) { + lvh::GamepadState state; + state.buttons.set(button); + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_EQ(report.size(), 54U); + EXPECT_EQ(read_u32_le(report, 2U), expected_mask) + << "logical button " << static_cast(std::to_underlying(button)); + } +} + +TEST(ReportTest, ParsesSteamControllerRumbleAndAddressableHaptics) { + const auto profile = lvh::profiles::steam_controller_2026(); + + const auto rumble = lvh::reports::parse_output_report( + profile, + {0x80, 0x00, 0x34, 0x12, 0xCD, 0xAB, 0xFE, 0x57, 0x13, 0x02} + ); + EXPECT_EQ(rumble.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(rumble.low_frequency_rumble, 0xABCDU); + EXPECT_EQ(rumble.high_frequency_rumble, 0x1357U); + + const auto pulse = lvh::reports::parse_output_report( + profile, + {0x81, 0x03, 0xE8, 0x03, 0xD0, 0x07, 0x05, 0x00} + ); + ASSERT_EQ(pulse.kind, lvh::GamepadOutputKind::haptics); + ASSERT_TRUE(pulse.haptic_effect.has_value()); + EXPECT_EQ(pulse.haptic_effect->target, lvh::GamepadHapticTarget::both); + EXPECT_EQ(pulse.haptic_effect->kind, lvh::GamepadHapticEffectKind::pulse); + EXPECT_EQ(pulse.haptic_effect->duration_us, 1000); + EXPECT_EQ(pulse.haptic_effect->interval_us, 2000U); + EXPECT_EQ(pulse.haptic_effect->repeat_count, 5U); + + const auto tone = lvh::reports::parse_output_report( + profile, + {0x83, 0x01, 0xFA, 0xB8, 0x01, 0x19, 0x00, 0x05, 0x00, 100} + ); + ASSERT_TRUE(tone.haptic_effect.has_value()); + EXPECT_EQ(tone.haptic_effect->target, lvh::GamepadHapticTarget::left); + EXPECT_EQ(tone.haptic_effect->kind, lvh::GamepadHapticEffectKind::tone); + EXPECT_EQ(tone.haptic_effect->gain_db, -6); + EXPECT_EQ(tone.haptic_effect->frequency_hz, 440U); + EXPECT_EQ(tone.haptic_effect->duration_us, 25000); + EXPECT_EQ(tone.haptic_effect->lfo_frequency_hz, 5U); + EXPECT_EQ(tone.haptic_effect->lfo_depth_percent, 100U); + + const auto command = lvh::reports::parse_output_report(profile, {0x82, 0x02, 0x04, 0xF4}); + ASSERT_TRUE(command.haptic_effect.has_value()); + EXPECT_EQ(command.haptic_effect->target, lvh::GamepadHapticTarget::right); + EXPECT_EQ(command.haptic_effect->kind, lvh::GamepadHapticEffectKind::rumble); + EXPECT_EQ(command.haptic_effect->gain_db, -12); + + const auto sweep = lvh::reports::parse_output_report( + profile, + {0x84, 0x03, 0xFD, 0xE8, 0x03, 0x64, 0x00, 0xC8, 0x00} + ); + ASSERT_TRUE(sweep.haptic_effect.has_value()); + EXPECT_EQ(sweep.haptic_effect->target, lvh::GamepadHapticTarget::both); + EXPECT_EQ(sweep.haptic_effect->kind, lvh::GamepadHapticEffectKind::logarithmic_sweep); + EXPECT_EQ(sweep.haptic_effect->gain_db, -3); + EXPECT_EQ(sweep.haptic_effect->duration_us, 1000000); + EXPECT_EQ(sweep.haptic_effect->start_frequency_hz, 100U); + EXPECT_EQ(sweep.haptic_effect->end_frequency_hz, 200U); + + const auto script = lvh::reports::parse_output_report(profile, {0x85, 0x01, 0x07, 0xF6}); + ASSERT_TRUE(script.haptic_effect.has_value()); + EXPECT_EQ(script.haptic_effect->target, lvh::GamepadHapticTarget::left); + EXPECT_EQ(script.haptic_effect->kind, lvh::GamepadHapticEffectKind::script); + EXPECT_EQ(script.haptic_effect->script_id, 7U); + EXPECT_EQ(script.haptic_effect->gain_db, -10); +} + TEST(ReportTest, EncodesHatSwitch) { using enum lvh::GamepadButton; diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp index a5f1d2cf..8ceecf7c 100644 --- a/tests/unit/test_virtualhid_control_model.cpp +++ b/tests/unit/test_virtualhid_control_model.cpp @@ -59,6 +59,7 @@ TEST(VirtualHidControlModelTest, NamesKnownAndFallbackEnumValues) { EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::raw_report), L"raw report"); EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::trigger_rumble), L"trigger rumble"); EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::player_leds), L"player leds"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::haptics), L"haptics"); EXPECT_EQ(control::output_kind_name(static_cast(255)), L"raw report"); EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::unknown), L"unknown"); @@ -153,6 +154,12 @@ TEST(VirtualHidControlModelTest, BuildsMomentaryMouseButtonEvents) { EXPECT_FALSE(release.pressed); } +TEST(VirtualHidControlModelTest, EnablesKeyboardNavigationOnlyForMouseControls) { + EXPECT_FALSE(control::keyboard_navigation_enabled(lvh::DeviceType::gamepad)); + EXPECT_TRUE(control::keyboard_navigation_enabled(lvh::DeviceType::mouse)); + EXPECT_FALSE(control::keyboard_navigation_enabled(lvh::DeviceType::keyboard)); +} + TEST(VirtualHidControlModelTest, ConvertsSliderValues) { EXPECT_EQ(control::axis_to_slider(-2.0F), -control::slider_scale); EXPECT_EQ(control::axis_to_slider(-0.5F), -50); @@ -192,11 +199,11 @@ TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) { EXPECT_EQ( control::profile_feature_summary(generic), - L"Features: battery no | rumble yes | trigger rumble no | RGB LED no | player LEDs no | adaptive triggers no | raw output yes" + L"Features: battery no | rumble yes | trigger rumble no | RGB LED no | player LEDs no | adaptive triggers no | haptics no | raw output yes" ); EXPECT_EQ( control::profile_feature_summary(dualsense), - L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | player LEDs no | adaptive triggers yes | raw output yes" + L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | player LEDs no | adaptive triggers yes | haptics no | raw output yes" ); EXPECT_EQ( control::device_feature_summary(mouse), diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index 64000d12..ddfebe0a 100644 --- a/tests/unit/test_windows_backend.cpp +++ b/tests/unit/test_windows_backend.cpp @@ -118,6 +118,31 @@ TEST_F(WindowsBackendTest, SwitchReportsStreamAtTheNativeCadence) { })); } +TEST_F(WindowsBackendTest, SteamControllerStreamsNativeStateAndBatteryReports) { + const auto result = lvh::detail::test::windows_backend_steam_controller_report_stream(); + + expect_ok(result.create_status); + expect_ok(result.submit_status); + EXPECT_TRUE(result.repeated_state_report); + EXPECT_TRUE(result.saw_battery_report); + expect_ok(result.close_status); + EXPECT_EQ(result.device.device_type, LVH_WINDOWS_DEVICE_GAMEPAD); + EXPECT_EQ(result.device.bus_type, LVH_WINDOWS_BUS_USB); + EXPECT_EQ(result.device.flags & LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_HAPTICS, LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_HAPTICS); + EXPECT_EQ(result.device.vendor_id, 0x28DEU); + EXPECT_EQ(result.device.product_id, 0x1302U); + EXPECT_EQ(result.device.report_id, 0x42U); + EXPECT_EQ(result.device.input_report_size, 54U); + EXPECT_EQ(result.device.output_report_size, 10U); + EXPECT_EQ(result.device.name, "Steam Controller"); + EXPECT_EQ(result.device.manufacturer, "Valve Software"); + EXPECT_EQ(result.device.stable_id, "steam-controller-2026"); + EXPECT_TRUE(result.saw_haptic_output); + ASSERT_TRUE(result.haptic_output.haptic_effect.has_value()); + EXPECT_EQ(result.haptic_output.haptic_effect->target, lvh::GamepadHapticTarget::both); + EXPECT_EQ(result.haptic_output.haptic_effect->kind, lvh::GamepadHapticEffectKind::pulse); +} + TEST_F(WindowsBackendTest, XboxReportsOnlySubmitShoulderStateTransitions) { for (const auto kind : {lvh::GamepadProfileKind::xbox_one, lvh::GamepadProfileKind::xbox_series}) { SCOPED_TRACE(static_cast(kind)); diff --git a/tests/unit/test_windows_broker_validation.cpp b/tests/unit/test_windows_broker_validation.cpp index ae7bc322..2b464d4a 100644 --- a/tests/unit/test_windows_broker_validation.cpp +++ b/tests/unit/test_windows_broker_validation.cpp @@ -18,6 +18,7 @@ #include // lib includes +#include #include namespace { @@ -203,6 +204,25 @@ TEST(WindowsBrokerValidationTest, RejectsMalformedCreateFields) { EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); } +TEST(WindowsBrokerValidationTest, AcceptsSteamControllerCreateRequest) { + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::steam_controller_2026(); + options.metadata.stable_id = "steam-controller-2026"; + options.metadata.has_battery = true; + + LvhWindowsBrokerCreateDeviceRequest request {}; + request.header = request_header( + LvhWindowsBrokerRequestType::create_device, + sizeof(request) + ); + request.client_control_handle = 1U; + request.device = lvh::detail::windows::make_create_device_request(1U, options); + + EXPECT_EQ(request.device.gamepad_kind, LVH_WINDOWS_GAMEPAD_STEAM_CONTROLLER_2026); + EXPECT_NE(request.device.flags & LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_HAPTICS, 0U); + EXPECT_TRUE(lvh::windows::broker_validation::valid_request(request)); +} + TEST(WindowsBrokerValidationTest, ValidatesMouseCreateFields) { auto request = valid_create_request(); request.device.device_type = LVH_WINDOWS_DEVICE_MOUSE; diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp index 41bd5966..ce53f1b4 100644 --- a/tests/unit/test_windows_consumers.cpp +++ b/tests/unit/test_windows_consumers.cpp @@ -722,6 +722,60 @@ TEST_F(WindowsConsumerTest, SdlHidapiOutputReachesDefaultPlayStationAndSwitchCal } } +TEST_F(WindowsConsumerTest, SdlReadsSteamControllerMotionAtExpectedScale) { + SdlGamepadSubsystem sdl; + ASSERT_TRUE(sdl.initialized()) << SDL_GetError(); + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + ASSERT_NE(runtime, nullptr); + ASSERT_TRUE(runtime->capabilities().supports_gamepad) + << "The installed libvirtualhid Windows driver is required for this integration test"; + + const auto previous_gamepads = current_sdl_gamepads(); + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::steam_controller_2026(); + options.metadata.stable_id = "02:11:22:33:44:55"; + auto created = lvh::GamepadStateAdapter::create(*runtime, options); + ASSERT_TRUE(created) << created.status.message(); + + auto gamepad = wait_for_new_sdl_gamepad( + previous_gamepads, + options.profile.vendor_id, + options.profile.product_id + ); + ASSERT_NE(gamepad.get(), nullptr) << SDL_GetError(); + ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_GYRO)) << SDL_GetError(); + ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_GYRO, true)) << SDL_GetError(); + + constexpr float angular_velocity_degrees_per_second = 180.0F; + constexpr float expected_radians_per_second = 3.14159265F; + std::array gyroscope {}; + bool received_motion = false; + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (std::chrono::steady_clock::now() < deadline) { + ASSERT_TRUE(created.adapter->set_gyroscope( + lvh::Vector3 {.x = angular_velocity_degrees_per_second} + ) + .ok()); + SDL_UpdateGamepads(); + SDL_PumpEvents(); + if ( + SDL_GetGamepadSensorData(gamepad.get(), SDL_SENSOR_GYRO, gyroscope.data(), gyroscope.size()) && + std::abs(gyroscope[0] - expected_radians_per_second) < 0.2F + ) { + received_motion = true; + break; + } + std::this_thread::sleep_for(20ms); + } + EXPECT_TRUE(received_motion) << "Steam Controller gyro scale mismatch: " << gyroscope[0] << " rad/s"; + + gamepad.reset(); + ASSERT_TRUE(created.adapter->close().ok()); +} + TEST_F(WindowsConsumerTest, SdlExposesSubmittedBatteryStateForNonXboxProfiles) { SdlGamepadSubsystem sdl; ASSERT_TRUE(sdl.initialized()) << SDL_GetError(); diff --git a/tests/unit/test_windows_protocol.cpp b/tests/unit/test_windows_protocol.cpp index e765be08..f80433e0 100644 --- a/tests/unit/test_windows_protocol.cpp +++ b/tests/unit/test_windows_protocol.cpp @@ -7,6 +7,7 @@ #include "fixtures/fixtures.hpp" #include "generic_pid_rumble.hpp" #include "platform/windows/control_protocol.hpp" +#include "shared/steam_controller_protocol.hpp" // standard includes #include @@ -60,7 +61,7 @@ TEST(WindowsProtocolTest, ExposesStableProtocolConstants) { EXPECT_STREQ(lvh::detail::windows::default_control_device_path.data(), R"(\\.\LibVirtualHid)"); EXPECT_STREQ(lvh::detail::windows::global_control_device_path.data(), R"(\\.\Global\LibVirtualHid)"); - EXPECT_EQ(LVH_WINDOWS_CONTROL_PROTOCOL_VERSION, 5U); + EXPECT_EQ(LVH_WINDOWS_CONTROL_PROTOCOL_VERSION, 6U); EXPECT_EQ(LVH_WINDOWS_IOCTL_CREATE_DEVICE, 0x8000E000U); EXPECT_EQ(LVH_WINDOWS_IOCTL_DESTROY_DEVICE, 0x8000E004U); EXPECT_EQ(LVH_WINDOWS_IOCTL_SUBMIT_INPUT_REPORT, 0x8000E008U); @@ -128,6 +129,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_controller_2026), + LVH_WINDOWS_GAMEPAD_STEAM_CONTROLLER_2026 + ); EXPECT_EQ( lvh::detail::windows::protocol_gamepad_kind(static_cast(255)), LVH_WINDOWS_GAMEPAD_GENERIC @@ -144,6 +149,7 @@ TEST(WindowsProtocolTest, BuildsCapabilityFlags) { capabilities.supports_rgb_led = true; capabilities.supports_battery = true; capabilities.supports_adaptive_triggers = true; + capabilities.supports_haptics = true; const auto flags = lvh::detail::windows::gamepad_flags(capabilities); EXPECT_NE(flags & LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RUMBLE, 0U); @@ -152,6 +158,7 @@ TEST(WindowsProtocolTest, BuildsCapabilityFlags) { EXPECT_NE(flags & LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RGB_LED, 0U); EXPECT_NE(flags & LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_BATTERY, 0U); EXPECT_NE(flags & LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_ADAPTIVE_TRIGGERS, 0U); + EXPECT_NE(flags & LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_HAPTICS, 0U); } TEST(WindowsProtocolTest, CopyHelpersTruncateAndZeroFill) { @@ -187,6 +194,33 @@ TEST(WindowsProtocolTest, CopyHelpersTruncateAndZeroFill) { EXPECT_EQ(submit_request.report[4], 0U); } +TEST(WindowsProtocolTest, SteamControllerFeatureStateRepliesToNativeQueries) { + lvh::detail::steam_controller_protocol::FeatureState state; + const std::array attributes_request {0x01, 0x83}; + ASSERT_TRUE(state.handle_set_feature(1U, attributes_request)); + + const auto attributes = state.get_feature_report(1U); + ASSERT_TRUE(attributes.has_value()); + ASSERT_EQ(attributes->size(), 64U); + EXPECT_EQ((*attributes)[0], 0x01U); + EXPECT_EQ((*attributes)[1], 0x83U); + EXPECT_EQ((*attributes)[2], 15U); + EXPECT_EQ((*attributes)[3], 0x01U); + EXPECT_EQ((*attributes)[4], 0x02U); + EXPECT_EQ((*attributes)[5], 0x13U); + + auto string_request = std::array {0x01, 0xAE}; + string_request[3] = 1U; + ASSERT_TRUE(state.handle_set_feature(1U, string_request)); + const auto string_response = state.get_feature_report(1U); + ASSERT_TRUE(string_response.has_value()); + EXPECT_EQ((*string_response)[1], 0xAEU); + EXPECT_EQ((*string_response)[2], 21U); + EXPECT_EQ((*string_response)[3], 1U); + EXPECT_FALSE(state.handle_set_feature(3U, string_request)); + EXPECT_FALSE(state.get_feature_report(3U).has_value()); +} + TEST(WindowsProtocolTest, PacksGamepadCreateRequest) { lvh::CreateGamepadOptions options; options.profile = lvh::profiles::dualsense_bluetooth(); diff --git a/tests/unit/test_windows_vhf_input_report_queue.cpp b/tests/unit/test_windows_vhf_input_report_queue.cpp index 5ad94f8d..5398024c 100644 --- a/tests/unit/test_windows_vhf_input_report_queue.cpp +++ b/tests/unit/test_windows_vhf_input_report_queue.cpp @@ -108,6 +108,12 @@ namespace { 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}, ProfileCase {lvh::profiles::dualsense_bluetooth(), LVH_WINDOWS_GAMEPAD_DUALSENSE, LVH_WINDOWS_BUS_BLUETOOTH, true}, + ProfileCase { + lvh::profiles::steam_controller_2026(), + LVH_WINDOWS_GAMEPAD_STEAM_CONTROLLER_2026, + LVH_WINDOWS_BUS_USB, + true, + }, }; for (const auto &test_case : profiles) { diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index ad155316..e7d5f708 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -41,6 +41,7 @@ namespace { using lvh::tools::virtualhid_control::device_feature_summary; using lvh::tools::virtualhid_control::device_type_choices; using lvh::tools::virtualhid_control::device_type_name; + using lvh::tools::virtualhid_control::keyboard_navigation_enabled; using lvh::tools::virtualhid_control::mouse_button_choices; using lvh::tools::virtualhid_control::mouse_button_event; using lvh::tools::virtualhid_control::mouse_event_for_action; @@ -728,6 +729,11 @@ namespace { } } + template + void release_momentary_buttons(SubmitHandler submit_event) { + release_active_buttons(submit_event); + } + void forget_device(lvh::DeviceId id) { std::erase_if(scheduled_events_, [id](const ScheduledMouseEvent &event) { return event.device_id == id; @@ -977,10 +983,36 @@ namespace { ControlApp(const ControlApp &) = delete; ControlApp &operator=(const ControlApp &) = delete; - void tick() { + bool tick(bool release_inputs) { + if (release_inputs) { + for (std::size_t index = 0; index < button_active_.size(); ++index) { + if (!button_active_[index]) { + continue; + } + button_active_[index] = false; + set_selected_button(index, false); + } + mouse_control_panel_.release_momentary_buttons( + [this](lvh::DeviceId id, const lvh::MouseEvent &event) { + submit_mouse_event(id, event); + } + ); + } + mouse_control_panel_.tick([this](lvh::DeviceId id, const lvh::MouseEvent &event) { submit_mouse_event(id, event); }); + + auto device_type = std::optional {}; + { + std::lock_guard lock {mutex_}; + if (devices_.contains(selected_id_)) { + device_type = lvh::DeviceType::gamepad; + } else if (mice_.contains(selected_id_)) { + device_type = lvh::DeviceType::mouse; + } + } + return keyboard_navigation_enabled(device_type.value_or(device_panel_.current_device_type())); } void render() { @@ -1811,8 +1843,6 @@ namespace { IMGUI_CHECKVERSION(); ImGui::CreateContext(); auto &io = ImGui::GetIO(); - io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; - // The tool creates virtual gamepads that SDL can see, so gamepad navigation would feed back into the UI. io.IniFilename = nullptr; ImGui::StyleColorsDark(); @@ -1822,10 +1852,14 @@ namespace { ImGui_ImplSDL3_InitForSDLRenderer(window, renderer); ImGui_ImplSDLRenderer3_Init(renderer); + // The tool creates virtual gamepads that SDL can see. Do not open them from + // the UI process or let their input feed back into the controls. + ImGui_ImplSDL3_SetGamepadMode(ImGui_ImplSDL3_GamepadMode_Manual, nullptr, 0); ControlApp app; auto done = false; while (!done) { + auto release_momentary_inputs = false; SDL_Event event; while (SDL_PollEvent(&event)) { ImGui_ImplSDL3_ProcessEvent(&event); @@ -1835,9 +1869,15 @@ namespace { if (event.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED && event.window.windowID == SDL_GetWindowID(window)) { done = true; } + if ( + (event.type == SDL_EVENT_WINDOW_FOCUS_LOST || event.type == SDL_EVENT_WINDOW_MOUSE_LEAVE) && + event.window.windowID == SDL_GetWindowID(window) + ) { + release_momentary_inputs = true; + } } - app.tick(); + const auto use_keyboard_navigation = app.tick(release_momentary_inputs); if ((SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) != 0U) { SDL_Delay(10); @@ -1846,6 +1886,11 @@ namespace { ImGui_ImplSDLRenderer3_NewFrame(); ImGui_ImplSDL3_NewFrame(); + if (use_keyboard_navigation) { + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + } else { + io.ConfigFlags &= ~ImGuiConfigFlags_NavEnableKeyboard; + } ImGui::NewFrame(); app.render(); diff --git a/tools/virtualhid_control_model.cpp b/tools/virtualhid_control_model.cpp index 8311da31..9904d995 100644 --- a/tools/virtualhid_control_model.cpp +++ b/tools/virtualhid_control_model.cpp @@ -79,6 +79,8 @@ namespace lvh::tools::virtualhid_control { return L"trigger rumble"; case player_leds: return L"player leds"; + case haptics: + return L"haptics"; } return L"raw report"; } @@ -141,6 +143,8 @@ namespace lvh::tools::virtualhid_control { return profiles::dualsense(); case switch_pro: return profiles::switch_pro(); + case steam_controller_2026: + return profiles::steam_controller_2026(); } return std::nullopt; } @@ -194,6 +198,10 @@ namespace lvh::tools::virtualhid_control { }; } + bool keyboard_navigation_enabled(DeviceType device_type) { + return device_type == DeviceType::mouse; + } + int axis_to_slider(float value) { return static_cast(std::lround(std::clamp(value, -1.0F, 1.0F) * static_cast(slider_scale))); } @@ -226,7 +234,8 @@ namespace lvh::tools::virtualhid_control { supports_gamepad_output(profile, rgb_led) || supports_gamepad_output(profile, adaptive_triggers) || supports_gamepad_output(profile, trigger_rumble) || - supports_gamepad_output(profile, player_leds); + supports_gamepad_output(profile, player_leds) || + supports_gamepad_output(profile, haptics); } std::wstring profile_feature_summary(const DeviceProfile &profile) { @@ -240,6 +249,7 @@ namespace lvh::tools::virtualhid_control { stream << L" | RGB LED " << yes_no(supports_gamepad_output(profile, rgb_led)); stream << L" | player LEDs " << yes_no(supports_gamepad_output(profile, player_leds)); stream << L" | adaptive triggers " << yes_no(supports_gamepad_output(profile, adaptive_triggers)); + stream << L" | haptics " << yes_no(supports_gamepad_output(profile, haptics)); stream << L" | raw output " << yes_no(supports_gamepad_output(profile, raw_report)); return stream.str(); } @@ -288,6 +298,11 @@ namespace lvh::tools::virtualhid_control { } wrote = true; } + if (state.latest_haptics) { + append_summary_separator(stream, wrote); + stream << L"haptics"; + wrote = true; + } if (!wrote && state.latest_raw_report) { stream << L"raw report"; wrote = true; @@ -358,6 +373,9 @@ namespace lvh::tools::virtualhid_control { case player_leds: state.latest_player_leds = output; break; + case haptics: + state.latest_haptics = output; + break; case raw_report: state.latest_raw_report = output; break; diff --git a/tools/virtualhid_control_model.hpp b/tools/virtualhid_control_model.hpp index ab478248..e8c5df93 100644 --- a/tools/virtualhid_control_model.hpp +++ b/tools/virtualhid_control_model.hpp @@ -78,6 +78,7 @@ namespace lvh::tools::virtualhid_control { std::optional latest_rgb_led; std::optional latest_adaptive_triggers; std::optional latest_player_leds; + std::optional latest_haptics; std::optional latest_raw_report; }; @@ -94,6 +95,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"steam", L"Steam Controller (2026)", GamepadProfileKind::steam_controller_2026, ClientControllerType::unknown}, }; inline constexpr std::array button_choices { @@ -118,6 +120,14 @@ namespace lvh::tools::virtualhid_control { ButtonChoice {L"Paddle 2", GamepadButton::paddle2}, ButtonChoice {L"Paddle 3", GamepadButton::paddle3}, ButtonChoice {L"Paddle 4", GamepadButton::paddle4}, + ButtonChoice {L"Left pad click", GamepadButton::left_touchpad}, + ButtonChoice {L"Right pad click", GamepadButton::right_touchpad}, + ButtonChoice {L"Left trigger click", GamepadButton::left_trigger_click}, + ButtonChoice {L"Right trigger click", GamepadButton::right_trigger_click}, + ButtonChoice {L"Left stick touch", GamepadButton::left_stick_touch}, + ButtonChoice {L"Right stick touch", GamepadButton::right_stick_touch}, + ButtonChoice {L"Left grip touch", GamepadButton::left_grip_touch}, + ButtonChoice {L"Right grip touch", GamepadButton::right_grip_touch}, }; inline constexpr std::array mouse_button_choices { @@ -160,6 +170,17 @@ namespace lvh::tools::virtualhid_control { std::int32_t scroll_step ); MouseEvent mouse_button_event(MouseButton button, bool pressed); + /** + * @brief Check whether the selected device type needs keyboard navigation. + * + * Gamepad input must not navigate the control UI because UI-created virtual + * gamepads can otherwise feed their own input back into the tool. Keyboard + * navigation remains available for mouse testing without a physical mouse. + * + * @param device_type Device type displayed by the control panel. + * @return `true` only when keyboard navigation should be enabled. + */ + bool keyboard_navigation_enabled(DeviceType device_type); int axis_to_slider(float value); int trigger_to_slider(float value); float slider_to_float(long value);