Files
punktfunk/design/steam-controller-deck-support.md
T
enricobuehler 8870e85233 feat(steam): raw_gadget virtual Deck — full Steam Input recognition (proven on Deck)
The interface-2 wall is climbed. packaging/linux/steam-deck-gadget/deck_raw_gadget.c
is a raw_gadget userspace emulator of a real 3-interface USB Steam Deck (28DE:1205,
mouse=0/keyboard=1/controller=2) on a dummy_hcd loopback UDC, with descriptors
captured verbatim from a physical Deck and full HID feature-report handling.

Live on a real Deck (SteamOS 3.8.11): hid-steam reads our serial (PFDECK000),
creates the Steam Deck + Motion Sensors evdevs, and Steam Input PROMOTES it —
controller.txt "Interface: 2 ... device opened ... reserving XInput slot 1" +
"input: Microsoft X-Box 360 pad 1". Stable (1 connect, 0 disconnects, no zombie);
the kernel Steam Deck evdev is then grabbed by Steam Input which exposes its own
X-Box pad, exactly like a real Deck. First time a virtual Deck is fully Steam-Input
promoted (UHID can't — it has no USB interface number, so Steam filters it).

Also includes the configfs f_hid variant (configfs_gadget_up/down.sh) — the minimal
reproducer that proved interface 2 makes Steam open+XInput-reserve the device, but
f_hid can't serve feature reports so Steam dropped it as a zombie.

Gotchas documented in the README: 7-byte vs 9-byte endpoint descriptor, no-data OUT
controls acked via zero-length EP0_READ (not WRITE, else error -110), streamer must
not start before SET_CONFIGURATION is acked. SteamOS-host only (needs dummy_hcd +
raw_gadget). Recognition proven; feeding real client reports + a host backend is next.
Not pushed.

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

791 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-ABI** —
> `PunktfunkRichInputEx` (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 parity** — `GamepadType.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 matched** — `ABS_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_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_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 `InputEvent`s.
- **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 →
**DualSense** → **Xbox360**. 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/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 `InjectorService` pointer
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 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-dualsense` → `pf-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 info` →
`ID_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.
### Remaining validation (no further construction recommended)
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.
### Gadget path SUCCESS — raw_gadget Deck gets full Steam Input recognition (2026-06-29)
The `f_hid` zombie was a feature-report problem, and `raw_gadget` (userspace handles every control
transfer) solves it. `packaging/linux/steam-deck-gadget/deck_raw_gadget.c` presents the real
3-interface Deck (descriptors captured verbatim from a physical Deck, controller on interface 2) and
answers the HID feature reports hid-steam/Steam need. Live on the Deck:
```
hid-steam ... Steam Controller 'PFDECK000' connected (serial READ — not XXXXXXXXXX)
input: Steam Deck / Steam Deck Motion Sensors (kernel gamepad + IMU evdevs)
controller.txt: Interface: 2 ... device opened for index 14 ... reserving XInput slot 1
input: Microsoft X-Box 360 pad 1 (Steam Input's XInput output — PROMOTED)
```
Stable (1 connect, 0 disconnects, no zombie). The kernel `"Steam Deck"` evdev is then grabbed by
Steam Input, which exposes its own X-Box 360 pad — a real Deck's exact behaviour. **This is the first
time a virtual Steam Deck has been fully promoted by Steam Input** (UHID can't; the interface-2 wall
is climbed). The hard part — recognition — is done.
Implementation gotchas (see the packaging README): `struct usb_endpoint_descriptor` is 9 bytes but
the wire descriptor needs 7; no-data OUT controls are acked with a zero-length `EP0_READ` not
`EP0_WRITE` (else `error -110`); the input streamer must not start until after SET_CONFIGURATION is
acked. Scope: SteamOS-host only (needs `dummy_hcd` + `raw_gadget`, which SteamOS ships; a generic
Linux host would have to build them).
**Remaining:** feed real client state through the interface-2 endpoint (the `steam_proto` serializer
already produces correct Deck reports — wire it to the gadget's stream), and wrap this as a host
gamepad backend (a `raw_gadget` alternative to the UHID `SteamDeckPad`). Then the streamed Deck/SC
client reaches the host's games as a true Deck through Steam Input.