diff --git a/.gitea/workflows/windows-drivers.yml b/.gitea/workflows/windows-drivers.yml index 45cc2542..5f4a9185 100644 --- a/.gitea/workflows/windows-drivers.yml +++ b/.gitea/workflows/windows-drivers.yml @@ -153,9 +153,9 @@ jobs: # `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` + # `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a # toolchain-only probe crate and is excluded.) - run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings - - name: cargo fmt --check the safe-layer + gamepad drivers - run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check + run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings + - name: cargo fmt --check the safe-layer + gamepad/mouse drivers + run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build run: | # explicit --target (.cargo/config.toml) -> output under the triple subdir. diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index a4b4cd91..53245f39 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -765,6 +765,106 @@ pub mod gamepad { }; } +/// Virtual-pointer shared-memory layout (host ↔ the UMDF HID-mouse minidriver `pf_mouse`). +/// +/// Why a virtual mouse exists at all: with no pointing device present (a headless Windows host — +/// no dongle attached), win32k reports the cursor as absent (`SM_MOUSEPRESENT` = 0) and DWM never +/// composites a cursor into the pf-vdisplay frame, so a streamed desktop has an invisible pointer +/// even though `SendInput` moves it. A resident HID mouse devnode makes Windows always consider a +/// pointer present — the Sunshine/Parsec-class fix. Injection stays `SendInput`; the report path +/// below exists for validation (`vmouse-spike`) and as the future higher-fidelity route. +/// +/// The channel is the **sealed pad channel** verbatim (`design/gamepad-channel-sealing.md`): the +/// same [`gamepad::PadBootstrap`] mailbox handshake (and therefore the same +/// [`gamepad::GAMEPAD_PROTO_VERSION`] lockstep), a mouse-specific mailbox name +/// ([`mouse_boot_name`]) and DATA magic, and `pad_index` validation (a single resident mouse = +/// index 0). Reusing the handshake means `pf-umdf-util`'s audited `ChannelClient`/`PadChannel` +/// serve the mouse unchanged. +pub mod mouse { + use alloc::string::String; + use bytemuck::{Pod, Zeroable}; + + /// Mouse DATA-section magic ("PFMO" LE) — distinct from the pad magics so a cross-wired + /// delivery fails validation. + pub const MOUSE_MAGIC: u32 = 0x4F4D_4650; + + /// `Global\pfmouse-boot-` — the virtual mouse's bootstrap mailbox + /// ([`crate::gamepad::PadBootstrap`]). + pub fn mouse_boot_name(index: u8) -> String { + alloc::format!("Global\\pfmouse-boot-{index}") + } + + /// HID identity both sides report/expect ("PF" / "MO" — an obviously-virtual identity; no + /// software matches on it, unlike the pads' cloned Sony/Valve ids). + pub const MOUSE_VID: u16 = 0x5046; + pub const MOUSE_PID: u16 = 0x4D4F; + pub const MOUSE_VER: u16 = 0x0100; + + /// The one input report (id `0x01`): `[id, buttons(5 bits), x_lo, x_hi, y_lo, y_hi, wheel, + /// pan]` — absolute X/Y over `0..=`[`MOUSE_ABS_MAX`], relative wheel/pan. + pub const MOUSE_REPORT_ID: u8 = 0x01; + pub const MOUSE_REPORT_LEN: usize = 8; + /// Logical maximum of the absolute X/Y axes (15-bit, the HID-descriptor convention). + pub const MOUSE_ABS_MAX: u16 = 0x7FFF; + + /// Build the 8-byte input report. Pure so the byte layout is unit-tested on every dev machine + /// (the driver workspace is `panic = "abort"` and hosts no test harness); the driver only + /// ferries these bytes, it never builds them. + #[must_use] + pub fn input_report(buttons: u8, x: u16, y: u16, wheel: i8, pan: i8) -> [u8; MOUSE_REPORT_LEN] { + let x = x.min(MOUSE_ABS_MAX); + let y = y.min(MOUSE_ABS_MAX); + [ + MOUSE_REPORT_ID, + buttons & 0x1F, + (x & 0xFF) as u8, + (x >> 8) as u8, + (y & 0xFF) as u8, + (y >> 8) as u8, + wheel as u8, + pan as u8, + ] + } + + /// Virtual-mouse shared section (64 B). The host writes an input report then bumps `in_seq` + /// (Release); the driver's timer Acquire-loads `in_seq` and completes a pended `READ_REPORT` + /// with the fresh report — event-driven like a real mouse, so an idle section generates NO + /// HID traffic (a constant report stream would read as user activity to the OS). + #[repr(C)] + #[derive(Clone, Copy, Pod, Zeroable, Debug)] + pub struct MouseShm { + pub magic: u32, + /// Bumped by the host AFTER `report` is in place (Release) — the driver's new-input + /// trigger. `0` = nothing published yet. + pub in_seq: u32, + /// The latest HID input report (id [`MOUSE_REPORT_ID`], [`MOUSE_REPORT_LEN`] bytes). + pub report: [u8; MOUSE_REPORT_LEN], + /// Written by the driver's timer while attached: [`crate::gamepad::GAMEPAD_PROTO_VERSION`] + /// (the mouse channel rides the gamepad handshake). `0` = no driver attached — the host + /// health check keys off it. + pub driver_proto: u32, + /// Bumped by the driver's timer each tick — liveness (advances whether or not input flows). + pub driver_heartbeat: u32, + /// The device index this section serves (host-stamped before the magic; the driver + /// validates it against its devnode Location — same fail-closed check as the pads). + pub pad_index: u32, + pub _reserved: [u8; 36], + } + + // Offsets are the cross-process wire contract — pin every one (same discipline as `gamepad`). + const _: () = { + use core::mem::{offset_of, size_of}; + + assert!(size_of::() == 64); + assert!(offset_of!(MouseShm, magic) == 0); + assert!(offset_of!(MouseShm, in_seq) == 4); + assert!(offset_of!(MouseShm, report) == 8); + assert!(offset_of!(MouseShm, driver_proto) == 16); + assert!(offset_of!(MouseShm, driver_heartbeat) == 20); + assert!(offset_of!(MouseShm, pad_index) == 24); + }; +} + #[cfg(test)] mod tests { use super::*; @@ -1041,6 +1141,25 @@ mod tests { assert!((360..=440).contains(&back), "min decoded {back} millinits"); } + #[test] + fn mouse_report_and_names_are_stable() { + assert_eq!(mouse::mouse_boot_name(0), "Global\\pfmouse-boot-0"); + // "PFMO" little-endian, and never colliding with a pad magic (cross-wire validation). + assert_eq!(mouse::MOUSE_MAGIC.to_le_bytes(), *b"PFMO"); + assert_ne!(mouse::MOUSE_MAGIC, gamepad::XUSB_MAGIC); + assert_ne!(mouse::MOUSE_MAGIC, gamepad::PAD_MAGIC); + // The 8-byte report layout the driver ferries and the host builds. + let r = mouse::input_report(0b0000_0101, 0x1234, 0x7FFF, -3, 7); + assert_eq!(r, [0x01, 0x05, 0x34, 0x12, 0xFF, 0x7F, 0xFD, 0x07]); + // Clamps: axes to the 15-bit logical max, buttons to the declared 5. + let r = mouse::input_report(0xFF, 0xFFFF, 0, 0, 0); + assert_eq!((r[1], r[2], r[3]), (0x1F, 0xFF, 0x7F)); + // A zeroed section reads as "nothing published" (in_seq 0) — the driver's idle state. + let shm = mouse::MouseShm::zeroed(); + assert_eq!(shm.in_seq, 0); + assert_eq!(bytemuck::bytes_of(&shm).len(), 64); + } + #[test] fn guid_is_not_sudovda() { const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D; diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 149e3edc..3573b648 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -212,6 +212,22 @@ pub fn deck_windows_spike(args: &[String]) -> Result<()> { crate::inject::dualsense_windows::deck_spike_hold(0, secs) } +/// Windows vmouse SPIKE: hold the pf-mouse virtual HID pointer and sweep the REAL cursor via HID +/// reports — proves devnode → INF bind → mshidumdf → mouhid → win32k on-glass, and that a resident +/// virtual pointer makes `SM_MOUSEPRESENT` true (DWM then composites the cursor) with no dongle +/// attached. Run with the host service STOPPED (the resident mouse owns the mailbox otherwise). +/// `--seconds N` (default 30). +#[cfg(target_os = "windows")] +pub fn vmouse_spike(args: &[String]) -> Result<()> { + let secs: u64 = args + .iter() + .skip_while(|a| *a != "--seconds") + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(30); + crate::inject::mouse_windows::spike_hold(secs) +} + /// Windows: create a virtual DualSense via the UMDF driver (a SwDeviceCreate per-session /// devnode plus the shared-memory channel) and hold it, pushing one fixed frame (Cross + /// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 81d07786..8dd332cb 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -235,6 +235,12 @@ pub mod gamepad; #[cfg(target_os = "windows")] #[path = "inject/windows/gamepad_raii.rs"] mod gamepad_raii; +/// Windows: the RESIDENT virtual HID mouse via the pf-mouse UMDF minidriver — keeps +/// `SM_MOUSEPRESENT` true on headless hosts so DWM composites a cursor into the IDD frame +/// (`SendInput` alone moves an invisible pointer when no physical mouse is attached). +#[cfg(target_os = "windows")] +#[path = "inject/windows/mouse_windows.rs"] +pub mod mouse_windows; /// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for /// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff /// retry. diff --git a/crates/punktfunk-host/src/inject/service.rs b/crates/punktfunk-host/src/inject/service.rs index b313c2ca..43e159a2 100644 --- a/crates/punktfunk-host/src/inject/service.rs +++ b/crates/punktfunk-host/src/inject/service.rs @@ -18,6 +18,12 @@ pub(crate) struct InjectorService { impl InjectorService { pub(crate) fn start() -> InjectorService { + // Windows: make sure the process-wide resident virtual HID mouse exists (idempotent). + // Without a pointing device present, win32k reports no cursor and DWM composites none + // into the IDD frame — SendInput injection alone moves an invisible pointer. + #[cfg(target_os = "windows")] + super::mouse_windows::ensure_resident(); + let (tx, rx) = std::sync::mpsc::channel::(); if let Err(e) = std::thread::Builder::new() .name("punktfunk-injector".into()) diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index e13aa4ce..cd84a441 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -82,7 +82,12 @@ pub(super) struct SwDeviceProfile<'a> { /// PnP instance id — distinct namespaces per type (`pf_pad_` vs `pf_ds4_`) so the two /// never reuse the same devnode shell. pub instance: &'a str, - /// Index for the deterministic per-pad ContainerId. + /// `Data1` of the deterministic ContainerId — a per-device-FAMILY tag (`"PFDS"` for the pads, + /// `"PFMO"` for the virtual mouse) so two families at the same index never share a container + /// (Windows would group them into one "device" in the Devices UI). + pub container_tag: u32, + /// Index for the deterministic per-pad ContainerId — ALSO stamped into the devnode Location, + /// which the driver reads as its bootstrap-mailbox index. pub container_index: u8, /// The INF-matched hardware id (`pf_dualsense` / `pf_dualshock4`), listed FIRST so the INF binds. pub hwid: &'a str, @@ -160,9 +165,9 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option< .encode_utf16() .chain(std::iter::once(0)) .collect(); - // Deterministic per-pad ContainerId {50464453-0000-0000-0000-0000000000} ("PFDS"). + // Deterministic ContainerId {-0000-0000-0000-0000000000} (tag e.g. "PFDS"/"PFMO"). let container = GUID::from_values( - 0x5046_4453, + p.container_tag, 0x0000, 0x0000, [0, 0, 0, 0, 0, 0, 0, p.container_index], @@ -300,6 +305,7 @@ impl DsWinPad { let inst = format!("{}_{index}", id.instance_prefix); let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { instance: &inst, + container_tag: 0x5046_4453, // "PFDS" container_index: index, hwid: id.hwid, usb_vid_pid: id.usb_vid_pid, @@ -504,6 +510,7 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> { let inst = format!("pf_deckspike_{index}"); let (hsw, _) = create_swdevice(&SwDeviceProfile { instance: &inst, + container_tag: 0x5046_4453, // "PFDS" container_index: index, hwid: "pf_steamdeck", usb_vid_pid: "VID_28DE&PID_1205", diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index bf34b5ab..cb4f3243 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -61,6 +61,7 @@ impl Ds4WinPad { let inst = format!("pf_ds4_{index}"); let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { instance: &inst, + container_tag: 0x5046_4453, // "PFDS" container_index: index, hwid: "pf_dualshock4", usb_vid_pid: "VID_054C&PID_09CC", diff --git a/crates/punktfunk-host/src/inject/windows/mouse_windows.rs b/crates/punktfunk-host/src/inject/windows/mouse_windows.rs new file mode 100644 index 00000000..cd47e27d --- /dev/null +++ b/crates/punktfunk-host/src/inject/windows/mouse_windows.rs @@ -0,0 +1,239 @@ +//! Resident virtual HID mouse on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-mouse`). +//! +//! **Why**: with no pointing device attached (a headless streaming box — no dongle), win32k reports +//! the cursor as absent (`GetSystemMetrics(SM_MOUSEPRESENT)` = 0) and DWM never composites a cursor +//! into the pf-vdisplay frame — the streamed desktop has an invisible pointer even though +//! `SendInput` moves it. Keeping ONE virtual HID mouse devnode alive for the host's lifetime makes +//! Windows always consider a pointer present and draw the cursor — the Sunshine/Parsec-class fix, +//! with zero client changes. Injection stays [`super::sendinput`]; the report path here is +//! exercised by `punktfunk-host vmouse-spike` (on-glass validation) and is the future +//! higher-fidelity injection route. +//! +//! Transport is the **sealed pad channel** verbatim ([`PadChannel`], +//! `design/gamepad-channel-sealing.md`): an unnamed 64-B `MouseShm` DATA section the host +//! duplicates into the driver's WUDFHost, bootstrapped via the named `Global\pfmouse-boot-0` +//! mailbox. The devnode is `SwDeviceCreate`'d like a pad but held for the PROCESS lifetime (the +//! [`ensure_resident`] thread never drops it), so the pointer survives across sessions; it +//! disappears with the host service, which is exactly when nobody is streaming. + +use super::dualsense_windows::{create_swdevice, SwDeviceProfile}; +use super::gamepad_raii::{DriverAttach, PadChannel}; +use anyhow::Result; +use pf_driver_proto::mouse::{input_report, mouse_boot_name, MouseShm, MOUSE_MAGIC}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; + +const SHM_SIZE: usize = core::mem::size_of::(); +const OFF_IN_SEQ: usize = core::mem::offset_of!(MouseShm, in_seq); +const OFF_REPORT: usize = core::mem::offset_of!(MouseShm, report); +const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(MouseShm, driver_proto); +const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(MouseShm, driver_heartbeat); +const OFF_PAD_INDEX: usize = core::mem::offset_of!(MouseShm, pad_index); + +/// The one resident virtual mouse: the `SwDeviceCreate`'d `pf_mouse_0` devnode (the pf-mouse HID +/// minidriver loads on it → Windows counts a pointer present) plus the sealed shared-memory +/// channel. Dropping it removes the devnode — [`ensure_resident`] therefore never drops it. +pub struct VirtualMouse { + /// Devnode RAII (`SwDeviceClose` on drop). `None` falls back to an out-of-band devnode. + _sw: Option, + channel: PadChannel, + attach: DriverAttach, + seq: u32, +} + +impl VirtualMouse { + /// Create the sealed channel (unnamed DATA section + `Global\pfmouse-boot-0` mailbox), stamp + /// the index + the magic LAST, then spawn the devnode and eagerly deliver the DATA handle. + pub fn open() -> Result { + let boot_name = mouse_boot_name(0); + let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; + let base = channel.data_base(); + // SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Index + // first, magic LAST — the same publish order the pads use. + unsafe { + std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, 0u32); + std::ptr::write_unaligned(base as *mut u32, MOUSE_MAGIC); + } + let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { + instance: "pf_mouse_0", + container_tag: 0x5046_4D4F, // "PFMO" — never grouped with a pad's container + container_index: 0, + hwid: "pf_mouse", + // An obviously-virtual identity (PF:MO). The synthesized USB bus tokens are inert for + // a mouse (nothing fingerprints them); reusing the shared profile keeps one code path. + usb_vid_pid: "VID_5046&PID_4D4F", + usb_mi: None, + description: "punktfunk Virtual Mouse", + }) { + Ok((h, i)) => (Some(h), i), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_mouse devnode"); + (None, None) + } + }; + let _sw = hsw.map(super::gamepad_raii::SwDevice::new); + channel.deliver_eager(Duration::from_millis(1500)); + Ok(VirtualMouse { + _sw, + channel, + attach: DriverAttach::new( + "pf_mouse", + "pf_mouse.inf", + "C:\\Users\\Public\\pfmouse-driver.log", + boot_name, + instance_id, + ), + seq: 0, + }) + } + + /// Publish an input report (5-bit buttons, absolute 15-bit x/y, wheel/pan deltas) and bump + /// `in_seq` (Release) — the driver's timer completes a pended `READ_REPORT` with it. Unused by + /// sessions today (`SendInput` injects); the spike drives it, and a future fidelity mode will. + pub fn send_report(&mut self, buttons: u8, x: u16, y: u16, wheel: i8, pan: i8) { + let r = input_report(buttons, x, y, wheel, pan); + self.seq = self.seq.wrapping_add(1).max(1); // never publish seq 0 (= "nothing yet") + let base = self.channel.data_base(); + // SAFETY: base points at SHM_SIZE bytes; the report slot is OFF_REPORT..+8 and OFF_IN_SEQ + // (== 4) is 4-aligned off the page-aligned base, so the AtomicU32 view is valid. The report + // bytes are published BEFORE the seq (Release) — the driver's Acquire load of `in_seq` + // therefore observes the matching report. + unsafe { + std::ptr::copy_nonoverlapping(r.as_ptr(), base.add(OFF_REPORT), r.len()); + (*(base.add(OFF_IN_SEQ) as *const AtomicU32)).store(self.seq, Ordering::Release); + } + } + + /// One service tick: pump the sealed-channel delivery and feed the driver-attach health + /// watcher (the driver's 8 ms timer stamps `driver_proto` while it has the section mapped). + pub fn service(&mut self) { + self.channel.pump(); + self.attach.observe(self.driver_proto()); + } + + fn driver_proto(&self) -> u32 { + // SAFETY: base points at SHM_SIZE bytes; OFF_DRIVER_PROTO is in range. + unsafe { + std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) + } + } + + fn driver_heartbeat(&self) -> u32 { + // SAFETY: base points at SHM_SIZE bytes; OFF_DRIVER_HEARTBEAT is in range. + unsafe { + std::ptr::read_unaligned( + self.channel.data_base().add(OFF_DRIVER_HEARTBEAT) as *const u32 + ) + } + } +} + +/// Make sure the resident virtual mouse exists (idempotent, best-effort). Called whenever an +/// [`InjectorService`](crate::inject::InjectorService) starts — multiple services (native + +/// GameStream) share the ONE process-wide mouse, guarded here. Spawns a keeper thread that owns +/// the devnode for the process lifetime and pumps the channel at a slow tick (delivery is eager at +/// open; the pump only handles a late WUDFHost + feeds the attach diagnostics). +/// +/// `PUNKTFUNK_NO_VIRTUAL_MOUSE=1` opts out (diagnostics, or an operator who objects to a virtual +/// pointer device). +pub(crate) fn ensure_resident() { + use std::sync::OnceLock; + static STARTED: OnceLock<()> = OnceLock::new(); + STARTED.get_or_init(|| { + if std::env::var_os("PUNKTFUNK_NO_VIRTUAL_MOUSE").is_some_and(|v| v != "0") { + tracing::info!( + "virtual HID mouse disabled (PUNKTFUNK_NO_VIRTUAL_MOUSE) — with no physical \ + pointer attached, Windows will not draw a cursor into the stream" + ); + return; + } + if let Err(e) = std::thread::Builder::new() + .name("punktfunk-vmouse".into()) + .spawn(keeper_thread) + { + tracing::warn!(error = %e, "virtual-mouse keeper thread spawn failed"); + } + }); +} + +/// Open-with-retry, then hold + pump forever. Open only realistically fails on a mailbox squat +/// (another punktfunk-host instance) — retry slowly; a missing/failed DRIVER is not an open +/// failure (the devnode exists but nothing binds), which [`DriverAttach`] diagnoses via the pump. +fn keeper_thread() { + loop { + match VirtualMouse::open() { + Ok(mut m) => { + tracing::info!( + "resident virtual HID mouse created (pf_mouse — keeps SM_MOUSEPRESENT true \ + so DWM composites the cursor on headless hosts)" + ); + loop { + m.service(); + std::thread::sleep(Duration::from_millis(250)); + } + } + Err(e) => { + tracing::warn!( + error = %format!("{e:#}"), + "virtual HID mouse open failed — retrying in 60s (headless hosts stream an \ + invisible cursor until it exists)" + ); + std::thread::sleep(Duration::from_secs(60)); + } + } + } +} + +/// `vmouse-spike` (dev validation): hold the virtual mouse and drive the REAL cursor through the +/// HID report path — proves the full chain (SwDeviceCreate → INF bind → mshidumdf → mouhid → +/// win32k) on-glass. Run with the host service STOPPED (the resident mouse owns the mailbox name +/// otherwise). Verify while it holds: `Get-PnpDevice` shows the pf_mouse devnode + a HID child, +/// `GetSystemMetrics(SM_MOUSEPRESENT)` = 1 with no physical mouse, and the cursor sweeps a +/// horizontal line mid-screen. +pub fn spike_hold(secs: u64) -> Result<()> { + let mut m = VirtualMouse::open()?; + println!("virtual HID mouse devnode up (5046:4D4F) — waiting for the driver to attach…"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while m.driver_proto() == 0 && std::time::Instant::now() < deadline { + m.service(); + std::thread::sleep(Duration::from_millis(50)); + } + if m.driver_proto() == 0 { + println!( + "driver never attached (10s). Install it: punktfunk-host.exe driver install --gamepad \ + --dir (pf_mouse.inf ships with the gamepad drivers); see the WARN above." + ); + } else { + println!( + "driver attached (proto {}). Sweeping the cursor for {secs}s — watch the glass: the \ + pointer should glide left↔right across mid-screen; wheel ticks every second.", + m.driver_proto() + ); + } + let t0 = std::time::Instant::now(); + let mut i: u64 = 0; + let beat_before = m.driver_heartbeat(); + while t0.elapsed() < Duration::from_secs(secs) { + // Triangle-wave X sweep over the middle 3/4 of the axis, fixed mid-screen Y; one wheel + // tick per second so scroll delivery is visible too. + let phase = (i % 240) as i32; // 240 steps × 16 ms ≈ 4 s per round trip + let tri = if phase < 120 { phase } else { 240 - phase }; + let x = 4096 + (tri as u32 * (24576 / 120)) as u16; + let wheel: i8 = if i % 60 == 0 { 1 } else { 0 }; + m.send_report(0, x, 0x4000, wheel, 0); + m.service(); + i += 1; + std::thread::sleep(Duration::from_millis(16)); + } + let beat = m.driver_heartbeat(); + println!( + "vmouse-spike: done (driver heartbeat advanced {} ticks — {}). Devnode removed on exit.", + beat.wrapping_sub(beat_before), + if beat != beat_before { + "driver alive" + } else { + "driver NOT ticking" + } + ); + Ok(()) +} diff --git a/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs b/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs index 6b888df2..5dbd15f5 100644 --- a/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs @@ -65,6 +65,7 @@ impl DeckWinPad { let inst = format!("pf_deck_{index}"); let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { instance: &inst, + container_tag: 0x5046_4453, // "PFDS" container_index: index, hwid: "pf_steamdeck", usb_vid_pid: "VID_28DE&PID_1205", diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 6ffcca93..aab324df 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -279,6 +279,10 @@ fn real_main() -> Result<()> { // Windows N4 SPIKE: hold a software-devnode HID Steam Deck and watch Steam Input promote it. #[cfg(target_os = "windows")] Some("deck-windows-spike") => devtest::deck_windows_spike(&args), + // Windows: hold the pf-mouse virtual HID pointer and sweep the real cursor via HID reports + // (validates the resident-mouse cursor-presence fix on-glass). `--seconds N`. + #[cfg(target_os = "windows")] + Some("vmouse-spike") => devtest::vmouse_spike(&args), // Windows: create a virtual DualSense (or --ds4/--edge/--deck/--xbox) via the UMDF driver and // hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`. #[cfg(target_os = "windows")] diff --git a/packaging/windows/build-gamepad-drivers.ps1 b/packaging/windows/build-gamepad-drivers.ps1 index e7bab28f..e76deaa1 100644 --- a/packaging/windows/build-gamepad-drivers.ps1 +++ b/packaging/windows/build-gamepad-drivers.ps1 @@ -13,7 +13,9 @@ supplied DRIVER_CERT secret) + ONE exported .cer - the layout `punktfunk-host.exe driver install --gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer). - Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + punktfunk-driver.cer. + Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} + + punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad — it shares + this pipeline + the --gamepad install path.) .EXAMPLE pwsh -File build-gamepad-drivers.ps1 -Out C:\t\gamepad @@ -37,6 +39,10 @@ $clear = Join-Path $PSScriptRoot 'clear-force-integrity.ps1' $drivers = @( @{ crate = 'pf-dualsense'; dll = 'pf_dualsense.dll'; inx = 'pf-dualsense\pf_dualsense.inx'; inf = 'pf_dualsense.inf'; cat = 'pf_dualsense.cat' } @{ crate = 'pf-xusb'; dll = 'pf_xusb.dll'; inx = 'pf-xusb\pf_xusb.inx'; inf = 'pf_xusb.inf'; cat = 'pf_xusb.cat' } + # Not a gamepad, but it rides the identical UMDF HID pipeline + the same install path + # (`driver install --gamepad` adds every staged .inf): the resident virtual HID mouse that + # keeps SM_MOUSEPRESENT true so DWM composites a cursor on headless hosts. + @{ crate = 'pf-mouse'; dll = 'pf_mouse.dll'; inx = 'pf-mouse\pf_mouse.inx'; inf = 'pf_mouse.inf'; cat = 'pf_mouse.cat' } ) foreach ($d in $drivers) { if (-not (Test-Path (Join-Path $DriversDir $d.inx))) { throw "no $($d.inx) under $DriversDir" } diff --git a/packaging/windows/drivers/Cargo.lock b/packaging/windows/drivers/Cargo.lock index 03841acd..f16d5ec0 100644 --- a/packaging/windows/drivers/Cargo.lock +++ b/packaging/windows/drivers/Cargo.lock @@ -412,6 +412,17 @@ dependencies = [ "wdk-sys", ] +[[package]] +name = "pf-mouse" +version = "0.0.1" +dependencies = [ + "pf-driver-proto", + "pf-umdf-util", + "wdk", + "wdk-build", + "wdk-sys", +] + [[package]] name = "pf-umdf-util" version = "0.0.1" diff --git a/packaging/windows/drivers/Cargo.toml b/packaging/windows/drivers/Cargo.toml index c4837527..550e675a 100644 --- a/packaging/windows/drivers/Cargo.toml +++ b/packaging/windows/drivers/Cargo.toml @@ -7,7 +7,7 @@ # crates/pf-driver-proto from the main tree. [workspace] resolver = "2" -members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb"] +members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb", "pf-mouse"] [workspace.package] edition = "2024" diff --git a/packaging/windows/drivers/pf-mouse/Cargo.toml b/packaging/windows/drivers/pf-mouse/Cargo.toml new file mode 100644 index 00000000..64fb4400 --- /dev/null +++ b/packaging/windows/drivers/pf-mouse/Cargo.toml @@ -0,0 +1,32 @@ +# pf-mouse - punktfunk virtual HID mouse (absolute pointer) UMDF2 HID minidriver. +# A member of the in-tree drivers workspace (shares the vendored wdk-sys/wdk-build with the bindgen pin +# + the crt-static .cargo/config), built from source per release like the gamepad drivers. +[package] +name = "pf-mouse" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false +description = "punktfunk virtual HID mouse (absolute pointer) UMDF2 HID minidriver" + +[package.metadata.wdk.driver-model] +driver-type = "UMDF" +umdf-version-major = 2 +target-umdf-version-minor = 31 + +[lib] +crate-type = ["cdylib"] + +[build-dependencies] +wdk-build.workspace = true + +[dependencies] +wdk.workspace = true +wdk-sys.workspace = true +pf-driver-proto.workspace = true +pf-umdf-util.workspace = true + +[features] +default = ["hid"] +hid = ["wdk-sys/hid"] +nightly = ["wdk-sys/nightly", "wdk/nightly"] diff --git a/packaging/windows/drivers/pf-mouse/build.rs b/packaging/windows/drivers/pf-mouse/build.rs new file mode 100644 index 00000000..a434b873 --- /dev/null +++ b/packaging/windows/drivers/pf-mouse/build.rs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// License: MIT OR Apache-2.0 + +//! Build script for the `sample-umdf-driver` crate. +//! +//! Based on the [`wdk_build::Config`] parsed from the build tree, this build +//! script will provide `Cargo` with the necessary information to build the +//! driver binary (ex. linker flags) + +fn main() -> Result<(), wdk_build::ConfigError> { + wdk_build::configure_wdk_binary_build() +} diff --git a/packaging/windows/drivers/pf-mouse/pf_mouse.inx b/packaging/windows/drivers/pf-mouse/pf_mouse.inx new file mode 100644 index 00000000..6cf1625e --- /dev/null +++ b/packaging/windows/drivers/pf-mouse/pf_mouse.inx @@ -0,0 +1,79 @@ +;/*++ +; punktfunk virtual HID mouse (absolute pointer) — UMDF2 HID minidriver INF. +; Same skeleton as pf_dualsense.inx (the WDK vhidmini2 UMDF2 shape). +; Depends on MsHidUmdf.inf (build >= 22000). +; Install: devgen /add /hardwareid "root\pf_mouse" (after pnputil /add-driver /install) +;--*/ +[Version] +Signature="$WINDOWS NT$" +Class=HIDClass +ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da} +Provider=%ProviderString% +CatalogFile=pf_mouse.cat +PnpLockdown=1 + +[DestinationDirs] +DefaultDestDir = 13 + +[SourceDisksNames] +1=%Disk_Description%,,, + +[SourceDisksFiles] +pf_mouse.dll=1 + +[Manufacturer] +%ManufacturerString%=pf, NT$ARCH$.10.0...22000 + +[pf.NT$ARCH$.10.0...22000] +; Hardware ids: `root\pf_mouse` for a root-enumerated devnode (devgen/devcon tests); `pf_mouse` for +; the host's SwDeviceCreate'd resident pointer (the `root\` prefix is reserved for root enumeration, +; so SwDeviceCreate rejects it with E_INVALIDARG). +%DeviceDesc%=pfMouse, root\pf_mouse, pf_mouse + +[pfMouse.NT] +CopyFiles=UMDriverCopy +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT + +[pfMouse.NT.hw] +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT.hw +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.hw + +[pfMouse.NT.Services] +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT.Services +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.Services + +[pfMouse.NT.Filters] +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.Filters + +[pfMouse.NT.Wdf] +UmdfService="pf_mouse", pf_mouse_Install +UmdfServiceOrder=pf_mouse +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfMethodNeitherAction=Copy +UmdfFsContextUsePolicy=CanUseFsContext2 +; Its own WUDFHost so the driver's per-device statics never collide with a gamepad's (parity with +; the pad INFs — and the resident mouse outlives any session, so isolation is cheap insurance). +UmdfHostProcessSharing=ProcessSharingDisabled + +[pf_mouse_Install] +UmdfLibraryVersion=$UMDFVERSION$ +ServiceBinary="%13%\pf_mouse.dll" + +[UMDriverCopy] +pf_mouse.dll + +[Strings] +ProviderString ="punktfunk" +ManufacturerString ="punktfunk" +ClassName ="HID device" +Disk_Description ="punktfunk Mouse Installation Disk" +DeviceDesc ="punktfunk Virtual Mouse" diff --git a/packaging/windows/drivers/pf-mouse/src/lib.rs b/packaging/windows/drivers/pf-mouse/src/lib.rs new file mode 100644 index 00000000..83d7e6da --- /dev/null +++ b/packaging/windows/drivers/pf-mouse/src/lib.rs @@ -0,0 +1,477 @@ +// punktfunk virtual HID mouse — UMDF2 HID minidriver (absolute pointer). +// +// Why it exists: with NO pointing device present (a headless streaming host — no dongle), win32k +// reports the cursor as absent (`SM_MOUSEPRESENT` = 0) and DWM never composites a cursor into the +// pf-vdisplay frame, so the streamed desktop has an invisible pointer even though `SendInput` +// moves it. This driver keeps a resident HID mouse devnode alive for the host service's lifetime, +// which makes Windows always consider a pointer present and draw the cursor — the industry-standard +// fix (what Sunshine/Parsec-class virtual-input drivers achieve). Injection stays `SendInput`; +// the report path below is exercised by `punktfunk-host vmouse-spike` (validation) and is the +// future higher-fidelity injection route. +// +// Structure is pf-dualsense minus the identity zoo: one fixed HID identity (PF:MO, an obviously +// virtual VID/PID no software matches on), one 8-byte input report (5 buttons + absolute 15-bit +// X/Y + wheel + AC-pan), no feature/output reports. The host channel is the **sealed pad channel** +// (design/gamepad-channel-sealing.md) verbatim — mailbox `Global\pfmouse-boot-`, unnamed +// `pf_driver_proto::mouse::MouseShm` DATA section — so the whole handshake + shared-memory surface +// lives in `pf_umdf_util` (the audited unsafe layer) and this crate's logic is 100% SAFE Rust; the +// only `unsafe` here is the unavoidable WDF setup FFI, each with a `// SAFETY:` proof. +// +// Report delivery is EVENT-DRIVEN like a real mouse: the timer completes a pended READ_REPORT only +// when the host bumped `in_seq` — an idle section generates no HID traffic (a constant report +// stream would read as user activity to the OS: idle timers, display sleep). + +#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)] +// Every remaining `unsafe {}` (all WDF setup FFI) must carry a `// SAFETY:` proof. +#![deny(unsafe_op_in_unsafe_fn)] +#![deny(clippy::undocumented_unsafe_blocks)] + +use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering}; + +use pf_driver_proto::mouse::{ + MOUSE_PID, MOUSE_REPORT_ID, MOUSE_REPORT_LEN, MOUSE_VER, MOUSE_VID, MouseShm, +}; +use pf_umdf_util::channel::{ChannelClient, ChannelConfig}; +use pf_umdf_util::nt_success; +use pf_umdf_util::wdf::{self, Request}; +use wdk_sys::{ + NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG, + WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, + WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, + call_unsafe_wdf_function_binding, windows::OutputDebugStringA, +}; + +// ---- NTSTATUS values ---- +const STATUS_SUCCESS: NTSTATUS = 0; +const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS; + +// ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ---- +const fn hid_ctl(id: u32) -> u32 { + (0x0000_000b << 16) | (id << 2) | 3 +} +const IOCTL_HID_GET_DEVICE_DESCRIPTOR: u32 = hid_ctl(0); +const IOCTL_HID_GET_REPORT_DESCRIPTOR: u32 = hid_ctl(1); +const IOCTL_HID_READ_REPORT: u32 = hid_ctl(2); +const IOCTL_HID_WRITE_REPORT: u32 = hid_ctl(3); +const IOCTL_HID_GET_DEVICE_ATTRIBUTES: u32 = hid_ctl(9); +const IOCTL_HID_GET_STRING: u32 = hid_ctl(4); +const IOCTL_UMDF_HID_SET_OUTPUT_REPORT: u32 = hid_ctl(22); +const IOCTL_UMDF_HID_GET_INPUT_REPORT: u32 = hid_ctl(23); + +// ---- WDF enum values ---- +const WdfIoQueueDispatchParallel: i32 = 2; +const WdfIoQueueDispatchManual: i32 = 3; +const WdfUseDefault: i32 = 2; // WDF_TRI_STATE +const WdfExecutionLevelInheritFromParent: i32 = 1; // WDF_EXECUTION_LEVEL +const WdfSynchronizationScopeInheritFromParent: i32 = 1; // WDF_SYNCHRONIZATION_SCOPE + +// HID report descriptor (80 bytes): one application collection (Generic Desktop / Mouse), report +// id 0x01 — 5 buttons, ABSOLUTE 15-bit X/Y (logical 0..=32767), relative wheel + AC-pan. Absolute +// axes so a future report-driven injection maps 1:1 onto the desktop, and so the OS treats the +// device as a pointer that never "drifts"; presence (not fidelity) is this driver's job today. +#[rustfmt::skip] +static MOUSE_RDESC: [u8; 80] = [ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xA1, 0x01, // Collection (Application) + 0x85, 0x01, // Report ID (1) + 0x09, 0x01, // Usage (Pointer) + 0xA1, 0x00, // Collection (Physical) + 0x05, 0x09, // Usage Page (Button) + 0x19, 0x01, // Usage Minimum (1) + 0x29, 0x05, // Usage Maximum (5) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x05, // Report Count (5) + 0x81, 0x02, // Input (Data,Var,Abs) — buttons 1..5 + 0x75, 0x03, // Report Size (3) + 0x95, 0x01, // Report Count (1) + 0x81, 0x03, // Input (Const) — pad + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x02, // Report Count (2) + 0x81, 0x02, // Input (Data,Var,Abs) — absolute X/Y + 0x09, 0x38, // Usage (Wheel) + 0x15, 0x81, // Logical Minimum (-127) + 0x25, 0x7F, // Logical Maximum (127) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x06, // Input (Data,Var,Rel) — wheel + 0x05, 0x0C, // Usage Page (Consumer) + 0x0A, 0x38, 0x02, // Usage (AC Pan) + 0x15, 0x81, // Logical Minimum (-127) + 0x25, 0x7F, // Logical Maximum (127) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x06, // Input (Data,Var,Rel) — horizontal wheel + 0xC0, // End Collection + 0xC0, // End Collection +]; + +// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then +// {reportType=0x22, wReportLength = 80 (0x0050)}. +static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x50, 0x00]; + +// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. +fn hid_attrs() -> [u8; 32] { + let mut a = [0u8; 32]; + a[0..4].copy_from_slice(&32u32.to_le_bytes()); + a[4..6].copy_from_slice(&MOUSE_VID.to_le_bytes()); + a[6..8].copy_from_slice(&MOUSE_PID.to_le_bytes()); + a[8..10].copy_from_slice(&MOUSE_VER.to_le_bytes()); + a +} + +/// A report that answers a client's GET_INPUT_REPORT query before the host published anything: +/// id + all-zero state. Never fed into the input stream (READ_REPORT completes only on a fresh +/// host publish), so it cannot warp the cursor to (0,0). +const NEUTRAL_REPORT: [u8; MOUSE_REPORT_LEN] = { + let mut r = [0u8; MOUSE_REPORT_LEN]; + r[0] = MOUSE_REPORT_ID; + r +}; + +static MANUAL_QUEUE: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); +/// The latest host-published report (kept for GET_INPUT_REPORT queries). +static INPUT_REPORT: std::sync::Mutex<[u8; MOUSE_REPORT_LEN]> = + std::sync::Mutex::new(NEUTRAL_REPORT); +/// The last `in_seq` a READ_REPORT was completed for — the event-driven gate. NOT advanced when no +/// read is pended (the next tick retries), so a publish is never dropped while a reader exists. +static DELIVERED_SEQ: AtomicU32 = AtomicU32::new(0); + +// ---- the sealed host channel: layouts + offsets from pf_driver_proto (drift = compile error) ---- +const SHM_MAGIC: u32 = pf_driver_proto::mouse::MOUSE_MAGIC; // "PFMO" +const SHM_SIZE: usize = core::mem::size_of::(); +const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION; + +// MouseShm field offsets (the driver reads report + in_seq, writes the health marks). +const OFF_IN_SEQ: usize = core::mem::offset_of!(MouseShm, in_seq); +const OFF_REPORT: usize = core::mem::offset_of!(MouseShm, report); +const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(MouseShm, driver_proto); +const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(MouseShm, driver_heartbeat); +const OFF_PAD_INDEX: usize = core::mem::offset_of!(MouseShm, pad_index); + +/// The sealed-channel client (`ProcessSharingDisabled` gives the mouse its own WUDFHost, so this +/// static is per-device). The handshake/adoption/validation state machine lives in `pf_umdf_util`. +static CHANNEL: ChannelClient = ChannelClient::new(); + +/// This device's channel config (magic/size/index offset + our logger). +fn channel_cfg() -> ChannelConfig { + ChannelConfig { + tag: "pf-mouse", + boot_name_prefix: "Global\\pfmouse-boot-", + data_magic: SHM_MAGIC, + data_size: SHM_SIZE, + pad_index_off: OFF_PAD_INDEX, + log, + } +} + +/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds, +/// or the `PFMOUSE_DEBUG_LOG` (system-wide) env var — the same policy as the pad drivers (audit +/// §4.4): a RELEASE driver never writes the Public file. DebugView can't see the UMDF host across +/// session 0, so the file stays the bring-up diagnostic when enabled. +fn file_log_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFMOUSE_DEBUG_LOG").is_some()) +} + +/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex` +/// (pf-vdisplay's pattern) — no per-line open/close. +fn file_appender() -> Option<&'static std::sync::Mutex> { + use std::sync::OnceLock; + static APPENDER: OnceLock>> = OnceLock::new(); + APPENDER + .get_or_init(|| { + if !file_log_enabled() { + return None; + } + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("C:\\Users\\Public\\pfmouse-driver.log") + .ok() + .map(std::sync::Mutex::new) + }) + .as_ref() +} + +fn log(s: &str) { + if let Ok(c) = std::ffi::CString::new(s) { + // SAFETY: `c` is a valid NUL-terminated string for the duration of the call. + unsafe { OutputDebugStringA(c.as_ptr().cast()) }; + } + use std::io::Write; + if let Some(m) = file_appender() + && let Ok(mut f) = m.lock() + { + let _ = writeln!(f, "{s}"); + } +} +macro_rules! dbglog { ($($a:tt)*) => { log(&format!($($a)*)) } } + +#[unsafe(export_name = "DriverEntry")] +pub unsafe extern "system" fn driver_entry( + driver: PDRIVER_OBJECT, + registry_path: PCUNICODE_STRING, +) -> NTSTATUS { + log("[pf-mouse] DriverEntry"); + // SAFETY: zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback. + let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() }; + config.Size = core::mem::size_of::() as ULONG; + config.EvtDriverDeviceAdd = Some(evt_device_add); + + // SAFETY: all pointers valid; driver/registry_path provided by the loader. + unsafe { + call_unsafe_wdf_function_binding!( + WdfDriverCreate, + driver, + registry_path, + WDF_NO_OBJECT_ATTRIBUTES, + &mut config, + WDF_NO_HANDLE.cast::() + ) + } +} + +extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { + log("[pf-mouse] EvtDeviceAdd"); + + // Mark as a filter (HID minidriver sits below mshidumdf.sys). + // SAFETY: device_init is provided by the framework and non-null. + unsafe { call_unsafe_wdf_function_binding!(WdfFdoInitSetFilter, device_init) }; + + let mut device: WDFDEVICE = core::ptr::null_mut(); + // SAFETY: device_init valid; attributes allowed null; device receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceCreate, + &mut device_init, + WDF_NO_OBJECT_ATTRIBUTES, + &mut device + ) + }; + if !nt_success(st) { + dbglog!("[pf-mouse] WdfDeviceCreate failed 0x{:08x}", st as u32); + return st; + } + + // SAFETY: `device` is the live device just created — the exact contract this fn requires. + let shm_idx = unsafe { wdf::query_location_index(device) }; + CHANNEL.set_index(shm_idx); + dbglog!("[pf-mouse] shm index = {shm_idx}"); + + // 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() }; + qcfg.Size = core::mem::size_of::() as ULONG; + qcfg.DispatchType = WdfIoQueueDispatchParallel; + qcfg.PowerManaged = WdfUseDefault; + qcfg.DefaultQueue = 1; + qcfg.EvtIoDeviceControl = Some(evt_io_device_control); + // WDF_IO_QUEUE_CONFIG_INIT sets this to (ULONG)-1 (unlimited); mem::zeroed left it 0, + // which on a parallel queue means present ZERO requests → EvtIoDeviceControl never fires. + qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX; + let mut default_queue: WDFQUEUE = core::ptr::null_mut(); + // SAFETY: device + config valid; attributes null; queue receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueCreate, + device, + &mut qcfg, + WDF_NO_OBJECT_ATTRIBUTES, + &mut default_queue + ) + }; + if !nt_success(st) { + dbglog!( + "[pf-mouse] default WdfIoQueueCreate failed 0x{:08x}", + st as u32 + ); + return st; + } + + // Manual queue: pended READ_REPORT requests are completed by the timer on fresh host input. + // SAFETY: zeroed config then fields set. + let mut mcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() }; + mcfg.Size = core::mem::size_of::() as ULONG; + mcfg.DispatchType = WdfIoQueueDispatchManual; + mcfg.PowerManaged = WdfUseDefault; + let mut manual_queue: WDFQUEUE = core::ptr::null_mut(); + // SAFETY: device + config valid; attributes null; queue receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueCreate, + device, + &mut mcfg, + WDF_NO_OBJECT_ATTRIBUTES, + &mut manual_queue + ) + }; + if !nt_success(st) { + dbglog!( + "[pf-mouse] manual WdfIoQueueCreate failed 0x{:08x}", + st as u32 + ); + return st; + } + MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst); + + // Periodic timer (parent = manual queue): sealed-channel pump + health marks + event-driven + // READ_REPORT completion. 8 ms — the proven pf-dualsense cadence; the mouse is presence-first + // (SendInput injects), so a 125 Hz ceiling on the validation/report path is fine. + // SAFETY: zeroed config then fields set. + let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() }; + tcfg.Size = core::mem::size_of::() as ULONG; + tcfg.EvtTimerFunc = Some(evt_timer); + tcfg.Period = 8; // ms + tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern) + // SAFETY: a zeroed WDF_OBJECT_ATTRIBUTES is a valid all-null attributes struct; we set Size + the + // fields we use below. + let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() }; + tattr.Size = core::mem::size_of::() as ULONG; + tattr.ParentObject = manual_queue.cast(); + // mem::zeroed leaves these at 0 (Invalid) → set them like WDF_OBJECT_ATTRIBUTES_INIT + // (matches the working vhidmini2 UMDF timer setup; avoids 0xc0200209 / 0xc00000bb). + tattr.ExecutionLevel = WdfExecutionLevelInheritFromParent; + tattr.SynchronizationScope = WdfSynchronizationScopeInheritFromParent; + let mut timer: WDFTIMER = core::ptr::null_mut(); + // SAFETY: config + attributes valid; timer receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer) + }; + if !nt_success(st) { + dbglog!("[pf-mouse] WdfTimerCreate failed 0x{:08x}", st as u32); + return st; + } + // SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative). + let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) }; + + log("[pf-mouse] device ready (HID mouse 5046:4D4F)"); + STATUS_SUCCESS +} + +extern "C" fn evt_io_device_control( + _queue: WDFQUEUE, + request: WDFREQUEST, + _output_len: usize, + _input_len: usize, + ioctl: ULONG, +) { + // SAFETY: `request` is the live request for THIS EvtIoDeviceControl invocation — exactly the + // contract `Request::new` requires. Everything after is safe (the token owns completion). + let request = unsafe { Request::new(request) }; + + // Skip the READ_REPORT cadence so the log stays readable; the descriptor handshake still logs. + if ioctl != IOCTL_HID_READ_REPORT { + dbglog!("[pf-mouse] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}"); + } + + // READ_REPORT forwards to the manual queue (the timer completes it on fresh input) — this + // CONSUMES the request token, so it's handled apart from the status-and-complete paths below. + if ioctl == IOCTL_HID_READ_REPORT { + let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst); + // SAFETY: `mq` is the manual queue created in EvtDeviceAdd (a live WDFQUEUE of this device). + match unsafe { request.forward_to_queue(mq) } { + Ok(()) => {} // framework owns it now (completed by the timer) + Err((req, st)) => req.complete(st), // forward failed → complete with the error + } + return; + } + + let status: NTSTATUS = match ioctl { + IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(&HID_DESC), + IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs()), + IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(&MOUSE_RDESC), + IOCTL_UMDF_HID_GET_INPUT_REPORT => { + let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT); + request.copy_to_output(&report) + } + // No output reports are declared; ack a stray write instead of failing the sender. + IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => STATUS_SUCCESS, + IOCTL_HID_GET_STRING => on_get_string(&request), + _ => STATUS_NOT_IMPLEMENTED, + }; + + dbglog!("[pf-mouse] ioctl 0x{ioctl:08x} -> 0x{:08x}", status as u32); + request.complete(status); +} + +// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word +// is the language id. Windows polls ids 0x0E/0x0F/0x10 (manufacturer/product/serial) as well as +// the 0/1/2 HID_STRING_ID_* constants — serve both (the pf-dualsense finding). +fn on_get_string(request: &Request) -> NTSTATUS { + let (bytes, _) = match request.input_bytes(4) { + Ok(v) => v, + Err(st) => return st, + }; + let id_val: u32 = if bytes.len() >= 4 { + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) + } else { + 0 + }; + let string_id = id_val & 0xFFFF; + let s: &str = match string_id { + 0 | 0x000E => "punktfunk", + 2 | 0x0010 => "PFMOUSE00", + _ => "punktfunk Virtual Mouse", + }; + let mut wide: Vec = Vec::with_capacity(s.len() * 2 + 2); + for u in s.encode_utf16() { + wide.extend_from_slice(&u.to_le_bytes()); + } + wide.extend_from_slice(&[0, 0]); // NUL terminator (UTF-16) + request.copy_to_output(&wide) +} + +extern "C" fn evt_timer(timer: WDFTIMER) { + // One sealed-channel tick: publish our pid / adopt a delivery / detect host-gone (all safe, + // via pf_umdf_util), then stamp the health marks the host watches. + let Some(view) = CHANNEL.pump(&channel_cfg()) else { + return; // host gone or not attached — nothing to deliver, nothing to mark + }; + view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION); + let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1); + view.write_u32(OFF_DRIVER_HEARTBEAT, hb); + + // Event-driven delivery: only when the host published a NEW report (in_seq advanced) does a + // pended READ_REPORT complete. Acquire pairs with the host's Release bump, so the report bytes + // read below are the ones that seq published. If no read is pended right now, DELIVERED_SEQ is + // NOT advanced — the next tick retries while hidclass re-pends its reader. + let seq = view.load_u32(OFF_IN_SEQ, Ordering::Acquire); + if seq == 0 || seq == DELIVERED_SEQ.load(Ordering::Relaxed) { + return; + } + // SAFETY-free queue access: the timer's parent object is the manual queue (set in + // EvtDeviceAdd); the framework guarantees a live handle here. + // SAFETY: see above — WdfTimerGetParentObject on the framework-provided live timer. + let queue = + unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE; + // SAFETY: `queue` is that live manual queue — the exact contract `retrieve_next_request` needs. + let Some(request) = (unsafe { wdf::retrieve_next_request(queue) }) else { + return; // no reader pended — retry next tick (seq stays undelivered) + }; + let mut report = [0u8; MOUSE_REPORT_LEN]; + view.read_bytes(OFF_REPORT, &mut report); + DELIVERED_SEQ.store(seq, Ordering::Relaxed); + if report[0] == MOUSE_REPORT_ID { + if let Ok(mut g) = INPUT_REPORT.lock() { + *g = report; + } + let st = request.copy_to_output(&report); + request.complete(st); + } else { + // A malformed publish (host bug / torn first write): don't feed hidclass garbage — repend + // by completing nothing this tick. The request was already retrieved, so complete it with + // the last good report instead of dropping it on the floor. + let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT); + let st = request.copy_to_output(&report); + request.complete(st); + } +}