feat(windows): IddCx hardware-cursor channel — remote-desktop sweep M2c

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:43:23 +02:00
co-authored by Claude Fable 5
parent c3cbffe662
commit 3a34440a6b
21 changed files with 1009 additions and 36 deletions
+21
View File
@@ -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<pf_frame::CursorOverlay> {
None
}
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
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<dyn Send>,
sender: FrameChannelSender,
cursor_sender: Option<CursorChannelSender>,
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
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<dyn Capturer>)
}
+80 -1
View File
@@ -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<cursor::CursorShared>,
width: u32,
height: u32,
slots: Vec<HostSlot>,
@@ -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<dyn Send>,
sender: crate::FrameChannelSender,
cursor_sender: Option<crate::CursorChannelSender>,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
// 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<crate::CursorChannelSender>,
) -> Result<Self> {
// 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<crate::CursorChannelSender>,
) -> Result<Self> {
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<pf_frame::CursorOverlay> {
self.cursor_shared.as_mut().and_then(|c| c.read())
}
fn next_frame(&mut self) -> Result<CapturedFrame> {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
@@ -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<u64> {
// 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) {
@@ -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<ConvertedShape>,
}
struct ConvertedShape {
rgba: std::sync::Arc<Vec<u8>>,
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<CursorShared> {
// 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::<CursorShm>();
std::ptr::write_bytes(view.Value.cast::<u8>(), 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<pf_frame::CursorOverlay> {
let shm = self.section.ptr::<CursorShm>();
// 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::<AtomicU32>() };
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::<u8>().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),
}
}
+152 -7
View File
@@ -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::<AddRequest>() == 40);
assert!(size_of::<AddReply>() == 20);
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
@@ -288,6 +323,10 @@ pub mod control {
assert!(size_of::<RemoveRequest>() == 8);
assert!(offset_of!(RemoveRequest, session_id) == 0);
assert!(size_of::<SetCursorChannelRequest>() == 16);
assert!(offset_of!(SetCursorChannelRequest, target_id) == 0);
assert!(offset_of!(SetCursorChannelRequest, header_handle) == 8);
assert!(size_of::<UpdateModesRequest>() == 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::<CursorShm>() == 64);
assert!(size_of::<CursorShm>() <= 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::<CursorShm>(), 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::<CursorShm>(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");
+8
View File
@@ -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
@@ -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<punktfunk_core::quic::HdrMeta>) {}
/// 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
@@ -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<LUID>,
/// 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<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle)
}
@@ -390,6 +407,7 @@ impl VirtualDisplayManager {
mode: Mode,
client_fp: Option<[u8; 32]>,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
hw_cursor: bool,
quit: Option<Arc<AtomicBool>>,
) -> Result<VirtualOutput> {
// 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<punktfunk_core::quic::HdrMeta>,
hw_cursor: bool,
inner: &mut MgrInner,
) -> Result<Monitor> {
// 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<LUID>` 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,
})
}
@@ -56,6 +56,7 @@ pub(crate) trait VdisplayDriver: Send + Sync {
render_luid: Option<LUID>,
preferred_monitor_id: u32,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
hw_cursor: bool,
) -> Result<AddedMonitor>;
/// 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).
@@ -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<LUID>,
preferred_monitor_id: u32,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
hw_cursor: bool,
) -> Result<AddedMonitor> {
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<punktfunk_core::quic::HdrMeta>,
/// 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<std::sync::atomic::AtomicBool>) {
self.quit = Some(quit);
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
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:?}"
+18
View File
@@ -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<pf_capture::CursorChannelSender> = 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)"))
}
+29 -8
View File
@@ -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<crate::vdisplay::Compositor>,
) -> 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::<SessionContext>(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,
+18 -4
View File
@@ -999,6 +999,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// the client is not drawing it locally (the M2 cursor channel — blending too would
// show it twice).
ctx.compositor != pf_vdisplay::Compositor::Gamescope && !ctx.cursor_forward,
ctx.cursor_forward,
);
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
@@ -1089,6 +1090,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// panel instead of the driver's built-in ~1000-nit placeholder. No-op on Linux
// backends and for older/SDR clients.
vd.set_client_hdr(client_hdr);
// Cursor-forward sessions ask the backend for an out-of-band hardware cursor
// (Windows pf-vdisplay / IddCx; no-op on Linux — the portal already separates it).
vd.set_hw_cursor(cursor_forward);
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the
// backend mints — the retry-hold below AND the capturer's — carries the session's quit
// flag, so a user "stop" (⌘D → the QUIT close code) tears the virtual monitor down the
@@ -2007,10 +2011,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
}
// Cursor channel (M2): every iteration — new frame OR repeat — states the pointer
// (self-healing under datagram loss) and forwards a changed shape via the control
// bridge. `frame` is the newest bound frame either way. A hidden-but-known pointer
// (overlay with `visible: false`) is the M3 relative-mode hint.
// bridge. A hidden-but-known pointer (overlay with `visible: false`) is the M3
// relative-mode hint. The capturer's LIVE cursor (the Windows hardware-cursor channel,
// where pointer-only moves produce no frame) outranks the frame-attached overlay
// (the Linux portal path); `frame` is the newest bound frame either way.
if let Some(fwd) = cursor_fwd.as_mut() {
fwd.tick(frame.cursor.as_ref(), &conn, &cursor_shape_tx);
let live = capturer.cursor();
fwd.tick(
live.as_ref().or(frame.cursor.as_ref()),
&conn,
&cursor_shape_tx,
);
}
// The overlay now surfaces hidden pointers too (for the hint above) — strip them
// HERE, after forwarding, so no blend path ever draws an invisible cursor.
@@ -2675,6 +2686,7 @@ pub(super) fn prepare_display(
mode: punktfunk_core::Mode,
client_identity: Option<[u8; 32]>,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
cursor_forward: bool,
bitrate_kbps: u32,
bit_depth: u8,
chroma: crate::encode::ChromaFormat,
@@ -2691,7 +2703,8 @@ pub(super) fn prepare_display(
bit_depth,
chroma,
codec,
compositor != pf_vdisplay::Compositor::Gamescope,
compositor != pf_vdisplay::Compositor::Gamescope && !cursor_forward,
cursor_forward,
);
if codec == crate::encode::Codec::PyroWave {
plan.wire_chunk = Some(shard_payload as usize);
@@ -2699,6 +2712,7 @@ pub(super) fn prepare_display(
let mut vd = crate::vdisplay::open(compositor)?;
vd.set_client_identity(client_identity);
vd.set_client_hdr(client_hdr);
vd.set_hw_cursor(cursor_forward);
vd.set_quit_flag(quit.clone());
// Slot-scoped setup serialization + reconnect preempt — see the inline arm in
// `virtual_stream` for the full rationale; released when this fn returns.
@@ -108,6 +108,10 @@ pub struct SessionPlan {
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
/// blending path when this is set, so the pointer never silently vanishes from the stream.
pub cursor_blend: bool,
/// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
/// hardware cursor up via [`OutputFormat::hw_cursor`](pf_frame::OutputFormat).
pub cursor_forward: bool,
}
impl SessionPlan {
@@ -118,6 +122,7 @@ impl SessionPlan {
chroma: crate::encode::ChromaFormat,
codec: crate::encode::Codec,
cursor_blend: bool,
cursor_forward: bool,
) -> Self {
SessionPlan {
capture: CaptureBackend::resolve(),
@@ -129,6 +134,7 @@ impl SessionPlan {
codec,
wire_chunk: None,
cursor_blend,
cursor_forward,
}
}
@@ -171,6 +177,7 @@ impl SessionPlan {
crate::capture::OutputFormat {
gpu,
hdr: self.hdr,
hw_cursor: self.cursor_forward,
// 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the
// default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4.
chroma_444: self.chroma.is_444(),