d8c254281e
Finish the client side of the Steam Controller / Steam Deck pipeline. - C-ABI (core abi.rs): PunktfunkRichInputEx — a size-prefixed superset of PunktfunkRichInput that can express the second trackpad (surface), a distinct click vs touch, signed coords + pressure — plus punktfunk_connection_send_rich_input2 (the struct_size ABI-skew-guard precedent). The only way a C client (Apple/embedders) can emit a TouchpadEx; the legacy struct + send_rich_input stay byte-for-byte. punktfunk_core.h regenerated. - Decky (clients/decky): a "Steam Deck" gamepad type in Settings + an unmissable Disable-Steam-Input instruction shown when it's selected (in Game Mode Steam Input holds 0x1205, so the SDL HIDAPI Steam driver can't open the Deck's controls until the user disables Steam Input for the shortcut). Plus a best-effort, feature-detected disableSteamInputForShortcut() in launchStream — never blocks/throws; the manual toggle is the documented source of truth. - Apple parity (PunktfunkConnection.swift): GamepadType.steamController/steamDeck (wire 5/6) + name parsing, so the resolved type round-trips. Capture is blocked (GameController never surfaces a 0x28DE HID device). - Android parity (Gamepad.kt): PREF_STEAMCONTROLLER/STEAMDECK + the Valve 0x28DE PIDs in prefFor(). Rich-input capture stays out of scope (no rich-input plane yet) — standard buttons/sticks resolve to the host's Steam Deck pad. Rust workspace clippy/fmt/test green; Decky src/ typechecks clean (only a pre-existing @decky/api dep resolution error remains); Swift/Kotlin compile on their CI. The full pipeline is now BUILT; what remains is validation that needs hardware we don't have (a running Steam on the host, a live Deck client, the Moonlight paddle regression). Not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
612 lines
41 KiB
Markdown
612 lines
41 KiB
Markdown
# Rich Steam Controller & Steam Deck support
|
||
|
||
> **Status:** **M0–M4 GREEN — the full Steam Controller/Deck pipeline is built (2026-06-29).** Host:
|
||
> the virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD=
|
||
> steamdeck`), and the protocol carries the rich inputs. 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. Remaining is **validation, not
|
||
> construction** (see below) + the deferred extras (M5 fallback-remap polish, M6 SteamOS-host
|
||
> conflict check, M7 Windows UMDF Steam driver).
|
||
>
|
||
> **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 (whole feature, needs hardware we don't have):** (1) recognition of the
|
||
> virtual Deck by a **running Steam** on the host; (2) a **live Deck/Steam Controller client** actually
|
||
> sending paddles/trackpads/gyro; (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.)
|
||
|