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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<crate::frame_transport::FramePublisher>,
|
||||
/// 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<crate::frame_transport::FrameStash> {
|
||||
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
|
||||
|
||||
@@ -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,38 +305,50 @@ 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(
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
frames_since_try += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ...Buffer2 is required once CAN_PROCESS_FP16 is set. AcquireSystemMemoryBuffer=FALSE keeps
|
||||
// the GPU surface (out.MetaData.pSurface) — STEP 6 publishes it into the shared ring in the
|
||||
@@ -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::<ID3D11Texture2D>()
|
||||
{
|
||||
p.publish(&tex);
|
||||
if let Ok(tex) = res.cast::<ID3D11Texture2D>() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user