feat(host/steam): M0 — virtual hid-steam UHID device binds + parses (Linux)
Greenfield virtual Steam Deck controller, the Steam analogue of the shipped virtual DualSense. Proves the kernel hid-steam driver binds a /dev/uhid 28DE:1205 device, registers it as a real Steam Deck, and parses our input reports — the go/no-go gate for the full Steam Controller/Deck pipeline. - inject/proto/steam_proto.rs: keeper module — the vendor HID descriptor (one feature report, the sole thing steam_is_valve_interface() checks), the command/feature IDs, serialize_deck_state, and the serial GET_REPORT reply. Unit-tested. - src/bin/steam_uhid_spike.rs: throwaway M0 spike (Linux-only) — opens /dev/uhid, creates the device, services the handshake including UHID_SET_REPORT (which the DualSense backend omits and which hid-steam stalls ~5s/cmd without), and heartbeats a neutral report. - design/steam-controller-deck-support.md: full design + M0–M7 plan; the two walls (Steam Input capture ownership; virtual-Steam recognition) and the fidelity ceiling. Status: M0 GREEN. On-box (headless Ubuntu 26.04, kernel 7.0, no Steam): journalctl -k shows hid-steam binding the device (rebind off hid-generic), "Steam Controller connected", and the kernel creating BOTH a "Steam Deck" gamepad evdev and a "Steam Deck Motion Sensors" IMU evdev (INPUT_PROP_ACCELEROMETER). A layout-agnostic mash-probe drove 23 distinct BTN_* codes through hid-steam -> evdev, proving the input-report parse path. M1 line-checks the exact per-bit report layout against the lab kernel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
# Rich Steam Controller & Steam Deck support
|
||||
|
||||
> **Status:** **M0 GREEN — Linux feasibility PROVEN on-box (2026-06-29).** The greenfield virtual
|
||||
> `hid-steam` device works: a `/dev/uhid` `28DE:1205` device binds the kernel `hid-steam` driver,
|
||||
> registers as a real Steam Deck, and parses our input reports. The full client-capture → protocol
|
||||
> → inject pipeline (M1+) is unblocked. This remains the design + milestone plan; the Steam analogue
|
||||
> of the shipped virtual DualSense (`design/windows-dualsense-scoping.md`).
|
||||
>
|
||||
> **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.)
|
||||
|
||||
Reference in New Issue
Block a user