Files
punktfunk/crates/pf-capture/src/windows/idd_push.rs
T
enricobuehlerandClaude Fable 5 8362d57001
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m40s
apple / swift (push) Successful in 5m9s
ci / bench (push) Successful in 6m36s
ci / rust-arm64 (push) Successful in 10m12s
deb / build-publish (push) Successful in 9m32s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
android / android (push) Canceled after 13m40s
apple / screenshots (push) Canceled after 8m25s
arch / build-publish (push) Canceled after 13m44s
ci / rust (push) Canceled after 13m45s
deb / build-publish-host (push) Canceled after 12m5s
deb / build-publish-client-arm64 (push) Canceled after 7m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 2m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1s
windows-host / package (push) Canceled after 13m45s
fix(vdisplay/windows): exclusive topology gets re-asserted — a verified isolate is not durable
The CCD isolate verifies "sole active desktop" at commit time and is never
checked again. On a hybrid Intel+NVIDIA box the internal panel is re-activated
moments later (the isolated topology is deliberately not saved to the CCD
database so teardown can restore the user's layout, and the post-isolate
resize/HDR churn — or the panel's own driver, or display-poller software —
re-resolves back to the stored layout). Proven on-glass: seconds after the
verify, CCD showed the panel ACTIVE beside the virtual display while the host
still believed it was exclusive — cursor and windows can land off-stream, and
the lock screen can land on the physical panel.

A group-scoped watchdog now re-queries every PUNKTFUNK_EXCLUSIVE_REASSERT_MS
(default 2 s, 0 disables) while an EXCLUSIVE isolate is live and re-issues the
isolate when a non-managed display crept back, logging who-is-fighting
escalation (3×WARN → 1×ERROR → DEBUG). Gated on a new GroupState.ccd_exclusive
flag so a Primary group's deliberately-lit panels are never "fixed". Cycles
take the state lock via try_lock with a sliced sleep, so teardown's stop+join
under the state lock cannot deadlock and is bounded by ~250 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:53:19 +02:00

2021 lines
109 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! P2 direct frame push (kill DDA) — HOST side, over the **sealed channel**
//! (`design/idd-push-security.md`). The frame channel carries whole-desktop pixels, so its protection
//! must match DDA's (where capturer and consumer are one process and there is no openable channel at
//! all): the HOST (SYSTEM) creates the shared header + frame-ready event + ring of keyed-mutex textures
//! **UNNAMED** on the discrete render GPU — nothing to enumerate, open by name, or pre-create
//! ("squat") — then DUPLICATES the handles into the pf-vdisplay driver's WUDFHost process
//! ([`ChannelBroker`]; SYSTEM can `DuplicateHandle` into the LocalService host, the reverse is
//! correctly denied, which is why the HOST is the broker) and delivers the handle VALUES over the
//! SYSTEM-only control device (`IOCTL_SET_FRAME_CHANNEL`). A handle value is meaningless outside the
//! target process's handle table, so the bootstrap's ACL is not load-bearing; the only way to reach the
//! frames is to already be one of the two endpoint processes. The driver copies frames in; we consume
//! the ring straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook.
//! The SOLE Windows capture path. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
//! `DRV_STATUS_*` codes, the channel-delivery struct and the publish token all come from
//! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so
//! drift is a compile error rather than a "must match" comment.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, PyroFrameShare, VideoConverter,
WinCaptureTarget,
};
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
use anyhow::{bail, Context, Result};
use pf_driver_proto::{control, frame};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::{w, Interface, PCWSTR, PWSTR};
use windows::Win32::Foundation::{
DuplicateHandle, LocalFree, DUPLICATE_CLOSE_SOURCE, DUPLICATE_HANDLE_OPTIONS,
DUPLICATE_SAME_ACCESS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0,
};
use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence,
ID3D11RenderTargetView, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET,
D3D11_BIND_SHADER_RESOURCE, D3D11_FENCE_FLAG_SHARED, D3D11_RESOURCE_MISC_SHARED,
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
};
use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM,
DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
};
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
};
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
use windows::Win32::System::Threading::{
CreateEventW, GetCurrentProcess, OpenProcess, QueryFullProcessImageNameW, WaitForSingleObject,
PROCESS_DUP_HANDLE, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_MOVE, MOUSEINPUT,
};
use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos};
// The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
// `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides
// `use` it, so a layout/code drift is a compile error (the proto has `const` size asserts).
use frame::{
unpack_opened_detail, SharedHeader, DRV_STATUS_BIND_FAIL, DRV_STATUS_NONE,
DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION,
};
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not
/// part of the proto contract — it is a DXGI sharing-API arg, mirrored on the driver side).
const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
/// Least access the driver needs on the duplicated **header section**: map it read/write (it reads the
/// layout + writes `driver_status`/`driver_render_luid`/the publish token). `SECTION_MAP_READ |
/// SECTION_MAP_WRITE` (== the driver's `FILE_MAP_READ | FILE_MAP_WRITE` map flag). Duplicating with
/// exactly this — instead of `DUPLICATE_SAME_ACCESS`, which would copy the host's full-access creator
/// handle — is the "grant least privilege" discipline for unnamed shared objects (Raymond Chen,
/// *"unnamed objects aren't safe just because they're unnamed"*): a compromised driver's handle can't
/// `WRITE_DAC`/`WRITE_OWNER`/`DELETE` the object, only map it.
const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
/// Least access the driver needs on the duplicated **frame-ready event**: it only `SetEvent`s it, which
/// requires `EVENT_MODIFY_STATE`. (The host holds `SYNCHRONIZE` on its own handle to wait.)
const EVENT_MODIFY_STATE: u32 = 0x0002;
/// Host-owned output-ring depth: distinct NVENC-input textures rotated per frame so the in-flight
/// encode of frame N and the convert/copy of frame N+1 never touch the same texture. 3 covers a
/// pipeline depth of 2 with one slot of margin.
const OUT_RING: usize = 3;
/// Monotonic per-process generation stamped into the header + every publish token, so the host rejects
/// a stale-ring publish and the driver detects a recreate. (With unnamed textures there is no name
/// collision to avoid — the generation's remaining job is the recreate/stale-publish handshake.)
static IDD_GENERATION: AtomicU32 = AtomicU32::new(1);
/// Mint the next ring generation: masked to [`frame::FrameToken::GENERATION_MASK`] and never `0`.
///
/// [`IDD_GENERATION`] is a full `u32`, but the publish token carries only 24 bits of it and
/// `FrameToken::unpack` masks what it reads — so an unmasked `self.generation` stops matching ANY
/// token past 2²⁴ recreates and `try_consume`'s `tok.generation != self.generation` becomes
/// permanently true: every frame rejected, forever. `0` is skipped because it is also the
/// cleared-`latest` sentinel [`IddPushCapturer::recreate_ring`] stores. Latent — 2²⁴ recreates is a
/// long session — but the invariant belongs at the single mint point, not in the comparison.
fn next_generation() -> u32 {
loop {
let g = IDD_GENERATION.fetch_add(1, Ordering::Relaxed) & frame::FrameToken::GENERATION_MASK;
if g != 0 {
return g;
}
}
}
fn now_ns() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
/// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d,
/// THEN the [`OwnedHandle`] closes the underlying mapping object (order matters — unmap before close).
/// A `header` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must
/// outlive it (it's declared before it in [`IddPushCapturer`], and moving the section doesn't move the
/// OS mapping, so the borrowed pointer stays valid).
struct MappedSection {
handle: OwnedHandle,
view: MEMORY_MAPPED_VIEW_ADDRESS,
}
impl MappedSection {
/// The mapped view base as a `*mut T` (a borrow into the section; valid only while it lives).
fn ptr<T>(&self) -> *mut T {
self.view.Value as *mut T
}
}
impl Drop for MappedSection {
fn drop(&mut self) {
// SAFETY: `view` is the live view we created with `MapViewOfFile` and have not yet unmapped;
// unmap it BEFORE `handle` (the OwnedHandle) closes the mapping object — order matters.
unsafe {
let _ = UnmapViewOfFile(self.view);
}
}
}
struct HostSlot {
tex: ID3D11Texture2D,
mutex: IDXGIKeyedMutex,
/// The UNNAMED shared-resource NT handle: keeps the resource alive for the session AND is the
/// source the [`ChannelBroker`] duplicates into the driver's WUDFHost (the ONLY way the driver can
/// reach this texture — there is no name to open). An [`OwnedHandle`] so it closes on drop.
shared: OwnedHandle,
/// SRV on the slot texture so the HDR path samples the FP16 slot DIRECTLY (no slot→scratch copy);
/// the convert pass writes the output ring while holding the slot's keyed mutex. Unused for SDR
/// (which converts the BGRA slot → NV12 on the video engine, via its own per-frame input view).
srv: ID3D11ShaderResourceView,
}
/// One host out-ring slot: the NVENC/AMF/QSV encode-input texture, plus — when the output format is
/// P010 — the two per-plane RTVs the HDR shader passes render through.
///
/// The views live HERE, built once with the slot, because they are a property of the texture and the
/// mode, not of the frame: `HdrP010Converter::convert` used to create both on EVERY HDR frame, inside
/// the ring slot's keyed-mutex hold, so the driver sat blocked on that slot while we allocated views.
struct OutSlot {
tex: ID3D11Texture2D,
/// `(luma R16_UNORM, chroma R16G16_UNORM)` plane views. `None` for NV12/BGRA outputs, which the
/// video processor or a plain `CopyResource` writes without an RTV of ours.
p010: Option<(ID3D11RenderTargetView, ID3D11RenderTargetView)>,
}
/// One PyroWave output-ring slot: the two SEPARATE shareable plane textures the wavelet encoder
/// imports (design/pyrowave-windows-host-zerocopy.md) plus their RTVs (the [`BgraToYuvPlanes`] CSC
/// renders into them). Y is full-res `R8_UNORM`, CbCr is half-res `R8G8_UNORM`; both are
/// `SHARED | SHARED_NTHANDLE`. Rotated per frame like `out_ring` so encode N and convert N+1 touch
/// different textures.
struct PyroOutSlot {
y: ID3D11Texture2D,
y_rtv: ID3D11RenderTargetView,
cbcr: ID3D11Texture2D,
cbcr_rtv: ID3D11RenderTargetView,
}
/// RAII guard over an [`IDXGIKeyedMutex`]: [`acquire`](Self::acquire) does `AcquireSync(key, timeout)`,
/// `Drop` does `ReleaseSync(key)`. So the lock is released even if the work between acquire and the end
/// of the guard's scope `?`-returns or panics — the "leak the keyed-mutex lock → stall the driver on
/// that slot" footgun the consume loop guards against by hand. Keeps the hot loop free of a raw
/// `ReleaseSync` that a future early-return could skip.
struct KeyedMutexGuard<'a> {
mutex: &'a IDXGIKeyedMutex,
key: u64,
}
/// `WAIT_ABANDONED` as an HRESULT: the driver died while holding the slot's keyed mutex — ownership
/// still transferred to this caller. SUCCESS-severity (positive), like `WAIT_TIMEOUT` (0x102): the
/// windows-rs `Result` wrapper erases both (`.ok()` maps every non-negative HRESULT to `Ok(())`), so
/// acquisition MUST be classified on the raw vtable HRESULT. Mirrors the driver's constants
/// (`frame_transport.rs`).
const WAIT_ABANDONED_HRESULT: i32 = 0x0000_0080;
impl<'a> KeyedMutexGuard<'a> {
/// Acquire `mutex` at `key`, waiting up to `timeout_ms`. `None` if the acquire times out / errors
/// (the caller skips the frame), so the guard is only ever held when the lock is genuinely held.
fn acquire(
mutex: &'a IDXGIKeyedMutex,
key: u64,
timeout_ms: u32,
) -> Option<KeyedMutexGuard<'a>> {
// SAFETY: `mutex` is a live `IDXGIKeyedMutex` on this thread's immediate-context device.
// Raw vtable call, NOT the `Result` wrapper: `.is_err()` treated WAIT_TIMEOUT (positive =
// `Ok`) as acquired, handing out a guard for a slot the DRIVER still held — converting from
// a texture mid-copy (torn frame) and `ReleaseSync`ing a key this side never took.
let hr = unsafe {
(Interface::vtable(mutex).AcquireSync)(Interface::as_raw(mutex), key, timeout_ms)
};
match hr.0 {
// Acquired — S_OK, or WAIT_ABANDONED (the driver died holding the slot: the lock is
// OURS now, and refusing the guard would leave the key held forever, wedging the slot).
0 | WAIT_ABANDONED_HRESULT => Some(KeyedMutexGuard { mutex, key }),
// WAIT_TIMEOUT (slot busy — the caller skips this frame) or a genuine error: never held.
_ => None,
}
}
}
impl Drop for KeyedMutexGuard<'_> {
fn drop(&mut self) {
// SAFETY: we hold `mutex` at `key` (acquired in `acquire`, never released elsewhere); release it.
unsafe {
let _ = self.mutex.ReleaseSync(self.key);
}
}
}
/// Confirm the process is a genuine system WUDFHost — `%SystemRoot%\System32\WUDFHost.exe` — before a
/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's
/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a
/// tampered mailbox could name an arbitrary process to receive the channel, so this is the
/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL
/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid)
/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`);
/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
///
/// # Safety
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
pub unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> {
let mut buf = [0u16; 512];
let mut len = buf.len() as u32;
// SAFETY: `process` carries QUERY_LIMITED per the contract; `buf`/`len` are a valid out-buffer and
// its capacity, and on success `len` is updated to the count of UTF-16 units written (no NUL).
unsafe {
QueryFullProcessImageNameW(
process,
PROCESS_NAME_WIN32,
PWSTR(buf.as_mut_ptr()),
&mut len,
)
.with_context(|| format!("QueryFullProcessImageNameW on the {what} pid"))?;
}
let path = String::from_utf16_lossy(&buf[..len as usize]);
let got = path.to_ascii_lowercase().replace('/', "\\");
let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
let expected = format!("{}\\system32\\wudfhost.exe", sysroot.to_ascii_lowercase());
if got != expected {
bail!(
"{what} pid {wudf_pid} is not the system WUDFHost (image={path:?}, expected \
{expected:?}) — refusing to duplicate the channel's handles into it (spoofed driver / \
wrong devnode?)"
);
}
Ok(())
}
#[path = "idd_push/channel.rs"]
mod channel;
// The one-time construction half (sweep Phase 5.4): adapter resolve + HDR negotiate + ring/header/
// event creation + channel delivery + the bounded first-frame gate. Read this file when a session
// will not START; read the steady state below when one stops flowing.
#[path = "idd_push/open.rs"]
mod open;
// The synthetic-input DWM compose kick — a fallback for the driver's own frame republish.
#[path = "idd_push/compose_kick.rs"]
mod compose_kick;
use compose_kick::kick_dwm_compose;
#[path = "idd_push/cursor.rs"]
mod cursor;
#[path = "idd_push/cursor_blend.rs"]
mod cursor_blend;
#[path = "idd_push/cursor_poll.rs"]
mod cursor_poll;
#[path = "idd_push/descriptor.rs"]
mod descriptor;
#[path = "idd_push/stall.rs"]
mod stall;
use channel::ChannelBroker;
use descriptor::{DescriptorPoller, DisplayDescriptor};
use stall::StallWatch;
pub struct IddPushCapturer {
device: ID3D11Device,
context: ID3D11DeviceContext,
target_id: u32,
/// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE
/// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Also the
/// duplication source for the driver's header handle on every [`ChannelBroker::send`].
section: MappedSection,
header: *mut SharedHeader,
event: OwnedHandle,
/// 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. With the channel delivered, the driver's
/// hardware cursor keeps DWM from compositing ANY cursor into the frame; the SHAPE now comes
/// from [`cursor_poll::CursorPoller`] (the IddCx query is alpha-only — see cursor_poll.rs),
/// and this shm read is the fallback if that poller dies.
cursor_shared: Option<cursor::CursorShared>,
/// The GDI cursor-shape poller (design §8): the overlay source while alive. `Some` when
/// `cursor_shared` is (both ride the negotiated cursor channel + successful delivery) — or
/// when `composite_forced` (no channel, but the target's sticky declare needs a blend source).
cursor_poll: Option<cursor_poll::CursorPoller>,
/// Retained delivery sender (`IOCTL_SET_CURSOR_CHANNEL`) for RE-delivery: a driver-side
/// monitor re-arrival (match-window re-arrival resize, a sibling session recreating the
/// shared slot) destroys the driver's cursor worker — the section here survives, so the
/// channel is re-delivered on ring recreates.
cursor_sender: Option<crate::CursorChannelSender>,
/// The cursor-render flip sender (`IOCTL_SET_CURSOR_FORWARD`) — the secure-desktop guard's
/// actuator. UAC/Winlogon render only through the OS's software-cursor path (its default on
/// every mode commit); with our hardware cursor declared (and re-declared on every
/// swap-chain assign) that path never comes back, and the secure desktop never presents —
/// the stream freezes on the last normal-desktop frame for the whole UAC/lock interaction.
/// [`Self::poll_secure_desktop`] flips the declare off/on at the secure-desktop edges.
cursor_forward: Option<crate::CursorForwardSender>,
/// The secure-desktop guard's edge state: `true` = the poller reports a secure input
/// desktop and the declare is currently stood down.
secure_active: bool,
/// The CAPTURE mouse model is active — the HOST composites the pointer into the frame
/// (see cursor_blend.rs for why DWM cannot: a declared IddCx hardware cursor is forever).
composite_cursor: bool,
/// This session never negotiated the cursor channel but its target carries an IRREVOCABLE
/// hardware-cursor declare from an earlier session (`WinCaptureTarget::cursor_excluded`,
/// §8.6): DWM delivers pointer-free frames and no client draws the cursor, so the ONLY path
/// to a visible pointer is compositing here. Pins `composite_cursor` on — nothing may turn
/// it off (there is no channel to hand the pointer to).
composite_forced: bool,
/// The cursor-quad blend pass (lazy; per capture device). `None` after a build failure —
/// composite mode then degrades to pointer-less frames (warned once).
cursor_blend: Option<cursor_blend::CursorBlendPass>,
cursor_blend_failed: bool,
/// Sticky: [`Self::live_cursor`] has fallen back to the driver's shm section. The two sources
/// keep independent serial namespaces, so once crossed we never go back (see there).
cursor_shm_latched: bool,
/// The frame-sized blend scratch (slot copy + cursor quad): texture + SRV + (w, h, fmt)
/// it was built for — rebuilt when the ring geometry changes.
blend_scratch: Option<(
ID3D11Texture2D,
ID3D11ShaderResourceView,
u32,
u32,
DXGI_FORMAT,
)>,
/// The (serial, x, y, visible) of the LAST blended pointer — the composite-regen change
/// key: pointer-only motion produces no driver publish (the declared hardware cursor
/// doesn't dirty frames), so `try_consume` regenerates from the last slot when this moves.
last_blend_key: Option<(u64, i32, i32, bool)>,
/// The ring slot of the last FRESH publish — the regen source.
last_slot: Option<usize>,
/// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on
/// each blend-scratch rebuild (first use + ring geometry changes). 2.5 ≈ the Windows
/// SDR-brightness default; without it the composited cursor renders visibly dark on HDR.
sdr_white_scale: f32,
width: u32,
height: u32,
slots: Vec<HostSlot>,
/// The ring/texture generation, bumped every time the ring is recreated at a new format (the
/// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
/// (and so stale-ring publishes are rejected).
generation: u32,
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Gates the
/// composition depth: a 10-bit client PROACTIVELY enables advanced color at `open` (HDR without a
/// manual toggle); an SDR-only client forces it OFF and the descriptor poller PINS it there, so a
/// client that advertised SDR ("HDR off") is never handed the in-band PQ upgrade the pixel-format-
/// driven encoder would otherwise stamp from an HDR composition. (An HDR-negotiated H.26x session
/// still follows a host-side "Use HDR" flip; all clients decode Main10 + auto-detect PQ from the VUI.)
client_10bit: bool,
/// The DISPLAY's CURRENT HDR state (from `advanced_color_enabled`) — the user can flip "Use HDR" in
/// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion.
/// Polled in the capture loop; a change recreates the ring (see [`Self::recreate_ring`]).
display_hdr: bool,
/// One-shot latch for [`Self::poll_display_hdr`]'s "could not pin the negotiated depth" error —
/// the poller samples every ~250 ms, and a display that refuses the flip refuses it every time.
hdr_pin_warned: bool,
/// The session negotiated full-chroma 4:4:4: while the display is SDR the BGRA slot passes
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
/// TRUE 4:4:4 and the conversion follows the VUI matrix (BT.709 limited, always written).
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source):
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
want_444: bool,
/// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md +
/// design/pyrowave-444-hdr.md). When set, frames come from the separate-plane `pyro_ring`
/// (shareable Y + CbCr textures the mode-aware [`BgraToYuvPlanes`] CSC writes) and a **shared
/// fence** is signalled after each convert, so the pyrowave encoder zero-copy-imports the two
/// textures into its own Vulkan device ordered after the D3D11 convert. The composition is
/// PINNED to the negotiated depth: SDR sessions force advanced color OFF (8-bit BGRA → R8
/// planes), 10-bit sessions enable it like H.26x (scRGB FP16 → R16 studio-code planes);
/// `want_444` sizes the chroma plane full-res.
pyrowave: bool,
/// PyroWave: the shared D3D11 timeline fence (created lazily on the first frame, `SHARED` flag).
/// The capturer `Signal`s it after each frame's GPU convert; the encoder's Vulkan side waits it.
pyro_fence: Option<ID3D11Fence>,
/// PyroWave: the fence's persistent shared NT handle, passed (as a raw value) on EVERY frame. The
/// encoder DUPLICATEs + imports it as a Vulkan timeline semaphore whenever it has none (first
/// frame or after an encoder rebuild), so this original must stay valid across rebuilds — hence
/// one handle for the capturer's whole life. An [`OwnedHandle`] so it is CLOSED when the capturer
/// drops; it used to be a bare `isize` that nothing ever closed, leaking one NT handle per
/// PyroWave session. Closing ours cannot disturb the encoder: it holds its own duplicate.
pyro_fence_handle: Option<OwnedHandle>,
/// PyroWave: the monotonically increasing fence value (one `Signal` per emitted frame).
pyro_fence_value: u64,
/// PyroWave: the separate-plane output ring (Y R8 + CbCr R8G8 shareable textures + RTVs), used
/// INSTEAD of `out_ring` for a pyrowave session. Built lazily; rebuilt on a mode change.
pyro_ring: Vec<PyroOutSlot>,
/// PyroWave: the BGRA→YUV-planes CSC (BT.709 limited, matching `rgb2yuv.comp`). Built lazily.
pyro_conv: Option<BgraToYuvPlanes>,
/// PyroWave: the last presented (Y, CbCr) textures — the repeat source (analogue of
/// `last_present` for the two-plane path).
pyro_last: Option<(ID3D11Texture2D, ID3D11Texture2D)>,
/// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads
/// its snapshot instead of running CCD queries inline on the frame path.
desc_poller: DescriptorPoller,
/// Sequence of the last poller sample the capture loop consumed (0 = none yet).
desc_seq: u64,
/// Two-strikes debounce for descriptor changes: the first differing sample arms this; only a
/// SECOND consecutive sample with the same new descriptor triggers the recreate, so a
/// single-sample transient (a topology re-probe blip) never tears the ring down.
pending_desc: Option<DisplayDescriptor>,
/// Set when a display-descriptor change triggered a ring recreate (recovery, game-capture bug GB1);
/// cleared when a fresh frame resumes. If it stays set past the recovery window, `try_consume` drops
/// the session (recover-or-drop, no DDA).
recovering_since: Option<Instant>,
/// When the last FRESH driver frame was consumed — feeds the driver-death watch in
/// [`Self::try_consume`] (a dead WUDFHost is otherwise indistinguishable from an idle desktop:
/// both stop publishing, and the encode loop would repeat the last frame forever).
last_fresh: Instant,
/// Rate-limits the WUDFHost liveness probe (one 0 ms wait per second, and only while stale).
last_liveness: Instant,
/// Rate-limits the mid-session [`kick_dwm_compose`] nudge (recovery window only).
last_kick: Instant,
/// Capture-stall watch (see [`StallWatch`]): flags multi-hundred-ms DWM composition holes
/// during active flow and warns when they turn metronomic — the sole-virtual-display
/// periodic-stutter diagnostic.
stall_watch: StallWatch,
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
/// NV12 (SDR, BT.709 limited) or P010 (HDR, BT.2020 PQ limited), so NVENC takes native YUV and skips
/// its internal RGB→YUV CSC on the SM/3D engine the game saturates (plan §5.A). Rebuilt on a
/// display-mode flip. Built lazily.
out_ring: Vec<OutSlot>,
out_idx: usize,
/// BGRA slot → NV12 (BT.709 limited) on the dedicated D3D11 VIDEO engine, used while the display is
/// SDR — keeps the colour-convert OFF the contended 3D/compute engine. Built lazily; rebuilt on a
/// size/HDR flip.
video_conv: Option<VideoConverter>,
/// FP16 scRGB slot → P010 (BT.2020 PQ limited) via two shader passes, used while the display is HDR
/// (NVIDIA's VideoProcessor can't do RGB→P010). The passes run on the 3D engine, but it still skips
/// NVENC's internal SM-side CSC. Built lazily.
hdr_p010_conv: Option<HdrP010Converter>,
last_seq: u64,
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
status_logged: bool,
/// Session-lifetime `PowerRequestDisplayRequired` (RAII, `powercfg /requests`-visible): keeps
/// the console out of display-off while this capturer lives — DWM composes nothing (for ANY
/// display) once the console's displays power down, so without this a lid-closed/idle box can
/// go dark mid-stream and the ring runs dry. Prevention only; waking an ALREADY-off display is
/// the HID compose kick's job ([`crate::HID_COMPOSE_KICK`]). `None` when the kernel refused
/// (best-effort, the pre-existing behavior).
_display_wake: Option<pf_frame::session_tuning::DisplayWakeRequest>,
_keepalive: Box<dyn Send>,
}
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
// only dereferenced there. `Send` transfers ownership to one thread at a time with NO concurrent
// access; we do not (and must not) claim `Sync`.
unsafe impl Send for IddPushCapturer {}
impl IddPushCapturer {
#[inline]
fn latest(&self) -> u64 {
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
// `SharedHeader`). `addr_of!((*self.header).latest)` forms the address of the `latest` field
// WITHOUT a reference; it is an 8-aligned `u64` (so valid for `AtomicU64`), and the `Acquire` load
// is the consumer half of the cross-process publish handshake (pairs with the driver's `Release`).
unsafe {
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
.load(Ordering::Acquire)
}
}
/// Log the driver's status once it first reports (the only driver-visibility channel we have).
fn log_driver_status_once(&mut self) {
if self.status_logged {
return;
}
let (status, detail, lo, hi) = self.driver_diag();
if status == 0 {
return;
}
self.status_logged = true;
let render_luid = format!("{hi:08x}:{lo:08x}");
match status {
DRV_STATUS_OPENED => tracing::info!(
render_luid,
"IDD push: driver attached to the shared ring"
),
DRV_STATUS_TEX_FAIL => tracing::error!(
render_luid,
detail = format!("0x{detail:08x}"),
"IDD push: driver could NOT open our textures — render-adapter mismatch (it renders on \
a different GPU than where we created the ring)"
),
DRV_STATUS_NO_DEVICE1 => {
tracing::error!("IDD push: driver has no ID3D11Device1 to open shared resources")
}
DRV_STATUS_BIND_FAIL => tracing::error!(
ring_claims_target = detail,
our_target = self.target_id,
"IDD push: driver REFUSED the ring↔monitor binding (host stash cross-wire?)"
),
other => tracing::warn!(other, render_luid, "IDD push: driver reported an unknown status"),
}
}
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven by the DISPLAY's HDR
/// state (like the WGC path) plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
/// 10-bit limited) → NVENC Main10, and the client auto-detects PQ from the HEVC VUI; SDR →
/// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
/// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
/// SM-side CSC, because the video processor can only produce subsampled output). We do NOT
/// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the
/// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 +
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
// PyroWave never uses this out-ring (it has its own separate-plane `pyro_ring`); the
// format here only labels the frame. SDR sessions label NV12 (BT.709 limited), HDR
// (negotiated 10-bit) sessions P010 — matching the studio-code planes the pyro CSC writes.
if self.pyrowave {
return if self.display_hdr {
(DXGI_FORMAT_P010, PixelFormat::P010)
} else {
(DXGI_FORMAT_NV12, PixelFormat::Nv12)
};
}
if self.display_hdr {
if self.want_444 {
warn_444_hdr_downgrade_once();
}
(DXGI_FORMAT_P010, PixelFormat::P010)
} else if self.want_444 {
(DXGI_FORMAT_B8G8R8A8_UNORM, PixelFormat::Bgra)
} else {
(DXGI_FORMAT_NV12, PixelFormat::Nv12)
}
}
/// The ring (shared-texture) format for a given HDR state: FP16 when the display composes in
/// advanced colour, BGRA when SDR. Taken by value so [`Self::recreate_ring`] can size the new
/// slots for the INCOMING state before committing it (see there).
fn ring_format_for(hdr: bool) -> DXGI_FORMAT {
if hdr {
DXGI_FORMAT_R16G16B16A16_FLOAT
} else {
DXGI_FORMAT_B8G8R8A8_UNORM
}
}
/// The ring format for the display's CURRENT HDR state.
fn ring_format(&self) -> DXGI_FORMAT {
Self::ring_format_for(self.display_hdr)
}
/// The driver's four best-effort diagnostic words from the shared header:
/// `(driver_status, detail, render_luid_low, render_luid_high)`.
///
/// SAFETY (once, for all callers): `self.header` points into the live, owned shared-header
/// mapping (page-aligned, sized `>= size_of::<SharedHeader>()`), so each field read is in-bounds
/// and aligned, and no reference into the shared region is formed. The driver writes these
/// cross-process, but an aligned word read cannot tear and these are best-effort DIAGNOSTICS —
/// the real handshake is the atomic `magic`/`latest`.
fn driver_diag(&self) -> (u32, u32, u32, i32) {
// SAFETY: see the doc comment above.
unsafe {
(
(*self.header).driver_status,
(*self.header).driver_status_detail,
(*self.header).driver_render_luid_low,
(*self.header).driver_render_luid_high,
)
}
}
/// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the
/// generation so the driver re-attaches ([`is_stale`]) to the new-format textures and DELIVERS the
/// new channel (fresh duplicates of the header + event + the new textures — every delivery is a
/// self-contained handle set the driver owns); clears the header's `latest` so we don't consume a
/// stale slot from the old ring; drops the conversion textures so they rebuild at the new format.
fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> {
// BUILD FIRST, COMMIT AFTER. `create_ring_slots` is fallible — VRAM pressure at a large new
// mode, which is exactly when resizes happen — and the geometry used to be committed BEFORE
// it ran. A failed recreate then left the capturer emitting frames stamped with the NEW
// width/height/format against the OLD ring, the old generation and an unchanged header:
// nothing downstream could detect the mismatch, because every field it would compare against
// had already been moved. Nothing below this line fails.
let fmt = Self::ring_format_for(new_display_hdr);
// SAFETY: `create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls); we pass a live
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
// returned slot's texture + keyed mutex belongs to that same `self.device`.
let new_slots = unsafe { Self::create_ring_slots(&self.device, new_w, new_h, fmt)? };
self.display_hdr = new_display_hdr;
self.width = new_w;
self.height = new_h;
let new_gen = next_generation();
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
// `SharedHeader`). The `latest`/`generation` stores go through `addr_of!`-formed field pointers (no
// references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the
// `dxgi_format`/`width`/`height` writes are in-bounds raw writes through the pointer (no `&mut`).
// The `Release` fence + the `Release` `generation` store publish all preceding writes so the driver
// only re-attaches (`Acquire`) once the new textures + format are in place.
unsafe {
// Clear `latest` to the 0 sentinel (generation 0, which try_consume rejects). The real guard
// against consuming an unwritten new-ring slot is the generation tag in `latest`: a stale
// old-ring publish racing this recreate carries the OLD generation and is rejected. We wait
// for the driver's first NEW-generation publish.
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
.store(0, Ordering::Relaxed);
(*self.header).dxgi_format = fmt.0 as u32;
(*self.header).width = new_w;
(*self.header).height = new_h;
// Clear the driver's diagnostic words too. `wait_for_attach` — the ONLY classifier of
// TEX_FAIL/BIND_FAIL and the only source of the render-LUID rebind — runs at open only,
// so a recreate's re-attach is never classified; a stale `DRV_STATUS_OPENED` left over
// from the previous generation then made a FAILED re-attach look healthy, and the
// recover-or-drop bail below reported nothing at all while the driver's real diagnosis
// sat unread in the header. Cleared, these fields describe THIS generation's attach,
// which is what that bail now prints.
(*self.header).driver_status = DRV_STATUS_NONE;
(*self.header).driver_status_detail = 0;
// Publish the new generation LAST (Release): when the driver observes it (Acquire) the new
// textures already exist and the format is already updated.
std::sync::atomic::fence(Ordering::Release);
(*(std::ptr::addr_of!((*self.header).generation) as *const AtomicU32))
.store(new_gen, Ordering::Release);
}
// …and let `log_driver_status_once` report the new generation's attach (or its failure).
self.status_logged = false;
self.slots = new_slots; // drops the old slots → closes their shared handles + SRVs
self.generation = new_gen;
// Deliver the new generation's channel. The driver's old publisher sees the generation bump
// (`is_stale`), drops (closing its old handles), and re-attaches from this delivery. On failure
// the broker already reaped its remote duplicates; the recover-or-drop window in `try_consume`
// then ends the session cleanly (the driver can never attach to an undelivered ring).
// SAFETY: `broker.send` requires live `header`/`event` handles of this process — both borrow the
// owned `self.section.handle`/`self.event` for the duration of the synchronous call.
if let Err(e) = unsafe {
self.broker.send(
self.target_id,
new_gen,
HANDLE(self.section.handle.as_raw_handle()),
HANDLE(self.event.as_raw_handle()),
&self.slots,
)
} {
tracing::warn!(
error = %format!("{e:#}"),
"IDD push: frame-channel re-delivery failed after ring recreate"
);
}
// Ring recreates ride display churn that can also have re-arrived the MONITOR driver-side
// (destroying its cursor worker with it) — re-deliver the surviving cursor section so the
// hardware-cursor declaration follows the CURRENT monitor generation.
if let (Some(cs), Some(send)) = (self.cursor_shared.as_ref(), self.cursor_sender.as_ref()) {
let _ = deliver_cursor_channel(&self.broker, self.target_id, cs, send);
}
self.blend_scratch = None; // ring geometry/format changed — rebuild at next blend
// The HDR SDR-white reference belongs to the mode we just moved to. Queried HERE, not from
// the blend, which holds the ring slot's keyed mutex (see `refresh_sdr_white_scale`).
self.refresh_sdr_white_scale();
self.last_slot = None; // old-ring slot indices are meaningless now
self.last_seq = 0;
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
self.hdr_p010_conv = None;
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
// the freshly HDR-formatted pyro ring — every frame corrupted. `ensure_pyro_conv` only
// builds when None, so it must be reset here like its siblings.
self.pyro_conv = None;
self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode
self.pyro_last = None;
self.out_idx = 0;
self.last_present = None;
Ok(())
}
/// Follow the [`DescriptorPoller`]'s snapshot of the display's live HDR state + resolution;
/// recreate the ring when the display REALLY changed (a "Use HDR" flip, or a fullscreen game
/// mode-setting the virtual display out from under the negotiated size — game-capture bug
/// GB1). Called from the capture loop (incl. while frozen on a format mismatch); cheap — one
/// mutex read, the CCD queries run off-thread. Two-strikes debounce: a change is acted on
/// only when TWO consecutive samples agree on the same new descriptor (~½ s), so a
/// single-sample transient during a topology re-probe never costs a ring recreate.
fn poll_display_hdr(&mut self) {
let (mut now, seq) = self.desc_poller.snapshot();
if seq == self.desc_seq {
return; // no new sample since last consume
}
self.desc_seq = seq;
// Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR"
// flip — flip the display back and treat the descriptor as the negotiated state (so the ring
// is never recreated at the wrong format):
// - a PyroWave session: its encoder was opened for fixed plane formats (R8 SDR / R16 HDR),
// so it can't follow a flip the way H.26x re-inits do;
// - ANY SDR-negotiated session (`!client_10bit`, either codec): a host-side flip to HDR
// must not promote the stream to P010 PQ behind a client that advertised SDR-only.
// An HDR-negotiated H.26x session is NOT pinned — it still follows a host "Use HDR" flip in
// either direction (its encoder re-inits on the depth change).
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
let want = self.client_10bit;
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s `bool`
// and then write `now.hdr = self.client_10bit` — substituting the DESIRED state for the
// observed one, which broke in both directions on a display that cannot be flipped
// (the state this file already logs as "Downgrade point D" at open):
// - want HDR, display stays SDR: the fabricated `true` differed from `current`, so two
// samples drove `recreate_ring(true, …)` and rebuilt the ring FP16 while the driver
// composed 8-bit BGRA. Every publish was then dropped by the driver's format guard,
// `recovering_since` expired, and `try_consume` bailed — a permanent 3-second
// reconnect loop.
// - want SDR, display stays HDR: the fabricated `false` MATCHED `current`, so no
// recreate ever fired and the ring stayed BGRA against an FP16 composition. Same
// dropped-publish outcome, silently.
// Reading back immediately can catch a flip that has not settled yet; that costs one
// debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring, which is why
// this does not block the frame path on a settle poll the way `open_on` does.
// SAFETY: both are `unsafe fn`s over CCD DisplayConfig; each takes a copy of the plain
// `u32` target id (plus a `bool`), forms no lasting borrow, and returns an owned value.
let requested =
unsafe { pf_win_display::win_display::set_advanced_color(self.target_id, want) };
// SAFETY: as above — a read-only CCD query over a copy of the plain `u32` target id.
let observed =
unsafe { pf_win_display::win_display::advanced_color_enabled(self.target_id) };
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
now.hdr = observed.unwrap_or(now.hdr);
if now.hdr != want && !self.hdr_pin_warned {
self.hdr_pin_warned = true;
tracing::error!(
target_id = self.target_id,
want_hdr = want,
observed_hdr = ?observed,
set_advanced_color_returned = requested,
pyrowave = self.pyrowave,
client_10bit = self.client_10bit,
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
it actually composes instead (a physical display forcing HDR, or a driver that \
refuses the flip). The stream's depth will not match the negotiation; the \
encoder's caps cross-check reports the truth to the client"
);
}
}
let current = DisplayDescriptor {
hdr: self.display_hdr,
width: self.width,
height: self.height,
};
if now == current {
self.pending_desc = None; // steady (or a blip reverted before its second strike)
return;
}
if self.pending_desc != Some(now) {
self.pending_desc = Some(now); // first strike — arm, act on confirmation
return;
}
self.pending_desc = None;
tracing::info!(
target_id = self.target_id,
from = format!("{}x{} hdr={}", self.width, self.height, self.display_hdr),
to = format!("{}x{} hdr={}", now.width, now.height, now.hdr),
"IDD push: display descriptor changed — recreating the ring at the new mode"
);
// Start the recovery clock (if not already running): if a fresh frame doesn't resume within the
// window, try_consume drops the session rather than freeze.
self.recovering_since.get_or_insert_with(Instant::now);
if let Err(e) = self.recreate_ring(now.hdr, now.width, now.height) {
tracing::warn!(error = %format!("{e:#}"), "IDD push: ring recreate failed");
}
}
/// Build the host-owned output ring (`OUT_RING` textures at [`Self::out_format`] + RTVs) if not yet
/// built. Rotated per frame so the in-flight encode of N and the convert/copy of N+1 touch different
/// textures. Rebuilt (cleared) when the display-mode flip changes the output format.
fn ensure_out_ring(&mut self) -> Result<()> {
if !self.out_ring.is_empty() {
return Ok(());
}
let (format, _) = self.out_format();
let desc = D3D11_TEXTURE2D_DESC {
Width: self.width,
Height: self.height,
MipLevels: 1,
ArraySize: 1,
Format: format,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
// NVENC registers it as encode input — matching the WGC YUV ring. (PyroWave uses its own
// shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.)
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0,
MiscFlags: 0,
};
for _ in 0..OUT_RING {
let mut t: Option<ID3D11Texture2D> = None;
// SAFETY: `CreateTexture2D` is called on `self.device` (the capturer's live D3D11 device);
// `&desc` is a fully-initialized stack `D3D11_TEXTURE2D_DESC`, the data arg is `None` (no
// initial data), and `Some(&mut t)` is a live out-parameter the call fills. `?` rejects a failed
// HRESULT before `t` is unwrapped, and the created texture belongs to `self.device`.
// `plane_rtv` is `unsafe` for the same COM reason and takes that same device plus the
// texture we just made; a driver that rejects planar RTVs fails HERE, at ring build, which
// is where such a failure belongs (it used to surface on the first frame).
unsafe {
self.device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(IDD out ring)")?;
let tex = t.context("null out-ring texture")?;
let p010 = if format == DXGI_FORMAT_P010 {
Some((
HdrP010Converter::plane_rtv(&self.device, &tex, DXGI_FORMAT_R16_UNORM)?,
HdrP010Converter::plane_rtv(&self.device, &tex, DXGI_FORMAT_R16G16_UNORM)?,
))
} else {
None
};
self.out_ring.push(OutSlot { tex, p010 });
}
}
Ok(())
}
/// PyroWave: build the separate-plane output ring (`OUT_RING` × {full-res R8 Y, half-res R8G8
/// CbCr}, both `SHARED | SHARED_NTHANDLE` + RTV) if not yet built. The wavelet encoder imports the
/// two SEPARATE textures (a single planar NV12 import is unreliable on NVIDIA); the
/// [`BgraToYuvPlanes`] CSC renders into their RTVs.
fn ensure_pyro_ring(&mut self) -> Result<()> {
if !self.pyro_ring.is_empty() {
return Ok(());
}
let (w, h) = (self.width, self.height);
// SAFETY: all D3D11 calls target `self.device`; every `&desc` is a fully-initialized stack
// struct and every `Some(&mut _)` a live out-param; `?` rejects a failed HRESULT before use.
// The created textures/RTVs belong to `self.device`.
unsafe {
let make = |dev: &ID3D11Device,
fmt: DXGI_FORMAT,
w: u32,
h: u32|
-> Result<(ID3D11Texture2D, ID3D11RenderTargetView)> {
let desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: fmt,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0,
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
| D3D11_RESOURCE_MISC_SHARED.0) as u32,
};
let mut tex: Option<ID3D11Texture2D> = None;
dev.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(pyro plane)")?;
let tex = tex.context("null pyro plane texture")?;
let mut rtv: Option<ID3D11RenderTargetView> = None;
dev.CreateRenderTargetView(&tex, None, Some(&mut rtv))
.context("CreateRenderTargetView(pyro plane)")?;
Ok((tex, rtv.context("null pyro plane rtv")?))
};
// Plane formats/geometry follow the negotiated session: 16-bit UNORM planes for an
// HDR (10-bit) session (P010-style studio codes from the pyro HDR CSC), full-res
// chroma for 4:4:4 (design/pyrowave-444-hdr.md Phase 3).
let (yf, cf) = if self.display_hdr {
(DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM)
} else {
(DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM)
};
let (cw, ch) = if self.want_444 {
(w, h)
} else {
(w / 2, h / 2)
};
for _ in 0..OUT_RING {
let (y, y_rtv) = make(&self.device, yf, w, h)?;
let (cbcr, cbcr_rtv) = make(&self.device, cf, cw, ch)?;
self.pyro_ring.push(PyroOutSlot {
y,
y_rtv,
cbcr,
cbcr_rtv,
});
}
}
Ok(())
}
/// PyroWave: build the (mode-aware) RGB→YUV-planes CSC if not yet built. The mode is
/// session-fixed: SDR/BGRA vs HDR/scRGB input, half- vs full-res chroma — the composition
/// is pinned to the negotiated depth (`poll_display_hdr`), so the converter never needs a
/// mid-session mode swap.
fn ensure_pyro_conv(&mut self) -> Result<()> {
if self.pyro_conv.is_none() {
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
// failure before it is stored.
self.pyro_conv = Some(unsafe {
BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)?
});
}
Ok(())
}
/// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
/// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
fn ensure_converter(&mut self) -> Result<()> {
if self.display_hdr {
if self.hdr_p010_conv.is_none() {
// SAFETY: `HdrP010Converter::new` is `unsafe` (it compiles D3D11 shaders + creates
// resources); we pass a live borrow of `self.device`, the device the converter's resources
// belong to, and `?` propagates any failure before the converter is stored.
self.hdr_p010_conv =
Some(unsafe { HdrP010Converter::new(&self.device, self.width, self.height)? });
}
} else if self.want_444 {
// Full-chroma passthrough — no conversion resources to build.
} else if self.video_conv.is_none() {
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
// plain `u32` dimensions, and `?` propagates any failure before it is stored. The converter's
// resources belong to that same device/context.
self.video_conv = Some(unsafe {
VideoConverter::new(&self.device, &self.context, self.width, self.height, false)?
});
}
Ok(())
}
/// PyroWave: after this frame's GPU convert, `Signal` the shared fence and return the fence
/// `(handle, value)` for the encoder — the persistent shared handle EVERY frame (the encoder
/// imports it whenever it has no timeline yet, e.g. after a mode-switch rebuild) + the
/// incrementing value. `None` for a non-PyroWave session. The fence + its shared handle are
/// created lazily on the first call. `Flush` submits the queued convert + signal so the encoder's
/// cross-API Vulkan timeline wait resolves promptly instead of blocking on a still-unsubmitted
/// signal. The caller pairs the returned fence with the frame's CbCr texture into a
/// [`PyroFrameShare`].
///
/// # Safety
/// Runs on the owning capture/encode thread that holds the immediate context; forms no lasting
/// borrow of `self`'s COM objects.
unsafe fn pyro_fence_signal(&mut self) -> Result<Option<(Option<isize>, u64)>> {
// SAFETY: per the contract above this runs on the owning capture/encode thread, which holds
// the immediate context. Every call is a `?`-checked COM method on `self`'s live device or
// context (or on a `cast()` of one), and `CreateSharedHandle` yields a fresh NT handle whose
// raw value is only STORED here — never dereferenced, and never closed on this path.
unsafe {
if !self.pyrowave {
return Ok(None);
}
if self.pyro_fence.is_none() {
let dev5: ID3D11Device5 = self
.device
.cast()
.context("ID3D11Device -> ID3D11Device5 (shared fence)")?;
// windows-rs returns COM interfaces via an out-param (unlike the HANDLE-returning
// CreateSharedHandle below).
let mut fence_out: Option<ID3D11Fence> = None;
dev5.CreateFence(0, D3D11_FENCE_FLAG_SHARED, &mut fence_out)
.context("CreateFence(D3D11_FENCE_FLAG_SHARED)")?;
let fence = fence_out.context("null D3D11 fence")?;
// GENERIC_ALL (0x1000_0000) — the access the pyrowave interop test hands the handle.
let handle: HANDLE = fence
.CreateSharedHandle(None, 0x1000_0000, PCWSTR::null())
.context("ID3D11Fence::CreateSharedHandle")?;
self.pyro_fence = Some(fence);
// `handle` is the shared NT handle `CreateSharedHandle` just minted for this fence —
// a unique, still-open handle owned by this process — so `OwnedHandle` becomes its
// sole owner and closes it exactly once, when the capturer drops. (Inside this fn's
// `unsafe` scope; see its SAFETY block.)
self.pyro_fence_handle = Some(OwnedHandle::from_raw_handle(handle.0 as _));
self.pyro_fence_value = 0;
}
self.pyro_fence_value += 1;
let value = self.pyro_fence_value;
let ctx4: ID3D11DeviceContext4 = self
.context
.cast()
.context("ID3D11DeviceContext -> ID3D11DeviceContext4 (fence signal)")?;
{
let fence = self.pyro_fence.as_ref().expect("fence just created");
ctx4.Signal(fence, value)
.context("ID3D11 fence Signal after convert")?;
}
// Submit the queued convert + signal so the encoder's Vulkan timeline wait can resolve.
self.context.Flush();
// Pass the persistent shared handle EVERY frame (not once): the encoder can be rebuilt on a
// client mode-switch, and a rebuilt encoder needs to re-import the fence into its fresh Vulkan
// device. The encoder imports only when it has no timeline yet (and DUPLICATES the handle so
// this original stays valid for the next rebuild).
Ok(Some((
self.pyro_fence_handle
.as_ref()
.map(|h| h.as_raw_handle() as isize),
value,
)))
}
}
/// THE live cursor overlay for this session — the single source every consumer reads
/// ([`Capturer::cursor`], [`Self::cursor_blend_key`], [`Self::prepare_blend_scratch`]).
///
/// A LIVE poller wins, even while it still reports `None` (pre-first-shape); the driver's shm
/// section serves only a poller that failed to start or died. The choice is LATCHED for the
/// session because the two sources maintain INDEPENDENT serial namespaces: interleaving them
/// mid-session hands the client two different shapes under one serial and poisons its shape
/// cache. So once the shm has been used, the poller is not re-adopted.
///
/// One function because the three consumers disagreed: `cursor()` degraded poller→shm correctly,
/// but the BLEND path — `cursor_blend_key` and `prepare_blend_scratch`, the only consumer that
/// matters in the composite model, since the Windows encode loop never attaches `frame.cursor` —
/// read `cursor_poll` directly with no `alive()` check and no shm fallback. The documented
/// fallback and the spawn-failure warning were both untrue for exactly those sessions: a dead
/// poller meant pointer-less frames, not a degraded pointer.
fn live_cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
if !self.cursor_shm_latched {
if let Some(p) = &self.cursor_poll {
if p.alive() {
return p.read();
}
}
// The poller is gone (or never started) and we are about to read the shm — latch, so a
// poller that somehow reports alive again cannot re-cross the serial namespaces.
if self.cursor_shared.is_some() {
self.cursor_shm_latched = true;
tracing::warn!(
target_id = self.target_id,
"cursor: the GDI shape poller is not running — degrading to the driver's \
hardware-cursor shm section for the rest of the session (alpha-only shapes: \
monochrome/masked cursors will look wrong)"
);
}
}
self.cursor_shared.as_mut().and_then(|c| c.read())
}
/// Refresh [`Self::sdr_white_scale`] — where DWM places SDR white on this HDR desktop, which the
/// composited cursor must match or it renders visibly dark (~2.5× at the Windows default).
///
/// Called at open and after every ring recreate, NEVER from the blend. `sdr_white_level_scale` is
/// a CCD query that contends the display-config lock — the exact contention `DescriptorPoller`'s
/// doc says must stay off the frame path — and it used to run inside `prepare_blend_scratch`,
/// which holds the ring slot's KEYED MUTEX: the driver's publisher sat blocked on that slot for
/// the duration of a display-config round-trip. "Only on scratch rebuilds" is not the same as
/// harmless when the thing being blocked is the producer. A failed query keeps the prior value.
fn refresh_sdr_white_scale(&mut self) {
if !self.display_hdr {
return;
}
// SAFETY: `sdr_white_level_scale` is an `unsafe fn` running a read-only CCD query over owned
// local buffers; the `Copy` target id crosses by value and it returns an owned value.
let queried = unsafe { pf_win_display::win_display::sdr_white_level_scale(self.target_id) };
self.sdr_white_scale = queried.unwrap_or(self.sdr_white_scale);
tracing::info!(
target_id = self.target_id,
queried = ?queried,
applied = self.sdr_white_scale,
"cursor composite: HDR SDR-white scale (1.0 = 80 nits; None = query failed — keeping \
the prior value)"
);
}
/// The (serial, x, y, visible) of the CURRENT live cursor — the composite-regen change key.
/// `None` while no source has a shape yet.
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32, bool)> {
self.live_cursor().map(|o| (o.serial, o.x, o.y, o.visible))
}
/// Composite the pointer for this convert: ensure the frame-sized blend scratch, copy the
/// slot into it, and alpha-blend the GDI poller's shape at its polled position. Returns the
/// scratch (texture + SRV) the conversion should read INSTEAD of the slot; `None` degrades
/// to the pointer-less slot (scratch/pass creation failed — warned once). A hidden pointer
/// blends nothing (the plain copy is the correct frame).
///
/// # Safety
/// D3D11 calls on the owning capture/encode thread's device + immediate context, called
/// while holding the slot's keyed mutex (the copy reads the slot).
unsafe fn prepare_blend_scratch(
&mut self,
slot_tex: &ID3D11Texture2D,
) -> Option<(ID3D11Texture2D, ID3D11ShaderResourceView)> {
// SAFETY: per the contract above, D3D11 calls on the owning thread's device + immediate
// context while the slot's keyed mutex is held. `CreateTexture2D`/`CreateShaderResourceView`
// take a fully-initialized stack descriptor plus live out-params and are `.ok()`-checked before
// use; `CopyResource` moves between our own scratch and the caller's live slot texture, which
// share format and size by construction (the scratch is rebuilt whenever the ring geometry
// changes).
unsafe {
let fmt = self.ring_format();
// (Re)build the scratch at the current ring geometry.
let stale = self
.blend_scratch
.as_ref()
.is_none_or(|(_, _, w, h, f)| (*w, *h, *f) != (self.width, self.height, fmt));
if stale {
self.blend_scratch = None;
let desc = D3D11_TEXTURE2D_DESC {
Width: self.width,
Height: self.height,
MipLevels: 1,
ArraySize: 1,
Format: fmt,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
..Default::default()
};
let mut tex: Option<ID3D11Texture2D> = None;
let built = self
.device
.CreateTexture2D(&desc, None, Some(&mut tex))
.ok()
.and(tex)
.and_then(|t| {
let mut srv: Option<ID3D11ShaderResourceView> = None;
self.device
.CreateShaderResourceView(&t, None, Some(&mut srv))
.ok()
.and(srv)
.map(|v| (t, v))
});
match built {
Some((t, v)) => {
// `sdr_white_scale` is NOT queried here — see `refresh_sdr_white_scale`:
// this runs while the ring slot's keyed mutex is held, and a CCD
// display-config round-trip has no business blocking the driver's publisher.
self.blend_scratch = Some((t, v, self.width, self.height, fmt));
}
None => {
if !self.cursor_blend_failed {
self.cursor_blend_failed = true;
tracing::warn!(
"cursor blend scratch creation failed — capture-model frames stay \
pointer-less this session"
);
}
return None;
}
}
}
let (tex, srv, ..) = self.blend_scratch.as_ref().expect("just ensured");
let (tex, srv) = (tex.clone(), srv.clone());
self.context.CopyResource(&tex, slot_tex);
// Blend the pointer (visible shapes only; hidden = the copy alone is the frame).
// Through `live_cursor`, so a dead poller degrades to the shm section HERE too — this
// is the path that actually draws the pointer in the composite model, and the one that
// used to read the poller unconditionally.
let overlay = self.live_cursor();
self.last_blend_key = overlay.as_ref().map(|o| (o.serial, o.x, o.y, o.visible));
if let Some(ov) = overlay.filter(|o| o.visible) {
if self.cursor_blend.is_none() && !self.cursor_blend_failed {
match cursor_blend::CursorBlendPass::new(&self.device) {
Ok(p) => self.cursor_blend = Some(p),
Err(e) => {
self.cursor_blend_failed = true;
tracing::warn!(
"cursor blend pass build failed — capture-model frames stay \
pointer-less this session: {e:#}"
);
}
}
}
if let Some(pass) = self.cursor_blend.as_mut() {
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
// scale it to the target's SDR white so it matches the desktop around it.
let scale = if self.display_hdr {
self.sdr_white_scale
} else {
0.0
};
if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, scale) {
if !self.cursor_blend_failed {
self.cursor_blend_failed = true;
tracing::warn!("cursor blend draw failed — pointer-less frames: {e:#}");
}
}
}
}
Some((tex, srv))
}
}
/// The secure-desktop guard (the 0.18.0 UAC/Winlogon regression). UAC consent and Winlogon
/// live on the SECURE desktop, which the OS renders through the software-cursor path — its
/// per-mode-commit default. With this session's IddCx hardware cursor declared (and
/// re-declared by the driver on every swap-chain assign), that path never materialises, the
/// secure desktop never presents into our swap-chain, and the stream freezes on the last
/// normal-desktop frame for the entire UAC/lock interaction. On the poller's secure edge:
/// stand the declare down (`SET_CURSOR_FORWARD` off — the driver stops its per-assign
/// re-declare — plus the host facade's forced same-mode re-commit that actualises the
/// software cursor); on dismissal, re-declare. Runs on the capture/encode thread every tick
/// (it must keep running while frames are stalled — that is exactly the state it exits).
fn poll_secure_desktop(&mut self) {
let Some(fwd) = self.cursor_forward.as_ref() else {
return;
};
// Sessions with a declare possibly in play: the channel session that declared it, and
// the forced-composite session whose (reused) driver monitor may still run an earlier
// session's cursor worker. A plain session on a clean target has no poller — no guard.
if self.cursor_shared.is_none() && !self.composite_forced {
return;
}
let secure = self
.cursor_poll
.as_ref()
.is_some_and(|p| p.secure_desktop());
if secure == self.secure_active {
return;
}
self.secure_active = secure;
if secure {
tracing::info!(
target_id = self.target_id,
"secure desktop (UAC/Winlogon) active — standing the IddCx hardware-cursor \
declare down so the OS software-cursor path can render it"
);
if let Err(e) = fwd(false) {
tracing::warn!(
"secure-desktop cursor-forward stand-down failed (secure content may stay \
invisible this session): {e:#}"
);
}
} else {
tracing::info!(
target_id = self.target_id,
"secure desktop dismissed — restoring the cursor render model"
);
// Re-declare only for the session that RUNS the cursor channel; a forced-composite
// session never wanted the declare (leaving the driver's desired state off also
// stops a reused worker's per-assign re-declares for good — the next channel
// session's open-time reset re-arms it).
if self.cursor_shared.is_some() {
if let Err(e) = fwd(true) {
tracing::warn!(
"secure-desktop cursor-forward re-enable failed (client-drawn cursor \
may double with a composited one): {e:#}"
);
}
}
}
}
fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
self.log_driver_status_once();
// The secure-desktop guard first: while UAC/Winlogon is up there may be NO fresh frames
// at all — this edge is what brings them back.
self.poll_secure_desktop();
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
self.poll_display_hdr();
// Recover-or-drop (GB1): if a descriptor change triggered a recreate but no fresh frame has resumed
// within the window, the IDD-push path can't follow the display (e.g. an exclusive-flip) — drop the
// session cleanly (the loop's `?` ends it → the client reconnects) rather than freeze forever.
if let Some(since) = self.recovering_since {
if since.elapsed() > Duration::from_secs(3) {
// Say WHY, from the driver's own evidence. `recreate_ring` cleared these fields, so
// whatever they hold now is this generation's re-attach — the one nothing else
// classifies (`wait_for_attach` runs at open only). Without it this bail was a bare
// "could not recover", and the driver's real diagnosis (a TEX_FAIL render-adapter
// mismatch, say, with the adapter it actually renders on) was sitting unread.
let (st, detail, lo, hi) = self.driver_diag();
bail!(
"IDD-push: display descriptor changed and the ring could not recover within 3s — \
dropping the session so the client reconnects. This generation's re-attach: \
driver_status={st} detail=0x{detail:08x} driver_render_luid={hi:08x}:{lo:08x} \
(0=never attached, 1=attached but published nothing, 2=could not open our \
textures — render-adapter mismatch, 4=refused the ring↔monitor binding)"
);
}
// Same idle-desktop stall as the open-time attach gate: after a mid-session ring
// recreate (HDR flip / mode change) an idle desktop composes nothing, so the fresh ring
// never sees a frame and the 3 s recover-or-drop above kills a healthy session. A
// stash-capable driver republishes its retained frame at the re-attach, so this kick
// is the legacy-driver fallback here too. Nudge DWM (rate-limited) once the natural
// post-recreate compose (and the stash republish) has had its chance. This is the ONE
// call site on the live frame path: the kick may BLOCK this (capture/encode) thread
// ~35 ms on its cursor-on-a-sibling-display branch (see `kick_dwm_compose`'s COST
// note) — acceptable only because we are already ≥600 ms into a recovery window with
// no frames arriving, and the 800 ms schedule below bounds the repeat rate.
if since.elapsed() > Duration::from_millis(600)
&& self.last_kick.elapsed() > Duration::from_millis(800)
{
self.last_kick = Instant::now();
tracing::debug!(
target_id = self.target_id,
"IDD push: no frame after ring recreate — falling back to a synthetic compose \
kick (stash-capable drivers republish at re-attach; old driver?)"
);
kick_dwm_compose(self.target_id);
}
}
// Driver-death watch (the SDR path has no other signal): a dead WUDFHost stops publishing,
// which at the ring is indistinguishable from an idle desktop — the encode loop would repeat
// the last frame forever (frozen video + live audio) and `next_frame`'s 20 s bail is
// unreachable once anything ever presented. While no fresh frame is arriving, probe the
// broker's pinned process handle (rate-limited) and fail the capturer so the session's
// rebuild path recreates output + ring against the restarted device.
if self.last_fresh.elapsed() > Duration::from_secs(2)
&& self.last_liveness.elapsed() > Duration::from_secs(1)
{
self.last_liveness = Instant::now();
if !self.broker.driver_alive() {
bail!(
"IDD-push: the pf-vdisplay WUDFHost (pid {}) exited mid-session — driver died; \
failing the capturer so the session rebuilds the virtual output",
self.broker.wudf_pid
);
}
}
let latest = self.latest();
// `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish
// whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0
// sentinel we reset to) so we never consume an unwritten new-ring slot — eliminating the
// toggle-time garbage frame.
let tok = frame::FrameToken::unpack(latest);
if tok.generation != self.generation {
return Ok(None);
}
let seq = u64::from(tok.seq);
let mut slot = tok.slot as usize;
let fresh = seq != self.last_seq && slot < self.slots.len();
let mut regen = false;
if !fresh {
// Composite cursor model: pointer-only motion produces NO new publish (the declared
// hardware cursor never dirties the frame), so a static desktop would freeze the
// blended pointer. Regenerate from the LAST slot whenever the polled cursor state
// changed — the re-converted out-ring frame carries the pointer's new position.
let moved = self.composite_cursor
&& self.last_slot.is_some()
&& self.cursor_blend_key() != self.last_blend_key;
if !moved {
return Ok(None);
}
slot = self.last_slot.expect("checked above");
if slot >= self.slots.len() {
return Ok(None); // ring shrank across a recreate — wait for a fresh publish
}
regen = true;
}
// Build the ring + converter BEFORE acquiring the slot so nothing between Acquire and Release
// can `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
// PyroWave uses its OWN two-plane ring (`pyro_ring`); everything else the single NV12/BGRA ring.
let i = self.out_idx;
let (out, pyro_slot) = if self.pyrowave {
self.ensure_pyro_ring()?;
self.ensure_pyro_conv()?;
let s = &self.pyro_ring[i];
(
None,
Some((
s.y.clone(),
s.y_rtv.clone(),
s.cbcr.clone(),
s.cbcr_rtv.clone(),
)),
)
} else {
self.ensure_out_ring()?;
self.ensure_converter()?;
let s = &self.out_ring[i];
(Some((s.tex.clone(), s.p010.clone())), None)
};
let (_, pf) = self.out_format();
let ring_len = if self.pyrowave {
self.pyro_ring.len()
} else {
self.out_ring.len()
};
// Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
// the slot back immediately and the encode of the PREVIOUS frame overlaps this convert.
// Clone the slot's COM interfaces (an AddRef each) so the guard borrows LOCALS, leaving
// `self` free for the composite blend prep inside the lock.
let (slot_tex, slot_srv, slot_mutex) = {
let s = &self.slots[slot];
(s.tex.clone(), s.srv.clone(), s.mutex.clone())
};
// Acquire the slot's keyed mutex via a RAII guard, scoped to JUST the convert/copy below so it
// releases at the same point as the old hand-written `ReleaseSync` (the driver gets the slot back
// immediately, NOT held across the rest of `try_consume`) — but now leak-proof on any early return.
{
let Some(_lock) = KeyedMutexGuard::acquire(&slot_mutex, 0, 8) else {
return Ok(None);
};
// SAFETY: convert on the owning (encode) thread's immediate context, holding the slot lock.
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
// the slot back to the driver.
unsafe {
// Composite cursor model: divert the convert input through the blend scratch —
// a slot copy with the pointer quad alpha-blended on top. `None` = compositing
// off or degraded (the conversion then reads the slot as always).
let blended = if self.composite_cursor {
self.prepare_blend_scratch(&slot_tex)
} else {
None
};
if self.pyrowave {
// PyroWave: ring slot SRV (BGRA for SDR, scRGB FP16 for HDR) → the two separate
// plane textures via the mode-aware CSC; the shared fence signalled just after
// (`pyro_fence_signal`) orders the encoder's cross-device Vulkan read after this
// convert. The composition format is pinned to the negotiated depth.
let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot");
if let Some(conv) = self.pyro_conv.as_ref() {
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
conv.convert(&self.context, src, y_rtv, cbcr_rtv, self.width, self.height)?;
}
} else if self.display_hdr {
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
if let Some(conv) = self.hdr_p010_conv.as_ref() {
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
let (_, rtvs) = out.as_ref().expect("out ring");
// The slot's P010 plane views, built once in `ensure_out_ring`.
let (y_rtv, uv_rtv) = rtvs.as_ref().expect("P010 out slot has plane RTVs");
conv.convert(&self.context, src, y_rtv, uv_rtv, self.width, self.height)?;
}
} else if self.want_444 {
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
// copy-engine move; the slot releases back to the driver immediately.
let src = blended.as_ref().map(|(t, _)| t).unwrap_or(&slot_tex);
self.context
.CopyResource(&out.as_ref().expect("out ring").0, src);
} else {
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
if let Some(conv) = self.video_conv.as_ref() {
let src = blended.as_ref().map(|(t, _)| t).unwrap_or(&slot_tex);
conv.convert(src, &out.as_ref().expect("out ring").0)?;
}
}
}
// `_lock` drops here → `ReleaseSync(0)`.
}
self.out_idx = (i + 1) % ring_len;
self.last_seq = seq;
if fresh {
self.last_slot = Some(slot);
}
if let Some((y, _, cbcr, _)) = pyro_slot.as_ref() {
self.pyro_last = Some((y.clone(), cbcr.clone()));
} else {
self.last_present = Some((out.as_ref().expect("out ring").0.clone(), pf));
}
let now = Instant::now();
if regen {
// A regen re-encodes OLD desktop content at a new pointer position — it is not a
// fresh driver frame; feeding the freshness/stall bookkeeping would mask a dead
// driver and pollute stall attribution.
} else if self.recovering_since.take().is_some() {
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
// recreate, already logged by the recreate path) — reset the stall watch so it
// doesn't read as a DWM stall.
self.stall_watch.reset();
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
// All of the reporting — the OS-event correlation, the two metronomic verdicts and
// the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was
// ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two
// counters were capturer fields that nothing else touched.
self.stall_watch.report(&stall, now);
}
if !regen {
self.last_fresh = now; // feeds the driver-death watch
}
// Build the frame. For PyroWave the encode input is the Y plane
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
// after the convert above. SAFETY: on the owning capture/encode thread.
let (texture, pyro) = if let Some((y, _, cbcr, _)) = pyro_slot {
// SAFETY: on the owning capture/encode thread holding the immediate context.
let (fence_handle, fence_value) =
unsafe { self.pyro_fence_signal() }?.expect("pyrowave session signals its fence");
(
y,
Some(PyroFrameShare {
cbcr,
fence_handle,
fence_value,
ring_gen: self.generation,
}),
)
} else {
(out.expect("out ring texture").0, None)
};
Ok(Some(CapturedFrame {
width: self.width,
height: self.height,
pts_ns: now_ns(),
format: pf,
payload: FramePayload::D3d11(D3d11Frame {
texture,
device: self.device.clone(),
pyro,
}),
cursor: None,
}))
}
fn repeat_last(&mut self) -> Option<CapturedFrame> {
// Copy the last presented frame into a FRESH rotated out-ring slot so a repeat (static desktop, no
// new driver frame) never re-hands a slot that may still be encoding under pipeline_depth>1 — the
// out-ring rotation IS the texture-ownership contract, and repeats must honor it too (audit §5.3).
// OUT_RING(3) > the max pipeline_depth(2) guarantees the rotated slot is not in flight.
let i = self.out_idx;
// PyroWave: copy the last Y+CbCr into a fresh two-plane slot; texture = Y, CbCr + fence in `pyro`.
if self.pyrowave {
let (src_y, src_cbcr) = self.pyro_last.clone()?;
let slot = self.pyro_ring.get(i)?;
let (dst_y, dst_cbcr) = (slot.y.clone(), slot.cbcr.clone());
// SAFETY: GPU copies on the owning thread's immediate context; src/dst are our own pyro-ring
// plane textures of identical format/size.
unsafe {
self.context.CopyResource(&dst_y, &src_y);
self.context.CopyResource(&dst_cbcr, &src_cbcr);
}
self.out_idx = (i + 1) % self.pyro_ring.len();
self.pyro_last = Some((dst_y.clone(), dst_cbcr.clone()));
// Fence the copies above so the encoder reads completed textures. SAFETY: owning thread.
let (fence_handle, fence_value) = match unsafe { self.pyro_fence_signal() } {
Ok(Some(f)) => f,
_ => {
tracing::warn!("pyrowave: fence signal failed on a repeat frame — dropping it");
return None;
}
};
return Some(CapturedFrame {
width: self.width,
height: self.height,
pts_ns: now_ns(),
format: self.out_format().1,
payload: FramePayload::D3d11(D3d11Frame {
texture: dst_y,
device: self.device.clone(),
pyro: Some(PyroFrameShare {
cbcr: dst_cbcr,
fence_handle,
fence_value,
ring_gen: self.generation,
}),
}),
cursor: None,
});
}
let (src, pf) = self.last_present.clone()?;
let dst = self.out_ring.get(i)?.tex.clone();
// SAFETY: GPU copy on the owning thread's immediate context; src/dst are our out-ring textures of
// identical format/size (src is a previous out-ring slot; dst the next).
unsafe {
self.context.CopyResource(&dst, &src);
}
self.out_idx = (i + 1) % self.out_ring.len();
self.last_present = Some((dst.clone(), pf));
Some(CapturedFrame {
width: self.width,
height: self.height,
pts_ns: now_ns(),
format: pf,
payload: FramePayload::D3d11(D3d11Frame {
texture: dst,
device: self.device.clone(),
pyro: None,
}),
cursor: None,
})
}
}
/// Duplicate `cs`'s section into the driver's WUDFHost and send `IOCTL_SET_CURSOR_CHANNEL`.
/// `true` = the driver adopted it (worker declared per its `cursor_forward_on` state). Shared by
/// the open-time delivery and every RE-delivery (ring recreate / flip NOT_FOUND) — the request is
/// idempotent driver-side (a replaced worker is stopped + joined).
fn deliver_cursor_channel(
broker: &ChannelBroker,
target_id: u32,
cs: &cursor::CursorShared,
send_cursor: &crate::CursorChannelSender,
) -> bool {
// SAFETY: `cs.section_handle()` borrows the section mapping `cs` owns (live across this
// synchronous call); the broker's WUDFHost process handle is live for the broker's lifetime.
let value = match unsafe { broker.dup_into_public(cs.section_handle()) } {
Ok(v) => v,
Err(e) => {
tracing::warn!("cursor section duplication failed (composited cursor stays): {e:#}");
return false;
}
};
let req = pf_driver_proto::control::SetCursorChannelRequest {
target_id,
_pad: 0,
header_handle: value,
};
match send_cursor(&req) {
Ok(()) => {
tracing::info!(
target_id,
"IDD push(host): cursor channel delivered — driver declares the hardware cursor"
);
true
}
Err(e) => {
broker.close_remote_public(value);
tracing::warn!("cursor channel delivery failed (composited cursor stays): {e:#}");
false
}
}
}
impl Capturer for IddPushCapturer {
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
self.live_cursor()
}
fn set_cursor_forward(&mut self, on: bool) {
// The composite (capture) model is implemented HOST-side: the driver's hardware cursor
// stays declared for the session's whole life — the only dependable state (there is NO
// working un-declare; see cursor_blend.rs) — keeping every frame pointer-free, and the
// capturer blends the GDI poller's shape into the frame itself. No driver round-trip.
// `composite_forced` (a channel-less session on a sticky-declared target) is pinned ON:
// with no client drawing, un-compositing would erase the pointer entirely.
let composite = (!on && self.cursor_shared.is_some()) || self.composite_forced;
if self.composite_cursor != composite {
self.composite_cursor = composite;
self.last_blend_key = None; // regenerate immediately at the current pointer state
tracing::info!(
composite,
"cursor render model: host compositing {}",
if composite {
"ON (capture model — blending the pointer into frames)"
} else {
"OFF (client draws locally)"
}
);
}
}
fn next_frame(&mut self) -> Result<CapturedFrame> {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
// SAFETY: `self.event` is the live frame-ready `OwnedHandle` this capturer owns; its raw value
// (borrowed for the call, so it outlives this synchronous wait) is a valid auto-reset event
// handle. `WaitForSingleObject` only reads the handle; the 16 ms timeout bounds the wait.
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 16) };
if let Some(f) = self.try_consume()? {
return Ok(f);
}
if let Some(f) = self.repeat_last() {
return Ok(f);
}
if Instant::now() > deadline {
let (st, detail, lo, hi) = self.driver_diag();
bail!(
"no IDD-push frame within 20s (target {}) — driver_status={st} detail=0x{detail:08x} \
driver_render_luid={hi:08x}:{lo:08x}. 0=driver never attached (swap-chain not \
assigned / driver not active), 1=attached but no frames (idle desktop?), 2=driver \
couldn't open our textures (render-adapter mismatch).",
self.target_id
);
}
}
}
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
self.try_consume()
}
fn supports_arrival_wait(&self) -> bool {
true
}
fn wait_arrival(&mut self, deadline: Instant) {
// Frame-driven trigger (latency plan T1.1): wake the encode loop the moment the driver
// publishes a frame we haven't consumed. The shared-header token is the truth (state,
// not edge — the auto-reset event may have been consumed by an earlier wait); the event
// is just the wakeup, waited in ≤16 ms slices exactly like `next_frame`'s bringup wait.
loop {
let tok = frame::FrameToken::unpack(self.latest());
if tok.generation == self.generation && u64::from(tok.seq) != self.last_seq {
return; // a fresh publish exists — `try_latest` will consume it
}
let Some(left) = deadline.checked_duration_since(Instant::now()) else {
return;
};
let ms = (left.as_millis() as u32).clamp(1, 16);
// SAFETY: `self.event` is the live frame-ready `OwnedHandle` this capturer owns; its
// raw value (borrowed for the call, so it outlives this synchronous wait) is a valid
// auto-reset event handle. `WaitForSingleObject` only reads the handle; the bounded
// timeout keeps the wait sliced.
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), ms) };
}
}
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
// While the display is HDR we emit BT.2020 PQ (Rgb10a2) → the encoder forces HEVC Main10 + the
// PQ VUI; pair that with a mastering-display SEI so any decoder tone-maps from a real grade. The
// driver doesn't (yet) forward the OS's IDDCX_HDR10_METADATA, so use the generic HDR10 baseline
// (the same metadata the native HDR path sends on the 0xCE datagram).
self.display_hdr.then(pf_frame::hdr::generic_hdr10)
}
fn pipeline_depth(&self) -> usize {
// 2 = one frame deferred: submit N+1 (capture + convert/copy into a fresh out-ring texture) while
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe.
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
// always has its own texture).
pf_host_config::config().idd_depth.clamp(1, OUT_RING)
}
fn capture_target_id(&self) -> Option<u32> {
Some(self.target_id)
}
fn resize_output(&mut self, width: u32, height: u32) -> bool {
// Host-initiated resize (latency plan P2.3): the session's resize handler has already
// committed the display's new mode (the manager's in-place mode set), so recreate the ring
// at the new size NOW — no DescriptorPoller two-strike debounce (that stays, unchanged,
// for EXTERNAL changes: HDR flips, game mode-sets). The driver re-attaches to the fresh
// ring and republishes; on an in-place mode set the OS's mode-set full redraw gives the
// stash/first frame within the recover window. Same recover-or-drop arming as the
// poller-driven recreate, so a ring that can't re-attach still fails the session cleanly
// instead of freezing.
if (width, height) == (self.width, self.height) {
return true; // already at the requested size (refresh-only change) — nothing to do
}
tracing::info!(
target_id = self.target_id,
from = format!("{}x{}", self.width, self.height),
to = format!("{width}x{height}"),
"IDD push: host-initiated resize — recreating the ring at the new mode"
);
self.recovering_since.get_or_insert_with(Instant::now);
if let Err(e) = self.recreate_ring(self.display_hdr, width, height) {
tracing::warn!(
error = %format!("{e:#}"),
"IDD push: host-initiated ring recreate failed — falling back to a full rebuild"
);
return false;
}
true
}
fn recreate_ring_in_place(&mut self) -> bool {
// Same-mode ring recreate (trait doc: swap-chain bounce recovery) — deliberately NOT
// routed through `resize_output`, whose same-size fast path would no-op exactly the
// case this exists for. Same recover-or-drop arming as the resize recreate.
//
// Restart OS presentation FIRST: the eviction's topology commit leaves DWM not
// presenting to this display, so a re-attached ring would only ever receive the
// driver's stash (measured: new_fps=0 forever after the re-attach). CDS_RESET forces a
// real mode-set at the CURRENT mode — the same lever bring-up's ADD path relies on —
// and the ring recreate below then re-attaches after that churn, not before it.
// SAFETY: `resolve_gdi_name` runs the CCD query FFI over a `Copy` target id (owned
// return) — same contract as every sibling call in this file.
match unsafe { pf_win_display::win_display::resolve_gdi_name(self.target_id) } {
Some(gdi) => {
if !pf_win_display::win_display::force_mode_reset(&gdi) {
tracing::warn!(
target_id = self.target_id,
"IDD push: presentation-restart mode reset failed — re-attaching anyway"
);
}
}
None => tracing::warn!(
target_id = self.target_id,
"IDD push: no GDI name for the presentation-restart mode reset — re-attaching \
anyway"
),
}
tracing::info!(
target_id = self.target_id,
mode = format!("{}x{}", self.width, self.height),
"IDD push: same-mode ring recreate — re-running the driver attach handshake"
);
self.recovering_since.get_or_insert_with(Instant::now);
if let Err(e) = self.recreate_ring(self.display_hdr, self.width, self.height) {
tracing::warn!(
error = %format!("{e:#}"),
"IDD push: same-mode ring recreate failed"
);
return false;
}
true
}
}
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
/// desktop needs the PQ tone curve, which the P010 shader provides at 4:2:0), so the stream
/// honestly downgrades — the encoder's `chroma_444` caps cross-check reports it and the in-band
/// SPS keeps the client decoding correctly. Once per process: the state can flap mid-session.
fn warn_444_hdr_downgrade_once() {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::warn!(
"4:4:4 negotiated but the display is HDR — no 10-bit full-chroma source exists; \
encoding HDR 4:2:0 (P010) instead (disable HDR on the virtual display for 4:4:4)"
);
}
}
impl Drop for IddPushCapturer {
fn drop(&mut self) {
// A channel session ending while the secure-desktop guard is engaged must not leave the
// driver's per-target desired state off — the next session's channel delivery would
// adopt UNdeclared and silently run the composite model (§8.6's cross-session trap).
// The open-time reset also covers this (host-crash case); this is the orderly-teardown
// belt.
if self.secure_active && self.cursor_shared.is_some() {
if let Some(fwd) = self.cursor_forward.as_ref() {
let _ = fwd(true);
}
}
self.slots.clear();
// The shared header section (`MappedSection`), the frame-ready `event` (`OwnedHandle`) and the
// broker's WUDFHost process handle free themselves via RAII (unmap view, then close handle) —
// nothing of this session's channel outlives the capturer on the host side; the driver's
// duplicates die with its publisher / monitor / WUDFHost (teardown invariant,
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
}
}
#[cfg(test)]
mod tests {
use super::stall::Stall;
use super::*;
/// W14: the mint must stay inside the publish token's 24-bit generation field, and must skip 0.
///
/// `IDD_GENERATION` is a full `u32` while `FrameToken` carries 24 bits and `unpack` MASKS what it
/// reads, so an unmasked `self.generation` stops matching any token past 2²⁴ recreates and
/// `try_consume`'s `tok.generation != self.generation` becomes permanently true — every frame
/// rejected, forever. The counter is parked just below the boundary here so the wrap is what gets
/// exercised, not the happy path. (Same-module access to the private static; no capturer is
/// running, and no other test touches it.)
#[test]
fn the_ring_generation_survives_the_publish_token() {
IDD_GENERATION.store(frame::FrameToken::GENERATION_MASK - 2, Ordering::Relaxed);
let mut seen = Vec::new();
for _ in 0..8 {
let g = next_generation();
assert_ne!(g, 0, "0 also means the cleared-`latest` sentinel");
assert_eq!(
g & frame::FrameToken::GENERATION_MASK,
g,
"generation {g} does not fit the token's field"
);
// The round trip `try_consume` actually performs.
let tok = frame::FrameToken {
generation: g,
seq: 12345,
slot: 2,
};
let back = frame::FrameToken::unpack(tok.pack());
assert_eq!(back.generation, g, "generation lost in the token");
assert_eq!(back.seq, 12345, "seq lost in the token");
assert_eq!(back.slot, 2, "slot lost in the token");
seen.push(g);
}
// The wrap really happened (we started 2 below the mask), and produced no duplicate 0.
assert!(
seen.contains(&frame::FrameToken::GENERATION_MASK),
"{seen:?}"
);
assert!(
seen.iter().any(|&g| g < 8),
"the counter should have wrapped: {seen:?}"
);
}
/// The 0 sentinel `recreate_ring` stores must be REJECTED by the generation compare, whatever
/// the live generation is — that is what stops a consume from the unwritten new ring.
#[test]
fn the_cleared_latest_sentinel_never_matches_a_live_generation() {
let cleared = frame::FrameToken::unpack(0);
assert_eq!(cleared.generation, 0);
assert_eq!(cleared.seq, 0);
for g in [1u32, 2, 0x7F_FFFF, frame::FrameToken::GENERATION_MASK] {
assert_ne!(cleared.generation, g, "sentinel matched generation {g}");
}
}
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
/// return what each `note_fresh` produced.
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<Stall>> {
let base = Instant::now();
let mut w = StallWatch::new();
offsets_ms
.iter()
.map(|ms| w.note_fresh(base + Duration::from_millis(*ms)))
.collect()
}
/// 60 fps flow (16 ms cadence) for `frames` frames starting at `start_ms`, appended to `out`.
fn flow(out: &mut Vec<u64>, start_ms: u64, frames: u64) {
out.extend((0..frames).map(|i| start_ms + i * 16));
}
#[test]
fn stall_detected_after_active_flow() {
// 20 frames of 60 fps flow, then a 300 ms hole — the resuming frame reads as a stall.
let mut t = Vec::new();
flow(&mut t, 0, 20); // last frame at 304 ms
t.push(604);
let out = watch_run(&t);
assert!(out[..20].iter().all(Option::is_none));
let stall = out[20].as_ref().expect("hole after active flow is a stall");
assert_eq!(stall.gap.as_millis(), 300);
assert!(stall.metronomic.is_none(), "one stall is not a cycle");
}
#[test]
fn idle_desktop_gaps_are_not_stalls() {
// Caret-blink damage: frames ~530 ms apart — the activity gate never opens, so neither
// the blink gaps nor a long idle hole count.
let t: Vec<u64> = (0..12).map(|i| i * 530).chain([20_000]).collect();
assert!(watch_run(&t).iter().all(Option::is_none));
}
#[test]
fn thirty_fps_content_still_qualifies_as_active() {
// A 30 fps-capped game (33 ms cadence): 8 pre-gap frames span 231 ms ≤ ACTIVE_SPAN, so a
// 200 ms hole still reads as a stall.
let mut t: Vec<u64> = (0..10).map(|i| i * 33).collect(); // last at 297 ms
t.push(497);
let out = watch_run(&t);
assert!(out[10].is_some(), "30 fps flow must pass the activity gate");
}
#[test]
fn metronomic_stalls_self_diagnose() {
// The field signature: ~300 ms DWM holes every 4 s inside 60 fps flow. Stalls land at the
// cycle BOUNDARIES (5 cycles → 4 stalls); the 4th completes the metronome streak and
// reports the ~4 s period.
let mut t = Vec::new();
for cycle in 0..5u64 {
// ~3.7 s of flow, then the hole to the next cycle start.
flow(&mut t, cycle * 4_000, 232); // last frame at cycle*4000 + 3696
}
let out = watch_run(&t);
let stalls: Vec<&Stall> = out.iter().flatten().collect();
assert_eq!(stalls.len(), 4, "each cycle boundary is one stall");
assert!(stalls[..3].iter().all(|s| s.metronomic.is_none()));
let period = stalls[3]
.metronomic
.expect("the 4th evenly-spaced event completes the metronome streak");
assert!(
(period.as_secs_f64() - 4.0).abs() < 0.3,
"period={period:?}"
);
}
#[test]
fn reset_swallows_the_recreate_gap() {
// Active flow, then a ring recreate (reset), then flow resumes 800 ms later — the resume
// frame must NOT read as a stall, and detection re-arms afterwards.
let base = Instant::now();
let at = |ms: u64| base + Duration::from_millis(ms);
let mut w = StallWatch::new();
for i in 0..20u64 {
assert!(w.note_fresh(at(i * 16)).is_none());
}
w.reset();
assert!(w.note_fresh(at(1_104)).is_none(), "recreate gap swallowed");
for i in 1..20u64 {
assert!(w.note_fresh(at(1_104 + i * 16)).is_none());
}
assert!(
w.note_fresh(at(1_104 + 19 * 16 + 300)).is_some(),
"detection re-armed after the reset"
);
}
}