//! 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(&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> { // 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, /// 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, /// 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, /// 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, /// 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_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, /// 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, /// 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, /// 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, /// 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, /// PyroWave: the BGRA→YUV-planes CSC (BT.709 limited, matching `rgb2yuv.comp`). Built lazily. pyro_conv: Option, /// 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, /// 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, /// 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, 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, /// 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, 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, _keepalive: Box, } // 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::()`), 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 = 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 = None; dev.CreateTexture2D(&desc, None, Some(&mut tex)) .context("CreateTexture2D(pyro plane)")?; let tex = tex.context("null pyro plane texture")?; let mut rtv: Option = 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, 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 = 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 { 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 = None; let built = self .device .CreateTexture2D(&desc, None, Some(&mut tex)) .ok() .and(tex) .and_then(|t| { let mut srv: Option = 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> { 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 { // 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 { 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 { 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> { 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 { // 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 { 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> { 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, 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 = (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 = (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" ); } }