feat(host/windows): resident virtual HID mouse (pf-mouse UMDF minidriver)

Headless Windows hosts (no dongle) stream an INVISIBLE cursor: with no
pointing device present win32k reports SM_MOUSEPRESENT=0 and DWM never
composites a pointer into the pf-vdisplay frame, even though SendInput
moves it. Keep ONE virtual HID mouse devnode alive for the host's
lifetime — the Sunshine/Parsec-class fix, zero client changes.

- pf-mouse: UMDF2 HID minidriver, one fixed identity (PF:MO 5046:4D4F,
  obviously virtual, nothing fingerprints it), one 8-byte input report
  (5 buttons + absolute 15-bit X/Y + wheel + AC-pan). Transport is the
  sealed pad channel verbatim (Global\pfmouse-boot-0 mailbox + unnamed
  MouseShm DATA section) so pf-umdf-util's audited layer serves it
  unchanged; report delivery is event-driven (idle = no HID traffic).
- host: inject::mouse_windows — VirtualMouse (SwDeviceCreate'd devnode +
  channel), ensure_resident() keeper thread started by every
  InjectorService (process-wide, PUNKTFUNK_NO_VIRTUAL_MOUSE opts out),
  vmouse-spike on-glass validation (cursor sweep via HID reports).
- proto: mouse module (magic, boot-name, identity, report layout,
  unit-tested input_report packing).
- SwDeviceProfile grows container_tag so the mouse's ContainerId family
  (PFMO) never groups with a pad's (PFDS) in the Devices UI.
- packaging: pf-mouse rides the gamepad-driver build + install pipeline
  (build-gamepad-drivers.ps1, windows-drivers.yml, driver install
  --gamepad picks up every staged .inf).

On-glass validated on winbox: devnode + HID child bind, SM_MOUSEPRESENT=1
with no physical mouse, cursor sweeps via HID reports (vmouse-spike).

This work was implemented in a parallel session; committed here as the
build prerequisite for the HID compose kick that follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:02:10 +02:00
parent 3d9b329084
commit 85dd2bb077
17 changed files with 1024 additions and 8 deletions
+16
View File
@@ -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
+6
View File
@@ -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.
@@ -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::<InputEvent>();
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-injector".into())
@@ -82,7 +82,12 @@ pub(super) struct SwDeviceProfile<'a> {
/// PnP instance id — distinct namespaces per type (`pf_pad_<idx>` vs `pf_ds4_<idx>`) 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<idx>} ("PFDS").
// Deterministic ContainerId {<tag>-0000-0000-0000-0000000000<idx>} (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",
@@ -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",
@@ -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::<MouseShm>();
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<super::gamepad_raii::SwDevice>,
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<VirtualMouse> {
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 <stage> (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(())
}
@@ -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",
+4
View File
@@ -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")]