fix(vdisplay): first-frame guarantee — republish a retained frame at ring attach

DWM composes a display only when something dirties it, so a session opened
onto an idle desktop never produced a first frame: the host's synthetic-input
"compose kick" (cursor wiggle / sibling-display jump) was the only source, and
it is inherently unreliable — blocked on the secure desktop, defeated by a
fullscreen game's ClipCursor, user-visible, and dead in service contexts. The
field symptom: connect → black stream until something repaints the desktop.

Reconstruct DDA's first-frame semantics at the driver instead (DDA seeds a new
duplication with the current desktop image; IDD-push never had an equivalent):

* frame_transport.rs: new FrameStash — the retained last composed frame, a
  driver-private copy-only texture. publish() now reports Published /
  DescMismatch / Dropped, and harvest_into() pulls the last-published ring
  slot into the stash (keyed-mutex guarded, freshness-checked) before a
  superseded publisher is dropped — between sessions the driver keeps writing
  the host-side-dead old ring, so that slot IS the current desktop image.
* swap_chain_processor.rs: the worker stashes every frame the ring can NOT
  take (unattached, or descriptor-mismatched during a mode/HDR-flip race),
  harvests before a supersede, and REPUBLISHES the stash into every freshly
  attached ring — the host sees a normal seq=1 publish milliseconds after
  channel delivery, no compose needed. Zero steady-state cost: matched
  publishes touch only the ring. The frame-channel stash is now polled every
  iteration (attach latency = first-frame latency; it was 1-in-30).
* monitor.rs: preserved_stash (LUID-tagged) so the retained frame survives
  swap-chain unassign→reassign flaps, alongside the preserved publisher.
* host idd_push.rs: kick_dwm_compose demoted to documented last-resort
  fallback for pre-stash drivers; a debug log now fires when a kick actually
  runs so field logs show whether the stash path is working.

No proto change: the republish is an ordinary publish, so old host + new
driver and new host + old driver both keep working (the latter via the kick).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 15:16:36 +02:00
parent 5748706631
commit 89a08f83af
4 changed files with 362 additions and 56 deletions
@@ -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<ID3D11Texture2D>,
/// 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<Instant>,
}
// 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<Instant> {
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<ID3D11Texture2D> = 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
}
}