f6c6e4e594
inject.rs + inject/* (the per-OS injectors — wlroots virtual-input, KWin
fake_input, libei/reis, gamescope-EI on Linux; SendInput on Windows — plus the
virtual-gamepad HID stack: DualSense/DualShock4/Switch Pro/Steam Controller/Deck
over uhid/usbip and the Windows UMDF drivers, the proto codecs, the injector
service, and the uhid manager) move into crates/pf-inject behind the
InputInjector trait (plan §W6). It consumes punktfunk_core::input (the neutral
GamepadEvent/InputEvent vocabulary, moved to core in W5) + the pf-driver-proto
wire contract, and reaches pf-capture only for the Windows gamepad-channel
WUDFHost check + the resident-mouse compose-kick hook.
The one inject->vdisplay coupling (the libei gamescope-EI backend needs the EIS
relay socket path) is broken via a leaf: gamescope_ei_socket_file moves to
pf-paths as the shared contract — the gamescope producer (host vdisplay) keeps
its session-env-lock wrapper around it, the libei consumer (pf-inject) reads it
directly post-retarget. The host keeps a `mod inject { pub use pf_inject::* }`
shim so every crate::inject::* path (the native/gamestream input planes + devtest)
is unchanged; the heavy input deps (wayland/reis/xkbcommon/usbip + the KWin
fake-input protocol XML) moved with the crate.
Verified: Linux clippy -D warnings (pf-inject + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-inject 69/69 + host 230/230 tests; Windows clippy -D warnings
(pf-inject --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
356 lines
17 KiB
Rust
356 lines
17 KiB
Rust
//! 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::{AtomicBool, AtomicU32, Ordering};
|
||
use std::sync::{Condvar, Mutex};
|
||
use std::time::Duration;
|
||
use windows::Win32::Foundation::POINT;
|
||
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||
|
||
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
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One pending compose-kick aim, desktop coordinates: the target display's rect plus the
|
||
/// virtual-desktop bounds to normalize against (both from CCD, so they describe the CONSOLE's
|
||
/// layout whatever session this process is in). Newest-wins single slot — kicks are idempotent
|
||
/// damage nudges, queueing them would only multiply pointer blips.
|
||
struct KickAim {
|
||
rect: (i32, i32, i32, i32),
|
||
bounds: (i32, i32, i32, i32),
|
||
}
|
||
|
||
struct KickSlot {
|
||
slot: Mutex<Option<KickAim>>,
|
||
wake: Condvar,
|
||
}
|
||
|
||
static KICK: KickSlot = KickSlot {
|
||
slot: Mutex::new(None),
|
||
wake: Condvar::new(),
|
||
};
|
||
|
||
/// True while the keeper's mouse is open AND the pf-mouse driver is attached (its 8 ms timer
|
||
/// stamps `driver_proto`) — the only state in which a kick's reports actually reach win32k.
|
||
static MOUSE_READY: AtomicBool = AtomicBool::new(false);
|
||
|
||
/// Request a pointer jiggle on the given display through the resident virtual mouse — the
|
||
/// COMPOSE KICK's reliable arm. A report from a HID device is REAL input to win32k: it wakes a
|
||
/// powered-off display subsystem (lid-closed / display idle-off / modern standby), resets idle
|
||
/// timers, counts as user presence, and is delivered regardless of the calling process's session
|
||
/// or the active desktop — every condition under which the `SendInput` kick is silently impotent
|
||
/// (wrong session → wrong input queue; secure desktop → blocked; display-off → nothing to
|
||
/// damage). Asynchronous: the keeper thread (which owns the one process-wide mouse) executes it
|
||
/// within its tick. Returns `false` when the resident mouse isn't up (opted out, driver not
|
||
/// installed, not yet attached) — the caller falls back to `SendInput`.
|
||
pub(crate) fn hid_kick(rect: (i32, i32, i32, i32), bounds: (i32, i32, i32, i32)) -> bool {
|
||
if !MOUSE_READY.load(Ordering::Relaxed) {
|
||
return false;
|
||
}
|
||
*KICK.slot.lock().unwrap() = Some(KickAim { rect, bounds });
|
||
KICK.wake.notify_one();
|
||
true
|
||
}
|
||
|
||
/// Execute one compose kick on the keeper thread: park the pointer at the target rect's center,
|
||
/// dwell one composition interval, wiggle ~2 px, then put it back where it was. Every report is
|
||
/// device-level input (see [`hid_kick`]). The dwell is load-bearing (the Stage-W3 on-glass
|
||
/// finding, same as the SendInput jump path): DWM samples the cursor position at the next vsync
|
||
/// tick, so a sub-tick round trip composes nothing. The gaps also respect the driver's 8 ms
|
||
/// report timer — back-to-back writes into the single report slot would coalesce.
|
||
///
|
||
/// The restore is best-effort via `GetCursorPos`: in a wrong-session host it describes the wrong
|
||
/// session's pointer, so the console pointer is instead left near the target's center — which is
|
||
/// the streamed display, exactly where the pointer is about to be useful.
|
||
fn perform_kick(m: &mut VirtualMouse, aim: KickAim) {
|
||
let (bx, by, bw, bh) = aim.bounds;
|
||
if bw <= 0 || bh <= 0 {
|
||
return;
|
||
}
|
||
// Field-log which kick arm fired (the SendInput arm logs in kick_dwm_compose) — a lid-closed
|
||
// repro should show this line followed by the driver's first acquired frame.
|
||
tracing::debug!(
|
||
rect = ?aim.rect,
|
||
bounds = ?aim.bounds,
|
||
"HID compose kick — parking the pointer on the target display (display wake + damage)"
|
||
);
|
||
let map = |px: i32, py: i32| -> (u16, u16) {
|
||
let nx = ((px - bx).clamp(0, bw - 1) as i64 * 0x7FFF) / i64::from(bw - 1).max(1);
|
||
let ny = ((py - by).clamp(0, bh - 1) as i64 * 0x7FFF) / i64::from(bh - 1).max(1);
|
||
(nx as u16, ny as u16)
|
||
};
|
||
let mut p = POINT::default();
|
||
// SAFETY: plain FFI; `p` is a valid out-param for this synchronous call.
|
||
let orig = unsafe { GetCursorPos(&mut p) }
|
||
.is_ok()
|
||
.then_some((p.x, p.y));
|
||
let (rx, ry, rw, rh) = aim.rect;
|
||
let (cx, cy) = map(rx + rw / 2, ry + rh / 2);
|
||
// ~2 desktop pixels in HID units, at least 1 — the wiggle must actually move the pointer.
|
||
let dx = ((2 * 0x7FFF) / bw.max(1)).max(1) as u16;
|
||
m.send_report(0, cx, cy, 0, 0);
|
||
std::thread::sleep(Duration::from_millis(35));
|
||
m.send_report(0, cx.saturating_add(dx).min(0x7FFF), cy, 0, 0);
|
||
std::thread::sleep(Duration::from_millis(35));
|
||
match orig {
|
||
Some((ox, oy)) => {
|
||
let (ox, oy) = map(ox, oy);
|
||
m.send_report(0, ox, oy, 0, 0);
|
||
}
|
||
None => m.send_report(0, cx, cy, 0, 0),
|
||
}
|
||
}
|
||
|
||
/// Make sure the resident virtual mouse exists (idempotent, best-effort). Called whenever an
|
||
/// [`InjectorService`](crate::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;
|
||
}
|
||
// Hand the capture crate its HID compose-kick hook (the one-way-edge inversion: pf-capture
|
||
// never reaches back into inject). Registered exactly when the resident mouse is being
|
||
// brought up; until the driver actually attaches, `hid_kick` reports not-ready and the
|
||
// kick falls back to SendInput.
|
||
let _ = pf_capture::HID_COMPOSE_KICK.set(hid_kick);
|
||
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.
|
||
/// Each tick also publishes kick-readiness ([`MOUSE_READY`]) and executes at most one pending
|
||
/// compose kick ([`hid_kick`]) — the condvar wait keeps kick latency at "immediately", not "next
|
||
/// 250 ms tick", while an idle keeper still only wakes 4×/s.
|
||
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();
|
||
MOUSE_READY.store(m.driver_proto() != 0, Ordering::Relaxed);
|
||
let (mut slot, _timeout) = KICK
|
||
.wake
|
||
.wait_timeout_while(
|
||
KICK.slot.lock().unwrap(),
|
||
Duration::from_millis(250),
|
||
|k| k.is_none(),
|
||
)
|
||
.unwrap();
|
||
let aim = slot.take();
|
||
drop(slot);
|
||
if let Some(aim) = aim {
|
||
if m.driver_proto() != 0 {
|
||
perform_kick(&mut m, aim);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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(())
|
||
}
|