feat(windows-host): IDD-push recovers from a game mode-set, else drops (game-capture bug GB1)
The bug: a fullscreen game mode-sets the virtual display (format/size); the driver's publish() guard then drops every frame; the host's ring — fixed at the session-negotiated mode — never adapts -> frozen picture, then black on reconnect. RECOVER (no DDA, per the chosen design): the ring now TRACKS the display's actual mode. At open it is sized to the display's actual resolution (new win_display::active_resolution, CCD/GDI) — so reconnecting while a game holds a different mode just works. Mid-session, the 250ms poll (was HDR-only) now also follows the active resolution; on any descriptor change (size or HDR) it recreates the ring at the new mode (recreate_ring generalized to a new size) -> the driver re-attaches -> frames resume at the game's mode. No freeze, no reconnect needed. DROP if unrecoverable: a descriptor change starts a recovery clock (recovering_since); if no fresh frame resumes within 3s (e.g. an exclusive-flip the host can't follow), try_consume bails -> the session ends cleanly -> the client reconnects, instead of freezing forever. A pure idle desktop (no mode change) never triggers this. Verified: host clippy (nvenc) clean on the RTX box. NEEDS ON-GLASS (Doom repro on .158): confirm the poll sees the mode-set, the ring recreates + recovers, the encoder+client adapt to the size change; tune the 3s window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,10 @@ pub struct IddPushCapturer {
|
|||||||
/// Throttle for the `advanced_color_enabled` poll (a CCD `QueryDisplayConfig`, ~ms — too costly per
|
/// Throttle for the `advanced_color_enabled` poll (a CCD `QueryDisplayConfig`, ~ms — too costly per
|
||||||
/// frame at 240 Hz).
|
/// frame at 240 Hz).
|
||||||
last_acm_poll: Instant,
|
last_acm_poll: Instant,
|
||||||
|
/// Set when a display-descriptor change triggered a ring recreate (recovery, game-capture bug GB1);
|
||||||
|
/// cleared when a fresh frame resumes. If it stays set past the recovery window, `try_consume` drops
|
||||||
|
/// the session (recover-or-drop, no DDA).
|
||||||
|
recovering_since: Option<Instant>,
|
||||||
/// Host-owned ROTATING output ring NVENC encodes (texture + RTV per slot). Rotating it per frame is
|
/// Host-owned ROTATING output ring NVENC encodes (texture + RTV per slot). Rotating it per frame is
|
||||||
/// the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
/// the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||||
/// ASIC, frame N+1's convert/copy writes a DIFFERENT texture on the 3D engine — the two overlap. The
|
/// ASIC, frame N+1's convert/copy writes a DIFFERENT texture on the 3D engine — the two overlap. The
|
||||||
@@ -360,8 +364,22 @@ impl IddPushCapturer {
|
|||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let (w, h, _hz) = preferred
|
let (pw, ph, _hz) = preferred
|
||||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||||
|
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
|
||||||
|
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
|
||||||
|
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
|
||||||
|
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
|
||||||
|
let (w, h) =
|
||||||
|
unsafe { crate::win_display::active_resolution(target.target_id) }.unwrap_or((pw, ph));
|
||||||
|
if (w, h) != (pw, ph) {
|
||||||
|
tracing::info!(
|
||||||
|
target_id = target.target_id,
|
||||||
|
negotiated = format!("{pw}x{ph}"),
|
||||||
|
actual = format!("{w}x{h}"),
|
||||||
|
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
|
||||||
|
);
|
||||||
|
}
|
||||||
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
||||||
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
||||||
// COMMIT_MODES2 colorspace/rgb_bpc log). The user can flip "Use HDR" in Windows at any time, so
|
// COMMIT_MODES2 colorspace/rgb_bpc log). The user can flip "Use HDR" in Windows at any time, so
|
||||||
@@ -496,6 +514,7 @@ impl IddPushCapturer {
|
|||||||
client_10bit,
|
client_10bit,
|
||||||
display_hdr,
|
display_hdr,
|
||||||
last_acm_poll: Instant::now(),
|
last_acm_poll: Instant::now(),
|
||||||
|
recovering_since: None,
|
||||||
out_ring: Vec::new(),
|
out_ring: Vec::new(),
|
||||||
out_idx: 0,
|
out_idx: 0,
|
||||||
hdr_conv: None,
|
hdr_conv: None,
|
||||||
@@ -653,8 +672,10 @@ impl IddPushCapturer {
|
|||||||
/// generation so the driver re-attaches ([`is_stale`]) to the new-format textures; clears the
|
/// generation so the driver re-attaches ([`is_stale`]) to the new-format textures; clears the
|
||||||
/// header's `latest` so we don't consume a stale slot from the old ring; drops the conversion
|
/// 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.
|
/// textures so they rebuild at the new format.
|
||||||
fn recreate_ring(&mut self, new_display_hdr: bool) -> Result<()> {
|
fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> {
|
||||||
self.display_hdr = new_display_hdr;
|
self.display_hdr = new_display_hdr;
|
||||||
|
self.width = new_w;
|
||||||
|
self.height = new_h;
|
||||||
let fmt = self.ring_format();
|
let fmt = self.ring_format();
|
||||||
let new_gen = IDD_GENERATION.fetch_add(1, Ordering::Relaxed);
|
let new_gen = IDD_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||||
let new_slots = unsafe {
|
let new_slots = unsafe {
|
||||||
@@ -675,6 +696,8 @@ impl IddPushCapturer {
|
|||||||
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
|
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
|
||||||
.store(0, Ordering::Relaxed);
|
.store(0, Ordering::Relaxed);
|
||||||
(*self.header).dxgi_format = fmt.0 as u32;
|
(*self.header).dxgi_format = fmt.0 as u32;
|
||||||
|
(*self.header).width = new_w;
|
||||||
|
(*self.header).height = new_h;
|
||||||
// Publish the new generation LAST (Release): when the driver observes it (Acquire) the new
|
// Publish the new generation LAST (Release): when the driver observes it (Acquire) the new
|
||||||
// textures already exist and the format is already updated.
|
// textures already exist and the format is already updated.
|
||||||
std::sync::atomic::fence(Ordering::Release);
|
std::sync::atomic::fence(Ordering::Release);
|
||||||
@@ -699,16 +722,23 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
self.last_acm_poll = Instant::now();
|
self.last_acm_poll = Instant::now();
|
||||||
let now_hdr = unsafe { crate::win_display::advanced_color_enabled(self.target_id) };
|
let now_hdr = unsafe { crate::win_display::advanced_color_enabled(self.target_id) };
|
||||||
if now_hdr == self.display_hdr {
|
// Follow the display's ACTUAL resolution too — a fullscreen game can mode-set the virtual display
|
||||||
|
// out from under the negotiated size (game-capture bug GB1). Unknown read → keep our current size.
|
||||||
|
let (now_w, now_h) = unsafe { crate::win_display::active_resolution(self.target_id) }
|
||||||
|
.unwrap_or((self.width, self.height));
|
||||||
|
if now_hdr == self.display_hdr && now_w == self.width && now_h == self.height {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id = self.target_id,
|
target_id = self.target_id,
|
||||||
display_hdr = now_hdr,
|
from = format!("{}x{} hdr={}", self.width, self.height, self.display_hdr),
|
||||||
client_10bit = self.client_10bit,
|
to = format!("{now_w}x{now_h} hdr={now_hdr}"),
|
||||||
"IDD push: display HDR mode flipped — recreating the ring at the new format"
|
"IDD push: display descriptor changed — recreating the ring at the new mode"
|
||||||
);
|
);
|
||||||
if let Err(e) = self.recreate_ring(now_hdr) {
|
// 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_w, now_h) {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "IDD push: ring recreate failed");
|
tracing::warn!(error = %format!("{e:#}"), "IDD push: ring recreate failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,6 +795,17 @@ impl IddPushCapturer {
|
|||||||
self.log_driver_status_once();
|
self.log_driver_status_once();
|
||||||
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
||||||
self.poll_display_hdr();
|
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) {
|
||||||
|
bail!(
|
||||||
|
"IDD-push: display descriptor changed and the ring could not recover within 3s — \
|
||||||
|
dropping the session so the client reconnects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let latest = self.latest();
|
let latest = self.latest();
|
||||||
// `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish
|
// `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
|
// whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0
|
||||||
@@ -814,6 +855,7 @@ impl IddPushCapturer {
|
|||||||
self.out_idx = (i + 1) % self.out_ring.len();
|
self.out_idx = (i + 1) % self.out_ring.len();
|
||||||
self.last_seq = seq;
|
self.last_seq = seq;
|
||||||
self.last_present = Some((out.clone(), pf));
|
self.last_present = Some((out.clone(), pf));
|
||||||
|
self.recovering_since = None; // a fresh frame resumed → recovered
|
||||||
Ok(Some(CapturedFrame {
|
Ok(Some(CapturedFrame {
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use windows::Win32::Devices::Display::{
|
|||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
||||||
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
||||||
ENUM_DISPLAY_SETTINGS_MODE,
|
ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::vdisplay::Mode;
|
use crate::vdisplay::Mode;
|
||||||
@@ -67,6 +67,27 @@ pub(crate) unsafe fn resolve_gdi_name(target_id: u32) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The virtual display's CURRENT active resolution `(width, height)` via the GDI/CCD API, or `None` if the
|
||||||
|
/// target isn't an active display yet / the query fails. The IDD-push capturer sizes its ring to this
|
||||||
|
/// ACTUAL mode and polls it to recreate the ring when it changes — a fullscreen game can change the
|
||||||
|
/// virtual display's mode out from under the session-negotiated one (game-capture bug GB1).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Calls the GDI/CCD APIs; safe to call from any thread.
|
||||||
|
pub(crate) unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> {
|
||||||
|
let gdi = resolve_gdi_name(target_id)?;
|
||||||
|
let wname: Vec<u16> = gdi.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let mut dm = DEVMODEW {
|
||||||
|
dmSize: size_of::<DEVMODEW>() as u16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let ok = EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm).as_bool();
|
||||||
|
if !ok || dm.dmPelsWidth == 0 || dm.dmPelsHeight == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((dm.dmPelsWidth, dm.dmPelsHeight))
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle the SudoVDA target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
/// Toggle the SudoVDA target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
||||||
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
|
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
|
||||||
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
|
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
|
||||||
|
|||||||
Reference in New Issue
Block a user