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:
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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:?}"
|
||||
|
||||
@@ -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)"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user