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:
@@ -153,9 +153,9 @@ jobs:
|
|||||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||||
# toolchain-only probe crate and is excluded.)
|
# toolchain-only probe crate and is excluded.)
|
||||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||||
- name: cargo fmt --check the safe-layer + gamepad drivers
|
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
|
||||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
|
||||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||||
run: |
|
run: |
|
||||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||||
|
|||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1041,6 +1141,25 @@ mod tests {
|
|||||||
assert!((360..=440).contains(&back), "min decoded {back} millinits");
|
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]
|
#[test]
|
||||||
fn guid_is_not_sudovda() {
|
fn guid_is_not_sudovda() {
|
||||||
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
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)
|
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
|
/// 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 +
|
/// 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
|
/// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ pub mod gamepad;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "inject/windows/gamepad_raii.rs"]
|
#[path = "inject/windows/gamepad_raii.rs"]
|
||||||
mod gamepad_raii;
|
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
|
/// 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
|
/// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff
|
||||||
/// retry.
|
/// retry.
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ pub(crate) struct InjectorService {
|
|||||||
|
|
||||||
impl InjectorService {
|
impl InjectorService {
|
||||||
pub(crate) fn start() -> 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>();
|
let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();
|
||||||
if let Err(e) = std::thread::Builder::new()
|
if let Err(e) = std::thread::Builder::new()
|
||||||
.name("punktfunk-injector".into())
|
.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
|
/// PnP instance id — distinct namespaces per type (`pf_pad_<idx>` vs `pf_ds4_<idx>`) so the two
|
||||||
/// never reuse the same devnode shell.
|
/// never reuse the same devnode shell.
|
||||||
pub instance: &'a str,
|
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,
|
pub container_index: u8,
|
||||||
/// The INF-matched hardware id (`pf_dualsense` / `pf_dualshock4`), listed FIRST so the INF binds.
|
/// The INF-matched hardware id (`pf_dualsense` / `pf_dualshock4`), listed FIRST so the INF binds.
|
||||||
pub hwid: &'a str,
|
pub hwid: &'a str,
|
||||||
@@ -160,9 +165,9 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
.encode_utf16()
|
.encode_utf16()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect();
|
.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(
|
let container = GUID::from_values(
|
||||||
0x5046_4453,
|
p.container_tag,
|
||||||
0x0000,
|
0x0000,
|
||||||
0x0000,
|
0x0000,
|
||||||
[0, 0, 0, 0, 0, 0, 0, p.container_index],
|
[0, 0, 0, 0, 0, 0, 0, p.container_index],
|
||||||
@@ -300,6 +305,7 @@ impl DsWinPad {
|
|||||||
let inst = format!("{}_{index}", id.instance_prefix);
|
let inst = format!("{}_{index}", id.instance_prefix);
|
||||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||||
instance: &inst,
|
instance: &inst,
|
||||||
|
container_tag: 0x5046_4453, // "PFDS"
|
||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: id.hwid,
|
hwid: id.hwid,
|
||||||
usb_vid_pid: id.usb_vid_pid,
|
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 inst = format!("pf_deckspike_{index}");
|
||||||
let (hsw, _) = create_swdevice(&SwDeviceProfile {
|
let (hsw, _) = create_swdevice(&SwDeviceProfile {
|
||||||
instance: &inst,
|
instance: &inst,
|
||||||
|
container_tag: 0x5046_4453, // "PFDS"
|
||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: "pf_steamdeck",
|
hwid: "pf_steamdeck",
|
||||||
usb_vid_pid: "VID_28DE&PID_1205",
|
usb_vid_pid: "VID_28DE&PID_1205",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ impl Ds4WinPad {
|
|||||||
let inst = format!("pf_ds4_{index}");
|
let inst = format!("pf_ds4_{index}");
|
||||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||||
instance: &inst,
|
instance: &inst,
|
||||||
|
container_tag: 0x5046_4453, // "PFDS"
|
||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: "pf_dualshock4",
|
hwid: "pf_dualshock4",
|
||||||
usb_vid_pid: "VID_054C&PID_09CC",
|
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 inst = format!("pf_deck_{index}");
|
||||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||||
instance: &inst,
|
instance: &inst,
|
||||||
|
container_tag: 0x5046_4453, // "PFDS"
|
||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: "pf_steamdeck",
|
hwid: "pf_steamdeck",
|
||||||
usb_vid_pid: "VID_28DE&PID_1205",
|
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.
|
// Windows N4 SPIKE: hold a software-devnode HID Steam Deck and watch Steam Input promote it.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
Some("deck-windows-spike") => devtest::deck_windows_spike(&args),
|
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
|
// 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`.
|
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
supplied DRIVER_CERT secret) + ONE exported .cer - the layout `punktfunk-host.exe driver install
|
supplied DRIVER_CERT secret) + ONE exported .cer - the layout `punktfunk-host.exe driver install
|
||||||
--gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer).
|
--gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer).
|
||||||
|
|
||||||
Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + punktfunk-driver.cer.
|
Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} +
|
||||||
|
punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad — it shares
|
||||||
|
this pipeline + the --gamepad install path.)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
pwsh -File build-gamepad-drivers.ps1 -Out C:\t\gamepad
|
pwsh -File build-gamepad-drivers.ps1 -Out C:\t\gamepad
|
||||||
@@ -37,6 +39,10 @@ $clear = Join-Path $PSScriptRoot 'clear-force-integrity.ps1'
|
|||||||
$drivers = @(
|
$drivers = @(
|
||||||
@{ crate = 'pf-dualsense'; dll = 'pf_dualsense.dll'; inx = 'pf-dualsense\pf_dualsense.inx'; inf = 'pf_dualsense.inf'; cat = 'pf_dualsense.cat' }
|
@{ crate = 'pf-dualsense'; dll = 'pf_dualsense.dll'; inx = 'pf-dualsense\pf_dualsense.inx'; inf = 'pf_dualsense.inf'; cat = 'pf_dualsense.cat' }
|
||||||
@{ crate = 'pf-xusb'; dll = 'pf_xusb.dll'; inx = 'pf-xusb\pf_xusb.inx'; inf = 'pf_xusb.inf'; cat = 'pf_xusb.cat' }
|
@{ crate = 'pf-xusb'; dll = 'pf_xusb.dll'; inx = 'pf-xusb\pf_xusb.inx'; inf = 'pf_xusb.inf'; cat = 'pf_xusb.cat' }
|
||||||
|
# Not a gamepad, but it rides the identical UMDF HID pipeline + the same install path
|
||||||
|
# (`driver install --gamepad` adds every staged .inf): the resident virtual HID mouse that
|
||||||
|
# keeps SM_MOUSEPRESENT true so DWM composites a cursor on headless hosts.
|
||||||
|
@{ crate = 'pf-mouse'; dll = 'pf_mouse.dll'; inx = 'pf-mouse\pf_mouse.inx'; inf = 'pf_mouse.inf'; cat = 'pf_mouse.cat' }
|
||||||
)
|
)
|
||||||
foreach ($d in $drivers) {
|
foreach ($d in $drivers) {
|
||||||
if (-not (Test-Path (Join-Path $DriversDir $d.inx))) { throw "no $($d.inx) under $DriversDir" }
|
if (-not (Test-Path (Join-Path $DriversDir $d.inx))) { throw "no $($d.inx) under $DriversDir" }
|
||||||
|
|||||||
Generated
+11
@@ -412,6 +412,17 @@ dependencies = [
|
|||||||
"wdk-sys",
|
"wdk-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-mouse"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"pf-driver-proto",
|
||||||
|
"pf-umdf-util",
|
||||||
|
"wdk",
|
||||||
|
"wdk-build",
|
||||||
|
"wdk-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-umdf-util"
|
name = "pf-umdf-util"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# crates/pf-driver-proto from the main tree.
|
# crates/pf-driver-proto from the main tree.
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb"]
|
members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb", "pf-mouse"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# pf-mouse - punktfunk virtual HID mouse (absolute pointer) UMDF2 HID minidriver.
|
||||||
|
# A member of the in-tree drivers workspace (shares the vendored wdk-sys/wdk-build with the bindgen pin
|
||||||
|
# + the crt-static .cargo/config), built from source per release like the gamepad drivers.
|
||||||
|
[package]
|
||||||
|
name = "pf-mouse"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
description = "punktfunk virtual HID mouse (absolute pointer) UMDF2 HID minidriver"
|
||||||
|
|
||||||
|
[package.metadata.wdk.driver-model]
|
||||||
|
driver-type = "UMDF"
|
||||||
|
umdf-version-major = 2
|
||||||
|
target-umdf-version-minor = 31
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
wdk-build.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wdk.workspace = true
|
||||||
|
wdk-sys.workspace = true
|
||||||
|
pf-driver-proto.workspace = true
|
||||||
|
pf-umdf-util.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["hid"]
|
||||||
|
hid = ["wdk-sys/hid"]
|
||||||
|
nightly = ["wdk-sys/nightly", "wdk/nightly"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// License: MIT OR Apache-2.0
|
||||||
|
|
||||||
|
//! Build script for the `sample-umdf-driver` crate.
|
||||||
|
//!
|
||||||
|
//! Based on the [`wdk_build::Config`] parsed from the build tree, this build
|
||||||
|
//! script will provide `Cargo` with the necessary information to build the
|
||||||
|
//! driver binary (ex. linker flags)
|
||||||
|
|
||||||
|
fn main() -> Result<(), wdk_build::ConfigError> {
|
||||||
|
wdk_build::configure_wdk_binary_build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
;/*++
|
||||||
|
; punktfunk virtual HID mouse (absolute pointer) — UMDF2 HID minidriver INF.
|
||||||
|
; Same skeleton as pf_dualsense.inx (the WDK vhidmini2 UMDF2 shape).
|
||||||
|
; Depends on MsHidUmdf.inf (build >= 22000).
|
||||||
|
; Install: devgen /add /hardwareid "root\pf_mouse" (after pnputil /add-driver /install)
|
||||||
|
;--*/
|
||||||
|
[Version]
|
||||||
|
Signature="$WINDOWS NT$"
|
||||||
|
Class=HIDClass
|
||||||
|
ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da}
|
||||||
|
Provider=%ProviderString%
|
||||||
|
CatalogFile=pf_mouse.cat
|
||||||
|
PnpLockdown=1
|
||||||
|
|
||||||
|
[DestinationDirs]
|
||||||
|
DefaultDestDir = 13
|
||||||
|
|
||||||
|
[SourceDisksNames]
|
||||||
|
1=%Disk_Description%,,,
|
||||||
|
|
||||||
|
[SourceDisksFiles]
|
||||||
|
pf_mouse.dll=1
|
||||||
|
|
||||||
|
[Manufacturer]
|
||||||
|
%ManufacturerString%=pf, NT$ARCH$.10.0...22000
|
||||||
|
|
||||||
|
[pf.NT$ARCH$.10.0...22000]
|
||||||
|
; Hardware ids: `root\pf_mouse` for a root-enumerated devnode (devgen/devcon tests); `pf_mouse` for
|
||||||
|
; the host's SwDeviceCreate'd resident pointer (the `root\` prefix is reserved for root enumeration,
|
||||||
|
; so SwDeviceCreate rejects it with E_INVALIDARG).
|
||||||
|
%DeviceDesc%=pfMouse, root\pf_mouse, pf_mouse
|
||||||
|
|
||||||
|
[pfMouse.NT]
|
||||||
|
CopyFiles=UMDriverCopy
|
||||||
|
Include=MsHidUmdf.inf
|
||||||
|
Needs=MsHidUmdf.NT
|
||||||
|
Include=WUDFRD.inf
|
||||||
|
Needs=WUDFRD_LowerFilter.NT
|
||||||
|
|
||||||
|
[pfMouse.NT.hw]
|
||||||
|
Include=MsHidUmdf.inf
|
||||||
|
Needs=MsHidUmdf.NT.hw
|
||||||
|
Include=WUDFRD.inf
|
||||||
|
Needs=WUDFRD_LowerFilter.NT.hw
|
||||||
|
|
||||||
|
[pfMouse.NT.Services]
|
||||||
|
Include=MsHidUmdf.inf
|
||||||
|
Needs=MsHidUmdf.NT.Services
|
||||||
|
Include=WUDFRD.inf
|
||||||
|
Needs=WUDFRD_LowerFilter.NT.Services
|
||||||
|
|
||||||
|
[pfMouse.NT.Filters]
|
||||||
|
Include=WUDFRD.inf
|
||||||
|
Needs=WUDFRD_LowerFilter.NT.Filters
|
||||||
|
|
||||||
|
[pfMouse.NT.Wdf]
|
||||||
|
UmdfService="pf_mouse", pf_mouse_Install
|
||||||
|
UmdfServiceOrder=pf_mouse
|
||||||
|
UmdfKernelModeClientPolicy=AllowKernelModeClients
|
||||||
|
UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects
|
||||||
|
UmdfMethodNeitherAction=Copy
|
||||||
|
UmdfFsContextUsePolicy=CanUseFsContext2
|
||||||
|
; Its own WUDFHost so the driver's per-device statics never collide with a gamepad's (parity with
|
||||||
|
; the pad INFs — and the resident mouse outlives any session, so isolation is cheap insurance).
|
||||||
|
UmdfHostProcessSharing=ProcessSharingDisabled
|
||||||
|
|
||||||
|
[pf_mouse_Install]
|
||||||
|
UmdfLibraryVersion=$UMDFVERSION$
|
||||||
|
ServiceBinary="%13%\pf_mouse.dll"
|
||||||
|
|
||||||
|
[UMDriverCopy]
|
||||||
|
pf_mouse.dll
|
||||||
|
|
||||||
|
[Strings]
|
||||||
|
ProviderString ="punktfunk"
|
||||||
|
ManufacturerString ="punktfunk"
|
||||||
|
ClassName ="HID device"
|
||||||
|
Disk_Description ="punktfunk Mouse Installation Disk"
|
||||||
|
DeviceDesc ="punktfunk Virtual Mouse"
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
// punktfunk virtual HID mouse — UMDF2 HID minidriver (absolute pointer).
|
||||||
|
//
|
||||||
|
// Why it exists: with NO pointing device present (a headless streaming host — no dongle), win32k
|
||||||
|
// reports the cursor as absent (`SM_MOUSEPRESENT` = 0) and DWM never composites a cursor into the
|
||||||
|
// pf-vdisplay frame, so the streamed desktop has an invisible pointer even though `SendInput`
|
||||||
|
// moves it. This driver keeps a resident HID mouse devnode alive for the host service's lifetime,
|
||||||
|
// which makes Windows always consider a pointer present and draw the cursor — the industry-standard
|
||||||
|
// fix (what Sunshine/Parsec-class virtual-input drivers achieve). Injection stays `SendInput`;
|
||||||
|
// the report path below is exercised by `punktfunk-host vmouse-spike` (validation) and is the
|
||||||
|
// future higher-fidelity injection route.
|
||||||
|
//
|
||||||
|
// Structure is pf-dualsense minus the identity zoo: one fixed HID identity (PF:MO, an obviously
|
||||||
|
// virtual VID/PID no software matches on), one 8-byte input report (5 buttons + absolute 15-bit
|
||||||
|
// X/Y + wheel + AC-pan), no feature/output reports. The host channel is the **sealed pad channel**
|
||||||
|
// (design/gamepad-channel-sealing.md) verbatim — mailbox `Global\pfmouse-boot-<i>`, unnamed
|
||||||
|
// `pf_driver_proto::mouse::MouseShm` DATA section — so the whole handshake + shared-memory surface
|
||||||
|
// lives in `pf_umdf_util` (the audited unsafe layer) and this crate's logic is 100% SAFE Rust; the
|
||||||
|
// only `unsafe` here is the unavoidable WDF setup FFI, each with a `// SAFETY:` proof.
|
||||||
|
//
|
||||||
|
// Report delivery is EVENT-DRIVEN like a real mouse: the timer completes a pended READ_REPORT only
|
||||||
|
// when the host bumped `in_seq` — an idle section generates no HID traffic (a constant report
|
||||||
|
// stream would read as user activity to the OS: idle timers, display sleep).
|
||||||
|
|
||||||
|
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
|
||||||
|
// Every remaining `unsafe {}` (all WDF setup FFI) must carry a `// SAFETY:` proof.
|
||||||
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
|
||||||
|
|
||||||
|
use pf_driver_proto::mouse::{
|
||||||
|
MOUSE_PID, MOUSE_REPORT_ID, MOUSE_REPORT_LEN, MOUSE_VER, MOUSE_VID, MouseShm,
|
||||||
|
};
|
||||||
|
use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
|
||||||
|
use pf_umdf_util::nt_success;
|
||||||
|
use pf_umdf_util::wdf::{self, Request};
|
||||||
|
use wdk_sys::{
|
||||||
|
NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
|
||||||
|
WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES,
|
||||||
|
WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER,
|
||||||
|
call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- NTSTATUS values ----
|
||||||
|
const STATUS_SUCCESS: NTSTATUS = 0;
|
||||||
|
const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
|
||||||
|
|
||||||
|
// ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ----
|
||||||
|
const fn hid_ctl(id: u32) -> u32 {
|
||||||
|
(0x0000_000b << 16) | (id << 2) | 3
|
||||||
|
}
|
||||||
|
const IOCTL_HID_GET_DEVICE_DESCRIPTOR: u32 = hid_ctl(0);
|
||||||
|
const IOCTL_HID_GET_REPORT_DESCRIPTOR: u32 = hid_ctl(1);
|
||||||
|
const IOCTL_HID_READ_REPORT: u32 = hid_ctl(2);
|
||||||
|
const IOCTL_HID_WRITE_REPORT: u32 = hid_ctl(3);
|
||||||
|
const IOCTL_HID_GET_DEVICE_ATTRIBUTES: u32 = hid_ctl(9);
|
||||||
|
const IOCTL_HID_GET_STRING: u32 = hid_ctl(4);
|
||||||
|
const IOCTL_UMDF_HID_SET_OUTPUT_REPORT: u32 = hid_ctl(22);
|
||||||
|
const IOCTL_UMDF_HID_GET_INPUT_REPORT: u32 = hid_ctl(23);
|
||||||
|
|
||||||
|
// ---- WDF enum values ----
|
||||||
|
const WdfIoQueueDispatchParallel: i32 = 2;
|
||||||
|
const WdfIoQueueDispatchManual: i32 = 3;
|
||||||
|
const WdfUseDefault: i32 = 2; // WDF_TRI_STATE
|
||||||
|
const WdfExecutionLevelInheritFromParent: i32 = 1; // WDF_EXECUTION_LEVEL
|
||||||
|
const WdfSynchronizationScopeInheritFromParent: i32 = 1; // WDF_SYNCHRONIZATION_SCOPE
|
||||||
|
|
||||||
|
// HID report descriptor (80 bytes): one application collection (Generic Desktop / Mouse), report
|
||||||
|
// id 0x01 — 5 buttons, ABSOLUTE 15-bit X/Y (logical 0..=32767), relative wheel + AC-pan. Absolute
|
||||||
|
// axes so a future report-driven injection maps 1:1 onto the desktop, and so the OS treats the
|
||||||
|
// device as a pointer that never "drifts"; presence (not fidelity) is this driver's job today.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
static MOUSE_RDESC: [u8; 80] = [
|
||||||
|
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||||
|
0x09, 0x02, // Usage (Mouse)
|
||||||
|
0xA1, 0x01, // Collection (Application)
|
||||||
|
0x85, 0x01, // Report ID (1)
|
||||||
|
0x09, 0x01, // Usage (Pointer)
|
||||||
|
0xA1, 0x00, // Collection (Physical)
|
||||||
|
0x05, 0x09, // Usage Page (Button)
|
||||||
|
0x19, 0x01, // Usage Minimum (1)
|
||||||
|
0x29, 0x05, // Usage Maximum (5)
|
||||||
|
0x15, 0x00, // Logical Minimum (0)
|
||||||
|
0x25, 0x01, // Logical Maximum (1)
|
||||||
|
0x75, 0x01, // Report Size (1)
|
||||||
|
0x95, 0x05, // Report Count (5)
|
||||||
|
0x81, 0x02, // Input (Data,Var,Abs) — buttons 1..5
|
||||||
|
0x75, 0x03, // Report Size (3)
|
||||||
|
0x95, 0x01, // Report Count (1)
|
||||||
|
0x81, 0x03, // Input (Const) — pad
|
||||||
|
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||||
|
0x09, 0x30, // Usage (X)
|
||||||
|
0x09, 0x31, // Usage (Y)
|
||||||
|
0x15, 0x00, // Logical Minimum (0)
|
||||||
|
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
|
||||||
|
0x75, 0x10, // Report Size (16)
|
||||||
|
0x95, 0x02, // Report Count (2)
|
||||||
|
0x81, 0x02, // Input (Data,Var,Abs) — absolute X/Y
|
||||||
|
0x09, 0x38, // Usage (Wheel)
|
||||||
|
0x15, 0x81, // Logical Minimum (-127)
|
||||||
|
0x25, 0x7F, // Logical Maximum (127)
|
||||||
|
0x75, 0x08, // Report Size (8)
|
||||||
|
0x95, 0x01, // Report Count (1)
|
||||||
|
0x81, 0x06, // Input (Data,Var,Rel) — wheel
|
||||||
|
0x05, 0x0C, // Usage Page (Consumer)
|
||||||
|
0x0A, 0x38, 0x02, // Usage (AC Pan)
|
||||||
|
0x15, 0x81, // Logical Minimum (-127)
|
||||||
|
0x25, 0x7F, // Logical Maximum (127)
|
||||||
|
0x75, 0x08, // Report Size (8)
|
||||||
|
0x95, 0x01, // Report Count (1)
|
||||||
|
0x81, 0x06, // Input (Data,Var,Rel) — horizontal wheel
|
||||||
|
0xC0, // End Collection
|
||||||
|
0xC0, // End Collection
|
||||||
|
];
|
||||||
|
|
||||||
|
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then
|
||||||
|
// {reportType=0x22, wReportLength = 80 (0x0050)}.
|
||||||
|
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x50, 0x00];
|
||||||
|
|
||||||
|
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
|
||||||
|
fn hid_attrs() -> [u8; 32] {
|
||||||
|
let mut a = [0u8; 32];
|
||||||
|
a[0..4].copy_from_slice(&32u32.to_le_bytes());
|
||||||
|
a[4..6].copy_from_slice(&MOUSE_VID.to_le_bytes());
|
||||||
|
a[6..8].copy_from_slice(&MOUSE_PID.to_le_bytes());
|
||||||
|
a[8..10].copy_from_slice(&MOUSE_VER.to_le_bytes());
|
||||||
|
a
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A report that answers a client's GET_INPUT_REPORT query before the host published anything:
|
||||||
|
/// id + all-zero state. Never fed into the input stream (READ_REPORT completes only on a fresh
|
||||||
|
/// host publish), so it cannot warp the cursor to (0,0).
|
||||||
|
const NEUTRAL_REPORT: [u8; MOUSE_REPORT_LEN] = {
|
||||||
|
let mut r = [0u8; MOUSE_REPORT_LEN];
|
||||||
|
r[0] = MOUSE_REPORT_ID;
|
||||||
|
r
|
||||||
|
};
|
||||||
|
|
||||||
|
static MANUAL_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut());
|
||||||
|
/// The latest host-published report (kept for GET_INPUT_REPORT queries).
|
||||||
|
static INPUT_REPORT: std::sync::Mutex<[u8; MOUSE_REPORT_LEN]> =
|
||||||
|
std::sync::Mutex::new(NEUTRAL_REPORT);
|
||||||
|
/// The last `in_seq` a READ_REPORT was completed for — the event-driven gate. NOT advanced when no
|
||||||
|
/// read is pended (the next tick retries), so a publish is never dropped while a reader exists.
|
||||||
|
static DELIVERED_SEQ: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
// ---- the sealed host channel: layouts + offsets from pf_driver_proto (drift = compile error) ----
|
||||||
|
const SHM_MAGIC: u32 = pf_driver_proto::mouse::MOUSE_MAGIC; // "PFMO"
|
||||||
|
const SHM_SIZE: usize = core::mem::size_of::<MouseShm>();
|
||||||
|
const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION;
|
||||||
|
|
||||||
|
// MouseShm field offsets (the driver reads report + in_seq, writes the health marks).
|
||||||
|
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 sealed-channel client (`ProcessSharingDisabled` gives the mouse its own WUDFHost, so this
|
||||||
|
/// static is per-device). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||||
|
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||||
|
|
||||||
|
/// This device's channel config (magic/size/index offset + our logger).
|
||||||
|
fn channel_cfg() -> ChannelConfig {
|
||||||
|
ChannelConfig {
|
||||||
|
tag: "pf-mouse",
|
||||||
|
boot_name_prefix: "Global\\pfmouse-boot-",
|
||||||
|
data_magic: SHM_MAGIC,
|
||||||
|
data_size: SHM_SIZE,
|
||||||
|
pad_index_off: OFF_PAD_INDEX,
|
||||||
|
log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
|
||||||
|
/// or the `PFMOUSE_DEBUG_LOG` (system-wide) env var — the same policy as the pad drivers (audit
|
||||||
|
/// §4.4): a RELEASE driver never writes the Public file. DebugView can't see the UMDF host across
|
||||||
|
/// session 0, so the file stays the bring-up diagnostic when enabled.
|
||||||
|
fn file_log_enabled() -> bool {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static ON: OnceLock<bool> = OnceLock::new();
|
||||||
|
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFMOUSE_DEBUG_LOG").is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex`
|
||||||
|
/// (pf-vdisplay's pattern) — no per-line open/close.
|
||||||
|
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
|
||||||
|
APPENDER
|
||||||
|
.get_or_init(|| {
|
||||||
|
if !file_log_enabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open("C:\\Users\\Public\\pfmouse-driver.log")
|
||||||
|
.ok()
|
||||||
|
.map(std::sync::Mutex::new)
|
||||||
|
})
|
||||||
|
.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(s: &str) {
|
||||||
|
if let Ok(c) = std::ffi::CString::new(s) {
|
||||||
|
// SAFETY: `c` is a valid NUL-terminated string for the duration of the call.
|
||||||
|
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
||||||
|
}
|
||||||
|
use std::io::Write;
|
||||||
|
if let Some(m) = file_appender()
|
||||||
|
&& let Ok(mut f) = m.lock()
|
||||||
|
{
|
||||||
|
let _ = writeln!(f, "{s}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
macro_rules! dbglog { ($($a:tt)*) => { log(&format!($($a)*)) } }
|
||||||
|
|
||||||
|
#[unsafe(export_name = "DriverEntry")]
|
||||||
|
pub unsafe extern "system" fn driver_entry(
|
||||||
|
driver: PDRIVER_OBJECT,
|
||||||
|
registry_path: PCUNICODE_STRING,
|
||||||
|
) -> NTSTATUS {
|
||||||
|
log("[pf-mouse] DriverEntry");
|
||||||
|
// SAFETY: zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback.
|
||||||
|
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
|
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
|
||||||
|
config.EvtDriverDeviceAdd = Some(evt_device_add);
|
||||||
|
|
||||||
|
// SAFETY: all pointers valid; driver/registry_path provided by the loader.
|
||||||
|
unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(
|
||||||
|
WdfDriverCreate,
|
||||||
|
driver,
|
||||||
|
registry_path,
|
||||||
|
WDF_NO_OBJECT_ATTRIBUTES,
|
||||||
|
&mut config,
|
||||||
|
WDF_NO_HANDLE.cast::<WDFDRIVER>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
|
||||||
|
log("[pf-mouse] EvtDeviceAdd");
|
||||||
|
|
||||||
|
// Mark as a filter (HID minidriver sits below mshidumdf.sys).
|
||||||
|
// SAFETY: device_init is provided by the framework and non-null.
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfFdoInitSetFilter, device_init) };
|
||||||
|
|
||||||
|
let mut device: WDFDEVICE = core::ptr::null_mut();
|
||||||
|
// SAFETY: device_init valid; attributes allowed null; device receives the handle.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(
|
||||||
|
WdfDeviceCreate,
|
||||||
|
&mut device_init,
|
||||||
|
WDF_NO_OBJECT_ATTRIBUTES,
|
||||||
|
&mut device
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!("[pf-mouse] WdfDeviceCreate failed 0x{:08x}", st as u32);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: `device` is the live device just created — the exact contract this fn requires.
|
||||||
|
let shm_idx = unsafe { wdf::query_location_index(device) };
|
||||||
|
CHANNEL.set_index(shm_idx);
|
||||||
|
dbglog!("[pf-mouse] shm index = {shm_idx}");
|
||||||
|
|
||||||
|
// Default parallel queue handling all IOCTLs.
|
||||||
|
// SAFETY: zeroed config then fields set; Size matches the struct.
|
||||||
|
let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
|
qcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
|
||||||
|
qcfg.DispatchType = WdfIoQueueDispatchParallel;
|
||||||
|
qcfg.PowerManaged = WdfUseDefault;
|
||||||
|
qcfg.DefaultQueue = 1;
|
||||||
|
qcfg.EvtIoDeviceControl = Some(evt_io_device_control);
|
||||||
|
// WDF_IO_QUEUE_CONFIG_INIT sets this to (ULONG)-1 (unlimited); mem::zeroed left it 0,
|
||||||
|
// which on a parallel queue means present ZERO requests → EvtIoDeviceControl never fires.
|
||||||
|
qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX;
|
||||||
|
let mut default_queue: WDFQUEUE = core::ptr::null_mut();
|
||||||
|
// SAFETY: device + config valid; attributes null; queue receives the handle.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(
|
||||||
|
WdfIoQueueCreate,
|
||||||
|
device,
|
||||||
|
&mut qcfg,
|
||||||
|
WDF_NO_OBJECT_ATTRIBUTES,
|
||||||
|
&mut default_queue
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!(
|
||||||
|
"[pf-mouse] default WdfIoQueueCreate failed 0x{:08x}",
|
||||||
|
st as u32
|
||||||
|
);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual queue: pended READ_REPORT requests are completed by the timer on fresh host input.
|
||||||
|
// SAFETY: zeroed config then fields set.
|
||||||
|
let mut mcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
|
mcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
|
||||||
|
mcfg.DispatchType = WdfIoQueueDispatchManual;
|
||||||
|
mcfg.PowerManaged = WdfUseDefault;
|
||||||
|
let mut manual_queue: WDFQUEUE = core::ptr::null_mut();
|
||||||
|
// SAFETY: device + config valid; attributes null; queue receives the handle.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(
|
||||||
|
WdfIoQueueCreate,
|
||||||
|
device,
|
||||||
|
&mut mcfg,
|
||||||
|
WDF_NO_OBJECT_ATTRIBUTES,
|
||||||
|
&mut manual_queue
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!(
|
||||||
|
"[pf-mouse] manual WdfIoQueueCreate failed 0x{:08x}",
|
||||||
|
st as u32
|
||||||
|
);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Periodic timer (parent = manual queue): sealed-channel pump + health marks + event-driven
|
||||||
|
// READ_REPORT completion. 8 ms — the proven pf-dualsense cadence; the mouse is presence-first
|
||||||
|
// (SendInput injects), so a 125 Hz ceiling on the validation/report path is fine.
|
||||||
|
// SAFETY: zeroed config then fields set.
|
||||||
|
let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
|
tcfg.Size = core::mem::size_of::<WDF_TIMER_CONFIG>() as ULONG;
|
||||||
|
tcfg.EvtTimerFunc = Some(evt_timer);
|
||||||
|
tcfg.Period = 8; // ms
|
||||||
|
tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern)
|
||||||
|
// SAFETY: a zeroed WDF_OBJECT_ATTRIBUTES is a valid all-null attributes struct; we set Size + the
|
||||||
|
// fields we use below.
|
||||||
|
let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||||
|
tattr.Size = core::mem::size_of::<WDF_OBJECT_ATTRIBUTES>() as ULONG;
|
||||||
|
tattr.ParentObject = manual_queue.cast();
|
||||||
|
// mem::zeroed leaves these at 0 (Invalid) → set them like WDF_OBJECT_ATTRIBUTES_INIT
|
||||||
|
// (matches the working vhidmini2 UMDF timer setup; avoids 0xc0200209 / 0xc00000bb).
|
||||||
|
tattr.ExecutionLevel = WdfExecutionLevelInheritFromParent;
|
||||||
|
tattr.SynchronizationScope = WdfSynchronizationScopeInheritFromParent;
|
||||||
|
let mut timer: WDFTIMER = core::ptr::null_mut();
|
||||||
|
// SAFETY: config + attributes valid; timer receives the handle.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!("[pf-mouse] WdfTimerCreate failed 0x{:08x}", st as u32);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
// SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative).
|
||||||
|
let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) };
|
||||||
|
|
||||||
|
log("[pf-mouse] device ready (HID mouse 5046:4D4F)");
|
||||||
|
STATUS_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn evt_io_device_control(
|
||||||
|
_queue: WDFQUEUE,
|
||||||
|
request: WDFREQUEST,
|
||||||
|
_output_len: usize,
|
||||||
|
_input_len: usize,
|
||||||
|
ioctl: ULONG,
|
||||||
|
) {
|
||||||
|
// SAFETY: `request` is the live request for THIS EvtIoDeviceControl invocation — exactly the
|
||||||
|
// contract `Request::new` requires. Everything after is safe (the token owns completion).
|
||||||
|
let request = unsafe { Request::new(request) };
|
||||||
|
|
||||||
|
// Skip the READ_REPORT cadence so the log stays readable; the descriptor handshake still logs.
|
||||||
|
if ioctl != IOCTL_HID_READ_REPORT {
|
||||||
|
dbglog!("[pf-mouse] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// READ_REPORT forwards to the manual queue (the timer completes it on fresh input) — this
|
||||||
|
// CONSUMES the request token, so it's handled apart from the status-and-complete paths below.
|
||||||
|
if ioctl == IOCTL_HID_READ_REPORT {
|
||||||
|
let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst);
|
||||||
|
// SAFETY: `mq` is the manual queue created in EvtDeviceAdd (a live WDFQUEUE of this device).
|
||||||
|
match unsafe { request.forward_to_queue(mq) } {
|
||||||
|
Ok(()) => {} // framework owns it now (completed by the timer)
|
||||||
|
Err((req, st)) => req.complete(st), // forward failed → complete with the error
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: NTSTATUS = match ioctl {
|
||||||
|
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(&HID_DESC),
|
||||||
|
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs()),
|
||||||
|
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(&MOUSE_RDESC),
|
||||||
|
IOCTL_UMDF_HID_GET_INPUT_REPORT => {
|
||||||
|
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
|
||||||
|
request.copy_to_output(&report)
|
||||||
|
}
|
||||||
|
// No output reports are declared; ack a stray write instead of failing the sender.
|
||||||
|
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => STATUS_SUCCESS,
|
||||||
|
IOCTL_HID_GET_STRING => on_get_string(&request),
|
||||||
|
_ => STATUS_NOT_IMPLEMENTED,
|
||||||
|
};
|
||||||
|
|
||||||
|
dbglog!("[pf-mouse] ioctl 0x{ioctl:08x} -> 0x{:08x}", status as u32);
|
||||||
|
request.complete(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word
|
||||||
|
// is the language id. Windows polls ids 0x0E/0x0F/0x10 (manufacturer/product/serial) as well as
|
||||||
|
// the 0/1/2 HID_STRING_ID_* constants — serve both (the pf-dualsense finding).
|
||||||
|
fn on_get_string(request: &Request) -> NTSTATUS {
|
||||||
|
let (bytes, _) = match request.input_bytes(4) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(st) => return st,
|
||||||
|
};
|
||||||
|
let id_val: u32 = if bytes.len() >= 4 {
|
||||||
|
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let string_id = id_val & 0xFFFF;
|
||||||
|
let s: &str = match string_id {
|
||||||
|
0 | 0x000E => "punktfunk",
|
||||||
|
2 | 0x0010 => "PFMOUSE00",
|
||||||
|
_ => "punktfunk Virtual Mouse",
|
||||||
|
};
|
||||||
|
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
||||||
|
for u in s.encode_utf16() {
|
||||||
|
wide.extend_from_slice(&u.to_le_bytes());
|
||||||
|
}
|
||||||
|
wide.extend_from_slice(&[0, 0]); // NUL terminator (UTF-16)
|
||||||
|
request.copy_to_output(&wide)
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn evt_timer(timer: WDFTIMER) {
|
||||||
|
// One sealed-channel tick: publish our pid / adopt a delivery / detect host-gone (all safe,
|
||||||
|
// via pf_umdf_util), then stamp the health marks the host watches.
|
||||||
|
let Some(view) = CHANNEL.pump(&channel_cfg()) else {
|
||||||
|
return; // host gone or not attached — nothing to deliver, nothing to mark
|
||||||
|
};
|
||||||
|
view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
||||||
|
let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
||||||
|
view.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
||||||
|
|
||||||
|
// Event-driven delivery: only when the host published a NEW report (in_seq advanced) does a
|
||||||
|
// pended READ_REPORT complete. Acquire pairs with the host's Release bump, so the report bytes
|
||||||
|
// read below are the ones that seq published. If no read is pended right now, DELIVERED_SEQ is
|
||||||
|
// NOT advanced — the next tick retries while hidclass re-pends its reader.
|
||||||
|
let seq = view.load_u32(OFF_IN_SEQ, Ordering::Acquire);
|
||||||
|
if seq == 0 || seq == DELIVERED_SEQ.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY-free queue access: the timer's parent object is the manual queue (set in
|
||||||
|
// EvtDeviceAdd); the framework guarantees a live handle here.
|
||||||
|
// SAFETY: see above — WdfTimerGetParentObject on the framework-provided live timer.
|
||||||
|
let queue =
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE;
|
||||||
|
// SAFETY: `queue` is that live manual queue — the exact contract `retrieve_next_request` needs.
|
||||||
|
let Some(request) = (unsafe { wdf::retrieve_next_request(queue) }) else {
|
||||||
|
return; // no reader pended — retry next tick (seq stays undelivered)
|
||||||
|
};
|
||||||
|
let mut report = [0u8; MOUSE_REPORT_LEN];
|
||||||
|
view.read_bytes(OFF_REPORT, &mut report);
|
||||||
|
DELIVERED_SEQ.store(seq, Ordering::Relaxed);
|
||||||
|
if report[0] == MOUSE_REPORT_ID {
|
||||||
|
if let Ok(mut g) = INPUT_REPORT.lock() {
|
||||||
|
*g = report;
|
||||||
|
}
|
||||||
|
let st = request.copy_to_output(&report);
|
||||||
|
request.complete(st);
|
||||||
|
} else {
|
||||||
|
// A malformed publish (host bug / torn first write): don't feed hidclass garbage — repend
|
||||||
|
// by completing nothing this tick. The request was already retrieved, so complete it with
|
||||||
|
// the last good report instead of dropping it on the floor.
|
||||||
|
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
|
||||||
|
let st = request.copy_to_output(&report);
|
||||||
|
request.complete(st);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user