From 8d8168b0e09232c695a6802e515e78d13cefcc93 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 11:44:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(gamepad):=20N4=20spike=20kit=20=E2=80=94?= =?UTF-8?q?=20software-devnode=20Steam=20Deck=20probe=20for=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/pf-driver-proto/src/lib.rs | 4 + .../src/inject/windows/dualsense_windows.rs | 66 ++++++++ crates/punktfunk-host/src/main.rs | 13 ++ .../drivers/pf-dualsense/pf_dualsense.inx | 2 +- .../windows/drivers/pf-dualsense/src/lib.rs | 151 +++++++++++++++--- 5 files changed, 213 insertions(+), 23 deletions(-) diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index af1eec20..9eb4403a 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -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). diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 7a562c35..3b4de1ec 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -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 diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index b167e2da..52807140 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -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 diff --git a/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx b/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx index df221f50..e686ef8f 100644 --- a/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx +++ b/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx @@ -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 diff --git a/packaging/windows/drivers/pf-dualsense/src/lib.rs b/packaging/windows/drivers/pf-dualsense/src/lib.rs index b3d4b32e..8b5755c0 100644 --- a/packaging/windows/drivers/pf-dualsense/src/lib.rs +++ b/packaging/windows/drivers/pf-dualsense/src/lib.rs @@ -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", }, };