# Rich Steam Controller & Steam Deck support

> **Status:** **M0–M6 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 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`, 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. 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` + `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 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-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.

### Input-flow status (2026-06-29) — delivered + format-validated; clean live-read is a backend task

With the A button held in the streamed report on a `pressa` build, on the Deck:
- **Reports are delivered to hid-steam** — the gadget logs `STREAM: first input report delivered
  (host is polling int IN)`, i.e. hid-steam polls our interface-2 interrupt-IN endpoint and our
  64-byte state reports reach it.
- **The report format is already validated** — `serialize_deck_state` was on-box-validated in M1, and
  M2's `backend_binds_and_input_flows` test reads the buttons/axes back through `EVIOCGKEY`/`EVIOCGABS`
  off the *same* kernel `hid-steam` decode the gadget feeds. The gadget changes only the transport.
- **The gamepad evdev forms** (`input: Steam Deck` on `5-1:1.2`), but it is **transient** — hid-steam
  destroys + recreates it as `gamepad_mode` toggles, because Steam keeps re-probing/resetting our
  device (our PoC serves the serial but not Steam's full `GetControllerInfo` attribute set), and the
  test Deck is churned by dozens of connect/disconnect cycles. So a *stable* live `EVIOCGKEY` catch of
  the held A wasn't obtained.

Conclusion: input delivery + format are proven; the only gap is the gamepad-evdev transience, which is
a **feature-report-completeness** problem — exactly what the host backend fixes (serve the full Deck
feature/attribute contract so Steam stops fighting it). That's the next step, not more PoC patching.

### Feature contract hardened — the churn is fixed (2026-06-29)

The gamepad-evdev churn was Steam re-probing because the gadget served zeros for the HID feature
reports Steam's `GetControllerInfo` reads. The real contract was captured from a physical Deck
(`packaging/linux/steam-deck-gadget/get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`) and implemented in
`steam_gadget.rs::feature_reply`: the **`0x83` GET_ATTRIBUTES_VALUES** blob (`[83,2d, 9×(id,u32-LE)]`
— product id `0x1205`, a per-instance unit serial, capability attrs) plus the **`0xAE`** string
attributes (serial / board serial) and a settings echo. Result on the Deck: **1 connect / 0
disconnect / 1 gamepad evdev** (was constant churn), Steam *activates* the gadget cleanly (no
`GetControllerInfo failed`, no zombie) and emits its **X-Box 360 pad 1**. usbmon on the gadget's bus
confirms our state reports (pressed button at byte 8) are delivered on the interrupt-IN and consumed
by hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode, the input chain is proven end-to-end. The
only piece left is a foreground-game confirmation that Steam Input maps it onto the X-Box pad (Steam
only maps contextually), after which the gadget can default on for SteamOS hosts.

### Glass confirmed + default-on for SteamOS (2026-06-29)

Validated glass-to-glass on the Deck: the gadget shows up as a distinct second Steam controller, a
held A snaps the Steam overlay shut as "Resume Game" (so Steam Input receives + acts on the gadget's
input), and **a button press registers in a real game** — confirmed in-game. The two-Deck test
confound (the Deck has its physical Deck + the virtual one) is a test-rig artifact, not a feature
limit: a real non-Deck SteamOS host has only the virtual Deck, and a Deck-as-host degrades `SteamDeck`
→ DualSense via the M6 conflict gate before the gadget is ever built. So `gadget_preferred()` now
defaults **on for SteamOS** (`/etc/os-release` `ID=steamos`), off elsewhere (UHID stays default),
with `PUNKTFUNK_STEAM_GADGET=1`/`0` to force. The virtual Steam Deck — recognized + promoted by Steam
Input, churn-free, input flowing to games — is complete.
