Files
punktfunk/design/steam-controller-deck-support.md
T
enricobuehler 95308d352b feat(host/steam): M2 — virtual Steam Deck as a wired PadBackend (Linux)
Make the virtual hid-steam device a selectable per-session host gamepad,
end-to-end on Linux: PUNKTFUNK_GAMEPAD=steamdeck now builds a
SteamControllerManager that creates a /dev/uhid 28DE:1205 Deck, enters
gamepad_mode, and feeds the byte-exact Deck report (M1).

- inject/linux/steam_controller.rs: SteamControllerManager / SteamDeckPad,
  mirroring dualsense.rs (open/create2, GET/SET_REPORT pump, heartbeat, RAII
  destroy). Two Steam-specific quirks beyond the DualSense path:
    * gamepad_mode entry — best-effort `lizard_mode=0` via sysfs, plus a b9.6
      creation pulse (MODE_ENTER) so steam_do_deck_input_event stops
      early-returning, plus an anti-toggle guard (MENU_HOLD_CAP) so a long
      in-game Start-hold can't flip gamepad_mode back off.
    * UHID_SET_REPORT answered err=0 (DualSense omits it; the kernel stalls
      ~5s/cmd otherwise); the 0xEB rumble report parsed onto the 0xCA plane.
- core config.rs: GamepadPref::SteamDeck (wire byte 6) + SteamController
  (byte 5, reserved — folds to Xbox360 until its backend lands); from_u8 /
  from_name / as_str. Forward-compatible (unknown byte -> Auto); the C-ABI
  PUNKTFUNK_GAMEPAD_* constants stay M3, so no generated-header drift.
- punktfunk1.rs: PadBackend::SteamDeck variant + select / handle / apply_rich
  / pump / heartbeat arms; pick_gamepad Linux arm.

On-box: an #[ignore]d backend test (backend_binds_and_input_flows) drives the
real SteamDeckPad — 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. Not pushed. Next: M3 (protocol/ABI wire) + M4 (client
capture).

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

568 lines
37 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:** **M0M2 GREEN — Linux virtual Deck binds, is byte-exact, AND is a wired host backend,
> on-box (2026-06-29).** The greenfield virtual `hid-steam` device works end-to-end as a selectable
> host gamepad backend: `PUNKTFUNK_GAMEPAD=steamdeck` builds a per-session `SteamControllerManager`
> that creates a `/dev/uhid` `28DE:1205` device, enters `gamepad_mode`, and feeds the byte-exact Deck
> report. Next: M3 (the protocol/ABI wire surface — back-button bits, `TouchpadEx`, the C-ABI
> `GamepadPref` constants) + M4 (client capture). This remains the design + milestone plan; the Steam
> analogue of the shipped virtual DualSense (`design/windows-dualsense-scoping.md`).
>
> **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.)