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
+119
View File
@@ -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-<index>` — 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::<MouseShm>() == 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;