diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index 18f48074..86994c86 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -193,10 +193,20 @@ impl Drop for KeyedMutexGuard<'_> { } } -/// Nudge DWM into composing THE TARGET virtual display. DWM presents a display only when something -/// DIRTIES it — an idle desktop never does, so a freshly-attached ring (session open, or a -/// mid-session ring recreate) can sit at E_PENDING with no first frame even though everything is -/// healthy. pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into +/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a +/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring +/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even +/// though everything is healthy. +/// +/// The PRIMARY first-frame mechanism is the driver's `FrameStash` (frame_transport.rs): the driver +/// retains the last composed frame and republishes it into every freshly-attached ring, so with a +/// stash-capable driver the first frame lands milliseconds after the channel delivery and this kick +/// never fires. It remains for pre-stash drivers and for the empty-stash cold start (a monitor that +/// has NEVER composed — normally the activation compose covers that). Synthetic input is inherently +/// unreliable — blocked on the secure desktop, defeated by a fullscreen game's ClipCursor, and +/// user-visible in the sibling-display case — which is exactly why it was demoted to fallback. +/// +/// pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into /// the frame — a guaranteed real present onto the IDD swap-chain (empirically what /// `punktfunk-probe --input-test` always relied on). /// @@ -1185,9 +1195,12 @@ impl IddPushCapturer { /// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case: /// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard /// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past - /// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom). At - /// session open the OS activates the virtual display → DWM composites it → a frame arrives within ~1 s, - /// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken. + /// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom). + /// A stash-capable driver republishes its retained desktop frame the moment it attaches (the + /// first-frame guarantee — `FrameStash`, driver frame_transport.rs), so the normal case clears this + /// gate in milliseconds even on an idle desktop; failing that, at session open the OS activates the + /// virtual display → DWM composites it → a frame arrives within ~1 s, plus the compose-kick fallback + /// below — no frame within the window = genuinely broken. fn wait_for_attach(&self) -> Result<()> { // Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR // monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a @@ -1204,11 +1217,15 @@ impl IddPushCapturer { ); } let deadline = Instant::now() + Duration::from_secs(4); - // Compose-kick schedule: DWM only presents a display something DIRTIED, so on an idle - // desktop a perfectly healthy attach sees no first frame (E_PENDING forever) and this gate - // used to fail the session — the "idle desktop → no frames" gotcha (a real client escaped - // it only because its own input soon dirtied the desktop; a headless probe never did). - // Give the natural post-activate compose a moment, then nudge. + // First-frame expectation: a stash-capable driver republishes its retained desktop frame + // the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the + // gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick + // schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has + // never composed): DWM only presents a display something DIRTIED, so on an idle desktop + // an attach would otherwise sit at E_PENDING forever and fail this gate — the + // "idle desktop → no frames" gotcha. Give the natural post-activate compose (and the + // stash republish) a moment, then nudge; log when we do, so field logs show whether the + // stash path is working. let mut next_kick = Instant::now() + Duration::from_millis(600); loop { // SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized @@ -1261,6 +1278,14 @@ impl IddPushCapturer { return Ok(()); } if Instant::now() >= next_kick { + // Reaching a kick at all means the driver did NOT republish a retained frame + // (pre-stash driver, or a never-composed display) — worth a line in the field log. + tracing::debug!( + target_id = self.target_id, + driver_status = st, + "IDD push: no first frame after attach delivery — falling back to a synthetic \ + compose kick (stash-capable drivers republish instantly; old driver?)" + ); kick_dwm_compose(self.target_id); next_kick = Instant::now() + Duration::from_millis(800); } @@ -1558,12 +1583,19 @@ impl IddPushCapturer { } // 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. Nudge - // DWM (rate-limited) once the natural post-recreate compose has had its chance. + // 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. 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); } } diff --git a/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs b/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs index c2e8fc21..d5e1fb2d 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs @@ -18,8 +18,15 @@ //! layout, the [`FrameToken`] packing, the `MAGIC`/`RING_LEN`, the `DRV_STATUS_*` codes and the //! channel-delivery struct are NOT hand-duplicated here: both sides `use pf_driver_proto::{control, //! frame}`, which OWNS the contract (with `const` size asserts so any drift is a compile error). +//! +//! This module also owns the [`FrameStash`] — the driver-retained copy of the most recent composed +//! frame that the swap-chain worker republishes into every freshly-attached ring, so a session +//! opening onto an IDLE desktop gets its first frame immediately instead of waiting for something +//! to dirty the display (the first-frame guarantee; see the type docs). Purely driver-internal +//! behavior — no proto change: the host just sees a normal `seq = 1` publish right after attach. use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::Instant; use pf_driver_proto::control::SetFrameChannelRequest; use pf_driver_proto::frame::{ @@ -28,8 +35,10 @@ use pf_driver_proto::frame::{ }; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Graphics::Direct3D11::{ - D3D11_TEXTURE2D_DESC, ID3D11Device, ID3D11Device1, ID3D11DeviceContext, ID3D11Texture2D, + D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, ID3D11Device, ID3D11Device1, ID3D11DeviceContext, + ID3D11Texture2D, }; +use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC; use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex; use windows::Win32::System::Memory::{ FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, UnmapViewOfFile, @@ -124,6 +133,137 @@ struct Slot { mutex: IDXGIKeyedMutex, } +/// The driver-retained copy of the most recent composed frame — the FIRST-FRAME GUARANTEE. +/// +/// DWM presents a display only when something DIRTIES it, so a ring freshly attached over an idle +/// desktop could wait forever for its first frame (session open onto a static desktop = black +/// stream until something repaints). DXGI Desktop Duplication never had this problem because the +/// OS seeds a new duplication with the CURRENT desktop image; this stash reconstructs that +/// guarantee for the IDD-push path. The swap-chain worker copies into it every composed frame it +/// canNOT deliver to a live ring (no publisher attached, or a size/format-mismatched surface +/// racing the host's ring recreate) and HARVESTS a superseded publisher's last-published slot, so +/// at every attach the freshest desktop image is republished into the new ring immediately — no +/// compose to wait for, no synthetic-input "kick" needed on the host. +/// +/// Costs nothing at steady state: a matched publish goes ONLY into the ring, and between sessions +/// the still-attached publisher keeps writing the (dead) previous ring, which the harvest then +/// reads — the extra `CopyResource` happens only for unattached/mismatched frames, which are +/// damage-driven and rare. +pub struct FrameStash { + /// Lazily (re)created at the source's size/format: a plain default-usage texture (no bind or + /// misc flags — a pure copy source/target) on the worker's pooled device. + tex: Option, + /// When the retained content was captured (monotonic). Harvest only overwrites OLDER content, + /// so a superseded publisher's last frame can never clobber a fresher mismatch-stashed surface + /// (e.g. the first FP16 frames of an HDR flip stashed while the ring was still BGRA). + stored_at: Option, +} + +// SAFETY: like `FramePublisher` — created and used only on the swap-chain worker thread; the +// preserved hand-off across workers is serialized by the monitor stash's Mutex. +unsafe impl Send for FrameStash {} + +impl Default for FrameStash { + fn default() -> Self { + Self::new() + } +} + +impl FrameStash { + pub const fn new() -> Self { + Self { + tex: None, + stored_at: None, + } + } + + /// The retained frame, if any content has been stored. + pub fn texture(&self) -> Option<&ID3D11Texture2D> { + if self.stored_at.is_some() { + self.tex.as_ref() + } else { + None + } + } + + /// When the retained content was captured (`None` = empty). + pub fn stored_at(&self) -> Option { + self.stored_at + } + + /// Copy `src` into the stash — (re)creating the stash texture if the size/format differ — and + /// stamp `at` as the content's capture instant. Best-effort: a failed texture create leaves the + /// stash empty (the attach republish then simply has nothing, which is the old behavior). + /// + /// The CALLER owns `src`'s synchronization: a ring slot's keyed mutex must be held across this + /// call (harvest), and a swap-chain surface is exclusively ours pre-`FinishedProcessingFrame`. + pub fn store( + &mut self, + device: &ID3D11Device, + context: &ID3D11DeviceContext, + src: &ID3D11Texture2D, + at: Instant, + ) { + let mut desc = D3D11_TEXTURE2D_DESC::default(); + // SAFETY: `src` is a live texture per the caller's contract; `desc` is a valid local out-param. + unsafe { src.GetDesc(&mut desc) }; + let matches = self.tex.as_ref().is_some_and(|t| { + let mut d = D3D11_TEXTURE2D_DESC::default(); + // SAFETY: `t` is the live stash texture; `d` is a valid local out-param. + unsafe { t.GetDesc(&mut d) }; + d.Width == desc.Width && d.Height == desc.Height && d.Format == desc.Format + }); + if !matches { + self.tex = None; + self.stored_at = None; + // Struct-update from `default()` so the flag fields keep their zero default whatever + // their windows-crate type — a copy-only texture wants no bind/misc/CPU flags (in + // particular NOT the source's SHARED_KEYEDMUTEX, which would gate every copy). + let make = D3D11_TEXTURE2D_DESC { + Width: desc.Width, + Height: desc.Height, + MipLevels: 1, + ArraySize: 1, + Format: desc.Format, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + ..Default::default() + }; + let mut t: Option = None; + // SAFETY: `device` is the worker's live pooled device; `make` is a fully-initialized + // local desc and `t` a valid out-param, checked below (best-effort on failure). + if unsafe { device.CreateTexture2D(&make, None, Some(&mut t)) }.is_err() { + return; + } + self.tex = t; + } + if let Some(t) = self.tex.as_ref() { + // SAFETY: `t` and `src` are live, size/format-matched textures on the same (pooled) + // device; the pooled immediate context is multithread-protected (`Direct3DDevice`). + unsafe { context.CopyResource(t, src) }; + self.stored_at = Some(at); + } + } +} + +/// What [`FramePublisher::publish`] did with a surface — the worker feeds the [`FrameStash`] on +/// the outcomes where the ring did NOT take the frame. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PublishOutcome { + /// Copied into a slot + signaled the host. + Published, + /// The surface's size/format doesn't match the ring (a display mode-set / HDR flip racing the + /// host's ring recreate) — worth stashing: it is the freshest desktop image, in exactly the + /// descriptor the recreated ring will want. + DescMismatch, + /// Dropped without publishing (all slots busy / device error) — the host is alive and + /// consuming, so there is nothing to retain. + Dropped, +} + /// Publishes acquired swap-chain surfaces into the HOST-created ring. Owned by the swap-chain processor /// thread; attached lazily once the host's channel delivery lands in the monitor stash. pub struct FramePublisher { @@ -146,6 +286,9 @@ pub struct FramePublisher { /// Set when a surface is dropped for a descriptor mismatch (a game mode-set the display), cleared on a /// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1). mismatch_logged: bool, + /// The slot of the most recent successful publish + when it happened — what [`Self::harvest_into`] + /// reads when this publisher is superseded. `None` until the first publish. + last_published: Option<(u32, Instant)>, /// The render adapter (LUID) this publisher's device + opened ring textures live on. A worker /// re-adopts a publisher preserved across a swap-chain unassign→reassign flap ONLY when the /// freshly-assigned swap-chain renders on this SAME adapter (else the opened textures would be @@ -362,6 +505,7 @@ impl FramePublisher { ring_format: unsafe { (*header).dxgi_format }, generation: header_gen, mismatch_logged: false, + last_published: None, render_luid_low, render_luid_high, }) @@ -394,11 +538,50 @@ impl FramePublisher { (self.render_luid_low, self.render_luid_high) } + /// Copy the most recently PUBLISHED frame out of the ring into `stash` — called just before this + /// publisher is dropped for a supersede (a mid-session ring recreate, or a new session's channel + /// delivery), when the ring it wrote is about to become unreachable. Between sessions the driver + /// keeps publishing into the previous (host-side dead) ring, so the last-written slot IS the + /// current desktop image — harvesting it seeds the next attach's first-frame republish. Skips + /// when the stash already holds fresher content (see [`FrameStash::stored_at`]) or the slot's + /// keyed mutex can't be had within 8 ms (a live host mid-consume — frames are flowing anyway). + pub fn harvest_into(&self, device: &ID3D11Device, stash: &mut FrameStash) { + let Some((slot, at)) = self.last_published else { + return; + }; + if stash.stored_at().is_some_and(|s| s >= at) { + return; + } + let Some(s) = self.slots.get(slot as usize) else { + return; + }; + // SAFETY: `s.mutex` is the live keyed mutex on this ring slot's shared texture; an 8 ms + // try-acquire of key 0. Raw vtable call for the same reason as `publish` below: the `Result` + // wrapper erases the success-severity WAIT_TIMEOUT/WAIT_ABANDONED codes. + let hr = + unsafe { (Interface::vtable(&s.mutex).AcquireSync)(Interface::as_raw(&s.mutex), 0, 8) }; + match hr.0 { + // Acquired — S_OK, or WAIT_ABANDONED (the host died holding the slot: ownership + // transferred to us; exactly the between-sessions case the harvest exists for). + 0 | WAIT_ABANDONED_HRESULT => { + // STRAIGHT-LINE between acquire and release (`store` is infallible-by-contract: + // best-effort, no early return propagates past it), so the lock cannot leak. + stash.store(device, &self.context, &s.tex, at); + // SAFETY: the keyed mutex is held (acquired above); release it exactly once. + unsafe { + let _ = s.mutex.ReleaseSync(0); + } + } + // Busy or a genuine error — keep whatever the stash had. + _ => {} + } + } + /// Copy `surface` into the next free ring slot and signal the host. Never blocks (0 ms try-acquire). - pub fn publish(&mut self, surface: &ID3D11Texture2D) { + pub fn publish(&mut self, surface: &ID3D11Texture2D) -> PublishOutcome { let ring_len = self.slots.len() as u32; if ring_len == 0 { - return; + return PublishOutcome::Dropped; } // Format guard: `CopyResource` needs the surface + ring textures to share a DXGI format. Drop a // frame that doesn't match (e.g. an FP16 HDR surface arriving while the ring is still BGRA, before @@ -425,7 +608,7 @@ impl FramePublisher { self.ring_format ); } - return; + return PublishOutcome::DescMismatch; } self.mismatch_logged = false; let start = self.next; @@ -474,15 +657,17 @@ impl FramePublisher { let _ = SetEvent(self.event); } self.next = (slot + 1) % ring_len; - return; + self.last_published = Some((slot, Instant::now())); + return PublishOutcome::Published; } // Busy — the host holds this slot (the designed backpressure): try the next one. WAIT_TIMEOUT_HRESULT => continue, // Genuine failure (negative HRESULT — device removed / invalid call): drop the frame. - _ => return, + _ => return PublishOutcome::Dropped, } } // All slots busy — drop this frame (never block the swap-chain thread). + PublishOutcome::Dropped } } diff --git a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs index 2f9d950c..513f4426 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs @@ -69,6 +69,12 @@ pub struct MonitorObject { /// ([`take_preserved_publisher`]) and resumes publishing into the same ring. Dropped with the /// `MonitorObject` on teardown (closing its ring handles) if no worker ever reclaims it. pub preserved_publisher: Option, + /// The worker's [`FrameStash`](crate::frame_transport::FrameStash) (the retained last composed + /// frame — the first-frame guarantee) preserved across a swap-chain unassign→reassign flap, + /// tagged with the render-adapter LUID its texture lives on: the next worker re-adopts it only + /// on the SAME adapter (same pooled device — a cross-device texture would be unusable). Dropped + /// with the `MonitorObject` on teardown (it holds only a driver-private texture, no handles). + pub preserved_stash: Option<(crate::frame_transport::FrameStash, u32, i32)>, /// When the entry was created — the watchdog skips still-initializing monitors. pub created_at: Instant, } @@ -372,6 +378,46 @@ pub fn take_preserved_publisher( .take() } +/// Preserve an EXITING worker's [`FrameStash`](crate::frame_transport::FrameStash) on its monitor +/// across a swap-chain unassign→reassign flap, tagged with the render-adapter LUID it lives on +/// (see [`MonitorObject::preserved_stash`]). An empty stash, or one for a monitor that no longer +/// exists (genuine teardown), is simply dropped — unlike a publisher it owns no handles, so there +/// is nothing to hand back. +pub fn preserve_stash( + target_id: u32, + luid_low: u32, + luid_high: i32, + stash: crate::frame_transport::FrameStash, +) { + if target_id == 0 || stash.texture().is_none() { + return; + } + let mut lock = lock_monitors(); + if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) { + m.preserved_stash = Some((stash, luid_low, luid_high)); + } +} + +/// Take (remove) the preserved [`FrameStash`](crate::frame_transport::FrameStash) for a freshly- +/// (re)assigned swap-chain worker — returned only when the worker's render adapter matches the one +/// the stash was preserved on (same pooled device); a mismatched stash is dropped (its texture +/// would be cross-device). +pub fn take_preserved_stash( + target_id: u32, + luid_low: u32, + luid_high: i32, +) -> Option { + if target_id == 0 { + return None; + } + let (stash, low, high) = lock_monitors() + .iter_mut() + .find(|m| m.target_id == target_id)? + .preserved_stash + .take()?; + ((low, high) == (luid_low, luid_high)).then_some(stash) +} + /// Install a swap-chain processor on the monitor whose handle matches, returning any PREVIOUS processor /// for the caller to drop OUTSIDE the lock. Dropping a processor RAII-joins its worker thread, so it must /// never happen while holding `MONITOR_MODES` (the worker would block the whole control plane / risk a @@ -461,6 +507,7 @@ pub fn create_monitor( swap_chain_processor: None, frame_channel: None, preserved_publisher: None, + preserved_stash: None, created_at: Instant::now(), }); id diff --git a/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs b/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs index 381ead74..7283830a 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs @@ -7,7 +7,9 @@ //! (`IddCxSwapChainSetDevice`) and loops acquire/finish. STEP 6 lazily attaches a [`FramePublisher`] to //! the host's shared ring and, on each acquired frame, `CopyResource`s `out.MetaData.pSurface` into the //! next ring slot before finishing the frame (a non-IDD-push session simply never attaches and keeps -//! draining). +//! draining). Frames the ring can NOT take feed the [`FrameStash`] instead, which every fresh attach +//! republishes as its instant first frame — the first-frame guarantee that makes a session opened onto +//! an IDLE desktop show a picture without waiting for anything to dirty the display. //! //! Ported from the proven oracle (`packaging/windows/vdisplay-driver/pf-vdisplay/src/ //! swap_chain_processor.rs`) onto wdk-sys + wdk-iddcx. The oracle's `wdf_umdf`/`wdf_umdf_sys` are @@ -23,7 +25,7 @@ use std::{ atomic::{AtomicBool, Ordering}, }, thread::{self, JoinHandle}, - time::Duration, + time::{Duration, Instant}, }; use wdk_sys::iddcx::{ @@ -48,7 +50,10 @@ use windows::{ core::{Interface, w}, }; -use crate::{direct_3d_device::Direct3DDevice, frame_transport::FramePublisher}; +use crate::{ + direct_3d_device::Direct3DDevice, + frame_transport::{FramePublisher, FrameStash, PublishOutcome}, +}; /// E_PENDING — `ReleaseAndAcquireBuffer2` returns this (HRESULT-shaped) when the swap-chain is valid but /// DWM has composed no new frame yet; wait on the surface-available event and retry. @@ -240,7 +245,8 @@ impl SwapChainProcessor { // frame objects are unnamed — the host duplicates their handles into this process and delivers // the values via IOCTL_SET_FRAME_CHANNEL, which the control plane stashes on our monitor // (`monitor::take_frame_channel`). Until a delivery lands we just drain — exactly the STEP-5 - // behaviour — so a non-IDD-push session never stalls. The stash is polled every ~30 iterations. + // behaviour — so a non-IDD-push session never stalls. The frame-channel stash is polled every + // iteration (attach latency = first-frame latency, since attach republishes the FrameStash). // STEP 6 sibling-join fix: re-adopt a FramePublisher PRESERVED across a swap-chain // unassign→reassign flap. When a SIBLING display churns the desktop topology (a second client // joining / leaving / resizing), the OS reassigns THIS monitor's swap-chain and the previous @@ -266,7 +272,13 @@ impl SwapChainProcessor { None } }); - let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration + // The FIRST-FRAME stash (see `FrameStash`): the retained last composed frame, republished + // into every fresh ring at attach so a session opening onto an idle desktop is never black. + // Re-adopted across a swap-chain flap on the same adapter (`take_preserved_stash` drops a + // cross-adapter one — its texture lives on the other adapter's pooled device). + let mut stash: FrameStash = + crate::monitor::take_preserved_stash(target_id, render_luid_low, render_luid_high) + .unwrap_or_default(); let mut logged_pending = false; let mut logged_frame = false; @@ -293,37 +305,49 @@ impl SwapChainProcessor { if publisher.as_ref().is_some_and(FramePublisher::is_stale) || (publisher.is_some() && crate::monitor::has_frame_channel(target_id)) { - publisher = None; - frames_since_try = u32::MAX; // re-attach immediately + // Harvest the superseded ring's last-published frame into the stash BEFORE dropping + // the publisher: between sessions the driver keeps publishing into the (host-side + // dead) previous ring, so that slot holds the CURRENT desktop image — exactly what + // the new ring's attach below republishes as its instant first frame. + if let Some(p) = publisher.take() { + p.harvest_into(&device.device, &mut stash); + } } - // Lazy-attach (rate-limited) at the loop TOP so we keep trying even while the display is idle - // (E_PENDING / no frames presented yet), not only when a frame is acquired. Checking the - // stash is a cheap mutex peek that stays empty until the host's channel delivery lands; a + // Lazy-attach at the loop TOP so we keep trying even while the display is idle (E_PENDING / + // no frames presented yet), not only when a frame is acquired. Polled EVERY iteration (a + // cheap mutex peek, the same cost the pending-delivery check above already pays): attach + // latency is now first-frame latency, since the attach itself republishes the stash. A // taken delivery is consumed whether the attach succeeds or not (on failure its handles are // closed, the host's wait-for-attach reads the status code, and any retry is a NEW delivery). - if publisher.is_none() { - if frames_since_try >= 30 { - frames_since_try = 0; - if let Some(channel) = crate::monitor::take_frame_channel(target_id) { - // `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match` - // happy under `-D warnings`; attach on success, drop the delivery on Err. - // `target_id` binds the attach: the mapped ring must name THIS monitor - // (proto v3 validation inside from_channel — a cross-delivered ring is - // refused, never published into). - if let Ok(p) = FramePublisher::from_channel( - channel, - target_id, - render_luid_low, - render_luid_high, - &device.device, - &device.device_context, - ) { - publisher = Some(p); - } - } - } else { - frames_since_try += 1; + // A failed attach only drops the delivery (its handles are closed inside from_channel; + // the host's wait-for-attach reads the status code, and any retry is a NEW delivery). + // `target_id` binds the attach: the mapped ring must name THIS monitor (proto v3 + // validation inside from_channel — a cross-delivered ring is refused, never published + // into). + if publisher.is_none() + && let Some(channel) = crate::monitor::take_frame_channel(target_id) + && let Ok(mut p) = FramePublisher::from_channel( + channel, + target_id, + render_luid_low, + render_luid_high, + &device.device, + &device.device_context, + ) + { + // FIRST-FRAME GUARANTEE: republish the retained desktop image into the fresh ring + // immediately. On an idle desktop DWM composes nothing, so without this the host + // would wait (and kick synthetic input) for a frame that may never come. A + // stale-descriptor stash (e.g. pre-HDR-flip) is rejected by publish()'s guard — + // at worst the old wait-for-compose path. + if let Some(t) = stash.texture() + && p.publish(t) == PublishOutcome::Published + { + dbglog!( + "[pf-vd] frame-push(driver): republished the retained frame into the fresh ring (target={target_id}) — instant first frame, no compose needed" + ); } + publisher = Some(p); } // ...Buffer2 is required once CAN_PROCESS_FP16 is set. AcquireSystemMemoryBuffer=FALSE keeps @@ -397,10 +421,24 @@ impl SwapChainProcessor { // drop, below — the queued GPU copy is unaffected: D3D defers destruction, and // the copy is ordered before the consumer via the slot keyed mutex). let res = unsafe { IDXGIResource::from_raw(raw) }; - if let Some(p) = publisher.as_mut() - && let Ok(tex) = res.cast::() - { - p.publish(&tex); + if let Ok(tex) = res.cast::() { + match publisher.as_mut().map(|p| p.publish(&tex)) { + // Ring took it (or the host is alive and busy) — nothing to retain. + Some(PublishOutcome::Published | PublishOutcome::Dropped) => {} + // No ring, or the surface's descriptor doesn't match it (a mode-set / + // HDR flip racing the host's ring recreate): RETAIN the frame — it is + // the desktop image the next attach republishes as its first frame. + // Unattached/mismatched composes are damage-driven and transient, so + // the extra copy costs nothing at steady state. + Some(PublishOutcome::DescMismatch) | None => { + stash.store( + &device.device, + &device.device_context, + &tex, + Instant::now(), + ); + } + } } // `res` drops here → the acquire's surface reference is released, pre-Finished. } @@ -427,6 +465,10 @@ impl SwapChainProcessor { if let Some(p) = publisher.take() { let _ = crate::monitor::preserve_publisher(target_id, p); } + // Preserve the first-frame stash alongside (dropped inside if empty or the monitor is gone + // — it owns no handles, just a driver-private texture). The next worker on this adapter + // re-adopts it, so the retained frame survives the flap too. + crate::monitor::preserve_stash(target_id, render_luid_low, render_luid_high, stash); } }