Files
punktfunk/design/steam-controller-deck-support.md
T
enricobuehler ae71e4628d feat(clients/steam): M4 — desktop SDL clients capture the rich Steam inputs
The Linux + Windows native clients (clients/{linux,windows}/src/gamepad.rs) now
capture and send the Steam Controller / Steam Deck rich inputs, so a real Deck
(off Steam Input) or a Steam Controller on a desktop client drives the host's
virtual hid-steam pad end-to-end:

- Set SDL's HIDAPI Steam hints (SDL_JOYSTICK_HIDAPI_STEAMDECK / _STEAM) before
  init so SDL opens Valve devices directly (paddles + both trackpads + gyro as
  first-class SDL gamepad inputs).
- Detect the Deck/SC by VID/PID (0x28DE + 0x1205 / 0x1102 / 0x1142) ->
  GamepadPref::SteamDeck (there is no SDL gamepad type for it), so the host
  builds the virtual Deck with the right identity.
- Map the SDL paddle + Misc1 buttons -> BTN_PADDLE1..4 / BTN_MISC1 (a free win
  for Xbox Elite paddles too).
- Route a SECOND touchpad -> RichInput::TouchpadEx (SDL touchpad 0 = left ->
  surface 1, 1 = right -> surface 2, signed coords); a single touchpad keeps the
  legacy Touchpad. New forward_touch() helper centralizes the choice.
- Track held touchpad contacts per (surface, finger) and lift them on pad
  switch/detach so a contact held at that moment can't stick.
- Sensor (gyro/accel) capture was already generic across pad types.

Linux client builds + clippy clean; the Windows client is a near-verbatim
mirror (windows CI compiles it). On a Deck in Game Mode, Steam Input still holds
the device — the user disables Steam Input for the client (the Decky UX, next);
on a desktop client (or a Deck with Steam Input off) the hints just work.

Remaining M4: Decky Disable-Steam-Input UX, Apple/Android parity, and the C-ABI
PunktfunkRichInputEx + send_rich_input2 (Apple/embedder send path). Not pushed.

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

39 KiB
Raw Blame History

Rich Steam Controller & Steam Deck support

Status: M0M3 GREEN + M4 desktop-capture done (2026-06-29). The host side is complete: the virtual hid-steam Deck binds, is byte-exact, is a wired backend (PUNKTFUNK_GAMEPAD= steamdeck), and the protocol carries the rich Steam inputs. The Linux + Windows SDL clients now capture + send them. Remaining M4: the Decky Disable-Steam-Input UX, Apple/Android parity, and the C-ABI PunktfunkRichInputEx/send_rich_input2 (Apple/embedder send path).

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 matchedABS_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_INFOnot 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 InputEvents.
  • 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 → DualSenseXbox360. 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/accelRichInput::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/R5BTN_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-dualsensepf-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 infoID_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.)