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:
@@ -538,6 +538,10 @@ pub mod gamepad {
|
||||
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
|
||||
/// codec plus the four native back/Fn button bits).
|
||||
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 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
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
|
||||
/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
|
||||
|
||||
@@ -388,6 +388,19 @@ fn real_main() -> Result<()> {
|
||||
println!("switchpro-test: done");
|
||||
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
|
||||
// + 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user