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
+4
View File
@@ -538,6 +538,10 @@ pub mod gamepad {
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report /// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
/// codec plus the four native back/Fn button bits). /// codec plus the four native back/Fn button bits).
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2; pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
/// `device_type` = **N4-spike** Steam Deck identity (`VID_28DE&PID_1205`). Exists only for
/// the `deck-windows-spike` go/no-go probe (does Steam Input on Windows promote a
/// software-devnode HID Deck?) — never stamped by a session.
pub const DEVTYPE_STEAMDECK_SPIKE: u8 = 3;
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches — /// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
/// the host's positive "driver is alive on this section" signal (health check + version audit). /// the host's positive "driver is alive on this section" signal (health check + version audit).
@@ -471,6 +471,72 @@ impl PadProto for DsWinProto {
} }
} }
/// **N4 spike** (gamepad-new-types §6, timeboxed): create a software-devnode HID **Steam Deck**
/// (`device_type = 3`, `VID_28DE&PID_1205`) and hold it for `secs`, streaming the neutral Deck
/// frame, so the go/no-go question — does Steam Input on Windows promote a software-devnode HID
/// Deck, or does it require a real USB bus identity (the documented GameInput instance-path
/// gap)? — can be answered by watching Steam's `logs/controller.txt` / controller settings
/// while this holds. Never used by a session; wired to the `deck-windows-spike` subcommand.
pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let mut channel = PadChannel::create(boot_name, SHM_SIZE)?;
let base = channel.data_base();
// Neutral Deck input frame: [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09, 0x3C], all released.
let mut neutral = [0u8; 64];
(neutral[0], neutral[2], neutral[3]) = (0x01, 0x09, 0x3C);
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
// FIRST, magic LAST — the same publish order the session pads use.
unsafe {
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK_SPIKE;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("pf_deckspike_{index}");
let (hsw, _) = create_swdevice(&SwDeviceProfile {
instance: &inst,
container_index: index,
hwid: "pf_steamdeck",
usb_vid_pid: "VID_28DE&PID_1205",
description: "punktfunk Virtual Steam Deck (spike)",
})?;
let _sw = super::gamepad_raii::SwDevice::new(hsw);
channel.deliver_eager(std::time::Duration::from_millis(1500));
println!(
"virtual Steam Deck devnode up (28DE:1205, device_type 3) — holding {secs}s.\n\
Observe: Get-PnpDevice -PresentOnly | findstr 1205; Steam logs\\controller.txt for a\n\
detect/promote line; Steam Settings > Controller for a 'Steam Deck' entry.\n\
GO = Steam lists/promotes it; NO-GO = it never appears (the Linux `Interface: -1` gap\n\
applies verbatim — document and keep the SteamDeck->DualSense Windows fold)."
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
let mut last_out_seq = 0u32;
while std::time::Instant::now() < deadline {
channel.pump();
// Log any feature/output traffic Steam sends — each one is spike evidence.
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
let seq = unsafe {
std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32)
};
if seq != last_out_seq {
last_out_seq = seq;
let mut out = [0u8; 16];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe {
std::ptr::copy_nonoverlapping(
channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
16,
)
};
println!(" output report from a client (Steam?): {out:02x?}");
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
println!("deck-windows-spike: done (devnode removed on exit)");
Ok(())
}
/// All virtual DualSense pads of a session — the Windows analogue of /// All virtual DualSense pads of a session — the Windows analogue of
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared /// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat /// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
+13
View File
@@ -388,6 +388,19 @@ fn real_main() -> Result<()> {
println!("switchpro-test: done"); println!("switchpro-test: done");
Ok(()) Ok(())
} }
// Windows N4 SPIKE (gamepad-new-types §6): hold a software-devnode HID Steam Deck
// (28DE:1205 via device_type 3) and watch whether Steam Input promotes it. Needs the
// updated signed driver installed + Steam running. `--seconds N` (default 120).
#[cfg(target_os = "windows")]
Some("deck-windows-spike") => {
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(120);
inject::dualsense_windows::deck_spike_hold(0, secs)
}
// Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode // Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode
// + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives // + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives
// the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify // the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify
@@ -30,7 +30,7 @@ pf_dualsense.dll=1
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` for the host's ; 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 ; 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. ; 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] [pfDualSense.NT]
CopyFiles=UMDriverCopy CopyFiles=UMDriverCopy
+129 -22
View File
@@ -65,6 +65,12 @@ const DS_VER: u16 = 0x0100;
const DS4_PID: u16 = 0x09CC; const DS4_PID: u16 = 0x09CC;
/// DualSense Edge product id — served (same VID/version) when the host stamps device_type=2. /// DualSense Edge product id — served (same VID/version) when the host stamps device_type=2.
const DS_EDGE_PID: u16 = 0x0DF2; 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). // 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. // 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, 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 // 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); // {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB);
// DualSense Edge = 389 (0x0185). // DualSense Edge = 389 (0x0185).
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01]; 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 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 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]. // 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] { fn hid_attrs(devtype: u8) -> [u8; 32] {
let pid = match devtype { let (vid, pid) = match devtype {
1 => DS4_PID, 1 => (DS_VID, DS4_PID),
2 => DS_EDGE_PID, 2 => (DS_VID, DS_EDGE_PID),
_ => DS_PID, 3 => (DECK_VID, DECK_PID),
_ => (DS_VID, DS_PID),
}; };
let mut a = [0u8; 32]; let mut a = [0u8; 32];
a[0..4].copy_from_slice(&32u32.to_le_bytes()); 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[6..8].copy_from_slice(&pid.to_le_bytes());
a[8..10].copy_from_slice(&DS_VER.to_le_bytes()); a[8..10].copy_from_slice(&DS_VER.to_le_bytes());
a 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[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
r 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] { fn neutral_report(devtype: u8) -> [u8; 64] {
if devtype == 1 { match devtype {
DS4_NEUTRAL_REPORT 1 => DS4_NEUTRAL_REPORT,
} else { 3 => DECK_NEUTRAL_REPORT,
NEUTRAL_REPORT // DualSense and Edge share the report 0x01 shape _ => 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() { IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(match device_type() {
1 => &DS4_HID_DESC, 1 => &DS4_HID_DESC,
2 => &EDGE_HID_DESC, 2 => &EDGE_HID_DESC,
3 => &DECK_HID_DESC,
_ => &HID_DESC, _ => &HID_DESC,
}), }),
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())), 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() { IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() {
1 => &DS4_RDESC[..], 1 => &DS4_RDESC[..],
2 => &DS_EDGE_RDESC[..], 2 => &DS_EDGE_RDESC[..],
3 => &DECK_RDESC[..],
_ => &DUALSENSE_RDESC[..], _ => &DUALSENSE_RDESC[..],
}), }),
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
on_output_report(&request, ioctl) on_output_report(&request, ioctl)
} }
IOCTL_UMDF_HID_SET_FEATURE => { IOCTL_UMDF_HID_SET_FEATURE => on_set_feature(&request),
log("[pf-ds] SET_FEATURE (stub ok)");
STATUS_SUCCESS
}
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request), IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => { IOCTL_UMDF_HID_GET_INPUT_REPORT => {
request.copy_to_output(&neutral_report(device_type())) request.copy_to_output(&neutral_report(device_type()))
@@ -589,8 +616,88 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
STATUS_SUCCESS 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 { 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) { let (bytes, _) = match request.input_bytes(1) {
Ok(v) => v, Ok(v) => v,
Err(st) => return st, Err(st) => return st,
@@ -635,21 +742,21 @@ fn on_get_string(request: &Request) -> NTSTATUS {
let devtype = device_type(); let devtype = device_type();
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}"); dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
let s: &str = match string_id { let s: &str = match string_id {
0 | 0x000e => { 0 | 0x000e => match devtype {
if devtype == 1 { 1 => "Sony Computer Entertainment",
"Sony Computer Entertainment" 3 => "Valve Software",
} else { _ => "Sony Interactive Entertainment",
"Sony Interactive Entertainment" },
}
}
2 | 0x0010 => match devtype { 2 | 0x0010 => match devtype {
1 => "DEADBEEF0001", 1 => "DEADBEEF0001",
2 => "35533AD6E775", 2 => "35533AD6E775",
3 => "PFDK50460003",
_ => "35533AD6E774", _ => "35533AD6E774",
}, },
_ => match devtype { _ => match devtype {
1 => "Wireless Controller", 1 => "Wireless Controller",
2 => "DualSense Edge Wireless Controller", 2 => "DualSense Edge Wireless Controller",
3 => "Steam Deck Controller",
_ => "DualSense Wireless Controller", _ => "DualSense Wireless Controller",
}, },
}; };