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>
53 KiB
Rich Steam Controller & Steam Deck support
Status: M0–M6 GREEN — full pipeline + fallback + conflict gate built (2026-06-29). Host: the virtual
hid-steamDeck 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 theTouchpadExsend 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:1205and Steam's28DE:11FFXInput output pad live — so a second virtual28DEmakes Steam juggle two Decks. (b) Bind robustness: the virtual Deck bindshid-steamon 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'scontroller.txtenumerates the virtual Deck (Local Device Found, type: 28de 1205, Product "Punktfunk Steam Deck") but logsInterface: -1and never promotes it (no28DE:11FFpad, no "Controller connected"). On the same Steam logs the physical Deck isInterface: 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-steambinds by VID/PID regardless (so the kernel evdevs + SDL-hidapi path work), but Steam Input itself will not manage a UHID virtual Deck. (The feared0x83/0xA1attribute 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/devicesfor a non-virtual28DE) +degrade_steam_on_conflict()inresolve_gamepad— a resolvedSteamDeck/SteamControlleron a host with a physical Steam controller degrades to DualSense (then the uhid ladder), overridable withPUNKTFUNK_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 rescalemotion_wire_to_deck— the wire carries DualSense-convention units (what every client capture emits), the Deck'shid-steamreport 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 asBTN_TRIGGER_HAPPY5-8). Plus a runtime degrade ladder inresolve_gamepad: a UHID backend (DualSense/DS4/SteamDeck) on a host where/dev/uhidisn'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 optionalRemapConfiggrowth: 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-ABI —
PunktfunkRichInputEx(size-prefixed superset) +punktfunk_connection_send_rich_input2(thestruct_sizeskew-guard precedent; the only way a C client emitsTouchpadEx); legacyPunktfunkRichInput/send_rich_inputbyte-for-byte;punktfunk_core.hregenerated. Decky — a "Steam Deck" gamepad option + an unmissable Disable-Steam-Input instruction (shown when selected) + a best-effort feature-detected programmatic flip inlaunchStream(never throws; the manual toggle is the source of truth). Apple/Android parity —GamepadType.steamController/ steamDeck(Swift) +PREF_STEAMCONTROLLER/STEAMDECK+ the0x28DEPIDs inprefFor(Kotlin), so the type round-trips; capture stays out of scope there (iOS GameController won't surface a28DEdevice; Android has no rich-input plane yet). Rust workspace clippy/fmt/test green; Deckysrc/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/uhidis root-only0600, 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 → theBTN_PADDLE1..4/BTN_MISC1wire 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 legacyTouchpad. 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'sbuttonFlags2<<16namespace, so GameStream paddle + native grips share one map);RichInput::TouchpadEx(kind0x03— surface 0/1/2, click, signed coords, pressure);HidOutput::TrackpadHaptic(kind0x04). ABI:PUNKTFUNK_GAMEPAD_STEAMDECK=6/_STEAMCONTROLLER=5+ the paddle/RICH_TOUCHPAD_EX/HIDOUT_TRACKPAD_HAPTICconstants,from_hidpacksTrackpadHapticinto the existingwhich+effect[0..6](the legacy structs do not grow — guarded bysize_of==20/19asserts); regeneratedpunktfunk_core.h. Host:steam_proto::from_gamepadmaps the paddles → the four Deck grips + QAM;apply_richroutesTouchpadExleft/right → the matching pad; every DS manager (DualSense/DS4, Linux + Windows) gained aTouchpadExarm (surface 0/2 → its one touchpad); the xpadBUTTON_MAPfinally 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_gamepadgrip +apply_richleft-pad) → evdevBTN_A+ABS_HAT0X. Workspace clippy/fmt/test green. Deferred to M4: the C-ABIPunktfunkRichInputEx+send_rich_input2(only the Apple/C send path needs it; the host decodesTouchpadExtoday).M2 result (backend + wiring, on-box):
inject/linux/steam_controller.rs(SteamControllerManager/SteamDeckPad, mirroringdualsense.rs) is wired intoPadBackend(newSteamDeckvariant +select/handle/apply_rich/pump/heartbeatarms) and selectable viaGamepadPref::SteamDeck(core enum byte 6 +pick_gamepadLinux arm;SteamController= byte 5 is reserved, folds to Xbox360 until its backend lands). Two Steam-specific quirks beyond the DualSense path: (1)gamepad_modeentry — best-effortlizard_mode=0via sysfs + ab9.6creation pulse (MODE_ENTER650 ms) + an anti-toggle guard (MENU_HOLD_CAP350 ms) so a long in-game Start-hold can't flipgamepad_modeoff; (2)UHID_SET_REPORTanswerederr=0(DualSense omits it) + the0xEBrumble parsed onto the universal 0xCA plane. An#[ignore]d on-box test (backend_binds_and_input_flows) drives the real backend: it bindshid-steam(gamepad + IMU evdevs), enters gamepad mode,BTN_Areaches the evdev, and the device tears down on drop. Workspace clippy/fmt/test green; no generated-header drift (the C-ABIGamepadPrefconstants are M3).M1 result (byte-exact serializer, on-box):
inject/proto/steam_proto.rsnow carries the full Deck contract transcribed verbatim from the kernelsteam_do_deck_input_event/steam_do_deck_sensors_event: theu64button map (bytes 8..16), sticks/triggers/trackpads/IMU at their exact offsets,from_gamepad+apply_richmappers, the rumble-feedback parser (0xEB), and the serial reply (now with the leading report-id byte the kernel strips — fixes the M0XXXXXXXXXXfallback). The validator pulses theb9.6mode-switch to entergamepad_mode(the parser early-returns under defaultlizard_modeotherwise), holds a known test pattern, and reads both evdevs viaEVIOCGABS/EVIOCGKEY: every field matched —ABS_X/Y/RX/RY(incl. the kernel Y-negation), both triggers, the touched right-padHAT1X/Y, the IMU accel/gyro (with theABS_Z/RZnegations), and the 6 expected buttons incl. the L4/R5 grips.byte 8 bit 7 = BTN_AIS correct (the M0 "didn't hold" was a flaky single-bit read beforegamepad_modewas 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 keeperinject/proto/steam_proto.rs) created a UHID28DE:1205device with a minimal vendor descriptor (one feature report — the sole thingsteam_is_valve_interfacechecks) and serviced the handshake (theUHID_SET_REPORTanswers the DualSense backend omits).journalctl -kshowedhid-steambinding it (rebound offhid-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); thegamepad_modeentry strategy on a real host (pulseb9.6at session start, or loadhid_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 keeperinject/proto/steam_proto.rs) created a UHID28DE:1205device with a minimal vendor descriptor (one feature report — the sole thingsteam_is_valve_interfacechecks) and serviced the handshake (theUHID_SET_REPORTanswers the DualSense backend omits).journalctl -kshowedhid-steambinding it (rebound offhid-generic),"Steam Controller … connected", and the kernel creating both evdevs:"Steam Deck"(gamepad,BTN_Ain key caps) and"Steam Deck Motion Sensors"(INPUT_PROP_ACCELEROMETER, 6 IMU axes). A layout-agnostic mash-probe confirmed the input path: 23 distinctBTN_*codes (A/B/X/Y, TL/TR, SELECT/START/MODE, THUMBL, all 4 DPAD, grips, back codes) toggled throughhid-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-sourcedbyte 8 bit 7 = BTN_Adid not hold on 7.0). The serial GET_REPORT reply also needs its report-number-prefix offset fixed (the kernel used theXXXXXXXXXXfallback; 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):
- Full pipeline — capture on every client + inject on the hosts, not a one-platform demo.
- 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.
- Max fidelity — build the greenfield virtual
hid-steamdriver. Linux UHID first (validates the contract against open-sourcehid-steam.c+ SDL hidapi); Windows UMDF later, gated on the Linux result (§8). - 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 overBUS_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_INFOfeature 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:
0xAEID_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.0x81ID_CLEAR_DIGITAL_MAPPINGS/0x8EID_LOAD_DEFAULT_SETTINGS/0x87ID_SET_SETTINGS_VALUES(lizard-mode + settings) — ack err=0, ignore content. The Deck path skips the auto lizard-mode disable atinput_open, so these only arrive on an options-hold or via Steam userspace — but must be ack'd to avoid the per-command stall.0x83ID_GET_ATTRIBUTES_VALUES/0xA1ID_GET_DEVICE_INFO— not 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_rumblein 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:
0xEBID_TRIGGER_RUMBLE_CMD— Deck rumble motors; map left/right →(low, high)on the existing universal 0xCA rumble plane (exactly like the DualSensefb.rumble).0x8FID_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 new0xCD 0x04TrackpadHaptic(§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 verbatimSTEAMDECK_RDESC(≥1 feature report at id 0);SteamStatesuperset model (sticks, analog triggers, packed buttons, dpad,gyro[3]/accel[3],back:[bool;4], twoSteamPad{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, mirroringdualsense_proto's tests.crates/punktfunk-host/src/inject/linux/steam_controller.rs—/dev/uhidplumbing +SteamControllerManager, byte-identical structure todualsense.rs:SteamPad::open(index, model)doesUHID_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 separate0x40_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 P1–P4 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, andPUNKTFUNK_GAMEPAD_BTN_PADDLE1..4/_BTN_MISC1for embedders building rawInputEvents. - Do not mutate
PunktfunkRichInput(nostruct_sizeguard). Add a guarded supersetPunktfunkRichInputEx{ 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_input2that reads the size prefix first (theconnect_exN/config_from_ptrprecedent). LegacyPunktfunkRichInput+send_rich_inputstay byte-for-byte. from_hidgains theTrackpadHaptic → effect[]-packing arm;to_rich(Ex) gains theTouchpadExarm.- Compile guards: extend the
GamepadPreflockstepassert!block with the two new variants; addassert!(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)
- Before
sdl3::init(), setSDL_JOYSTICK_HIDAPI_STEAMDECK="1"+SDL_JOYSTICK_HIDAPI_STEAM ="1"(the exactsdl3::hint::setmechanism already used forSDL_NO_SIGNAL_HANDLERS). - In
pad_info, override toGamepadPref::SteamDeckwhenvendor_id()==0x28DEandproduct_id() ∈ {0x1205 Deck, 0x1102 SC wired, 0x1142 SC dongle}; add a"Steam Deck"label. - Extend
button_bit:RightPaddle1→BTN_PADDLE1,LeftPaddle1→BTN_PADDLE2,RightPaddle2→BTN_PADDLE3,LeftPaddle2→BTN_PADDLE4,Misc1→BTN_MISC1(free win for Elite). - Bind the
touchpadsurface field in the threeControllerTouchpad*arms; branch ontouchpads_count()rather than hard-coding 2 — surface 0 → existingRichInput::Touchpad, surface ≥1 →RichInput::TouchpadEx{surface}. - Track held contacts keyed by
(surface, finger)and lift them (active=false) inflush_heldon pad switch/detach (today only the DualSense single surface is implicitly lifted). - 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 selectsgamepad pref=steamdeck, adds"steamdeck"to the option set (TS +main.pyvalidation), 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 optionalwindow.DeckyBackend/collectionStoreglobals), called best-effort insideensureShortcut(), never blocking or throwing intolaunchStream. 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
.steamDeckenum case (wire byte 6) so the type round-trips; no capture. GameController never surfaces a28DEHID device as aGCExtendedGamepad(Apple has no Steam Input; a raw path would needIOHIDManager). Document as blocked. - Android: parity only — add
PREF_STEAMDECK+ the28DEPIDs to the mapping. Capture of paddles/trackpads/gyro is out of scope here:send_rich_inputis itself still a TODO (session.rs:13), and a Deck dongle appears only as a generic gamepad viaInputDevice. 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:
- Explicit client
SteamController/SteamDeckpref — honored if/dev/uhidis writable ANDhid-steamis loadable; else degrade. PUNKTFUNK_GAMEPAD=steamdeck|steamcontrollerhost env.- 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.
- Degrade ladder when a requested Steam pad is unavailable:
hid-steammissing → DualSense → Xbox360. The Welcome carries the real choice.
SteamOS/Deck-as-host conflict: a host already running Steam eagerly grabs any
28DEdevice, 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/accel →
RichInput::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
InjectorServicepointer plane (config; default mouse, matching SteamOS desktop feel). - Back buttons L4/L5/R4/R5 →
BTN_PADDLE1..4→ on a uinput Xbox pad,BTN_TRIGGER_HAPPY1..4(0x2c0..0x2c3, what Steam Input/SDL read as paddles); add the matchingUI_SET_KEYBITregistrations increate(). 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/heartbeatflowing so the sensorts += 188advances, 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-dualsense→pf-steamdeck. Keep verbatim thevhidmini2-derived WDF scaffolding, the FORCE_INTEGRITY PE-bit clear (PE+0x5e), the timer-completes-pended-READ_REPORT pattern, the queueNumberOfPresentedRequests=u32::MAXand timerExecutionLevel/SynchronizationScope=InheritFromParent + AutomaticSerialization=TRUEgotchas, theGlobal\pfds-shm-<idx>shared-memory channel, the multi-padpszDeviceLocation/UmdfHostProcessSharing=ProcessSharingDisabledplumbing, theInclude=MsHidUmdf.inf/WUDFRD.infINF stanza, andSwDeviceCreate(enumeratorpunktfunk, hardware idpf_steamdeck). - Swap identity (VID
0x28DE/ PID0x1205), the hid-steam report descriptor, and — the riskiest, non-derivable part — the0x83 GET_ATTRIBUTES_VALUES+0xA1 GET_DEVICE_INFOfeature blobs captured from real hardware (SDL #12166: Steam/SDL aborts the controller if these probes fail). Add ACK-only SET_FEATURE handlers for0x81/0x87/0x8E. - Host backend: a
SteamDeckWindowsreusinginject/windows/dualsense_windows.rsalmost 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 .ps1pnputil 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. M1–M3 are Linux. M4–M5 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+GamepadPref5/6 encode/decode round-trips + an old-peer-drops-unknown-kind assertion; thefrom_gamepadpaddle/misc mapping;steam_protoreport-offset +parse_steam_outputunit tests (mirrordualsense_proto); thesteam_remapfold 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=steamdeckforces 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
dmesgshowshid-steam … Valve Software Steam Deck Controller; the sysfs node bindshid_steam; a gamepad evdev AND a second IMU evdev appear (udevadm info→ID_INPUT_JOYSTICK=1+INPUT_PROP_ACCELEROMETER). - RECOGNITION proof:
sdl2-jstest --list/ an SDL3 app reports GUID28de:1205"Steam Deck";evtestshowsBTN_SOUTHetc.; 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_openedstandoff 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 triggersFF_RUMBLE; confirm a0xEBSET_REPORT arrives and our parser emits(low, high)on0xCAback 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:11FFwith working bindings + glyphs. - GameStream regression: confirm the new
buttonFlags2consumption 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.txtloggedLocal Device Found type 054c 0ce6 "DualSense Wireless Controller", thenController using HIDAPI driver, vid=0x054c, pid=0x0ce6and loadedconfigset_controller_ps5.vdf(it even read back our calibration/pairing/firmware feature blobs). So the same interface-1that 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 andAutoprefers 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_hcdis 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-steamevdev 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 M0–M6 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-steamUHID device is real and kernel-validated on two kernels — the open question was always Steam-userspace promotion, and now it's answered.
Remaining validation (no further construction recommended)
- 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.
- 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) andhid-steambinds all three interfaces — the controller onbInterfaceNumber=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--1UHID device was filtered out. - It then failed at the next step —
f_hidcan't serve HID feature reports (hid-steam: steam_send_report: error -32 (ae 16 01)→ serialXXXXXXXXXX; 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.