diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 232e0a1c..9366cd96 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -991,6 +991,85 @@ mod drain_tests { } } + /// The driver reads its HID identity back off the same hardware id — that mapping is what + /// decides which report descriptor and which VID/PID a pad enumerates with, and it is settled + /// at `EvtDeviceAdd`, before the sealed channel can possibly say anything (its delivery goes + /// through the HID interface that does not exist yet). So every hwid the host uses must appear + /// in `devtype_from_hwids`' table against this side's `devtype`, and the table must be ordered + /// so no token shadows a longer one — `pf_dualsense` is a prefix of `pf_dualsenseedge`, and an + /// Edge listed after it would resolve to a plain DualSense. + /// + /// Until the driver read the ids, EVERY non-DualSense pad enumerated with the DualSense + /// descriptor: a Steam Deck's 64-byte frame parsed as DualSense report `0x01` pins the left + /// stick to a corner and holds d-pad UP forever. + #[test] + fn hwid_devtype_table_matches_the_driver() { + let src = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packaging/windows/drivers/pf-gamepad/src/lib.rs" + ); + let driver = std::fs::read_to_string(src).expect("read pf-gamepad lib.rs"); + let table = driver + .split_once("fn devtype_from_hwids") + .expect("devtype_from_hwids not found — did the driver's identity resolution move?") + .1; + let table = table.split_once("] {").expect("table literal").0; + // `("pf_steamdeck", 3u8),` → ("pf_steamdeck", 3), in source order. + let entries: Vec<(String, u8)> = table + .lines() + .filter_map(|l| l.trim().strip_prefix('(')) + .filter_map(|l| l.split_once(',')) + .filter_map(|(id, dt)| { + let id = id.trim().trim_matches('"').to_ascii_lowercase(); + let dt = dt + .trim() + .trim_end_matches([')', ',']) + .trim_end_matches("u8"); + dt.parse().ok().map(|dt| (id, dt)) + }) + .collect(); + assert_eq!( + entries.len(), + 4, + "parsed {entries:?} out of the driver's table — the shape changed and this test went \ + vacuous; fix the parse rather than deleting the assert" + ); + for (i, (id, _)) in entries.iter().enumerate() { + for (later, _) in &entries[i + 1..] { + assert!( + !later.starts_with(id.as_str()), + "the driver tests {id:?} before {later:?}, so a {later:?} devnode would \ + resolve to {id:?}'s identity — put the longer id first" + ); + } + } + for (hwid, devtype) in [ + (WinDsIdentity::dualsense().hwid, 0), + ( + WinDsIdentity::dualsense_edge().hwid, + pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE, + ), + ( + super::super::dualshock4_windows::DS4_HWID, + pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4, + ), + ( + super::super::steam_deck_windows::DECK_HWID, + pf_driver_proto::gamepad::DEVTYPE_STEAMDECK, + ), + ] { + let want = hwid.to_ascii_lowercase(); + let got = entries.iter().find(|(id, _)| *id == want); + assert_eq!( + got.map(|(_, dt)| *dt), + Some(devtype), + "the host stamps device_type={devtype} for hardware id {hwid:?}, but the driver's \ + table says {got:?} — the pad would enumerate with another controller's report \ + descriptor" + ); + } + } + #[test] fn legacy_driver_still_drains_the_latest_slot() { let mut buf = section(); diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index d8b59b8f..1cdabfd3 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -65,10 +65,10 @@ 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. +/// The Steam Deck controller identity (Valve 28DE:1205), served when the host stamps +/// device_type=3. Started as the N4 spike (gamepad-new-types §6) answering "does Steam Input on +/// Windows promote a software-devnode HID Deck?"; it is now a shipping identity — every Steam Deck +/// CLIENT streaming to a Windows host declares it, and `steam_deck_windows` builds the pad. const DECK_VID: u16 = 0x28DE; const DECK_PID: u16 = 0x1205; @@ -375,12 +375,36 @@ fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) { /// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so /// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. static CHANNEL: ChannelClient = ChannelClient::new(); -/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge) — the -/// neutral-report shape when -/// the channel detaches, and the fallback identity while unattached. +/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, +/// 3 = Steam Deck) — the neutral-report shape when the channel detaches, and the fallback identity +/// while unattached. static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0); /// device_type()'s bounded first-read wait fires at most once (see its docs). static DEVTYPE_WAITED: AtomicBool = AtomicBool::new(false); +/// The identity resolved from the devnode's PnP hardware ids at `EvtDeviceAdd` ([`devtype_from_hwids`]); +/// `u32::MAX` = not resolved. See [`device_type`] for why this exists. +static PNP_DEVTYPE: AtomicU32 = AtomicU32::new(u32::MAX); + +/// Map a devnode's hardware-id list (lowercase, `;`-separated — see +/// [`wdf::query_hardware_ids`](pf_umdf_util::wdf::query_hardware_ids)) to the `device_type` the host +/// stamps into the section. The host picks one `pf_*` id per identity and lists it FIRST (it is the +/// INF binding contract, pinned by `dualsense_windows::drain_tests::hwid_matches_inf`), so the two +/// can never disagree. +/// +/// Order matters: `pf_dualsense` is a prefix of `pf_dualsenseedge`, so the Edge is tested first. +fn devtype_from_hwids(ids: &str) -> Option { + for (token, devtype) in [ + ("pf_steamdeck", 3u8), + ("pf_dualsenseedge", 2), + ("pf_dualshock4", 1), + ("pf_dualsense", 0), + ] { + if ids.contains(token) { + return Some(devtype); + } + } + None +} /// This pad's channel config (magic/size/pad_index offset + our logger). fn channel_cfg() -> ChannelConfig { @@ -510,6 +534,23 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI CHANNEL.set_index(shm_idx); dbglog!("[pf-gamepad] shm index = {shm_idx}"); + // Settle WHICH controller we are before hidclass asks (see `device_type`): the PnP hardware ids + // are the only identity available this early, and every descriptor/attribute answer depends on it. + // SAFETY: `device` is the live device just created — the exact contract this fn requires. + let hwids = unsafe { wdf::query_hardware_ids(device) }; + match devtype_from_hwids(&hwids) { + Some(t) => { + PNP_DEVTYPE.store(t as u32, Ordering::Relaxed); + LAST_DEVTYPE.store(t as u32, Ordering::Relaxed); + dbglog!("[pf-gamepad] identity from PnP hardware ids: device_type={t} ({hwids})"); + } + // No pf_* id: an unexpected devnode (or a property query that failed). Keep the historical + // behaviour — wait for the channel, then fall back to DualSense. + None => dbglog!( + "[pf-gamepad] no pf_* hardware id in ({hwids}) — identity deferred to the channel" + ), + } + // Default parallel queue handling all IOCTLs. // SAFETY: zeroed config then fields set; Size matches the struct. let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() }; @@ -922,19 +963,33 @@ fn on_get_string(request: &Request) -> NTSTATUS { request.copy_to_output(&wide) } -/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense -/// (default), 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck. Read fresh on each enumeration -/// query — cheap. If -/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers -/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel -/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense -/// identity because of a lost race. After that one bounded wait, fall back to the last observed type. +/// The device-type selector: 0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck. +/// Read fresh on each enumeration query — cheap. +/// +/// ⚠️ **The sealed section cannot answer the enumeration queries.** hidclass asks for +/// `GET_DEVICE_DESCRIPTOR` / `GET_REPORT_DESCRIPTOR` / `GET_DEVICE_ATTRIBUTES` while it STARTS the +/// device; the host can only deliver the DATA section over the HID device interface +/// (`ProofTransport::HidFeatureReport`), which does not exist until those very queries are answered. +/// So the channel is *structurally* unavailable here, not merely racing — the 1 s wait below always +/// timed out, and every non-DualSense identity silently enumerated with the DualSense VID/PID **and +/// the DualSense report descriptor**. For the Deck that meant Windows parsed the 64-byte +/// `ID_CONTROLLER_DECK_STATE` frame as DualSense report `0x01`: `LX = report[1] = 0x00` (stick hard +/// left), `LY = report[2] = 0x09` (hard up) and a d-pad hat of 0 (UP held) — the "stuck stick/button" +/// a Steam Deck client saw on a Windows host. +/// +/// [`PNP_DEVTYPE`] closes it: the devnode's hardware ids carry the identity and are readable at +/// `EvtDeviceAdd`, before anything is asked. The section stays authoritative once attached (same +/// host wrote both), and the bounded wait survives only for a devnode whose ids matched nothing. fn device_type() -> u8 { if let Some(view) = CHANNEL.data() { let t = view.read_u8(OFF_DEVICE_TYPE); LAST_DEVTYPE.store(t as u32, Ordering::Relaxed); return t; } + let pnp = PNP_DEVTYPE.load(Ordering::Relaxed); + if pnp != u32::MAX { + return pnp as u8; + } if !DEVTYPE_WAITED.swap(true, Ordering::SeqCst) { let cfg = channel_cfg(); for _ in 0..100 { diff --git a/packaging/windows/drivers/pf-umdf-util/src/wdf.rs b/packaging/windows/drivers/pf-umdf-util/src/wdf.rs index b3a583b9..d72f11c8 100644 --- a/packaging/windows/drivers/pf-umdf-util/src/wdf.rs +++ b/packaging/windows/drivers/pf-umdf-util/src/wdf.rs @@ -13,6 +13,9 @@ const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS; /// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (the const isn't re-exported at the /// wdk_sys root; the value is stable WDM). const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10; +/// DEVICE_REGISTRY_PROPERTY: DevicePropertyHardwareID — the devnode's `REG_MULTI_SZ` hardware-id +/// list (same caveat: stable WDM value, not re-exported). +const DEVICE_PROPERTY_HARDWARE_ID: i32 = 1; #[inline] fn nt_success(s: NTSTATUS) -> bool { @@ -206,3 +209,58 @@ pub unsafe fn query_location_index(device: WDFDEVICE) -> u32 { } if any { idx } else { 0 } } + +/// Read the devnode's hardware-id list (`pszzHardwareIds`) as one lowercase ASCII string, ids +/// separated by `;` (e.g. `"pf_steamdeck;usb\\vid_28de&pid_1205&rev_0100&mi_02;…"`). Empty if the +/// property is absent. +/// +/// This is the ONE identity surface available at `EvtDeviceAdd` — before hidclass asks for the +/// report descriptor and attributes, and long before the sealed channel can deliver (delivery goes +/// through the HID device interface, which only exists once those very queries are answered). A +/// driver that must know *which* device it is at descriptor time has to read it here. +/// +/// # Safety +/// `device` must be the live `WDFDEVICE` created in the current `EvtDeviceAdd`. +pub unsafe fn query_hardware_ids(device: WDFDEVICE) -> String { + let mut mem: wdk_sys::WDFMEMORY = core::ptr::null_mut(); + // SAFETY: `device` is live per this fn's contract; property = HardwareID; pool ignored in UMDF; + // `mem` receives the handle (device-parented — the framework frees it at device teardown). + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceAllocAndQueryProperty, + device, + DEVICE_PROPERTY_HARDWARE_ID, + 0, + WDF_NO_OBJECT_ATTRIBUTES, + &mut mem + ) + }; + if !nt_success(st) || mem.is_null() { + return String::new(); + } + let mut len: usize = 0; + // SAFETY: `mem` is the valid memory object just allocated; `len` receives its size. + let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) } + as *const u16; + if buf.is_null() { + return String::new(); + } + // SAFETY: `buf` is valid for `len` bytes per `WdfMemoryGetBuffer`; we read exactly `len / 2` u16. + let chars = unsafe { core::slice::from_raw_parts(buf, len / 2) }; + // REG_MULTI_SZ: NUL-separated, double-NUL-terminated. Ids are ASCII in practice; anything else + // is dropped rather than lossily transliterated (this string is only ever substring-matched). + let mut out = String::with_capacity(chars.len()); + for &c in chars { + match c { + 0 => { + if out.ends_with(';') || out.is_empty() { + break; // the terminating second NUL + } + out.push(';'); + } + 0x20..=0x7E => out.push((c as u8 as char).to_ascii_lowercase()), + _ => {} + } + } + out +}