diff --git a/crates/pf-capture/src/linux/xfixes_cursor.rs b/crates/pf-capture/src/linux/xfixes_cursor.rs index f00a37a5..f1f1e1db 100644 --- a/crates/pf-capture/src/linux/xfixes_cursor.rs +++ b/crates/pf-capture/src/linux/xfixes_cursor.rs @@ -331,13 +331,13 @@ fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) { } /// One request+reply — x11rb splits errors (the request is `ConnectionError`, `reply()` is -/// `ReplyError` which is `From`), so `?` collapses both. +/// `ReplyError` which is `From`), so the request `?` converts into the reply error. fn fetch_cursor_image(conn: &RustConnection) -> Result { - Ok(conn.xfixes_get_cursor_image()?.reply()?) + conn.xfixes_get_cursor_image()?.reply() } fn fetch_pointer(conn: &RustConnection, root: Window) -> Result { - Ok(conn.query_pointer(root)?.reply()?) + conn.query_pointer(root)?.reply() } /// XFixes cursor pixels are packed `0xAARRGGBB` with **premultiplied** alpha (the Xrender / Xcursor diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index cd35fd85..50941ca3 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -399,8 +399,9 @@ pub struct IddPushCapturer { /// from [`cursor_poll::CursorPoller`] (the IddCx query is alpha-only — see cursor_poll.rs), /// and this shm read is the fallback if that poller dies. cursor_shared: Option, - /// The GDI cursor-shape poller (design §8): the overlay source while alive. `Some` exactly - /// when `cursor_shared` is (both ride the negotiated cursor channel + successful delivery). + /// The GDI cursor-shape poller (design §8): the overlay source while alive. `Some` when + /// `cursor_shared` is (both ride the negotiated cursor channel + successful delivery) — or + /// when `composite_forced` (no channel, but the target's sticky declare needs a blend source). cursor_poll: Option, /// Retained delivery sender (`IOCTL_SET_CURSOR_CHANNEL`) for RE-delivery: a driver-side /// monitor re-arrival (match-window re-arrival resize, a sibling session recreating the @@ -410,6 +411,12 @@ pub struct IddPushCapturer { /// The CAPTURE mouse model is active — the HOST composites the pointer into the frame /// (see cursor_blend.rs for why DWM cannot: a declared IddCx hardware cursor is forever). composite_cursor: bool, + /// This session never negotiated the cursor channel but its target carries an IRREVOCABLE + /// hardware-cursor declare from an earlier session (`WinCaptureTarget::cursor_excluded`, + /// §8.6): DWM delivers pointer-free frames and no client draws the cursor, so the ONLY path + /// to a visible pointer is compositing here. Pins `composite_cursor` on — nothing may turn + /// it off (there is no channel to hand the pointer to). + composite_forced: bool, /// The cursor-quad blend pass (lazy; per capture device). `None` after a build failure — /// composite mode then degrades to pointer-less frames (warned once). cursor_blend: Option, @@ -1016,10 +1023,23 @@ impl IddPushCapturer { } } }); + // No channel this session, but the target's sticky declare (an EARLIER session's — + // irrevocable, §8.6) keeps DWM's frames pointer-free with no client drawing either: + // the only visible pointer is the one composited here, so force composite mode on. + let composite_forced = target.cursor_excluded && cursor_sender.is_none(); + if composite_forced { + tracing::info!( + target_id = target.target_id, + "target carries an irrevocable hardware-cursor declare from an earlier \ + desktop-mode session and this session has no cursor channel — the host \ + composites the pointer into frames (forced, for the session's life)" + ); + } // The GDI shape poller rides the SAME gate as the delivered channel: with the driver's // hardware cursor keeping the frame cursor-free, the poller supplies the full-fidelity // shape (masked/monochrome included — the IddCx query can't; see cursor_poll.rs). - let cursor_poll = cursor_shared.as_ref().map(|_| { + // Forced-composite sessions need it too — it is their only shape/position source. + let cursor_poll = (cursor_shared.is_some() || composite_forced).then(|| { // Safety of the CCD call: read-only QueryDisplayConfig over owned locals (same // call CursorShared::create makes) — already inside open_on's unsafe region. let rect = pf_win_display::win_display::source_desktop_rect(target.target_id) @@ -1088,7 +1108,8 @@ impl IddPushCapturer { cursor_shared, cursor_poll, cursor_sender, - composite_cursor: false, + composite_cursor: composite_forced, + composite_forced, cursor_blend: None, cursor_blend_failed: false, blend_scratch: None, @@ -2314,7 +2335,9 @@ impl Capturer for IddPushCapturer { // stays declared for the session's whole life — the only dependable state (there is NO // working un-declare; see cursor_blend.rs) — keeping every frame pointer-free, and the // capturer blends the GDI poller's shape into the frame itself. No driver round-trip. - let composite = !on && self.cursor_shared.is_some(); + // `composite_forced` (a channel-less session on a sticky-declared target) is pinned ON: + // with no client drawing, un-compositing would erase the pointer entirely. + let composite = (!on && self.cursor_shared.is_some()) || self.composite_forced; if self.composite_cursor != composite { self.composite_cursor = composite; self.last_blend_key = None; // regenerate immediately at the current pointer state diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index dd744cd6..7c03915c 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -79,6 +79,16 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) { /// LIVE monitor, so the capture mouse model gets DWM's composited pointer back (full fidelity) /// and the desktop model gets exclusion + forwarding. Nothing existing changed; against a v5 /// driver the unknown IOCTL fails and the host logs + keeps the declared-at-ADD behavior. +/// v6 tail ext (no bump, the `AddRequest` luminance-tail discipline): +/// [`control::AddReply::cursor_excluded`] — the driver reports whether the ADDed monitor's OS +/// target already carries a hardware-cursor declare from an earlier session. A declare is +/// IRREVOCABLE for the target's life (remote-desktop-sweep §8.6, proven on-glass): DWM never +/// composites the software cursor back into that target's frames, and the sticky state survives +/// monitor REMOVE→ADD because the host hands every client a STABLE target id. The host uses the +/// flag to self-composite the pointer (GDI poller + blend) in sessions that never negotiate the +/// cursor channel — without it those sessions are silently cursor-less. Both skews degrade +/// cleanly: an old driver writes only the 20-byte reply prefix (host reads `0` = unknown/clean), +/// an old host retrieves a 20-byte buffer (driver writes just the prefix). pub const PROTOCOL_VERSION: u32 = 6; /// The OLDEST driver protocol this host still drives (v4 is additive over v3 — see the v4 note on @@ -207,8 +217,21 @@ pub mod control { /// `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, + /// Non-zero = this monitor's OS target already carries an IRREVOCABLE hardware-cursor + /// declare from an earlier session (remote-desktop-sweep §8.6): DWM excludes the pointer + /// from every frame on this target, forever, and a session without the cursor channel must + /// self-composite (GDI poller + blend) or stream a cursor-less desktop. Appended after + /// [`ADD_REPLY_LEGACY_SIZE`] under the same dual-size discipline as the `AddRequest` + /// luminance tail: an un-upgraded driver writes only the legacy prefix (the host's + /// zero-initialized buffer then reads `0` = unknown/clean), an un-upgraded host retrieves a + /// legacy-size buffer (the driver writes just the prefix). + pub cursor_excluded: u32, } + /// [`AddReply`]'s size before the `cursor_excluded` tail — the prefix an un-upgraded driver + /// writes and an un-upgraded host retrieves (see the field docs). + pub const ADD_REPLY_LEGACY_SIZE: usize = 20; + /// `IOCTL_REMOVE` input. #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] @@ -329,12 +352,14 @@ pub mod control { assert!(offset_of!(AddRequest, hw_cursor) == 36); assert!(size_of::() == 40); - assert!(size_of::() == 20); + assert!(size_of::() == 24); 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); + // The cursor-excluded tail starts exactly at the legacy boundary (prefix-compat). + assert!(offset_of!(AddReply, cursor_excluded) == ADD_REPLY_LEGACY_SIZE); assert!(size_of::() == 32 + 8 * RING_LEN_USIZE); assert!(offset_of!(SetFrameChannelRequest, target_id) == 0); diff --git a/crates/pf-frame/src/dxgi.rs b/crates/pf-frame/src/dxgi.rs index 8db1fbbf..54ae03e4 100644 --- a/crates/pf-frame/src/dxgi.rs +++ b/crates/pf-frame/src/dxgi.rs @@ -32,6 +32,11 @@ pub struct WinCaptureTarget { /// 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, + /// The ADD reply flagged this target as carrying an IRREVOCABLE IddCx hardware-cursor declare + /// from an earlier session (remote-desktop-sweep §8.6): DWM excludes the pointer from its + /// frames forever, so a session WITHOUT the cursor channel must self-composite (the IDD-push + /// capturer's forced-composite gate) or the streamed desktop has no cursor at all. + pub cursor_excluded: bool, } /// The PyroWave (Windows) zero-copy sharing payload attached to a captured frame: the SECOND plane diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index 48226ff3..e41f2a56 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -74,6 +74,12 @@ struct Monitor { /// This monitor was ADDed with the v5 hardware-cursor flag (already driver-proto-gated) — /// preserved across a re-arrival resize so the recreated monitor keeps the cursor channel. hw_cursor: bool, + /// The ADD reply flagged this monitor's OS target as carrying an IRREVOCABLE hardware-cursor + /// declare from an earlier session (§8.6): DWM excludes the pointer from its frames forever + /// (the sticky state survives monitor REMOVE→ADD via the stable per-client target id), so a + /// session WITHOUT the cursor channel must self-composite — carried into + /// [`WinCaptureTarget`] for the IDD-push capturer's forced-composite gate. + cursor_excluded: bool, /// 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. The SAME /// process for every parallel monitor (one devnode → one WUDFHost hosts all publishers), which @@ -101,6 +107,7 @@ impl Monitor { gdi_name: n, target_id: self.target_id, wudf_pid: self.wudf_pid, + cursor_excluded: self.cursor_excluded, }) } } @@ -1092,6 +1099,7 @@ impl VirtualDisplayManager { position: (0, 0), gen: self.gen.fetch_add(1, Ordering::Relaxed), hw_cursor, + cursor_excluded: added.cursor_excluded, }) } @@ -1315,6 +1323,9 @@ impl VirtualDisplayManager { position: old.position, gen: old.gen, hw_cursor: old.hw_cursor, + // Fresh from THIS reply, not `old`: the driver's per-target declare registry is the + // ground truth (this session may itself have declared since the original ADD). + cursor_excluded: added.cursor_excluded, }) } diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs index f8f13648..c5d4e085 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs @@ -22,6 +22,11 @@ pub(crate) struct AddedMonitor { pub luid: LUID, pub wudf_pid: u32, pub resolved_monitor_id: u32, + /// The driver reports the OS target already carries an IRREVOCABLE hardware-cursor declare + /// from an earlier session (`AddReply::cursor_excluded`, remote-desktop-sweep §8.6): DWM + /// excludes the pointer from this target's frames forever, so a session without the cursor + /// channel must self-composite (GDI poller + blend) or stream a cursor-less desktop. + pub cursor_excluded: bool, } /// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay. diff --git a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs index b16b944e..a32fccbd 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs @@ -605,10 +605,13 @@ impl VdisplayDriver for PfVdisplayDriver { })?; // Fail closed on a short reply — `target_id`/`wudf_pid`/`luid` below feed OpenProcess + the // WUDFHost verification, so don't decode a partially-written (zeroed) reply as authoritative. - if (n as usize) < size_of::() { + // The LEGACY size, not the full struct: an un-upgraded driver writes only the prefix before + // the `cursor_excluded` tail; `out` is zero-initialized, so the missing tail reads `0` + // (= unknown/clean — exactly what a driver that can't track declares should report). + if (n as usize) < control::ADD_REPLY_LEGACY_SIZE { anyhow::bail!( - "pf-vdisplay ADD returned {n} bytes, expected {}", - size_of::() + "pf-vdisplay ADD returned {n} bytes, expected at least {}", + control::ADD_REPLY_LEGACY_SIZE ); } // `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed 4-byte @@ -623,6 +626,7 @@ impl VdisplayDriver for PfVdisplayDriver { target_id = reply.target_id, adapter_luid = %format_args!("{:#x}", luid.LowPart), wudf_pid = reply.wudf_pid, + cursor_excluded = reply.cursor_excluded != 0, "pf-vdisplay monitor created {}x{}@{}", mode.width, mode.height, @@ -659,6 +663,7 @@ impl VdisplayDriver for PfVdisplayDriver { luid, wudf_pid: reply.wudf_pid, resolved_monitor_id: reply.resolved_monitor_id, + cursor_excluded: reply.cursor_excluded != 0, }) } diff --git a/packaging/windows/drivers/pf-vdisplay/src/control.rs b/packaging/windows/drivers/pf-vdisplay/src/control.rs index 3e59b1b8..2b4642e9 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/control.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/control.rs @@ -165,9 +165,14 @@ unsafe fn add(request: WDFREQUEST) { // 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(), + // The target already carries an irrevocable hardware-cursor declare from an earlier + // session — a channel-less session must self-composite the pointer (§8.6 gap). + cursor_excluded: crate::monitor::target_declared(target_id) as u32, }; + // Dual-size reply (the `cursor_excluded` tail ext): an un-upgraded host retrieves only the + // legacy 20-byte buffer — write the prefix it asked for instead of failing its ADD. // SAFETY: `request` is the framework WDFREQUEST. - unsafe { write_output_complete(request, &reply) }; + unsafe { write_output_prefix_complete(request, &reply, control::ADD_REPLY_LEGACY_SIZE) }; } /// `IOCTL_SET_FRAME_CHANNEL`: adopt the handle values the host duplicated into this process and stash @@ -356,6 +361,18 @@ unsafe fn read_input(request: WDFREQUEST) -> Option { /// # Safety /// `request` is the framework `WDFREQUEST`. unsafe fn write_output_complete(request: WDFREQUEST, value: &T) { + // SAFETY: forwarded per this function's own contract; min = the full struct. + unsafe { write_output_prefix_complete(request, value, core::mem::size_of::()) } +} + +/// [`write_output_complete`] for a reply with an APPENDED tail field (the `AddReply +/// cursor_excluded` dual-size discipline): accepts any output buffer of at least `min_size` +/// bytes and writes `min(buffer, size_of::())` bytes of the struct — an un-upgraded host +/// retrieving only the legacy prefix gets exactly that prefix instead of a failed IOCTL. +/// +/// # Safety +/// `request` is the framework `WDFREQUEST`. +unsafe fn write_output_prefix_complete(request: WDFREQUEST, value: &T, min_size: usize) { let mut buf: *mut core::ffi::c_void = core::ptr::null_mut(); let mut len: usize = 0; // SAFETY: `request` valid; `buf`/`len` are out-params written by the framework. @@ -363,7 +380,7 @@ unsafe fn write_output_complete(request: WDFREQUEST, value: &T) { call_unsafe_wdf_function_binding!( WdfRequestRetrieveOutputBuffer, request, - core::mem::size_of::(), + min_size, &mut buf, &mut len ) @@ -372,9 +389,13 @@ unsafe fn write_output_complete(request: WDFREQUEST, value: &T) { complete(request, st); return; } - // SAFETY: `buf` has >= size_of::() writable bytes; T is a Pod control struct. - unsafe { buf.cast::().write_unaligned(*value) }; - complete_info(request, STATUS_SUCCESS, core::mem::size_of::()); + let take = len.min(core::mem::size_of::()); + // SAFETY: the framework guarantees `buf` has >= `len` writable bytes and `take <= len`; + // T is a Pod control struct, so any byte prefix of it is valid to copy out. + unsafe { + core::ptr::copy_nonoverlapping((value as *const T).cast::(), buf.cast::(), take); + } + complete_info(request, STATUS_SUCCESS, take); } /// Complete a request with just a status (no output). diff --git a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs index a1fc9feb..b0c6ee00 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs @@ -199,6 +199,35 @@ fn cursor_forward_desired(target_id: u32) -> bool { .unwrap_or(true) } +/// OS target ids on which `IddCxMonitorSetupHardwareCursor` ever SUCCEEDED. A declare is +/// IRREVOCABLE for the target's life (no un-declare DDI; empty-caps re-setup is rejected; even a +/// successful same-mode re-commit never brings DWM's composited pointer back — all proven +/// on-glass, remote-desktop-sweep §8.6) and it survives monitor REMOVE→ADD because the host hands +/// each client a STABLE target id. Reported back on every ADD +/// ([`AddReply::cursor_excluded`](pf_driver_proto::control::AddReply)) so a session that never +/// negotiates the cursor channel knows it must self-composite the pointer instead of streaming a +/// cursor-less desktop. Never cleared: the state's true scope is this WUDFHost's life +/// (`ProcessSharingDisabled` — the process, and with it the adapter and every sticky declare, +/// dies on adapter reset), which is exactly when this static resets too. Bounded: ≤ 16 ids. +static DECLARED_TARGETS: Mutex> = Mutex::new(Vec::new()); + +/// Record a SUCCESSFUL hardware-cursor declare on `target_id` (see [`DECLARED_TARGETS`]). +fn mark_declared(target_id: u32) { + let mut declared = DECLARED_TARGETS.lock().unwrap_or_else(|e| e.into_inner()); + if !declared.contains(&target_id) { + declared.push(target_id); + dbglog!("[pf-vd] cursor: target {target_id} marked hardware-cursor declared (irrevocable)"); + } +} + +/// True if `target_id` ever had a hardware cursor declared (see [`DECLARED_TARGETS`]). +pub fn target_declared(target_id: u32) -> bool { + DECLARED_TARGETS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(&target_id) +} + /// Record a departing monitor's advertised list for its id ([`MODE_HISTORY`]). fn remember_modes(id: u32, modes: &[Mode]) { let mut hist = MODE_HISTORY.lock().unwrap_or_else(|e| e.into_inner()); @@ -432,6 +461,10 @@ pub fn set_cursor_channel( // the dispatch layer is wrong here — the handles are gone, so this is a plain failure. return Ok(()); // adopted (handles consumed); the host detects no-publish and moves on }; + if declare { + // The worker only spawns after `IddCxMonitorSetupHardwareCursor` succeeded. + mark_declared(target_id); + } let mut lock = lock_monitors(); if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) { m.cursor_worker = Some(worker); @@ -535,12 +568,18 @@ pub fn resetup_cursor(object: iddcx::IDDCX_MONITOR) { // A composite-mode monitor (`cursor_forward_on == false`, the mid-stream flip) must // NOT re-declare — the commit's software-cursor default is exactly what it wants. .filter(|m| m.cursor_forward_on) - .and_then(|m| m.cursor_worker.as_ref()) - .map(|w| w.data_event()) + .and_then(|m| { + m.cursor_worker + .as_ref() + .map(|w| (w.data_event(), m.target_id)) + }) }; - if let Some(ev) = data_event { + if let Some((ev, target_id)) = data_event { let st = crate::cursor_worker::setup_hardware_cursor(object, ev); dbglog!("[pf-vd] cursor: re-setup on swap-chain assign -> {st:#x}"); + if wdk_iddcx::nt_success(st) { + mark_declared(target_id); + } } } @@ -591,6 +630,9 @@ pub fn set_cursor_forward(target_id: u32, enable: bool) -> bool { // Enable declares immediately against the live worker's event (works any time). let st = crate::cursor_worker::setup_hardware_cursor(object, ev); dbglog!("[pf-vd] cursor: forward flip enable=1 (declare) -> {st:#x}"); + if wdk_iddcx::nt_success(st) { + mark_declared(target_id); + } } (false, _, _) => { // There is NO un-declare DDI: re-issuing the setup with empty caps is rejected