fix(gamepad/windows): a pad enumerates as the controller it IS, not always a DualSense

A Steam Deck client streaming to a Windows host had a stuck stick and a stuck
d-pad. The virtual pad was enumerating with the DualSense VID/PID **and the
DualSense report descriptor**, so Windows parsed the 64-byte Deck 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 — which is UP, held forever.

The driver picked its identity from `device_type` in the sealed section, but
hidclass asks for the descriptors and attributes while it STARTS the device, and
the section can only be delivered over the HID device interface — which does not
exist until those queries are answered. The channel was structurally unavailable
at the only moment it was needed, so `device_type()`'s bounded wait always timed
out and every identity fell back to DualSense. Not a race: DualShock 4 and the
Edge enumerated as DualSenses too (verified on .173 — both report 054C:0CE6 with
a 64-byte DualSense input report, while their on-demand strings read correctly).

The devnode's own hardware ids carry the identity and are readable at
EvtDeviceAdd, before anything is asked, so resolve it there. The section stays
authoritative once attached; the old wait survives only for a devnode whose ids
match nothing.

`hwid_devtype_table_matches_the_driver` pins the host's hwid → device_type
mapping against the driver's table, including the ordering trap that
`pf_dualsense` is a prefix of `pf_dualsenseedge`.
This commit is contained in:
2026-07-30 23:40:35 +02:00
parent 5742ec9548
commit 00c29f82f2
3 changed files with 206 additions and 14 deletions
@@ -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();
+69 -14
View File
@@ -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<u8> {
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 {
@@ -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
}