feat(host/steam): harden the gadget feature contract — fixes the evdev churn
The virtual Deck's gamepad evdev was churning (destroyed + recreated) because Steam kept re-probing: GetControllerInfo reads HID feature reports, and the gadget served zeros for them. Captured the real contract off a physical Deck (packaging/linux/steam-deck-gadget/get_deck_attrs.c, hidraw HIDIOCGFEATURE — usbmon truncates to 32B) and implemented it in steam_gadget.rs::feature_reply: - 0x83 GET_ATTRIBUTES_VALUES: [83, 2d, 9×(attr-id, u32-LE)] — product id 0x1205, a per-instance unit serial (0x0a/0x04, so a gadget never collides with a real Deck or another gadget), and the capability attrs (0x09=0x2e, 0x0b=0x0fa0, rest 0). - 0xAE GET_STRING_ATTRIBUTE: [ae, len, attr, ascii] — serial (attr 1) / board serial (attr 0). - other commands (0x87 settings): echo the last write. Validated on the Deck: 1 connect / 0 disconnect / 1 gamepad evdev (was constant churn), Steam activates the gadget cleanly (no GetControllerInfo failed, no zombie) and emits its X-Box 360 pad. usbmon on the gadget's bus confirms our state reports (pressed button at byte 8) are delivered on the interrupt-IN and consumed by hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode the input chain is proven end-to-end. Remaining: a foreground-game confirmation of Steam Input's XInput mapping, then default the gadget on for SteamOS. Workspace clippy/fmt/test green. Not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -247,6 +247,7 @@ impl SteamDeckGadget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serial = format!("PFDECK{index:04}");
|
let serial = format!("PFDECK{index:04}");
|
||||||
|
let unit_id = 0x5046_0000u32 | index as u32; // "PF" + index — a synthetic per-instance device id
|
||||||
let report = Arc::new(Mutex::new(neutral_report()));
|
let report = Arc::new(Mutex::new(neutral_report()));
|
||||||
let feedback = Arc::new(Mutex::new(Default::default()));
|
let feedback = Arc::new(Mutex::new(Default::default()));
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
@@ -262,7 +263,9 @@ impl SteamDeckGadget {
|
|||||||
let feedback = feedback.clone();
|
let feedback = feedback.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pf-deck-gadget-ctrl".into())
|
.name("pf-deck-gadget-ctrl".into())
|
||||||
.spawn(move || control_loop(fd, running, ctrl_ep, configured, feedback, serial))
|
.spawn(move || {
|
||||||
|
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
|
||||||
|
})
|
||||||
.context("spawn gadget control thread")?
|
.context("spawn gadget control thread")?
|
||||||
};
|
};
|
||||||
// Stream thread: push the current report on the controller interrupt-IN endpoint.
|
// Stream thread: push the current report on the controller interrupt-IN endpoint.
|
||||||
@@ -336,6 +339,7 @@ fn control_loop(
|
|||||||
configured: Arc<AtomicBool>,
|
configured: Arc<AtomicBool>,
|
||||||
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
||||||
serial: String,
|
serial: String,
|
||||||
|
unit_id: u32,
|
||||||
) {
|
) {
|
||||||
let raw = fd.0;
|
let raw = fd.0;
|
||||||
let cfg = build_config();
|
let cfg = build_config();
|
||||||
@@ -369,6 +373,7 @@ fn control_loop(
|
|||||||
&ctrl,
|
&ctrl,
|
||||||
&cfg,
|
&cfg,
|
||||||
&serial,
|
&serial,
|
||||||
|
unit_id,
|
||||||
&ctrl_ep,
|
&ctrl_ep,
|
||||||
&configured,
|
&configured,
|
||||||
&mut last_set,
|
&mut last_set,
|
||||||
@@ -394,6 +399,7 @@ fn handle_control(
|
|||||||
ctrl: &Setup,
|
ctrl: &Setup,
|
||||||
cfg: &[u8],
|
cfg: &[u8],
|
||||||
serial: &str,
|
serial: &str,
|
||||||
|
unit_id: u32,
|
||||||
ctrl_ep: &std::sync::atomic::AtomicI32,
|
ctrl_ep: &std::sync::atomic::AtomicI32,
|
||||||
configured: &AtomicBool,
|
configured: &AtomicBool,
|
||||||
last_set: &mut Vec<u8>,
|
last_set: &mut Vec<u8>,
|
||||||
@@ -450,7 +456,7 @@ fn handle_control(
|
|||||||
match ctrl.b_request {
|
match ctrl.b_request {
|
||||||
0x01 => {
|
0x01 => {
|
||||||
// GET_REPORT — serve the Deck feature reply for the last requested command.
|
// GET_REPORT — serve the Deck feature reply for the last requested command.
|
||||||
let resp = feature_reply(last_set, serial);
|
let resp = feature_reply(last_set, serial, unit_id);
|
||||||
let n = resp.len().min(wl);
|
let n = resp.len().min(wl);
|
||||||
ep0_write(raw, &resp[..n]);
|
ep0_write(raw, &resp[..n]);
|
||||||
}
|
}
|
||||||
@@ -482,20 +488,55 @@ fn handle_control(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the HID feature GET_REPORT reply for whatever the host last asked via SET_REPORT.
|
/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command. Steam's
|
||||||
fn feature_reply(last_set: &[u8], serial: &str) -> Vec<u8> {
|
/// `GetControllerInfo` reads the `0x83` attributes + the `0xAE` serial; **serving the real `0x83`
|
||||||
// The Deck serial path: SET [0xAE,…] then GET → [0xAE,len,0x01,ascii]. For unknown commands echo
|
/// blob is what stops Steam re-probing** (the gamepad-evdev churn). The contract (`0x83` 9-attribute
|
||||||
// the command byte with a zeroed body (enough to keep hid-steam's probe from erroring).
|
/// layout + the `0xAE` string format) was captured from a physical Steam Deck via hidraw. `unit_id`
|
||||||
|
/// stamps a per-instance value into the device-id attributes (`0x0a`/`0x04`) so a gadget never
|
||||||
|
/// collides with a real Deck or another gadget.
|
||||||
|
fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; 64] {
|
||||||
let cmd = last_set.first().copied().unwrap_or(0xAE);
|
let cmd = last_set.first().copied().unwrap_or(0xAE);
|
||||||
let mut r = vec![0u8; 64];
|
let mut r = [0u8; 64];
|
||||||
r[0] = cmd;
|
match cmd {
|
||||||
if cmd == 0xAE {
|
0x83 => {
|
||||||
|
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)].
|
||||||
|
r[0] = 0x83;
|
||||||
|
r[1] = 0x2d;
|
||||||
|
let attrs: [(u8, u32); 9] = [
|
||||||
|
(0x01, 0x1205), // product id
|
||||||
|
(0x02, 0),
|
||||||
|
(0x0a, unit_id), // unit serial number (per-instance)
|
||||||
|
(0x04, unit_id ^ 0x5555_5555),
|
||||||
|
(0x09, 0x2e),
|
||||||
|
(0x0b, 0x0fa0),
|
||||||
|
(0x0d, 0),
|
||||||
|
(0x0c, 0),
|
||||||
|
(0x0e, 0),
|
||||||
|
];
|
||||||
|
let mut o = 2;
|
||||||
|
for (id, val) in attrs {
|
||||||
|
r[o] = id;
|
||||||
|
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
|
||||||
|
o += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0xAE => {
|
||||||
|
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr
|
||||||
|
// 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id.
|
||||||
|
let attr = last_set.get(2).copied().unwrap_or(0x01);
|
||||||
let b = serial.as_bytes();
|
let b = serial.as_bytes();
|
||||||
let len = b.len().clamp(1, 21);
|
let len = b.len().clamp(1, 20);
|
||||||
|
r[0] = 0xAE;
|
||||||
r[1] = len as u8;
|
r[1] = len as u8;
|
||||||
r[2] = 0x01;
|
r[2] = attr;
|
||||||
r[3..3 + len].copy_from_slice(&b[..len]);
|
r[3..3 + len].copy_from_slice(&b[..len]);
|
||||||
}
|
}
|
||||||
|
_ => {
|
||||||
|
// Settings read-back (e.g. 0x87): echo the host's last command + data.
|
||||||
|
let n = last_set.len().min(64);
|
||||||
|
r[..n].copy_from_slice(&last_set[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -807,3 +807,18 @@ With the A button held in the streamed report on a `pressa` build, on the Deck:
|
|||||||
Conclusion: input delivery + format are proven; the only gap is the gamepad-evdev transience, which is
|
Conclusion: input delivery + format are proven; the only gap is the gamepad-evdev transience, which is
|
||||||
a **feature-report-completeness** problem — exactly what the host backend fixes (serve the full Deck
|
a **feature-report-completeness** problem — exactly what the host backend fixes (serve the full Deck
|
||||||
feature/attribute contract so Steam stops fighting it). That's the next step, not more PoC patching.
|
feature/attribute contract so Steam stops fighting it). That's the next step, not more PoC patching.
|
||||||
|
|
||||||
|
### Feature contract hardened — the churn is fixed (2026-06-29)
|
||||||
|
|
||||||
|
The gamepad-evdev churn was Steam re-probing because the gadget served zeros for the HID feature
|
||||||
|
reports Steam's `GetControllerInfo` reads. The real contract was captured from a physical Deck
|
||||||
|
(`packaging/linux/steam-deck-gadget/get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`) and implemented in
|
||||||
|
`steam_gadget.rs::feature_reply`: the **`0x83` GET_ATTRIBUTES_VALUES** blob (`[83,2d, 9×(id,u32-LE)]`
|
||||||
|
— product id `0x1205`, a per-instance unit serial, capability attrs) plus the **`0xAE`** string
|
||||||
|
attributes (serial / board serial) and a settings echo. Result on the Deck: **1 connect / 0
|
||||||
|
disconnect / 1 gamepad evdev** (was constant churn), Steam *activates* the gadget cleanly (no
|
||||||
|
`GetControllerInfo failed`, no zombie) and emits its **X-Box 360 pad 1**. usbmon on the gadget's bus
|
||||||
|
confirms our state reports (pressed button at byte 8) are delivered on the interrupt-IN and consumed
|
||||||
|
by hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode, the input chain is proven end-to-end. The
|
||||||
|
only piece left is a foreground-game confirmation that Steam Input maps it onto the X-Box pad (Steam
|
||||||
|
only maps contextually), after which the gadget can default on for SteamOS hosts.
|
||||||
|
|||||||
@@ -71,9 +71,27 @@ real module): it enumerates the 3-interface Deck, hid-steam binds it + reads our
|
|||||||
musl, `ioctl(fd, RUN)` with no third arg passes a garbage `value`, and raw_gadget's `RUN`/`CONFIGURE`/
|
musl, `ioctl(fd, RUN)` with no third arg passes a garbage `value`, and raw_gadget's `RUN`/`CONFIGURE`/
|
||||||
`EP0_STALL` reject a non-zero `value` with `EINVAL` — so the no-arg ioctls must pass an explicit `0`.
|
`EP0_STALL` reject a non-zero `value` with `EINVAL` — so the no-arg ioctls must pass an explicit `0`.
|
||||||
|
|
||||||
|
## Feature contract (hardened — churn fixed)
|
||||||
|
|
||||||
|
Steam's `GetControllerInfo` reads HID **feature reports**; serving the real ones is what stops Steam
|
||||||
|
re-probing (which was destroying + recreating the gamepad evdev — the "churn"). The contract was
|
||||||
|
captured from a physical Deck (`get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`; usbmon truncates to 32B):
|
||||||
|
|
||||||
|
- **`0x83` GET_ATTRIBUTES_VALUES** — `[83, 2d, 9× (attr-id, u32-LE)]`: product id `0x1205`, a unit
|
||||||
|
serial (`0x0a`/`0x04` — we stamp a per-instance value so a gadget never collides with a real Deck),
|
||||||
|
and capability attrs (`0x09=0x2e`, `0x0b=0x0fa0`, `0x02/0x0c/0x0d/0x0e=0`). **This blob is the fix.**
|
||||||
|
- **`0xAE` GET_STRING_ATTRIBUTE** — `[ae, len, attr, ascii]`: serial (attr 1), board serial (attr 0).
|
||||||
|
- Other commands (e.g. `0x87` settings) read back the last write (echo).
|
||||||
|
|
||||||
|
Result on the Deck (`feature_reply` in `steam_gadget.rs`): **1 connect / 0 disconnect / 1 gamepad
|
||||||
|
evdev** (was constant churn), and Steam *activates* the controller cleanly (no `GetControllerInfo
|
||||||
|
failed`, no zombie) and emits its **X-Box 360 pad**. usbmon on the gadget's bus confirms our state
|
||||||
|
reports (with the pressed button at byte 8) are delivered on the interrupt-IN and consumed by
|
||||||
|
hid-steam — so the input transport is proven end-to-end.
|
||||||
|
|
||||||
## Remaining
|
## Remaining
|
||||||
|
|
||||||
- **Harden the feature contract** so Steam stops re-probing + the gamepad evdev stops churning (serve
|
- **Glass confirmation of the XInput mapping** — Steam Input only maps the gadget's raw input onto its
|
||||||
Steam's full `GetControllerInfo` attribute set, captured from a physical Deck) — then a clean live
|
X-Box pad while a game using Steam Input is focused; confirm a button reaches a real game, then
|
||||||
input-flow check + defaulting the gadget on for SteamOS hosts.
|
default the gadget on for SteamOS hosts (it's strictly better than the non-promoted UHID path).
|
||||||
- A `punktfunk-host` build for SteamOS to exercise the integrated path end-to-end with a live client.
|
- A `punktfunk-host` build for SteamOS to exercise the integrated path end-to-end with a live client.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Query a physical Steam Deck's feature reports (SET command then GET response) over hidraw to get
|
||||||
|
// the FULL blobs (usbmon truncates to 32 bytes). Steam feature reports are unnumbered 64-byte; the
|
||||||
|
// hidraw buffer prefixes a report-id byte (0).
|
||||||
|
#include <linux/hidraw.h>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
static void dump(const char *name, unsigned char *b, int n) {
|
||||||
|
printf("%s rc=%d:", name, n);
|
||||||
|
for (int i = 0; i < (n > 0 ? n : 0); i++) printf(" %02x", b[i]);
|
||||||
|
printf("\n");
|
||||||
|
}
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
if (argc < 2) { printf("usage: %s /dev/hidrawN\n", argv[0]); return 1; }
|
||||||
|
int fd = open(argv[1], O_RDWR);
|
||||||
|
if (fd < 0) { perror("open"); return 1; }
|
||||||
|
// SET [reportid=0, cmd, len, attr, ...] then GET the response.
|
||||||
|
unsigned char queries[][4] = {
|
||||||
|
{0x83, 0x00, 0x00, 0x00}, // GET_ATTRIBUTES_VALUES
|
||||||
|
{0xae, 0x16, 0x01, 0x00}, // GET_STRING_ATTRIBUTE, serial (attr 1)
|
||||||
|
{0xae, 0x16, 0x00, 0x00}, // GET_STRING_ATTRIBUTE, attr 0
|
||||||
|
{0xae, 0x16, 0x02, 0x00}, // attr 2 (board serial?)
|
||||||
|
};
|
||||||
|
for (int q = 0; q < 4; q++) {
|
||||||
|
unsigned char set[65] = {0};
|
||||||
|
set[0] = 0; // report id 0
|
||||||
|
memcpy(set + 1, queries[q], 4);
|
||||||
|
int sr = ioctl(fd, HIDIOCSFEATURE(65), set);
|
||||||
|
usleep(3000);
|
||||||
|
unsigned char get[65] = {0};
|
||||||
|
get[0] = 0;
|
||||||
|
int gr = ioctl(fd, HIDIOCGFEATURE(65), get);
|
||||||
|
printf("=== query cmd=%02x attr=%02x (SET rc=%d) ===\n", queries[q][0], queries[q][2], sr);
|
||||||
|
dump(" GET", get, gr);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user