From 3a34440a6b9962fc77a6a15a7ddd05c768d0fce2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 22 Jul 2026 08:46:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows):=20IddCx=20hardware-cursor=20chan?= =?UTF-8?q?nel=20=E2=80=94=20remote-desktop=20sweep=20M2c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the cursor channel to Windows hosts. The pf-vdisplay driver declares an IddCx hardware cursor for sessions that negotiated cursor-forward — DWM then EXCLUDES the pointer from the IDD frame and delivers shape/position out-of-band, into the same CursorOverlay → forwarder → wire → client pipeline the Linux portal path uses. - pf-driver-proto v5 (additive, host floor stays 3): AddRequest's spare tail becomes hw_cursor (same size/offsets); IOCTL_SET_CURSOR_CHANNEL delivers a host-created CursorShm section (64-byte seqlock header + 256² shape buffer, layout pinned + tested). No event crosses the boundary — the host polls at encode-tick pace. - driver: wdk-iddcx grows the two cursor DDI wrappers; a per-monitor cursor worker (event wait → QueryHardwareCursor → seqlock publish) starts only when BOTH the ADD asked and the channel arrived, so a failed delivery leaves DWM compositing as today. Shape bytes ship raw (BGRA/masked + pitch); the host converts. - host: the section rides the existing sealed-channel broker (least- privilege dup, remote reap on failure); IddPushCapturer::cursor() seqlock-reads → CursorOverlay (BGRA→RGBA, masked-color approximation, desktop→frame origin shift, per-shape conversion cache). New Capturer::cursor() trait hook — the encode loop prefers it over the frame-attached overlay because hardware-cursor moves produce NO new frame on a static desktop. hw_cursor survives the re-arrival resize (carried on the manager's Monitor). - negotiation: cursor_forward grows the Windows arm (client cap ∧ driver proto ≥ 5, probed once via the control device); SessionPlan carries cursor_forward → OutputFormat.hw_cursor. - drive-by: wdk-probe's two pre-existing same-type casts (clippy) and pf-vdisplay's stale spike-test refs (crate::win_display moved to pf-win-display; tracing-subscriber was never a dep) repaired. Verified: proto tests on Mac AND MSVC (15/15 incl. the CursorShm layout pin); clippy -D warnings for proto/frame/capture/vdisplay/host on Linux (.21) and native Windows (.173); the DRIVER workspace clippy -D warnings green against the real WDK 10.0.26100 bindgen (DDI names, enum variants and IDARG layouts all bind). On-box driver deploy + on-glass validation follow. Co-Authored-By: Claude Fable 5 --- crates/pf-capture/src/lib.rs | 21 ++ crates/pf-capture/src/windows/idd_push.rs | 81 ++++- .../src/windows/idd_push/channel.rs | 16 + .../pf-capture/src/windows/idd_push/cursor.rs | 194 ++++++++++++ crates/pf-driver-proto/src/lib.rs | 159 +++++++++- crates/pf-frame/src/lib.rs | 8 + crates/pf-vdisplay/src/vdisplay/backend.rs | 7 + .../src/vdisplay/windows/manager.rs | 35 ++- .../src/vdisplay/windows/manager/driver.rs | 1 + .../src/vdisplay/windows/pf_vdisplay.rs | 59 +++- crates/punktfunk-host/src/capture.rs | 18 ++ crates/punktfunk-host/src/native/handshake.rs | 37 ++- crates/punktfunk-host/src/native/stream.rs | 22 +- crates/punktfunk-host/src/session_plan.rs | 7 + .../windows/drivers/pf-vdisplay/Cargo.toml | 1 + .../drivers/pf-vdisplay/src/control.rs | 32 ++ .../drivers/pf-vdisplay/src/cursor_worker.rs | 277 ++++++++++++++++++ .../windows/drivers/pf-vdisplay/src/lib.rs | 1 + .../drivers/pf-vdisplay/src/monitor.rs | 47 +++ .../windows/drivers/wdk-iddcx/src/lib.rs | 18 ++ .../wdk-probe/src/iddcx_surface_assert.rs | 4 +- 21 files changed, 1009 insertions(+), 36 deletions(-) create mode 100644 crates/pf-capture/src/windows/idd_push/cursor.rs create mode 100644 packaging/windows/drivers/pf-vdisplay/src/cursor_worker.rs diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index dc8f1957..81e7a6fe 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -69,6 +69,15 @@ pub trait Capturer: Send { /// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet). /// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram), /// so the two stay a single source of truth. May change mid-session if the source is regraded. + /// The capture source's LIVE cursor state, when it arrives out-of-band from the frames + /// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and + /// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves + /// produce NO new frame, so the frame-attached overlay would go stale on a static desktop. + /// Default `None`: the Linux portal path attaches its cursor to frames instead. + fn cursor(&mut self) -> Option { + None + } + fn hdr_meta(&self) -> Option { None } @@ -367,6 +376,16 @@ pub type FrameChannelSender = std::sync::Arc< dyn Fn(&pf_driver_proto::control::SetFrameChannelRequest) -> Result<()> + Send + Sync, >; +/// Delivery closure for the v5 hardware-cursor channel (`IOCTL_SET_CURSOR_CHANNEL`) — same +/// facade contract as [`FrameChannelSender`]. `Some` also OPTS THE SESSION IN: the capturer +/// creates + delivers the cursor section only when the host hands it a sender (the negotiated +/// cursor-forward sessions), and the driver only declares the hardware cursor once that +/// delivery lands — so a plain session keeps DWM's composited pointer untouched. +#[cfg(target_os = "windows")] +pub type CursorChannelSender = std::sync::Arc< + dyn Fn(&pf_driver_proto::control::SetCursorChannelRequest) -> Result<()> + Send + Sync, +>; + // One-time PipeWire library init, shared by the video (portal) and audio capture threads. #[cfg(target_os = "linux")] pub mod pwinit; @@ -450,6 +469,7 @@ pub fn open_idd_push( pyrowave: bool, keepalive: Box, sender: FrameChannelSender, + cursor_sender: Option, ) -> std::result::Result, (anyhow::Error, Box)> { idd_push::IddPushCapturer::open( target, @@ -459,6 +479,7 @@ pub fn open_idd_push( pyrowave, keepalive, sender, + cursor_sender, ) .map(|c| Box::new(c) as Box) } diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 089d1a09..147078f9 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -365,6 +365,8 @@ pub unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> #[path = "idd_push/channel.rs"] mod channel; +#[path = "idd_push/cursor.rs"] +mod cursor; #[path = "idd_push/descriptor.rs"] mod descriptor; #[path = "idd_push/stall.rs"] @@ -386,6 +388,10 @@ pub struct IddPushCapturer { /// 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, + /// The v5 hardware-cursor channel's host end (`Some` = delivered; the driver declared the + /// hardware cursor and seqlock-publishes into it). Survives ring recreates — the section is + /// independent of the frame ring's generation. + cursor_shared: Option, width: u32, height: u32, slots: Vec, @@ -603,6 +609,7 @@ impl IddPushCapturer { /// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the /// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch). #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] pub fn open( target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>, @@ -611,11 +618,20 @@ impl IddPushCapturer { pyrowave: bool, keepalive: Box, sender: crate::FrameChannelSender, + cursor_sender: Option, ) -> std::result::Result)> { // The stall-attribution listener (idempotent): started with the first IDD-push capturer so // the stall log can correlate DWM holes with OS display events for the session's lifetime. pf_win_display::display_events::spawn_once(); - match Self::open_inner(target, preferred, client_10bit, want_444, pyrowave, sender) { + match Self::open_inner( + target, + preferred, + client_10bit, + want_444, + pyrowave, + sender, + cursor_sender, + ) { Ok(mut me) => { me._keepalive = keepalive; Ok(me) @@ -632,6 +648,7 @@ impl IddPushCapturer { want_444: bool, pyrowave: bool, sender: crate::FrameChannelSender, + cursor_sender: Option, ) -> Result { // The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the // selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor @@ -654,6 +671,7 @@ impl IddPushCapturer { pyrowave, luid, sender.clone(), + cursor_sender.clone(), ) { Ok(me) => Ok(me), Err(e) => { @@ -687,6 +705,7 @@ impl IddPushCapturer { pyrowave, drv, sender, + cursor_sender, ) .context("IDD-push rebind to the driver's reported render adapter") } @@ -702,6 +721,7 @@ impl IddPushCapturer { pyrowave: bool, luid: LUID, sender: crate::FrameChannelSender, + cursor_sender: Option, ) -> Result { let (pw, ph, _hz) = preferred .context("IDD push needs the negotiated mode (WxH) to size the shared ring")?; @@ -935,6 +955,60 @@ impl IddPushCapturer { ) .context("deliver IDD-push frame channel to the driver")?; + // v5 hardware-cursor channel (M2c): create + deliver the CursorShm section. Failure + // is NON-fatal — the driver never declares the hardware cursor without this delivery, + // so the session degrades to today's composited pointer (and the forwarder simply + // never sees a live overlay). + let cursor_shared = cursor_sender.and_then(|send_cursor| { + match cursor::CursorShared::create(target.target_id) { + Ok(cs) => { + // Duplication safety: `section_handle` is live for `cs`'s lifetime + // (owned mapping); already inside open_on's unsafe region. + let dup = broker.dup_into_public(cs.section_handle()); + match dup { + Ok(value) => { + let req = pf_driver_proto::control::SetCursorChannelRequest { + target_id: target.target_id, + _pad: 0, + header_handle: value, + }; + match send_cursor(&req) { + Ok(()) => { + tracing::info!( + target_id = target.target_id, + "IDD push(host): cursor channel delivered — driver \ + declares the hardware cursor" + ); + Some(cs) + } + Err(e) => { + broker.close_remote_public(value); + tracing::warn!( + "cursor channel delivery failed (composited cursor \ + stays): {e:#}" + ); + None + } + } + } + Err(e) => { + tracing::warn!( + "cursor section duplication failed (composited cursor \ + stays): {e:#}" + ); + None + } + } + } + Err(e) => { + tracing::warn!( + "cursor section creation failed (composited cursor stays): {e:#}" + ); + None + } + } + }); + tracing::info!( target_id = target.target_id, wudf_pid = target.wudf_pid, @@ -993,6 +1067,7 @@ impl IddPushCapturer { last_seq: 0, last_present: None, status_logged: false, + cursor_shared, // Held from BEFORE the first-frame gate (the display must not idle off while we // wait for the first compose) until the capturer drops with the session. _display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(), @@ -1982,6 +2057,10 @@ impl std::fmt::Display for AttachTexFail { impl std::error::Error for AttachTexFail {} impl Capturer for IddPushCapturer { + fn cursor(&mut self) -> Option { + self.cursor_shared.as_mut().and_then(|c| c.read()) + } + fn next_frame(&mut self) -> Result { let deadline = Instant::now() + Duration::from_secs(20); loop { diff --git a/crates/pf-capture/src/windows/idd_push/channel.rs b/crates/pf-capture/src/windows/idd_push/channel.rs index a4a8e993..f370110f 100644 --- a/crates/pf-capture/src/windows/idd_push/channel.rs +++ b/crates/pf-capture/src/windows/idd_push/channel.rs @@ -105,6 +105,22 @@ impl ChannelBroker { Ok(out.0 as usize as u64) } + /// Duplicate the cursor section into WUDFHost (v5 cursor channel) with the same + /// least-privilege section rights as the frame header. Thin `pub(super)` face over + /// [`dup_into`](Self::dup_into) for the cursor-delivery path in `open_on`. + /// + /// # Safety + /// `h` must be a live handle of the current process. + pub(super) unsafe fn dup_into_public(&self, h: HANDLE) -> Result { + // SAFETY: forwarded contract — `h` is live per this fn's own contract. + unsafe { self.dup_into(h, Some(SECTION_MAP_RW)) } + } + + /// [`close_remote`](Self::close_remote) for the cursor-delivery failure path. + pub(super) fn close_remote_public(&self, value: u64) { + self.close_remote(value); + } + /// 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) { diff --git a/crates/pf-capture/src/windows/idd_push/cursor.rs b/crates/pf-capture/src/windows/idd_push/cursor.rs new file mode 100644 index 00000000..fb2780f9 --- /dev/null +++ b/crates/pf-capture/src/windows/idd_push/cursor.rs @@ -0,0 +1,194 @@ +//! Host side of the v5 hardware-cursor channel (remote-desktop-sweep M2c): the capturer creates +//! an unnamed [`CursorShm`] section, delivers it to the pf-vdisplay driver (which declares an +//! IddCx hardware cursor — DWM then EXCLUDES the pointer from the frames we consume), and reads +//! the driver's seqlock publishes here at encode-tick pace, converting them into the same +//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the +//! cursor forwarder, the wire, the client renderer) is shared. + +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it. +#![deny(clippy::undocumented_unsafe_blocks)] + +use super::*; +use pf_driver_proto::cursor::{ + CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET, + CURSOR_SHM_SIZE, CURSOR_TYPE_MASKED_COLOR, +}; +use std::sync::atomic::AtomicU32; + +/// The host end of one monitor's cursor channel: the section (we created it — the mapping stays +/// valid for the capturer's life) plus the reader's conversion cache. +pub(super) struct CursorShared { + section: MappedSection, + /// The monitor's desktop origin — IddCx reports positions in DESKTOP coordinates; the + /// overlay wants frame-relative. Fetched at attach (the virtual monitor's placement is + /// stable for the session; a topology change recreates the pipeline anyway). + origin: (i32, i32), + /// Conversion cache: the last `shape_id` whose pixels were converted, and the result. + /// Position-only updates (the common case) reuse it — a refcount bump, no pixel work. + cached_id: u32, + cached: Option, +} + +struct ConvertedShape { + rgba: std::sync::Arc>, + w: u32, + h: u32, + hot_x: u32, + hot_y: u32, +} + +impl CursorShared { + /// Create + initialize the section (magic stamped, seq even/zero). The returned handle is + /// the section itself (owned by `self`); the caller duplicates it into the WUDFHost. + pub(super) fn create(target_id: u32) -> Result { + // SAFETY: plain FFI. Unnamed pagefile-backed section, host-lifetime owned; the view is + // mapped once and unmapped never (the capturer's life = the session's life). + let section = unsafe { + let map = CreateFileMappingW( + INVALID_HANDLE_VALUE, + None, + PAGE_READWRITE, + 0, + CURSOR_SHM_SIZE as u32, + PCWSTR::null(), + ) + .context("CreateFileMapping(cursor)")?; + let map = OwnedHandle::from_raw_handle(map.0 as _); + let view = MapViewOfFile( + HANDLE(map.as_raw_handle()), + FILE_MAP_ALL_ACCESS, + 0, + 0, + CURSOR_SHM_SIZE, + ); + if view.Value.is_null() { + bail!("MapViewOfFile failed for the cursor section"); + } + let shm = view.Value.cast::(); + std::ptr::write_bytes(view.Value.cast::(), 0, CURSOR_SHM_SIZE); + // Magic LAST-ish (the driver validates it at adopt; seq 0 = even = consistent). + std::sync::atomic::fence(Ordering::Release); + (*shm).magic = CURSOR_MAGIC; + MappedSection { handle: map, view } + }; + // Desktop origin of this monitor's source — for the desktop→frame coordinate shift. + // SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned + // locals (same call the compose-kick path makes). + let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) }; + let origin = rect.map(|(x, y, _w, _h)| (x, y)).unwrap_or((0, 0)); + Ok(CursorShared { + section, + origin, + cached_id: 0, + cached: None, + }) + } + + /// The section handle for the broker's duplication into the WUDFHost. + pub(super) fn section_handle(&self) -> HANDLE { + HANDLE(self.section.handle.as_raw_handle()) + } + + /// Seqlock-read the driver's latest publish → a frame-relative [`pf_frame::CursorOverlay`]. + /// `None` until the first publish lands (or while the pointer has never been seen). A hidden + /// pointer returns `Some` with `visible: false` — the forwarder turns that into the client's + /// relative-mode hint, exactly like the Linux path. + pub(super) fn read(&mut self) -> Option { + let shm = self.section.ptr::(); + // SAFETY: the view spans CURSOR_SHM_SIZE for self's lifetime; seq is 4-aligned in the + // fixed layout (offset 4). + let seq = unsafe { &*std::ptr::addr_of!((*shm).seq).cast::() }; + for _ in 0..64 { + let s1 = seq.load(Ordering::Acquire); + if s1 == 0 { + return None; // no publish yet + } + if s1 & 1 != 0 { + std::hint::spin_loop(); + continue; // writer mid-update + } + // SAFETY: header reads within the mapped view; consistency is validated by the + // seq re-check below (a torn read is discarded and retried). + let hdr = unsafe { std::ptr::read_volatile(shm) }; + // Shape pixels: convert only when the OS minted a new shape id. + if hdr.visible != 0 && hdr.shape_id != self.cached_id { + let rows = hdr.height.min(CURSOR_SHAPE_MAX) as usize; + let width = hdr.width.min(CURSOR_SHAPE_MAX) as usize; + let pitch = (hdr.pitch as usize).min(CURSOR_SHAPE_BYTES / rows.max(1)); + let mut raw = vec![0u8; rows * pitch]; + // SAFETY: the shape region spans CURSOR_SHAPE_BYTES from CURSOR_SHAPE_OFFSET + // inside the mapped view; `rows * pitch` is clamped to it above. + unsafe { + std::ptr::copy_nonoverlapping( + self.section.ptr::().add(CURSOR_SHAPE_OFFSET), + raw.as_mut_ptr(), + rows * pitch, + ); + } + // Discard the copy if the writer raced us mid-shape (seq moved) — retry. + if seq.load(Ordering::Acquire) != s1 { + continue; + } + self.cached = Some(convert_shape(&hdr, &raw, width, rows, pitch)); + self.cached_id = hdr.shape_id; + } else if seq.load(Ordering::Acquire) != s1 { + continue; + } + let shape = self.cached.as_ref()?; + return Some(pf_frame::CursorOverlay { + x: hdr.x - self.origin.0, + y: hdr.y - self.origin.1, + w: shape.w, + h: shape.h, + rgba: shape.rgba.clone(), + serial: u64::from(hdr.shape_id), + hot_x: shape.hot_x, + hot_y: shape.hot_y, + visible: hdr.visible != 0, + }); + } + None // persistent tearing (writer wedged mid-seq) — skip this tick + } +} + +/// Convert the OS's 32-bpp pitch-strided shape rows into the overlay's packed straight RGBA. +/// ALPHA cursors are BGRA with straight per-pixel alpha (swap R↔B). MASKED_COLOR approximates: +/// alpha 0x00 = opaque color pixel; 0xFF = an XOR pixel we cannot honor client-side — rendered +/// as a translucent mid-gray so inversion cursors stay visible instead of vanishing. +fn convert_shape( + hdr: &CursorShm, + raw: &[u8], + width: usize, + rows: usize, + pitch: usize, +) -> ConvertedShape { + let masked = hdr.cursor_type == CURSOR_TYPE_MASKED_COLOR; + let mut rgba = Vec::with_capacity(width * rows * 4); + for y in 0..rows { + let row = &raw[y * pitch..]; + for x in 0..width { + let o = x * 4; + if o + 4 > row.len() { + rgba.extend_from_slice(&[0, 0, 0, 0]); + continue; + } + let (b, g, r, a) = (row[o], row[o + 1], row[o + 2], row[o + 3]); + if masked { + if a == 0 { + rgba.extend_from_slice(&[r, g, b, 0xFF]); + } else { + rgba.extend_from_slice(&[0x80, 0x80, 0x80, 0xB4]); + } + } else { + rgba.extend_from_slice(&[r, g, b, a]); + } + } + } + ConvertedShape { + rgba: std::sync::Arc::new(rgba), + w: width as u32, + h: rows as u32, + hot_x: hdr.hot_x.min(width.saturating_sub(1) as u32), + hot_y: hdr.hot_y.min(rows.saturating_sub(1) as u32), + } +} diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 0a5522bc..1912d45e 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -66,7 +66,15 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) { /// too ([`MIN_DRIVER_PROTOCOL_VERSION`]) and simply falls back to the re-arrival resize against it; /// a v4 driver serving an older (v3-asserting) host fails that host's strict handshake — ship /// driver+host together, as ever. -pub const PROTOCOL_VERSION: u32 = 4; +/// v5: ADDITIVE — the IddCx HARDWARE CURSOR channel (remote-desktop-sweep M2c): +/// [`control::AddRequest::hw_cursor`] (the former tail `_reserved` — same size, same offsets) +/// asks the driver to declare a hardware cursor for this monitor (DWM then EXCLUDES the pointer +/// from the desktop frame and delivers shape/position out-of-band), and +/// [`control::IOCTL_SET_CURSOR_CHANNEL`] delivers the host-created [`cursor::CursorShm`] section +/// the driver's cursor thread seqlock-publishes into. Nothing existing changed; the host gates +/// the feature on the handshake-reported version (`>= 5`) and keeps composited-cursor behavior +/// against older drivers. +pub const PROTOCOL_VERSION: u32 = 5; /// The OLDEST driver protocol this host still drives (v4 is additive over v3 — see the v4 note on /// [`PROTOCOL_VERSION`]): a v3 driver lacks only `IOCTL_UPDATE_MODES`, which the host gates on the @@ -110,6 +118,14 @@ pub mod control { /// identity (saved per-monitor DPI) and the driver's swap-chain/stash machinery survive. A v3 /// driver fails this unknown IOCTL → the host falls back to the re-arrival resize. pub const IOCTL_UPDATE_MODES: u32 = ctl_code(0x907); + /// Deliver a monitor's hardware-cursor channel (v5): the handle VALUE of the unnamed + /// [`cursor::CursorShm`](crate::cursor) file mapping the host duplicated into the WUDFHost + /// (same delivery model as [`IOCTL_SET_FRAME_CHANNEL`], no event — the host polls the + /// seqlock at its encode-tick pace). Sent once after ADD, only for a monitor whose + /// [`AddRequest::hw_cursor`] was set. The driver maps it, calls + /// `IddCxMonitorSetupHardwareCursor`, and starts its cursor-query thread. Input + /// [`SetCursorChannelRequest`]. + pub const IOCTL_SET_CURSOR_CHANNEL: u32 = ctl_code(0x908); /// `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 @@ -147,9 +163,13 @@ pub mod control { /// The client display's min luminance in MILLI-nits (0.001 cd/m² — the CTA min-luminance /// range lives well below 1 nit) → Desired Content Min Luminance. `0` = unknown. pub min_luminance_millinits: u32, - /// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding); - /// free expansion room for the next appended field. - pub _reserved: u32, + /// Non-zero = declare an IddCx HARDWARE CURSOR for this monitor (v5, remote-desktop-sweep + /// M2c): DWM stops compositing the pointer into the frame and the driver publishes + /// shape/position into the [`cursor::CursorShm`](crate::cursor) section delivered by + /// [`IOCTL_SET_CURSOR_CHANNEL`]. Byte-compatible with the old tail `_reserved` (offset 36): + /// an un-upgraded driver ignores it (cursor stays composited — the host already gates on + /// the handshake version, this is defense in depth), an un-upgraded host sends `0` (off). + pub hw_cursor: u32, } /// [`AddRequest`]'s size before the client-HDR luminance tail — the prefix an un-upgraded @@ -253,6 +273,18 @@ pub mod control { /// at the compile-time maximum; `ring_len` says how many entries are live). pub const RING_LEN_USIZE: usize = RING_LEN as usize; + /// `IOCTL_SET_CURSOR_CHANNEL` input (v5): the hardware-cursor section for one monitor. + #[repr(C)] + #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] + pub struct SetCursorChannelRequest { + /// The OS target id from [`AddReply`] — which monitor this channel belongs to. + pub target_id: u32, + pub _pad: u32, + /// The [`cursor::CursorShm`](crate::cursor) file-mapping handle VALUE, already duplicated + /// into the driver's WUDFHost process ([`AddReply::wudf_pid`]). + pub header_handle: u64, + } + // 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. @@ -269,6 +301,9 @@ pub mod control { assert!(offset_of!(AddRequest, max_luminance_nits) == ADD_REQUEST_LEGACY_SIZE); assert!(offset_of!(AddRequest, max_frame_avg_nits) == 28); assert!(offset_of!(AddRequest, min_luminance_millinits) == 32); + // v5: the former tail `_reserved` — same offset, same total size (rename-only). + assert!(offset_of!(AddRequest, hw_cursor) == 36); + assert!(size_of::() == 40); assert!(size_of::() == 20); assert!(offset_of!(AddReply, adapter_luid_low) == 0); @@ -288,6 +323,10 @@ pub mod control { assert!(size_of::() == 8); assert!(offset_of!(RemoveRequest, session_id) == 0); + assert!(size_of::() == 16); + assert!(offset_of!(SetCursorChannelRequest, target_id) == 0); + assert!(offset_of!(SetCursorChannelRequest, header_handle) == 8); + assert!(size_of::() == 24); assert!(offset_of!(UpdateModesRequest, session_id) == 0); assert!(offset_of!(UpdateModesRequest, width) == 8); @@ -960,6 +999,84 @@ pub mod mouse { }; } +/// The v5 hardware-cursor channel (remote-desktop-sweep M2c): one unnamed file mapping per +/// monitor, host-created, delivered by handle value ([`control::IOCTL_SET_CURSOR_CHANNEL`]). +/// The DRIVER's cursor thread (woken by its IddCx `hNewCursorDataAvailable` event) seqlock-writes +/// shape + position + visibility; the HOST reads at its encode-tick pace — no event crosses the +/// boundary. Writer: bump [`CursorShm::seq`] to ODD, write fields (+ shape bytes when the OS said +/// the shape changed), bump to EVEN. Reader: read seq (retry while odd), copy, re-read seq — +/// unchanged ⇒ consistent snapshot. Position-only updates never touch the shape bytes, so a +/// reader that skips unchanged `shape_id`s never copies torn pixels. +pub mod cursor { + use bytemuck::{Pod, Zeroable}; + + /// First field of [`CursorShm`] — `b"PFCU"` little-endian; anything else = not attached yet. + pub const CURSOR_MAGIC: u32 = u32::from_le_bytes(*b"PFCU"); + + /// Max cursor side (px) the driver declares to the OS (`IDDCX_CURSOR_CAPS::MaxX/MaxY`) and + /// the section's shape buffer is sized for. Windows XL accessibility cursors top out here; + /// the host's wire forwarder downscales to its own cap anyway. + pub const CURSOR_SHAPE_MAX: u32 = 256; + + /// Shape-buffer bytes: 32-bpp at the declared max. + pub const CURSOR_SHAPE_BYTES: usize = (CURSOR_SHAPE_MAX * CURSOR_SHAPE_MAX * 4) as usize; + + /// Byte offset of the shape pixels inside the section (one cache-line-ish header). + pub const CURSOR_SHAPE_OFFSET: usize = 64; + + /// Total section size. + pub const CURSOR_SHM_SIZE: usize = CURSOR_SHAPE_OFFSET + CURSOR_SHAPE_BYTES; + + /// `IDDCX_CURSOR_SHAPE_TYPE` values mirrored for the host (the driver writes the OS value + /// verbatim into [`CursorShm::cursor_type`]). + pub const CURSOR_TYPE_MASKED_COLOR: u32 = 1; + pub const CURSOR_TYPE_ALPHA: u32 = 2; + + /// The section header (the shape pixels follow at [`CURSOR_SHAPE_OFFSET`]). `x`/`y` are the + /// shape's TOP-LEFT in desktop coordinates (the IddCx `IDARG_OUT_QUERY_HWCURSOR::X/Y` + /// convention — position − hotspot, can be negative); `shape_id` is the OS's per-set counter + /// (bumps on every shape set, the overlay serial); pixels are the OS's 32-bpp rows at + /// `pitch` bytes (BGRA for ALPHA; color+mask for MASKED_COLOR — the host converts). + #[repr(C)] + #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] + pub struct CursorShm { + pub magic: u32, + /// Seqlock: odd = writer mid-update. + pub seq: u32, + pub visible: u32, + pub cursor_type: u32, + pub x: i32, + pub y: i32, + pub shape_id: u32, + pub width: u32, + pub height: u32, + pub pitch: u32, + pub hot_x: u32, + pub hot_y: u32, + /// Reserved expansion room up to [`CURSOR_SHAPE_OFFSET`]. + pub _reserved: [u32; 4], + } + + // Layout is load-bearing across the process boundary — pin it. + const _: () = { + use core::mem::{offset_of, size_of}; + assert!(size_of::() == 64); + assert!(size_of::() <= CURSOR_SHAPE_OFFSET); + assert!(offset_of!(CursorShm, magic) == 0); + assert!(offset_of!(CursorShm, seq) == 4); + assert!(offset_of!(CursorShm, visible) == 8); + assert!(offset_of!(CursorShm, cursor_type) == 12); + assert!(offset_of!(CursorShm, x) == 16); + assert!(offset_of!(CursorShm, y) == 20); + assert!(offset_of!(CursorShm, shape_id) == 24); + assert!(offset_of!(CursorShm, width) == 28); + assert!(offset_of!(CursorShm, height) == 32); + assert!(offset_of!(CursorShm, pitch) == 36); + assert!(offset_of!(CursorShm, hot_x) == 40); + assert!(offset_of!(CursorShm, hot_y) == 44); + }; +} + #[cfg(test)] mod tests { use super::*; @@ -1079,7 +1196,7 @@ mod tests { max_luminance_nits: 800, max_frame_avg_nits: 400, min_luminance_millinits: 50, // 0.05 nits - _reserved: 0, + hw_cursor: 1, }; let bytes = bytemuck::bytes_of(&req); assert_eq!(bytes.len(), 40); @@ -1161,11 +1278,39 @@ mod tests { req ); assert_eq!(bytes[8..12], 2560u32.to_le_bytes()); - // The compat window: v4 is additive over v3, so the host floor stays one below. - assert_eq!(PROTOCOL_VERSION, 4); + // The compat window: v4/v5 are additive over v3, so the host floor stays at 3. + assert_eq!(PROTOCOL_VERSION, 5); assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3); } + #[test] + fn cursor_shm_layout_is_pinned() { + use cursor::*; + // The header must leave the shape offset intact whatever grows inside `_reserved`. + assert_eq!(core::mem::size_of::(), 64); + assert_eq!(CURSOR_SHM_SIZE, 64 + 256 * 256 * 4); + assert_eq!(CURSOR_MAGIC, u32::from_le_bytes(*b"PFCU")); + // Seqlock snapshot discipline survives a bytemuck roundtrip. + let hdr = CursorShm { + magic: CURSOR_MAGIC, + seq: 2, + visible: 1, + cursor_type: CURSOR_TYPE_ALPHA, + x: -3, + y: 7, + shape_id: 42, + width: 32, + height: 32, + pitch: 128, + hot_x: 4, + hot_y: 5, + _reserved: [0; 4], + }; + let bytes = bytemuck::bytes_of(&hdr); + assert_eq!(*bytemuck::from_bytes::(bytes), hdr); + assert_eq!(bytes[16..20], (-3i32).to_le_bytes()); + } + #[test] fn gamepad_names_and_magics_are_stable() { assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0"); diff --git a/crates/pf-frame/src/lib.rs b/crates/pf-frame/src/lib.rs index 06140dc4..ab946b8d 100644 --- a/crates/pf-frame/src/lib.rs +++ b/crates/pf-frame/src/lib.rs @@ -152,6 +152,12 @@ pub struct OutputFormat { /// codec's backend, and the fallback family) would misread the two-plane buffer as packed /// RGB. Always `false` on Windows (the IDD-push capturer owns its own formats). pub nv12_native: bool, + /// The session negotiated the cursor-forward channel (remote-desktop-sweep M2c): on Windows + /// the IDD-push capturer creates + delivers the driver's hardware-cursor section, so DWM + /// stops compositing the pointer into the frames and the capturer surfaces it via + /// `Capturer::cursor()` instead. Ignored on Linux (the portal's `SPA_META_Cursor` already + /// separates the pointer; the session plan's `cursor_blend` gate handles the rest). + pub hw_cursor: bool, } impl OutputFormat { @@ -169,6 +175,8 @@ impl OutputFormat { chroma_444: false, // GameStream never negotiates PyroWave (native punktfunk/1 only). pyrowave: false, + // GameStream/spike sessions never negotiate the cursor channel. + hw_cursor: false, // Conservative: the GameStream + spike paths don't resolve the codec here, and a // Moonlight client may negotiate H264 (whose VAAPI backend can't ingest NV12) — so // they never prefer the producer-native NV12 pod. The punktfunk/1 plane opts in via diff --git a/crates/pf-vdisplay/src/vdisplay/backend.rs b/crates/pf-vdisplay/src/vdisplay/backend.rs index 49ce7830..0d804ddd 100644 --- a/crates/pf-vdisplay/src/vdisplay/backend.rs +++ b/crates/pf-vdisplay/src/vdisplay/backend.rs @@ -134,6 +134,13 @@ pub trait VirtualDisplay: Send { /// the backend's default EDID. Default: no-op — only the Windows pf-vdisplay backend can mint /// per-monitor EDIDs today (the Linux compositors' virtual outputs take no EDID from us). fn set_client_hdr(&mut self, _hdr: Option) {} + /// Ask the backend for an out-of-band HARDWARE CURSOR on the created output (the M2c cursor + /// channel): the compositor/OS stops compositing the pointer into captured frames and the + /// capture layer surfaces shape/position separately. Carried on the backend instance; set + /// once before [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay + /// backend (IddCx hardware cursor, driver proto v5) implements it; the Linux portal path + /// gets the same split from `SPA_META_Cursor` without asking. + fn set_hw_cursor(&mut self, _on: bool) {} /// The stable identity slot the backend resolved for the most recent [`create`](Self::create) — /// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The /// registry reads it right after `create` to key the display's group **arrangement** (manual diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index bc62ac21..197430cd 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -71,6 +71,9 @@ struct Monitor { /// selectable). The pin is never re-issued on reuse, so this is what the driver still renders /// on — [`warn_if_pick_moved`] compares the CURRENT pick against it. render_pin: Option, + /// 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 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 @@ -267,6 +270,20 @@ pub fn vdm() -> &'static VirtualDisplayManager { /// for the process lifetime — a dead one is RETIRED (kept alive, see [`DeviceSlot`]), so a stale copy /// can only fail IOCTLs, never dangle. `None` before the first backend open — impossible for a /// capturer, which only exists on a monitor the manager created. +/// Can this host's pf-vdisplay driver run the v5 hardware-cursor channel? Reads the +/// handshake-latched protocol version, opening the control device once if no session has +/// opened it yet this service run (the same open every session performs anyway) — so the +/// Welcome-time capability decision never guesses. `false` when the driver is missing/stale. +pub fn hw_cursor_capable() -> bool { + let m = vdm(); + let v = m.driver_proto.load(Ordering::Relaxed); + if v != 0 { + return v >= 5; + } + let _ = m.ensure_device(); + m.driver_proto.load(Ordering::Relaxed) >= 5 +} + pub fn control_device_handle() -> Option { VDM.get().and_then(VirtualDisplayManager::device_handle) } @@ -390,6 +407,7 @@ impl VirtualDisplayManager { mode: Mode, client_fp: Option<[u8; 32]>, client_hdr: Option, + hw_cursor: bool, quit: Option>, ) -> Result { // Console-session guard: a host outside the ACTIVE console session cannot drive the display @@ -622,7 +640,9 @@ impl VirtualDisplayManager { // SAFETY: `create_monitor` requires `dev` to be a valid control handle; `dev` is the handle // `ensure_device()` returned above (cached handles are never closed — a dead one is retired, // kept alive; see `DeviceSlot`), and we hold the `state` lock. - let mon = match unsafe { self.create_monitor(dev, mode, slot, client_hdr, &mut inner) } { + let mon = match unsafe { + self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner) + } { // The cached device died under us (driver upgrade / WUDFHost restart, detected only // now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and // retry ONCE so the reconnect-after-driver-restart succeeds first try instead of @@ -635,7 +655,7 @@ impl VirtualDisplayManager { ); // SAFETY: as above — `dev` is the handle the reopening `ensure_device` just // returned, and the `state` lock is still held. - unsafe { self.create_monitor(dev, mode, slot, client_hdr, &mut inner)? } + unsafe { self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)? } } r => r?, }; @@ -861,6 +881,7 @@ impl VirtualDisplayManager { mode: Mode, slot: u32, client_hdr: Option, + hw_cursor: bool, inner: &mut MgrInner, ) -> Result { // The slot id doubles as the driver-preferred monitor id (EDID serial / ConnectorIndex), so @@ -868,13 +889,17 @@ impl VirtualDisplayManager { // `0` (anonymous) = the driver auto-allocates the lowest-free id. let preferred_id = slot; let render_pin = resolve_render_pin(); + // Hardware cursor only against a driver that implements the v5 channel: an older driver + // ignores the AddRequest field anyway (composited cursor), but gating here keeps the + // capture layer from creating + delivering a section nobody will ever publish into. + let hw_cursor = hw_cursor && self.driver_proto.load(Ordering::Relaxed) >= 5; // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that. // `render_pin` is an `Option` by value (plain `Copy`), so no borrowed memory // crosses the call. let added = unsafe { self.driver - .add_monitor(dev, mode, render_pin, preferred_id, client_hdr)? + .add_monitor(dev, mode, render_pin, preferred_id, client_hdr, hw_cursor)? }; // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. @@ -1061,6 +1086,7 @@ impl VirtualDisplayManager { resolved_monitor_id: added.resolved_monitor_id, position: (0, 0), gen: self.gen.fetch_add(1, Ordering::Relaxed), + hw_cursor, }) } @@ -1231,7 +1257,7 @@ impl VirtualDisplayManager { // values passed by value — no borrow crosses the call. let added = unsafe { self.driver - .add_monitor(dev, mode, render_pin, slot, client_hdr) + .add_monitor(dev, mode, render_pin, slot, client_hdr, old.hw_cursor) .context("re-arrival ADD at the new mode")? }; self.ensure_pinger(); @@ -1283,6 +1309,7 @@ impl VirtualDisplayManager { resolved_monitor_id: added.resolved_monitor_id, position: old.position, gen: old.gen, + hw_cursor: old.hw_cursor, }) } diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs index ccc8f40d..f8f13648 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs @@ -56,6 +56,7 @@ pub(crate) trait VdisplayDriver: Send + Sync { render_luid: Option, preferred_monitor_id: u32, client_hdr: Option, + hw_cursor: bool, ) -> Result; /// Refresh the LIVE monitor `key`'s advertised mode list to lead with `mode` (the in-place /// mid-stream resize, latency plan P2 — pf-vdisplay `IOCTL_UPDATE_MODES`, driver protocol v4). diff --git a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs index 3a7f6e15..1451683c 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs @@ -234,6 +234,30 @@ pub unsafe fn send_frame_channel(dev: HANDLE, req: &control::SetFrameChannelRequ .context("pf-vdisplay SET_FRAME_CHANNEL") } +/// Deliver a monitor's hardware-cursor section (`IOCTL_SET_CURSOR_CHANNEL`, proto v5) — the +/// cursor sibling of [`send_frame_channel`], same delivery/ownership contract. +/// +/// # Safety +/// `dev` must be a live pf-vdisplay control handle (see [`super::manager::control_device_handle`]). +pub unsafe fn send_cursor_channel( + dev: HANDLE, + req: &control::SetCursorChannelRequest, +) -> 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 across this synchronous call; no output buffer. + unsafe { + ioctl( + dev, + control::IOCTL_SET_CURSOR_CHANNEL, + bytemuck::bytes_of(req), + &mut none, + ) + } + .map(|_| ()) + .context("pf-vdisplay SET_CURSOR_CHANNEL") +} + /// RAII over a SetupAPI device-info list: every exit path of [`open_device`] destroys it (the error /// paths used to leak one `HDEVINFO` per failed open — and a driverless / mid-upgrade box probes /// repeatedly). @@ -460,6 +484,7 @@ impl VdisplayDriver for PfVdisplayDriver { render_luid: Option, preferred_monitor_id: u32, client_hdr: Option, + hw_cursor: bool, ) -> Result { let session_id = next_session_id(); // The client display's volume rides into the monitor's EDID CTA HDR block; all-zero = @@ -485,7 +510,11 @@ impl VdisplayDriver for PfVdisplayDriver { max_luminance_nits, max_frame_avg_nits, min_luminance_millinits, - _reserved: 0, + // v5 cursor channel: the driver declares an IddCx hardware cursor for this monitor + // (DWM stops compositing the pointer into the frame); the capture layer delivers the + // CursorShm section right after its ring. Zero toward older drivers is harmless — + // the host only sets this when the handshake-reported proto is >= 5. + hw_cursor: hw_cursor as u32, }; // SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports // its real render LUID in the shared header, so the host binds correctly even if this is ignored. @@ -682,6 +711,10 @@ pub struct PfVdisplayDisplay { /// freshly created monitor's EDID advertises this volume so host apps tone-map to the client's /// real panel. client_hdr: Option, + /// Declare an IddCx hardware cursor on the created monitor (the M2c cursor channel). Set by + /// [`set_hw_cursor`](VirtualDisplay::set_hw_cursor) before `create`; only honored when the + /// driver handshake reported proto >= 5. + hw_cursor: bool, /// The session's deliberate-quit flag (`None` = no signal → the linger policy applies). Set by /// [`set_quit_flag`](VirtualDisplay::set_quit_flag) before `create`; rides into every lease this /// backend mints so a user "stop" tears the monitor down immediately instead of lingering. @@ -694,6 +727,7 @@ impl PfVdisplayDisplay { Ok(Self { client_fp: None, client_hdr: None, + hw_cursor: false, quit: None, }) } @@ -712,12 +746,22 @@ impl VirtualDisplay for PfVdisplayDisplay { self.client_hdr = hdr; } + fn set_hw_cursor(&mut self, on: bool) { + self.hw_cursor = on; + } + fn set_quit_flag(&mut self, quit: std::sync::Arc) { self.quit = Some(quit); } fn create(&mut self, mode: Mode) -> Result { - super::manager::vdm().acquire(mode, self.client_fp, self.client_hdr, self.quit.clone()) + super::manager::vdm().acquire( + mode, + self.client_fp, + self.client_hdr, + self.hw_cursor, + self.quit.clone(), + ) } } @@ -804,17 +848,12 @@ mod tests { // Live-run diagnostics: surface the manager/backend tracing (activation ladder, settle // waits, UPDATE_MODES) on stdout — a bare test harness has no subscriber, which made the // first on-glass run blind. - let _ = tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".into()), - ) - .try_init(); + // (tracing-subscriber is not a dep of this crate — run the host binary for traced runs.) // Context probe: can this process see the CCD active-path set at all? (`None` = the query // itself fails in this session/window-station — the whole ladder would be blind, and a // "monitor never activated" verdict would be an artifact of the test context.) // SAFETY: CCD query over an owned empty slice (test-only diagnostics). - let active0 = unsafe { crate::win_display::count_other_active(&[]) }; + let active0 = unsafe { pf_win_display::win_display::count_other_active(&[]) }; println!("spike: CCD active paths visible before create: {active0:?}"); let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay"); let first = vd @@ -847,7 +886,7 @@ mod tests { .target_id; let in_place = t1 == t2; // SAFETY: CCD query over a Copy target id (test-only diagnostics). - let active = unsafe { crate::win_display::active_resolution(t2) }; + let active = unsafe { pf_win_display::win_display::active_resolution(t2) }; println!( "in-place resize spike: in_place={in_place} (target {t1} -> {t2}) took {resize_ms} ms, \ active resolution now {active:?}" diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index efa2a83a..31afcf06 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -157,6 +157,23 @@ pub fn capture_virtual_output( // proactively enables advanced color and selects the per-frame conversion. There is NO fallback: // if it can't open or the driver doesn't attach, the session fails cleanly and the client // reconnects. + // Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure — + // its presence opts the session in (the capturer creates + delivers the CursorShm section, + // the driver declares the IddCx hardware cursor). Built exactly like `sender` above. + let cursor_sender: Option = want.hw_cursor.then(|| { + std::sync::Arc::new( + move |req: &pf_driver_proto::control::SetCursorChannelRequest| { + // SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is + // never closed for the process lifetime (`send_cursor_channel`'s precondition). + unsafe { + crate::vdisplay::driver::send_cursor_channel( + windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void), + req, + ) + } + }, + ) as pf_capture::CursorChannelSender + }); pf_capture::open_idd_push( target, pref, @@ -165,6 +182,7 @@ pub fn capture_virtual_output( want.pyrowave, keep, sender, + cursor_sender, ) .map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)")) } diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 6d3aa2d9..22181b48 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -10,18 +10,35 @@ use super::*; /// Whether this session forwards the cursor out-of-band (design/remote-desktop-sweep.md M2): /// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the -/// capture path can deliver cursor metadata separately from the frame — today that is the -/// Linux portal `SPA_META_Cursor` path only: not gamescope (its capture paints no cursor at -/// all), not Windows (DWM composites into the IDD frame — M2c). THE single predicate: the -/// Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off wiring both read it, -/// so they can never disagree. +/// capture path can deliver cursor metadata separately from the frame — the Linux portal +/// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows +/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c). THE single +/// predicate: the Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off +/// wiring both read it, so they can never disagree. pub(super) fn cursor_forward( client_caps: u8, compositor: Option, ) -> bool { - cfg!(target_os = "linux") - && client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR != 0 - && compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope) + if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 { + return false; + } + #[cfg(target_os = "linux")] + { + compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope) + } + #[cfg(target_os = "windows")] + { + // Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel — + // DWM composites the pointer into the IDD frame otherwise, and forwarding a second + // copy would double it. The probe latches by opening the control device once. + let _ = compositor; + crate::vdisplay::manager::hw_cursor_capable() + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + let _ = compositor; + false + } } /// Run the Hello→Welcome→Start negotiation. Borrows the control streams (the caller keeps them for @@ -510,6 +527,9 @@ pub(super) async fn negotiate( let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::(1); let client_identity = endpoint::peer_fingerprint(conn); let client_hdr = hello.display_hdr; + // Same predicate the Welcome's HOST_CAP_CURSOR bit used — the prepared display and + // the session wiring must agree with what we just advertised. + let cursor_fw = cursor_forward(hello.client_caps, Some(comp)); let (mode, shard_payload) = (hello.mode, welcome.shard_payload); let trace = bringup.clone(); std::thread::Builder::new() @@ -520,6 +540,7 @@ pub(super) async fn negotiate( mode, client_identity, client_hdr, + cursor_fw, bitrate_kbps, bit_depth, chroma, diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 8fbc0b84..39230eec 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -999,6 +999,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option