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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user