From 0992548de7c4a1ed31ad8f7287d92b6e3bfb452e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 11:30:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(host/windows):=20HID=20compose=20kick=20?= =?UTF-8?q?=E2=80=94=20wake=20and=20dirty=20the=20display=20through=20the?= =?UTF-8?q?=20virtual=20mouse=20(lid-closed=20first-frame=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final piece of the lid-closed field report (fixes 1-3: 3d9b3290; prerequisites: 85dd2bb0 pf-mouse, 845a9760 leaf primitives, 94ca4041 pf-capture hook): the IDD first-frame gate can only pass if DWM composes at least one frame, and a lid-closed/locked/idle machine is exactly the state Windows has decided not to compose in. The SendInput compose kick is conditional on this process's context — wrong session → wrong input queue; secure desktop → blocked; display powered off → no wake. A report from the resident pf-mouse HID device is REAL input to win32k: session-independent, secure-desktop-proof, wakes a powered-off display, counts as user presence. Parsec-class mechanism, and semantically honest — a remote user starting a stream IS a user arriving at this machine. - hid_kick(rect, bounds): newest-wins kick slot + condvar, serviced by the keeper thread that owns the ONE process-wide VirtualMouse (a second open() would squat the bootstrap mailbox). Not-ready (opt-out, driver missing, not yet attached) returns false → the capture crate falls back to SendInput. - perform_kick: park the pointer at the target display's center, dwell 35 ms (Stage-W3: DWM samples cursor position at the next vsync tick; the gaps also keep reports from coalescing in the driver's 8 ms timer), wiggle ~2 px, restore the saved position. Desktop→HID coordinates normalize against pf_win_display::desktop_bounds() (CCD union — correct from any session, unlike per-session GDI metrics). - ensure_resident registers the hook (pf_capture::HID_COMPOSE_KICK) — the one-way-edge inversion: pf-capture never reaches into inject. - keeper loop: condvar wait (250 ms tick) so a kick executes immediately, not at the next tick; publishes MOUSE_READY from driver_proto. Paired with pf-frame's DisplayWakeRequest (held by the capturer from before the first-frame gate): the power request keeps the display from going dark mid-session, the HID kick wakes it when it already is. Verified on winbox: combined-tree cargo check + clippy for punktfunk-host, pf-capture, pf-frame, pf-win-display all EXIT 0. Co-Authored-By: Claude Fable 5 --- .../src/inject/windows/mouse_windows.rs | 120 +++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-host/src/inject/windows/mouse_windows.rs b/crates/punktfunk-host/src/inject/windows/mouse_windows.rs index cd47e27d..4b034110 100644 --- a/crates/punktfunk-host/src/inject/windows/mouse_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/mouse_windows.rs @@ -20,8 +20,11 @@ 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::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::(); const OFF_IN_SEQ: usize = core::mem::offset_of!(MouseShm, in_seq); @@ -128,6 +131,96 @@ impl VirtualMouse { } } +/// 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>, + 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::inject::InjectorService) starts — multiple services (native + /// GameStream) share the ONE process-wide mouse, guarded here. Spawns a keeper thread that owns @@ -147,6 +240,11 @@ pub(crate) fn ensure_resident() { ); 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) @@ -159,6 +257,9 @@ pub(crate) fn ensure_resident() { /// 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() { @@ -169,7 +270,22 @@ fn keeper_thread() { ); loop { m.service(); - std::thread::sleep(Duration::from_millis(250)); + 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) => {