From c21549c1364a29137aa9f878e8d0a04d74f693e6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 2 Jul 2026 16:33:31 +0000 Subject: [PATCH] feat(host/windows,drivers): gamepad driver attach/heartbeat health surfaced in logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gamepad drivers have no IOCTL plane (hidclass gates the stack), so until now the host had ZERO visibility into whether a driver ever bound: a pad could be "created" with no driver installed and nothing was logged. Two health fields are carved from reserved shm space (layout-compatible; pf-driver-proto pins the offsets): driver_proto — stamped by pf-xusb at device add + per serviced XInput IOCTL (movement = the game-visible path) and by pf-dualsense/DS4 from its ~125Hz timer — and driver_heartbeat. Host-side, every pad owns a DriverAttach watcher fed from the existing service() poll: INFO on attach (WARN on proto mismatch), and after 3s of silence ONE diagnosis WARN combining a cached pnputil /enum-drivers store check, the devnode's CM problem code (CM_Locate_DevNodeW/CM_Get_DevNode_Status on the instance id now captured from the create callback, with plain-language hints: 28 = not installed, 52 = signature/Memory Integrity, …) and the driver's debug log path. Also fixes a real bug both SwDeviceCreate wrappers shared: the 10s WaitForSingleObject result was ignored and the callback HRESULT zero-initialised, so a PnP timeout read as SUCCESS (now E_FAIL init + explicit timeout error). Failure-mode table: design/gamepad-driver-health.md. Linux workspace green; Windows host + drivers CI-compile only, on-box recipe at the bottom of the design doc. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 9 + crates/pf-driver-proto/src/lib.rs | 29 ++- .../src/inject/windows/dualsense_windows.rs | 80 +++++-- .../src/inject/windows/dualshock4_windows.rs | 35 ++- .../src/inject/windows/gamepad_raii.rs | 207 ++++++++++++++++++ .../src/inject/windows/gamepad_windows.rs | 76 +++++-- design/gamepad-driver-health.md | 65 ++++++ .../windows/drivers/pf-dualsense/src/lib.rs | 16 +- packaging/windows/drivers/pf-xusb/src/lib.rs | 29 ++- 9 files changed, 495 insertions(+), 51 deletions(-) create mode 100644 design/gamepad-driver-health.md diff --git a/CLAUDE.md b/CLAUDE.md index 98c26e6..7cd3e89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc boundary, and finished captures are saved as on-disk recordings (`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page (recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.* + **Web-console log view** (`log_capture.rs`): a `tracing` layer tees DEBUG-and-up (independent of + `RUST_LOG`) into a 4096-entry in-memory ring, served cursor-paged at `GET /api/v1/logs` + (bearer-only) → the console's **Logs** page (follow/pause · level filter · search). The Windows + gamepad drivers now stamp attach/heartbeat marks into their shm sections and the host's + `DriverAttach` watcher turns silence into a one-shot diagnosis WARN (driver-store check + CM + devnode problem code) — failure-mode table: [`design/gamepad-driver-health.md`](design/gamepad-driver-health.md). + The Android client gained Settings → **Connected controllers** (device list + VID/PID + resolved + pad type + live input test) for the client end of the same chain. *Log view + driver health: + Linux-tested; Windows/Android sides CI/device-validation pending.* - **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM** diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 065edef..13b09a3 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -311,6 +311,13 @@ pub mod gamepad { /// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity). pub const DEVTYPE_DUALSHOCK4: u8 = 1; + /// 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 section starts zeroed, so `0` always means "no driver has attached (yet)"; a pre-health + /// driver never writes the field and reads as not-attached, which the host log line calls out + /// (the remedy is the same: reinstall the drivers). Bump on a gamepad-layout change. + pub const GAMEPAD_PROTO_VERSION: u32 = 1; + /// `Global\pfxusb-shm-` — the virtual Xbox 360 (XInput) shared section. pub fn xusb_shm_name(index: u8) -> String { alloc::format!("Global\\pfxusb-shm-{index}") @@ -342,7 +349,14 @@ pub mod gamepad { pub rumble_seq: u32, pub rumble_large: u8, pub rumble_small: u8, - pub _reserved1: [u8; 34], + pub _pad0: [u8; 2], + /// Written by the driver when it binds (device add) and on every serviced IOCTL: + /// [`GAMEPAD_PROTO_VERSION`]. `0` = no driver attached — the host health check keys off it. + pub driver_proto: u32, + /// Bumped by the driver on every serviced XInput IOCTL — proves the game-visible path (it + /// only advances while something polls the slot, so a static value is not an error). + pub driver_heartbeat: u32, + pub _reserved1: [u8; 24], } /// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID @@ -363,7 +377,14 @@ pub mod gamepad { pub output: [u8; 64], /// HID identity selector — see [`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]. pub device_type: u8, - pub _reserved1: [u8; 115], + pub _pad0: [u8; 3], + /// Written by the driver's timer while it has the section mapped: [`GAMEPAD_PROTO_VERSION`]. + /// `0` = no driver attached — the host health check keys off it. + pub driver_proto: u32, + /// Bumped by the driver's ~125 Hz timer each tick — a true liveness heartbeat (unlike the + /// XUSB one, this advances whenever the driver is loaded, game or not). + pub driver_heartbeat: u32, + pub _reserved1: [u8; 104], } // Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing @@ -385,6 +406,8 @@ pub mod gamepad { assert!(offset_of!(XusbShm, rumble_seq) == 24); assert!(offset_of!(XusbShm, rumble_large) == 28); assert!(offset_of!(XusbShm, rumble_small) == 29); + assert!(offset_of!(XusbShm, driver_proto) == 32); + assert!(offset_of!(XusbShm, driver_heartbeat) == 36); assert!(size_of::() == 256); assert!(offset_of!(PadShm, magic) == 0); @@ -392,6 +415,8 @@ pub mod gamepad { assert!(offset_of!(PadShm, out_seq) == 72); assert!(offset_of!(PadShm, output) == 76); assert!(offset_of!(PadShm, device_type) == 140); + assert!(offset_of!(PadShm, driver_proto) == 144); + assert!(offset_of!(PadShm, driver_heartbeat) == 148); }; } diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index d7d080c..480229f 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -29,7 +29,7 @@ use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, }; -use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0}; use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; /// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset @@ -47,6 +47,8 @@ pub(super) const OFF_OUTPUT: usize = /// DualSense (the default — the section is zeroed), 1 = DualShock 4. pub(super) const OFF_DEVTYPE: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type); +pub(super) const OFF_DRIVER_PROTO: usize = + core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto); pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4; /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_` software devnode (the driver @@ -58,16 +60,20 @@ struct DsWinPad { _sw: Option, /// The named shared section the driver maps (RAII — unmapped + closed on drop). shm: super::gamepad_raii::Shm, + /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. + attach: super::gamepad_raii::DriverAttach, seq: u8, ts: u32, last_out_seq: u32, } -/// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports. +/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports, +/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). #[repr(C)] struct SwCreateCtx { event: HANDLE, result: HRESULT, + instance_id: [u16; 128], } /// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the @@ -76,18 +82,35 @@ unsafe extern "system" fn sw_create_cb( _dev: HSWDEVICE, result: HRESULT, ctx: *const c_void, - _id: PCWSTR, + id: PCWSTR, ) { if !ctx.is_null() { - // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback. + // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the + // creator blocks on the event). `id` is a NUL-terminated string for the callback's duration. unsafe { let c = ctx as *mut SwCreateCtx; (*c).result = result; + if !id.is_null() { + for i in 0..(*c).instance_id.len() - 1 { + let ch = *id.0.add(i); + (*c).instance_id[i] = ch; + if ch == 0 { + break; + } + } + } let _ = SetEvent((*c).event); } } } +impl SwCreateCtx { + fn instance_id(&self) -> Option { + let len = self.instance_id.iter().position(|&c| c == 0)?; + (len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len])) + } +} + /// The PnP identity for a virtual controller devnode — varies by controller type so the same /// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4 /// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below. @@ -131,7 +154,7 @@ pub(super) struct SwDeviceProfile<'a> { /// (hence `punktfunk`, not `pf_dualsense`), and the completion callback is mandatory (the docs mark /// `pCallback` as `[in]`, not optional — a NULL callback is rejected). The caller must be /// Administrator (the host service runs as LocalSystem). -pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result { +pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option)> { // Build a double-NUL-terminated UTF-16 multi-sz from a list of ids. let multi_sz = |ids: &[&str]| -> Vec { ids.iter() @@ -189,9 +212,12 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result { // SAFETY: a manual-reset, initially-unsignaled, unnamed event. let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? }; + // `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT + // would read as success and mask the failure (found by the 2026-07 driver-health audit). let mut ctx = SwCreateCtx { event, - result: HRESULT(0), + result: E_FAIL, + instance_id: [0; 128], }; // SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning); // windows-rs returns the HSWDEVICE (the C out-param) as the Result value. @@ -216,10 +242,18 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result { }; // Block until PnP finishes enumerating (the callback signals), then check its result. // SAFETY: event is valid. + let wait = unsafe { WaitForSingleObject(event, 10_000) }; + // SAFETY: event is valid. unsafe { - WaitForSingleObject(event, 10_000); let _ = CloseHandle(event); } + if wait != WAIT_OBJECT_0 { + // SAFETY: hsw is the handle SwDeviceCreate returned. + unsafe { SwDeviceClose(hsw) }; + return Err(anyhow!( + "SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged" + )); + } if ctx.result.is_err() { // SAFETY: hsw is the handle SwDeviceCreate returned. unsafe { SwDeviceClose(hsw) }; @@ -228,7 +262,7 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result { ctx.result )); } - Ok(hsw) + Ok((hsw, ctx.instance_id())) } impl DsWinPad { @@ -236,10 +270,8 @@ impl DsWinPad { /// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives /// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`). fn open(index: u8) -> Result { - let shm = super::gamepad_raii::Shm::create( - &HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)), - SHM_SIZE, - )?; + let shm_name = pf_driver_proto::gamepad::pad_shm_name(index); + let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; let base = shm.base(); // Stamp the neutral input report, then the magic LAST (the driver only accepts the section // once magic is set). The device-type stays 0 (DualSense — the section is already zeroed). @@ -256,23 +288,30 @@ impl DsWinPad { // rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense` // devnode (installer / dev-box devgen). let inst = format!("pf_pad_{index}"); - let hsw = match create_swdevice(&SwDeviceProfile { + let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { instance: &inst, container_index: index, hwid: "pf_dualsense", usb_vid_pid: "VID_054C&PID_0CE6", description: "punktfunk Virtual DualSense", }) { - Ok(h) => Some(h), + Ok((h, id)) => (Some(h), id), Err(e) => { tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode"); - None + (None, None) } }; let _sw = hsw.map(super::gamepad_raii::SwDevice::new); Ok(DsWinPad { _sw, shm, + attach: super::gamepad_raii::DriverAttach::new( + "pf_dualsense", + "pf_dualsense.inf", + "C:\\Users\\Public\\pfds-driver.log", + shm_name, + instance_id, + ), seq: 0, ts: 0, last_out_seq: 0, @@ -292,10 +331,17 @@ impl DsWinPad { } /// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a - /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new. + /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything + /// new. Also feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps + /// `driver_proto` while it has the section mapped). fn service(&mut self, pad: u8) -> DsFeedback { let mut fb = DsFeedback::default(); // SAFETY: base points at SHM_SIZE bytes. + let proto = unsafe { + std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32) + }; + self.attach.observe(proto); + // SAFETY: base points at SHM_SIZE bytes. let seq = unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) }; if seq != self.last_out_seq { @@ -471,7 +517,7 @@ impl DualSenseWindowsManager { self.last_write[idx] = Instant::now(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled"); + tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); self.broken = true; } } diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index 802f1c2..20d26a1 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -9,8 +9,8 @@ use super::dualsense_proto::DsState; use super::dualsense_windows::{ - create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_INPUT, OFF_OUTPUT, - OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE, + create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, + OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE, }; use super::dualshock4_proto::{ parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, @@ -28,6 +28,8 @@ struct Ds4WinPad { _sw: Option, /// The named shared section the driver maps (RAII — unmapped + closed on drop). shm: super::gamepad_raii::Shm, + /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. + attach: super::gamepad_raii::DriverAttach, counter: u8, ts: u16, last_out_seq: u32, @@ -37,10 +39,8 @@ impl Ds4WinPad { /// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic, /// then spawn the `pf_ds4_` devnode (the driver loads on it and maps the section). fn open(index: u8) -> Result { - let shm = super::gamepad_raii::Shm::create( - &HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)), - SHM_SIZE, - )?; + let shm_name = pf_driver_proto::gamepad::pad_shm_name(index); + let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; let base = shm.base(); // device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST. // SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range. @@ -54,23 +54,30 @@ impl Ds4WinPad { std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); } let inst = format!("pf_ds4_{index}"); - let hsw = match create_swdevice(&SwDeviceProfile { + let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { instance: &inst, container_index: index, hwid: "pf_dualshock4", usb_vid_pid: "VID_054C&PID_09CC", description: "punktfunk Virtual DualShock 4", }) { - Ok(h) => Some(h), + Ok((h, id)) => (Some(h), id), Err(e) => { tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable"); - None + (None, None) } }; let _sw = hsw.map(super::gamepad_raii::SwDevice::new); Ok(Ds4WinPad { _sw, shm, + attach: super::gamepad_raii::DriverAttach::new( + "pf_dualshock4", + "pf_dualsense.inf", // one driver package serves both HID identities + "C:\\Users\\Public\\pfds-driver.log", + shm_name, + instance_id, + ), counter: 0, ts: 0, last_out_seq: 0, @@ -90,10 +97,16 @@ impl Ds4WinPad { } /// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a - /// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. + /// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also + /// feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps `driver_proto`). fn service(&mut self) -> Ds4Feedback { let mut fb = Ds4Feedback::default(); // SAFETY: base points at SHM_SIZE bytes. + let proto = unsafe { + std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32) + }; + self.attach.observe(proto); + // SAFETY: base points at SHM_SIZE bytes. let seq = unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) }; if seq != self.last_out_seq { @@ -272,7 +285,7 @@ impl DualShock4WindowsManager { self.last_write[idx] = Instant::now(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled"); + tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); self.broken = true; } } diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs index 8f05ffb..86bec12 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs @@ -9,7 +9,13 @@ use anyhow::{anyhow, Result}; use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; use windows::core::{w, HSTRING, PCWSTR}; +use windows::Win32::Devices::DeviceAndDriverInstallation::{ + CM_Get_DevNode_Status, CM_Locate_DevNodeW, CM_DEVNODE_STATUS_FLAGS, CM_LOCATE_DEVNODE_NORMAL, + CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED, +}; use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::Win32::Foundation::INVALID_HANDLE_VALUE; use windows::Win32::Security::Authorization::{ @@ -121,3 +127,204 @@ impl Drop for SwDevice { unsafe { SwDeviceClose(self.0) }; } } + +// ── Driver health surfacing ───────────────────────────────────────────────────────────────────── +// +// The gamepad drivers have no IOCTL plane (hidclass gates the stack), so the only cross-process +// signal is the shared section itself. The drivers stamp `driver_proto` into their section once +// attached (pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION); [`DriverAttach`] watches that field +// from the host's regular pump and turns silence into actionable WARN/ERROR log lines — the piece +// that used to be missing entirely: a pad could be "created" with no driver installed and nothing +// was ever logged until the user gave up ("host doesn't see my controller" bug reports). + +/// How long to give PnP to bind the driver + the driver to stamp the section before warning. +const ATTACH_GRACE: Duration = Duration::from_secs(3); + +/// Per-pad driver-attach watcher: feed it the section's `driver_proto` on every service tick; it +/// logs the attach (INFO), a version mismatch (WARN), or — after [`ATTACH_GRACE`] of silence — one +/// diagnosis WARN combining the driver-store check and the devnode problem code. States never +/// repeat their log line, so the pump can call this at full rate. +pub(super) struct DriverAttach { + /// Driver label for log lines (`pf_xusb` / `pf_dualsense` / `pf_dualshock4`). + driver: &'static str, + /// The INF the driver store must hold for this driver (`pf_xusb.inf` / `pf_dualsense.inf`). + inf: &'static str, + /// The driver's own debug log, referenced in the diagnosis line. + driver_log: &'static str, + /// Section name, for log lines. + shm_name: String, + /// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path). + instance_id: Option, + created: Instant, + state: AttachState, +} + +enum AttachState { + Waiting, + /// Diagnosis logged; still watching so a late attach gets its INFO line. + Warned, + Attached, +} + +impl DriverAttach { + pub(super) fn new( + driver: &'static str, + inf: &'static str, + driver_log: &'static str, + shm_name: String, + instance_id: Option, + ) -> DriverAttach { + DriverAttach { + driver, + inf, + driver_log, + shm_name, + instance_id, + created: Instant::now(), + state: AttachState::Waiting, + } + } + + /// `driver_proto` is the section field the driver stamps once attached (0 = not attached). + pub(super) fn observe(&mut self, driver_proto: u32) { + match self.state { + AttachState::Attached => {} + AttachState::Waiting | AttachState::Warned if driver_proto != 0 => { + let late = matches!(self.state, AttachState::Warned); + tracing::info!( + driver = self.driver, + shm = %self.shm_name, + proto = driver_proto, + late, + "gamepad driver attached to the shared section" + ); + if driver_proto != pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION { + tracing::warn!( + driver = self.driver, + driver_proto, + host_proto = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION, + "gamepad driver/host protocol mismatch — update the drivers: punktfunk-host.exe driver install --gamepad" + ); + } + self.state = AttachState::Attached; + } + AttachState::Waiting if self.created.elapsed() >= ATTACH_GRACE => { + self.diagnose(); + self.state = AttachState::Warned; + } + _ => {} + } + } + + /// One-shot WARN with everything the host can find out about WHY the driver isn't attached: + /// driver-store presence, the devnode's PnP status/problem code, and where to look next. + fn diagnose(&self) { + let store = match driver_store_has(self.inf) { + Some(true) => "driver package present in the driver store", + Some(false) => { + "driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad" + } + None => "driver store could not be queried (pnputil failed)", + }; + let devnode = match &self.instance_id { + Some(id) => devnode_status_line(id), + None => { + "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)" + .to_string() + } + }; + tracing::warn!( + driver = self.driver, + shm = %self.shm_name, + grace_secs = ATTACH_GRACE.as_secs(), + store, + devnode = %devnode, + driver_log = self.driver_log, + "gamepad driver has not attached to the shared section — the virtual pad exists but no \ + driver is serving it (games will not see it); an old (pre-health) driver also reads as \ + not-attached: update with punktfunk-host.exe driver install --gamepad" + ); + } +} + +/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only +/// consulted on the failure path, so the one-off subprocess cost never hits a healthy session. +fn driver_store_inventory() -> &'static str { + static INV: OnceLock = OnceLock::new(); + INV.get_or_init(|| { + std::process::Command::new("pnputil") + .arg("/enum-drivers") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).to_ascii_lowercase()) + .unwrap_or_default() + }) +} + +/// Whether the driver store holds `inf` (e.g. `pf_xusb.inf`). `None` = pnputil unavailable/failed. +fn driver_store_has(inf: &str) -> Option { + let inv = driver_store_inventory(); + if inv.is_empty() { + return None; + } + Some(inv.contains(&inf.to_ascii_lowercase())) +} + +/// Human-readable PnP status of a devnode: driver bound/started or the CM problem code with a hint. +fn devnode_status_line(instance_id: &str) -> String { + let wide: Vec = instance_id + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut devinst = 0u32; + // SAFETY: `wide` is a valid NUL-terminated UTF-16 instance id; `devinst` receives the handle. + let cr = unsafe { + CM_Locate_DevNodeW( + &mut devinst, + PCWSTR(wide.as_ptr()), + CM_LOCATE_DEVNODE_NORMAL, + ) + }; + if cr != CR_SUCCESS { + return format!( + "devnode {instance_id} not found (CM_Locate_DevNodeW CR={})", + cr.0 + ); + } + let mut status = CM_DEVNODE_STATUS_FLAGS(0); + let mut problem = CM_PROB(0); + // SAFETY: devinst is the devnode located above; the two out-params receive status + problem. + let cr = unsafe { CM_Get_DevNode_Status(&mut status, &mut problem, devinst, 0) }; + if cr != CR_SUCCESS { + return format!("devnode {instance_id}: status query failed (CR={})", cr.0); + } + if status.0 & DN_HAS_PROBLEM.0 != 0 { + return format!( + "devnode {instance_id} has PnP problem code {} ({}) [status 0x{:08x}]", + problem.0, + cm_problem_hint(problem.0), + status.0 + ); + } + format!( + "devnode {instance_id} status 0x{:08x} (driver_loaded={} started={})", + status.0, + status.0 & DN_DRIVER_LOADED.0 != 0, + status.0 & DN_STARTED.0 != 0, + ) +} + +/// The CM_PROB_* codes a virtual-pad devnode realistically hits, with the operator-facing cause. +fn cm_problem_hint(problem: u32) -> &'static str { + match problem { + 1 => "not configured — no driver bound; install the drivers", + 10 => "device failed to start — driver bound but its start failed; check the driver log", + 18 => "reinstall required — re-run driver install", + 24 => "device not present/working — PnP could not start the virtual devnode", + 28 => "drivers not installed — the pf driver package is missing from the store or its certificate is not trusted", + 31 => "driver failed to load — binding found the package but loading it failed", + 39 => "driver corrupt or missing — reinstall the drivers", + 43 => "reported failure after start — check the driver log", + 52 => "driver signature rejected — certificate not in Root/TrustedPublisher, or blocked by Memory Integrity", + _ => "see Device Manager for this code", + } +} diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index bd2f3f3..1c5c3b5 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -21,7 +21,7 @@ use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, }; -use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0}; use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; // Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset @@ -40,12 +40,15 @@ const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx); const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry); const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq); const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29 +const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto); -/// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports. +/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports, +/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). #[repr(C)] struct SwCreateCtx { event: HANDLE, result: HRESULT, + instance_id: [u16; 128], } /// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result + wake the creator. @@ -53,24 +56,41 @@ unsafe extern "system" fn sw_create_cb( _dev: HSWDEVICE, result: HRESULT, ctx: *const c_void, - _id: PCWSTR, + id: PCWSTR, ) { if !ctx.is_null() { - // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback. + // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the + // creator blocks on the event). `id` is a NUL-terminated string for the callback's duration. unsafe { let c = ctx as *mut SwCreateCtx; (*c).result = result; + if !id.is_null() { + for i in 0..(*c).instance_id.len() - 1 { + let ch = *id.0.add(i); + (*c).instance_id[i] = ch; + if ch == 0 { + break; + } + } + } let _ = SetEvent((*c).event); } } } +impl SwCreateCtx { + fn instance_id(&self) -> Option { + let len = self.instance_id.iter().position(|&c| c == 0)?; + (len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len])) + } +} + /// Spawn the `pf_xusb_` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The /// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads, /// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but /// we still pass a deterministic non-null `pContainerId` (the null-sentinel trips an `xinput1_4` /// slot-skip bug). `SwDeviceClose` removes it on drop. -fn create_swdevice(index: u8) -> Result { +fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option)> { let hwids: Vec = "pf_xusb".encode_utf16().chain([0u16, 0u16]).collect(); let instid: Vec = format!("pf_xusb_{index}") .encode_utf16() @@ -100,9 +120,12 @@ fn create_swdevice(index: u8) -> Result { // SAFETY: a manual-reset, initially-unsignaled, unnamed event. let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? }; + // `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT + // would read as success and mask the failure (found by the 2026-07 driver-health audit). let mut ctx = SwCreateCtx { event, - result: HRESULT(0), + result: E_FAIL, + instance_id: [0; 128], }; // SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning). let hsw = match unsafe { @@ -125,10 +148,18 @@ fn create_swdevice(index: u8) -> Result { } }; // SAFETY: event valid; block until PnP finishes enumerating, then check the callback result. + let wait = unsafe { WaitForSingleObject(event, 10_000) }; + // SAFETY: event is valid. unsafe { - WaitForSingleObject(event, 10_000); let _ = CloseHandle(event); } + if wait != WAIT_OBJECT_0 { + // SAFETY: hsw is the handle SwDeviceCreate returned. + unsafe { SwDeviceClose(hsw) }; + return Err(anyhow!( + "SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged" + )); + } if ctx.result.is_err() { // SAFETY: hsw is the handle SwDeviceCreate returned. unsafe { SwDeviceClose(hsw) }; @@ -137,7 +168,7 @@ fn create_swdevice(index: u8) -> Result { ctx.result )); } - Ok(hsw) + Ok((hsw, ctx.instance_id())) } /// A single virtual Xbox 360 pad: the `pf_xusb_` devnode plus the mapped shared section. @@ -146,6 +177,8 @@ struct XusbWinPad { _sw: Option, /// Owns `Global\pfxusb-shm-` (the section + its mapped view; drop unmaps + closes). shm: super::gamepad_raii::Shm, + /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. + attach: super::gamepad_raii::DriverAttach, packet: u32, last_rumble_seq: u32, } @@ -155,10 +188,8 @@ impl XusbWinPad { fn open(index: u8) -> Result { // Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the // section handle + its mapped view (zero-filled) and unmaps/closes on drop. - let shm = super::gamepad_raii::Shm::create( - &HSTRING::from(pf_driver_proto::gamepad::xusb_shm_name(index)), - SHM_SIZE, - )?; + let shm_name = pf_driver_proto::gamepad::xusb_shm_name(index); + let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; let base = shm.base(); // Zero the section then stamp the magic LAST (the driver only accepts it once magic is set). // SAFETY: base points at SHM_SIZE writable bytes. @@ -166,17 +197,24 @@ impl XusbWinPad { std::ptr::write_bytes(base, 0, SHM_SIZE); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); } - let hsw = match create_swdevice(index) { - Ok(h) => Some(h), + let (hsw, instance_id) = match create_swdevice(index) { + Ok((h, id)) => (Some(h), id), Err(e) => { tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; XUSB devnode unavailable"); - None + (None, None) } }; let _sw = hsw.map(super::gamepad_raii::SwDevice::new); Ok(XusbWinPad { _sw, shm, + attach: super::gamepad_raii::DriverAttach::new( + "pf_xusb", + "pf_xusb.inf", + "C:\\Users\\Public\\pfxusb-driver.log", + shm_name, + instance_id, + ), packet: 0, last_rumble_seq: 0, }) @@ -204,10 +242,14 @@ impl XusbWinPad { } /// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns - /// `(large, small)` motor levels (0..=255) when a new one arrived. + /// `(large, small)` motor levels (0..=255) when a new one arrived. Also feeds the driver-attach + /// health watcher (the driver stamps `driver_proto` at device add + on every serviced IOCTL). fn service(&mut self) -> Option<(u8, u8)> { let base = self.shm.base(); // SAFETY: base points at SHM_SIZE bytes. + let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) }; + self.attach.observe(proto); + // SAFETY: base points at SHM_SIZE bytes. let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) }; if seq == self.last_rumble_seq { return None; @@ -257,7 +299,7 @@ impl GamepadManager { self.last_rumble[idx] = (0, 0); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)"); + tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); self.broken = true; } } diff --git a/design/gamepad-driver-health.md b/design/gamepad-driver-health.md new file mode 100644 index 0000000..31670cc --- /dev/null +++ b/design/gamepad-driver-health.md @@ -0,0 +1,65 @@ +# Windows gamepad-driver health: failure modes and how each is surfaced + +Written for the "host doesn't see the client's gamepad" class of bug report (2026-07-02). The +Windows virtual pads have many silent ways to fail: the stack spans the host process, a named +shared-memory section, a PnP software devnode, a UMDF driver in its own WUDFHost.exe, and finally +the game's input API (XInput / HID / SDL). Before this work the host logged only its *own* create +calls — a pad could "exist" with no driver installed and nothing was ever logged. This document +enumerates the failure modes and states, for each, how it is now detected and what the log line +says. All host lines land in stderr / `%ProgramData%\punktfunk\logs\host.log` (service) **and** the +in-memory ring served at `GET /api/v1/logs` → the web console **Logs** page. + +## The health signals + +The gamepad drivers have no IOCTL plane (`hidclass` gates the device stack), so the only +cross-process channel is the shared section itself. Two fields were carved out of reserved space +(layout-compatible; old drivers simply never write them, `pf-driver-proto` pins the offsets): + +| field | XusbShm | PadShm | writer | meaning | +|---|---|---|---|---| +| `driver_proto` | @32 | @144 | driver | `GAMEPAD_PROTO_VERSION` once attached; `0` = no driver on this section | +| `driver_heartbeat` | @36 | @148 | driver | XUSB: +1 per serviced XInput IOCTL (game-visible path). DS/DS4: +1 per ~8 ms timer tick (liveness) | + +Host side, every pad owns a `DriverAttach` watcher (`inject/windows/gamepad_raii.rs`), fed from the +existing `service()` poll. State machine, each transition logs exactly once: + +- `driver_proto != 0` → INFO `gamepad driver attached to the shared section` (with `late=true` if + it came after the warning); WARN on a proto/host version mismatch. +- 3 s of silence → one diagnosis WARN combining: **driver-store check** (`pnputil /enum-drivers`, + cached once per process, only run on the failure path), **devnode PnP status** (`CM_Locate_DevNodeW` + + `CM_Get_DevNode_Status` on the instance id captured from the SwDeviceCreate callback, with a + plain-language hint per CM problem code), and the driver's own debug log path. + +## Failure modes + +| # | failure | cause examples | detection | surfaced as | +|---|---|---|---|---| +| 1 | Driver package not installed | fresh box, installer's `driver install --gamepad` skipped/failed, package pruned | attach timeout → `pnputil /enum-drivers` misses `pf_xusb.inf`/`pf_dualsense.inf` | WARN `driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad` | +| 2 | Package present but binding failed | certificate not in Root/TrustedPublisher, Memory Integrity (HVCI) rejects it, stale DriverVer kept the old binary | attach timeout → devnode problem code (28 = drivers not installed, 52 = signature rejected, 31/39 = load failure) | WARN with the CM problem code + hint | +| 3 | Driver bound but crashed / never started | WUDFHost crash, `WdfDeviceCreate`/queue failure inside the driver | attach timeout → devnode status shows `driver_loaded`/`started` flags; the driver's own log (`C:\Users\Public\pf*-driver.log`) has the failing WDF call | WARN referencing both | +| 4 | `SwDeviceCreate` fails outright | not Administrator/SYSTEM, PnP wedged, `_` in enumerator (E_INVALIDARG) | existing error path (unchanged) | WARN `SwDeviceCreate failed; … devnode unavailable`, pad continues on the out-of-band fallback | +| 5 | `SwDeviceCreate` callback never fires | PnP service hung | **was silently mis-read as success** (zero-init `HRESULT(0)` + ignored `WaitForSingleObject` return). Fixed: `result` inits to `E_FAIL`, the wait result is checked | ERROR `enumeration callback never fired (10s) — PnP may be wedged` | +| 6 | Driver attached, then WUDFHost died mid-session | crash, killed | `driver_heartbeat` freezes (DS/DS4: timer-driven, so a freeze is conclusive; XUSB: only advances while a game polls, so absence is *not* an error) | field exists for a future stall check; not auto-warned yet (XUSB semantics make a generic rule false-positive-prone) | +| 7 | Version skew host↔driver | new host + old installed driver (or vice versa) | `driver_proto` ≠ host's `GAMEPAD_PROTO_VERSION`; pre-health drivers read as never-attached | WARN `driver/host protocol mismatch — update the drivers` (mismatch) / the mode-1 diagnosis text notes the pre-health case | +| 8 | Whole backend latched off | first pad creation failed → `broken` latch disables pads for the session | existing behaviour, now with remedy text | ERROR `…controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)` | +| 9 | Section created but game can't see the pad | XInput slot ordering, HidHide-style HID filters on the game process, RPCS3 pad-handler config, GameInput's instance-path VID/PID parse | **not host-detectable** — outside our process and the driver's stack. XUSB `driver_heartbeat` advancing proves "some XInput client polls us", which brackets the problem to the game's side | diagnosis text points at the driver log; the client-side controller view (Android "Connected controllers") covers the other end of the chain | + +## What deliberately did NOT change + +- The `broken` latch stays one-way per session (retry loops against a missing driver would spam + PnP); the log line now says so and gives the remedy. +- No mgmt-API health endpoint yet — the log ring is the surfacing channel. If the web console ever + grows a "gamepad health" card, `DriverAttach` is the state to expose. +- The DS/DS4 heartbeat is not yet watched for mid-session stalls (mode 6): worth adding once the + XUSB/DS semantics split is encoded (DS freeze = conclusive, XUSB freeze = normal when no game + polls). + +## Validation status + +- Linux-side: workspace build/tests/clippy green; `pf-driver-proto` layout asserts pin the new + offsets (compile-time). +- Windows host code + both drivers: compile-checked in CI only (this box cannot cross-build the + native deps); **not yet on-box validated**. On-box test recipe: stop the service, `pnputil + /delete-driver` the gamepad package, connect a client with a pad → expect the mode-1 WARN in the + console Logs page within ~3 s of the pad arriving; reinstall drivers → expect the + `attached (late=true)` INFO on the next session. diff --git a/packaging/windows/drivers/pf-dualsense/src/lib.rs b/packaging/windows/drivers/pf-dualsense/src/lib.rs index be0654b..9aa6d9f 100644 --- a/packaging/windows/drivers/pf-dualsense/src/lib.rs +++ b/packaging/windows/drivers/pf-dualsense/src/lib.rs @@ -229,11 +229,14 @@ static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_ // UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack // (custom interface CreateFile → err 31; custom IOCTL on the HID handle → err 1) and UMDF has no // control device, so the host channel is a named section the (privileged) host CREATES and the driver -// OPENS. Layout (256 B): magic u32 @0 ("PFDS"), input_seq u32 @4, input_report[64] @8, -// output_seq u32 @72, output_report[64] @76. +// OPENS. Layout (256 B, must match pf_driver_proto::gamepad::PadShm): magic u32 @0 ("PFDS"), +// input_seq u32 @4, input_report[64] @8, output_seq u32 @72, output_report[64] @76, +// device_type u8 @140, driver_proto u32 @144 (we stamp GAMEPAD_PROTO_VERSION = the host's +// driver-attach health signal), driver_heartbeat u32 @148 (we bump per timer tick = liveness). const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" little-endian const SHM_SIZE: usize = 256; +const GAMEPAD_PROTO_VERSION: u32 = 1; // must match pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION static LOGGED_SHM: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); // kernel32 file-mapping APIs (resolved via std's kernel32 import; UMDF permits file mapping). @@ -770,6 +773,15 @@ extern "C" fn evt_timer(timer: WDFTIMER) { *g = buf; } } + // Health marks the host watches: driver_proto @144 (attach signal, idempotent) and + // driver_heartbeat @148 (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound + // and alive" apart from "driver package missing/failed to bind". + // SAFETY: view points at a mapped 256-byte section; proto @144, heartbeat @148. + unsafe { + core::ptr::write_unaligned(view.add(144) as *mut u32, GAMEPAD_PROTO_VERSION); + let hb = view.add(148) as *mut u32; + core::ptr::write_unaligned(hb, core::ptr::read_unaligned(hb).wrapping_add(1)); + } }); // SAFETY: timer valid; parent is the manual queue. let queue = diff --git a/packaging/windows/drivers/pf-xusb/src/lib.rs b/packaging/windows/drivers/pf-xusb/src/lib.rs index 4c5416f..aae578b 100644 --- a/packaging/windows/drivers/pf-xusb/src/lib.rs +++ b/packaging/windows/drivers/pf-xusb/src/lib.rs @@ -70,13 +70,16 @@ const XUSB_VERSION: u16 = 0x0103; const WdfIoQueueDispatchParallel: i32 = 2; const WdfUseDefault: i32 = 2; // WDF_TRI_STATE -// ---- shared-memory layout (host ↔ driver), must match the host's xbox_xusb_windows backend ---- +// ---- shared-memory layout (host ↔ driver), must match pf_driver_proto::gamepad::XusbShm ---- // magic u32 @0 ("PFXU"); packet u32 @4 (host bumps on state change → dwPacketNumber); the XUSB_REPORT // payload @8: wButtons u16 @8, bLeftTrigger @10, bRightTrigger @11, sThumbLX i16 @12, LY @14, RX @16, -// RY @18; rumble_seq u32 @24 (driver bumps on SET_STATE); rumble large @28, small @29. +// RY @18; rumble_seq u32 @24 (driver bumps on SET_STATE); rumble large @28, small @29; +// driver_proto u32 @32 (we stamp GAMEPAD_PROTO_VERSION = attach signal for the host's health check); +// driver_heartbeat u32 @36 (we bump per serviced IOCTL = the game-visible polling path moves). const FILE_MAP_RW: u32 = 0x0002 | 0x0004; const SHM_MAGIC: u32 = 0x5558_4650; // "PFXU" little-endian const SHM_SIZE: usize = 64; +const GAMEPAD_PROTO_VERSION: u32 = 1; // must match pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION unsafe extern "system" { fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void; @@ -234,6 +237,9 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI return st; } + // Tell the host we're alive on the section (its driver-attach health check keys off this). + touch_driver_marks(); + log("[pf-xusb] device ready (XUSB interface registered)"); STATUS_SUCCESS } @@ -285,6 +291,22 @@ fn read_state() -> (u32, u16, u8, u8, i16, i16, i16, i16) { out } +/// Stamp the driver health marks the host watches: `driver_proto` @32 (the attach signal, +/// idempotent) and `driver_heartbeat` @36 (+1). Called at device add and on every serviced IOCTL, +/// so the host can tell "driver bound and alive" apart from "driver package missing/failed to +/// bind" and see the game-visible polling path advance. No-op until the host's section exists +/// (with_shm re-opens per access, so a section created after we started still gets marked). +fn touch_driver_marks() { + with_shm(|v| { + // SAFETY: v points at a mapped SHM_SIZE section with valid magic; proto @32, heartbeat @36. + unsafe { + core::ptr::write_unaligned(v.add(32) as *mut u32, GAMEPAD_PROTO_VERSION); + let hb = v.add(36) as *mut u32; + core::ptr::write_unaligned(hb, core::ptr::read_unaligned(hb).wrapping_add(1)); + } + }); +} + /// Publish a game's rumble (from SET_STATE) into shared memory for the host to forward. fn publish_rumble(large: u8, small: u8) { with_shm(|v| { @@ -352,6 +374,9 @@ extern "C" fn evt_io_device_control( input_len: usize, ioctl: ULONG, ) { + // Health marks first: attach signal + heartbeat (also covers a section the host created after + // this device started — the marks land on the next XInput poll). + touch_driver_marks(); let status: NTSTATUS = match ioctl { IOCTL_XUSB_GET_INFORMATION => copy_to_output(request, &build_information()), IOCTL_XUSB_GET_INFORMATION_EX => {