Carry the rich Steam Controller / Steam Deck inputs end-to-end on the wire — strictly additive + forward-compatible (unknown kinds/bits drop on old peers). Core (punktfunk-core): - input.rs: BTN_PADDLE1..4 + BTN_MISC1 in Moonlight's buttonFlags2<<16 namespace (so the GameStream paddle path and native grips share one host injector map; Steam L4/L5/R4/R5 reuse the four Xbox-Elite paddle slots). - quic.rs: RichInput::TouchpadEx (kind 0x03 — surface 0/1/2, touch+click, signed coords, pressure; the second trackpad the single Touchpad can't express) and HidOutput::TrackpadHaptic (kind 0x04 — the SC voice-coil pulse). Round-tripped. - abi.rs: PUNKTFUNK_GAMEPAD_STEAMDECK=6 / _STEAMCONTROLLER=5, the paddle bits, 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 new size_of==20/19 asserts); GamepadPref lockstep + paddle-bit lockstep asserts extended. include/punktfunk_core.h regenerated. Host (punktfunk-host): - steam_proto::from_gamepad maps the wire paddles -> the four Deck grips + QAM; apply_rich routes TouchpadEx left/right -> the matching pad. - every DualSense/DS4 manager (Linux + Windows) gained a TouchpadEx arm (surface 0/2 -> its one touchpad; surface 1 ignored) so the variant compiles everywhere and a Steam client streaming to a DS host keeps its right pad. - the xpad BUTTON_MAP finally consumes the GameStream paddle bits (BTN_TRIGGER_HAPPY5-8) — Sunshine/Moonlight paddle clients were silently no-op'd before (design §5.6). - Android feedback: drop TrackpadHaptic (no coils; rumble rides 0xCA). Validated on-box: the ignored backend test now drives the full wire path — from_gamepad (BTN_A + the L4 grip) + apply_rich (a left-pad TouchpadEx) reach the evdev as BTN_A + ABS_HAT0X=-8000. Wire round-trips + paddle/TouchpadEx mapping unit-tested. Workspace clippy/fmt/test green. Not pushed. Deferred to M4: the C-ABI PunktfunkRichInputEx + send_rich_input2 (only the Apple/embedder *send* path needs it; the host decodes TouchpadEx today). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
38 KiB
Rich Steam Controller & Steam Deck support
Status: M0–M3 GREEN — virtual Deck binds + byte-exact + wired backend + the rich wire, on-box (2026-06-29). The greenfield virtual
hid-steamdevice works end-to-end as a selectable host gamepad backend (PUNKTFUNK_GAMEPAD=steamdeck), and the protocol now carries the rich Steam inputs (back buttons + second trackpad). Next: M4 (client capture — SDL Steam hints, paddles, 2nd touchpad, the Decky Disable-Steam-Input UX, + the C-ABIPunktfunkRichInputEx/send_rich_input2for the Apple/embedder send path). The Steam analogue of the shipped virtual DualSense.M3 result (protocol / ABI wire, on-box): strictly additive + forward-compatible (§5). Core: back-button bits
BTN_PADDLE1..4+BTN_MISC1(in Moonlight'sbuttonFlags2<<16namespace, so GameStream paddle + native grips share one map);RichInput::TouchpadEx(kind0x03— surface 0/1/2, click, signed coords, pressure);HidOutput::TrackpadHaptic(kind0x04). ABI:PUNKTFUNK_GAMEPAD_STEAMDECK=6/_STEAMCONTROLLER=5+ the paddle/RICH_TOUCHPAD_EX/HIDOUT_TRACKPAD_HAPTICconstants,from_hidpacksTrackpadHapticinto the existingwhich+effect[0..6](the legacy structs do not grow — guarded bysize_of==20/19asserts); regeneratedpunktfunk_core.h. Host:steam_proto::from_gamepadmaps the paddles → the four Deck grips + QAM;apply_richroutesTouchpadExleft/right → the matching pad; every DS manager (DualSense/DS4, Linux + Windows) gained aTouchpadExarm (surface 0/2 → its one touchpad); the xpadBUTTON_MAPfinally 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_gamepadgrip +apply_richleft-pad) → evdevBTN_A+ABS_HAT0X. Workspace clippy/fmt/test green. Deferred to M4: the C-ABIPunktfunkRichInputEx+send_rich_input2(only the Apple/C send path needs it; the host decodesTouchpadExtoday).M2 result (backend + wiring, on-box):
inject/linux/steam_controller.rs(SteamControllerManager/SteamDeckPad, mirroringdualsense.rs) is wired intoPadBackend(newSteamDeckvariant +select/handle/apply_rich/pump/heartbeatarms) and selectable viaGamepadPref::SteamDeck(core enum byte 6 +pick_gamepadLinux arm;SteamController= byte 5 is reserved, folds to Xbox360 until its backend lands). Two Steam-specific quirks beyond the DualSense path: (1)gamepad_modeentry — best-effortlizard_mode=0via sysfs + ab9.6creation pulse (MODE_ENTER650 ms) + an anti-toggle guard (MENU_HOLD_CAP350 ms) so a long in-game Start-hold can't flipgamepad_modeoff; (2)UHID_SET_REPORTanswerederr=0(DualSense omits it) + the0xEBrumble parsed onto the universal 0xCA plane. An#[ignore]d on-box test (backend_binds_and_input_flows) drives the real backend: it bindshid-steam(gamepad + IMU evdevs), enters gamepad mode,BTN_Areaches the evdev, and the device tears down on drop. Workspace clippy/fmt/test green; no generated-header drift (the C-ABIGamepadPrefconstants are M3).M1 result (byte-exact serializer, on-box):
inject/proto/steam_proto.rsnow carries the full Deck contract transcribed verbatim from the kernelsteam_do_deck_input_event/steam_do_deck_sensors_event: theu64button map (bytes 8..16), sticks/triggers/trackpads/IMU at their exact offsets,from_gamepad+apply_richmappers, the rumble-feedback parser (0xEB), and the serial reply (now with the leading report-id byte the kernel strips — fixes the M0XXXXXXXXXXfallback). The validator pulses theb9.6mode-switch to entergamepad_mode(the parser early-returns under defaultlizard_modeotherwise), holds a known test pattern, and reads both evdevs viaEVIOCGABS/EVIOCGKEY: every field matched —ABS_X/Y/RX/RY(incl. the kernel Y-negation), both triggers, the touched right-padHAT1X/Y, the IMU accel/gyro (with theABS_Z/RZnegations), and the 6 expected buttons incl. the L4/R5 grips.byte 8 bit 7 = BTN_AIS correct (the M0 "didn't hold" was a flaky single-bit read beforegamepad_modewas 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 keeperinject/proto/steam_proto.rs) created a UHID28DE:1205device with a minimal vendor descriptor (one feature report — the sole thingsteam_is_valve_interfacechecks) and serviced the handshake (theUHID_SET_REPORTanswers the DualSense backend omits).journalctl -kshowedhid-steambinding it (rebound offhid-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); thegamepad_modeentry strategy on a real host (pulseb9.6at session start, or loadhid_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 keeperinject/proto/steam_proto.rs) created a UHID28DE:1205device with a minimal vendor descriptor (one feature report — the sole thingsteam_is_valve_interfacechecks) and serviced the handshake (theUHID_SET_REPORTanswers the DualSense backend omits).journalctl -kshowedhid-steambinding it (rebound offhid-generic),"Steam Controller … connected", and the kernel creating both evdevs:"Steam Deck"(gamepad,BTN_Ain key caps) and"Steam Deck Motion Sensors"(INPUT_PROP_ACCELEROMETER, 6 IMU axes). A layout-agnostic mash-probe confirmed the input path: 23 distinctBTN_*codes (A/B/X/Y, TL/TR, SELECT/START/MODE, THUMBL, all 4 DPAD, grips, back codes) toggled throughhid-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-sourcedbyte 8 bit 7 = BTN_Adid not hold on 7.0). The serial GET_REPORT reply also needs its report-number-prefix offset fixed (the kernel used theXXXXXXXXXXfallback; 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):
- Full pipeline — capture on every client + inject on the hosts, not a one-platform demo.
- 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.
- Max fidelity — build the greenfield virtual
hid-steamdriver. Linux UHID first (validates the contract against open-sourcehid-steam.c+ SDL hidapi); Windows UMDF later, gated on the Linux result (§8). - 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 overBUS_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_INFOfeature 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:
0xAEID_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.0x81ID_CLEAR_DIGITAL_MAPPINGS/0x8EID_LOAD_DEFAULT_SETTINGS/0x87ID_SET_SETTINGS_VALUES(lizard-mode + settings) — ack err=0, ignore content. The Deck path skips the auto lizard-mode disable atinput_open, so these only arrive on an options-hold or via Steam userspace — but must be ack'd to avoid the per-command stall.0x83ID_GET_ATTRIBUTES_VALUES/0xA1ID_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_rumblein 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:
0xEBID_TRIGGER_RUMBLE_CMD— Deck rumble motors; map left/right →(low, high)on the existing universal 0xCA rumble plane (exactly like the DualSensefb.rumble).0x8FID_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 new0xCD 0x04TrackpadHaptic(§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 verbatimSTEAMDECK_RDESC(≥1 feature report at id 0);SteamStatesuperset model (sticks, analog triggers, packed buttons, dpad,gyro[3]/accel[3],back:[bool;4], twoSteamPad{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, mirroringdualsense_proto's tests.crates/punktfunk-host/src/inject/linux/steam_controller.rs—/dev/uhidplumbing +SteamControllerManager, byte-identical structure todualsense.rs:SteamPad::open(index, model)doesUHID_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 separate0x40_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, andPUNKTFUNK_GAMEPAD_BTN_PADDLE1..4/_BTN_MISC1for embedders building rawInputEvents. - Do not mutate
PunktfunkRichInput(nostruct_sizeguard). Add a guarded supersetPunktfunkRichInputEx{ 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_input2that reads the size prefix first (theconnect_exN/config_from_ptrprecedent). LegacyPunktfunkRichInput+send_rich_inputstay byte-for-byte. from_hidgains theTrackpadHaptic → effect[]-packing arm;to_rich(Ex) gains theTouchpadExarm.- Compile guards: extend the
GamepadPreflockstepassert!block with the two new variants; addassert!(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)
- Before
sdl3::init(), setSDL_JOYSTICK_HIDAPI_STEAMDECK="1"+SDL_JOYSTICK_HIDAPI_STEAM ="1"(the exactsdl3::hint::setmechanism already used forSDL_NO_SIGNAL_HANDLERS). - In
pad_info, override toGamepadPref::SteamDeckwhenvendor_id()==0x28DEandproduct_id() ∈ {0x1205 Deck, 0x1102 SC wired, 0x1142 SC dongle}; add a"Steam Deck"label. - Extend
button_bit:RightPaddle1→BTN_PADDLE1,LeftPaddle1→BTN_PADDLE2,RightPaddle2→BTN_PADDLE3,LeftPaddle2→BTN_PADDLE4,Misc1→BTN_MISC1(free win for Elite). - Bind the
touchpadsurface field in the threeControllerTouchpad*arms; branch ontouchpads_count()rather than hard-coding 2 — surface 0 → existingRichInput::Touchpad, surface ≥1 →RichInput::TouchpadEx{surface}. - Track held contacts keyed by
(surface, finger)and lift them (active=false) inflush_heldon pad switch/detach (today only the DualSense single surface is implicitly lifted). - 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 selectsgamepad pref=steamdeck, adds"steamdeck"to the option set (TS +main.pyvalidation), 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 optionalwindow.DeckyBackend/collectionStoreglobals), called best-effort insideensureShortcut(), never blocking or throwing intolaunchStream. 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
.steamDeckenum case (wire byte 6) so the type round-trips; no capture. GameController never surfaces a28DEHID device as aGCExtendedGamepad(Apple has no Steam Input; a raw path would needIOHIDManager). Document as blocked. - Android: parity only — add
PREF_STEAMDECK+ the28DEPIDs to the mapping. Capture of paddles/trackpads/gyro is out of scope here:send_rich_inputis itself still a TODO (session.rs:13), and a Deck dongle appears only as a generic gamepad viaInputDevice. 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:
- Explicit client
SteamController/SteamDeckpref — honored if/dev/uhidis writable ANDhid-steamis loadable; else degrade. PUNKTFUNK_GAMEPAD=steamdeck|steamcontrollerhost env.- 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.
- Degrade ladder when a requested Steam pad is unavailable:
hid-steammissing → DualSense → Xbox360. The Welcome carries the real choice.
SteamOS/Deck-as-host conflict: a host already running Steam eagerly grabs any
28DEdevice, 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
InjectorServicepointer 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 matchingUI_SET_KEYBITregistrations increate(). 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/heartbeatflowing so the sensorts += 188advances, 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 thevhidmini2-derived WDF scaffolding, the FORCE_INTEGRITY PE-bit clear (PE+0x5e), the timer-completes-pended-READ_REPORT pattern, the queueNumberOfPresentedRequests=u32::MAXand timerExecutionLevel/SynchronizationScope=InheritFromParent + AutomaticSerialization=TRUEgotchas, theGlobal\pfds-shm-<idx>shared-memory channel, the multi-padpszDeviceLocation/UmdfHostProcessSharing=ProcessSharingDisabledplumbing, theInclude=MsHidUmdf.inf/WUDFRD.infINF stanza, andSwDeviceCreate(enumeratorpunktfunk, hardware idpf_steamdeck). - Swap identity (VID
0x28DE/ PID0x1205), the hid-steam report descriptor, and — the riskiest, non-derivable part — the0x83 GET_ATTRIBUTES_VALUES+0xA1 GET_DEVICE_INFOfeature blobs captured from real hardware (SDL #12166: Steam/SDL aborts the controller if these probes fail). Add ACK-only SET_FEATURE handlers for0x81/0x87/0x8E. - Host backend: a
SteamDeckWindowsreusinginject/windows/dualsense_windows.rsalmost 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 .ps1pnputil 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+GamepadPref5/6 encode/decode round-trips + an old-peer-drops-unknown-kind assertion; thefrom_gamepadpaddle/misc mapping;steam_protoreport-offset +parse_steam_outputunit tests (mirrordualsense_proto); thesteam_remapfold 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=steamdeckforces 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
dmesgshowshid-steam … Valve Software Steam Deck Controller; the sysfs node bindshid_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 GUID28de:1205"Steam Deck";evtestshowsBTN_SOUTHetc.; 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_openedstandoff 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 triggersFF_RUMBLE; confirm a0xEBSET_REPORT arrives and our parser emits(low, high)on0xCAback 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:11FFwith working bindings + glyphs. - GameStream regression: confirm the new
buttonFlags2consumption 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.)