fix(vdisplay/capture): channel-less sessions composite the pointer on a sticky-declared target

A declared IddCx hardware cursor is IRREVOCABLE for its OS target's life
(§8.6), and the sticky exclusion survives monitor REMOVE→ADD because each
client gets a STABLE target id — so once any desktop-mode session declared,
every later pure-capture session on that target streamed a cursor-less
desktop: DWM excluded the pointer, no channel forwarded it, no blend drew it
(the exact no-regression gap §8.6's per-session cap gate cannot see).

Driver: track every successful SetupHardwareCursor per target
(DECLARED_TARGETS — scoped to the WUDFHost's life, exactly the sticky
state's scope) and report it in a new AddReply::cursor_excluded tail field
(dual-size discipline, both skews degrade cleanly; no proto bump).

Host: the flag rides AddedMonitor → Monitor → WinCaptureTarget; a session
WITHOUT the cursor channel on a flagged target forces composite mode in the
IDD-push capturer — GDI poller + blend for the session's life, pinned on
(set_cursor_forward cannot clear it: with no client drawing, un-compositing
would erase the pointer entirely).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:47:53 +02:00
co-authored by Claude Fable 5
parent d5ae8dcc3e
commit 7058647264
9 changed files with 157 additions and 20 deletions
@@ -165,9 +165,14 @@ unsafe fn add(request: WDFREQUEST) {
// This WUDFHost's pid — where the host duplicates the sealed frame channel's handles INTO
// (`ProcessSharingDisabled`: this process is exclusively ours and dies with the device).
wudf_pid: std::process::id(),
// The target already carries an irrevocable hardware-cursor declare from an earlier
// session — a channel-less session must self-composite the pointer (§8.6 gap).
cursor_excluded: crate::monitor::target_declared(target_id) as u32,
};
// Dual-size reply (the `cursor_excluded` tail ext): an un-upgraded host retrieves only the
// legacy 20-byte buffer — write the prefix it asked for instead of failing its ADD.
// SAFETY: `request` is the framework WDFREQUEST.
unsafe { write_output_complete(request, &reply) };
unsafe { write_output_prefix_complete(request, &reply, control::ADD_REPLY_LEGACY_SIZE) };
}
/// `IOCTL_SET_FRAME_CHANNEL`: adopt the handle values the host duplicated into this process and stash
@@ -356,6 +361,18 @@ unsafe fn read_input<T: Copy>(request: WDFREQUEST) -> Option<T> {
/// # Safety
/// `request` is the framework `WDFREQUEST`.
unsafe fn write_output_complete<T: Copy>(request: WDFREQUEST, value: &T) {
// SAFETY: forwarded per this function's own contract; min = the full struct.
unsafe { write_output_prefix_complete(request, value, core::mem::size_of::<T>()) }
}
/// [`write_output_complete`] for a reply with an APPENDED tail field (the `AddReply
/// cursor_excluded` dual-size discipline): accepts any output buffer of at least `min_size`
/// bytes and writes `min(buffer, size_of::<T>())` bytes of the struct — an un-upgraded host
/// retrieving only the legacy prefix gets exactly that prefix instead of a failed IOCTL.
///
/// # Safety
/// `request` is the framework `WDFREQUEST`.
unsafe fn write_output_prefix_complete<T: Copy>(request: WDFREQUEST, value: &T, min_size: usize) {
let mut buf: *mut core::ffi::c_void = core::ptr::null_mut();
let mut len: usize = 0;
// SAFETY: `request` valid; `buf`/`len` are out-params written by the framework.
@@ -363,7 +380,7 @@ unsafe fn write_output_complete<T: Copy>(request: WDFREQUEST, value: &T) {
call_unsafe_wdf_function_binding!(
WdfRequestRetrieveOutputBuffer,
request,
core::mem::size_of::<T>(),
min_size,
&mut buf,
&mut len
)
@@ -372,9 +389,13 @@ unsafe fn write_output_complete<T: Copy>(request: WDFREQUEST, value: &T) {
complete(request, st);
return;
}
// SAFETY: `buf` has >= size_of::<T>() writable bytes; T is a Pod control struct.
unsafe { buf.cast::<T>().write_unaligned(*value) };
complete_info(request, STATUS_SUCCESS, core::mem::size_of::<T>());
let take = len.min(core::mem::size_of::<T>());
// SAFETY: the framework guarantees `buf` has >= `len` writable bytes and `take <= len`;
// T is a Pod control struct, so any byte prefix of it is valid to copy out.
unsafe {
core::ptr::copy_nonoverlapping((value as *const T).cast::<u8>(), buf.cast::<u8>(), take);
}
complete_info(request, STATUS_SUCCESS, take);
}
/// Complete a request with just a status (no output).
@@ -199,6 +199,35 @@ fn cursor_forward_desired(target_id: u32) -> bool {
.unwrap_or(true)
}
/// OS target ids on which `IddCxMonitorSetupHardwareCursor` ever SUCCEEDED. A declare is
/// IRREVOCABLE for the target's life (no un-declare DDI; empty-caps re-setup is rejected; even a
/// successful same-mode re-commit never brings DWM's composited pointer back — all proven
/// on-glass, remote-desktop-sweep §8.6) and it survives monitor REMOVE→ADD because the host hands
/// each client a STABLE target id. Reported back on every ADD
/// ([`AddReply::cursor_excluded`](pf_driver_proto::control::AddReply)) so a session that never
/// negotiates the cursor channel knows it must self-composite the pointer instead of streaming a
/// cursor-less desktop. Never cleared: the state's true scope is this WUDFHost's life
/// (`ProcessSharingDisabled` — the process, and with it the adapter and every sticky declare,
/// dies on adapter reset), which is exactly when this static resets too. Bounded: ≤ 16 ids.
static DECLARED_TARGETS: Mutex<Vec<u32>> = Mutex::new(Vec::new());
/// Record a SUCCESSFUL hardware-cursor declare on `target_id` (see [`DECLARED_TARGETS`]).
fn mark_declared(target_id: u32) {
let mut declared = DECLARED_TARGETS.lock().unwrap_or_else(|e| e.into_inner());
if !declared.contains(&target_id) {
declared.push(target_id);
dbglog!("[pf-vd] cursor: target {target_id} marked hardware-cursor declared (irrevocable)");
}
}
/// True if `target_id` ever had a hardware cursor declared (see [`DECLARED_TARGETS`]).
pub fn target_declared(target_id: u32) -> bool {
DECLARED_TARGETS
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&target_id)
}
/// Record a departing monitor's advertised list for its id ([`MODE_HISTORY`]).
fn remember_modes(id: u32, modes: &[Mode]) {
let mut hist = MODE_HISTORY.lock().unwrap_or_else(|e| e.into_inner());
@@ -432,6 +461,10 @@ pub fn set_cursor_channel(
// the dispatch layer is wrong here — the handles are gone, so this is a plain failure.
return Ok(()); // adopted (handles consumed); the host detects no-publish and moves on
};
if declare {
// The worker only spawns after `IddCxMonitorSetupHardwareCursor` succeeded.
mark_declared(target_id);
}
let mut lock = lock_monitors();
if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) {
m.cursor_worker = Some(worker);
@@ -535,12 +568,18 @@ pub fn resetup_cursor(object: iddcx::IDDCX_MONITOR) {
// A composite-mode monitor (`cursor_forward_on == false`, the mid-stream flip) must
// NOT re-declare — the commit's software-cursor default is exactly what it wants.
.filter(|m| m.cursor_forward_on)
.and_then(|m| m.cursor_worker.as_ref())
.map(|w| w.data_event())
.and_then(|m| {
m.cursor_worker
.as_ref()
.map(|w| (w.data_event(), m.target_id))
})
};
if let Some(ev) = data_event {
if let Some((ev, target_id)) = data_event {
let st = crate::cursor_worker::setup_hardware_cursor(object, ev);
dbglog!("[pf-vd] cursor: re-setup on swap-chain assign -> {st:#x}");
if wdk_iddcx::nt_success(st) {
mark_declared(target_id);
}
}
}
@@ -591,6 +630,9 @@ pub fn set_cursor_forward(target_id: u32, enable: bool) -> bool {
// Enable declares immediately against the live worker's event (works any time).
let st = crate::cursor_worker::setup_hardware_cursor(object, ev);
dbglog!("[pf-vd] cursor: forward flip enable=1 (declare) -> {st:#x}");
if wdk_iddcx::nt_success(st) {
mark_declared(target_id);
}
}
(false, _, _) => {
// There is NO un-declare DDI: re-issuing the setup with empty caps is rejected