feat(gamepad): N4 spike kit — software-devnode Steam Deck probe for Windows

The gamepad-new-types §6 go/no-go rig, ready to run the moment .173 is
back (the box is currently down, so the observation itself is still
owed): does Steam Input on Windows promote a software-devnode HID Deck
(28DE:1205), or does it need a real USB bus identity (the documented
GameInput instance-path gap — the Linux 'Interface: -1' lesson)?

- Driver: scratch device_type=3 serves the Deck identity — the captured
  38-byte controller-interface descriptor, 28DE:1205 attributes, Valve
  strings, the Deck neutral frame, and the Steam 0x83/0xAE feature
  contract (SET_FEATURE latches the command, GET_FEATURE answers it —
  attribute blob + unit serial mirroring steam_proto::feature_reply).
  Never stamped by a session. INF gains pf_steamdeck.
- Host: deck_spike_hold() + the `deck-windows-spike` subcommand — stamps
  devtype 3, spawns the devnode under VID_28DE&PID_1205, streams the
  neutral frame, prints what to observe (Steam logs/controller.txt,
  controller settings) and logs any output reports Steam writes.

Run recipe (on .173, once the updated signed driver is staged): install
driver, start Steam, `punktfunk-host.exe deck-windows-spike`, watch
controller.txt. GO -> plan a proper N4 phase (the Deck codec is already
shared); NO-GO -> document next to the Linux Interface:-1 note and keep
the SteamDeck->DualSense Windows fold.

Verified: .133 clippy -D warnings + the driver workspace cargo check
(WDK) both green; .21 clippy + 304/0 tests unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:44:59 +02:00
parent 61c752e91e
commit 8d8168b0e0
5 changed files with 213 additions and 23 deletions
@@ -30,7 +30,7 @@ pf_dualsense.dll=1
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` for the host's
; virtual DualShock 4 / DualSense Edge — the same driver binds all of them and serves the matching
; identity per the device_type byte the host stamps into shared memory.
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4, pf_dualsenseedge
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4, pf_dualsenseedge, pf_steamdeck
[pfDualSense.NT]
CopyFiles=UMDriverCopy
+129 -22
View File
@@ -65,6 +65,12 @@ const DS_VER: u16 = 0x0100;
const DS4_PID: u16 = 0x09CC;
/// DualSense Edge product id — served (same VID/version) when the host stamps device_type=2.
const DS_EDGE_PID: u16 = 0x0DF2;
/// **N4 spike** (gamepad-new-types §6): the Steam Deck controller identity (Valve 28DE:1205),
/// served when the host stamps device_type=3. Exists ONLY to answer the go/no-go question "does
/// Steam Input on Windows promote a software-devnode HID Deck?" — the host never stamps 3
/// outside the `deck-windows-spike` subcommand.
const DECK_VID: u16 = 0x28DE;
const DECK_PID: u16 = 0x1205;
// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs).
// NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273.
@@ -211,24 +217,37 @@ static DS_EDGE_RDESC: [u8; 389] = [
0x09, 0x53, 0xB1, 0x02, 0xC0,
];
// ---- N4-spike Steam Deck assets (served when the host stamps device_type=3) ----
// The Deck's captured CONTROLLER-interface report descriptor (38 bytes, interface 2 of a real
// 28DE:1205 — verbatim from inject/proto/steam_proto.rs RDESC_DECK_CTRL): one vendor-defined
// (page 0xFFFF) collection with a 64-byte input + 64-byte feature report.
#[rustfmt::skip]
static DECK_RDESC: [u8; 38] = [
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00,
0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75,
0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
];
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then
// {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB);
// DualSense Edge = 389 (0x0185).
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01];
static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01];
static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01];
static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
// `devtype` selects the product id (same VID/version for all three identities).
// `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck.
fn hid_attrs(devtype: u8) -> [u8; 32] {
let pid = match devtype {
1 => DS4_PID,
2 => DS_EDGE_PID,
_ => DS_PID,
let (vid, pid) = match devtype {
1 => (DS_VID, DS4_PID),
2 => (DS_VID, DS_EDGE_PID),
3 => (DECK_VID, DECK_PID),
_ => (DS_VID, DS_PID),
};
let mut a = [0u8; 32];
a[0..4].copy_from_slice(&32u32.to_le_bytes());
a[4..6].copy_from_slice(&DS_VID.to_le_bytes());
a[4..6].copy_from_slice(&vid.to_le_bytes());
a[6..8].copy_from_slice(&pid.to_le_bytes());
a[8..10].copy_from_slice(&DS_VER.to_le_bytes());
a
@@ -258,11 +277,20 @@ const DS4_NEUTRAL_REPORT: [u8; 64] = {
r[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
r
};
// Neutral Steam Deck input frame (unnumbered): header [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09,
// payload-len 0x3C], everything released.
const DECK_NEUTRAL_REPORT: [u8; 64] = {
let mut r = [0u8; 64];
r[0] = 0x01;
r[2] = 0x09;
r[3] = 0x3C;
r
};
fn neutral_report(devtype: u8) -> [u8; 64] {
if devtype == 1 {
DS4_NEUTRAL_REPORT
} else {
NEUTRAL_REPORT // DualSense and Edge share the report 0x01 shape
match devtype {
1 => DS4_NEUTRAL_REPORT,
3 => DECK_NEUTRAL_REPORT,
_ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape
}
}
@@ -527,21 +555,20 @@ extern "C" fn evt_io_device_control(
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(match device_type() {
1 => &DS4_HID_DESC,
2 => &EDGE_HID_DESC,
3 => &DECK_HID_DESC,
_ => &HID_DESC,
}),
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())),
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() {
1 => &DS4_RDESC[..],
2 => &DS_EDGE_RDESC[..],
3 => &DECK_RDESC[..],
_ => &DUALSENSE_RDESC[..],
}),
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
on_output_report(&request, ioctl)
}
IOCTL_UMDF_HID_SET_FEATURE => {
log("[pf-ds] SET_FEATURE (stub ok)");
STATUS_SUCCESS
}
IOCTL_UMDF_HID_SET_FEATURE => on_set_feature(&request),
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => {
request.copy_to_output(&neutral_report(device_type()))
@@ -589,8 +616,88 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
STATUS_SUCCESS
}
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob.
/// N4 spike: the last SET_FEATURE payload (the Steam command byte + args, minus the report-id
/// prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE on the one
/// unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are fire-and-
/// forget) — acking them is all they need.
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
// SET_FEATURE: ack (the PS identities' contract), and latch the payload for the Deck's
// GET_FEATURE answer. Per the UMDF marshalling convention the report data is the input buffer.
fn on_set_feature(request: &Request) -> NTSTATUS {
if let Ok((bytes, _)) = request.input_bytes(64) {
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
// command-first view. (PS set-features carry their own report id first — harmless.)
let src: &[u8] = if bytes.first() == Some(&0x00) && bytes.len() > 1 {
&bytes[1..]
} else {
&bytes
};
if let Ok(mut g) = LAST_SET_FEATURE.lock() {
g.fill(0);
let n = src.len().min(64);
g[..n].copy_from_slice(&src[..n]);
}
}
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
STATUS_SUCCESS
}
/// N4 spike: build the Deck's GET_FEATURE reply from the latched SET_FEATURE command — the
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
/// truth this mirrors). Anything else echoes the latched command.
fn deck_feature_reply() -> [u8; 64] {
let last = LAST_SET_FEATURE
.lock()
.map(|g| *g)
.unwrap_or([0u8; 64]);
let unit_id: u32 = 0x5046_0003; // "PF" + the spike's scratch index
let serial = b"PFDK50460003";
let mut r = [0u8; 64];
match last[0] {
0x83 => {
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
r[0] = 0x83;
r[1] = 0x2D;
let attrs: [(u8, u32); 9] = [
(0x01, 0x1205),
(0x02, 0),
(0x0A, unit_id),
(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…].
let attr = if last[2] != 0 { last[2] } else { 0x01 };
r[0] = 0xAE;
r[1] = serial.len() as u8;
r[2] = attr;
r[3..3 + serial.len()].copy_from_slice(serial);
}
_ => r.copy_from_slice(&last),
}
r
}
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob
// (the Deck identity instead answers the latched Steam command — its one feature report is
// unnumbered).
fn on_get_feature(request: &Request) -> NTSTATUS {
if device_type() == 3 {
return request.copy_to_output(&deck_feature_reply());
}
let (bytes, _) = match request.input_bytes(1) {
Ok(v) => v,
Err(st) => return st,
@@ -635,21 +742,21 @@ fn on_get_string(request: &Request) -> NTSTATUS {
let devtype = device_type();
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
let s: &str = match string_id {
0 | 0x000e => {
if devtype == 1 {
"Sony Computer Entertainment"
} else {
"Sony Interactive Entertainment"
}
}
0 | 0x000e => match devtype {
1 => "Sony Computer Entertainment",
3 => "Valve Software",
_ => "Sony Interactive Entertainment",
},
2 | 0x0010 => match devtype {
1 => "DEADBEEF0001",
2 => "35533AD6E775",
3 => "PFDK50460003",
_ => "35533AD6E774",
},
_ => match devtype {
1 => "Wireless Controller",
2 => "DualSense Edge Wireless Controller",
3 => "Steam Deck Controller",
_ => "DualSense Wireless Controller",
},
};