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