From 95a08e99c3361c5a3f3c8f9b50f88aee8eaca843 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 3 Jul 2026 12:08:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(host/windows):=20seal=20the=20host?= =?UTF-8?q?=E2=86=94driver=20channels=20(frame=20+=20gamepad,=20proto=20v2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frame ring (pf-vdisplay) and both gamepad SHM channels move off named Global\ objects (openable by any sibling LocalService) to UNNAMED sections/events whose handles the host DuplicateHandles into the driver's verified WUDFHost with least access — frame delivery over the SYSTEM+admins-only IOCTL_SET_FRAME_CHANNEL, pads over a 32-byte named bootstrap mailbox (pid + handle value only, DoS-bounded; HID minidrivers have no control device). Driver-validated pad_index kills cross-pad redirects; v1↔v2 mixes fail closed with diagnosis logs on both sides. Sibling-LocalService denial proven empirically (design/idd-push-security.md, design/gamepad-channel-sealing.md). Driver-side raw ops now live behind pf-umdf-util (checked shm accessors, the forbid(unsafe_code) ChannelClient state machine, WDF request tokens) — the pad drivers' logic is 100% safe Rust; whole drivers workspace clippy-gated in CI. driver install --gamepad now sweeps SWD\punktfunk phantom devnodes: a re-created SwDevice REVIVES the old devnode with its previously-bound driver (never re-ranks), so an upgrade otherwise leaves the old driver serving — or, across the v1→v2 fence, a dead pad (found live on the RTX box). On-glass validated on the RTX 4090 box: frame path 7007 frames p50 2.06 ms cross-machine; DualSense + XUSB "sealed pad channel mapped"/proto=2 attach via both the test harness and a real streaming session; phantom-sweep repro. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/windows-drivers.yml | 16 +- crates/pf-driver-proto/src/lib.rs | 263 ++++++-- .../src/capture/windows/dxgi.rs | 4 + .../src/capture/windows/idd_push.rs | 577 +++++++++++------- .../src/inject/windows/dualsense_windows.rs | 86 ++- .../src/inject/windows/dualshock4_windows.rs | 74 ++- .../src/inject/windows/gamepad_raii.rs | 400 ++++++++++-- .../src/inject/windows/gamepad_windows.rs | 65 +- .../src/vdisplay/windows/manager.rs | 17 +- .../src/vdisplay/windows/pf_vdisplay.rs | 33 +- crates/punktfunk-host/src/windows/install.rs | 44 ++ design/gamepad-channel-sealing.md | 236 +++++++ design/idd-push-security.md | 145 +++++ packaging/windows/drivers/Cargo.lock | 12 + packaging/windows/drivers/Cargo.toml | 3 +- .../windows/drivers/pf-dualsense/Cargo.toml | 2 + .../windows/drivers/pf-dualsense/README.md | 5 +- .../windows/drivers/pf-dualsense/src/lib.rs | 513 ++++++---------- .../windows/drivers/pf-umdf-util/Cargo.toml | 17 + .../drivers/pf-umdf-util/src/channel.rs | 192 ++++++ .../windows/drivers/pf-umdf-util/src/lib.rs | 35 ++ .../drivers/pf-umdf-util/src/section.rs | 241 ++++++++ .../windows/drivers/pf-umdf-util/src/wdf.rs | 208 +++++++ .../drivers/pf-vdisplay/pf_vdisplay.inx | 6 +- .../drivers/pf-vdisplay/src/adapter.rs | 2 + .../drivers/pf-vdisplay/src/callbacks.rs | 15 +- .../drivers/pf-vdisplay/src/control.rs | 40 ++ .../pf-vdisplay/src/direct_3d_device.rs | 8 +- .../pf-vdisplay/src/frame_transport.rs | 328 ++++++---- .../windows/drivers/pf-vdisplay/src/lib.rs | 7 +- .../windows/drivers/pf-vdisplay/src/log.rs | 10 +- .../drivers/pf-vdisplay/src/monitor.rs | 56 +- .../pf-vdisplay/src/swap_chain_processor.rs | 65 +- packaging/windows/drivers/pf-xusb/Cargo.toml | 2 + packaging/windows/drivers/pf-xusb/README.md | 20 +- packaging/windows/drivers/pf-xusb/src/lib.rs | 401 +++++------- .../windows/drivers/wdk-iddcx/src/lib.rs | 11 +- 37 files changed, 2985 insertions(+), 1174 deletions(-) create mode 100644 design/gamepad-channel-sealing.md create mode 100644 design/idd-push-security.md create mode 100644 packaging/windows/drivers/pf-umdf-util/Cargo.toml create mode 100644 packaging/windows/drivers/pf-umdf-util/src/channel.rs create mode 100644 packaging/windows/drivers/pf-umdf-util/src/lib.rs create mode 100644 packaging/windows/drivers/pf-umdf-util/src/section.rs create mode 100644 packaging/windows/drivers/pf-umdf-util/src/wdf.rs diff --git a/.gitea/workflows/windows-drivers.yml b/.gitea/workflows/windows-drivers.yml index 00d0cd5..315c8f6 100644 --- a/.gitea/workflows/windows-drivers.yml +++ b/.gitea/workflows/windows-drivers.yml @@ -131,11 +131,21 @@ jobs: # dispatched provisioning workflow landing on a different one. Path is relative to the job # working-directory (packaging/windows/drivers). Near-noop once the toolchain is present. run: ../../../scripts/ci/ensure-windows-toolchain.ps1 - - name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay) + - name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers) # Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) + - # pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve - # against IddCxStub end-to-end (M1 step 2 gate). + # pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two + # gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub + # end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links. run: cargo build -v + - name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates) + # The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited + # unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a + # `// 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 + # 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 + - name: cargo fmt --check the safe-layer + gamepad drivers + run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build run: | # explicit --target (.cargo/config.toml) -> output under the triple subdir. diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 13b09a3..75e33c5 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -2,11 +2,17 @@ //! //! Two planes: //! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the -//! render adapter, keepalive, info, clear-all). Owned, clean, versioned — NOT the SudoVDA ABI. -//! * [`frame`] — the IDD-push frame transport: the host creates a ring of shared keyed-mutex textures -//! (+ a header + a frame-ready event) and the driver opens them and publishes composited frames into -//! them. This crate owns the [`frame::SharedHeader`] layout, the [`frame::FrameToken`] packing, the -//! `Global\` object-name scheme, and the driver-status codes. +//! render adapter, keepalive, info, clear-all, deliver the frame channel). Owned, clean, versioned — +//! NOT the SudoVDA ABI. +//! * [`frame`] — the IDD-push frame transport: the host creates a ring of **unnamed** shared +//! keyed-mutex textures (+ a header + a frame-ready event), duplicates their handles into the +//! driver's WUDFHost process and delivers the handle VALUES over +//! [`control::IOCTL_SET_FRAME_CHANNEL`]; the driver publishes composited frames into them. There is +//! deliberately no object-name scheme: an unnamed object cannot be enumerated, opened by name, or +//! pre-created ("squatted") — only the two endpoint processes ever hold a handle to any frame object +//! (the sealed channel, `design/idd-push-security.md`). This crate owns the [`frame::SharedHeader`] +//! layout, the [`frame::FrameToken`] packing, the channel-delivery struct, and the driver-status +//! codes. //! //! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs` //! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them @@ -43,16 +49,22 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) { /// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host /// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting. -pub const PROTOCOL_VERSION: u32 = 1; +/// v2: the sealed frame channel — the frame objects are unnamed and delivered by handle duplication +/// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication +/// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is +/// incompatible by design. +pub const PROTOCOL_VERSION: u32 = 2; /// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`. pub const fn ctl_code(func: u32) -> u32 { (0x22u32 << 16) | (func << 2) } -/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive. +/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive + +/// frame-channel delivery. pub mod control { use super::ctl_code; + use super::frame::RING_LEN; use bytemuck::{Pod, Zeroable}; // Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering. @@ -69,6 +81,10 @@ pub mod control { /// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the /// SudoVDA "send-and-hope-it's-ignored" hack. pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905); + /// Deliver a monitor's IDD-push frame channel: the handle VALUES of the unnamed shared objects the + /// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once + /// after the ring is created and again on every mid-session ring recreate (HDR-mode flip). + pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906); /// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns /// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this @@ -103,6 +119,11 @@ pub mod control { /// `_reserved` (offset 12): an un-upgraded driver leaves it `0`, so the host can tell its /// preference was ignored (stale driver) and log it instead of silently losing per-client config. pub resolved_monitor_id: u32, + /// The driver's own process id (the WUDFHost hosting `pf_vdisplay`) — the target the host + /// duplicates the unnamed frame-object handles INTO (`OpenProcess(PROCESS_DUP_HANDLE)` + + /// `DuplicateHandle`, then [`IOCTL_SET_FRAME_CHANNEL`]). Reported per-ADD, not per-open, so a + /// WUDFHost restart between sessions can never leave the host duplicating into a dead process. + pub wudf_pid: u32, } /// `IOCTL_REMOVE` input. @@ -129,6 +150,39 @@ pub mod control { pub watchdog_timeout_s: u32, } + /// `IOCTL_SET_FRAME_CHANNEL` input — the sealed frame channel's bootstrap. Every handle field is a + /// handle VALUE already duplicated into the driver's WUDFHost process by the host; receiving it, the + /// driver OWNS those handles (it closes whatever it doesn't consume — a replaced, invalid, or + /// unmatched delivery must not leak entries in its own handle table). + /// + /// Handle values are only meaningful inside the target process's handle table, so this struct is + /// harmless to any third party: reading it leaks nothing openable, and spoofing it (were the control + /// device reachable — it is ACL'd to SYSTEM + admins) could at worst feed the driver values that + /// don't resolve, a DoS of the attacker's own session. The frame objects themselves are unnamed and + /// therefore unreachable by any process that isn't one of the two endpoints. + #[repr(C)] + #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] + pub struct SetFrameChannelRequest { + /// The OS target id from [`AddReply`] — which monitor this channel belongs to. + pub target_id: u32, + /// The ring generation these textures belong to (must match the shared header's generation at + /// attach time; a stale delivery is dropped by the driver — a fresh one follows every recreate). + pub generation: u32, + /// How many leading entries of `texture_handles` are valid (`1..=`[`RING_LEN`]). + pub ring_len: u32, + pub _pad: u32, + /// The shared-header file-mapping handle (the driver maps it and writes status/publish tokens). + pub header_handle: u64, + /// The frame-ready auto-reset event handle (the driver signals it after each publish). + pub event_handle: u64, + /// The ring textures' shared NT handles (opened via `ID3D11Device1::OpenSharedResource1`). + pub texture_handles: [u64; RING_LEN_USIZE], + } + + /// [`RING_LEN`] as a usize for the `texture_handles` array length (the wire struct sizes the array + /// at the compile-time maximum; `ring_len` says how many entries are live). + pub const RING_LEN_USIZE: usize = RING_LEN as usize; + // Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already // rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!` // asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss. @@ -142,11 +196,20 @@ pub mod control { assert!(offset_of!(AddRequest, refresh_hz) == 16); assert!(offset_of!(AddRequest, preferred_monitor_id) == 20); - assert!(size_of::() == 16); + assert!(size_of::() == 20); assert!(offset_of!(AddReply, adapter_luid_low) == 0); assert!(offset_of!(AddReply, adapter_luid_high) == 4); assert!(offset_of!(AddReply, target_id) == 8); assert!(offset_of!(AddReply, resolved_monitor_id) == 12); + assert!(offset_of!(AddReply, wudf_pid) == 16); + + assert!(size_of::() == 32 + 8 * RING_LEN_USIZE); + assert!(offset_of!(SetFrameChannelRequest, target_id) == 0); + assert!(offset_of!(SetFrameChannelRequest, generation) == 4); + assert!(offset_of!(SetFrameChannelRequest, ring_len) == 8); + assert!(offset_of!(SetFrameChannelRequest, header_handle) == 16); + assert!(offset_of!(SetFrameChannelRequest, event_handle) == 24); + assert!(offset_of!(SetFrameChannelRequest, texture_handles) == 32); assert!(size_of::() == 8); assert!(offset_of!(RemoveRequest, session_id) == 0); @@ -161,11 +224,12 @@ pub mod control { }; } -/// The IDD-push frame transport: the host-created shared ring header, the publish token, the names, and -/// the driver-status codes. The texture ring itself is host-created D3D11 keyed-mutex textures (opened -/// by name on the driver side); only the *layout/contract* lives here. +/// The IDD-push frame transport: the host-created shared ring header, the publish token, and the +/// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures; +/// the driver reaches them (and the header + event) only through handles the host duplicated into its +/// process and delivered via [`crate::control::IOCTL_SET_FRAME_CHANNEL`] — the sealed channel. Only the +/// *layout/contract* lives here. pub mod frame { - use alloc::string::String; use bytemuck::{Pod, Zeroable}; /// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver @@ -195,8 +259,10 @@ pub mod frame { pub struct SharedHeader { pub magic: u32, pub version: u32, - /// Bumped by the host on a ring recreate (HDR-mode flip → new texture format/names). The driver - /// re-attaches when it changes; a publish carries it so the host rejects a stale-ring publish. + /// Bumped by the host on a ring recreate (HDR-mode flip → new texture format + a fresh + /// [`control::IOCTL_SET_FRAME_CHANNEL`](crate::control::IOCTL_SET_FRAME_CHANNEL) delivery). The + /// driver re-attaches when it changes; a publish carries it so the host rejects a stale-ring + /// publish. pub generation: u32, pub ring_len: u32, pub width: u32, @@ -245,21 +311,6 @@ pub mod frame { } } - /// `Global\pfvd-hdr-` — the shared metadata header mapping name. - pub fn header_name(target_id: u32) -> String { - alloc::format!("Global\\pfvd-hdr-{target_id}") - } - /// `Global\pfvd-evt-` — the frame-ready auto-reset event name. - pub fn event_name(target_id: u32) -> String { - alloc::format!("Global\\pfvd-evt-{target_id}") - } - /// `Global\pfvd-tex---` — a ring texture's shared-handle name. The - /// generation in the name means a recreate's new textures never collide with the old ring's - /// not-yet-released handles. - pub fn texture_name(target_id: u32, generation: u32, slot: u32) -> String { - alloc::format!("Global\\pfvd-tex-{target_id}-{generation}-{slot}") - } - // Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the // mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after // `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too. @@ -292,8 +343,10 @@ pub mod frame { /// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!` /// asserts makes a one-sided edit a compile error. /// -/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can -/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory. +/// Since v2 the channel is **sealed** (`design/gamepad-channel-sealing.md`, mirroring the frame +/// channel): the host creates the DATA section ([`XusbShm`]/[`PadShm`]) UNNAMED (SYSTEM-only DACL) +/// and duplicates its handle into the driver's WUDFHost; only the tiny [`PadBootstrap`] mailbox +/// stays named (it carries nothing exploitable). Layout only; the sections are host-created. pub mod gamepad { use alloc::string::String; use bytemuck::{Pod, Zeroable}; @@ -316,15 +369,68 @@ pub mod gamepad { /// The section starts zeroed, so `0` always means "no driver has attached (yet)"; a pre-health /// driver never writes the field and reads as not-attached, which the host log line calls out /// (the remedy is the same: reinstall the drivers). Bump on a gamepad-layout change. - pub const GAMEPAD_PROTO_VERSION: u32 = 1; + /// + /// v2: the **sealed pad channel** (`design/gamepad-channel-sealing.md`) — the DATA section + /// ([`XusbShm`]/[`PadShm`]) is UNNAMED and reaches the driver only as a handle the host duplicated + /// into its WUDFHost, bootstrapped through the named [`PadBootstrap`] mailbox; the DATA section + /// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery. + /// A v1 driver opens `Global\pf…-shm-` (which no longer exists) and a v1 host never creates + /// the mailbox a v2 driver polls, so a mixed pairing fails closed either way. + pub const GAMEPAD_PROTO_VERSION: u32 = 2; - /// `Global\pfxusb-shm-` — the virtual Xbox 360 (XInput) shared section. - pub fn xusb_shm_name(index: u8) -> String { - alloc::format!("Global\\pfxusb-shm-{index}") + /// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a + /// driver only trusts a fully-initialized mailbox. + pub const BOOT_MAGIC: u32 = 0x5442_4650; + + /// `Global\pfxusb-boot-` — the virtual Xbox 360 pad's bootstrap mailbox ([`PadBootstrap`]). + pub fn xusb_boot_name(index: u8) -> String { + alloc::format!("Global\\pfxusb-boot-{index}") } - /// `Global\pfds-shm-` — the virtual DualSense / DualShock 4 shared section. - pub fn pad_shm_name(index: u8) -> String { - alloc::format!("Global\\pfds-shm-{index}") + /// `Global\pfds-boot-` — the DualSense / DualShock 4 pad's bootstrap mailbox + /// ([`PadBootstrap`]). + pub fn pad_boot_name(index: u8) -> String { + alloc::format!("Global\\pfds-boot-{index}") + } + + /// The per-pad bootstrap mailbox (32 B, named `Global\pf…-boot-`, SY+LS DACL) — the ONLY + /// named object left on the gamepad channel. It exists because the pad drivers are UMDF HID + /// minidrivers with no control device (hidclass owns the stack), so there is no IOCTL to hand the + /// driver a duplicated handle or learn its WUDFHost pid; this mailbox is the late-bound handshake: + /// + /// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order); + /// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and — + /// iff `host_proto` matches its own version — publishes `driver_pid`; + /// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA + /// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST; + /// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates + /// the mapped section's magic + `pad_index` before use. + /// + /// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a + /// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService + /// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an + /// injection (it cannot place a valid section handle in the WUDFHost, and the driver's + /// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section). + #[repr(C)] + #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] + pub struct PadBootstrap { + /// [`BOOT_MAGIC`], host-stamped last at creation. + pub magic: u32, + /// The host's [`GAMEPAD_PROTO_VERSION`]. A driver whose own version differs must NOT publish + /// its pid (fail closed) — it still writes `driver_proto` so the host can log the mismatch. + pub host_proto: u32, + /// The driver's WUDFHost process id (driver-written; `0` = no driver yet). The duplication + /// target the host verifies (`verify_is_wudfhost`) before duplicating the DATA section into it. + pub driver_pid: u32, + /// The driver's [`GAMEPAD_PROTO_VERSION`] (driver-written; diagnostics only). + pub driver_proto: u32, + /// The DATA-section handle VALUE the host duplicated into `handle_pid`'s handle table + /// (host-written; valid only inside that process). + pub data_handle: u64, + /// The pid `data_handle` was duplicated for — a driver whose pid differs ignores the delivery. + pub handle_pid: u32, + /// Bumped by the host (host-global monotonic, never 0) AFTER `data_handle`/`handle_pid` are in + /// place — the driver's new-delivery trigger. + pub handle_seq: u32, } /// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped @@ -356,7 +462,12 @@ pub mod gamepad { /// Bumped by the driver on every serviced XInput IOCTL — proves the game-visible path (it /// only advances while something polls the slot, so a static value is not an error). pub driver_heartbeat: u32, - pub _reserved1: [u8; 24], + /// The pad index this section serves (host-stamped before the magic). The driver validates it + /// against its own `pszDeviceLocation` index when it maps the delivered handle, so a mis-routed + /// (or bootstrap-tampered) cross-pad delivery is rejected instead of silently cross-wiring two + /// pads. Carved from v1 reserved space (v2). + pub pad_index: u32, + pub _reserved1: [u8; 20], } /// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID @@ -384,7 +495,10 @@ pub mod gamepad { /// Bumped by the driver's ~125 Hz timer each tick — a true liveness heartbeat (unlike the /// XUSB one, this advances whenever the driver is loaded, game or not). pub driver_heartbeat: u32, - pub _reserved1: [u8; 104], + /// The pad index this section serves (host-stamped before the magic) — see + /// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2). + pub pad_index: u32, + pub _reserved1: [u8; 100], } // Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing @@ -408,6 +522,7 @@ pub mod gamepad { assert!(offset_of!(XusbShm, rumble_small) == 29); assert!(offset_of!(XusbShm, driver_proto) == 32); assert!(offset_of!(XusbShm, driver_heartbeat) == 36); + assert!(offset_of!(XusbShm, pad_index) == 40); assert!(size_of::() == 256); assert!(offset_of!(PadShm, magic) == 0); @@ -417,6 +532,16 @@ pub mod gamepad { assert!(offset_of!(PadShm, device_type) == 140); assert!(offset_of!(PadShm, driver_proto) == 144); assert!(offset_of!(PadShm, driver_heartbeat) == 148); + assert!(offset_of!(PadShm, pad_index) == 152); + + assert!(size_of::() == 32); + assert!(offset_of!(PadBootstrap, magic) == 0); + assert!(offset_of!(PadBootstrap, host_proto) == 4); + assert!(offset_of!(PadBootstrap, driver_pid) == 8); + assert!(offset_of!(PadBootstrap, driver_proto) == 12); + assert!(offset_of!(PadBootstrap, data_handle) == 16); + assert!(offset_of!(PadBootstrap, handle_pid) == 24); + assert!(offset_of!(PadBootstrap, handle_seq) == 28); }; } @@ -487,28 +612,71 @@ mod tests { adapter_luid_high: -2, target_id: 262, resolved_monitor_id: 7, + wudf_pid: 4242, }; let rbytes = bytemuck::bytes_of(&reply); - assert_eq!(rbytes.len(), 16); + assert_eq!(rbytes.len(), 20); assert_eq!(*bytemuck::from_bytes::(rbytes), reply); // resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible. assert_eq!(rbytes[12..16], 7u32.to_le_bytes()); + // The v2 duplication-target pid trails at offset 16. + assert_eq!(rbytes[16..20], 4242u32.to_le_bytes()); } #[test] - fn names_are_stable() { - assert_eq!(frame::header_name(10), "Global\\pfvd-hdr-10"); - assert_eq!(frame::event_name(10), "Global\\pfvd-evt-10"); - assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5"); + fn frame_channel_request_roundtrips_through_bytes() { + let mut req = control::SetFrameChannelRequest { + target_id: 262, + generation: 3, + ring_len: frame::RING_LEN, + _pad: 0, + header_handle: 0x0000_0000_0000_1a2c, + event_handle: 0x0000_0000_0000_1b30, + texture_handles: [0; control::RING_LEN_USIZE], + }; + for (k, t) in req.texture_handles.iter_mut().enumerate() { + *t = 0x2000 + k as u64 * 4; + } + let bytes = bytemuck::bytes_of(&req); + assert_eq!(bytes.len(), 32 + 8 * control::RING_LEN_USIZE); + assert_eq!( + *bytemuck::from_bytes::(bytes), + req + ); + // The handle values ride at 8-byte alignment from offset 16 (header, event, then the ring). + assert_eq!(bytes[16..24], 0x1a2cu64.to_le_bytes()); + assert_eq!(bytes[24..32], 0x1b30u64.to_le_bytes()); + assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes()); } #[test] fn gamepad_names_and_magics_are_stable() { - assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0"); - assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2"); + assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0"); + assert_eq!(gamepad::pad_boot_name(2), "Global\\pfds-boot-2"); // Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs). assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650); assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453); + // "PFBT" little-endian. + assert_eq!(gamepad::BOOT_MAGIC.to_le_bytes(), *b"PFBT"); + } + + #[test] + fn pad_bootstrap_roundtrips_through_bytes() { + let b = gamepad::PadBootstrap { + magic: gamepad::BOOT_MAGIC, + host_proto: gamepad::GAMEPAD_PROTO_VERSION, + driver_pid: 1234, + driver_proto: gamepad::GAMEPAD_PROTO_VERSION, + data_handle: 0x0000_0000_0000_2a4c, + handle_pid: 1234, + handle_seq: 7, + }; + let bytes = bytemuck::bytes_of(&b); + assert_eq!(bytes.len(), 32); + assert_eq!(*bytemuck::from_bytes::(bytes), b); + // The handle value rides 8-aligned at offset 16; the seq trails at 28 (written LAST by the host). + assert_eq!(bytes[16..24], 0x2a4cu64.to_le_bytes()); + assert_eq!(bytes[28..32], 7u32.to_le_bytes()); } #[test] @@ -521,6 +689,7 @@ mod tests { control::IOCTL_PING, control::IOCTL_GET_INFO, control::IOCTL_CLEAR_ALL, + control::IOCTL_SET_FRAME_CHANNEL, ]; for (i, a) in all.iter().enumerate() { for b in &all[i + 1..] { diff --git a/crates/punktfunk-host/src/capture/windows/dxgi.rs b/crates/punktfunk-host/src/capture/windows/dxgi.rs index b52641a..807b28d 100644 --- a/crates/punktfunk-host/src/capture/windows/dxgi.rs +++ b/crates/punktfunk-host/src/capture/windows/dxgi.rs @@ -42,6 +42,10 @@ pub struct WinCaptureTarget { pub gdi_name: String, /// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery. pub target_id: u32, + /// The pf-vdisplay driver's WUDFHost pid (from the ADD reply) — the process the IDD-push capturer + /// duplicates the sealed frame channel's handles INTO (`idd_push::ChannelBroker`). `0` = unknown + /// (a pre-v2 pairing can't occur — the version handshake is hard — so this only guards misuse). + pub wudf_pid: u32, } /// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path). diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index cd42951..003dcc8 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -1,14 +1,20 @@ -//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver's WUDFHost canNOT create named -//! kernel objects, so — exactly like the gamepad UMDF drivers (`inject/dualsense_windows.rs`) — the -//! HOST (privileged) CREATES the shared header + frame-ready event + ring of keyed-mutex textures -//! (`Global\` names, scoped `D:(A;;GA;;;SY)(A;;GA;;;LS)` to SYSTEM + the driver's LocalService host — -//! see `shared_object_sa`) on the discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring -//! straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. Gated by -//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/ +//! P2 direct frame push (kill DDA) — HOST side, over the **sealed channel** +//! (`design/idd-push-security.md`). The frame channel carries whole-desktop pixels, so its protection +//! must match DDA's (where capturer and consumer are one process and there is no openable channel at +//! all): the HOST (SYSTEM) creates the shared header + frame-ready event + ring of keyed-mutex textures +//! **UNNAMED** on the discrete render GPU — nothing to enumerate, open by name, or pre-create +//! ("squat") — then DUPLICATES the handles into the pf-vdisplay driver's WUDFHost process +//! ([`ChannelBroker`]; SYSTEM can `DuplicateHandle` into the LocalService host, the reverse is +//! correctly denied, which is why the HOST is the broker) and delivers the handle VALUES over the +//! SYSTEM-only control device (`IOCTL_SET_FRAME_CHANNEL`). A handle value is meaningless outside the +//! target process's handle table, so the bootstrap's ACL is not load-bearing; the only way to reach the +//! frames is to already be one of the two endpoint processes. The driver copies frames in; we consume +//! the ring straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. +//! Gated by `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/ //! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the -//! `DRV_STATUS_*` codes, the `Global\` name scheme and the publish token all come from -//! [`pf_driver_proto::frame`] (which OWNS the contract, with `const` size asserts) — both sides -//! `use` it, so drift is a compile error rather than a "must match" comment. +//! `DRV_STATUS_*` codes, the channel-delivery struct and the publish token all come from +//! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so +//! drift is a compile error rather than a "must match" comment. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). #![deny(clippy::undocumented_unsafe_blocks)] @@ -16,12 +22,15 @@ use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget}; use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use anyhow::{bail, Context, Result}; -use pf_driver_proto::frame; +use pf_driver_proto::{control, frame}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use windows::core::{w, Interface, HSTRING}; -use windows::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE, LUID}; +use windows::core::{w, Interface, PCWSTR, PWSTR}; +use windows::Win32::Foundation::{ + DuplicateHandle, DUPLICATE_CLOSE_SOURCE, DUPLICATE_HANDLE_OPTIONS, DUPLICATE_SAME_ACCESS, + HANDLE, INVALID_HANDLE_VALUE, LUID, +}; use windows::Win32::Graphics::Direct3D11::{ ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, @@ -42,47 +51,43 @@ use windows::Win32::System::Memory::{ CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, }; -use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; +use windows::Win32::System::Threading::{ + CreateEventW, GetCurrentProcess, OpenProcess, QueryFullProcessImageNameW, WaitForSingleObject, + PROCESS_DUP_HANDLE, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, +}; // The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the -// `DRV_STATUS_*` codes and the `Global\` name helpers — lives in `pf_driver_proto::frame`; both sides -// `use frame::*`, so a layout/name/code drift is a compile error (the proto has `const` size asserts). +// `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides +// `use` it, so a layout/code drift is a compile error (the proto has `const` size asserts). use frame::{ - event_name, header_name, texture_name, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, - DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION, + SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, + VERSION, }; /// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not /// part of the proto contract — it is a DXGI sharing-API arg, mirrored on the driver side). const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1; +/// Least access the driver needs on the duplicated **header section**: map it read/write (it reads the +/// layout + writes `driver_status`/`driver_render_luid`/the publish token). `SECTION_MAP_READ | +/// SECTION_MAP_WRITE` (== the driver's `FILE_MAP_READ | FILE_MAP_WRITE` map flag). Duplicating with +/// exactly this — instead of `DUPLICATE_SAME_ACCESS`, which would copy the host's full-access creator +/// handle — is the "grant least privilege" discipline for unnamed shared objects (Raymond Chen, +/// *"unnamed objects aren't safe just because they're unnamed"*): a compromised driver's handle can't +/// `WRITE_DAC`/`WRITE_OWNER`/`DELETE` the object, only map it. +const SECTION_MAP_RW: u32 = 0x0004 | 0x0002; +/// Least access the driver needs on the duplicated **frame-ready event**: it only `SetEvent`s it, which +/// requires `EVENT_MODIFY_STATE`. (The host holds `SYNCHRONIZE` on its own handle to wait.) +const EVENT_MODIFY_STATE: u32 = 0x0002; + /// Host-owned output-ring depth: distinct NVENC-input textures rotated per frame so the in-flight /// encode of frame N and the convert/copy of frame N+1 never touch the same texture. 3 covers a /// pipeline depth of 2 with one slot of margin. const OUT_RING: usize = 3; -/// Bring-up debug block (fixed name) — the host creates it; the driver writes diagnostics into it -/// independent of the per-target header. NOT part of `pf_driver_proto` (a host-side bring-up channel, -/// not the data path); the matching `DebugBlock` lives in the OLD oracle driver's `frame_transport.rs`. -#[repr(C)] -struct DebugBlock { - magic: u32, - run_core_entries: u32, - resolved_target_id: u32, - header_open_attempts: u32, - last_open_error: u32, - header_opened: u32, - render_luid_low: u32, - render_luid_high: i32, - frames_acquired: u32, - _pad: u32, -} -const DBG_NAME: &str = "Global\\pfvd-dbg"; -const DBG_MAGIC: u32 = 0x4742_4450; - -/// Monotonic per-process generation: each capturer instance stamps its ring-texture names with a -/// fresh value so a retried/overlapping `open()` never collides with a previous attempt's not-yet- -/// released shared-handle names (`DXGI_ERROR_NAME_ALREADY_EXISTS`). The driver reads it from the header. +/// Monotonic per-process generation stamped into the header + every publish token, so the host rejects +/// a stale-ring publish and the driver detects a recreate. (With unnamed textures there is no name +/// collision to avoid — the generation's remaining job is the recreate/stale-publish handshake.) static IDD_GENERATION: AtomicU32 = AtomicU32::new(1); fn now_ns() -> u64 { @@ -94,7 +99,7 @@ fn now_ns() -> u64 { /// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d, /// THEN the [`OwnedHandle`] closes the underlying mapping object (order matters — unmap before close). -/// A `header`/`dbg_block` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must +/// A `header` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must /// outlive it (it's declared before it in [`IddPushCapturer`], and moving the section doesn't move the /// OS mapping, so the borrowed pointer stays valid). struct MappedSection { @@ -122,10 +127,9 @@ impl Drop for MappedSection { struct HostSlot { tex: ID3D11Texture2D, mutex: IDXGIKeyedMutex, - /// The named shared-resource handle, held only to keep the resource alive (the driver opens it by - /// NAME). An [`OwnedHandle`] so it closes on drop (was a manual `CloseHandle` in a `Drop` impl); - /// never read directly — its sole purpose is the RAII close. - #[allow(dead_code)] + /// The UNNAMED shared-resource NT handle: keeps the resource alive for the session AND is the + /// source the [`ChannelBroker`] duplicates into the driver's WUDFHost (the ONLY way the driver can + /// reach this texture — there is no name to open). An [`OwnedHandle`] so it closes on drop. shared: OwnedHandle, /// SRV on the slot texture so the HDR path samples the FP16 slot DIRECTLY (no slot→scratch copy); /// the convert pass writes the output ring while holding the slot's keyed mutex. Unused for SDR @@ -168,28 +172,238 @@ impl Drop for KeyedMutexGuard<'_> { } } +/// Confirm the process is a genuine system WUDFHost — `%SystemRoot%\System32\WUDFHost.exe` — before a +/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's +/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a +/// tampered mailbox could name an arbitrary process to receive the channel, so this is the +/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL +/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid) +/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`); +/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`). +/// +/// # Safety +/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`. +pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> { + let mut buf = [0u16; 512]; + let mut len = buf.len() as u32; + // SAFETY: `process` carries QUERY_LIMITED per the contract; `buf`/`len` are a valid out-buffer and + // its capacity, and on success `len` is updated to the count of UTF-16 units written (no NUL). + unsafe { + QueryFullProcessImageNameW( + process, + PROCESS_NAME_WIN32, + PWSTR(buf.as_mut_ptr()), + &mut len, + ) + .with_context(|| format!("QueryFullProcessImageNameW on the {what} pid"))?; + } + let path = String::from_utf16_lossy(&buf[..len as usize]); + let got = path.to_ascii_lowercase().replace('/', "\\"); + let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string()); + let expected = format!("{}\\system32\\wudfhost.exe", sysroot.to_ascii_lowercase()); + if got != expected { + bail!( + "{what} pid {wudf_pid} is not the system WUDFHost (image={path:?}, expected \ + {expected:?}) — refusing to duplicate the channel's handles into it (spoofed driver / \ + wrong devnode?)" + ); + } + Ok(()) +} + +/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects +/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its +/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device +/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the +/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made +/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost. +struct ChannelBroker { + /// `PROCESS_DUP_HANDLE` handle to the driver's WUDFHost (pid from the ADD reply; + /// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's). + process: OwnedHandle, + /// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the + /// process lifetime, so holding the bare `HANDLE` is sound. + control: HANDLE, +} + +impl ChannelBroker { + /// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0` + /// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device + /// restart mid-open) — either way the caller fails the capture open cleanly. + /// + /// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles + /// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a + /// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a + /// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes + /// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening). + fn open(wudf_pid: u32) -> Result { + if wudf_pid == 0 { + bail!("driver reported no WUDFHost pid for the frame channel"); + } + let control = crate::vdisplay::manager::control_device_handle().context( + "pf-vdisplay control device not open (monitor not created via the manager?)", + )?; + // SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and + // moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it + // for the duration of the synchronous check and forms no lasting alias. + let process = unsafe { + let h = OpenProcess( + PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION, + false, + wudf_pid, + ) + .context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?; + let process = OwnedHandle::from_raw_handle(h.0 as _); + verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?; + process + }; + Ok(Self { process, control }) + } + + /// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only + /// there — the value is meaningless in any other process). `access = Some(rights)` grants the + /// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]); + /// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the + /// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with + /// just `DXGI_SHARED_RESOURCE_READ|WRITE`). + /// + /// # Safety + /// `h` must be a live handle of the current process. + unsafe fn dup_into(&self, h: HANDLE, access: Option) -> Result { + let mut out = HANDLE::default(); + let (desired, options) = match access { + Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)), + None => (0, DUPLICATE_SAME_ACCESS), + }; + // SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target; + // `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0) + // or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both. + unsafe { + DuplicateHandle( + GetCurrentProcess(), + h, + HANDLE(self.process.as_raw_handle()), + &mut out, + desired, + false, + options, + ) + } + .context("DuplicateHandle into the driver's WUDFHost")?; + Ok(out.0 as usize as u64) + } + + /// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE` + /// with no target closes the source handle regardless of the (ignored) result. + fn close_remote(&self, value: u64) { + if value == 0 { + return; + } + // SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS + // broker just created in that process's table (callers only pass back `dup_into` results the + // driver never received); closing it there cannot touch any other process's handles. + unsafe { + let _ = DuplicateHandle( + HANDLE(self.process.as_raw_handle()), + HANDLE(value as usize as *mut core::ffi::c_void), + HANDLE::default(), + std::ptr::null_mut(), + 0, + false, + DUPLICATE_CLOSE_SOURCE, + ); + } + } + + /// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the + /// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already + /// made is reaped remotely and an error returns (the caller fails the open / logs the recreate). + /// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the + /// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice. + /// + /// # Safety + /// `header` and `event` must be live handles of the current process (the capturer's own section + + /// event, borrowed for this synchronous call). + unsafe fn send( + &self, + target_id: u32, + generation: u32, + header: HANDLE, + event: HANDLE, + slots: &[HostSlot], + ) -> Result<()> { + debug_assert!(slots.len() <= control::RING_LEN_USIZE); + let mut req = control::SetFrameChannelRequest { + target_id, + generation, + ring_len: slots.len() as u32, + _pad: 0, + header_handle: 0, + event_handle: 0, + texture_handles: [0; control::RING_LEN_USIZE], + }; + // SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live + // `OwnedHandle` the slot keeps for exactly this purpose. + let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) }; + if result.is_err() { + // The driver never adopted the delivery — reap every remote duplicate so nothing lingers. + self.close_remote(req.header_handle); + self.close_remote(req.event_handle); + for v in req.texture_handles { + self.close_remote(v); + } + } + result + } + + /// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL. + /// Split out so `send` can reap whatever landed in `req` when any step errors. + /// + /// # Safety + /// As [`Self::send`]. + unsafe fn duplicate_and_deliver( + &self, + req: &mut control::SetFrameChannelRequest, + header: HANDLE, + event: HANDLE, + slots: &[HostSlot], + ) -> Result<()> { + // SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live + // handles of this process, and `self.control` is the manager's control handle, never closed for + // the process lifetime (`send_frame_channel`'s precondition). + unsafe { + // Least privilege per handle: the header maps read/write, the event is only signalled, and + // the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`). + req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?; + req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?; + for (k, s) in slots.iter().enumerate() { + req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?; + } + crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req) + } + } +} + /// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`]. pub struct IddPushCapturer { device: ID3D11Device, context: ID3D11DeviceContext, target_id: u32, /// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE - /// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Never read - /// directly (the `header` pointer is) — held purely so the mapping outlives the capturer. - #[allow(dead_code)] + /// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Also the + /// duplication source for the driver's header handle on every [`ChannelBroker::send`]. section: MappedSection, header: *mut SharedHeader, event: OwnedHandle, - /// Owns the bring-up debug section (mapping + view), or `None` when the debug block wasn't created. - /// Never read directly (the `dbg_block` pointer is) — held purely for the RAII unmap/close. - #[allow(dead_code)] - dbg_section: Option, - dbg_block: *mut DebugBlock, + /// The sealed channel's handle-duplication broker (WUDFHost process + control device); used at open + /// and again on every ring recreate to deliver fresh duplicates. + broker: ChannelBroker, width: u32, height: u32, slots: Vec, /// The ring/texture generation, bumped every time the ring is recreated at a new format (the - /// display's HDR mode flipped). Stamped into the texture names + the header so the driver re-attaches. + /// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches + /// (and so stale-ring publishes are rejected). generation: u32, /// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open` /// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it @@ -228,25 +442,31 @@ pub struct IddPushCapturer { status_logged: bool, _keepalive: Box, } -// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader`/`*mut DebugBlock` raw -// pointers (and the COM interfaces). It is created, used, and dropped by a SINGLE thread — the owning -// capture/encode thread — never shared: the `ID3D11DeviceContext` is the device's IMMEDIATE context -// (single-threaded by D3D11 contract) and is only ever touched from that thread, and the header/ -// dbg_block pointers (into mappings this struct owns) are only dereferenced there. `Send` transfers -// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`. +// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the +// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is +// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the +// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is +// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is +// only dereferenced there. `Send` transfers ownership to one thread at a time with NO concurrent +// access; we do not (and must not) claim `Sync`. unsafe impl Send for IddPushCapturer {} -/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM** (the host creates+publishes the -/// shared event + texture ring) and **LocalService** (the account the pf_vdisplay WUDFHost runs under, -/// which consumes them) — `D:(A;;GA;;;SY)(A;;GA;;;LS)`, the same scoping as the gamepad section. The -/// old SDDL granted **Everyone** (`WD`), which let any local user open the `Global\pfvd-*` objects and -/// read captured screen frames (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): -/// the WUDFHost token is `S-1-5-19` (LocalService), SYSTEM integrity, zero restricted SIDs — so SY+LS -/// suffices for the driver and excludes normal user processes. `psd` must outlive `sa`. +/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected +/// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal +/// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the +/// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not +/// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer +/// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or +/// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically +/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost +/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box +/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable +/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. `psd` must outlive +/// `sa`. See `design/idd-push-security.md`. unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> { let mut psd = PSECURITY_DESCRIPTOR::default(); ConvertStringSecurityDescriptorToSecurityDescriptorW( - w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"), + w!("D:P(A;;GA;;;SY)"), SDDL_REVISION_1, &mut psd, None, @@ -262,20 +482,18 @@ unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTO impl IddPushCapturer { /// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched - /// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared by the name - /// `pfvd-tex---` so the driver opens it; a fresh generation gives fresh names - /// (so a recreate never collides with the old ring's not-yet-released handles). + /// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an + /// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via + /// the duplicate the [`ChannelBroker`] sends after the ring is published. unsafe fn create_ring_slots( device: &ID3D11Device, - target_id: u32, - generation: u32, w: u32, h: u32, format: DXGI_FORMAT, ) -> Result> { let (sa, _psd) = shared_object_sa()?; let mut slots = Vec::new(); - for k in 0..RING_LEN { + for _ in 0..RING_LEN { let desc = D3D11_TEXTURE2D_DESC { Width: w, Height: h, @@ -304,7 +522,7 @@ impl IddPushCapturer { .CreateSharedHandle( Some(&sa as *const SECURITY_ATTRIBUTES), DXGI_SHARED_RESOURCE_RW, - &HSTRING::from(texture_name(target_id, generation, k)), + PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate ) .context("CreateSharedHandle(IDD-push ring slot)")?; // Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`). @@ -381,22 +599,22 @@ impl IddPushCapturer { // `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing. // - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`, // `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned - // interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names - // are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid - // because its backing `_psd` is held in scope for the whole block. + // interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device` are live borrows that + // outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid because its backing + // `_psd` is held in scope for the whole block. // - The header mapping is created AND viewed at `bytes == size_of::().max(64)`; the // view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The // OS view base is page-aligned, so `section.ptr::()` is suitably aligned for a // `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay - // within those `bytes` and write THROUGH the raw pointer without forming any `&mut`. The debug - // section is the same pattern at `dbg_bytes == size_of::()`, only entered when its - // own view is non-null. + // within those `bytes` and write THROUGH the raw pointer without forming any `&mut`. // - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!` // takes the field address without a reference; the field is a 4-aligned `u32` (valid for // `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake // that orders all preceding writes before the driver may observe `MAGIC`. - // - `header`/`dbg_block` point into the OS mappings, NOT into the `MappedSection` structs, so moving - // `section`/`dbg_section` into `me` leaves them valid (see the `MappedSection` doc comment). + // - `broker.send` requires live `header`/`event` handles of this process: both borrow the just- + // created owned section/event for the duration of that synchronous call. + // - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section` + // into `me` leaves it valid (see the `MappedSection` doc comment). unsafe { // If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and // size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have @@ -428,14 +646,14 @@ impl IddPushCapturer { let (sa, _psd) = shared_object_sa()?; let bytes = std::mem::size_of::().max(64); - // Header. + // Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name). let map = CreateFileMappingW( INVALID_HANDLE_VALUE, Some(&sa), PAGE_READWRITE, 0, bytes as u32, - &HSTRING::from(header_name(target.target_id)), + PCWSTR::null(), ) .context("CreateFileMapping(IDD-push header)")?; // Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail. @@ -463,69 +681,45 @@ impl IddPushCapturer { // reads this into its `ring_format` and drops any surface that doesn't match. (*header).dxgi_format = ring_fmt.0 as u32; - // Frame-ready event (auto-reset). - let event = CreateEventW( - Some(&sa), - false, - false, - &HSTRING::from(event_name(target.target_id)), - ) - .context("CreateEvent(IDD-push)")?; + // Frame-ready event (auto-reset) — UNNAMED, like everything on this channel. + let event = CreateEventW(Some(&sa), false, false, PCWSTR::null()) + .context("CreateEvent(IDD-push)")?; let event = OwnedHandle::from_raw_handle(event.0 as _); // Ring of shared keyed-mutex textures, format matched to the display's current mode. - let slots = - Self::create_ring_slots(&device, target.target_id, generation, w, h, ring_fmt)?; + let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?; - // Bring-up debug block (fixed name) — the driver writes diagnostics here. Best-effort. - let dbg_bytes = std::mem::size_of::(); - let (dbg_section, dbg_block) = match CreateFileMappingW( - INVALID_HANDLE_VALUE, - Some(&sa), - PAGE_READWRITE, - 0, - dbg_bytes as u32, - &HSTRING::from(DBG_NAME), - ) { - Ok(dm) => { - // Own the mapping handle so it (and its view) free via `MappedSection` RAII. - let dm = OwnedHandle::from_raw_handle(dm.0 as _); - let dv = MapViewOfFile( - HANDLE(dm.as_raw_handle()), - FILE_MAP_ALL_ACCESS, - 0, - 0, - dbg_bytes, - ); - if dv.Value.is_null() { - (None, std::ptr::null_mut()) // `dm` drops → mapping closed - } else { - let section = MappedSection { - handle: dm, - view: dv, - }; - let p = section.ptr::(); - std::ptr::write_bytes(p.cast::(), 0, dbg_bytes); - (*p).magic = DBG_MAGIC; - (Some(section), p) - } - } - Err(_) => (None, std::ptr::null_mut()), - }; - - // Publish: magic LAST (Release) — signals the driver the ring is ready to open. + // Publish: magic LAST (Release) — the ring must be fully initialized before the driver + // (which receives the channel strictly afterwards) can observe MAGIC. std::sync::atomic::fence(Ordering::Release); (*(std::ptr::addr_of!((*header).magic) as *const AtomicU32)) .store(MAGIC, Ordering::Release); + // Deliver the sealed channel: duplicate header + event + every slot texture into the + // driver's WUDFHost and hand it the values over the control device. All-or-nothing (the + // broker reaps its remote duplicates on failure), and a failure fails the open — without + // the delivery the driver can never attach. + let broker = ChannelBroker::open(target.wudf_pid)?; + broker + .send( + target.target_id, + generation, + HANDLE(section.handle.as_raw_handle()), + HANDLE(event.as_raw_handle()), + &slots, + ) + .context("deliver IDD-push frame channel to the driver")?; + tracing::info!( target_id = target.target_id, + wudf_pid = target.wudf_pid, render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), mode = format!("{w}x{h}"), display_hdr, client_10bit, ring_fp16 = display_hdr, - "IDD push(host): created shared ring; waiting for the driver to attach + publish" + "IDD push(host): created sealed ring + delivered the channel; waiting for the driver \ + to attach + publish" ); let me = Self { device, @@ -534,8 +728,7 @@ impl IddPushCapturer { section, header, event, - dbg_section, - dbg_block, + broker, width: w, height: h, slots, @@ -659,34 +852,6 @@ impl IddPushCapturer { } } - /// Log the driver's bring-up diagnostics (the fixed-name debug block) — independent of the - /// per-target header, so it tells us whether the swap-chain processor ran, what target_id it - /// resolved, whether the header opened (+ error), and whether frames flowed. - fn log_debug_block(&self) { - if self.dbg_block.is_null() { - tracing::warn!("IDD push DEBUG: no debug block"); - return; - } - // SAFETY: `self.dbg_block` was just checked non-null (the early return above); it points into the - // owned `dbg_section` mapping sized exactly `size_of::()` and page-aligned, so it is - // valid + aligned for `DebugBlock`. `d` is a short-lived SHARED reference used only to read the - // fields below; we never form `&mut` into this region, and the driver's cross-process writes are - // aligned `u32`s that don't tear (best-effort bring-up diagnostics). - let d = unsafe { &*self.dbg_block }; - tracing::error!( - run_core_entries = d.run_core_entries, - resolved_target_id = d.resolved_target_id, - header_open_attempts = d.header_open_attempts, - last_open_error = format!("0x{:08x}", d.last_open_error), - header_opened = d.header_opened, - driver_render_luid = format!("{:08x}:{:08x}", d.render_luid_high, d.render_luid_low), - frames_acquired = d.frames_acquired, - "IDD push DEBUG: driver-reported diagnostics (run_core_entries=0 ⇒ swap-chain processor \ - never ran; resolved_target_id≠ours ⇒ name mismatch; last_open_error 0x80070002 ⇒ header \ - not found; frames_acquired=0 ⇒ idle display)" - ); - } - /// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR /// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client /// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so @@ -712,9 +877,10 @@ impl IddPushCapturer { } /// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the - /// generation so the driver re-attaches ([`is_stale`]) to the new-format textures; clears the - /// header's `latest` so we don't consume a stale slot from the old ring; drops the conversion - /// textures so they rebuild at the new format. + /// generation so the driver re-attaches ([`is_stale`]) to the new-format textures and DELIVERS the + /// new channel (fresh duplicates of the header + event + the new textures — every delivery is a + /// self-contained handle set the driver owns); clears the header's `latest` so we don't consume a + /// stale slot from the old ring; drops the conversion textures so they rebuild at the new format. fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> { self.display_hdr = new_display_hdr; self.width = new_w; @@ -725,16 +891,8 @@ impl IddPushCapturer { // borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain // `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every // returned slot's texture + keyed mutex belongs to that same `self.device`. - let new_slots = unsafe { - Self::create_ring_slots( - &self.device, - self.target_id, - new_gen, - self.width, - self.height, - fmt, - )? - }; + let new_slots = + unsafe { Self::create_ring_slots(&self.device, self.width, self.height, fmt)? }; // SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a // `SharedHeader`). The `latest`/`generation` stores go through `addr_of!`-formed field pointers (no // references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the @@ -759,6 +917,26 @@ impl IddPushCapturer { } self.slots = new_slots; // drops the old slots → closes their shared handles + SRVs self.generation = new_gen; + // Deliver the new generation's channel. The driver's old publisher sees the generation bump + // (`is_stale`), drops (closing its old handles), and re-attaches from this delivery. On failure + // the broker already reaped its remote duplicates; the recover-or-drop window in `try_consume` + // then ends the session cleanly (the driver can never attach to an undelivered ring). + // SAFETY: `broker.send` requires live `header`/`event` handles of this process — both borrow the + // owned `self.section.handle`/`self.event` for the duration of the synchronous call. + if let Err(e) = unsafe { + self.broker.send( + self.target_id, + new_gen, + HANDLE(self.section.handle.as_raw_handle()), + HANDLE(self.event.as_raw_handle()), + &self.slots, + ) + } { + tracing::warn!( + error = %format!("{e:#}"), + "IDD push: frame-channel re-delivery failed after ring recreate" + ); + } self.last_seq = 0; self.out_ring.clear(); // the output format changed → rebuild lazily at the new format self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode @@ -982,44 +1160,6 @@ impl IddPushCapturer { } } -/// Diagnostic observer (O3.1): create the IDD-push ring + debug block as the SYSTEM host (LocalSystem -/// — proper privileges, the gamepad pattern) ALONGSIDE the normal WGC path, which provides the -/// presentation trigger. Logs whether the driver's `run_core` ran and pushed frames into a -/// host-created ring — resolving the `run_core=0` ambiguity (a user-created ring may be unwritable by -/// the driver). Gated by `PUNKTFUNK_IDD_PUSH_OBSERVE`; spawns a short-lived sampling thread. -pub fn spawn_observer(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) { - std::thread::spawn(move || { - let tid = target.target_id; - tracing::info!( - target_id = tid, - "IDD push OBSERVER: creating host ring (LocalSystem) + debug block alongside WGC" - ); - match IddPushCapturer::open(target, preferred, false, Box::new(())) { - Ok(mut cap) => { - let mut frames = 0u32; - for _ in 0..40 { - match cap.try_consume() { - Ok(Some(_)) => frames += 1, - Ok(None) => {} - Err(e) => tracing::warn!("IDD push OBSERVER: consume error: {e:#}"), - } - std::thread::sleep(Duration::from_millis(750)); - } - tracing::info!( - target_id = tid, - frames_from_ring = frames, - "IDD push OBSERVER: sampling done" - ); - cap.log_debug_block(); - } - Err((e, _keep)) => tracing::warn!( - target_id = tid, - "IDD push OBSERVER: ring open failed: {e:#}" - ), - } - }); -} - /// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`. fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID { if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() { @@ -1046,7 +1186,6 @@ impl Capturer for IddPushCapturer { return Ok(f); } if Instant::now() > deadline { - self.log_debug_block(); // SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping — the same // best-effort diagnostic fields as `log_driver_status_once` (aligned word reads can't tear; // no reference into the shared region is formed). @@ -1093,8 +1232,10 @@ impl Capturer for IddPushCapturer { impl Drop for IddPushCapturer { fn drop(&mut self) { self.slots.clear(); - // The shared header + debug sections (`MappedSection`) and the frame-ready `event` - // (`OwnedHandle`) free themselves via RAII (each unmaps its view, then closes its handle). - // _keepalive drops after, REMOVEing the virtual display. + // The shared header section (`MappedSection`), the frame-ready `event` (`OwnedHandle`) and the + // broker's WUDFHost process handle free themselves via RAII (unmap view, then close handle) — + // nothing of this session's channel outlives the capturer on the host side; the driver's + // duplicates die with its publisher / monitor / WUDFHost (teardown invariant, + // `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display. } } diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 480229f..5e481d6 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -1,15 +1,16 @@ -//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/dualsense-driver`). +//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`). //! //! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and //! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where //! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`, -//! the Windows backend talks to the UMDF driver over a **named shared-memory section** -//! `Global\pfds-shm-` (256 B: magic `u32@0`, input report `@8`, output seq `u32@72`, output -//! report `@76`). The host creates the section (privileged → a permissive SDDL so the WUDFHost can -//! open it); the driver maps it from its timer, feeds game `READ_REPORT`s from the input bytes, and -//! publishes a game's `0x02` (rumble / lightbar / player-LEDs / adaptive triggers) into the output -//! bytes. `hidclass` gates the device stack, so this user-mode IPC is the only viable channel (a -//! UMDF driver has no control device); see `windows-dualsense-scoping.md`. +//! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (256 B `PadShm`: +//! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`) reached over the +//! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the +//! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-` +//! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02` +//! (rumble / lightbar / player-LEDs / adaptive triggers) into the output bytes. `hidclass` gates the +//! device stack, so this user-mode IPC is the only viable channel (a UMDF driver has no control +//! device); see `windows-dualsense-scoping.md`. //! //! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_` software devnode (hardware id //! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual @@ -20,12 +21,13 @@ use super::dualsense_proto::{ parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, DS_TOUCH_W, }; +use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use anyhow::{anyhow, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::ffi::c_void; use std::time::{Duration, Instant}; -use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; +use windows::core::{w, GUID, HRESULT, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, }; @@ -49,17 +51,19 @@ pub(super) const OFF_DEVTYPE: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type); pub(super) const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto); +pub(super) const OFF_PAD_INDEX: usize = + core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index); pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4; /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_` software devnode (the driver -/// loads on it and the HID DualSense appears to games) plus the shared-memory section the driver maps. -/// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section. +/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel. +/// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections. struct DsWinPad { /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). /// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen). _sw: Option, - /// The named shared section the driver maps (RAII — unmapped + closed on drop). - shm: super::gamepad_raii::Shm, + /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery. + channel: PadChannel, /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. attach: super::gamepad_raii::DriverAttach, seq: u8, @@ -184,7 +188,7 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option< .encode_utf16() .chain(std::iter::once(0)) .collect(); - // The pad index, stamped into the device Location — the driver reads it to map `pfds-shm-` + // The pad index, stamped into the device Location — the driver reads it to poll `pfds-boot-` // (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return). let loc: Vec = format!("{}", p.container_index) .encode_utf16() @@ -266,17 +270,20 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option< } impl DsWinPad { - /// Create + map the section `Global\pfds-shm-`, stamp the magic, then spawn the - /// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives - /// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`). + /// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-` mailbox), stamp + /// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_` devnode (the + /// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the + /// pad's lifetime — dropping the pad removes it (`SwDeviceClose`). fn open(index: u8) -> Result { - let shm_name = pf_driver_proto::gamepad::pad_shm_name(index); - let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; - let base = shm.base(); - // Stamp the neutral input report, then the magic LAST (the driver only accepts the section - // once magic is set). The device-type stays 0 (DualSense — the section is already zeroed). - // SAFETY: base points at SHM_SIZE writable bytes. + let boot_name = pf_driver_proto::gamepad::pad_boot_name(index); + let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; + let base = channel.data_base(); + // Stamp the pad index (the driver validates it on attach) + the neutral input report, then + // the magic LAST (the driver only accepts the section once magic is set). The device-type + // stays 0 (DualSense — the section arrives zeroed). + // SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX/OFF_INPUT are in range. unsafe { + std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { let mut r = [0u8; DS_INPUT_REPORT_LEN]; serialize_state(&mut r, &DsState::neutral(), 0, 0); @@ -286,7 +293,7 @@ impl DsWinPad { } // Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the // rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense` - // devnode (installer / dev-box devgen). + // devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name. let inst = format!("pf_pad_{index}"); let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { instance: &inst, @@ -302,14 +309,17 @@ impl DsWinPad { } }; let _sw = hsw.map(super::gamepad_raii::SwDevice::new); + // Bounded eager delivery so the driver holds the DATA section before hidclass asks it for + // descriptors (the driver reads `device_type` from the section to pick its HID identity). + channel.deliver_eager(Duration::from_millis(1500)); Ok(DsWinPad { _sw, - shm, + channel, attach: super::gamepad_raii::DriverAttach::new( "pf_dualsense", "pf_dualsense.inf", "C:\\Users\\Public\\pfds-driver.log", - shm_name, + boot_name, instance_id, ), seq: 0, @@ -326,30 +336,40 @@ impl DsWinPad { serialize_state(&mut r, st, self.seq, self.ts); // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. unsafe { - std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len()) + std::ptr::copy_nonoverlapping( + r.as_ptr(), + self.channel.data_base().add(OFF_INPUT), + r.len(), + ) }; } /// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything - /// new. Also feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps - /// `driver_proto` while it has the section mapped). + /// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the + /// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped). fn service(&mut self, pad: u8) -> DsFeedback { + self.channel.pump(); let mut fb = DsFeedback::default(); // SAFETY: base points at SHM_SIZE bytes. let proto = unsafe { - std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32) + std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) }; self.attach.observe(proto); // SAFETY: base points at SHM_SIZE bytes. - let seq = - unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) }; + let seq = unsafe { + std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32) + }; if seq != self.last_out_seq { self.last_out_seq = seq; let mut out = [0u8; 64]; // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. unsafe { - std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64) + std::ptr::copy_nonoverlapping( + self.channel.data_base().add(OFF_OUTPUT), + out.as_mut_ptr(), + 64, + ) }; parse_ds_output(pad, &out, &mut fb); } diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index 20d26a1..5cb5b3d 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -1,33 +1,33 @@ //! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of -//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the -//! `Global\pfds-shm-` shared section the driver maps), same controller model ([`DsState`]); only -//! the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the report codec -//! ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4) into the -//! section so the one UMDF driver serves the DS4 descriptor / attributes / features instead of the -//! DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar (0xCD `Led`); a DS4 -//! has no adaptive triggers / player LEDs. +//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the sealed +//! shared-memory channel bootstrapped via `Global\pfds-boot-`), same controller model +//! ([`DsState`]); only the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the +//! report codec ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4) +//! into the DATA section so the one UMDF driver serves the DS4 descriptor / attributes / features +//! instead of the DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar +//! (0xCD `Led`); a DS4 has no adaptive triggers / player LEDs. use super::dualsense_proto::DsState; use super::dualsense_windows::{ create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, - OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE, + OFF_OUTPUT, OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, }; use super::dualshock4_proto::{ parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, }; +use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use anyhow::Result; use punktfunk_core::quic::{HidOutput, RichInput}; use std::time::{Duration, Instant}; -use windows::core::HSTRING; -/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_` devnode plus the mapped -/// shared section. Dropping it removes the devnode and unmaps + closes the section. +/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_` devnode plus the sealed +/// shared-memory channel. Dropping it removes the devnode and closes both sections. struct Ds4WinPad { /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). _sw: Option, - /// The named shared section the driver maps (RAII — unmapped + closed on drop). - shm: super::gamepad_raii::Shm, + /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery. + channel: PadChannel, /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. attach: super::gamepad_raii::DriverAttach, counter: u8, @@ -36,16 +36,19 @@ struct Ds4WinPad { } impl Ds4WinPad { - /// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic, - /// then spawn the `pf_ds4_` devnode (the driver loads on it and maps the section). + /// Create the sealed channel, stamp `device_type = DualShock 4` + the pad index + a neutral + /// report + the magic LAST, then spawn the `pf_ds4_` devnode (the driver loads on it and + /// receives the DATA handle over the bootstrap). fn open(index: u8) -> Result { - let shm_name = pf_driver_proto::gamepad::pad_shm_name(index); - let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; - let base = shm.base(); - // device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST. - // SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range. + let boot_name = pf_driver_proto::gamepad::pad_boot_name(index); + let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; + let base = channel.data_base(); + // device-type FIRST (so it's visible the moment magic is), pad index, neutral report, + // magic LAST. + // SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. unsafe { *base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4; + std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], { let mut r = [0u8; DS4_INPUT_REPORT_LEN]; serialize_state(&mut r, &DsState::neutral(), 0, 0); @@ -68,14 +71,18 @@ impl Ds4WinPad { } }; let _sw = hsw.map(super::gamepad_raii::SwDevice::new); + // Bounded eager delivery — for the DS4 this is what closes the identity race: the driver + // must read `device_type = 1` from the delivered DATA section before hidclass asks it for + // descriptors, or the pad would enumerate with the (default) DualSense identity. + channel.deliver_eager(Duration::from_millis(1500)); Ok(Ds4WinPad { _sw, - shm, + channel, attach: super::gamepad_raii::DriverAttach::new( "pf_dualshock4", "pf_dualsense.inf", // one driver package serves both HID identities "C:\\Users\\Public\\pfds-driver.log", - shm_name, + boot_name, instance_id, ), counter: 0, @@ -92,29 +99,40 @@ impl Ds4WinPad { serialize_state(&mut r, st, self.counter, self.ts); // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. unsafe { - std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len()) + std::ptr::copy_nonoverlapping( + r.as_ptr(), + self.channel.data_base().add(OFF_INPUT), + r.len(), + ) }; } /// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a /// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also - /// feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps `driver_proto`). + /// ticks the sealed-channel delivery and feeds the driver-attach health watcher (the driver's + /// ~125 Hz timer stamps `driver_proto`). fn service(&mut self) -> Ds4Feedback { + self.channel.pump(); let mut fb = Ds4Feedback::default(); // SAFETY: base points at SHM_SIZE bytes. let proto = unsafe { - std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32) + std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) }; self.attach.observe(proto); // SAFETY: base points at SHM_SIZE bytes. - let seq = - unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) }; + let seq = unsafe { + std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32) + }; if seq != self.last_out_seq { self.last_out_seq = seq; let mut out = [0u8; 64]; // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. unsafe { - std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64) + std::ptr::copy_nonoverlapping( + self.channel.data_base().add(OFF_OUTPUT), + out.as_mut_ptr(), + 64, + ) }; parse_ds4_output(&out, &mut fb); } diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs index 86bec12..31c8fd5 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs @@ -1,14 +1,29 @@ -//! Per-pad Windows resource RAII for the gamepad backends (DualSense / DualShock 4 / XUSB). +//! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 / +//! XUSB backends). //! -//! Each virtual pad owns two OS resources: the named shared-memory section (+ its mapped view) the -//! `pf_dualsense`/`pf_xusb` driver reads, and the `SwDeviceCreate`'d software devnode the driver loads -//! on. Before this module, all three backends hand-rolled the same `CreateFileMappingW` + -//! `MapViewOfFile` and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` + `CloseHandle` — -//! easy to drift or leak on an error path. [`Shm`] and [`SwDevice`] own those resources with RAII, so a -//! backend just holds them and the cleanup (and ordering) happens by construction. +//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb` +//! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox +//! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the +//! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources +//! with RAII; [`PadChannel`] owns the two sections plus the delivery handshake. +//! +//! **Why the channel is sealed** (`design/gamepad-channel-sealing.md`): the DATA section used to be a +//! `Global\pf…-shm-` named section with an SY+LS DACL, which let any *sibling LocalService* +//! process open it by name to read the live controller input or inject/forge input and rumble — the +//! same name-open vector the frame ring closed (`design/idd-push-security.md`). The DATA section is now +//! UNNAMED with a SYSTEM-only DACL and reaches the driver exclusively as a handle this host duplicated +//! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad +//! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the +//! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the +//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and +//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a +//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too +//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right). -use anyhow::{anyhow, Result}; -use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use anyhow::{anyhow, bail, Context, Result}; +use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION}; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering}; use std::sync::OnceLock; use std::time::{Duration, Instant}; use windows::core::{w, HSTRING, PCWSTR}; @@ -17,7 +32,10 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{ CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED, }; use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; -use windows::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows::Win32::Foundation::{ + DuplicateHandle, GetLastError, SetLastError, DUPLICATE_HANDLE_OPTIONS, ERROR_ALREADY_EXISTS, + HANDLE, INVALID_HANDLE_VALUE, WIN32_ERROR, +}; use windows::Win32::Security::Authorization::{ ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, }; @@ -26,54 +44,102 @@ use windows::Win32::System::Memory::{ CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, }; +use windows::Win32::System::Threading::{ + GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION, +}; -/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps -/// the view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three -/// backends' hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`. -/// -/// SDDL `D:(A;;GA;;;SY)(A;;GA;;;LS)`: GENERIC_ALL to **SYSTEM** (the host creates the section and -/// writes the live HID input report into it) and **LocalService** (the account the UMDF driver's -/// WUDFHost runs under, which reads it). The old SDDL granted **Everyone** (`WD`) — on the (mistaken) -/// assumption the driver needed a restricted token's broad access — letting any local user -/// `OpenFileMapping` the section to inject controller input or tamper the trusted channel -/// (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): the WUDFHost token is -/// `S-1-5-19` (LocalService), SYSTEM integrity, with **zero restricted SIDs** — so scoping to SY+LS is -/// sufficient for the driver and excludes normal (medium-IL, non-service) user processes. +/// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so +/// `SECTION_MAP_READ | SECTION_MAP_WRITE` (== the driver's `FILE_MAP_RW`). Granted explicitly in +/// [`PadChannel::deliver_to`] instead of `DUPLICATE_SAME_ACCESS` (least privilege for the sealed +/// section — the driver's handle then can't take ownership / change security / delete the object). +const SECTION_MAP_RW: u32 = 0x0004 | 0x0002; + +/// An anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps the +/// view, then the [`OwnedHandle`] closes the section handle (in that order). Created either +/// [unnamed](Self::create_unnamed) (the sealed DATA section — reachable only by handle duplication) or +/// [named](Self::create_named) (the bootstrap mailbox the driver opens by name). pub(super) struct Shm { - /// Owns the section handle (closed on drop). Held only for ownership — never read after construction. - _handle: OwnedHandle, + /// Owns the section handle (closed on drop). Also the duplication source for the sealed channel — + /// see [`Shm::raw_handle`]. + handle: OwnedHandle, view: MEMORY_MAPPED_VIEW_ADDRESS, } +/// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable +/// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`). +fn sddl_sa(sddl: PCWSTR) -> Result { + let mut psd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (leaked — see above). + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, + SDDL_REVISION_1, + &mut psd, + None, + )?; + } + Ok(SECURITY_ATTRIBUTES { + nLength: core::mem::size_of::() as u32, + lpSecurityDescriptor: psd.0, + bInheritHandle: false.into(), + }) +} + impl Shm { - /// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned - /// immediately, so any failure below (or the returned `Shm`'s drop) closes it. - pub(super) fn create(name: &HSTRING, size: usize) -> Result { - let mut psd = PSECURITY_DESCRIPTOR::default(); - // SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process - // exit — acceptable for a host-lifetime object). - unsafe { - ConvertStringSecurityDescriptorToSecurityDescriptorW( - w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"), - SDDL_REVISION_1, - &mut psd, - None, - )?; + /// Create + zero an **unnamed** `size`-byte section, mapped read/write — the sealed DATA section. + /// SDDL `D:P(A;;GA;;;SY)` (SYSTEM-only, protected): with no name there is nothing to enumerate, + /// open, or squat, and the driver reaches it through a duplicated handle, which carries the + /// source's access without re-checking the object DACL (the exact property the frame ring + /// validated on-glass — `design/idd-push-security.md`). + pub(super) fn create_unnamed(size: usize) -> Result { + let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?; + Self::create_inner(&sa, PCWSTR::null(), size).context("create unnamed gamepad DATA section") + } + + /// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL + /// `D:(A;;GA;;;SY)(A;;GA;;;LS)`: SYSTEM (this host) + LocalService (the driver's WUDFHost opens it + /// by name). Safe to leave name-openable because it carries nothing exploitable (see the module + /// docs). **Squat-checked**: `Global\` names are creatable by any service holding + /// `SeCreateGlobalPrivilege` (LocalService has it), so if the name already exists — + /// `ERROR_ALREADY_EXISTS`, meaning `CreateFileMappingW` silently *opened* a pre-existing object we + /// don't control — we close and retry briefly (our own driver holds the name for microseconds per + /// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or + /// another host instance's) mailbox. + pub(super) fn create_named(name: &HSTRING, size: usize) -> Result { + let sa = sddl_sa(w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"))?; + for attempt in 0..5 { + if attempt > 0 { + std::thread::sleep(Duration::from_millis(50)); + } + // SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous. + unsafe { SetLastError(WIN32_ERROR(0)) }; + let shm = Self::create_inner(&sa, PCWSTR(name.as_ptr()), size) + .with_context(|| format!("create gamepad bootstrap mailbox {name}"))?; + // SAFETY: read immediately after the create; windows-rs only touches the error slot on + // failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal. + if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS { + return Ok(shm); + } + // `shm` drops here → unmap + close our handle to the foreign object, then retry. } - let sa = SECURITY_ATTRIBUTES { - nLength: core::mem::size_of::() as u32, - lpSecurityDescriptor: psd.0, - bInheritHandle: false.into(), - }; - // SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above. + bail!( + "bootstrap mailbox {name} already exists and stayed alive across retries — another \ + punktfunk-host instance is serving this pad index, or a local service is squatting the \ + name (gamepad DoS attempt?)" + ); + } + + fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result { + // SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the + // descriptor behind `sa` outlives this call (leaked by `sddl_sa`). let map = unsafe { CreateFileMappingW( INVALID_HANDLE_VALUE, - Some(&sa), + Some(sa), PAGE_READWRITE, 0, size as u32, - PCWSTR(name.as_ptr()), + name, )? }; // SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early @@ -84,14 +150,11 @@ impl Shm { let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) }; if view.Value.is_null() { // `handle` drops here → closes the section. No view to unmap. - return Err(anyhow!("MapViewOfFile failed for {name}")); + return Err(anyhow!("MapViewOfFile failed")); } // SAFETY: `view` points at `size` writable bytes (just mapped). unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) }; - Ok(Shm { - _handle: handle, - view, - }) + Ok(Shm { handle, view }) } /// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not @@ -99,11 +162,16 @@ impl Shm { pub(super) fn base(&self) -> *mut u8 { self.view.Value as *mut u8 } + + /// The section handle as a borrowed `HANDLE` (the sealed channel's duplication source). + fn raw_handle(&self) -> HANDLE { + HANDLE(self.handle.as_raw_handle()) + } } impl Drop for Shm { fn drop(&mut self) { - // SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `_handle` field closes the + // SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `handle` field closes the // section (struct fields drop only after this `Drop::drop` returns). unsafe { let _ = UnmapViewOfFile(self.view); @@ -111,6 +179,230 @@ impl Drop for Shm { } } +// ── The sealed-channel bootstrap broker ───────────────────────────────────────────────────────── + +/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two +/// consecutive pads on the same index can't hand the (persistent, out-of-band-devnode) driver the same +/// seq twice. Starts at 1. +static BOOT_SEQ: AtomicU32 = AtomicU32::new(1); + +/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a +/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment). +/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing — +/// the WUDFHost dies with the devnode). +const MAX_DELIVERY_ATTEMPTS: u32 = 16; + +/// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the +/// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's +/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown — +/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent +/// (out-of-band-devnode) driver detects the host is gone). +pub(super) struct PadChannel { + data: Shm, + boot: Shm, + boot_name: String, + /// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed + /// verify can't be spun into a hot loop by a static mailbox. + last_seen_pid: u32, + attempts: u32, + delivered: bool, + warned_proto: bool, + warned_cap: bool, +} + +impl PadChannel { + /// Create the unnamed DATA section (`data_size` bytes, zeroed — the caller stamps its layout and + /// magic) plus the named bootstrap mailbox, stamped `host_proto` first and `BOOT_MAGIC` last so a + /// driver only trusts a fully-initialized mailbox. + pub(super) fn create(boot_name: String, data_size: usize) -> Result { + let data = Shm::create_unnamed(data_size)?; + let boot = Shm::create_named( + &HSTRING::from(boot_name.as_str()), + core::mem::size_of::(), + )?; + let base = boot.base(); + // SAFETY: `base` is the live, page-aligned mailbox view (>= size_of::()); the + // field offsets are pinned by the proto's asserts and naturally aligned, so the atomic views + // are valid. `host_proto` is published BEFORE `magic` (Release) — a driver that observes the + // magic (Acquire) sees the version. + unsafe { + (*(base.add(core::mem::offset_of!(PadBootstrap, host_proto)) as *const AtomicU32)) + .store(GAMEPAD_PROTO_VERSION, Ordering::Relaxed); + fence(Ordering::Release); + (*(base.add(core::mem::offset_of!(PadBootstrap, magic)) as *const AtomicU32)) + .store(BOOT_MAGIC, Ordering::Release); + } + Ok(PadChannel { + data, + boot, + boot_name, + last_seen_pid: 0, + attempts: 0, + delivered: false, + warned_proto: false, + warned_cap: false, + }) + } + + /// The DATA section's mapped base (the host side of `XusbShm`/`PadShm`). + pub(super) fn data_base(&self) -> *mut u8 { + self.data.base() + } + + /// The bootstrap mailbox name (log labelling). + pub(super) fn boot_name(&self) -> &str { + &self.boot_name + } + + /// Atomic `u32` load from a mailbox field. + fn boot_load(&self, off: usize) -> u32 { + // SAFETY: the mailbox view is live (owned by `self.boot`), page-aligned, and every + // `PadBootstrap` u32 field offset is 4-aligned (proto asserts), so the atomic view is valid; + // no reference into the shared region outlives the load. + unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) } + } + + /// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms + /// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads. + pub(super) fn pump(&mut self) { + // Version diagnostics: the driver writes its own proto version even when it refuses to + // publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches. + let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto)); + if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto { + self.warned_proto = true; + tracing::warn!( + mailbox = %self.boot_name, + driver_proto = drv_proto, + host_proto = GAMEPAD_PROTO_VERSION, + "gamepad driver/host protocol mismatch on the bootstrap mailbox — update the \ + drivers: punktfunk-host.exe driver install --gamepad" + ); + } + let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid)); + if pid == 0 || pid == self.last_seen_pid { + return; + } + self.last_seen_pid = pid; + if self.attempts >= MAX_DELIVERY_ATTEMPTS { + if !self.warned_cap { + self.warned_cap = true; + tracing::warn!( + mailbox = %self.boot_name, + attempts = self.attempts, + "gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \ + its driver pid (tampering?); no further handles will be duplicated" + ); + } + return; + } + self.attempts += 1; + match self.deliver_to(pid) { + Ok(seq) => { + self.delivered = true; + tracing::info!( + mailbox = %self.boot_name, + wudf_pid = pid, + seq, + "sealed gamepad channel delivered (DATA handle duplicated into the driver's \ + WUDFHost)" + ); + } + Err(e) => { + tracing::warn!( + mailbox = %self.boot_name, + pid, + error = %format!("{e:#}"), + "sealed gamepad channel delivery failed — will retry when the mailbox reports \ + a different driver pid" + ); + } + } + } + + /// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine + /// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver + /// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target + /// process (nothing to reap — there is no fallible step after the duplication). + fn deliver_to(&self, pid: u32) -> Result { + // SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the + // `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the + // synchronous check and forms no lasting alias. + let process = unsafe { + let h = OpenProcess( + PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION, + false, + pid, + ) + .context("OpenProcess(PROCESS_DUP_HANDLE) on the mailbox-reported pid")?; + let process = OwnedHandle::from_raw_handle(h.0 as _); + crate::capture::idd_push::verify_is_wudfhost( + HANDLE(process.as_raw_handle()), + pid, + "gamepad-channel", + )?; + process + }; + let mut remote = HANDLE::default(); + // SAFETY: `self.data.raw_handle()` is the live section handle this channel owns; + // `process` is the live PROCESS_DUP_HANDLE target; `&mut remote` is a valid out-param. + // Least privilege: the pad driver only MAPS the DATA section read/write (its `FILE_MAP_RW` = + // `SECTION_MAP_READ | SECTION_MAP_WRITE`), so grant exactly that instead of copying our + // full-access creator handle via `DUPLICATE_SAME_ACCESS` (Chen: don't over-grant unnamed + // shared objects — a compromised driver's handle then can't `WRITE_DAC`/`DELETE` the section). + unsafe { + DuplicateHandle( + GetCurrentProcess(), + self.data.raw_handle(), + HANDLE(process.as_raw_handle()), + &mut remote, + SECTION_MAP_RW, + false, + DUPLICATE_HANDLE_OPTIONS(0), + ) + .context("DuplicateHandle(gamepad DATA section) into the driver's WUDFHost")?; + } + let value = remote.0 as usize as u64; + let base = self.boot.base(); + let seq = BOOT_SEQ.fetch_add(1, Ordering::Relaxed); + // SAFETY: live, page-aligned mailbox view; `data_handle` is 8-aligned and `handle_pid`/ + // `handle_seq` 4-aligned (proto asserts). The handle value + owning pid are published BEFORE + // the seq (Release) — a driver that observes the new seq (Acquire) sees a complete delivery. + unsafe { + (*(base.add(core::mem::offset_of!(PadBootstrap, data_handle)) as *const AtomicU64)) + .store(value, Ordering::Relaxed); + (*(base.add(core::mem::offset_of!(PadBootstrap, handle_pid)) as *const AtomicU32)) + .store(pid, Ordering::Relaxed); + fence(Ordering::Release); + (*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32)) + .store(seq, Ordering::Release); + } + Ok(seq) + } + + /// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or + /// rejected) or `timeout` passes. Closes the identity race for the DualShock 4 (the driver reads + /// `device_type` from the DATA section when hidclass asks for descriptors — the channel should be + /// attached by then); the regular service pump takes over afterwards either way. + pub(super) fn deliver_eager(&mut self, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + self.pump(); + if self.last_seen_pid != 0 || Instant::now() >= deadline { + if !self.delivered { + tracing::debug!( + mailbox = %self.boot_name, + "eager gamepad-channel delivery window passed without an attach — the \ + service pump keeps polling (driver-attach diagnosis follows if it stays \ + silent)" + ); + } + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + } +} + /// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual /// `SwDeviceClose` each backend used to call in its `Drop`. pub(super) struct SwDevice(HSWDEVICE); @@ -151,7 +443,7 @@ pub(super) struct DriverAttach { inf: &'static str, /// The driver's own debug log, referenced in the diagnosis line. driver_log: &'static str, - /// Section name, for log lines. + /// Bootstrap-mailbox name, for log lines (the DATA section is unnamed). shm_name: String, /// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path). instance_id: Option, @@ -241,8 +533,8 @@ impl DriverAttach { devnode = %devnode, driver_log = self.driver_log, "gamepad driver has not attached to the shared section — the virtual pad exists but no \ - driver is serving it (games will not see it); an old (pre-health) driver also reads as \ - not-attached: update with punktfunk-host.exe driver install --gamepad" + driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \ + reads as not-attached: update with punktfunk-host.exe driver install --gamepad" ); } } diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index 1c5c3b5..6b9cd29 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -1,23 +1,23 @@ //! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver -//! (`packaging/windows/xusb-driver`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360 +//! (`packaging/windows/drivers/pf-xusb`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360 //! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel //! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_` devnode (the driver loads on it and -//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into the shared section -//! `Global\pfxusb-shm-`. GameStream/Moonlight already speak the XInput conventions (low-16 -//! button bits, sticks −32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1. +//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into an **unnamed** shared +//! DATA section the driver reaches over the **sealed channel** ([`PadChannel`] — handle duplicated +//! into its WUDFHost, bootstrapped via `Global\pfxusb-boot-`; see +//! `design/gamepad-channel-sealing.md`). GameStream/Moonlight already speak the XInput conventions +//! (low-16 button bits, sticks −32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1. //! //! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver //! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays //! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path. -//! -//! NB: the driver currently maps `Global\pfxusb-shm-0` (hardcoded), so a single pad (index 0) is -//! fully correct; mixed multi-pad needs the driver to read its own index first (same limitation as -//! the DualSense backend). +use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use anyhow::{anyhow, Result}; use std::ffi::c_void; -use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; +use std::time::Duration; +use windows::core::{w, GUID, HRESULT, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, }; @@ -41,6 +41,7 @@ const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry); const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq); const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29 const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto); +const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index); /// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports, /// and the PnP instance id PnP assigned (captured for devnode health diagnostics). @@ -100,7 +101,7 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option)> { .encode_utf16() .chain(std::iter::once(0)) .collect(); - // The pad index, stamped into the device Location — the driver reads it to map `pfxusb-shm-` + // The pad index, stamped into the device Location — the driver reads it to poll `pfxusb-boot-` // (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event). let loc: Vec = format!("{index}") .encode_utf16() @@ -171,12 +172,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option)> { Ok((hsw, ctx.instance_id())) } -/// A single virtual Xbox 360 pad: the `pf_xusb_` devnode plus the mapped shared section. +/// A single virtual Xbox 360 pad: the `pf_xusb_` devnode plus the sealed shared-memory channel. struct XusbWinPad { /// Owns the `pf_xusb_` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed. _sw: Option, - /// Owns `Global\pfxusb-shm-` (the section + its mapped view; drop unmaps + closes). - shm: super::gamepad_raii::Shm, + /// The sealed channel: the unnamed DATA section (the `XusbShm`) + the bootstrap mailbox + the + /// handle-delivery state machine (drop closes both sections). + channel: PadChannel, /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. attach: super::gamepad_raii::DriverAttach, packet: u32, @@ -184,17 +186,18 @@ struct XusbWinPad { } impl XusbWinPad { - /// Create + map `Global\pfxusb-shm-`, stamp the magic, then spawn the devnode. + /// Create the sealed channel (unnamed DATA section + `Global\pfxusb-boot-` mailbox), stamp + /// the pad index then the magic LAST, spawn the devnode, and eagerly deliver the DATA handle once + /// the driver publishes its pid. fn open(index: u8) -> Result { - // Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the - // section handle + its mapped view (zero-filled) and unmaps/closes on drop. - let shm_name = pf_driver_proto::gamepad::xusb_shm_name(index); - let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; - let base = shm.base(); - // Zero the section then stamp the magic LAST (the driver only accepts it once magic is set). - // SAFETY: base points at SHM_SIZE writable bytes. + let boot_name = pf_driver_proto::gamepad::xusb_boot_name(index); + let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; + let base = channel.data_base(); + // The section arrives zeroed; stamp the pad index (the driver validates it against its own + // devnode index on attach) then the magic LAST (the driver only accepts it once magic is set). + // SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX is in range. unsafe { - std::ptr::write_bytes(base, 0, SHM_SIZE); + std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); } let (hsw, instance_id) = match create_swdevice(index) { @@ -205,14 +208,18 @@ impl XusbWinPad { } }; let _sw = hsw.map(super::gamepad_raii::SwDevice::new); + // Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it + // the DATA handle before we return means the pad is live for the game's first XInput poll. + // On a missing/old driver this waits out the window once and the service pump takes over. + channel.deliver_eager(Duration::from_millis(1500)); Ok(XusbWinPad { _sw, - shm, + channel, attach: super::gamepad_raii::DriverAttach::new( "pf_xusb", "pf_xusb.inf", "C:\\Users\\Public\\pfxusb-driver.log", - shm_name, + boot_name, instance_id, ), packet: 0, @@ -225,7 +232,7 @@ impl XusbWinPad { #[allow(clippy::too_many_arguments)] fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) { self.packet = self.packet.wrapping_add(1); - let base = self.shm.base(); + let base = self.channel.data_base(); // SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every // `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field // writes. Single owner (`&mut self`), so no concurrent writer races these stores. @@ -242,10 +249,12 @@ impl XusbWinPad { } /// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns - /// `(large, small)` motor levels (0..=255) when a new one arrived. Also feeds the driver-attach - /// health watcher (the driver stamps `driver_proto` at device add + on every serviced IOCTL). + /// `(large, small)` motor levels (0..=255) when a new one arrived. Also ticks the sealed-channel + /// delivery (a late-binding driver gets its handle here) and feeds the driver-attach health + /// watcher (the driver stamps `driver_proto` once it maps the delivered section + per IOCTL). fn service(&mut self) -> Option<(u8, u8)> { - let base = self.shm.base(); + self.channel.pump(); + let base = self.channel.data_base(); // SAFETY: base points at SHM_SIZE bytes. let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) }; self.attach.observe(proto); diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 2581c5b..522a809 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -39,11 +39,13 @@ pub(crate) enum MonitorKey { Session(u64), } -/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID. +/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the +/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target). pub(crate) struct AddedMonitor { pub key: MonitorKey, pub target_id: u32, pub luid: LUID, + pub wudf_pid: u32, } /// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay. @@ -91,6 +93,9 @@ struct Monitor { key: MonitorKey, target_id: u32, luid: LUID, + /// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the + /// IDD-push capturer knows where to duplicate the sealed frame channel's handles. + wudf_pid: u32, gdi_name: Option, mode: Mode, stop: Arc, @@ -109,6 +114,7 @@ impl Monitor { adapter_luid: crate::capture::dxgi::pack_luid(self.luid), gdi_name: n, target_id: self.target_id, + wudf_pid: self.wudf_pid, }) } } @@ -166,6 +172,14 @@ pub(crate) fn vdm() -> &'static VirtualDisplayManager { .expect("VirtualDisplayManager used before a backend initialised it") } +/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery +/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: the device lives in a `OnceLock` +/// that is never cleared or closed for the process lifetime. `None` before the first backend open — +/// impossible for a capturer, which only exists on a monitor the manager created. +pub(crate) fn control_device_handle() -> Option { + VDM.get().and_then(VirtualDisplayManager::device_handle) +} + impl VirtualDisplayManager { pub(crate) fn backend_name(&self) -> &'static str { self.driver.name() @@ -436,6 +450,7 @@ impl VirtualDisplayManager { key: added.key, target_id: added.target_id, luid: added.luid, + wudf_pid: added.wudf_pid, gdi_name, mode, stop, diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs index 14efe89..96c4c18 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs @@ -158,6 +158,33 @@ unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> { .context("pf-vdisplay SET_RENDER_ADAPTER") } +/// Deliver a monitor's sealed frame channel to the driver: the handle values `req` carries were just +/// duplicated into the driver's WUDFHost by the IDD-push capturer's broker (`idd_push::ChannelBroker`), +/// and on IOCTL success the DRIVER owns them. No output buffer. The caller reaps the remote duplicates +/// on failure (the broker's `DUPLICATE_CLOSE_SOURCE` sweep) so no path leaks WUDFHost handles. +/// +/// # Safety +/// `dev` must be a live pf-vdisplay control handle (see [`super::manager::control_device_handle`]). +pub(crate) unsafe fn send_frame_channel( + dev: HANDLE, + req: &control::SetFrameChannelRequest, +) -> Result<()> { + let mut none: [u8; 0] = []; + // SAFETY: per this fn's contract `dev` is the live control handle. `bytes_of(req)` borrows the + // caller's request for the duration of this synchronous call as the input bytes; `none` is empty, + // so there is no output buffer. + unsafe { + ioctl( + dev, + control::IOCTL_SET_FRAME_CHANNEL, + bytemuck::bytes_of(req), + &mut none, + ) + } + .map(|_| ()) + .context("pf-vdisplay SET_FRAME_CHANNEL") +} + unsafe fn open_device() -> Result { let hdev = SetupDiGetClassDevsW( Some(&PF_VDISPLAY_INTERFACE), @@ -354,12 +381,13 @@ impl VdisplayDriver for PfVdisplayDriver { HighPart: reply.adapter_luid_high, }; tracing::info!( - "pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})", + "pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x}, wudf_pid={})", mode.width, mode.height, mode.refresh_hz, reply.target_id, - luid.LowPart + luid.LowPart, + reply.wudf_pid ); // Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id? // A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes @@ -395,6 +423,7 @@ impl VdisplayDriver for PfVdisplayDriver { key: MonitorKey::Session(session_id), target_id: reply.target_id, luid, + wudf_pid: reply.wudf_pid, }) } diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index 9d569e8..81d051d 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -162,9 +162,28 @@ fn install_gamepad(dir: &Path) -> Result<()> { eprintln!("warning: pnputil /add-driver {} failed", inf.display()); } } + // Sweep pad devnodes, INCLUDING phantoms a host crash / service stop left behind: a re-created + // SwDevice with a known instance id REVIVES the existing devnode with its previously-bound + // driver — it never re-ranks against the store — so after an upgrade the old driver keeps + // serving (or, across the v1→v2 sealed-channel fence, fails closed and the pad plays dead). + // Proven in the field on the RTX box: a v1 phantom pinned the old package through a v2 + // install. The devnodes are per-session objects the host recreates on demand, so removing + // them at driver-install time is always safe; the next pad binds the fresh package. + remove_pad_devnodes(); Ok(()) } +/// `pnputil /remove-device` every punktfunk virtual-pad devnode (live or phantom). +fn remove_pad_devnodes() { + for id in pad_instance_ids() { + if run_quiet("pnputil", &["/remove-device", &id]) { + println!("removed stale pad devnode {id}"); + } else { + eprintln!("warning: pnputil /remove-device {id} failed"); + } + } +} + // ── `driver uninstall [--gamepad]` ────────────────────────────────────────────────────────────── // The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our // virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver @@ -204,6 +223,9 @@ fn uninstall_pf_vdisplay() -> Result<()> { } fn uninstall_gamepad() -> Result<()> { + // Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall + // fixed), then the store packages. + remove_pad_devnodes(); delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]); Ok(()) } @@ -235,6 +257,28 @@ fn pf_vdisplay_instance_ids() -> Vec { ids } +/// Instance IDs of punktfunk virtual-pad devnodes (`SWD\PUNKTFUNK\…`), INCLUDING phantoms left by +/// a host crash / service stop (`pnputil /enum-devices` lists disconnected devnodes too). Same +/// un-localized VALUE-side parsing as [`pf_vdisplay_instance_ids`]; matched on the instance-id +/// prefix itself — the pads span two device classes (HIDClass + System), so no `/class` filter. +fn pad_instance_ids() -> Vec { + let out = run_capture("pnputil", &["/enum-devices"]); + let mut ids = Vec::new(); + for block in out.split("\r\n\r\n").flat_map(|b| b.split("\n\n")) { + let Some(first) = block.lines().find(|l| !l.trim().is_empty()) else { + continue; + }; + let Some((_, value)) = first.split_once(':') else { + continue; + }; + let id = value.trim(); + if id.to_ascii_uppercase().starts_with("SWD\\PUNKTFUNK\\") && !id.contains(' ') { + ids.push(id.to_string()); + } + } + ids +} + /// Delete every driver-store package (`%WINDIR%\INF\oem*.inf`) whose INF text mentions one of /// `needles` — our driver names are unique enough that a content match identifies the package /// without parsing `pnputil /enum-drivers`' localized output. `/uninstall /force` also unbinds it diff --git a/design/gamepad-channel-sealing.md b/design/gamepad-channel-sealing.md new file mode 100644 index 0000000..e06f718 --- /dev/null +++ b/design/gamepad-channel-sealing.md @@ -0,0 +1,236 @@ +# Handoff — sealing the gamepad SHM channels + +Status: **implemented (Option A), 2026-07-03 — Windows CI + on-glass validation pending.** The design +below was implemented as proposed; the "Implementation notes" section records what was actually built +and the deltas. Remaining: build + sign + redeploy both pad drivers, then the hardware validation plan +(§Validation) — it needs a physical controller on the box. + +This closes the one open residual left by the IDD-push sealed-channel work +(`design/idd-push-security.md`): frames were sealed; the gamepad input/output channel was not. + +## Unsafe hygiene (2026-07-03 follow-up — the drivers' `unsafe` was confined) + +After the seal landed, the pad drivers' `unsafe` footprint (raw `OpenFileMapping`/`MapViewOfFile`, +`read_unaligned`, the whole bootstrap state machine as bare-pointer arithmetic) was pulled into a new +audited crate **`pf-umdf-util`** (`packaging/windows/drivers/pf-umdf-util/`), so the drivers benefit +from Rust instead of being C-in-Rust: + +- `section::MappedView` — a mapped section wrapped as bounds- + alignment-checked accessors + (`load_u32`/`store_u32`/`read_bytes`/…). Callers never see the base pointer; an out-of-range offset + asserts instead of corrupting. `ViewCell` holds the adopted view as a leaked `&'static` (the + re-delivery-must-not-unmap rule, now type-enforced). +- `channel::ChannelClient` — the ENTIRE sealed-channel driver side (publish pid → adopt handle → + validate magic+`pad_index`), as a **`#![forbid(unsafe_code)]`** module over `MappedView`. One + implementation both pad drivers share (was hand-duplicated). +- `wdf::{Request, query_location_index, retrieve_next_request}` — the WDF request/memory/property FFI + behind safe methods; a callback turns its raw `WDFREQUEST` into a `Request` token once (the only + `unsafe` at the driver boundary), and completion consumes the token. + +Result: `pf-xusb`/`pf-dualsense` business logic is **100 % safe Rust**; the only remaining `unsafe` in +them is the unavoidable WDF *setup* FFI in `DriverEntry`/`EvtDeviceAdd`/the timer, each with a +`// SAFETY:` proof. The display driver `pf-vdisplay` is inherently FFI-bound (D3D11 / IddCx DDIs / +cross-process textures) so it can't be unsafe-*free*, but it's now unsafe-*audited*: every `unsafe {}` +carries a proof. Both invariants are lint-gated across the whole drivers workspace +(`#![deny(unsafe_op_in_unsafe_fn)]` + `#![deny(clippy::undocumented_unsafe_blocks)]`) and enforced by +a new `cargo clippy -D warnings` step in `windows-drivers.yml`. Verified on the RTX box (.173): the +whole workspace builds + clippies + fmt-checks clean; both gamepad DLLs still produce. + +## Implementation notes (what was built, 2026-07-03) + +- **Contract** (`pf_driver_proto::gamepad`, `GAMEPAD_PROTO_VERSION = 2`): `PadBootstrap` (32 B — + `magic "PFBT"`, `host_proto`, `driver_pid`, `driver_proto`, `data_handle: u64`, `handle_pid`, + `handle_seq`) with `Pod` + `offset_of!` asserts; `xusb_boot_name`/`pad_boot_name` + (`Global\pf…-boot-`) REPLACE the old `*_shm_name` fns (the DATA-section name is gone); + `XusbShm`/`PadShm` gained `pad_index` (carved from reserved space) so the DRIVER validates a + delivery resolves to *its own* pad — the authentic-side answer to the "redirect the dup into a + different pad's WUDFHost" hardening note (the section content is host-written and unreachable by a + sibling LS, so the check can't be spoofed). Both pad drivers now path-dep `pf-driver-proto` (as + pf-vdisplay does) instead of hand-synced literals. +- **Host** (`inject/windows/gamepad_raii.rs`): `Shm::create_unnamed` (DATA, `D:P(A;;GA;;;SY)`) + + `Shm::create_named` (mailbox, SY+LS, **squat-checked** — `ERROR_ALREADY_EXISTS` on create is + close+retry×5 then a hard error, so the handshake never runs through a pre-created object; this also + turns the previously-silent two-hosts-same-index cross-wire into a loud failure). `PadChannel` owns + both + the delivery state machine: poll `driver_pid` → `OpenProcess` → + `verify_is_wudfhost` (now shared with the frame broker in `capture/windows/idd_push.rs`) → + `DuplicateHandle` → publish `data_handle`/`handle_pid`, bump `handle_seq` last (Release). Pumped + from each backend's existing service tick (≤4 ms) + a bounded **eager delivery** (1.5 s) at pad-open + so the DS4's `device_type` is readable before hidclass asks for descriptors. Delivery attempts are + **capped at 16 per pad** so a tampered flapping mailbox can't mint unbounded remote handles. Same + pid never retried (failed verify can't be spun into a hot loop). +- **Drivers** (`pf-xusb`, `pf-dualsense`): per-tick `pump_bootstrap()` (the DS timer / every XUSB + IOCTL + a bounded EvtDeviceAdd worker thread for XUSB's no-game-running case) opens the mailbox *by + name each time* — the name existing doubles as host-liveness, replacing the old per-access section + open; mailbox gone → detach (DS additionally resets the pended-read report to neutral instead of + the old frozen-last-state behavior). The driver writes `driver_proto` always but publishes its pid + **only when `host_proto` matches** (fail closed both ways: v1 host never creates a mailbox a v2 + driver polls; a v1 driver opens a name that no longer exists). A delivery is adopted once + (CAS on `handle_seq`, reset when the mailbox disappears so a new host session's counter can't + collide), mapped, and validated: `magic` AND `pad_index == SHM_INDEX` — else unmapped + ignored + (the handle is deliberately NOT closed on validation failure: a tampered value could name an + unrelated handle in the driver's own table). The adopted view is cached and never unmapped + (re-delivery swaps + leaks the old 64/256 B mapping on purpose — a concurrent reader may hold it). + Driver log line for validation step 3: `sealed pad channel mapped (index …)`. +- **Not built:** Option B (devnode custom properties). The residual named mailbox is documented and + DoS-bounded; migrate later if it's ever deemed worth removing. + +## The problem (why this exists) + +Each virtual pad's host↔driver channel is a **named** shared-memory section: + +- `Global\pfxusb-shm-` (64 B, [`pf_driver_proto::gamepad::XusbShm`]) — virtual Xbox 360 / XInput. +- `Global\pfds-shm-` (256 B, [`pf_driver_proto::gamepad::PadShm`]) — virtual DualSense / DualShock 4. + +Both are created by the SYSTEM host with DACL `D:(A;;GA;;;SY)(A;;GA;;;LS)` (`inject/windows/gamepad_raii.rs` +`Shm::create`) so the driver's WUDFHost (LocalService) can open them by name. That means **a sibling +LocalService process can `OpenFileMapping` the section by name** and: + +- **read** the victim's live controller input (buttons/sticks/gyro/touchpad — host→driver `input` region), and +- **inject/forge** gamepad input or rumble (write the `input` region → the driver feeds it to whatever game + has focus; write the `output` region + bump `out_seq` → forge rumble/LED back to the client). + +This is the *same* name-open vector we closed for frames, one module over. Severity is lower than desktop +capture (it's game-controller I/O, scoped to the focused app, and requires the attacker to already have +LocalService code execution), but it is real and it is inconsistent to leave named next to a sealed frame ring. + +**Not a stopgap:** randomizing the section name is inadequate — the object namespace is enumerable with +`NtQueryDirectoryObject`, so a random name is discoverable. (Same reason it was rejected for frames.) The fix +is to remove the name. + +## Why it isn't already sealed the frame way + +The frame channel seals cleanly because pf-vdisplay has a **control device** (the IddCx device interface): +the host duplicates the unnamed handles into the driver's WUDFHost and delivers the values over +`IOCTL_SET_FRAME_CHANNEL`, and the driver reports its own pid in the `IOCTL_ADD` reply. + +The pad drivers (`pf-dualsense`, `pf-xusb`) are **UMDF HID minidrivers with no control device** — hidclass +owns the device stack and blocks a freely-openable control interface. That is *why* they use a named section +in the first place. So there is no IOCTL to (a) hand the driver a duplicated handle or (b) learn the driver's +WUDFHost pid. Compounding it: `pszDeviceLocation` (the existing host→driver property) is fixed at +`SwDeviceCreate` time — **before** the WUDFHost process exists — so the host can't duplicate a handle into a +not-yet-created process and stamp its value there. A bidirectional, late-bound handshake is required. + +## Current architecture (what to modify) + +Host (`crates/punktfunk-host/src/inject/windows/`): +- `gamepad_raii.rs` — `Shm::create(name, size)` creates the **named** section (SY+LS SDDL) + maps it; + `SwDevice` wraps the `SwDeviceCreate` devnode. +- `gamepad_windows.rs` (XUSB), `dualsense_windows.rs` (DualSense/DS4), `dualshock4_windows.rs` — each creates + its `Shm`, then `create_swdevice(index)` / `create_swdevice(profile)` which stamps the pad **index** into + `info.pszDeviceLocation` (a UTF-16 decimal string) and creates `pf_xusb_` / `pf_pad_`. + +Driver (`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`): +- `query_shm_index(device)` — `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` → parses the + decimal → `SHM_INDEX` static. +- On first control activity it builds `format!("Global\\pf…-shm-{}", SHM_INDEX)`, `OpenFileMappingW` + + `MapViewOfFile`. The dualsense driver also runs a ~125 Hz timer (writes `driver_heartbeat`) — an existing + poll loop to piggyback a bootstrap-wait on. + +Contract (`crates/pf-driver-proto/src/lib.rs` `mod gamepad`): owns `XusbShm`/`PadShm` layouts, the magics, +`xusb_shm_name`/`pad_shm_name`, `device_type`, `GAMEPAD_PROTO_VERSION`, and the driver_proto/heartbeat fields. + +## Proposed design — a late-bound bootstrap handshake + +Split each pad's channel into **(1) an unnamed DATA section** (the real `XusbShm`/`PadShm`, host↔driver) and +**(2) a tiny bootstrap mailbox** that carries only a magic + the driver's pid + a handle value. The handshake: + +1. **Host**, per pad: create the DATA section **unnamed** (`CreateFileMappingW` with `PCWSTR::null()`, DACL + `D:P(A;;GA;;;SY)` — SYSTEM-only, exactly as the sealed frame ring now uses; the driver reaches it by + duplicated handle, which carries access, so no LS ACE is needed). Then create the devnode via + `SwDeviceCreate`, stamping the pad index into `pszDeviceLocation` **as today** (the index still identifies + *which* pad's bootstrap the driver should use). +2. **Driver** `EvtDeviceAdd`: read the index (unchanged `query_shm_index`). Write `std::process::id()` where + the host can read it, then **poll** (piggyback the existing timer) for a delivered handle value; map the + DATA section from it once non-zero. +3. **Host**: learn the driver's pid, `OpenProcess(PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION)`, + **verify it is the WUDFHost servicing this pad's devnode** (see hardening note), `DuplicateHandle` the + DATA section into the WUDFHost, and deliver the resulting handle value back to the driver. + +Two viable transports for steps 2–3's pid-out / handle-in (pick one): + +- **Option A — named bootstrap mailbox** (`Global\pf…-boot-`, ~32 B, SY+LS): host creates it; driver + opens it by name (index from location), writes `driver_pid`, spins on `data_handle` != 0; host polls + `driver_pid`, dups the DATA section in, writes `data_handle` + a ready seq. **Safe to leave named + SY+LS** + because it carries *only* a pid (not sensitive) and a handle value (meaningless outside the target WUDFHost) + — identical to the frame channel's "the bootstrap ACL is not load-bearing" argument. A sibling LS that reads + it learns nothing exploitable; one that tampers it can at worst feed a bogus pid/handle → the driver maps a + value that doesn't resolve in its own table → **DoS, not a breach** (the attacker cannot place a valid + section handle in the WUDFHost, so it cannot make the driver map an attacker-controlled section). *Fastest to + build — reuses the existing named-section + poll machinery.* +- **Option B — devnode custom properties** (no `Global\` object at all): driver writes its pid via + `WdfDeviceAssignProperty(DEVPROPKEY_pf_pad_pid)`; host reads it via `CM_Get_DevNode_PropertyW` / + `SetupDiGetDevicePropertyW`, dups in, writes a `DEVPROPKEY_pf_pad_handle` property; driver re-queries it in + its timer. Tighter (property store isn't world-readable like the Global namespace) but more moving parts and + UMDF-property-write ergonomics to prove out. *Cleaner end-state.* + +Recommendation: **build Option A first** (small, mirrors the frame channel, gets the DATA section unnamed — +which is the actual isolation win, proven by #3 below), then optionally migrate the bootstrap to Option B if +the residual named mailbox is deemed worth removing. + +## Reuse the frame-channel precedent + +- **Ownership/adopt-on-success** discipline from `capture/windows/idd_push.rs` `ChannelBroker` — exactly one + side ever closes a duplicated handle value; reap remote duplicates (`DUPLICATE_CLOSE_SOURCE`) on any failure. +- **`verify_is_wudfhost`** (`idd_push.rs`) — before duplicating into the driver-reported pid, confirm it's + `%SystemRoot%\System32\WUDFHost.exe`. **Strengthen it here**: also confirm the pid is the host *servicing + this pad's devnode* (walk devnode → process, e.g. via the driver writing a per-pad nonce it echoes, or a + devnode/PID association) so a tampered bootstrap can't redirect the dup into a *different* pad's WUDFHost. +- **Contract in `pf_driver_proto::gamepad`** — add the bootstrap layout (`PadBootstrap { magic, driver_pid, + data_handle: u64, seq }`) with `Pod` + `offset_of!` asserts, bump `GAMEPAD_PROTO_VERSION`, and (Option A) + keep `pad_shm_name`/`xusb_shm_name` only for the bootstrap mailbox, dropping the data-section name. +- **SDDL** on the DATA section: `D:P(A;;GA;;;SY)` (SYSTEM-only) — validated safe for a duplicated-handle + consumer on the frame ring (the driver's `OpenSharedResource`/`MapViewOfFile` on a handle does not re-check + the object DACL). + +## Security properties after the change + +- The **DATA section is unnamed** and only ever handle-duplicated into the pad WUDFHost. Empirically + (`design/idd-push-security.md`, RTX box 2026-07-03) a **LocalService token is DACL-denied `OpenProcess` on a + UMDF WUDFHost for every access right incl. `QUERY_LIMITED`** — so a sibling LS cannot dup the handle out or + read the WUDFHost's memory. Unnamed + unopenable-host ⇒ no sibling-LS path to the input/output data. This is + the same guarantee the frame channel now has, and it rests on the same verified property. +- **Residual (Option A):** the bootstrap mailbox stays named + SY+LS, but carries only a pid + handle value → + worst case a sibling LS causes a **gamepad DoS**, never a read or injection. Option B removes even that. +- **Unchanged inherent limits:** admin/SYSTEM = total; the game reading the pad sees the input by design. + +## Validation plan (needs hardware) + +The blocker for calling this done is that it **requires a physical controller on the box** — the memory notes +repeatedly flag the gamepad path as "needs a physical pad to live-verify," and neither the probe nor a +synthetic client exercises a real game reading the virtual pad. + +1. Build + sign + redeploy `pf-dualsense` and `pf-xusb` (same loop as pf-vdisplay: + `packaging/windows/drivers/deploy-dev.ps1` per driver, or `redeploy-*`; DriverVer must strictly increase). + Bump `GAMEPAD_PROTO_VERSION` — a v_new host against a v_old pad driver (or vice-versa) must fail closed, so + deploy host + both pad drivers together. +2. Connect a real client with a physical controller; confirm in a game that input works and rumble/LED return. +3. Driver log (`C:\Users\Public\pfds-driver.log` / `pfxusb-driver.log` in debug builds): confirm the driver + reports its pid, receives a handle, and maps the DATA section (add a `dbglog!` "sealed pad channel mapped"). +4. Re-run the **sibling-LS `OpenFileMapping` test**: from a LocalService scheduled task, attempt to open the + old `Global\pf…-shm-` name — it must now **fail (name gone)**, and attempting to open the bootstrap + (Option A) must yield only pid+handle bytes. (Reuse the scheduled-task P/Invoke harness from the #3 frame + test — see the session that produced `design/idd-push-security.md`.) +5. Multi-pad: two controllers → two devnodes, two unnamed DATA sections, two bootstraps by index; confirm no + cross-talk and clean teardown (`SwDeviceClose` + host handle close; the WUDFHost dies with its devnode). + +## Risks / gotchas + +- **Regression risk to a working feature.** Gamepad input currently works on glass; this reroutes its + bootstrap. Keep the change behind the `GAMEPAD_PROTO_VERSION` bump and be ready to revert both drivers. +- **Chicken-and-egg timing.** The driver loads and wants the handle before the host has dup'd it — the poll + loop must tolerate a bounded wait (mirror the frame path's `wait_for_attach`, ~4 s) and the driver must not + block `EvtDeviceAdd` on it (spin in the timer, not the add callback). +- **Handle value in shared memory is a `u64`.** A WUDFHost handle value is process-local; writing it to the + bootstrap is safe (meaningless elsewhere), but the driver must treat it as untrusted (validate the mapped + DATA section's magic before use — the existing `XusbShm`/`PadShm` magic already gives this). +- **Two drivers, one contract.** DualSense and DualShock 4 share `pf-dualsense`/`PadShm`; XUSB is separate. + Factor the bootstrap into `pf_driver_proto::gamepad` so both drivers + the host use one definition (as the + frame channel does). + +## Effort + +Medium — comparable to the frame sealed-channel change but across **two** drivers plus the host inject code, +and gated on **physical-controller validation** that can't be driven over SSH. Files: `pf_driver_proto` +(gamepad module), `inject/windows/{gamepad_raii,gamepad_windows,dualsense_windows,dualshock4_windows}.rs`, +`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`. Reference implementation: the frame sealed channel +(`capture/windows/idd_push.rs` + `packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs` ++ `pf_driver_proto` `control`/`frame`). diff --git a/design/idd-push-security.md b/design/idd-push-security.md new file mode 100644 index 0000000..2672db3 --- /dev/null +++ b/design/idd-push-security.md @@ -0,0 +1,145 @@ +# IDD-push frame channel — security model (the sealed channel) + +Status: **implemented** (host `capture/windows/idd_push.rs` + driver +`packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs`, contract +`crates/pf-driver-proto` v2). Windows CI-validated; on-glass validation pending. + +## What is being protected + +The IDD-push path moves **whole-desktop frames** — including the secure desktop (UAC prompts, the +lock screen) — from the pf-vdisplay driver (UMDF, running in a `WUDFHost.exe` under LocalService) +into the SYSTEM host for encoding. That data is SYSTEM-tier-sensitive, and because we bypass the OS +capture APIs (Desktop Duplication / WGC), **we own the isolation those APIs would have provided.** + +DDA's isolation property is that capturer and consumer are the same process: there is no openable +channel at all — to reach the frames you must own the capturing process. The sealed channel +reproduces exactly that property for our two-process design. + +## The design + +``` +┌──────────────────────────┐ control device (SY+BA only) ┌───────────────────────────┐ +│ Host (SYSTEM service) │ ── IOCTL_SET_FRAME_CHANNEL: handle ────▶ │ pf-vdisplay driver │ +│ creates header/event/ │ VALUES only (integers) │ (WUDFHost, LocalService) │ +│ ring textures UNNAMED, │ │ maps/opens the duplicated │ +│ DuplicateHandle()s them │ ◀── frames via keyed-mutex textures ──── │ handles; publishes frames │ +│ INTO WUDFHost, encodes │ (no names anywhere) │ │ +└──────────────────────────┘ └───────────────────────────┘ + trust boundary: only these two processes ever hold a handle to any frame object +``` + +1. **Every frame object is unnamed** (header section, frame-ready event, all ring textures — + `CreateFileMappingW`/`CreateEventW`/`CreateSharedHandle` with a null name). An unnamed object is + in no namespace: it cannot be enumerated (`NtQueryDirectoryObject` can't see it), cannot be + opened by name, and cannot be pre-created ("squatted"). It can be shared **only** by handle + duplication. +2. **The host is the broker.** SYSTEM opens the driver's WUDFHost with `PROCESS_DUP_HANDLE` (the pid + comes from the `IOCTL_ADD` reply, per-monitor, so a WUDFHost restart can't leave us duplicating + into a dead process) and `DuplicateHandle`s each object in. The reverse direction — LocalService + injecting into SYSTEM — is correctly denied by the OS, which is why the broker must be the host. +3. **The bootstrap carries only integers.** `IOCTL_SET_FRAME_CHANNEL` delivers the duplicated handle + *values*. A handle value is only meaningful inside the target process's handle table: a third + party that read (or even forged) the message would learn nothing openable and could at most feed + values that don't resolve — a DoS of its own session, not a read. The bootstrap's ACL is therefore + **not load-bearing**; we still restrict the control device to `D:P(A;;GA;;;SY)(A;;GA;;;BA)` + (INF `Security`), because ADD/REMOVE/CLEAR_ALL shouldn't be world-callable either. + +Net result: the only way to reach the frames is to already run code as SYSTEM (the host) or inside +that specific WUDFHost (the driver) — DDA's property, achieved in user mode. + +## Why user-mode, not a kernel driver + +Ring level does not govern cross-process memory visibility — the handle/VAD access checks do; a user +process cannot `ReadProcessMemory` a LocalService process regardless of rings. What kernel-mode +*would* change is the blast radius of a driver bug: UMDF caps a pf-vdisplay compromise at the +LocalService token, a KMDF display driver would make it ring-0 full-system. Least-blast-radius is +the reason punktfunk ships **zero** kernel drivers (the gamepad stack dropped ViGEmBus for UMDF for +the same reason). The correct control for "SYSTEM-tier data in the channel" is sealing the channel — +done above — not raising the ring. + +## Handle-lifetime invariants (the auditable list) + +1. Frame objects unnamed; bootstrap carries only handle values. ✔ by construction +2. `bInheritHandle: false` on every object — no child inherits a handle. ✔ +3. Zero-init header + atomic `magic`-last publish (the driver never acts on a half-initialized + ring); generation-tagged publish tokens reject stale-ring frames. ✔ +4. Attacker-influenced header fields are bounds-checked before use (generation/seq/slot unpacking; + `ring_len` clamped; the driver validates `IOCTL_SET_FRAME_CHANNEL` before adopting anything). ✔ +5. **Adopt-on-success-only:** the driver owns (and eventually closes) the delivered handles iff the + IOCTL completed successfully; on ANY error completion it leaves them untouched and the host reaps + its remote duplicates (`DUPLICATE_CLOSE_SOURCE`). Exactly one side closes each value — no + double-close of possibly-reused handle values, no leak on a half-delivered channel. ✔ +6. Single ownership inside the driver: each delivery lives in exactly one place (monitor stash → + publisher), and whichever owner dies — replaced stash, dropped publisher, removed monitor, reaped + watchdog, departed device — closes the handles (`FrameChannel`/publisher `Drop`). Host-side + objects are RAII (`MappedSection`, `OwnedHandle`); nothing survives the capturer. ✔ +7. The object DACL is `D:P(A;;GA;;;SY)` — **SYSTEM only, protected**. Since the driver reaches the + objects via duplicated handles (which carry their own access; `OpenSharedResource1` on a handle does + not re-check the object DACL), the LocalService ACE was dropped — the minimal DACL. ✔ *(on-glass + confirmed 2026-07-03: the driver still attaches + delivers frames with SYSTEM-only objects.)* +8. **The duplication target is verified.** Before duplicating frame handles into `AddReply.wudf_pid`, + the host confirms that pid is `%SystemRoot%\System32\WUDFHost.exe` (`verify_is_wudfhost`). A spoofed + devnode advertising our interface GUID cannot redirect frames to an arbitrary process. ✔ +9. **Handles are duplicated with least privilege, not `DUPLICATE_SAME_ACCESS`.** The driver's copy of + the header section is `SECTION_MAP_READ|WRITE` (matched by the driver mapping `FILE_MAP_READ|WRITE`, + not `FILE_MAP_ALL_ACCESS`), the frame-ready event is `EVENT_MODIFY_STATE` (the driver only signals + it), and the ring textures keep their already-scoped `CreateSharedHandle` access + (`DXGI_SHARED_RESOURCE_READ|WRITE`). So a compromised driver's handles can map/signal but cannot + `WRITE_DAC`/`WRITE_OWNER`/`DELETE` the objects — the "give unnamed shared objects proper (minimal) + security attributes, because `DuplicateHandle` can still reach them" discipline (Raymond Chen, + *devblogs 2015-06-04*). Marginal here (the driver is already a trusted frame endpoint) but correct + hygiene, and it applies identically to the gamepad DATA section. ✔ *(on-glass confirmed 2026-07-03: + the driver attaches + streams `frames=7035` with the least-access header handle.)* + +Ring recreation (mid-session HDR flip) and host build-retries re-deliver a complete fresh handle set; +the driver treats a pending delivery as newest-wins (a retry's ring is a *different* header mapping, +whose generation bump an old publisher can never observe). + +## Empirical verification (2026-07-03, RTX box) + +The headline claim — "reaching a frame requires already being one of the two endpoint processes" — +was tested, not just argued. A **LocalService-token** process (scheduled task, the sibling-service +stand-in) attempting `OpenProcess` on the pf_vdisplay WUDFHost was **denied every access right**: +`PROCESS_DUP_HANDLE`, `PROCESS_VM_READ`, `PROCESS_QUERY_INFORMATION`, and even +`PROCESS_QUERY_LIMITED_INFORMATION` → `ERROR_ACCESS_DENIED`. The `QUERY_LIMITED` denial is decisive: +it is a read-class right MIC permits across integrity levels, so its denial is a **DACL exclusion of +the LocalService SID**, not an integrity ceiling — meaning even a higher-integrity LocalService +*service* is denied (LocalService lacks `SeDebugPrivilege`, so it cannot bypass the DACL). Combined +with the objects being unnamed, a sibling LocalService has **no reachable path to a frame**: no +name to open, no way to dup the handles out of WUDFHost, no way to read WUDFHost's memory. The +baseline (an elevated admin, holding `SeDebugPrivilege`) opened WUDFHost freely — expected, and the +reason "admin/SYSTEM = total" stays on the residual list below. + +## Residual limits — the honest floor + +* **The virtual display is a real monitor.** Any process in the interactive session can capture it + through the ordinary OS APIs (DDA/WGC/BitBlt), exactly as it can capture any physical monitor. + That floor is identical for every virtual-display streaming stack (Sunshine + VDD, Apollo/SudoVDA); + the sealed channel keeps *our* transport above that floor rather than below it. **This is the single + most realistic way for unprivileged session code to see the streamed pixels, and it is outside our + channel entirely.** +* **The gamepad channels are now sealed too** (2026-07-03, `design/gamepad-channel-sealing.md`, + gamepad proto v2 — on-glass validation pending): the pad DATA sections (`XusbShm`/`PadShm`) are + UNNAMED with `D:P(A;;GA;;;SY)`, handle-duplicated into the pad's WUDFHost by the host broker + (`inject/windows/gamepad_raii.rs` `PadChannel`, reusing this design's `verify_is_wudfhost` + + adopt-on-success discipline), and the driver validates the mapped section's magic + `pad_index` + before use. The pad drivers have no control device (hidclass), so the handshake runs over a tiny + **named bootstrap mailbox** (`Global\pf…-boot-`, SY+LS, `PadBootstrap`) that carries only + pids and a handle value — nothing exploitable; the *residual* is that a sibling LocalService can + tamper the mailbox for a **gamepad DoS** (never a read or an injection; deliveries are capped, and + the mailbox is squat-checked at create). The old sibling-LS read/inject vector on + `Global\pf…-shm-*` is gone — the names no longer exist. +* **Admin / SYSTEM = total.** The control device is `D:P(A;;GA;;;SY)(A;;GA;;;BA)`, so an admin can drive + `IOCTL_SET_FRAME_CHANNEL` (DoS a live session) and, with `SeDebugPrivilege`, dup a section into + WUDFHost to exfiltrate; and an admin can plant a fake devnode with our interface GUID to impersonate + the driver. All admin-gated (no non-privileged escalation), but the control plane is explicitly not a + boundary against admin. The host↔driver channel has no mutual authentication beyond the `GET_INFO` + version handshake + the `verify_is_wudfhost` image check. +* **`WDA_EXCLUDEFROMCAPTURE` windows are visible.** IDD-push taps the *present* side, not the + *capture* side, so windows that exclude themselves from capture still appear in the stream — true + of every virtual-display streaming stack. Untested on our lab box; treat as expected behavior. +* **DRM/HDCP:** protected content is blanked by DWM at composition, and HDCP is a monitor↔GPU + handshake an indirect display cannot satisfy — neither is bypassed by this path. +* IDD-push is currently the **sole Windows capture path** (DDA and the WGC relay were removed). An + OS-mediated-capture-only mode would trade away secure-desktop capture and latency; if a deployment + requires it, that's a feature request, not a toggle that exists today. diff --git a/packaging/windows/drivers/Cargo.lock b/packaging/windows/drivers/Cargo.lock index f127f58..03841ac 100644 --- a/packaging/windows/drivers/Cargo.lock +++ b/packaging/windows/drivers/Cargo.lock @@ -405,11 +405,21 @@ dependencies = [ name = "pf-dualsense" version = "0.0.1" dependencies = [ + "pf-driver-proto", + "pf-umdf-util", "wdk", "wdk-build", "wdk-sys", ] +[[package]] +name = "pf-umdf-util" +version = "0.0.1" +dependencies = [ + "pf-driver-proto", + "wdk-sys", +] + [[package]] name = "pf-vdisplay" version = "0.0.1" @@ -427,6 +437,8 @@ dependencies = [ name = "pf-xusb" version = "0.0.1" dependencies = [ + "pf-driver-proto", + "pf-umdf-util", "wdk", "wdk-build", "wdk-sys", diff --git a/packaging/windows/drivers/Cargo.toml b/packaging/windows/drivers/Cargo.toml index 2e39492..c483752 100644 --- a/packaging/windows/drivers/Cargo.toml +++ b/packaging/windows/drivers/Cargo.toml @@ -7,7 +7,7 @@ # crates/pf-driver-proto from the main tree. [workspace] resolver = "2" -members = ["wdk-probe", "wdk-iddcx", "pf-vdisplay", "pf-dualsense", "pf-xusb"] +members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb"] [workspace.package] edition = "2024" @@ -20,6 +20,7 @@ wdk = "0.4.1" wdk-sys = "0.5.1" wdk-build = "0.5.1" wdk-iddcx = { path = "wdk-iddcx" } +pf-umdf-util = { path = "pf-umdf-util" } pf-driver-proto = { path = "../../../crates/pf-driver-proto" } # Vendored windows-drivers-rs 0.5.1 (the published, self-contained crates) + an added `iddcx` diff --git a/packaging/windows/drivers/pf-dualsense/Cargo.toml b/packaging/windows/drivers/pf-dualsense/Cargo.toml index 6ff4161..8284a5a 100644 --- a/packaging/windows/drivers/pf-dualsense/Cargo.toml +++ b/packaging/windows/drivers/pf-dualsense/Cargo.toml @@ -23,6 +23,8 @@ 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"] diff --git a/packaging/windows/drivers/pf-dualsense/README.md b/packaging/windows/drivers/pf-dualsense/README.md index 67197aa..cc3595e 100644 --- a/packaging/windows/drivers/pf-dualsense/README.md +++ b/packaging/windows/drivers/pf-dualsense/README.md @@ -85,6 +85,9 @@ silently breaks them: - **Multi-pad** works via `UmdfHostProcessSharing=ProcessSharingDisabled` — each pad gets its own WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the - device Location (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-` channel. + device Location (`WdfDeviceAllocAndQueryProperty`) to poll its own `*-boot-` bootstrap + mailbox (the DATA section itself is unnamed — the sealed pad channel, + `design/gamepad-channel-sealing.md` — and its `pad_index` is validated against this index on + attach). - Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/dualsense.rs`. diff --git a/packaging/windows/drivers/pf-dualsense/src/lib.rs b/packaging/windows/drivers/pf-dualsense/src/lib.rs index 9aa6d9f..296eb1f 100644 --- a/packaging/windows/drivers/pf-dualsense/src/lib.rs +++ b/packaging/windows/drivers/pf-dualsense/src/lib.rs @@ -1,36 +1,39 @@ -// punktfunk virtual DualSense — UMDF2 HID minidriver (M0 spike). +// punktfunk virtual DualSense / DualShock 4 — UMDF2 HID minidriver. // // A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense -// (VID 054C / PID 0CE6) using the inputtino report descriptor + feature blobs punktfunk already -// ships in `inject/dualsense.rs`. Its purpose for M0(b) is to (1) enumerate as a genuine DualSense -// and (2) LOG every output report the game writes — the adaptive-trigger `0x02` gate. +// (VID 054C / PID 0CE6) or DualShock 4 (device_type=1) using the inputtino report descriptor + +// feature blobs punktfunk already ships in `inject/{dualsense,dualshock4}.rs`. Games see a genuine +// HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back. // // No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics. -// All WDF calls go through `call_unsafe_wdf_function_binding!`; HID/WDF structs are hand-built. +// The host channel is the **sealed pad channel** (design/gamepad-channel-sealing.md, proto v2): the +// whole handshake + all shared-memory access lives in `pf_umdf_util` (the audited unsafe layer), so +// this crate's channel/HID/IOCTL logic is 100% SAFE Rust. The only `unsafe` here is the unavoidable +// WDF setup FFI in DriverEntry/EvtDeviceAdd/the timer, each with a `// SAFETY:` proof. #![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::ffi::c_void; -use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering}; +use pf_driver_proto::gamepad::PadShm; +use pf_umdf_util::channel::{ChannelClient, ChannelConfig}; +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, WDFMEMORY, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, + 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_UNSUCCESSFUL: NTSTATUS = 0xC000_0001u32 as NTSTATUS; const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS; const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS; -const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS; -#[inline] -fn nt_success(s: NTSTATUS) -> bool { - s >= 0 -} +use pf_umdf_util::nt_success; // ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ---- const fn hid_ctl(id: u32) -> u32 { @@ -225,26 +228,45 @@ static MANUAL_QUEUE: AtomicPtr = AtomicPtr::new(core::ptr::null_mut( /// to pended game READ_REPORTs. Defaults to neutral until the host connects. static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_REPORT); -// ---- user-mode shared-memory IPC with the punktfunk host ---- +// ---- the sealed pad channel: layouts + offsets from pf_driver_proto (drift = compile error) ---- // UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack // (custom interface CreateFile → err 31; custom IOCTL on the HID handle → err 1) and UMDF has no -// control device, so the host channel is a named section the (privileged) host CREATES and the driver -// OPENS. Layout (256 B, must match pf_driver_proto::gamepad::PadShm): magic u32 @0 ("PFDS"), -// input_seq u32 @4, input_report[64] @8, output_seq u32 @72, output_report[64] @76, -// device_type u8 @140, driver_proto u32 @144 (we stamp GAMEPAD_PROTO_VERSION = the host's -// driver-attach health signal), driver_heartbeat u32 @148 (we bump per timer tick = liveness). -const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ -const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" little-endian -const SHM_SIZE: usize = 256; -const GAMEPAD_PROTO_VERSION: u32 = 1; // must match pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION -static LOGGED_SHM: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); +// control device. So the DATA section (`PadShm`, 256 B — input report @8, output seq @72, output +// report @76, device_type @140, health marks @144/@148, pad_index @152) is UNNAMED and reached only +// through a handle the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named mailbox +// `Global\pfds-boot-`. The handshake + all shared-memory access live in `pf_umdf_util`. +const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS" +const SHM_SIZE: usize = core::mem::size_of::(); +const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION; -// kernel32 file-mapping APIs (resolved via std's kernel32 import; UMDF permits file mapping). -unsafe extern "system" { - fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void; - fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void; - fn UnmapViewOfFile(addr: *const c_void) -> i32; - fn CloseHandle(h: *mut c_void) -> i32; +// PadShm field offsets (the driver reads input + device_type, writes output + health marks). +const OFF_INPUT: usize = core::mem::offset_of!(PadShm, input); +const OFF_OUT_SEQ: usize = core::mem::offset_of!(PadShm, out_seq); +const OFF_OUTPUT: usize = core::mem::offset_of!(PadShm, output); +const OFF_DEVICE_TYPE: usize = core::mem::offset_of!(PadShm, device_type); +const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(PadShm, driver_proto); +const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(PadShm, driver_heartbeat); +const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index); + +/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so +/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. +static CHANNEL: ChannelClient = ChannelClient::new(); +/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4) — the neutral-report shape when +/// the channel detaches, and the fallback identity while unattached. +static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0); +/// device_type()'s bounded first-read wait fires at most once (see its docs). +static DEVTYPE_WAITED: AtomicBool = AtomicBool::new(false); + +/// This pad's channel config (magic/size/pad_index offset + our logger). +fn channel_cfg() -> ChannelConfig { + ChannelConfig { + tag: "pf-ds", + boot_name_prefix: "Global\\pfds-boot-", + data_magic: SHM_MAGIC, + data_size: SHM_SIZE, + pad_index_off: OFF_PAD_INDEX, + log, + } } fn log(s: &str) { @@ -289,59 +311,6 @@ pub unsafe extern "system" fn driver_entry( } } -/// The pad index this device serves (which `pfds-shm-` section to map). The host stamps it into -/// the device Location (`pszDeviceLocation`); the driver reads it in EvtDeviceAdd. With -/// `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) each pad gets its own WUDFHost, so this -/// static is per-pad — the basis for multi-pad. -static SHM_INDEX: AtomicU32 = AtomicU32::new(0); -/// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (not re-exported at the wdk_sys root). -const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10; - -/// Read the pad index the host stamped into the device Location (a NUL-terminated UTF-16 decimal -/// string). Defaults to 0 (single-pad) if absent. -fn query_shm_index(device: WDFDEVICE) -> u32 { - let mut mem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: device valid; property = LocationInformation; pool ignored in UMDF; mem receives the handle. - let st = unsafe { - call_unsafe_wdf_function_binding!( - WdfDeviceAllocAndQueryProperty, - device, - DEVICE_PROPERTY_LOCATION_INFORMATION, - 0, - WDF_NO_OBJECT_ATTRIBUTES, - &mut mem - ) - }; - if !nt_success(st) || mem.is_null() { - return 0; - } - let mut len: usize = 0; - // SAFETY: mem valid. - let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) } - as *const u16; - if buf.is_null() { - return 0; - } - let mut idx: u32 = 0; - let mut any = false; - for i in 0..(len / 2).min(8) { - // SAFETY: buf valid for len bytes; i < len/2. - let c = unsafe { *buf.add(i) }; - if c == 0 { - break; - } - if (0x30..=0x39).contains(&c) { - idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32); - any = true; - } - } - if any { - idx - } else { - 0 - } -} - extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { log("[pf-ds] EvtDeviceAdd"); @@ -364,8 +333,9 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI return st; } - let shm_idx = query_shm_index(device); - SHM_INDEX.store(shm_idx, Ordering::Relaxed); + // 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-ds] shm index = {shm_idx}"); // Default parallel queue handling all IOCTLs. @@ -428,6 +398,8 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI 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::() as ULONG; tattr.ParentObject = manual_queue.cast(); @@ -458,140 +430,72 @@ extern "C" fn evt_io_device_control( _input_len: usize, ioctl: ULONG, ) { - let mut complete = true; + // 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 8ms READ_REPORT cadence so the log stays readable during a game test; // the 0x02 OUTPUT report (the gate) and the descriptor handshake still log. if ioctl != IOCTL_HID_READ_REPORT { dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}"); } + + // READ_REPORT forwards to the manual queue (the timer completes it) — 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 => { - copy_to_output(request, if device_type() == 1 { &DS4_HID_DESC } else { &HID_DESC }) - } - IOCTL_HID_GET_DEVICE_ATTRIBUTES => copy_to_output(request, &hid_attrs(device_type() == 1)), - IOCTL_HID_GET_REPORT_DESCRIPTOR => copy_to_output( - request, - if device_type() == 1 { - &DS4_RDESC[..] - } else { - &DUALSENSE_RDESC[..] - }, - ), - IOCTL_HID_READ_REPORT => { - let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst); - // SAFETY: request valid; mq is the manual queue created in EvtDeviceAdd. - let st = unsafe { - call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, mq) - }; - if nt_success(st) { - complete = false; - STATUS_SUCCESS - } else { - st - } - } + IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(if device_type() == 1 { + &DS4_HID_DESC + } else { + &HID_DESC + }), + IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type() == 1)), + IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(if device_type() == 1 { + &DS4_RDESC[..] + } else { + &DUALSENSE_RDESC[..] + }), IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { - on_output_report(request, ioctl) + on_output_report(&request, ioctl) } IOCTL_UMDF_HID_SET_FEATURE => { log("[pf-ds] SET_FEATURE (stub ok)"); STATUS_SUCCESS } - IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(request), + IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request), IOCTL_UMDF_HID_GET_INPUT_REPORT => { - copy_to_output(request, &neutral_report(device_type() == 1)) + request.copy_to_output(&neutral_report(device_type() == 1)) } - IOCTL_HID_GET_STRING => on_get_string(request), + IOCTL_HID_GET_STRING => on_get_string(&request), _ => STATUS_NOT_IMPLEMENTED, }; - if ioctl != IOCTL_HID_READ_REPORT { - dbglog!( - "[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x} complete={complete}", - status as u32 - ); - } - if complete { - // SAFETY: request valid and not forwarded. - unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status) }; - } -} - -// Copy `src` into the request's output memory and set the completed byte count. -fn copy_to_output(request: WDFREQUEST, src: &[u8]) -> NTSTATUS { - let mut mem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: request valid; mem receives the memory handle. - let st = unsafe { - call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut mem) - }; - if !nt_success(st) { - return st; - } - let mut outlen: usize = 0; - // SAFETY: mem valid; outlen receives the buffer size. - let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) }; - if outlen < src.len() { - return STATUS_INVALID_BUFFER_SIZE; - } - // SAFETY: mem valid; src is a valid buffer of src.len() bytes. - let st = unsafe { - call_unsafe_wdf_function_binding!( - WdfMemoryCopyFromBuffer, - mem, - 0usize, - src.as_ptr() as *mut c_void, - src.len() - ) - }; - if !nt_success(st) { - return st; - } - // SAFETY: request valid. - unsafe { - call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, src.len() as u64) - }; - STATUS_SUCCESS + dbglog!("[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x}", status as u32); + request.complete(status); } // The 0x02 gate: a game writing an output report (rumble / lightbar / ADAPTIVE TRIGGERS). Per the // UMDF marshalling convention the report data is the *input* buffer and the report id is carried in -// the *output* buffer length. We log it. -fn on_output_report(request: WDFREQUEST, ioctl: ULONG) -> NTSTATUS { - let mut inmem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: request valid. - let st = unsafe { - call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem) +// the *output* buffer length. We log it, then publish it to the DATA section for the host. +fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS { + let (bytes, inlen) = match request.input_bytes(64) { + Ok(v) => v, + Err(st) => return st, }; - if !nt_success(st) { - return st; - } - let mut inlen: usize = 0; - // SAFETY: inmem valid. - let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) } - as *const u8; + let report_id = request.output_buffer_len() as u32; // report id, UMDF convention - // report id from output-buffer length (UMDF convention). - let mut report_id: u32 = 0; - let mut outmem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: request valid; output memory is optional here. - if nt_success(unsafe { - call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut outmem) - }) { - let mut outlen: usize = 0; - // SAFETY: outmem valid. - let _ = - unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, outmem, &mut outlen) }; - report_id = outlen as u32; - } - - let n = inlen.min(48); let mut hex = String::new(); - if !inbuf.is_null() { - // SAFETY: inbuf valid for inlen bytes; we read at most n. - let bytes = unsafe { core::slice::from_raw_parts(inbuf, n) }; - for b in bytes { - hex.push_str(&format!("{b:02x} ")); - } + for b in bytes.iter().take(48) { + hex.push_str(&format!("{b:02x} ")); } let kind = if ioctl == IOCTL_HID_WRITE_REPORT { "WRITE_REPORT" @@ -600,45 +504,29 @@ fn on_output_report(request: WDFREQUEST, ioctl: ULONG) -> NTSTATUS { }; dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}"); - // Publish the game's 0x02 output report to shared memory for the host (rumble / lightbar / - // player-LEDs / adaptive triggers). output_report @76, output_seq @72. - if !inbuf.is_null() && inlen > 0 { - let n = inlen.min(64); - with_shm(|view| { - // SAFETY: view is a mapped 256-byte section; write the report then bump the host-polled seq. - unsafe { - core::ptr::copy_nonoverlapping(inbuf, view.add(76), n); - let seqp = view.add(72) as *mut u32; - let seq = core::ptr::read_unaligned(seqp).wrapping_add(1); - core::ptr::write_unaligned(seqp, seq); - } - }); + // Publish the game's 0x02 output report to the sealed DATA section for the host (rumble / + // lightbar / player-LEDs / adaptive triggers), then bump the host-polled output seq. + if !bytes.is_empty() + && let Some(view) = CHANNEL.data() + { + view.write_bytes(OFF_OUTPUT, &bytes); + let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1); + view.write_u32(OFF_OUT_SEQ, seq); } - // SAFETY: request valid. - unsafe { call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, inlen as u64) }; + request.set_information(inlen as u64); STATUS_SUCCESS } -// GET_FEATURE: report id from the input buffer; reply with the matching DualSense feature blob. -fn on_get_feature(request: WDFREQUEST) -> NTSTATUS { - let mut inmem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: request valid. - let st = unsafe { - call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem) +// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob. +fn on_get_feature(request: &Request) -> NTSTATUS { + let (bytes, _) = match request.input_bytes(1) { + Ok(v) => v, + Err(st) => return st, }; - if !nt_success(st) { - return st; - } - let mut inlen: usize = 0; - // SAFETY: inmem valid. - let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) } - as *const u8; - if inbuf.is_null() || inlen < 1 { + let Some(&report_id) = bytes.first() else { return STATUS_INVALID_PARAMETER; - } - // SAFETY: inbuf valid for >=1 byte. - let report_id = unsafe { *inbuf }; + }; // DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3. let blob: &[u8] = match (device_type() == 1, report_id) { (false, 0x05) => &DS_FEATURE_CALIBRATION, @@ -652,31 +540,21 @@ fn on_get_feature(request: WDFREQUEST) -> NTSTATUS { return STATUS_INVALID_PARAMETER; } }; - copy_to_output(request, blob) + request.copy_to_output(blob) } // IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word is // the language id. Reply with the requested device string as a NUL-terminated UTF-16 buffer. Native // PS5 / Steam code reads these (HidD_GetProductString / HidD_GetSerialNumberString — the serial is one -// way they tell USB from BT); the old default returned STATUS_NOT_IMPLEMENTED, leaving them blank. -// Observed live on this device, Windows polls ids 0x0E/0x0F/0x10 (lang 0x0409) cyclically — the -// manufacturer/product/serial slots — NOT the 0/1/2 HID_STRING_ID_* constants; we map both forms. -fn on_get_string(request: WDFREQUEST) -> NTSTATUS { - let mut inmem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: request valid. - let st = unsafe { - call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem) +// way they tell USB from BT). Observed live: Windows polls ids 0x0E/0x0F/0x10 (lang 0x0409) +// cyclically — the manufacturer/product/serial slots — NOT the 0/1/2 HID_STRING_ID_* constants; both. +fn on_get_string(request: &Request) -> NTSTATUS { + let (bytes, _) = match request.input_bytes(4) { + Ok(v) => v, + Err(st) => return st, }; - if !nt_success(st) { - return st; - } - let mut inlen: usize = 0; - // SAFETY: inmem valid. - let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) } - as *const u8; - // SAFETY: inbuf is valid for inlen bytes; read the 4-byte id value when present. - let id_val: u32 = if !inbuf.is_null() && inlen >= 4 { - unsafe { core::ptr::read_unaligned(inbuf as *const u32) } + let id_val: u32 = if bytes.len() >= 4 { + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) } else { 0 }; @@ -706,96 +584,81 @@ fn on_get_string(request: WDFREQUEST) -> NTSTATUS { } } }; - let mut wide: Vec = s.encode_utf16().collect(); - wide.push(0); // NUL terminator - // SAFETY: reinterpret the UTF-16 buffer as bytes for the byte-oriented copy_to_output. - let bytes = unsafe { core::slice::from_raw_parts(wide.as_ptr() as *const u8, wide.len() * 2) }; - copy_to_output(request, bytes) + let mut wide: Vec = 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) } -// Open + map the host's shared-memory section (Global\pfds-shm-0) and run `f` against the mapped base -// if it exists with a valid magic, then unmap. NOT cached: re-mapped per access so the driver always -// sees the current section (UMDF groups all devices in one WUDFHost, and the host may recreate the -// section across restarts — a cached view would go stale). ~125 maps/s from the timer = negligible. -fn with_shm(f: F) { - let name: Vec = format!("Global\\pfds-shm-{}", SHM_INDEX.load(Ordering::Relaxed)) - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); - // SAFETY: name is a valid NUL-terminated UTF-16 string. - let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, name.as_ptr()) }; - if h.is_null() { - return; - } - // SAFETY: h is a valid mapping handle; map the whole section. The view keeps the section alive, - // so the handle can be closed right away. - let view = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, SHM_SIZE) } as *mut u8; - unsafe { CloseHandle(h) }; - if view.is_null() { - return; - } - // SAFETY: view points at >= 4 mapped bytes. - let magic = unsafe { core::ptr::read_unaligned(view as *const u32) }; - if magic == SHM_MAGIC { - if !LOGGED_SHM.swap(true, Ordering::Relaxed) { - dbglog!( - "[pf-ds] control: shared memory mapped (Global\\pfds-shm-{})", - SHM_INDEX.load(Ordering::Relaxed) - ); - } - f(view); - } - // SAFETY: view came from MapViewOfFile. - unsafe { UnmapViewOfFile(view as *const c_void) }; -} - -/// The host's device-type selector from shared memory (`device_type` byte @140): 0 = DualSense -/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap, and the host stamps the -/// section before `SwDeviceCreate`, so it's set by the time hidclass asks for the descriptor / -/// attributes. Defaults to DualSense if the section isn't mapped yet (magic absent). +/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense +/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap. If the channel hasn't +/// attached when hidclass first asks (the host stamps the section + eager-delivers before +/// `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel briefly — +/// ONCE — for the delivery: a DS4 pad must not enumerate with the default DualSense identity because +/// of a lost race. After that one bounded wait, fall back to the last observed type. fn device_type() -> u8 { - let mut t = 0u8; - with_shm(|view| { - // SAFETY: view points at a mapped 256-byte section; the device-type byte is at offset 140. - t = unsafe { *view.add(140) }; - }); - t + if let Some(view) = CHANNEL.data() { + let t = view.read_u8(OFF_DEVICE_TYPE); + LAST_DEVTYPE.store(t as u32, Ordering::Relaxed); + return t; + } + if !DEVTYPE_WAITED.swap(true, Ordering::SeqCst) { + let cfg = channel_cfg(); + for _ in 0..100 { + if let Some(view) = CHANNEL.pump(&cfg) { + let t = view.read_u8(OFF_DEVICE_TYPE); + LAST_DEVTYPE.store(t as u32, Ordering::Relaxed); + return t; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + dbglog!( + "[pf-ds] device_type: sealed channel not attached within 1s — defaulting to the last observed identity" + ); + } + LAST_DEVTYPE.load(Ordering::Relaxed) as u8 } extern "C" fn evt_timer(timer: WDFTIMER) { - // Pull the latest host input report from shared memory (if the host has connected). - with_shm(|view| { - let mut buf = [0u8; 64]; - // SAFETY: view points at a mapped 256-byte section; input lives at offset 8..72. - unsafe { core::ptr::copy_nonoverlapping(view.add(8), buf.as_mut_ptr(), 64) }; - if buf[0] == 0x01 { - if let Ok(mut g) = INPUT_REPORT.lock() { + // One sealed-channel tick: publish our pid / adopt a delivery / detect host-gone, then pull the + // latest host input report from the attached DATA section (all safe, via pf_umdf_util). + match CHANNEL.pump(&channel_cfg()) { + Some(view) => { + let mut buf = [0u8; 64]; + view.read_bytes(OFF_INPUT, &mut buf); + if buf[0] == 0x01 + && let Ok(mut g) = INPUT_REPORT.lock() + { *g = buf; } + // Health marks the host watches: driver_proto (attach signal, idempotent) and + // driver_heartbeat (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound + // and alive" apart from "driver package missing/failed to bind". + 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); } - // Health marks the host watches: driver_proto @144 (attach signal, idempotent) and - // driver_heartbeat @148 (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound - // and alive" apart from "driver package missing/failed to bind". - // SAFETY: view points at a mapped 256-byte section; proto @144, heartbeat @148. - unsafe { - core::ptr::write_unaligned(view.add(144) as *mut u32, GAMEPAD_PROTO_VERSION); - let hb = view.add(148) as *mut u32; - core::ptr::write_unaligned(hb, core::ptr::read_unaligned(hb).wrapping_add(1)); + None => { + // Host gone (mailbox name vanished) or channel not attached yet: feed games the neutral + // report instead of a frozen last state (matters for the persistent out-of-band devnode, + // which outlives host sessions). + if let Ok(mut g) = INPUT_REPORT.lock() { + *g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) == 1); + } } - }); - // SAFETY: timer valid; parent is the manual queue. + } + + // Complete the next pended READ_REPORT with the current input report (safe queue/request API). + // SAFETY: the timer's parent object is the manual queue (set in EvtDeviceAdd); the framework + // guarantees a live handle here. let queue = unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE; - let mut request: WDFREQUEST = core::ptr::null_mut(); - // SAFETY: queue valid; request receives the next pended request if any. - let st = unsafe { - call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, queue, &mut request) - }; - if nt_success(st) { + // SAFETY: `queue` is that live manual queue — the exact contract `retrieve_next_request` needs. + if let Some(request) = unsafe { wdf::retrieve_next_request(queue) } { let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT); - let s = copy_to_output(request, &report); - // SAFETY: request valid and dequeued. - unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, s) }; + let st = request.copy_to_output(&report); + request.complete(st); } - let _ = STATUS_UNSUCCESSFUL; // keep the const referenced } diff --git a/packaging/windows/drivers/pf-umdf-util/Cargo.toml b/packaging/windows/drivers/pf-umdf-util/Cargo.toml new file mode 100644 index 0000000..a5a0313 --- /dev/null +++ b/packaging/windows/drivers/pf-umdf-util/Cargo.toml @@ -0,0 +1,17 @@ +# pf-umdf-util - the audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers. +# Everything a pad driver does with raw pointers or Win32/WDF FFI lives HERE, behind small safe +# (or explicitly-contracted unsafe) APIs, so the driver crates' business logic is 100% safe Rust: +# section - MappedView: bounds+alignment-checked shared-memory access (atomics for sync fields) +# channel - ChannelClient: the sealed pad channel's driver-side state machine (a SAFE module) +# wdf - Request/queue/device-property helpers over call_unsafe_wdf_function_binding +[package] +name = "pf-umdf-util" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false +description = "punktfunk UMDF driver util: safe shared-memory + sealed-channel + WDF request primitives" + +[dependencies] +wdk-sys.workspace = true +pf-driver-proto.workspace = true diff --git a/packaging/windows/drivers/pf-umdf-util/src/channel.rs b/packaging/windows/drivers/pf-umdf-util/src/channel.rs new file mode 100644 index 0000000..faac5ac --- /dev/null +++ b/packaging/windows/drivers/pf-umdf-util/src/channel.rs @@ -0,0 +1,192 @@ +//! The sealed pad channel, driver side (`design/gamepad-channel-sealing.md`, gamepad proto v2): +//! poll the named bootstrap mailbox by index, publish our pid (iff the host's proto version +//! matches), adopt the host-delivered DATA-section handle, and validate the mapped section's magic +//! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-dualsense` (they used +//! to hand-duplicate it), parameterized by [`ChannelConfig`]. +//! +//! This module **forbids `unsafe`**: the entire state machine is safe Rust over +//! [`section`](crate::section)'s checked accessors — the memory-safety surface of the sealed +//! channel lives in that module alone. + +#![forbid(unsafe_code)] + +use crate::section::{MappedView, ViewCell, close_handle_value}; +use core::mem::offset_of; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use pf_driver_proto::gamepad::{BOOT_MAGIC, GAMEPAD_PROTO_VERSION, PadBootstrap}; + +// PadBootstrap field offsets (the mailbox handshake; pinned by pf_driver_proto's asserts). +const BOOT_OFF_MAGIC: usize = offset_of!(PadBootstrap, magic); +const BOOT_OFF_HOST_PROTO: usize = offset_of!(PadBootstrap, host_proto); +const BOOT_OFF_DRIVER_PID: usize = offset_of!(PadBootstrap, driver_pid); +const BOOT_OFF_DRIVER_PROTO: usize = offset_of!(PadBootstrap, driver_proto); +const BOOT_OFF_DATA_HANDLE: usize = offset_of!(PadBootstrap, data_handle); +const BOOT_OFF_HANDLE_PID: usize = offset_of!(PadBootstrap, handle_pid); +const BOOT_OFF_HANDLE_SEQ: usize = offset_of!(PadBootstrap, handle_seq); +const BOOT_SIZE: usize = core::mem::size_of::(); + +/// What varies between the two pad drivers. +pub struct ChannelConfig { + /// Log-line prefix (`"pf-xusb"` / `"pf-ds"`). + pub tag: &'static str, + /// Mailbox name prefix, completed with the pad index (`"Global\\pfxusb-boot-"` / `"Global\\pfds-boot-"`). + pub boot_name_prefix: &'static str, + /// The DATA section's magic (`XUSB_MAGIC` / `PAD_MAGIC`). + pub data_magic: u32, + /// The DATA section's size (`size_of::()` / `size_of::()`). + pub data_size: usize, + /// `offset_of!(…Shm, pad_index)` in the DATA section. + pub pad_index_off: usize, + /// The driver's logger (each driver tees to its own debug file). + pub log: fn(&str), +} + +/// Per-pad channel state (a `static` in each driver — per-pad because +/// `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own WUDFHost). +pub struct ChannelClient { + /// The pad index from the devnode Location (which mailbox to poll + the `pad_index` the + /// delivered DATA section must carry). + index: AtomicU32, + /// The adopted DATA view; leaked-on-publish (see [`ViewCell`]) so a re-delivery can never + /// unmap a view a concurrent callback still reads through. + data: ViewCell, + /// The last `handle_seq` consumed (CAS-guarded so concurrent pumps adopt a delivery exactly + /// once). Reset to 0 when the mailbox disappears, so a NEW host session's delivery is always + /// fresh even if its (per-host-process) seq counter collides with the previous session's. + consumed_seq: AtomicU32, + logged_proto_mismatch: AtomicBool, + logged_pid: AtomicBool, +} + +impl Default for ChannelClient { + fn default() -> Self { + Self::new() + } +} + +impl ChannelClient { + pub const fn new() -> ChannelClient { + ChannelClient { + index: AtomicU32::new(0), + data: ViewCell::new(), + consumed_seq: AtomicU32::new(0), + logged_proto_mismatch: AtomicBool::new(false), + logged_pid: AtomicBool::new(false), + } + } + + /// Set the pad index (from the devnode Location, in `EvtDeviceAdd`). + pub fn set_index(&self, idx: u32) { + self.index.store(idx, Ordering::Relaxed); + } + + pub fn index(&self) -> u32 { + self.index.load(Ordering::Relaxed) + } + + /// The adopted DATA view regardless of mailbox liveness — for write paths where acting on a + /// stale section is harmless (the pump owns the detach semantics). + pub fn data(&self) -> Option<&'static MappedView> { + self.data.get() + } + + /// One tick of the sealed-channel state machine: publish our pid (+ proto version) in the + /// mailbox, adopt a delivered DATA handle, and return the attached DATA view — `None` while + /// unattached, on a host/driver version mismatch (fail closed), or when the mailbox is gone + /// (host gone). The mailbox is re-opened by name on every call: the name existing doubles as + /// host-liveness (the host closes it when the pad is torn down). + pub fn pump(&self, cfg: &ChannelConfig) -> Option<&'static MappedView> { + let name = format!("{}{}", cfg.boot_name_prefix, self.index()); + let boot = match MappedView::open_named(&name, BOOT_SIZE) { + Some(b) => b, + None => { + // Mailbox gone → the host (or this pad) is gone. Forget the consumed seq so the + // NEXT host session's first delivery always reads as fresh. + self.consumed_seq.store(0, Ordering::Relaxed); + return None; + } + }; + // Acquire pairs with the host's Release magic store, so a valid magic implies `host_proto` + // is visible. A missing/garbled magic reads as "no usable mailbox" (same as absent). + if boot.load_u32(BOOT_OFF_MAGIC, Ordering::Acquire) != BOOT_MAGIC { + self.consumed_seq.store(0, Ordering::Relaxed); + return None; + } + // Publish our proto version first (idempotent) — the host logs a mismatch even when we + // refuse to publish a pid below. + boot.store_u32( + BOOT_OFF_DRIVER_PROTO, + GAMEPAD_PROTO_VERSION, + Ordering::Relaxed, + ); + let host_proto = boot.load_u32(BOOT_OFF_HOST_PROTO, Ordering::Relaxed); + if host_proto != GAMEPAD_PROTO_VERSION { + if !self.logged_proto_mismatch.swap(true, Ordering::Relaxed) { + (cfg.log)(&format!( + "[{}] host proto {host_proto} != driver proto {GAMEPAD_PROTO_VERSION} — \ + refusing the handshake (update host + drivers together)", + cfg.tag + )); + } + return None; // version mismatch — fail closed + } + let mypid = std::process::id(); + if boot.load_u32(BOOT_OFF_DRIVER_PID, Ordering::Relaxed) != mypid { + boot.store_u32(BOOT_OFF_DRIVER_PID, mypid, Ordering::Release); + if !self.logged_pid.swap(true, Ordering::Relaxed) { + (cfg.log)(&format!("[{}] bootstrap: published pid {mypid}", cfg.tag)); + } + } + // A delivery addressed to us we haven't consumed? CAS so concurrent pumps (worker thread / + // timer + IOCTL paths) adopt exactly once. + let seq = boot.load_u32(BOOT_OFF_HANDLE_SEQ, Ordering::Acquire); + let cur = self.consumed_seq.load(Ordering::Relaxed); + if seq != 0 + && seq != cur + && boot.load_u32(BOOT_OFF_HANDLE_PID, Ordering::Relaxed) == mypid + && self + .consumed_seq + .compare_exchange(cur, seq, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + self.adopt(cfg, boot.load_u64(BOOT_OFF_DATA_HANDLE, Ordering::Relaxed)); + } + self.data() + } + + /// Map + validate a delivered DATA-section handle VALUE (untrusted until the mapped section + /// carries our magic AND our pad index). On success we own the handle (adopt-on-success) and + /// close it — the view keeps the section alive. On validation failure the handle is + /// deliberately NOT closed: a tampered value could name an unrelated handle in our own table. + fn adopt(&self, cfg: &ChannelConfig, value: u64) { + let Some(view) = MappedView::from_handle_value(value, cfg.data_size) else { + if value != 0 { + (cfg.log)(&format!( + "[{}] delivered DATA handle 0x{value:x} did not map — ignoring", + cfg.tag + )); + } + return; + }; + let magic = view.load_u32(0, Ordering::Relaxed); + let idx = view.load_u32(cfg.pad_index_off, Ordering::Relaxed); + let want = self.index(); + if magic != cfg.data_magic || idx != want { + (cfg.log)(&format!( + "[{}] delivered DATA section failed validation (magic 0x{magic:08x}, pad_index \ + {idx}, want {want}) — ignoring", + cfg.tag + )); + // `view` drops here → unmapped; the handle stays open (see above). + return; + } + // The value resolved to OUR pad's section, so it is the handle the host duplicated for us — + // we own it; the (about-to-be-leaked) view keeps the section alive after the close. + close_handle_value(value); + self.data.set(view); + (cfg.log)(&format!( + "[{}] sealed pad channel mapped (index {want})", + cfg.tag + )); + } +} diff --git a/packaging/windows/drivers/pf-umdf-util/src/lib.rs b/packaging/windows/drivers/pf-umdf-util/src/lib.rs new file mode 100644 index 0000000..2b43535 --- /dev/null +++ b/packaging/windows/drivers/pf-umdf-util/src/lib.rs @@ -0,0 +1,35 @@ +//! The audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers (`pf-xusb`, +//! `pf-dualsense`). +//! +//! A UMDF driver cannot be literally free of `unsafe` — WDF dispatch, Win32 section mapping and +//! cross-process shared memory are FFI by nature. What Rust *can* buy is confining every raw +//! operation to one small, reviewed layer with explicit contracts, so the drivers' business logic +//! (the sealed-channel state machine, report plumbing, IOCTL policy) is **100 % safe code** and a +//! memory-safety bug can only live in this crate. Three modules: +//! +//! * [`section`] — [`section::MappedView`]: bounds- and alignment-checked access to a mapped shared +//! section (atomics for the cross-process sync fields), plus the leaked-view [`section::ViewCell`]. +//! * [`channel`] — [`channel::ChannelClient`]: the sealed pad channel's driver side +//! (`design/gamepad-channel-sealing.md`), a **`#[forbid(unsafe_code)]` module** — the entire +//! handshake/validation/adoption state machine is safe Rust over [`section`]'s API. +//! * [`wdf`] — [`wdf::Request`] + queue/device-property helpers: each framework callback converts +//! its raw `WDFREQUEST` into a token exactly once (`unsafe`, with the framework's validity as the +//! contract); everything after that is safe. +//! +//! Lint gates (mirrored in every driver crate, enforced by the drivers CI clippy step): +//! `unsafe_op_in_unsafe_fn` + `clippy::undocumented_unsafe_blocks` — every remaining `unsafe {}` +//! must carry a `// SAFETY:` proof. + +#![deny(unsafe_op_in_unsafe_fn)] +#![deny(clippy::undocumented_unsafe_blocks)] + +pub mod channel; +pub mod section; +pub mod wdf; + +/// `NT_SUCCESS` — an NTSTATUS is an error iff negative. +#[inline] +#[must_use] +pub const fn nt_success(status: wdk_sys::NTSTATUS) -> bool { + status >= 0 +} diff --git a/packaging/windows/drivers/pf-umdf-util/src/section.rs b/packaging/windows/drivers/pf-umdf-util/src/section.rs new file mode 100644 index 0000000..4b67e05 --- /dev/null +++ b/packaging/windows/drivers/pf-umdf-util/src/section.rs @@ -0,0 +1,241 @@ +//! Safe access to Win32 shared-memory sections: [`MappedView`] wraps a mapped view of a known +//! length and exposes bounds- and alignment-checked accessors, so callers never touch the raw base +//! pointer. Cross-process sync fields (seqs, pids, handle values) go through real atomics; bulk +//! report regions use plain unaligned copies, guarded by the channel protocol's seq fields — the +//! same access discipline the host side uses (`inject/windows/gamepad_raii.rs`). + +use core::ffi::c_void; +use core::sync::atomic::{AtomicPtr, AtomicU32, AtomicU64, Ordering}; + +const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ + +// kernel32 file-mapping APIs (resolved via std's kernel32 import; UMDF permits file mapping). +unsafe extern "system" { + fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void; + fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void; + fn UnmapViewOfFile(addr: *const c_void) -> i32; + fn CloseHandle(h: *mut c_void) -> i32; +} + +/// A read/write view over a mapped shared section of exactly `len` bytes. Every accessor +/// bounds-checks (and, for the atomic ones, alignment-checks) its offset, so no caller can read or +/// write outside the mapping — the offsets are `offset_of!` constants from `pf_driver_proto`, making +/// a failed check a compile-shaped logic bug (it aborts the WUDFHost rather than corrupting). +/// +/// Concurrency: the peer process writes the section concurrently. Fields used for cross-process +/// synchronization must be accessed through the `load_*`/`store_*` atomic accessors; the bulk +/// byte/scalar accessors are plain unaligned accesses whose consistency is guarded by the channel +/// protocol (seq-fenced publishes), exactly as on the host side. +pub struct MappedView { + base: *mut u8, + len: usize, +} + +// SAFETY: `MappedView` is a pointer + length over an OS mapping that stays valid until +// `UnmapViewOfFile` in `Drop` (or forever, once leaked into a `ViewCell`). All access goes through +// the checked accessors — atomics for shared sync fields, unaligned reads/writes for bulk data — +// none of which require a single-thread owner, so sharing/sending the view across the driver's +// callback threads is sound. +unsafe impl Send for MappedView {} +// SAFETY: as above — `&MappedView` only exposes accessors that are safe under concurrent use. +unsafe impl Sync for MappedView {} + +impl MappedView { + /// Open the named section `name` and map its first `len` bytes read/write. `None` if the name + /// does not exist (e.g. the host is gone) or the mapping fails. The section handle is closed + /// immediately — the view keeps the section alive. + pub fn open_named(name: &str, len: usize) -> Option { + let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + // SAFETY: `wide` is a valid NUL-terminated UTF-16 string for the duration of the call. + let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, wide.as_ptr()) }; + if h.is_null() { + return None; + } + // SAFETY: `h` is the valid mapping handle just opened; map `len` bytes read/write. The view + // keeps the section alive, so the handle can be closed right away. + let base = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, len) } as *mut u8; + // SAFETY: `h` is the valid handle from `OpenFileMappingW`, owned solely by this function. + unsafe { CloseHandle(h) }; + if base.is_null() { + return None; + } + Some(MappedView { base, len }) + } + + /// Map `len` bytes of a section from a raw handle VALUE (the sealed channel's delivery — a + /// handle the host duplicated into this process). `None` if the value does not resolve to a + /// mappable section. The handle itself is NOT consumed — the caller decides after validating + /// the mapped content (see [`close_handle_value`]). + pub fn from_handle_value(value: u64, len: usize) -> Option { + if value == 0 { + return None; + } + // SAFETY: `MapViewOfFile` on an arbitrary handle value is safe — it fails (returns null) + // unless the value resolves to a section handle in this process's table with RW access. + let base = unsafe { MapViewOfFile(value as usize as *mut c_void, FILE_MAP_RW, 0, 0, len) } + as *mut u8; + if base.is_null() { + return None; + } + Some(MappedView { base, len }) + } + + /// Assert `off..off+n` is inside the view and, for atomics, `align`-aligned. The view base is + /// page-aligned (`MapViewOfFile`), so field alignment reduces to offset alignment. + #[inline] + fn check(&self, off: usize, n: usize, align: usize) { + assert!( + off.is_multiple_of(align) && off.checked_add(n).is_some_and(|end| end <= self.len), + "MappedView access out of bounds/alignment (off={off}, n={n}, len={})", + self.len + ); + } + + /// Atomic `u32` load at `off` (must be 4-aligned) — the cross-process sync accessor. + #[inline] + pub fn load_u32(&self, off: usize, order: Ordering) -> u32 { + self.check(off, 4, 4); + // SAFETY: `off` is in-bounds + 4-aligned per `check`, and the page-aligned mapping stays + // valid while `&self` lives; an `AtomicU32` view over shared memory is the defined way to + // race the peer process. + unsafe { (*(self.base.add(off) as *const AtomicU32)).load(order) } + } + + /// Atomic `u32` store at `off` (must be 4-aligned). + #[inline] + pub fn store_u32(&self, off: usize, v: u32, order: Ordering) { + self.check(off, 4, 4); + // SAFETY: as `load_u32` — in-bounds, aligned, valid for `&self`'s lifetime. + unsafe { (*(self.base.add(off) as *const AtomicU32)).store(v, order) } + } + + /// Atomic `u64` load at `off` (must be 8-aligned). + #[inline] + pub fn load_u64(&self, off: usize, order: Ordering) -> u64 { + self.check(off, 8, 8); + // SAFETY: as `load_u32`, with 8-byte size/alignment checked. + unsafe { (*(self.base.add(off) as *const AtomicU64)).load(order) } + } + + /// Plain byte read at `off` (bulk-region accessor — protocol-guarded, see the type docs). + #[inline] + pub fn read_u8(&self, off: usize) -> u8 { + self.check(off, 1, 1); + // SAFETY: in-bounds per `check`; a one-byte read cannot tear. + unsafe { *self.base.add(off) } + } + + /// Plain byte write at `off`. + #[inline] + pub fn write_u8(&self, off: usize, v: u8) { + self.check(off, 1, 1); + // SAFETY: in-bounds per `check`; a one-byte write cannot tear. + unsafe { *self.base.add(off) = v } + } + + /// Plain (unaligned) `u16` read at `off`. + #[inline] + pub fn read_u16(&self, off: usize) -> u16 { + self.check(off, 2, 1); + // SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement. + unsafe { core::ptr::read_unaligned(self.base.add(off) as *const u16) } + } + + /// Plain (unaligned) `u32` read at `off` — the bulk-region accessor for a DATA-section scalar + /// (host-written state / a driver-written publish counter; consistency comes from the channel + /// protocol's seq fences, not from this access, exactly as on the host side). + #[inline] + pub fn read_u32(&self, off: usize) -> u32 { + self.check(off, 4, 1); + // SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement. + unsafe { core::ptr::read_unaligned(self.base.add(off) as *const u32) } + } + + /// Plain (unaligned) `u32` write at `off` (bulk-region accessor). + #[inline] + pub fn write_u32(&self, off: usize, v: u32) { + self.check(off, 4, 1); + // SAFETY: in-bounds per `check`; `write_unaligned` has no alignment requirement. + unsafe { core::ptr::write_unaligned(self.base.add(off) as *mut u32, v) } + } + + /// Plain (unaligned) `i16` read at `off`. + #[inline] + pub fn read_i16(&self, off: usize) -> i16 { + self.check(off, 2, 1); + // SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement. + unsafe { core::ptr::read_unaligned(self.base.add(off) as *const i16) } + } + + /// Copy `dst.len()` bytes out of the view starting at `off`. + pub fn read_bytes(&self, off: usize, dst: &mut [u8]) { + self.check(off, dst.len(), 1); + // SAFETY: the source range is in-bounds per `check`; `dst` is a live exclusive borrow of + // `dst.len()` writable bytes and cannot overlap the foreign mapping. + unsafe { core::ptr::copy_nonoverlapping(self.base.add(off), dst.as_mut_ptr(), dst.len()) } + } + + /// Copy `src` into the view starting at `off`. + pub fn write_bytes(&self, off: usize, src: &[u8]) { + self.check(off, src.len(), 1); + // SAFETY: the destination range is in-bounds per `check`; `src` is a live borrow that + // cannot overlap the foreign mapping. + unsafe { core::ptr::copy_nonoverlapping(src.as_ptr(), self.base.add(off), src.len()) } + } +} + +impl Drop for MappedView { + fn drop(&mut self) { + // SAFETY: `base` is the live view from `MapViewOfFile`, unmapped exactly once (here). + unsafe { + UnmapViewOfFile(self.base as *const c_void); + } + } +} + +/// Close a raw handle VALUE owned by this process — the sealed channel's adopt-on-success step +/// (the mapped view keeps the section alive after the close). Closing a value that is not a live +/// handle of this process is a logic error the OS rejects (returns FALSE); it is not memory-unsafe. +pub fn close_handle_value(value: u64) { + if value == 0 { + return; + } + // SAFETY: `CloseHandle` validates the value against this process's handle table; no memory is + // dereferenced through it. + unsafe { CloseHandle(value as usize as *mut c_void) }; +} + +/// A lock-free cell holding the driver's adopted DATA view as a **leaked** `&'static MappedView`. +/// [`set`](Self::set) leaks the new view (and abandons the old one) instead of ever unmapping: +/// a concurrent framework callback may still be reading through a previously-returned reference, so +/// the mapping must never be torn down — a deliberate, bounded leak (one small view per delivery, +/// at most a handful per pad lifetime). +pub struct ViewCell(AtomicPtr); + +impl Default for ViewCell { + fn default() -> Self { + Self::new() + } +} + +impl ViewCell { + pub const fn new() -> ViewCell { + ViewCell(AtomicPtr::new(core::ptr::null_mut())) + } + + /// The current view, if one was published. The `'static` lifetime is real: published views are + /// leaked and never unmapped. + pub fn get(&self) -> Option<&'static MappedView> { + let p = self.0.load(Ordering::Acquire); + // SAFETY: `p` is either null or a `Box::leak`ed `MappedView` published by `set`, which is + // never dropped or unmapped — so the reference is valid for the process lifetime. + (!p.is_null()).then(|| unsafe { &*p }) + } + + /// Publish `view`, leaking it (and abandoning — NOT freeing — any previous view; see the type + /// docs for why the old mapping must stay alive). + pub fn set(&self, view: MappedView) { + let leaked: &'static mut MappedView = Box::leak(Box::new(view)); + self.0.swap(leaked, Ordering::Release); + } +} diff --git a/packaging/windows/drivers/pf-umdf-util/src/wdf.rs b/packaging/windows/drivers/pf-umdf-util/src/wdf.rs new file mode 100644 index 0000000..b3a583b --- /dev/null +++ b/packaging/windows/drivers/pf-umdf-util/src/wdf.rs @@ -0,0 +1,208 @@ +//! Safe(ly-contracted) helpers over the WDF request/memory/property DDIs the pad drivers use. The +//! pattern: a framework callback converts its raw `WDFREQUEST` into a [`Request`] token **once** +//! (`unsafe`, the framework's validity guarantee is the contract); every operation after that is a +//! safe method, and completion consumes the token so a request cannot be completed twice or used +//! after completion from safe code. + +use wdk_sys::{ + NTSTATUS, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFMEMORY, WDFQUEUE, WDFREQUEST, + call_unsafe_wdf_function_binding, +}; + +const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS; +/// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (the const isn't re-exported at the +/// wdk_sys root; the value is stable WDM). +const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10; + +#[inline] +fn nt_success(s: NTSTATUS) -> bool { + s >= 0 +} + +/// A validity token for one framework-delivered `WDFREQUEST`. Not `Copy`/`Clone`: completing or +/// forwarding consumes it, so safe code cannot touch a request the framework already owns again. +pub struct Request(WDFREQUEST); + +impl Request { + /// Wrap the raw request handed to the current framework callback. + /// + /// # Safety + /// `raw` must be the live, framework-provided `WDFREQUEST` of the callback invocation this is + /// called from (WDF owns handle validity; a forged/dangling handle is framework UB). + pub unsafe fn new(raw: WDFREQUEST) -> Request { + Request(raw) + } + + /// Complete the request with `status` (consumes the token — the framework owns it afterwards). + pub fn complete(self, status: NTSTATUS) { + // SAFETY: `self.0` is the live callback request per `Request::new`'s contract, not yet + // completed or forwarded (both consume the token). + unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, self.0, status) }; + } + + /// Copy `src` into the request's (buffered) output buffer and set the completed byte count. + /// Returns the status to complete with (`STATUS_INVALID_BUFFER_SIZE` if the buffer is short). + pub fn copy_to_output(&self, src: &[u8]) -> NTSTATUS { + let mut mem: WDFMEMORY = core::ptr::null_mut(); + // SAFETY: `self.0` is the live callback request; `mem` receives the memory handle. + let st = unsafe { + call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, self.0, &mut mem) + }; + if !nt_success(st) { + return st; + } + let mut outlen: usize = 0; + // SAFETY: `mem` is the valid memory object just retrieved; `outlen` receives its size. + let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) }; + if outlen < src.len() { + return STATUS_INVALID_BUFFER_SIZE; + } + // SAFETY: `mem` is valid and at least `src.len()` bytes; `src` is a live borrow. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfMemoryCopyFromBuffer, + mem, + 0usize, + src.as_ptr() as *mut core::ffi::c_void, + src.len() + ) + }; + if !nt_success(st) { + return st; + } + // SAFETY: `self.0` is the live callback request. + unsafe { + call_unsafe_wdf_function_binding!(WdfRequestSetInformation, self.0, src.len() as u64) + }; + 0 // STATUS_SUCCESS + } + + /// The request's input buffer: up to `cap` bytes copied out, plus the buffer's TRUE length. + /// `Err(status)` if the input memory can't be retrieved (propagate as the completion status). + pub fn input_bytes(&self, cap: usize) -> Result<(Vec, usize), NTSTATUS> { + let mut inmem: WDFMEMORY = core::ptr::null_mut(); + // SAFETY: `self.0` is the live callback request; `inmem` receives the memory handle. + let st = unsafe { + call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, self.0, &mut inmem) + }; + if !nt_success(st) { + return Err(st); + } + let mut len: usize = 0; + // SAFETY: `inmem` is the valid memory object just retrieved; `len` receives its size. + let p = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut len) } + as *const u8; + if p.is_null() { + return Ok((Vec::new(), 0)); + } + let n = len.min(cap); + // SAFETY: `p` is valid for `len` bytes per `WdfMemoryGetBuffer`; we read `n <= len`. + let bytes = unsafe { core::slice::from_raw_parts(p, n) }.to_vec(); + Ok((bytes, len)) + } + + /// The request's output-buffer LENGTH (0 if unavailable) — UMDF HID marshalling carries the + /// output-report id in it. + pub fn output_buffer_len(&self) -> usize { + let mut outmem: WDFMEMORY = core::ptr::null_mut(); + // SAFETY: `self.0` is the live callback request; output memory is optional here. + if !nt_success(unsafe { + call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, self.0, &mut outmem) + }) { + return 0; + } + let mut outlen: usize = 0; + // SAFETY: `outmem` is the valid memory object just retrieved; `outlen` receives its size. + let _ = + unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, outmem, &mut outlen) }; + outlen + } + + /// Set the completed-bytes information field (for paths that complete with a length but no + /// output copy, e.g. echoing an output report's length). + pub fn set_information(&self, info: u64) { + // SAFETY: `self.0` is the live callback request. + unsafe { call_unsafe_wdf_function_binding!(WdfRequestSetInformation, self.0, info) }; + } + + /// Forward the request to a manual queue. On success the framework owns it (the token is + /// consumed by value — the caller cannot touch the request again); on failure the token is + /// handed back with the status so the caller completes it. (`Request` has no `Drop`, so the + /// consumed-on-success token simply falls out of scope — nothing to run.) + /// + /// # Safety + /// `queue` must be a live manual `WDFQUEUE` of the same device (e.g. the one created in + /// `EvtDeviceAdd` and stashed in a static). + pub unsafe fn forward_to_queue(self, queue: WDFQUEUE) -> Result<(), (Request, NTSTATUS)> { + // SAFETY: `self.0` is the live callback request; `queue` is live per this fn's contract. + let st = + unsafe { call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, self.0, queue) }; + if nt_success(st) { + Ok(()) + } else { + Err((self, st)) + } + } +} + +/// Pop the next pended request off a manual queue (`None` when empty). +/// +/// # Safety +/// `queue` must be a live manual `WDFQUEUE` (e.g. the timer's parent object). +pub unsafe fn retrieve_next_request(queue: WDFQUEUE) -> Option { + let mut request: WDFREQUEST = core::ptr::null_mut(); + // SAFETY: `queue` is live per this fn's contract; `request` receives the next pended request. + let st = unsafe { + call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, queue, &mut request) + }; + // SAFETY: on success `request` is a live framework request this caller now services — the + // exact contract `Request::new` requires. + nt_success(st).then(|| unsafe { Request::new(request) }) +} + +/// Read the pad index the host stamped into the device Location (`pszDeviceLocation`), a +/// NUL-terminated UTF-16 decimal string. Defaults to 0 (single-pad) if absent. (The WDFMEMORY is +/// device-parented and freed by the framework at device teardown — one small alloc per device add.) +/// +/// # Safety +/// `device` must be the live `WDFDEVICE` created in the current `EvtDeviceAdd`. +pub unsafe fn query_location_index(device: WDFDEVICE) -> u32 { + let mut mem: wdk_sys::WDFMEMORY = core::ptr::null_mut(); + // SAFETY: `device` is live per this fn's contract; property = LocationInformation; pool ignored + // in UMDF; `mem` receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceAllocAndQueryProperty, + device, + DEVICE_PROPERTY_LOCATION_INFORMATION, + 0, + WDF_NO_OBJECT_ATTRIBUTES, + &mut mem + ) + }; + if !nt_success(st) || mem.is_null() { + return 0; + } + let mut len: usize = 0; + // SAFETY: `mem` is the valid memory object just allocated; `len` receives its size. + let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) } + as *const u16; + if buf.is_null() { + return 0; + } + let units = (len / 2).min(8); + // SAFETY: `buf` is valid for `len` bytes per `WdfMemoryGetBuffer`; we read `units * 2 <= len`. + let chars = unsafe { core::slice::from_raw_parts(buf, units) }; + let mut idx: u32 = 0; + let mut any = false; + for &c in chars { + if c == 0 { + break; + } + if (0x30..=0x39).contains(&c) { + idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32); + any = true; + } + } + if any { idx } else { 0 } +} diff --git a/packaging/windows/drivers/pf-vdisplay/pf_vdisplay.inx b/packaging/windows/drivers/pf-vdisplay/pf_vdisplay.inx index 5ae1eb5..9ff89ea 100644 --- a/packaging/windows/drivers/pf-vdisplay/pf_vdisplay.inx +++ b/packaging/windows/drivers/pf-vdisplay/pf_vdisplay.inx @@ -42,8 +42,10 @@ AddReg=pf_vdisplay_HardwareDeviceSettings [pf_vdisplay_HardwareDeviceSettings] HKR, , "UpperFilters", %REG_MULTI_SZ%, "IndirectKmd" HKR, "WUDF", "DeviceGroupId", %REG_SZ%, "pfVDisplayGroup" -; Let the host (LocalSystem service) + admins open the control device for the ADD/REMOVE/PING IOCTLs. -HKR, , "Security", , "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;WD)" +; Only the host (LocalSystem service) + admins may open the control device. Deliberately NO Everyone +; ACE (SudoVDA ships one for its user-mode host): the control plane creates/removes monitors and +; bootstraps the sealed frame channel (IOCTL_SET_FRAME_CHANNEL), so it is not for unprivileged callers. +HKR, , "Security", , "D:P(A;;GA;;;SY)(A;;GA;;;BA)" [pf_vdisplay_Install.NT.Services] AddService=WUDFRd,0x000001fa,WUDFRD_ServiceInstall diff --git a/packaging/windows/drivers/pf-vdisplay/src/adapter.rs b/packaging/windows/drivers/pf-vdisplay/src/adapter.rs index 033c4d3..25c95c1 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/adapter.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/adapter.rs @@ -36,6 +36,8 @@ struct SendAdapter(iddcx::IDDCX_ADAPTER); // SAFETY: an opaque IddCx handle, used only as an argument to IddCx DDIs (themselves the synchronisation // point) — never dereferenced in Rust. Storing it across threads in a OnceLock is sound. unsafe impl Send for SendAdapter {} +// SAFETY: as above — the handle is only ever passed by value to IddCx DDIs, never dereferenced, so +// shared `&SendAdapter` access across threads is sound. unsafe impl Sync for SendAdapter {} static ADAPTER: OnceLock = OnceLock::new(); diff --git a/packaging/windows/drivers/pf-vdisplay/src/callbacks.rs b/packaging/windows/drivers/pf-vdisplay/src/callbacks.rs index 2192610..ba0b1d9 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/callbacks.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/callbacks.rs @@ -51,8 +51,9 @@ pub unsafe extern "C" fn parse_monitor_description( p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION, p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION, ) -> NTSTATUS { - // SAFETY: framework-provided in/out args, valid for the call. + // SAFETY: the framework supplies a valid, live input-args pointer for the call. let in_args = unsafe { &*p_in }; + // SAFETY: the framework supplies a valid, live output-args pointer for the call. let out_args = unsafe { &mut *p_out }; // SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes. let edid = unsafe { @@ -100,8 +101,9 @@ pub unsafe extern "C" fn parse_monitor_description2( p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION2, p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION, ) -> NTSTATUS { - // SAFETY: framework-provided in/out args, valid for the call. + // SAFETY: the framework supplies a valid, live input-args pointer for the call. let in_args = unsafe { &*p_in }; + // SAFETY: the framework supplies a valid, live output-args pointer for the call. let out_args = unsafe { &mut *p_out }; // SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes. let edid = unsafe { @@ -156,8 +158,9 @@ pub unsafe extern "C" fn monitor_query_modes( p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES, p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES, ) -> NTSTATUS { - // SAFETY: framework-provided in/out args, valid for the call. + // SAFETY: the framework supplies a valid, live input-args pointer for the call. let in_args = unsafe { &*p_in }; + // SAFETY: the framework supplies a valid, live output-args pointer for the call. let out_args = unsafe { &mut *p_out }; let Some(modes) = crate::monitor::modes_for_object(monitor) else { return STATUS_NOT_FOUND; @@ -183,8 +186,9 @@ pub unsafe extern "C" fn monitor_query_modes2( p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES2, p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES, ) -> NTSTATUS { - // SAFETY: framework-provided in/out args, valid for the call. + // SAFETY: the framework supplies a valid, live input-args pointer for the call. let in_args = unsafe { &*p_in }; + // SAFETY: the framework supplies a valid, live output-args pointer for the call. let out_args = unsafe { &mut *p_out }; let Some(modes) = crate::monitor::modes_for_object(monitor) else { return STATUS_NOT_FOUND; @@ -279,7 +283,8 @@ pub unsafe extern "C" fn assign_swap_chain( drop(crate::monitor::take_swap_chain_processor(monitor)); // The OS target id (stamped on the monitor at creation, after IddCxMonitorArrival) keys the - // per-monitor objects STEP 6's host opens. 0 (default) if the monitor isn't found. + // frame-channel stash STEP 6's worker attaches from (the host addresses its IOCTL_SET_FRAME_CHANNEL + // delivery by this id). 0 (default) if the monitor isn't found — the worker then never attaches. let target_id = crate::monitor::target_id_for_object(monitor).unwrap_or(0); if let Some(device) = crate::direct_3d_device::pooled_device(luid) { diff --git a/packaging/windows/drivers/pf-vdisplay/src/control.rs b/packaging/windows/drivers/pf-vdisplay/src/control.rs index 544bad1..c109786 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/control.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/control.rs @@ -93,6 +93,8 @@ pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) { } // SAFETY: `request` is the framework WDFREQUEST. control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) }, + // SAFETY: `request` is the framework WDFREQUEST. + control::IOCTL_SET_FRAME_CHANNEL => unsafe { set_frame_channel(request) }, _ => complete(request, STATUS_NOT_FOUND), } } @@ -148,11 +150,49 @@ unsafe fn add(request: WDFREQUEST) { adapter_luid_high: luid_high, target_id, resolved_monitor_id: monitor_id, + // This WUDFHost's pid — where the host duplicates the sealed frame channel's handles INTO + // (`ProcessSharingDisabled`: this process is exclusively ours and dies with the device). + wudf_pid: std::process::id(), }; // SAFETY: `request` is the framework WDFREQUEST. unsafe { write_output_complete(request, &reply) }; } +/// `IOCTL_SET_FRAME_CHANNEL`: adopt the handle values the host duplicated into this process and stash +/// them on the target monitor for the swap-chain worker to attach with. The ownership contract with +/// the host is **adopt-on-success only**: this driver owns (and eventually closes) the handles iff the +/// IOCTL completes successfully; on ANY error completion it leaves them untouched, because the host +/// reaps its remote duplicates whenever the IOCTL fails — a close on both sides would double-close +/// values the OS may already have reused for unrelated handles. +/// +/// # Safety +/// `request` is the framework `WDFREQUEST`. +unsafe fn set_frame_channel(request: WDFREQUEST) { + // SAFETY: `request` is the framework WDFREQUEST. + let Some(req) = (unsafe { read_input::(request) }) else { + complete(request, STATUS_INVALID_PARAMETER); + return; + }; + // A malformed request adopts nothing (no FrameChannel is built, so no Drop can close anything). + let Some(ch) = crate::frame_transport::FrameChannel::from_request(&req) else { + complete(request, STATUS_INVALID_PARAMETER); + return; + }; + match crate::monitor::set_frame_channel(req.target_id, ch) { + Ok(()) => complete(request, STATUS_SUCCESS), + Err(ch) => { + dbglog!( + "[pf-vd] SET_FRAME_CHANNEL: no monitor with target_id {} — rejecting (host reaps the handles)", + req.target_id + ); + // NOT adopted: disarm the channel so its Drop does NOT close the handles (see the contract + // above — the host's error path reaps them remotely). + ch.into_unowned(); + complete(request, STATUS_NOT_FOUND); + } + } +} + /// `IOCTL_REMOVE`: depart + drop the monitor for the given session id. /// /// # Safety diff --git a/packaging/windows/drivers/pf-vdisplay/src/direct_3d_device.rs b/packaging/windows/drivers/pf-vdisplay/src/direct_3d_device.rs index b028c7f..9c5e2ce 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/direct_3d_device.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/direct_3d_device.rs @@ -123,10 +123,10 @@ static DEVICE_POOL: Mutex)>> = Mutex::new(None) pub fn pooled_device(luid: LUID) -> Option> { let key = (i64::from(luid.HighPart) << 32) | i64::from(luid.LowPart); let mut pool = DEVICE_POOL.lock().ok()?; - if let Some((k, dev)) = pool.as_ref() { - if *k == key { - return Some(dev.clone()); - } + if let Some((k, dev)) = pool.as_ref() + && *k == key + { + return Some(dev.clone()); } match Direct3DDevice::init(luid) { Ok(d) => { diff --git a/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs b/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs index 51d6c2d..3e55951 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs @@ -1,32 +1,30 @@ -//! STEP 6 — IDD-push frame publisher (DRIVER side). +//! STEP 6 — IDD-push frame publisher (DRIVER side), attached over the **sealed channel**. //! -//! The restricted WUDFHost token canNOT create named kernel objects (proven on the RTX box: it can't -//! even write a world-writable file), so — exactly like the gamepad UMDF drivers -//! (`crates/punktfunk-host/src/inject/dualsense_windows.rs`: *"the host creates the section, privileged, -//! with a permissive SDDL so the WUDFHost can open it; the driver maps it"*) — the **host** creates the -//! shared header + frame-ready event + ring of keyed-mutex textures, and the driver only **OPENS** them. -//! The driver writes its actual render-adapter LUID + a status code back into the host-created header (our -//! only driver-visibility channel: UMDF hides OutputDebugString in ETW and the token can't write files), -//! then copies each acquired swap-chain surface into the next ring slot and signals the host. +//! The restricted WUDFHost token canNOT create named kernel objects — and since the frame channel +//! carries whole-desktop pixels, the objects are not merely host-created but **unnamed**: nothing to +//! enumerate, open by name, or pre-create ("squat"). The **host** creates the shared header + +//! frame-ready event + ring of keyed-mutex textures with no names, duplicates the handles INTO this +//! WUDFHost process (`DuplicateHandle` — SYSTEM can, we can't reciprocate, which is why the host is the +//! broker), and delivers the handle VALUES over `IOCTL_SET_FRAME_CHANNEL` ([`crate::control`] stashes +//! them per monitor as a [`FrameChannel`]). The swap-chain worker picks the stash up and attaches with +//! [`FramePublisher::from_channel`]. Only the two endpoint processes ever hold a handle to any frame +//! object — see `design/idd-push-security.md`. //! -//! Host counterpart: `crates/punktfunk-host/src/capture/idd_push.rs`. The shared `SharedHeader` layout, -//! the [`FrameToken`] packing, the `Global\` object-name scheme, the `MAGIC`/`RING_LEN` and the -//! `DRV_STATUS_*` codes are NOT hand-duplicated here: both sides `use pf_driver_proto::frame::*`, which -//! OWNS the contract (with `const` size asserts so any drift is a compile error). +//! The driver writes its actual render-adapter LUID + a status code back into the host-created header +//! (our only driver-visibility channel: UMDF hides OutputDebugString in ETW and the token can't write +//! files), then copies each acquired swap-chain surface into the next ring slot and signals the host. //! -//! Ported from the proven oracle (`packaging/windows/vdisplay-driver/pf-vdisplay/src/frame_transport.rs`). -//! Differences from the oracle: -//! * the layout/consts/names/token come from `pf_driver_proto::frame` instead of being re-declared; -//! * `dbglog!` replaces `log::info!`; -//! * the optional fixed-name `Global\pfvd-dbg` `DebugBlock` bring-up channel is SKIPPED (not on the data -//! path). FOLLOW-UP: if the host bring-up diagnostics are needed again, port the oracle's `DebugBlock` -//! here too (it is owned by `idd_push.rs`, not the proto). +//! Host counterpart: `crates/punktfunk-host/src/capture/windows/idd_push.rs`. The shared `SharedHeader` +//! layout, the [`FrameToken`] packing, the `MAGIC`/`RING_LEN`, the `DRV_STATUS_*` codes and the +//! channel-delivery struct are NOT hand-duplicated here: both sides `use pf_driver_proto::{control, +//! frame}`, which OWNS the contract (with `const` size asserts so any drift is a compile error). use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use pf_driver_proto::control::SetFrameChannelRequest; use pf_driver_proto::frame::{ DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, FrameToken, MAGIC, RING_LEN, - SharedHeader, event_name, header_name, texture_name, + SharedHeader, }; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Graphics::Direct3D11::{ @@ -34,28 +32,95 @@ use windows::Win32::Graphics::Direct3D11::{ }; use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex; use windows::Win32::System::Memory::{ - FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, OpenFileMappingW, - UnmapViewOfFile, + FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, UnmapViewOfFile, }; -use windows::Win32::System::Threading::{OpenEventW, SYNCHRONIZATION_ACCESS_RIGHTS, SetEvent}; -use windows::core::{HSTRING, Interface}; +use windows::Win32::System::Threading::SetEvent; +use windows::core::Interface; -/// `DXGI_SHARED_RESOURCE_READ | _WRITE` — passed to `OpenSharedResourceByName` (matches the host's -/// `CreateSharedHandle` access). Kept local: it is a `OpenSharedResourceByName` arg, not part of the -/// proto contract. (Same value the host uses in `idd_push.rs`.) -const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1; -/// SYNCHRONIZE | EVENT_MODIFY_STATE — the driver does not wait on the event, only SIGNALS it. -const EVENT_ACCESS: u32 = 0x0010_0000 | 0x0002; /// `WAIT_TIMEOUT` as an HRESULT — `AcquireSync` returns this when the slot is held by the consumer. const WAIT_TIMEOUT_HRESULT: i32 = 0x0000_0102; +/// One monitor's sealed-channel bootstrap: the handle VALUES the host duplicated into THIS process +/// (`IOCTL_SET_FRAME_CHANNEL`). Owning a `FrameChannel` means owning those handles — exactly one of +/// {the monitor stash ([`crate::monitor`]), a [`FramePublisher`] under construction} holds it at any +/// time, and `Drop` closes every entry not consumed, so a replaced/unmatched/failed delivery can never +/// leak entries in the WUDFHost handle table. A `0` field means "taken" (or never valid) and is skipped. +pub struct FrameChannel { + /// The ring generation these textures belong to (checked against the header at attach). + generation: u32, + ring_len: u32, + header: u64, + event: u64, + textures: [u64; RING_LEN as usize], +} + +impl FrameChannel { + /// Validate + adopt the handle values from the host's IOCTL. `None` on a malformed request (bad + /// `ring_len`, zero handles) — the caller completes with `STATUS_INVALID_PARAMETER` and nothing is + /// adopted (a zero value is never treated as a handle). + pub fn from_request(req: &SetFrameChannelRequest) -> Option { + if req.ring_len == 0 || req.ring_len > RING_LEN { + return None; + } + if req.header_handle == 0 + || req.event_handle == 0 + || req.texture_handles[..req.ring_len as usize].contains(&0) + { + return None; + } + Some(Self { + generation: req.generation, + ring_len: req.ring_len, + header: req.header_handle, + event: req.event_handle, + textures: req.texture_handles, + }) + } + + /// Move a handle value out of the channel: the caller now owns it; `Drop` skips the zeroed slot. + fn take(v: &mut u64) -> HANDLE { + HANDLE(core::mem::take(v) as usize as *mut core::ffi::c_void) + } + + /// Disarm without closing anything — for the adopt-on-success-only contract: a delivery rejected + /// with an error completion was never adopted, and the HOST reaps its remote duplicates on that + /// error, so closing here too would double-close (see `crate::control::set_frame_channel`). + pub fn into_unowned(mut self) { + self.header = 0; + self.event = 0; + self.textures = [0; RING_LEN as usize]; + } +} + +impl Drop for FrameChannel { + fn drop(&mut self) { + for v in [&mut self.header, &mut self.event] + .into_iter() + .chain(self.textures.iter_mut()) + { + if *v != 0 { + let h = Self::take(v); + // SAFETY: `h` is a live handle the host duplicated into this process for us to own; it + // was not consumed (non-zero), so this is its sole close. + unsafe { + let _ = CloseHandle(h); + } + } + } + } +} + +// NB: `FrameChannel` is plain integers, so it is auto-`Send` — it crosses from the control-plane +// dispatch thread (stash) to the swap-chain worker (attach) with `MONITOR_MODES` serializing the +// hand-off; no manual impl needed (handle values are process-global tokens, not thread-affine). + struct Slot { tex: ID3D11Texture2D, mutex: IDXGIKeyedMutex, } /// Publishes acquired swap-chain surfaces into the HOST-created ring. Owned by the swap-chain processor -/// thread; attached lazily once the host has created the shared objects. +/// thread; attached lazily once the host's channel delivery lands in the monitor stash. pub struct FramePublisher { context: ID3D11DeviceContext, map: HANDLE, @@ -70,7 +135,8 @@ pub struct FramePublisher { ring_format: u32, /// The ring generation this publisher attached to. The host BUMPS the header generation when it /// recreates the ring at a new format mid-session (the display's HDR mode flipped) — [`Self::is_stale`] - /// detects that so `run_core` re-attaches to the new-format textures instead of dropping every frame. + /// detects that so `run_core` re-attaches to the new ring (whose channel the host re-delivers) + /// instead of dropping every frame. generation: u32, /// Set when a surface is dropped for a descriptor mismatch (a game mode-set the display), cleared on a /// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1). @@ -81,102 +147,99 @@ pub struct FramePublisher { unsafe impl Send for FramePublisher {} impl FramePublisher { - /// Try ONCE to attach to the host-created shared objects. Returns `Err` cheaply if the host hasn't - /// created/published them yet — the drain loop retries periodically, so a non-IDD-push session just - /// keeps draining with no stall. All early-return paths clean up the handles/mapping they opened - /// explicitly (raw-handle style, no RAII — matches the rest of this driver). - pub fn try_open( - target_id: u32, + /// Attach to the host ring from a delivered [`FrameChannel`]. Consumes the channel: on ANY failure + /// every handle is closed (taken ones explicitly, the rest by the channel's `Drop`) and the host + /// re-delivers on the next recreate — there is nothing to poll, so failure is terminal for THIS + /// delivery (the host's `wait_for_attach` sees the status code and fails the session open). All + /// early-return paths clean up explicitly (raw-handle style, no RAII — matches the rest of this + /// driver). + pub fn from_channel( + mut channel: FrameChannel, render_luid_low: u32, render_luid_high: i32, device: &ID3D11Device, context: &ID3D11DeviceContext, ) -> windows::core::Result { - // 1. Open the host-created header (RW). Err if the host hasn't created it yet. - // SAFETY: a plain Win32 call; the name HSTRING is valid for the call (`?` returns on failure). - let map = unsafe { - OpenFileMappingW( - FILE_MAP_ALL_ACCESS.0, - false, - &HSTRING::from(header_name(target_id)), - )? - }; - // SAFETY: `map` is the just-opened file mapping; mapping size_of::() bytes of it - // (the host created the mapping at >= that size). The null `view.Value` is checked below. + let ring_len = channel.ring_len; + + // 1. Map the header from the duplicated section handle (ours from here on). + let map = FrameChannel::take(&mut channel.header); + // SAFETY: `map` is the live section handle the host duplicated into this process; mapping + // size_of::() bytes of it (the host created the mapping at >= that size). The null + // `view.Value` is checked below. let view = unsafe { + // Read/write only — the host now duplicates the header handle with least access + // (`SECTION_MAP_READ | SECTION_MAP_WRITE`), so `FILE_MAP_ALL_ACCESS` would exceed the + // granted rights and fail. We read the layout + write status/publish-token fields; RW covers it. MapViewOfFile( map, - FILE_MAP_ALL_ACCESS, + FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, core::mem::size_of::(), ) }; if view.Value.is_null() { - // SAFETY: `map` is the just-opened mapping handle, closed once here on the error path. + let err = windows::core::Error::from_win32(); + // SAFETY: `map` is the taken section handle, closed once here on the error path (the rest of + // `channel` closes via its Drop). unsafe { let _ = CloseHandle(map); } - return Err(windows::core::Error::from_win32()); + return Err(err); } let header = view.Value.cast::(); // 2. Report our render adapter to the host immediately (lets it detect a mismatch). - // SAFETY: `header` points to the mapped, non-null host header (>= size_of::() bytes); - // these scalar writes are within it. The host opened the section with a permissive SDDL for us. + // SAFETY: `header` points to the mapped, non-null host header (>= size_of::() + // bytes); these scalar writes are within it. unsafe { (*header).driver_render_luid_low = render_luid_low; (*header).driver_render_luid_high = render_luid_high; } - // 3. The host sets magic==MAGIC only once the ring textures exist. Not ready → retry later. - // SAFETY: `header` is the mapped host header; `magic` lives within it and is read atomically - // (Acquire) to pair with the host's Release store once the ring textures are published. - let magic = unsafe { - (*(core::ptr::addr_of!((*header).magic) as *const AtomicU32)).load(Ordering::Acquire) + // 3. The host stamps magic==MAGIC BEFORE delivering the channel, and this channel's generation + // must match the header's CURRENT generation — a mismatch means the host recreated the ring + // again before we attached (a fresh delivery is on its way); drop this stale one. + // SAFETY: `header` is the mapped host header; `magic`/`generation` live within it and are read + // atomically (Acquire) to pair with the host's Release publishes. + let (magic, header_gen) = unsafe { + ( + (*(core::ptr::addr_of!((*header).magic) as *const AtomicU32)) + .load(Ordering::Acquire), + (*(core::ptr::addr_of!((*header).generation) as *const AtomicU32)) + .load(Ordering::Acquire), + ) }; - if magic != MAGIC { - // SAFETY: `header`/`map` are the live mapped view + handle; unmapped + closed once on this path. + if magic != MAGIC || header_gen != channel.generation { + dbglog!( + "[pf-vd] frame-push(driver): dropping channel delivery (magic ok: {}, channel gen {} vs header gen {header_gen})", + magic == MAGIC, + channel.generation + ); + // SAFETY: `header`/`map` are the live mapped view + taken handle; unmapped + closed once on + // this path. unsafe { let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { Value: header.cast(), }); let _ = CloseHandle(map); } - return Err(windows::core::Error::from_win32()); + // E_BOUNDS — stand-in for "stale delivery"; the caller only drops the attempt. + return Err(windows::core::HRESULT(0x8000_000Bu32 as i32).into()); } - // SAFETY: `header` is the mapped host header; these scalar fields live within it. - let (generation, ring_len) = - unsafe { ((*header).generation, (*header).ring_len.min(RING_LEN)) }; - // 4. Open the event (SYNCHRONIZE | EVENT_MODIFY_STATE so we can SetEvent). - // SAFETY: a plain Win32 call; the name HSTRING is valid for the call. - let event = match unsafe { - OpenEventW( - SYNCHRONIZATION_ACCESS_RIGHTS(EVENT_ACCESS), - false, - &HSTRING::from(event_name(target_id)), - ) - } { - Ok(e) => e, - Err(e) => { - // SAFETY: `header`/`map` are the live mapped view + handle; unmapped + closed once here. - unsafe { - let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { - Value: header.cast(), - }); - let _ = CloseHandle(map); - } - return Err(e); - } - }; + // 4. The frame-ready event (duplicated with the host handle's full access, so SetEvent works). + let event = FrameChannel::take(&mut channel.event); - // 5. Open device1 + the ring textures the host created (same render adapter required). + // 5. Open device1 + the ring textures from their duplicated shared handles (same render adapter + // required). Each NT handle is closed right after the open — the COM object holds its own + // reference, and the HOST keeps the resource alive with its own handle. let device1: ID3D11Device1 = match device.cast() { Ok(d) => d, Err(e) => { - // SAFETY: `header` is the mapped host header (status write within it); `event`/`map` are the - // live handles, all released once on this error path. + // SAFETY: `header` is the mapped host header (status write within it); `event`/`map` are + // the taken live handles, all released once on this error path. unsafe { (*header).driver_status = DRV_STATUS_NO_DEVICE1; let _ = CloseHandle(event); @@ -189,45 +252,45 @@ impl FramePublisher { } }; let mut slots = Vec::new(); - for k in 0..ring_len { - let name = HSTRING::from(texture_name(target_id, generation, k)); - // SAFETY: `device1` is a live ID3D11Device1; the name HSTRING is valid for the call. + // Take each texture handle one at a time (NOT the whole array up front), so an error return + // mid-loop still lets `channel`'s Drop close every not-yet-taken handle. + for value in channel.textures.iter_mut().take(ring_len as usize) { + let tex_handle = FrameChannel::take(value); + // SAFETY: `device1` is a live ID3D11Device1; `tex_handle` is the duplicated shared NT handle + // for this ring texture. let opened: windows::core::Result = - unsafe { device1.OpenSharedResourceByName(&name, DXGI_SHARED_RESOURCE_RW) }; - match opened { + unsafe { device1.OpenSharedResource1(tex_handle) }; + // SAFETY: `tex_handle` is ours (taken above) and no longer needed whether the open succeeded + // (the COM object holds the resource) or failed — close it exactly once here. + unsafe { + let _ = CloseHandle(tex_handle); + } + let failed = match opened { Ok(tex) => match tex.cast::() { - Ok(mutex) => slots.push(Slot { tex, mutex }), - Err(e) => { - // SAFETY: `header` is the mapped host header (status writes within it); `event`/`map` - // are the live handles, all released once on this error path. - unsafe { - (*header).driver_status = DRV_STATUS_TEX_FAIL; - (*header).driver_status_detail = e.code().0 as u32; - let _ = CloseHandle(event); - let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { - Value: header.cast(), - }); - let _ = CloseHandle(map); - } - return Err(e); + Ok(mutex) => { + slots.push(Slot { tex, mutex }); + None } + Err(e) => Some(e), }, - Err(e) => { - // Most likely a render-adapter mismatch (the host made the textures on a different - // GPU than the swap-chain renders on). Tell the host so it can report it. - // SAFETY: `header` is the mapped host header (status writes within it); `event`/`map` - // are the live handles, all released once on this error path. - unsafe { - (*header).driver_status = DRV_STATUS_TEX_FAIL; - (*header).driver_status_detail = e.code().0 as u32; - let _ = CloseHandle(event); - let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { - Value: header.cast(), - }); - let _ = CloseHandle(map); - } - return Err(e); + // Most likely a render-adapter mismatch (the host made the textures on a different GPU + // than the swap-chain renders on). Tell the host so it can report it. + Err(e) => Some(e), + }; + if let Some(e) = failed { + // SAFETY: `header` is the mapped host header (status writes within it); `event`/`map` + // are the taken live handles, all released once on this error path (the not-yet-taken + // texture handles close via `channel`'s Drop). + unsafe { + (*header).driver_status = DRV_STATUS_TEX_FAIL; + (*header).driver_status_detail = e.code().0 as u32; + let _ = CloseHandle(event); + let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { + Value: header.cast(), + }); + let _ = CloseHandle(map); } + return Err(e); } } @@ -236,7 +299,7 @@ impl FramePublisher { (*header).driver_status = DRV_STATUS_OPENED; } dbglog!( - "[pf-vd] frame-push(driver): attached to host ring gen {generation} ({ring_len} slots)" + "[pf-vd] frame-push(driver): attached to host ring gen {header_gen} ({ring_len} slots, sealed channel)" ); Ok(Self { context: context.clone(), @@ -248,7 +311,7 @@ impl FramePublisher { seq: 0, // SAFETY: `header` is the mapped host header; `dxgi_format` lives within it. ring_format: unsafe { (*header).dxgi_format }, - generation, + generation: header_gen, mismatch_logged: false, }) } @@ -261,8 +324,8 @@ impl FramePublisher { } /// True once the host has recreated the ring (bumped the header generation) — e.g. the display's HDR - /// mode flipped, so the ring format changed (FP16 ⇄ BGRA) and the texture names now carry a new - /// generation. `run_core` drops the publisher on this so it re-attaches to the new ring. + /// mode flipped, so the ring format changed (FP16 ⇄ BGRA) and a fresh channel delivery is coming. + /// `run_core` drops the publisher on this so it re-attaches to the new ring. pub fn is_stale(&self) -> bool { // SAFETY: `self.header` stays mapped for the publisher's lifetime; `generation` lives within it and // is read atomically (Acquire) to pair with the host's Release bump on a mid-session ring recreate. @@ -338,8 +401,8 @@ impl FramePublisher { } .pack(); self.latest_cell().store(latest, Ordering::Release); - // SAFETY: `self.event` is the live host-created frame-ready event we opened with - // EVENT_MODIFY_STATE; signalling it wakes the host consumer. + // SAFETY: `self.event` is the live host-created frame-ready event, duplicated into + // this process with the creator's access; signalling it wakes the host consumer. unsafe { let _ = SetEvent(self.event); } @@ -357,10 +420,11 @@ impl FramePublisher { impl Drop for FramePublisher { fn drop(&mut self) { // Slots FIRST (release the shared textures + keyed mutexes), THEN unmap the header, THEN the - // handles. + // handles — nothing of the channel outlives the publisher (teardown invariant, + // `design/idd-push-security.md`). self.slots.clear(); // SAFETY: drop runs once; `self.header` (if non-null) is the live mapped view and `self.event`/ - // `self.map` are the live handles this publisher opened — each unmapped/closed exactly once here. + // `self.map` are the live handles this publisher owns — each unmapped/closed exactly once here. unsafe { if !self.header.is_null() { let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { diff --git a/packaging/windows/drivers/pf-vdisplay/src/lib.rs b/packaging/windows/drivers/pf-vdisplay/src/lib.rs index 397a666..d922608 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/lib.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/lib.rs @@ -10,9 +10,12 @@ #![allow(non_snake_case, clippy::missing_safety_doc)] // P0 lint (audit §8): an unsafe op inside an `unsafe fn` must be in an explicit `unsafe {}` block, so the -// fn-level `unsafe` never silently blesses the whole body. (The per-site `// SAFETY:` discipline already -// landed in STEP 8.) +// fn-level `unsafe` never silently blesses the whole body, AND every `unsafe {}` must carry a `// SAFETY:` +// proof. An IddCx display driver is inherently FFI-bound (D3D11 / IddCx DDIs / cross-process shared +// textures), so it can't be unsafe-FREE the way the gamepad drivers now are (their logic moved onto the +// safe `pf_umdf_util` layer); these gates make it unsafe-AUDITED instead, and stop it regressing. #![deny(unsafe_op_in_unsafe_fn)] +#![deny(clippy::undocumented_unsafe_blocks)] #[macro_use] mod log; diff --git a/packaging/windows/drivers/pf-vdisplay/src/log.rs b/packaging/windows/drivers/pf-vdisplay/src/log.rs index af955b0..95467ca 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/log.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/log.rs @@ -45,11 +45,11 @@ pub fn log(s: &str) { unsafe { OutputDebugStringA(c.as_ptr().cast()) }; } use std::io::Write; - if let Some(m) = file_appender() { - if let Ok(mut f) = m.lock() { - let _ = writeln!(f, "{s}"); - let _ = f.flush(); - } + if let Some(m) = file_appender() + && let Ok(mut f) = m.lock() + { + let _ = writeln!(f, "{s}"); + let _ = f.flush(); } } diff --git a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs index 9cd1fc7..fc98010 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs @@ -53,6 +53,11 @@ pub struct MonitorObject { /// The live swap-chain drain worker, set by `assign_swap_chain` and dropped (RAII-joins the worker /// thread) by `unassign_swap_chain` / departure (STEP 5). pub swap_chain_processor: Option, + /// The host's sealed-channel delivery (`IOCTL_SET_FRAME_CHANNEL`) awaiting pickup by the swap-chain + /// worker ([`take_frame_channel`]). Exactly one owner per delivery: replacing or dropping the entry + /// closes an unconsumed channel's handles via [`FrameChannel`]'s `Drop`, so no delivery can leak + /// handles in the WUDFHost table whatever the monitor's fate. + pub frame_channel: Option, /// When the entry was created — the watchdog skips still-initializing monitors. pub created_at: Instant, } @@ -256,8 +261,8 @@ pub fn modes_for_object(object: iddcx::IDDCX_MONITOR) -> Option> { .map(|m| m.modes.clone()) } -/// The OS target id stamped on the monitor whose handle matches (used by `assign_swap_chain` to name the -/// shared-ring objects). `None` if the monitor isn't found. +/// The OS target id stamped on the monitor whose handle matches (used by `assign_swap_chain` to key the +/// frame-channel stash for its worker). `None` if the monitor isn't found. pub fn target_id_for_object(object: iddcx::IDDCX_MONITOR) -> Option { MONITOR_MODES .lock() @@ -267,6 +272,52 @@ pub fn target_id_for_object(object: iddcx::IDDCX_MONITOR) -> Option { .map(|m| m.target_id) } +/// Stash a host frame-channel delivery on the monitor with `target_id` (an ARRIVED monitor — a pending +/// entry's `target_id` is still 0, which the host can never send since OS target ids are non-zero). +/// Replacing an unconsumed delivery drops it → its handles close (it WAS adopted by a prior success). +/// `Err(ch)` if no such monitor exists — the caller must NOT close those handles (the host only sees +/// the error status and reaps its remote duplicates itself; closing here too would double-close values +/// the OS may have reused). +pub fn set_frame_channel( + target_id: u32, + ch: crate::frame_transport::FrameChannel, +) -> Result<(), crate::frame_transport::FrameChannel> { + if target_id == 0 { + return Err(ch); + } + let mut lock = lock_monitors(); + if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) { + m.frame_channel = Some(ch); + Ok(()) + } else { + Err(ch) + } +} + +/// Take (remove) the pending frame-channel delivery for `target_id`, transferring handle ownership to +/// the caller (the swap-chain worker's attach). `None` until the host delivers one. +pub fn take_frame_channel(target_id: u32) -> Option { + if target_id == 0 { + return None; + } + lock_monitors() + .iter_mut() + .find(|m| m.target_id == target_id)? + .frame_channel + .take() +} + +/// Is a frame-channel delivery pending for `target_id`? The swap-chain worker treats a pending +/// delivery as NEWEST-WINS: it supersedes an attached publisher, because the host only re-delivers +/// after (re)creating the ring — and a retry-created ring is a DIFFERENT header mapping, whose +/// generation bump an old publisher (mapped to the previous header) can never observe. +pub fn has_frame_channel(target_id: u32) -> bool { + target_id != 0 + && lock_monitors() + .iter() + .any(|m| m.target_id == target_id && m.frame_channel.is_some()) +} + /// Install a swap-chain processor on the monitor whose handle matches, returning any PREVIOUS processor /// for the caller to drop OUTSIDE the lock. Dropping a processor RAII-joins its worker thread, so it must /// never happen while holding `MONITOR_MODES` (the worker would block the whole control plane / risk a @@ -351,6 +402,7 @@ pub fn create_monitor( adapter_luid_low: 0, adapter_luid_high: 0, swap_chain_processor: None, + frame_channel: None, created_at: Instant::now(), }); id diff --git a/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs b/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs index 1c74abd..188e16c 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs @@ -78,6 +78,8 @@ pub struct SwapChainProcessor { // SAFETY: Raw ptr is managed by external library; access is serialised by the worker thread + the // terminate flag. unsafe impl Send for SwapChainProcessor {} +// SAFETY: as above — the raw pointer is only touched by the serialised worker, so a shared +// `&SwapChainProcessor` reference exposes no unsynchronised access. unsafe impl Sync for SwapChainProcessor {} impl SwapChainProcessor { @@ -223,10 +225,11 @@ impl SwapChainProcessor { return; } - // STEP 6 IDD-push: lazily ATTACH to the HOST-created shared ring. The restricted UMDF token can't - // create named objects, so the host creates the header + event + textures and we only OPEN them - // once they appear (`try_open`). Until then we just drain — exactly the STEP-5 behaviour — so a - // non-IDD-push session never stalls. Retried every ~30 loop iterations. + // STEP 6 IDD-push: lazily ATTACH to the HOST-created shared ring over the SEALED channel. The + // frame objects are unnamed — the host duplicates their handles into this process and delivers + // the values via IOCTL_SET_FRAME_CHANNEL, which the control plane stashes on our monitor + // (`monitor::take_frame_channel`). Until a delivery lands we just drain — exactly the STEP-5 + // behaviour — so a non-IDD-push session never stalls. The stash is polled every ~30 iterations. let mut publisher: Option = None; let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration @@ -243,31 +246,41 @@ impl SwapChainProcessor { break; } - // The host recreates the shared ring (new format) mid-session when the display's HDR mode - // flips — it bumps the header generation. Detect that and drop the publisher so we re-attach to - // the new-format textures below; otherwise we'd keep CopyResource'ing into the stale ring, whose - // format now mismatches the surface → the publish() format-guard drops every frame and the - // stream freezes until the next swap-chain recreate. - if publisher.as_ref().is_some_and(FramePublisher::is_stale) { + // Re-attach triggers, either of: + // * `is_stale` — the host recreated the ring mid-session (HDR flip): it bumps OUR header's + // generation and re-delivers; without dropping here we'd keep CopyResource'ing into the + // stale ring, whose format now mismatches the surface → the publish() format-guard drops + // every frame and the stream freezes until the next swap-chain recreate. + // * a PENDING delivery (newest-wins) — a host build-retry creates a whole NEW ring with a + // DIFFERENT header mapping; the old publisher's header never changes, so `is_stale` can't + // fire. The host only delivers after fully (re)creating a ring, so a pending delivery + // always supersedes whatever we're attached to. + if publisher.as_ref().is_some_and(FramePublisher::is_stale) + || (publisher.is_some() && crate::monitor::has_frame_channel(target_id)) + { publisher = None; frames_since_try = u32::MAX; // re-attach immediately } // Lazy-attach (rate-limited) at the loop TOP so we keep trying even while the display is idle - // (E_PENDING / no frames presented yet), not only when a frame is acquired. `try_open` is a - // cheap OpenFileMapping that fails fast until the host has created the ring. + // (E_PENDING / no frames presented yet), not only when a frame is acquired. Checking the + // stash is a cheap mutex peek that stays empty until the host's channel delivery lands; a + // taken delivery is consumed whether the attach succeeds or not (on failure its handles are + // closed, the host's wait-for-attach reads the status code, and any retry is a NEW delivery). if publisher.is_none() { if frames_since_try >= 30 { frames_since_try = 0; - // `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match` - // happy under `-D warnings`; semantics are identical — attach on success, retry on Err. - if let Ok(p) = FramePublisher::try_open( - target_id, - render_luid_low, - render_luid_high, - &device.device, - &device.device_context, - ) { - publisher = Some(p); + if let Some(channel) = crate::monitor::take_frame_channel(target_id) { + // `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match` + // happy under `-D warnings`; attach on success, drop the delivery on Err. + if let Ok(p) = FramePublisher::from_channel( + channel, + render_luid_low, + render_luid_high, + &device.device, + &device.device_context, + ) { + publisher = Some(p); + } } } else { frames_since_try += 1; @@ -337,10 +350,10 @@ impl SwapChainProcessor { if !raw.is_null() { // SAFETY: `raw` is IddCx's live surface pointer (valid until the next // ReleaseAndAcquire); `from_raw_borrowed` does not consume the refcount. - if let Some(res) = unsafe { IDXGIResource::from_raw_borrowed(&raw) } { - if let Ok(tex) = res.cast::() { - p.publish(&tex); - } + if let Some(res) = unsafe { IDXGIResource::from_raw_borrowed(&raw) } + && let Ok(tex) = res.cast::() + { + p.publish(&tex); } } } diff --git a/packaging/windows/drivers/pf-xusb/Cargo.toml b/packaging/windows/drivers/pf-xusb/Cargo.toml index a0e9e04..8cf5998 100644 --- a/packaging/windows/drivers/pf-xusb/Cargo.toml +++ b/packaging/windows/drivers/pf-xusb/Cargo.toml @@ -23,6 +23,8 @@ wdk-build.workspace = true [dependencies] wdk.workspace = true wdk-sys.workspace = true +pf-driver-proto.workspace = true +pf-umdf-util.workspace = true [features] default = [] diff --git a/packaging/windows/drivers/pf-xusb/README.md b/packaging/windows/drivers/pf-xusb/README.md index 1124258..ac88593 100644 --- a/packaging/windows/drivers/pf-xusb/README.md +++ b/packaging/windows/drivers/pf-xusb/README.md @@ -14,8 +14,11 @@ instance (= player slot 0–3) with `CreateFile`, and polls it with buffered IOC **System** setup class; - registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`; - answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from - controller state the host publishes into a shared section `Global\pfxusb-shm-0`; a game's rumble - (`SET_STATE`) is published back for the host to forward to the client. + controller state the host publishes into an **unnamed** shared DATA section reached over the + **sealed pad channel** (`design/gamepad-channel-sealing.md`): the host duplicates the section + handle into this driver's WUDFHost, bootstrapped via the named `Global\pfxusb-boot-` + mailbox (`pf_driver_proto::gamepad::PadBootstrap`); a game's rumble (`SET_STATE`) is published + back for the host to forward to the client. The WAIT_* IOCTLs return `STATUS_INVALID_DEVICE_REQUEST`, which makes `xinput1_4` fall back to synchronous `GET_STATE` polling — so no manual queue / timer is needed for classic XInput. (WGI/ @@ -37,11 +40,13 @@ GameInput admission additionally needs a `xinputhid` `UpperFilters` registry tri `wButtons` is the `XINPUT_GAMEPAD_*` bitmap (DPAD_UP `0x0001` … A `0x1000` B `0x2000` X `0x4000` Y `0x8000`). `dwPacketNumber` (GET_STATE `[5]`) must increment whenever the payload changes. -## Shared-memory layout `Global\pfxusb-shm-0` (64 B) — host writes state, driver writes rumble +## Shared-memory layout (unnamed DATA section, 64 B) — host writes state, driver writes rumble +`pf_driver_proto::gamepad::XusbShm` (the crate owns the offsets; both sides compile against it): `magic u32 @0` (`"PFXU"` `0x55584650`) · `packet u32 @4` (host bumps → dwPacketNumber) · `wButtons u16 @8` · `LT @10` · `RT @11` · `LX/LY/RX/RY i16 @12/@14/@16/@18` · `rumble_seq u32 @24` (driver bumps) · -`large @28` · `small @29`. +`large @28` · `small @29` · health marks `@32/@36` · `pad_index u32 @40` (validated against the +devnode's Location index when the delivered handle is mapped). ## Validated live (2026-06-22, maintainer's RTX test box) @@ -66,7 +71,8 @@ the whole build/sign/stage flow in CI. The manual steps: ## Host integration (done) `crates/punktfunk-host/src/inject/windows/gamepad_windows.rs` is the Windows `GamepadManager` (used by -`PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, maps `pfxusb-shm-`, writes +`PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, delivers the unnamed DATA +section over the sealed channel (`PadChannel`), writes the XInput state from the client's gamepad frame (already XInput-convention) and forwards rumble. There is **no ViGEmBus dependency** anymore. The driver is built + signed from source in CI (`build-gamepad-drivers.ps1`) and installed by the Inno Setup installer via @@ -75,8 +81,8 @@ is **no ViGEmBus dependency** anymore. The driver is built + signed from source ## Multi-pad The host stamps each pad's index into the device Location (`pszDeviceLocation`); the driver reads it -via `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` in EvtDeviceAdd and maps its own -`pfxusb-shm-`. `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) gives each pad its own +via `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` in EvtDeviceAdd and polls its own +`pfxusb-boot-` bootstrap mailbox (the delivered DATA section's `pad_index` is validated against it). `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) gives each pad its own WUDFHost, so the per-pad `SHM_INDEX` static doesn't collide. Validated live: two pads → two distinct XInput slots. (XInput assigns the player slot 0-3 by interface-enumeration order, independent of this index — which only routes shared memory.) diff --git a/packaging/windows/drivers/pf-xusb/src/lib.rs b/packaging/windows/drivers/pf-xusb/src/lib.rs index aae578b..82974aa 100644 --- a/packaging/windows/drivers/pf-xusb/src/lib.rs +++ b/packaging/windows/drivers/pf-xusb/src/lib.rs @@ -3,42 +3,39 @@ // // xinput1_4.dll enumerates GUID_DEVINTERFACE_XUSB, opens the Nth instance (= player slot), and polls // it with buffered IOCTLs. We register the interface and answer those IOCTLs from controller state the -// host publishes into a shared-memory section (`Global\pfxusb-shm-0`); a game's rumble (SET_STATE) is -// published back for the host to forward. Byte formats are the source-verified xusb22 wire layout -// (HIDMaestro driver/companion.c + nefarius/XInputHooker XUSB.h + ViGEm XUSB_REPORT). +// host publishes into a shared DATA section; a game's rumble (SET_STATE) is published back for the +// host to forward. Byte formats are the source-verified xusb22 wire layout (HIDMaestro +// driver/companion.c + nefarius/XInputHooker XUSB.h + ViGEm XUSB_REPORT). +// +// The host channel is the **sealed pad channel** (design/gamepad-channel-sealing.md, proto v2): the +// DATA section (`pf_driver_proto::gamepad::XusbShm`) is UNNAMED — we reach it only through a handle +// the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named `Global\pfxusb-boot-` +// mailbox. The whole handshake + all shared-memory access lives in `pf_umdf_util` (audited unsafe +// layer): this crate's channel/IOCTL/state logic is 100% SAFE Rust. The only `unsafe` here is the +// unavoidable WDF setup FFI in DriverEntry/EvtDeviceAdd, each with a `// SAFETY:` proof. // // We answer the WAIT_* IOCTLs with STATUS_INVALID_DEVICE_REQUEST, which makes xinput1_4 fall back to // synchronous GET_STATE polling — so no manual queue / timer is needed for classic XInput. #![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::ffi::c_void; -use core::sync::atomic::{AtomicU32, Ordering}; +use pf_driver_proto::gamepad::XusbShm; +use pf_umdf_util::channel::{ChannelClient, ChannelConfig}; +use pf_umdf_util::nt_success; +use pf_umdf_util::section::MappedView; +use pf_umdf_util::wdf::{self, Request}; use wdk_sys::{ - call_unsafe_wdf_function_binding, windows::OutputDebugStringA, GUID, NTSTATUS, PCUNICODE_STRING, - PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDFDEVICE, WDFDRIVER, WDFMEMORY, WDFQUEUE, WDFREQUEST, - WDF_DRIVER_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, + GUID, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG, + WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFDRIVER, WDFQUEUE, + WDFREQUEST, call_unsafe_wdf_function_binding, windows::OutputDebugStringA, }; -// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (the const isn't re-exported at the -// wdk_sys root; the value is stable WDM). -const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10; - -/// The pad index this device serves (which `pfxusb-shm-` section to map). The host stamps it -/// into the device Location (`pszDeviceLocation`); the driver reads it in EvtDeviceAdd. With -/// `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) each pad gets its own WUDFHost, so this -/// static is per-pad — the basis for multi-pad. -static SHM_INDEX: AtomicU32 = AtomicU32::new(0); - // ---- NTSTATUS ---- const STATUS_SUCCESS: NTSTATUS = 0; const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = 0xC000_0010u32 as NTSTATUS; -const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS; - -#[inline] -fn nt_success(s: NTSTATUS) -> bool { - s >= 0 -} // GUID_DEVINTERFACE_XUSB {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — what xinput1_4 enumerates + opens. const GUID_DEVINTERFACE_XUSB: GUID = GUID { @@ -70,27 +67,46 @@ const XUSB_VERSION: u16 = 0x0103; const WdfIoQueueDispatchParallel: i32 = 2; const WdfUseDefault: i32 = 2; // WDF_TRI_STATE -// ---- shared-memory layout (host ↔ driver), must match pf_driver_proto::gamepad::XusbShm ---- -// magic u32 @0 ("PFXU"); packet u32 @4 (host bumps on state change → dwPacketNumber); the XUSB_REPORT -// payload @8: wButtons u16 @8, bLeftTrigger @10, bRightTrigger @11, sThumbLX i16 @12, LY @14, RX @16, -// RY @18; rumble_seq u32 @24 (driver bumps on SET_STATE); rumble large @28, small @29; -// driver_proto u32 @32 (we stamp GAMEPAD_PROTO_VERSION = attach signal for the host's health check); -// driver_heartbeat u32 @36 (we bump per serviced IOCTL = the game-visible polling path moves). -const FILE_MAP_RW: u32 = 0x0002 | 0x0004; -const SHM_MAGIC: u32 = 0x5558_4650; // "PFXU" little-endian -const SHM_SIZE: usize = 64; -const GAMEPAD_PROTO_VERSION: u32 = 1; // must match pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION +// ---- the sealed host channel: layouts + offsets from pf_driver_proto (drift = compile error) ---- +const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU" +const SHM_SIZE: usize = core::mem::size_of::(); +const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION; -unsafe extern "system" { - fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void; - fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void; - fn UnmapViewOfFile(addr: *const c_void) -> i32; - fn CloseHandle(h: *mut c_void) -> i32; +// XusbShm field offsets (host writes state, we answer XInput; we write rumble + health marks). +const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet); +const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons); +const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger); +const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger); +const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx); +const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly); +const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx); +const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry); +const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq); +const OFF_RUMBLE_LARGE: usize = core::mem::offset_of!(XusbShm, rumble_large); +const OFF_RUMBLE_SMALL: usize = core::mem::offset_of!(XusbShm, rumble_small); +const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto); +const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(XusbShm, driver_heartbeat); +const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index); + +/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so +/// this static is per-pad). All shared-memory access + the bootstrap handshake live in `pf_umdf_util`. +static CHANNEL: ChannelClient = ChannelClient::new(); + +/// This pad's channel config (magic/size/pad_index offset + our logger). +fn channel_cfg() -> ChannelConfig { + ChannelConfig { + tag: "pf-xusb", + boot_name_prefix: "Global\\pfxusb-boot-", + data_magic: SHM_MAGIC, + data_size: SHM_SIZE, + pad_index_off: OFF_PAD_INDEX, + log, + } } fn log(s: &str) { if let Ok(c) = std::ffi::CString::new(s) { - // SAFETY: c is a valid null-terminated string for the duration of the call. + // SAFETY: `c` is a valid NUL-terminated string for the duration of the call. unsafe { OutputDebugStringA(c.as_ptr().cast()) }; } use std::io::Write; @@ -110,11 +126,11 @@ pub unsafe extern "system" fn driver_entry( registry_path: PCUNICODE_STRING, ) -> NTSTATUS { log("[pf-xusb] DriverEntry"); - // SAFETY: zeroed config then Size + callback set. + // SAFETY: a 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::() as ULONG; config.EvtDriverDeviceAdd = Some(evt_device_add); - // SAFETY: all pointers valid; provided by the loader. + // SAFETY: `driver`/`registry_path` are the loader-provided pointers; the config is valid. unsafe { call_unsafe_wdf_function_binding!( WdfDriverCreate, @@ -127,56 +143,11 @@ pub unsafe extern "system" fn driver_entry( } } -/// Read the pad index the host stamped into the device Location (`pszDeviceLocation`), a NUL-terminated -/// UTF-16 decimal string. Defaults to 0 (single-pad) if absent. -fn query_shm_index(device: WDFDEVICE) -> u32 { - let mut mem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: device valid; property = LocationInformation; pool ignored in UMDF; mem receives the handle. - let st = unsafe { - call_unsafe_wdf_function_binding!( - WdfDeviceAllocAndQueryProperty, - device, - DEVICE_PROPERTY_LOCATION_INFORMATION, - 0, - WDF_NO_OBJECT_ATTRIBUTES, - &mut mem - ) - }; - if !nt_success(st) || mem.is_null() { - return 0; - } - let mut len: usize = 0; - // SAFETY: mem valid. - let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) } - as *const u16; - if buf.is_null() { - return 0; - } - let mut idx: u32 = 0; - let mut any = false; - for i in 0..(len / 2).min(8) { - // SAFETY: buf valid for len bytes; i < len/2. - let c = unsafe { *buf.add(i) }; - if c == 0 { - break; - } - if (0x30..=0x39).contains(&c) { - idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32); - any = true; - } - } - if any { - idx - } else { - 0 - } -} - extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { log("[pf-xusb] EvtDeviceAdd"); let mut device: WDFDEVICE = core::ptr::null_mut(); - // SAFETY: device_init valid; attributes null; device receives the handle. + // SAFETY: `device_init` is the framework-provided init; attributes null; `device` receives it. let st = unsafe { call_unsafe_wdf_function_binding!( WdfDeviceCreate, @@ -190,12 +161,14 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI return st; } - let idx = query_shm_index(device); - SHM_INDEX.store(idx, Ordering::Relaxed); + // SAFETY: `device` is the live device just created — the exact contract `query_location_index` + // requires. + let idx = unsafe { wdf::query_location_index(device) }; + CHANNEL.set_index(idx); dbglog!("[pf-xusb] shm index = {idx}"); // Register the XUSB device interface (no reference string) — what xinput1_4 enumerates + opens. - // SAFETY: device valid; GUID static; null reference string. + // SAFETY: `device` is live; the GUID is a static; null reference string. let st = unsafe { call_unsafe_wdf_function_binding!( WdfDeviceCreateDeviceInterface, @@ -213,7 +186,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI } // Default parallel queue: all the XUSB IOCTLs land here. - // SAFETY: zeroed config then fields set; Size matches the struct. + // SAFETY: a zeroed WDF_IO_QUEUE_CONFIG is valid; we then set Size + the fields we use. let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() }; qcfg.Size = core::mem::size_of::() as ULONG; qcfg.DispatchType = WdfIoQueueDispatchParallel; @@ -222,7 +195,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI qcfg.EvtIoDeviceControl = Some(evt_io_device_control); qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX; let mut queue: WDFQUEUE = core::ptr::null_mut(); - // SAFETY: device + config valid; attributes null; queue receives the handle. + // SAFETY: `device` + `qcfg` are valid; attributes null; `queue` receives the handle. let st = unsafe { call_unsafe_wdf_function_binding!( WdfIoQueueCreate, @@ -237,93 +210,69 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI return st; } - // Tell the host we're alive on the section (its driver-attach health check keys off this). - touch_driver_marks(); + // Run the sealed-channel handshake on a worker (must NOT block EvtDeviceAdd): publish our pid in + // the bootstrap mailbox and poll for the host's delivered DATA handle, so the pad attaches (and + // the host's driver-attach health check goes green) even before any game polls XInput. Bounded; + // a later host (or a re-delivery) is still picked up by the per-IOCTL pump. This closure is 100% + // safe — the whole channel state machine lives in pf_umdf_util. + std::thread::spawn(|| { + let cfg = channel_cfg(); + for _ in 0..500 { + if let Some(v) = CHANNEL.pump(&cfg) { + touch_driver_marks(v); + return; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + log( + "[pf-xusb] no sealed-channel delivery within 10s (host absent, or host/driver version mismatch — see above)", + ); + }); log("[pf-xusb] device ready (XUSB interface registered)"); STATUS_SUCCESS } -// Open + map the host's shared section and run `f` against the mapped base if magic is valid, then -// unmap. Re-mapped per access (the host may recreate the section across restarts). -fn with_shm(f: F) { - let name: Vec = format!("Global\\pfxusb-shm-{}", SHM_INDEX.load(Ordering::Relaxed)) - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); - // SAFETY: name is a valid NUL-terminated UTF-16 string. - let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, name.as_ptr()) }; - if h.is_null() { - return; - } - // SAFETY: h is a valid mapping handle; map the whole section; the view keeps it alive. - let view = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, SHM_SIZE) } as *mut u8; - unsafe { CloseHandle(h) }; - if view.is_null() { - return; - } - // SAFETY: view points at >= 4 mapped bytes. - let magic = unsafe { core::ptr::read_unaligned(view as *const u32) }; - if magic == SHM_MAGIC { - f(view); - } - // SAFETY: view came from MapViewOfFile. - unsafe { UnmapViewOfFile(view as *const c_void) }; -} - -/// The current controller state from shared memory (zeros / neutral if the host hasn't connected). +/// The current controller state from the attached DATA section (zeros / neutral when unattached). /// Returns `(dwPacketNumber, wButtons, lt, rt, lx, ly, rx, ry)`. -fn read_state() -> (u32, u16, u8, u8, i16, i16, i16, i16) { - let mut out = (0u32, 0u16, 0u8, 0u8, 0i16, 0i16, 0i16, 0i16); - with_shm(|v| { - // SAFETY: v points at a mapped SHM_SIZE section with valid magic. - unsafe { - out.0 = core::ptr::read_unaligned(v.add(4) as *const u32); - out.1 = core::ptr::read_unaligned(v.add(8) as *const u16); - out.2 = *v.add(10); - out.3 = *v.add(11); - out.4 = core::ptr::read_unaligned(v.add(12) as *const i16); - out.5 = core::ptr::read_unaligned(v.add(14) as *const i16); - out.6 = core::ptr::read_unaligned(v.add(16) as *const i16); - out.7 = core::ptr::read_unaligned(v.add(18) as *const i16); - } - }); - out +fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i16) { + match data { + Some(v) => ( + v.read_u32(OFF_PACKET), + v.read_u16(OFF_BUTTONS), + v.read_u8(OFF_LT), + v.read_u8(OFF_RT), + v.read_i16(OFF_LX), + v.read_i16(OFF_LY), + v.read_i16(OFF_RX), + v.read_i16(OFF_RY), + ), + None => (0, 0, 0, 0, 0, 0, 0, 0), + } } -/// Stamp the driver health marks the host watches: `driver_proto` @32 (the attach signal, -/// idempotent) and `driver_heartbeat` @36 (+1). Called at device add and on every serviced IOCTL, -/// so the host can tell "driver bound and alive" apart from "driver package missing/failed to -/// bind" and see the game-visible polling path advance. No-op until the host's section exists -/// (with_shm re-opens per access, so a section created after we started still gets marked). -fn touch_driver_marks() { - with_shm(|v| { - // SAFETY: v points at a mapped SHM_SIZE section with valid magic; proto @32, heartbeat @36. - unsafe { - core::ptr::write_unaligned(v.add(32) as *mut u32, GAMEPAD_PROTO_VERSION); - let hb = v.add(36) as *mut u32; - core::ptr::write_unaligned(hb, core::ptr::read_unaligned(hb).wrapping_add(1)); - } - }); +/// Stamp the driver health marks the host watches: `driver_proto` (the attach signal, idempotent) +/// and `driver_heartbeat` (+1). Called once the channel attaches and on every serviced IOCTL, so the +/// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see +/// the game-visible polling path advance. +fn touch_driver_marks(data: &MappedView) { + data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION); + let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1); + data.write_u32(OFF_DRIVER_HEARTBEAT, hb); } -/// Publish a game's rumble (from SET_STATE) into shared memory for the host to forward. -fn publish_rumble(large: u8, small: u8) { - with_shm(|v| { - // SAFETY: v points at a mapped SHM_SIZE section; rumble_seq @24, large @28, small @29. - unsafe { - *v.add(28) = large; - *v.add(29) = small; - let seqp = v.add(24) as *mut u32; - let seq = core::ptr::read_unaligned(seqp).wrapping_add(1); - core::ptr::write_unaligned(seqp, seq); - } - }); +/// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward. +fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) { + let Some(v) = data else { return }; + v.write_u8(OFF_RUMBLE_LARGE, large); + v.write_u8(OFF_RUMBLE_SMALL, small); + let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1); + v.write_u32(OFF_RUMBLE_SEQ, seq); } // Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses). -fn build_get_state() -> [u8; 29] { - let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(); +fn build_get_state(data: Option<&MappedView>) -> [u8; 29] { + let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data); let mut s = [0u8; 29]; s[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes()); s[2] = 0x01; // device count @@ -374,11 +323,20 @@ extern "C" fn evt_io_device_control( input_len: usize, ioctl: ULONG, ) { - // Health marks first: attach signal + heartbeat (also covers a section the host created after - // this device started — the marks land on the next XInput poll). - touch_driver_marks(); + // SAFETY: `request` is the live request for THIS EvtIoDeviceControl invocation — exactly the + // contract `Request::new` requires. From here everything is safe (the token owns completion). + let request = unsafe { Request::new(request) }; + + // Sealed-channel pump + health marks first: adopt a (late) delivery, detach when the host's + // mailbox is gone, and stamp the attach/heartbeat marks the host watches (also covers a host + // started after this device — the pump attaches on the next XInput poll). + let data = CHANNEL.pump(&channel_cfg()); + if let Some(v) = data { + touch_driver_marks(v); + } + let status: NTSTATUS = match ioctl { - IOCTL_XUSB_GET_INFORMATION => copy_to_output(request, &build_information()), + IOCTL_XUSB_GET_INFORMATION => request.copy_to_output(&build_information()), IOCTL_XUSB_GET_INFORMATION_EX => { let mut ex = [0u8; 64]; ex[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes()); @@ -387,21 +345,19 @@ extern "C" fn evt_io_device_control( ex[8..10].copy_from_slice(&XUSB_VID.to_le_bytes()); ex[10..12].copy_from_slice(&XUSB_PID.to_le_bytes()); let n = output_len.min(64); - copy_to_output(request, &ex[..n]) + request.copy_to_output(&ex[..n]) } IOCTL_XUSB_GET_CAPABILITIES => { if output_len >= 36 { - copy_to_output(request, &build_caps_v2()) + request.copy_to_output(&build_caps_v2()) } else { - copy_to_output(request, &CAPS_V1) + request.copy_to_output(&CAPS_V1) } } - IOCTL_XUSB_GET_STATE => copy_to_output(request, &build_get_state()), - IOCTL_XUSB_GET_LED_STATE => copy_to_output(request, &[0x00, 0x00, 0x06]), - IOCTL_XUSB_GET_BATTERY_INFORMATION => { - copy_to_output(request, &[0x00, 0x01, 0x03, 0x00]) - } - IOCTL_XUSB_SET_STATE => on_set_state(request), + IOCTL_XUSB_GET_STATE => request.copy_to_output(&build_get_state(data)), + IOCTL_XUSB_GET_LED_STATE => request.copy_to_output(&[0x00, 0x00, 0x06]), + IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]), + IOCTL_XUSB_SET_STATE => on_set_state(&request, data), IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS, // Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling. IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST, @@ -410,78 +366,29 @@ extern "C" fn evt_io_device_control( STATUS_INVALID_DEVICE_REQUEST } }; - // SAFETY: request valid and not forwarded. - unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status) }; + request.complete(status); } // SET_STATE: the rumble packet. Classic xusb22 layout is small; the motor bytes sit near the end. -// We publish a best-effort (large = byte 3, small = byte 4 for the 5-byte form) and log the raw bytes +// We publish a best-effort (large = byte 2, small = byte 3 for the 5-byte form) and log the raw bytes // so the exact offsets can be confirmed against a real pad. -fn on_set_state(request: WDFREQUEST) -> NTSTATUS { - let mut inmem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: request valid. - let st = unsafe { - call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem) - }; - if nt_success(st) { - let mut len: usize = 0; - // SAFETY: inmem valid. - let p = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut len) } - as *const u8; - if !p.is_null() && len >= 2 { - let n = len.min(8); - // SAFETY: p valid for len bytes; read at most n. - let bytes = unsafe { core::slice::from_raw_parts(p, n) }; - let mut hex = String::new(); - for b in bytes { - hex.push_str(&format!("{b:02x} ")); - } - dbglog!("[pf-xusb] SET_STATE len={len} data: {hex}"); - // Observed 5-byte form {00, led, largeMotor, smallMotor, subcmd}: subcmd 0x02 = rumble - // (large/low-freq at [2], small/high-freq at [3]); 0x01 = player-LED set (ignored). - // 4-byte = raw XINPUT_VIBRATION → the two motor hi bytes. - if len >= 5 && bytes[4] == 0x02 { - publish_rumble(bytes[2], bytes[3]); - } else if len == 4 { - publish_rumble(bytes[1], bytes[3]); - } +fn on_set_state(request: &Request, data: Option<&MappedView>) -> NTSTATUS { + if let Ok((bytes, len)) = request.input_bytes(8) + && len >= 2 + { + let mut hex = String::new(); + for b in &bytes { + hex.push_str(&format!("{b:02x} ")); + } + dbglog!("[pf-xusb] SET_STATE len={len} data: {hex}"); + // Observed 5-byte form {00, led, largeMotor, smallMotor, subcmd}: subcmd 0x02 = rumble + // (large/low-freq at [2], small/high-freq at [3]); 0x01 = player-LED set (ignored). + // 4-byte = raw XINPUT_VIBRATION → the two motor hi bytes. + if len >= 5 && bytes[4] == 0x02 { + publish_rumble(data, bytes[2], bytes[3]); + } else if len == 4 { + publish_rumble(data, bytes[1], bytes[3]); } } STATUS_SUCCESS } - -// Copy `src` into the request's (buffered) output buffer and set the completed byte count. -fn copy_to_output(request: WDFREQUEST, src: &[u8]) -> NTSTATUS { - let mut mem: WDFMEMORY = core::ptr::null_mut(); - // SAFETY: request valid; mem receives the memory handle. - let st = unsafe { - call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut mem) - }; - if !nt_success(st) { - return st; - } - let mut outlen: usize = 0; - // SAFETY: mem valid; outlen receives the buffer size. - let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) }; - if outlen < src.len() { - return STATUS_INVALID_BUFFER_SIZE; - } - // SAFETY: mem valid; src is a valid buffer of src.len() bytes. - let st = unsafe { - call_unsafe_wdf_function_binding!( - WdfMemoryCopyFromBuffer, - mem, - 0usize, - src.as_ptr() as *mut c_void, - src.len() - ) - }; - if !nt_success(st) { - return st; - } - // SAFETY: request valid. - unsafe { - call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, src.len() as u64) - }; - STATUS_SUCCESS -} diff --git a/packaging/windows/drivers/wdk-iddcx/src/lib.rs b/packaging/windows/drivers/wdk-iddcx/src/lib.rs index fdd8e51..600bd6a 100644 --- a/packaging/windows/drivers/wdk-iddcx/src/lib.rs +++ b/packaging/windows/drivers/wdk-iddcx/src/lib.rs @@ -10,8 +10,10 @@ //! code — handled at the call site in STEP 5). #![no_std] #![allow(non_snake_case, clippy::missing_safety_doc)] -// P0 lint (audit §8): require explicit `unsafe {}` blocks inside `unsafe fn`s. +// P0 lint (audit §8): require explicit `unsafe {}` blocks inside `unsafe fn`s + a `// SAFETY:` proof on +// each (this crate is the IddCx DDI dispatch layer — inherently unsafe, so audited, not unsafe-free). #![deny(unsafe_op_in_unsafe_fn)] +#![deny(clippy::undocumented_unsafe_blocks)] pub use wdk_sys::iddcx; @@ -36,6 +38,7 @@ unsafe fn ddi(index: i32) -> T { let table = (&raw const iddcx::IddFunctions).cast::(); // SAFETY: `index` is a valid IddCx table slot; the slot holds a `PFN_*` whose layout is `T`. let slot = unsafe { table.add(index as usize) }; + // SAFETY: `slot` points at the `index`th (in-bounds) populated table entry, a `PFN_*` of layout `T`. unsafe { slot.cast::().read() } } @@ -62,7 +65,10 @@ macro_rules! iddcx_ddi { /// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract. #[inline] pub unsafe fn $name( $( $arg: $aty ),* ) -> NTSTATUS { + // SAFETY: `$idx`/`$pfn` are the matched IddCx table index + PFN type (pinned by this macro + // invocation), and the table is populated once the driver is loaded (this fn's contract). let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) }; + // SAFETY: only reads the stub-provided globals pointer; valid post-load per the contract. let g = unsafe { globals() }; // SAFETY: dispatching a populated DDI with the stub globals and caller-valid args. unsafe { (f.unwrap())(g, $( $arg ),* ) } @@ -79,7 +85,10 @@ macro_rules! iddcx_ddi { /// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract. #[inline] pub unsafe fn $name( $( $arg: $aty ),* ) { + // SAFETY: `$idx`/`$pfn` are the matched IddCx table index + PFN type (pinned by this macro + // invocation), and the table is populated once the driver is loaded (this fn's contract). let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) }; + // SAFETY: only reads the stub-provided globals pointer; valid post-load per the contract. let g = unsafe { globals() }; // SAFETY: dispatching a populated DDI with the stub globals and caller-valid args. unsafe { (f.unwrap())(g, $( $arg ),* ) }