Files
punktfunk/design/steam-controller-deck-support.md
T
enricobuehler a81f1304cd docs(steam): gadget PoC — interface 2 PROVEN (Steam opens + XInput-reserves it)
On the Deck (which ships dummy_hcd + raw_gadget + configfs f_hid), a pure-shell
configfs gadget stood up a real 3-interface USB Deck (kbd=0/mouse=1/controller=2,
28de:1205) on a dummy_hcd loopback UDC. hid-steam bound all 3 interfaces, and
crucially Steam PROMOTED the interface-2 controller: "Local Device Found ...
Interface: 2 ... Steam controller device opened for index 14 ... Steam Controller
reserving XInput slot 1" — exactly where the interface -1 UHID Deck was filtered.

It then failed only at feature-report exchange (f_hid can't serve HID GET_REPORT:
"steam_send_report: error -32", "couldn't get controller details ... zombie
controller"), and no gamepad evdev formed for the same reason. So interface 2 is
necessary AND sufficient for Steam to open+XInput-reserve the Deck; the remaining
piece is serving feature/output reports, which raw_gadget can (full control,
like UHID). Next: a raw_gadget 3-interface Deck emulator. Doc §11. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00

53 KiB
Raw Blame History

Rich Steam Controller & Steam Deck support

Status: M0M6 GREEN — full pipeline + fallback + conflict gate built (2026-06-29). Host: the virtual hid-steam Deck binds, is byte-exact, is a wired backend (PUNKTFUNK_GAMEPAD=steamdeck), the protocol carries the rich inputs, the fallback remap keeps them from silently dropping, and the conflict gate keeps a virtual Steam pad off a host that already has a physical one. Clients: the Linux + Windows SDL clients capture + send them; the Decky plugin has the Steam Deck mode + Disable-Steam-Input UX; the C-ABI has the TouchpadEx send path; Apple/Android round-trip the type.

⚠ Hardware finding that reframes the ceiling (2026-06-29, §11): a UHID virtual Deck binds the kernel hid-steam (so the kernel evdev + SDL-hidapi consumers see the full surface — grips, trackpads, IMU) but Steam Input will NOT manage it — Steam filters the Deck's controller to USB interface 2, and a single UHID device reports interface -1. So the virtual Deck's value is for non-Steam / SDL games on Linux, not Steam Input; the virtual DualSense stays the right path for Steam-Input hosts (Steam recognizes a single-interface DualSense). Recommendation: do NOT build M7 (a Windows virtual Deck would hit the same filter with no kernel-evdev fallback — nothing on Windows would consume it). Remaining is validation only (Moonlight paddle regression; a live SDL-game consume test).

M6 (conflict gate) result — validated on real hardware (a SteamOS Deck + a Bazzite host running Steam, 2026-06-29): (a) Empirical conflict confirmed. A Deck-as-host already has its physical 28DE:1205 and Steam's 28DE:11FF XInput output pad live — so a second virtual 28DE makes Steam juggle two Decks. (b) Bind robustness: the virtual Deck binds hid-steam on a second independent kernel (Bazzite 6.17.7, vs the dev box 7.0) and the kernel accepted our serial (the M1 report-id-0 fix). (c) Criterion-4 (running-Steam recognition) — RESOLVED, negative for Steam Input (this is the third wall, §2). Steam's controller.txt enumerates the virtual Deck (Local Device Found, type: 28de 1205, Product "Punktfunk Steam Deck") but logs Interface: -1 and never promotes it (no 28DE:11FF pad, no "Controller connected"). On the same Steam logs the physical Deck is Interface: 2 — a real Deck is a 3-interface USB device (keyboard 0, mouse 1, controller 2), and Steam binds the controller on interface 2. A single UHID device has no USB interface number → -1 → Steam skips it. hid-steam binds by VID/PID regardless (so the kernel evdevs + SDL-hidapi path work), but Steam Input itself will not manage a UHID virtual Deck. (The feared 0x83/0xA1 attribute probes never fired — it's an interface filter, not a probe-reject.) See §11 for what this means + the M7 recommendation. Policy (code): physical_steam_controller_present() (scans /sys/bus/hid/devices for a non-virtual 28DE) + degrade_steam_on_conflict() in resolve_gamepad — a resolved SteamDeck/SteamController on a host with a physical Steam controller degrades to DualSense (then the uhid ladder), overridable with PUNKTFUNK_STEAM_FORCE=1. Heuristic hardware-checked: TRUE on the Deck, FALSE on Bazzite. Workspace clippy/fmt/test green.

M5 (fallback remap + degrade ladder) result: new pure, unit-tested inject/proto/steam_remap.rs: (1) motion rescale motion_wire_to_deck — the wire carries DualSense-convention units (what every client capture emits), the Deck's hid-steam report wants 16 LSB/°·s + 16384 LSB/g, so the Deck backend now rescales (gyro ×16/20, accel ×16384/10000) — a real Deck↔Deck gyro/accel correctness fix; (2) fold_paddles + RemapConfig (PUNKTFUNK_STEAM_REMAP=paddles=drop| stickclicks|shoulders, default drop) wired into the DualSense + DS4 managers so a client's back grips aren't silently lost on a PlayStation fallback (those pads have no back-button HID slot; the uinput Xbox pad already exposes them as BTN_TRIGGER_HAPPY5-8). Plus a runtime degrade ladder in resolve_gamepad: a UHID backend (DualSense/DS4/SteamDeck) on a host where /dev/uhid isn't writable now falls back to the uinput Xbox 360 pad instead of a dead controller. The throwaway M0/M1 spike is deleted (M2's #[ignore]d backend test subsumes it). On-box backend test still green; workspace clippy/fmt/test green. Deferred as optional RemapConfig growth: gyro→mouse / trackpad→ stick/mouse synthesis on an Xbox target (no IMU/touchpad slot — currently a documented drop).

M4 (complete) result: (desktop capture — see the prior entry.) Plus: C-ABIPunktfunkRichInputEx (size-prefixed superset) + punktfunk_connection_send_rich_input2 (the struct_size skew-guard precedent; the only way a C client emits TouchpadEx); legacy PunktfunkRichInput/send_rich_input byte-for-byte; punktfunk_core.h regenerated. Decky — a "Steam Deck" gamepad option + an unmissable Disable-Steam-Input instruction (shown when selected) + a best-effort feature-detected programmatic flip in launchStream (never throws; the manual toggle is the source of truth). Apple/Android parityGamepadType.steamController/ steamDeck (Swift) + PREF_STEAMCONTROLLER/STEAMDECK + the 0x28DE PIDs in prefFor (Kotlin), so the type round-trips; capture stays out of scope there (iOS GameController won't surface a 28DE device; Android has no rich-input plane yet). Rust workspace clippy/fmt/test green; Decky src/ typechecks clean; Swift/Kotlin compile on their CI.

Pending VALIDATION (construction is done; M7 is NOT recommended — §11): (1) running-Steam recognition is RESOLVED — Steam won't promote a UHID virtual Deck (interface filter, §11); the virtual Deck serves non-Steam/SDL games, the virtual DualSense serves Steam Input. (2) A live SDL/non-Steam game consuming the virtual Deck's grips/trackpads (the path that works) — needs a real Deck/SC client + a Steam-Input-disabled consumer; note the Deck's /dev/uhid is root-only 0600, so a Deck-as-host needs a udev rule for the input group. (3) The Moonlight paddle regression from the M3 xpad-map change.

M4 (desktop client capture) result: clients/{linux,windows}/src/gamepad.rs (the SDL services) now: set the SDL HIDAPI Steam hints (SDL_JOYSTICK_HIDAPI_STEAMDECK/_STEAM) so SDL opens Valve devices directly; detect the Deck/SC by VID/PID (0x28DE + 0x1205/0x1102/0x1142) → GamepadPref::SteamDeck; map the SDL paddle + Misc1 buttons → the BTN_PADDLE1..4/BTN_MISC1 wire bits; and route a second touchpad → RichInput::TouchpadEx (SDL touchpad 0 = left → surface 1, 1 = right → surface 2, signed coords) while a single touchpad keeps the legacy Touchpad. Held touchpad contacts are now tracked per (surface,finger) and lifted on pad switch/detach. Sensor (gyro/accel) capture was already generic. Linux client builds + clippy clean; Windows is a near-verbatim mirror (windows CI compiles it). Caveat: on a Deck in Game Mode, Steam Input still holds the device — the user must disable Steam Input for the client (the Decky UX, next); on a desktop client (or a Deck with Steam Input off) the hints just work.

M3 result (protocol / ABI wire, on-box): strictly additive + forward-compatible (§5). Core: back-button bits BTN_PADDLE1..4 + BTN_MISC1 (in Moonlight's buttonFlags2<<16 namespace, so GameStream paddle + native grips share one map); RichInput::TouchpadEx (kind 0x03 — surface 0/1/2, click, signed coords, pressure); HidOutput::TrackpadHaptic (kind 0x04). ABI: PUNKTFUNK_GAMEPAD_STEAMDECK=6/_STEAMCONTROLLER=5 + the paddle/RICH_TOUCHPAD_EX/ HIDOUT_TRACKPAD_HAPTIC constants, from_hid packs TrackpadHaptic into the existing which+effect[0..6] (the legacy structs do not grow — guarded by size_of==20/19 asserts); regenerated punktfunk_core.h. Host: steam_proto::from_gamepad maps the paddles → the four Deck grips + QAM; apply_rich routes TouchpadEx left/right → the matching pad; every DS manager (DualSense/DS4, Linux + Windows) gained a TouchpadEx arm (surface 0/2 → its one touchpad); the xpad BUTTON_MAP finally consumes the GameStream paddle bits (BTN_TRIGGER_HAPPY5-8, previously dropped). Wire round-trips + mapping unit-tested; the on-box backend test now drives the full path (from_gamepad grip + apply_rich left-pad) → evdev BTN_A + ABS_HAT0X. Workspace clippy/fmt/test green. Deferred to M4: the C-ABI PunktfunkRichInputEx + send_rich_input2 (only the Apple/C send path needs it; the host decodes TouchpadEx today).

M2 result (backend + wiring, on-box): inject/linux/steam_controller.rs (SteamControllerManager/SteamDeckPad, mirroring dualsense.rs) is wired into PadBackend (new SteamDeck variant + select/handle/apply_rich/pump/heartbeat arms) and selectable via GamepadPref::SteamDeck (core enum byte 6 + pick_gamepad Linux arm; SteamController = byte 5 is reserved, folds to Xbox360 until its backend lands). Two Steam-specific quirks beyond the DualSense path: (1) gamepad_mode entry — best-effort lizard_mode=0 via sysfs + a b9.6 creation pulse (MODE_ENTER 650 ms) + an anti-toggle guard (MENU_HOLD_CAP 350 ms) so a long in-game Start-hold can't flip gamepad_mode off; (2) UHID_SET_REPORT answered err=0 (DualSense omits it) + the 0xEB rumble parsed onto the universal 0xCA plane. An #[ignore]d on-box test (backend_binds_and_input_flows) drives the real backend: it binds hid-steam (gamepad + IMU evdevs), enters gamepad mode, BTN_A reaches the evdev, and the device tears down on drop. Workspace clippy/fmt/test green; no generated-header drift (the C-ABI GamepadPref constants are M3).

M1 result (byte-exact serializer, on-box): inject/proto/steam_proto.rs now carries the full Deck contract transcribed verbatim from the kernel steam_do_deck_input_event / steam_do_deck_sensors_event: the u64 button map (bytes 8..16), sticks/triggers/trackpads/IMU at their exact offsets, from_gamepad + apply_rich mappers, the rumble-feedback parser (0xEB), and the serial reply (now with the leading report-id byte the kernel strips — fixes the M0 XXXXXXXXXX fallback). The validator pulses the b9.6 mode-switch to enter gamepad_mode (the parser early-returns under default lizard_mode otherwise), holds a known test pattern, and reads both evdevs via EVIOCGABS/EVIOCGKEY: every field matchedABS_X/Y/RX/RY (incl. the kernel Y-negation), both triggers, the touched right-pad HAT1X/Y, the IMU accel/gyro (with the ABS_Z/RZ negations), and the 6 expected buttons incl. the L4/R5 grips. byte 8 bit 7 = BTN_A IS correct (the M0 "didn't hold" was a flaky single-bit read before gamepad_mode was entered). 5 unit tests + workspace clippy/fmt/test green.

M0 result (box: headless Ubuntu 26.04, kernel 7.0, no Steam): the spike (crates/punktfunk-host/src/bin/steam_uhid_spike.rs + the keeper inject/proto/steam_proto.rs) created a UHID 28DE:1205 device with a minimal vendor descriptor (one feature report — the sole thing steam_is_valve_interface checks) and serviced the handshake (the UHID_SET_REPORT answers the DualSense backend omits). journalctl -k showed hid-steam binding it (rebound off hid-generic), "Steam Controller … connected", and the kernel creating both evdevs: "Steam Deck" (gamepad) and "Steam Deck Motion Sensors" (INPUT_PROP_ACCELEROMETER). Outstanding for later: recognition by a running Steam client (needs a box with Steam — untestable here); the gamepad_mode entry strategy on a real host (pulse b9.6 at session start, or load hid_steam lizard_mode=0) is an M2 backend decision.

M0 result (box: headless Ubuntu 26.04, kernel 7.0, no Steam): the spike (crates/punktfunk-host/src/bin/steam_uhid_spike.rs + the keeper inject/proto/steam_proto.rs) created a UHID 28DE:1205 device with a minimal vendor descriptor (one feature report — the sole thing steam_is_valve_interface checks) and serviced the handshake (the UHID_SET_REPORT answers the DualSense backend omits). journalctl -k showed hid-steam binding it (rebound off hid-generic), "Steam Controller … connected", and the kernel creating both evdevs: "Steam Deck" (gamepad, BTN_A in key caps) and "Steam Deck Motion Sensors" (INPUT_PROP_ACCELEROMETER, 6 IMU axes). A layout-agnostic mash-probe confirmed the input path: 23 distinct BTN_* codes (A/B/X/Y, TL/TR, SELECT/START/MODE, THUMBL, all 4 DPAD, grips, back codes) toggled through hid-steam → evdev. bind dual evdev incl. IMU report-parse path. Outstanding: (4) recognition by a running Steam client (needs a box with Steam — untestable here); and the exact per-bit button/stick/pad/IMU offsets (M1, line-checked vs the lab kernel — the v6.12-sourced byte 8 bit 7 = BTN_A did not hold on 7.0). The serial GET_REPORT reply also needs its report-number-prefix offset fixed (the kernel used the XXXXXXXXXX fallback; non-fatal).

1. Goal + scope

Carry the full Steam Controller / Steam Deck input surface end-to-end and let a remote host present a real virtual Steam device that Steam Input and games bind as genuine:

  • the 4 back grips (L4/L5/R4/R5),
  • both capacitive trackpads (the Deck's L/R pads, the SC's dual pads) with touch + click + pressure,
  • the IMU (3-axis gyro + 3-axis accel),
  • the Steam/quick-access (/QAM) buttons,
  • haptics/rumble back-channel (Deck rumble motors; SC trackpad voice-coils).

Locked decisions (2026-06-29):

  1. Full pipeline — capture on every client + inject on the hosts, not a one-platform demo.
  2. Disable-Steam-Input UX for the Deck-in-Game-Mode capture wall (§6) — we own the instruction and a best-effort programmatic flip; the manual toggle is the source of truth.
  3. Max fidelity — build the greenfield virtual hid-steam driver. Linux UHID first (validates the contract against open-source hid-steam.c + SDL hidapi); Windows UMDF later, gated on the Linux result (§8).
  4. The virtual DualSense remap is the proven fallback wherever a virtual Steam device is unavailable or undesired (§7), so Steam-only inputs are never silently dropped.

This is the same architectural bet as the virtual DualSense: the rich semantics (adaptive triggers there; back grips + trackpads + gyro bindings/glyphs here) only materialize end-to-end if the game/Steam sees a real device and therefore drives them. A generic Xbox pad makes the game take its Xbox code path and the rich surface never exists.

The unique value of a virtual Steam device is realized only when the host runs Steam Input, which re-grabs the 28DE device and re-emits it as 28DE:11FF with the user's per-game bindings and correct glyphs. Off-Steam, we fall back to DualSense (§7).

2. The two walls + the honest fidelity ceiling

There are exactly two hard problems. Everything else is plumbing we have already shipped twice (DualSense, DS4).

Wall A — Steam Input capture ownership (client side, solvable via UX)

On the client, enabling SDL's SDL_JOYSTICK_HIDAPI_STEAMDECK/HIDAPI_STEAM drivers makes SDL open the raw 28DE:1205 and expose paddles + trackpads + gyro as a first-class SDL gamepad (the inputtino/Sunshine path). But in Deck Gaming Mode, Steam Input grabs the device exclusively and re-presents it as a virtual XInput pad — so SDL's HIDAPI driver cannot open the raw device and the rich controls silently vanish. This is inherent: Steam owns the device. The only escape is the locked Disable-Steam-Input-per-title decision. Outside Game Mode (desktop client, or a Deck used as a streaming target), the hints just work.

Wall B — virtual-Steam-Controller recognition (host side, art-pushing)

On the host, no public project emulates a virtual hid-steam device (inputtino does hid-playstation/Xbox/Switch, not Steam). Two unknowns stack:

  • Linux (lower risk): the kernel bind path is well-mapped from drivers/hid/hid-steam.c (open source) — match by VID/PID over BUS_USB, answer the probe feature handshake, stream the 64-byte state report. This is provable against open source. M0 proves it.
  • Windows (higher risk): Steam's closed userspace driver must accept the same contract over a UMDF-presented HID interface, and SDL #12166 shows Steam/SDL aborts the controller if its 0x83 GET_ATTRIBUTES_VALUES / 0xA1 GET_DEVICE_INFO feature probes fail. Those reply blobs are not derivable — they must be captured from real hardware. Deferred to §8.

The fidelity ceiling — what is inherent vs solvable

Capability Status
Buttons, dual sticks, analog triggers, dpad Solvable — maps 1:1 from the existing Xbox-style frame
Back grips L4/L5/R4/R5 Solvable on the virtual Steam pad + uinput Xbox (BTN_TRIGGER_HAPPY*); lost on DualSense/DS4 fallback (no back-button HID slot — remapped or dropped, documented)
Dual trackpads (touch/click/pressure) Solvable on the virtual Steam pad; collapses to one surface on a DualSense fallback target
Gyro/accel (IMU) Solvable — already carried by RichInput::Motion; no IMU on an Xbox fallback (xpad has none)
Rich semantics + glyphs in games Inherently requires Steam Input running on the host to re-grab + rebind
Deck-in-Game-Mode capture Inherently requires disabling Steam Input for the punktfunk title
Trackpad voice-coil haptics (SC) Collapsed to the universal rumble plane unless a client renders localized haptics
Adaptive triggers / lightbar N/A — Steam devices have none

3. Architecture overview (capture → protocol → inject)

 CLIENT (SDL3 / GameController / NDK)             HOST (punktfunk1.rs PadBackend)
 ┌──────────────────────────────┐                 ┌────────────────────────────────────────┐
 │ buttons+sticks+triggers  ────┼── 0xC8 ────────►│ SteamControllerManager (Linux UHID)     │
 │ back grips L4/L5/R4/R5   ────┼── 0xC8 bits ───►│   → serialize_deck_state → /dev/uhid     │
 │ trackpads (2 surfaces)   ────┼── 0xCC 0x03 ───►│   → kernel hid-steam binds 28DE:1205      │
 │ gyro+accel (IMU)         ────┼── 0xCC 0x02 ───►│   → gamepad evdev + IMU evdev            │
 │ GamepadPref=SteamDeck    ────┼── Hello byte ──►│   → Steam Input re-emits 28DE:11FF        │
 └──────────────────────────────┘                 │                                          │
        ▲ rumble 0xCA / haptic 0xCD 0x04           │ parse_steam_output ◄── UHID_SET_REPORT   │
        └──────────────────────────────────────────┤   0xEB rumble / 0x8F pad-haptic          │
                                                    │                                          │
                                                    │ FALLBACK (target = DualSense/DS4/Xbox):  │
                                                    │   inject/proto/steam_remap.rs            │
                                                    │   gyro→Motion, pads→touchpad/stick/mouse │
                                                    │   grips→BTN_PADDLE→BTN_TRIGGER_HAPPY     │
                                                    └────────────────────────────────────────┘

The centerpiece is the virtual hid-steam UHID device (§4). Gamepads are not on the compositor injector path — they are owned by a per-session PadBackend created in PadBackend::select and torn down (RAII) when the session input thread exits, identical to the DualSense/DS4 lifecycle. So this drops in with zero changes to the injector, capture, or audio planes; only PadBackend + GamepadPref + the protocol kinds grow.

The proven fallback is the virtual DualSense remap: whenever the resolved backend is not a real Steam device, steam_remap.rs folds the Steam-only inputs into whatever pad we do present so nothing is silently lost.

4. The virtual hid-steam driver (Linux UHID first)

The mechanism is the exact analogue of the shipped virtual DualSense (inject/linux/dualsense.rs + inject/proto/dualsense_proto.rs), with three Steam-specific deltas: the bind identity, the feature SET_REPORT handshake (the DualSense backend only handles GET_REPORT — the Steam path MUST also service SET_REPORT or stall), and the unnumbered (report-id-0) raw 64-byte framing.

4.1 Binding mechanism

hid-steam matches purely by VID/PID over BUS_USB: HID_USB_DEVICE(0x28DE, 0x1205, STEAM_QUIRK_DECK). A UHID_CREATE2 device with bus = BUS_USB (0x03), vendor = 0x28DE, product = 0x1205 is bound and steam_probe runs. hid-steam is a raw-event driver (steam_raw_event returns "handled" and bypasses HID field parsing), so the report descriptor is almost cosmetic — except steam_probe requires hid_parse to succeed and a non-empty FEATURE report list, so the descriptor MUST declare ≥1 feature report at report id 0.

Recommend the Deck (0x1205), not the classic SC (0x1102), for M0. The Deck has standard dual sticks + dual analog triggers + 4 back grips + IMU, all of which map cleanly from the existing Xbox-style client frame + Motion/TouchpadEx planes. The classic SC has no right stick (dual trackpads) and trackpad-voice-coil-only haptics — awkward to synthesize. Deck is also what the locked Game-Mode UX targets. SC (0x1102, report id 1) is a later identity behind the same manager.

Reports are UNNUMBERED (report id 0). steam_send_report/steam_recv_report call hid_hw_raw_request(hdev, 0, …); interrupt-in payloads have no report-id prefix. So data[0] is the protocol constant 0x01, data[1] = 0x00, data[2] = 0x09. A numbered descriptor would shift the whole frame one byte and data[0] != 1 would drop every report.

4.2 The feature-report probe contract (the load-bearing delta from DualSense)

During probe, steam_register → steam_get_serial() sends command 0xAE (ID_GET_STRING_ATTRIBUTE) as a feature SET_REPORT, then blocks on a GET_REPORT for the reply. On UHID these arrive as UHID_SET_REPORT (type 13) and UHID_GET_REPORT (type 9). The service loop MUST handle three event types (vs the DualSense's two):

Event Reply Notes
UHID_GET_REPORT (9) UHID_GET_REPORT_REPLY (10) serial blob [0xAE, attrib=0x01, len, ascii…], or err=EIO
UHID_SET_REPORT (13) UHID_SET_REPORT_REPLY (14), err=0 always ignore → kernel stalls ~5 s/command; parse id u32@[4..8], rnum@[8], rsize u16@[9..11], data@[11..]
UHID_OUTPUT (6) parse if present feedback path

Command IDs the device must ack (err=0) and may parse:

  • 0xAE ID_GET_STRING_ATTRIBUTE (serial) — NON-FATAL: the kernel falls back to a fake serial "XXXXXXXXXX" and continues either way, so even an EIO reply yields a working device. Answering keeps probe instant.
  • 0x81 ID_CLEAR_DIGITAL_MAPPINGS / 0x8E ID_LOAD_DEFAULT_SETTINGS / 0x87 ID_SET_SETTINGS_VALUES (lizard-mode + settings) — ack err=0, ignore content. The Deck path skips the auto lizard-mode disable at input_open, so these only arrive on an options-hold or via Steam userspace — but must be ack'd to avoid the per-command stall.
  • 0x83 ID_GET_ATTRIBUTES_VALUES / 0xA1 ID_GET_DEVICE_INFOnot issued by the kernel Deck probe, but Steam userspace queries them for full Steam Input fidelity (gyro/back buttons). For M0 bind they are unnecessary; for full fidelity (M3+) answer with real-hardware-captured blobs.

4.3 Input-report layout (Deck ID_CONTROLLER_DECK_STATE, msg 0x09)

64-byte unnumbered report, little-endian. steam_raw_event drops anything where size != 64 || data[0] != 1 || data[1] != 0, then switch(data[2]).

[0]      0x01            protocol constant (REQUIRED ==1)
[1]      0x00            protocol constant (REQUIRED ==0)
[2]      0x09            ID_CONTROLLER_DECK_STATE   (0x01 = ID_CONTROLLER_STATE for the SC)
[3]      len             payload length, kernel ignores (set ~0x3C)
[4..8]   u32 LE          frame/sequence counter (monotonic)
[8]      buttons b8      {A,X,B,Y, L1,R1, L2-full,R2-full}
[9]      buttons b9      {DPAD_U,DPAD_R,DPAD_L,DPAD_D, view, steam, menu, GRIPL2(L5)}
[10]     buttons b10     {GRIPR2(R5), lpad_touch, rpad_touch, L3, R3, …}
[11..13] b11/b12         reserved/touch + THUMBR alt
[13]     buttons b13     GRIPL(L4)@bit1, GRIPR(R4)@bit2
[14]     buttons b14     BTN_BASE (quick-access)@bit2
[16..24] s16 x4 LE       LEFT pad X/Y, RIGHT pad X/Y      → ABS_HAT0X/Y, ABS_HAT1X/Y (res ~1638)
[24..36] s16 x6 LE       accel X, accel Z(neg), accel Y, gyro X, gyro Z(neg), gyro Y
                                                          → IMU ABS_X/Y/Z + ABS_RX/RY/RZ
[36..44] s16 x4 LE       orientation quaternion (optional)
[44..48] u16 x2 LE       LEFT trigger, RIGHT trigger      → ABS_HAT2Y / ABS_HAT2X
[48..56] s16 x4 LE       LEFT stick X/Y(neg), RIGHT stick X/Y(neg) → ABS_X/Y, ABS_RX/RY
[56..60] u16 x2 LE       LEFT/RIGHT pad pressure
[60..64] reserved

Neutral state: sticks/pads/triggers = 0x0000 (signed-centered at 0 — note this differs from the DualSense's 0x80 stick centers); all button bytes 0. On bind the kernel exposes two evdevs: a Deck gamepad (BTN_A..BTN_GRIPL/R + GRIPL2/R2, BTN_BASE, ABS sticks/triggers/pads) and a separate IMU evdev (INPUT_PROP_ACCELEROMETER).

The exact per-byte button bit masks and the 0xEB rumble offsets in this table are summarized from secondary parsing and MUST be confirmed line-by-line against steam_do_deck_input_event / steam_haptic_rumble in the lab kernel before trusting input fidelity. The backend logs a first-frame layout dump (the DS4 pattern) to catch slips.

4.4 Feedback surface

Simpler than the DualSense — no lightbar / player LEDs / adaptive triggers. Feedback arrives as a feature SET_REPORT (type 13, ack err=0), not a UHID_OUTPUT interrupt:

  • 0xEB ID_TRIGGER_RUMBLE_CMD — Deck rumble motors; map left/right → (low, high) on the existing universal 0xCA rumble plane (exactly like the DualSense fb.rumble).
  • 0x8F ID_TRIGGER_HAPTIC_PULSE — the SC's two trackpad voice-coils (pad 0=left/1=right/2=both, duration/interval/count). Niche on the Deck; for M0 ack-and-fold into 0xCA (left-pad→low, right-pad→high). For clients with localized haptics, surface as the new 0xCD 0x04 TrackpadHaptic (§5).

No 0xCD HID-output plane is otherwise needed for the Deck.

4.5 New Linux modules (mirror the DualSense trio)

  • crates/punktfunk-host/src/inject/proto/steam_proto.rs — transport-independent contract: STEAM_VENDOR=0x28DE; SteamModel{ Deck=0x1205 rid 9, Controller=0x1102 rid 1 }; the verbatim STEAMDECK_RDESC (≥1 feature report at id 0); SteamState superset model (sticks, analog triggers, packed buttons, dpad, gyro[3]/accel[3], back:[bool;4], two SteamPad{active, click, x:i16, y:i16, pressure:u16} surfaces, steam/quickaccess); SteamState::from_gamepad (the XInput mapper + the new paddle/misc wire bits); serialize_deck_state/serialize_sc_state (byte-exact); feature_reply(rnum); parse_steam_output(data, &mut SteamFeedback). Unit tests for offsets + output parsing, mirroring dualsense_proto's tests.
  • crates/punktfunk-host/src/inject/linux/steam_controller.rs/dev/uhid plumbing + SteamControllerManager, byte-identical structure to dualsense.rs: SteamPad::open(index, model) does UHID_CREATE2; write_state → UHID_INPUT2; service() answers GET_REPORT + SET_REPORT + OUTPUT; Drop → UHID_DESTROY; handle/apply_rich/pump/heartbeat(8 ms). The 8 ms heartbeat re-emits the last report — a real Deck streams continuously and multi-second silence reads as a disconnect to SDL/Steam.

Needs /dev/uhid writable (the existing 60-punktfunk.rules udev rule + input group, same as DualSense) and hid-steam present/loaded (modprobe hid-steam; mainline module).

5. Protocol / ABI changes (exact wire/constants)

Strictly additive and forward-compatible — everything rides existing tags (0xC8 buttons, 0xCC rich input, 0xCD HID-out, the GamepadPref Hello/Welcome byte). Unknown kinds/bits drop on old peers exactly as today.

5.1 Back-button bits (input.rs::gamepad, ride 0xC8, no length change)

We align to Moonlight's buttonFlags2 << 16 namespace so the GameStream paddle path and the native path share one injector map. The classic-paddle slots are GameStream-aligned; the four Steam grips sit just above the touchpad bit:

BTN_PADDLE1 = 0x0001_0000   (R4 / SDL RightPaddle1 / GameStream PADDLE1)
BTN_PADDLE2 = 0x0002_0000   (L4 / SDL LeftPaddle1  / GameStream PADDLE2)
BTN_PADDLE3 = 0x0004_0000   (R5 / SDL RightPaddle2 / GameStream PADDLE3)
BTN_PADDLE4 = 0x0008_0000   (L5 / SDL LeftPaddle2  / GameStream PADDLE4)
BTN_TOUCHPAD= 0x0010_0000   (already present, = TOUCHPAD_FLAG << 16)
BTN_MISC1   = 0x0020_0000   (Deck '…'/QAM, Share/Capture / GameStream MISC)

Decision (resolves the placement open-question): native back buttons reuse the GameStream paddle bits (0x0001_0000..0x0008_0000) rather than a separate 0x40_0000+ range. This unifies the GameStream-paddle and native-grip injector maps onto one table, and Xbox Elite paddles map for free. Steam L4/L5/R4/R5 ↔ Xbox P1P4 is a semantic 1:1 for binding purposes; the device identity carries the glyph distinction.

5.2 RichInput::TouchpadEx (kind 0x03, rides 0xCC, client→host)

0x01 Touchpad (DualSense single contact) is kept forever. The new superset carries the second pad + click + pressure with signed coords matching the real Steam report:

[0xCC][0x03][pad u8][surface u8][finger u8][state u8][x i16 LE][y i16 LE][pressure u16 LE]   // 12 B
  surface : 0 = single/DS touchpad, 1 = Steam left pad, 2 = Steam right pad
  state   : bit0 = touch (capacitive contact), bit1 = click (pad depressed)
  pressure: 0 if the surface has no sensor

Decode gated b.len() >= 12; unknown kind → None. New clients emit TouchpadEx for all touch surfaces; the host decodes both 0x01 and 0x03 indefinitely (no flag day). Every existing manager (dualsense.rs, dualshock4.rs, dualsense_windows.rs) gains a TouchpadEx arm (treat surface 0/2 → contact, ignore 1) so the new variant compiles everywhere.

5.3 HidOutput::TrackpadHaptic (kind 0x04, rides 0xCD, host→client)

[0xCD][0x04][pad u8][side u8][amplitude u16 LE][period u16 LE µs][count u16 LE]   // 10 B

Decode gated b.len() >= 10. The ABI PunktfunkHidOutput struct is NOT grown (it has no struct_size guard — growing it would overwrite old-built caller buffers): the new kind reuses existing fields — which = side, amplitude/period/count packed LE into effect[0..6] with effect_len = 6. Clients without coils drop it (or optionally map to rumble).

5.4 GamepadPref source hints

Trailing fwd-compat Hello/Welcome byte (unknown → Auto):

5 = SteamController   (steam | steamcontroller | sc)
6 = SteamDeck         (steamdeck | deck | sd)

These are source hints (what the physical client controller is) so the host prefers the virtual hid-steam backend + the right glyph identity; honored only where the backend exists (Linux UHID first), else degraded — the Welcome echoes the real resolved backend (honest downgrade). Clients auto-resolve from VID/PID (§6), like DS5→DualSense.

5.5 ABI surface (abi.rs + regenerate include/punktfunk_core.h)

  • New constants: PUNKTFUNK_RICH_TOUCHPAD_EX=3, PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC=4, PUNKTFUNK_GAMEPAD_STEAMCONTROLLER=5, PUNKTFUNK_GAMEPAD_STEAMDECK=6, and PUNKTFUNK_GAMEPAD_BTN_PADDLE1..4 / _BTN_MISC1 for embedders building raw InputEvents.
  • Do not mutate PunktfunkRichInput (no struct_size guard). Add a guarded superset PunktfunkRichInputEx{ u32 struct_size; u8 kind,pad,finger,active,surface,click; u8 _pad[2]; i16 x,y; u16 pressure; i16 gyro[3]; i16 accel[3]; } + punktfunk_connection_send_rich_input2 that reads the size prefix first (the connect_exN/config_from_ptr precedent). Legacy PunktfunkRichInput + send_rich_input stay byte-for-byte.
  • from_hid gains the TrackpadHaptic → effect[]-packing arm; to_rich (Ex) gains the TouchpadEx arm.
  • Compile guards: extend the GamepadPref lockstep assert! block with the two new variants; add assert!(size_of::<PunktfunkRichInput>()==20) + assert!(size_of::<PunktfunkHidOutput>() ==19) so the additive changes can never silently shift the legacy layouts. CI fails on header drift.

5.6 GameStream host-map fix (no protocol change)

gamestream/gamepad.rs:91 already computes buttons = buttonFlags | (buttonFlags2 << 16), but the xpad BUTTON_MAP drops every bit above 0x8000, so Moonlight paddle/Share clients are silently no-op'd today. Name the already-decoded bits (PADDLE1=0x0001_0000 … PADDLE4= 0x0008_0000, TOUCHPAD=0x0010_0000, MISC=0x0020_0000) and add them to the injector map so the native and GameStream back-button paths drive one unified output. This is a behavior change for existing Moonlight users — needs a live regression check (§10).

6. Client capture

sdl3-rs 0.18.4 API-coverage gate — RESOLVED (was the top client risk)

Independently verified against docs.rs for sdl3 0.18.4 (2026-06-29), so no raw sdl3-sys fallback is needed: Button::{LeftPaddle1,RightPaddle1,LeftPaddle2,RightPaddle2,Misc1..6, Touchpad} exist (confirmed); Gamepad::touchpads_count() + supported_touchpad_fingers() exist (confirmed); vendor_id()/product_id() exist (confirmed); Event::ControllerTouchpad {Down,Motion,Up} carries the touchpad surface field the current code discards. Sensor capture (gyro/accel) is already proven in-tree — the shipping client enables and reads SDL sensors for the DualSense today (set_sensors in clients/linux/src/gamepad.rs), so genericizing it past the DualSense gate is a one-line change, not a new dependency. The hint strings SDL_JOYSTICK_HIDAPI_STEAMDECK / SDL_JOYSTICK_HIDAPI_STEAM live in sdl3-sys. There is no SDL_GAMEPAD_TYPE_STEAM_DECK — detect by VID 0x28DE.

Linux + Windows SDL clients (near-verbatim ports — clients/{linux,windows}/src/gamepad.rs)

  1. Before sdl3::init(), set SDL_JOYSTICK_HIDAPI_STEAMDECK="1" + SDL_JOYSTICK_HIDAPI_STEAM ="1" (the exact sdl3::hint::set mechanism already used for SDL_NO_SIGNAL_HANDLERS).
  2. In pad_info, override to GamepadPref::SteamDeck when vendor_id()==0x28DE and product_id() ∈ {0x1205 Deck, 0x1102 SC wired, 0x1142 SC dongle}; add a "Steam Deck" label.
  3. Extend button_bit: RightPaddle1→BTN_PADDLE1, LeftPaddle1→BTN_PADDLE2, RightPaddle2→BTN_PADDLE3, LeftPaddle2→BTN_PADDLE4, Misc1→BTN_MISC1 (free win for Elite).
  4. Bind the touchpad surface field in the three ControllerTouchpad* arms; branch on touchpads_count() rather than hard-coding 2 — surface 0 → existing RichInput::Touchpad, surface ≥1 → RichInput::TouchpadEx{surface}.
  5. Track held contacts keyed by (surface, finger) and lift them (active=false) in flush_held on pad switch/detach (today only the DualSense single surface is implicitly lifted).
  6. Sensor capture is already generic — only the DualSense-only doc comments change.

Disable-Steam-Input UX (Decky + docs)

  • Decky (clients/decky/): a Settings toggle "Capture Steam Deck controls (paddles · trackpads · gyro)" that selects gamepad pref=steamdeck, adds "steamdeck" to the option set (TS + main.py validation), and renders an unmissable inline instruction: gamescope game page → gear → Controller → Steam Input → Off for the punktfunk shortcut.
  • Best-effort programmatic flip (clients/decky/src/steam.ts): disableSteamInputForShortcut(appId) — feature-detected at runtime (guarded exactly like the existing optional window.DeckyBackend/collectionStore globals), called best-effort inside ensureShortcut(), never blocking or throwing into launchStream. The manual toggle is the documented source of truth — there is no confirmed stable SteamClient API and it may regress across Steam updates.

Apple / Android — honest no-code-now scope

  • Apple: parity only — add a .steamDeck enum case (wire byte 6) so the type round-trips; no capture. GameController never surfaces a 28DE HID device as a GCExtendedGamepad (Apple has no Steam Input; a raw path would need IOHIDManager). Document as blocked.
  • Android: parity only — add PREF_STEAMDECK + the 28DE PIDs to the mapping. Capture of paddles/trackpads/gyro is out of scope here: send_rich_input is itself still a TODO (session.rs:13), and a Deck dongle appears only as a generic gamepad via InputDevice. Revisit after the rich-input port lands.

7. Host inject / mapping + host-integration semantics

PadBackend wiring

enum PadBackend (punktfunk1.rs) gains #[cfg(target_os="linux")] SteamController (SteamControllerManager) and SteamDeck(SteamControllerManager)one manager, a SteamModel field (sticks/descriptor/report differ, logic is shared; the DS4-reuses-DualSense pattern). Wire all five arms (select/handle/apply_rich/pump/heartbeat). pick_gamepad / resolve_gamepad gain GamepadPref::SteamController|SteamDeck if linux arms; on Windows and elsewhere they fold to Xbox360 until the UMDF driver lands (§8).

Selection / resolution policy (the load-bearing part)

Unlike the DualSense, a virtual Steam pad only pays off under specific host conditions, so resolution is gated. Highest priority first:

  1. Explicit client SteamController/SteamDeck pref — honored if /dev/uhid is writable AND hid-steam is loadable; else degrade.
  2. PUNKTFUNK_GAMEPAD=steamdeck|steamcontroller host env.
  3. Auto — resolve to a Steam pad only when the host is running Steam Input (so the rich semantics are actually consumed). Otherwise Auto prefers DualSense (broader non-Steam SDL surface: gyro + a real touchpad) over a Steam pad whose trackpads/grips a non-Steam game won't understand.
  4. Degrade ladder when a requested Steam pad is unavailable: hid-steam missing → DualSenseXbox360. The Welcome carries the real choice.

SteamOS/Deck-as-host conflict: a host already running Steam eagerly grabs any 28DE device, so our virtual pad could be double-handled alongside the operator's physical Deck controller. Default policy: gate Steam pads OFF on a SteamOS/gamescope host unless explicitly forced (M6 confirms).

Fallback remap (inject/proto/steam_remap.rs, pure + unit-testable)

When the resolved backend is DualSense/DS4/Xbox, fold the Steam-only inputs in so nothing drops:

  • Gyro/accelRichInput::Motion (native on DualSense/DS4; no-op on Xbox — xpad has no IMU).
  • Right trackpad → DualSense/DS4 touchpad contact (1:1 absolute surface); on an Xbox target, optionally synthesized to the right stick behind a config toggle.
  • Left trackpad → left stick or relative mouse via the existing InjectorService pointer plane (config; default mouse, matching SteamOS desktop feel).
  • Back buttons L4/L5/R4/R5BTN_PADDLE1..4 → on a uinput Xbox pad, BTN_TRIGGER_HAPPY1..4 (0x2c0..0x2c3, what Steam Input/SDL read as paddles); add the matching UI_SET_KEYBIT registrations in create(). DS4/DualSense have no back-button HID slot — paddles fall back to a configurable default (e.g. L4→L3, R4→R3) or are dropped, documented, not silent.
  • DS4 100 ms motion-timestamp keepalive applies whenever motion is forwarded onto a DS4 target — keep apply_rich/heartbeat flowing so the sensor ts += 188 advances, or games reject motion as stale.

A RemapConfig (env/config driven, e.g. PUNKTFUNK_STEAM_REMAP=…) holds the trackpad/back-button policy knobs.

8. Windows UMDF — a later, gated phase

Do not start until the Linux UHID device binds. Linux proves the report descriptor + feature blobs + state layout against open-source hid-steam.c + SDL hidapi; Windows then only adds the unknown of Steam's closed userspace driver accepting the same contract over UMDF.

Reuse the entire proven pf-dualsense UMDF path (the repo already proved a self-signed Rust UMDF HID minidriver loads under Secure Boot ON and is recognized as a genuine controller):

  • Fork packaging/windows/drivers/pf-dualsensepf-steamdeck. Keep verbatim the vhidmini2-derived WDF scaffolding, the FORCE_INTEGRITY PE-bit clear (PE+0x5e), the timer-completes-pended-READ_REPORT pattern, the queue NumberOfPresentedRequests=u32::MAX and timer ExecutionLevel/SynchronizationScope=InheritFromParent + AutomaticSerialization=TRUE gotchas, the Global\pfds-shm-<idx> shared-memory channel, the multi-pad pszDeviceLocation/UmdfHostProcessSharing=ProcessSharingDisabled plumbing, the Include=MsHidUmdf.inf/WUDFRD.inf INF stanza, and SwDeviceCreate (enumerator punktfunk, hardware id pf_steamdeck).
  • Swap identity (VID 0x28DE / PID 0x1205), the hid-steam report descriptor, and — the riskiest, non-derivable part — the 0x83 GET_ATTRIBUTES_VALUES + 0xA1 GET_DEVICE_INFO feature blobs captured from real hardware (SDL #12166: Steam/SDL aborts the controller if these probes fail). Add ACK-only SET_FEATURE handlers for 0x81/0x87/0x8E.
  • Host backend: a SteamDeckWindows reusing inject/windows/dualsense_windows.rs almost wholesale (SwDeviceCreate, map the section, pack the 64-byte state, read the haptic output slot).
  • Bundle pf_steamdeck.{inf,cat,dll} into the existing Inno installer + install-gamepad-drivers .ps1 pnputil flow, identical to pf-dualsense/DS4/XUSB.

NEVER emulate 28DE:11FF — that is Steam's own emulated output pad, not an input device; emulating it risks a feedback loop where Steam ingests its own output. Watch for: Steam requiring a USB instance path a SwDevice lacks; Steam wanting the sibling emulated keyboard/mouse collections present; VAC/device-trust rejection of a self-signed virtual Steam Controller; and gating the Deck PID (0x1205) on Deck hardware (wired-SC 0x1102 may be the safer desktop identity).

9. Milestone plan (M0 is the go/no-go)

See the structured milestones. The shape mirrors the DualSense effort: an M0 feasibility gate answers the recognition question before any pipeline is built. M1M3 are Linux. M4M5 are clients + protocol. M6 is the SteamOS-host conflict check. M7+ is the deferred Windows UMDF phase, itself re-gated on its own recognition spike.

10. Risks, open questions, validation

Validation / test plan

Loopback (no hardware):

  • Core: RichInput::TouchpadEx + HidOutput::TrackpadHaptic + GamepadPref 5/6 encode/decode round-trips + an old-peer-drops-unknown-kind assertion; the from_gamepad paddle/misc mapping; steam_proto report-offset + parse_steam_output unit tests (mirror dualsense_proto); the steam_remap fold policy.
  • pick_gamepad/resolve_gamepad: client SteamDeck + hid-steam present + Steam → SteamDeck; client SteamDeck + no module → DualSense in the Welcome; Auto + no Steam → DualSense; PUNKTFUNK_GAMEPAD=steamdeck forces it; Windows folds to Xbox360. Assert the Welcome echoes the real choice each time.

On-box (the box has hid-steam mainline + the udev rule):

  • BIND proof (Steam NOT running): a tiny test main creates the device + heartbeats neutral. Confirm dmesg shows hid-steam … Valve Software Steam Deck Controller; the sysfs node binds hid_steam; a gamepad evdev AND a second IMU evdev appear (udevadm infoID_INPUT_JOYSTICK=1 + INPUT_PROP_ACCELEROMETER).
  • RECOGNITION proof: sdl2-jstest --list / an SDL3 app reports GUID 28de:1205 "Steam Deck"; evtest shows BTN_SOUTH etc.; toggle the A bit and watch the key event.
  • STEAM proof (Steam running on the host): Settings → Controller shows a "Steam Deck Controller"; the kernel evdev disappears (the client_opened standoff is expected); bind a back grip to a key in Steam Input and confirm a non-Steam test game sees it.
  • RUMBLE proof: fftest / a game triggers FF_RUMBLE; confirm a 0xEB SET_REPORT arrives and our parser emits (low, high) on 0xCA back to the client.
  • Cross-machine: the Linux client (paddles + both pads + gyro) over the LAN → the virtual Deck on the host → Steam re-emits 28DE:11FF with working bindings + glyphs.
  • GameStream regression: confirm the new buttonFlags2 consumption doesn't emit spurious back-grip/record events for a stock Moonlight client with a normal pad.

(Full risks + open questions in the structured fields.)

11. The interface-2 ceiling — Steam Input won't manage a UHID virtual Deck (hardware-validated 2026-06-29)

Validated on a SteamOS Steam Deck (192.168.1.253) + a Bazzite host (192.168.1.41), both running Steam, with a minimal C UHID probe (28DE:1205 + the proven descriptor/handshake) run on Bazzite (no physical Steam controller, so a clean test bed).

What works. The kernel hid-steam binds the virtual Deck by VID/PID on a second independent kernel (Bazzite 6.17.7) exactly as on the dev box (7.0): it accepts our serial (the M1 report-id-0 fix), and creates both the "Steam Deck" gamepad evdev and the "Steam Deck Motion Sensors" IMU evdev. So any consumer that reads the kernel evdev or opens the hidraw via SDL's HIDAPI Steam Deck driver sees the full surface — the four grips (BTN_GRIPL/R/L2/R2), both trackpads (ABS_HAT0/1), and the IMU.

What does NOT work: Steam Input promotion. Steam's own controller driver enumerates the device — controller.txt logs Local Device Found, type: 28de 1205, Product "Punktfunk Steam Deck", path /dev/hidraw1, Interface: -1 — but never promotes it: no 28DE:11FF virtual XInput pad, no "Controller N connected". On the same Steam logs the physical Deck appears as Interface: 2. A real Steam Deck is a 3-interface USB device (keyboard = interface 0, mouse = 1, controller = 2), and Steam binds the controller specifically on interface 2. A single /dev/uhid device is not a USB device and has no bInterfaceNumber, so Steam reads -1 and filters it out. (Notably the 0x83 GET_ATTRIBUTES / 0xA1 GET_DEVICE_INFO probes the prior research feared — SDL #12166 — never fired: this is an interface filter, not an attribute-probe rejection. That blocker, if it exists, is Windows-driver-specific.)

Why UHID can't fix it. UHID creates one HID interface with no USB interface number; you cannot set one, and creating three UHID devices wouldn't help (each is still interface-less / -1). Presenting a real multi-interface USB Deck with the controller on interface 2 needs a USB gadget (dummy_hcd + configfs) or a kernel USB bus driver — a much larger, less portable lift, and contrary to punktfunk's "no kernel bus driver" stance (ViGEm was deliberately removed).

Strategic consequences

  • The virtual Deck's real value is non-Steam / SDL games on Linux (emulators, native SDL titles, anything reading the kernel evdev or SDL's HIDAPI Steam driver) — there it delivers grips + trackpads + gyro. It does not deliver Steam Input glyphs/bindings.
  • For Steam-Input hosts, the virtual DualSense is the right path — HARDWARE-VALIDATED (2026-06-29). A virtual DualSense (UHID, also Interface: -1) run on Bazzite while Steam ran was fully promoted: controller.txt logged Local Device Found type 054c 0ce6 "DualSense Wireless Controller", then Controller using HIDAPI driver, vid=0x054c, pid=0x0ce6 and loaded configset_controller_ps5.vdf (it even read back our calibration/pairing/firmware feature blobs). So the same interface -1 that the Deck is rejected at is accepted for the DualSense — proof the wall is specifically the Deck's multi-interface / interface-2 requirement, not a UHID limitation. The DualSense path therefore delivers real Steam Input (gyro + touchpad + glyphs + bindings) for a streamed Deck/SC client; the M5 paddle-fold carries the back grips onto standard buttons. This is why the M6 conflict gate degrades to DualSense and Auto prefers it. What the DualSense identity loses vs a real Deck: Deck glyphs, the second trackpad, and the 4 back grips as distinct Steam-Input-bindable paddles (they fold to face/shoulder/stick buttons instead).
  • Full Deck-identity Steam Input would need interface 2 → a USB gadget (dummy_hcd + configfs HID functions presenting kbd/mouse/controller, controller on interface 2). Feasible in principle (it gives real interface numbers), but heavy and less portable: dummy_hcd is not built on Bazzite, the Deck, or the dev box, so it would have to be built/loaded per-kernel on every Steam host — and an immutable SteamOS/Bazzite host makes that a package-layer + reboot. The marginal gain over the validated DualSense path is Deck glyphs + the 2nd trackpad + native back-paddle bindings.
  • M7 (a Windows UMDF virtual Steam Deck) is NOT recommended. Windows Steam applies the same interface filter, and Windows has no kernel-hid-steam evdev fallback — Windows games consume XInput / RawInput / Windows.Gaming.Input, none of which a non-promoted virtual Deck feeds. So a Windows virtual Deck would be consumed by nothing. The existing Windows virtual DualSense already covers the Steam-Input + gyro/touchpad case there.

What the M0M6 work still delivers (not wasted)

  • The protocol/wire (back buttons, second trackpad, gyro/accel, trackpad haptics) and the client capture (paddles, both trackpads, gyro from a real Deck/SC) are general — they feed the virtual DualSense path (Deck client → Steam-Input host) just as well, with the grips folded in (M5).
  • The virtual Deck backend is the best option for non-Steam Linux games, and the M5 motion rescale + fallback remap + the M6 conflict gate make the cross-backend behavior correct.
  • The whole effort proved the greenfield hid-steam UHID device is real and kernel-validated on two kernels — the open question was always Steam-userspace promotion, and now it's answered.
  1. A live SDL/non-Steam game on a Linux host actually consuming the virtual Deck's grips/trackpads (the path that does work) — needs a real Deck/SC client + a Steam-Input-disabled consumer.
  2. The Moonlight paddle regression from the M3 xpad-map change (stock paddle client → host).

Gadget PoC — interface 2 is PROVEN on the Deck (2026-06-29)

SteamOS ships every primitive (CONFIG_USB_DUMMY_HCD=m, CONFIG_USB_RAW_GADGET=m, CONFIG_USB_CONFIGFS_F_HID=y), so the gadget path is testable on the Deck itself with no module-building. A pure-shell configfs gadget (deck_gadget_up.sh) stood up a real 3-interface USB Deck on a dummy_hcd loopback UDC — keyboard = interface 0, mouse = 1, controller = interface 2 (STEAMDECK_RDESC), 28DE:1205. Result:

  • It enumerates as a real USB device (lsusb: 28de:1205 Valve Software Steam Deck Controller) and hid-steam binds all three interfaces — the controller on bInterfaceNumber=02.
  • Steam promoted it: Local Device Found … Interface: 2 … !! Steam controller device opened for index 14 … Steam Controller reserving XInput slot 1. This is the proof: a device on interface 2 IS opened + XInput-reserved by Steam, where the interface--1 UHID device was filtered out.
  • It then failed at the next step — f_hid can't serve HID feature reports (hid-steam: steam_send_report: error -32 (ae 16 01) → serial XXXXXXXXXX; Steam: couldn't get controller details … GetControllerInfo failed … Disconnecting zombie controller). No gamepad evdev was created either, for the same reason (hid-steam can't complete Deck init without the feature/output channel).

Conclusion: the wall is fully characterised and climbable. Interface 2 is necessary and sufficient for Steam to open + XInput-reserve the Deck; the only remaining piece is serving the HID feature/output reports, which f_hid can't but raw_gadget can (userspace handles every control transfer, exactly like the UHID path). Next: a raw_gadget userspace emulator of the 3-interface Deck (controller on interface 2) that answers the serial/attribute/settings feature reports + streams the 64-byte state report — then re-test hid-steam gamepad evdev + Steam promotion.