feat(host/windows): capture the secure desktop in HDR via DDA (no SDR drop)
ci / web (push) Successful in 32s
ci / rust (push) Successful in 1m26s
android / android (push) Failing after 43s
apple / swift (push) Successful in 55s
deb / build-publish (push) Successful in 2m24s
decky / build-publish (push) Successful in 22s
ci / bench (push) Successful in 4m30s
ci / docs-site (push) Successful in 28s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4m1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m46s
docker / deploy-docs (push) Successful in 21s

The secure-desktop DDA leg went black with HDR on: legacy DuplicateOutput (the SDR-era
API) can't capture an FP16/HDR desktop, and dropping the SudoVDA out of HDR is denied on
the Winlogon desktop (so the SDR-drop attempt just churned and stayed black).

Instead capture HDR natively on the DDA path — the capturer already has the full
FP16→BT.2020 PQ→R10G10B10A2 conversion (hdr_fp16 path), it just never requested FP16.
Thread a want_hdr flag into duplicate_output: for an HDR session request
DuplicateOutput1 with FP16 first and retry it (5×) instead of bailing to the
HDR-incapable legacy fallback. The secure-desktop mux now reads the monitor's real HDR
state and opens DDA in HDR when set — no advanced-color toggling at all. The
normal-desktop DDA overlay/flip issues that pushed us to WGC don't apply to the composed
Winlogon UI. want_hdr is threaded through every (re)duplication incl. ACCESS_LOST recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 22:11:07 +00:00
parent 69765bad93
commit ad0cb1b582
3 changed files with 77 additions and 109 deletions
+5 -3
View File
@@ -286,7 +286,7 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
.unwrap_or_default() .unwrap_or_default()
.to_ascii_lowercase(); .to_ascii_lowercase();
if backend == "dda" || backend == "dxgi" || wgc_disabled() { if backend == "dda" || backend == "dxgi" || wgc_disabled() {
return dxgi::DuplCapturer::open(target, pref, keep) return dxgi::DuplCapturer::open(target, pref, keep, false)
.map(|c| Box::new(c) as Box<dyn Capturer>); .map(|c| Box::new(c) as Box<dyn Capturer>);
} }
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded // WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
@@ -316,11 +316,13 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
} }
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA"); tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
dxgi::DuplCapturer::open(target, pref, keep).map(|c| Box::new(c) as Box<dyn Capturer>) dxgi::DuplCapturer::open(target, pref, keep, false)
.map(|c| Box::new(c) as Box<dyn Capturer>)
} }
Err(_) => { Err(_) => {
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA"); tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
dxgi::DuplCapturer::open(target, pref, keep).map(|c| Box::new(c) as Box<dyn Capturer>) dxgi::DuplCapturer::open(target, pref, keep, false)
.map(|c| Box::new(c) as Box<dyn Capturer>)
} }
} }
} }
+30 -13
View File
@@ -33,8 +33,8 @@ use windows::Win32::Graphics::Direct3D11::{
D3D11_USAGE_DYNAMIC, D3D11_USAGE_STAGING, D3D11_VIEWPORT, D3D11_USAGE_DYNAMIC, D3D11_USAGE_STAGING, D3D11_VIEWPORT,
}; };
use windows::Win32::Graphics::Dxgi::Common::{ use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM,
DXGI_SAMPLE_DESC, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
}; };
use windows::Win32::Graphics::Dxgi::{ use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIOutput1, IDXGIOutput5, CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIOutput1, IDXGIOutput5,
@@ -157,6 +157,7 @@ pub(crate) unsafe fn make_device(
/// recovery to rebuild the whole capture on the current (possibly secure) input desktop. /// recovery to rebuild the whole capture on the current (possibly secure) input desktop.
unsafe fn reopen_duplication( unsafe fn reopen_duplication(
gdi_name: &str, gdi_name: &str,
want_hdr: bool,
) -> Result<( ) -> Result<(
ID3D11Device, ID3D11Device,
ID3D11DeviceContext, ID3D11DeviceContext,
@@ -165,7 +166,8 @@ unsafe fn reopen_duplication(
)> { )> {
let (adapter, out) = find_output(gdi_name)?; let (adapter, out) = find_output(gdi_name)?;
let (dev, ctx) = make_device(&adapter)?; let (dev, ctx) = make_device(&adapter)?;
let dupl = duplicate_output(&out, &dev).context("re-DuplicateOutput after ACCESS_LOST")?; let dupl =
duplicate_output(&out, &dev, want_hdr).context("re-DuplicateOutput after ACCESS_LOST")?;
Ok((dev, ctx, out, dupl)) Ok((dev, ctx, out, dupl))
} }
@@ -179,13 +181,19 @@ unsafe fn reopen_duplication(
unsafe fn duplicate_output( unsafe fn duplicate_output(
output: &IDXGIOutput1, output: &IDXGIOutput1,
device: &ID3D11Device, device: &ID3D11Device,
want_hdr: bool,
) -> Result<IDXGIOutputDuplication> { ) -> Result<IDXGIOutputDuplication> {
if let Ok(output5) = output.cast::<IDXGIOutput5>() { if let Ok(output5) = output.cast::<IDXGIOutput5>() {
// BGRA8 only for now (SDR). NOTE: DuplicateOutput1 returns the FIRST format it can provide and // For an HDR session, request FP16 FIRST so DuplicateOutput1 hands back the desktop's real
// DXGI will CONVERT to it — so listing FP16 first would hand back FP16 even on an SDR desktop, // scRGB HDR surface → the `hdr_fp16` path converts it to BT.2020 PQ 10-bit for NVENC Main10.
// wrongly tripping the HDR path. Real HDR capture (FP16 first + IDXGIOutput6 colorspace // For SDR request BGRA8 only: listing FP16 first would make DXGI hand back FP16 even on an SDR
// detection to pick the path) is the follow-up once the churn is settled. // desktop, wrongly tripping the HDR path. (HDR DDA is used for the secure desktop, where the
let formats = [DXGI_FORMAT_B8G8R8A8_UNORM]; // SudoVDA may be in HDR and legacy DuplicateOutput — the SDR-era API — can't capture FP16.)
let formats: &[DXGI_FORMAT] = if want_hdr {
&[DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_B8G8R8A8_UNORM]
} else {
&[DXGI_FORMAT_B8G8R8A8_UNORM]
};
// RETRY DuplicateOutput1. The caller releases the OLD duplication (self.dupl = None) immediately // RETRY DuplicateOutput1. The caller releases the OLD duplication (self.dupl = None) immediately
// before calling us, and the kernel-side teardown of that duplication is ASYNC — the FIRST // before calling us, and the kernel-side teardown of that duplication is ASYNC — the FIRST
// DuplicateOutput1 right after can race it and return E_ACCESSDENIED ("output still duplicated") // DuplicateOutput1 right after can race it and return E_ACCESSDENIED ("output still duplicated")
@@ -207,14 +215,17 @@ unsafe fn duplicate_output(
// and on the normal desktop the release-before-reduplicate + gentle recovery already keep the // and on the normal desktop the release-before-reduplicate + gentle recovery already keep the
// legacy dup stable. Raise PUNKTFUNK_DUP_RETRY_N only on a box where DuplicateOutput1 can win // legacy dup stable. Raise PUNKTFUNK_DUP_RETRY_N only on a box where DuplicateOutput1 can win
// the old-dup-teardown race (then PUNKTFUNK_DUP_RETRY_MS sets the per-wait, default 200). // the old-dup-teardown race (then PUNKTFUNK_DUP_RETRY_MS sets the per-wait, default 200).
// HDR DDA genuinely NEEDS DuplicateOutput1 (legacy DuplicateOutput can't capture an FP16/HDR
// desktop — it returns E_INVALIDARG), so give it several attempts even on the secure desktop
// rather than bailing after one try to the useless legacy fallback. SDR keeps the default 1.
let attempts: u64 = std::env::var("PUNKTFUNK_DUP_RETRY_N") let attempts: u64 = std::env::var("PUNKTFUNK_DUP_RETRY_N")
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(1) .unwrap_or(if want_hdr { 5 } else { 1 })
.max(1); .max(1);
let mut last_err = None; let mut last_err = None;
for attempt in 0..attempts { for attempt in 0..attempts {
match output5.DuplicateOutput1(device, 0, &formats) { match output5.DuplicateOutput1(device, 0, formats) {
Ok(d) => { Ok(d) => {
if attempt > 0 { if attempt > 0 {
tracing::debug!( tracing::debug!(
@@ -1026,6 +1037,10 @@ pub struct DuplCapturer {
/// Format-tagged because the SDR path presents BGRA `gpu_copy` while the HDR path presents the /// Format-tagged because the SDR path presents BGRA `gpu_copy` while the HDR path presents the
/// 10-bit `hdr10_out` — the encoder needs the right format on every frame. /// 10-bit `hdr10_out` — the encoder needs the right format on every frame.
last_present: Option<(ID3D11Texture2D, PixelFormat)>, last_present: Option<(ID3D11Texture2D, PixelFormat)>,
/// Whether this capturer should request an HDR (FP16) duplication — `DuplicateOutput1` with FP16
/// first, retried (legacy DuplicateOutput can't capture HDR). Set for the secure-desktop DDA leg
/// when the SudoVDA is in HDR; threaded into every (re)duplication incl. ACCESS_LOST recovery.
want_hdr: bool,
/// HDR (scRGB FP16) capture state. Set when the duplication surface is `R16G16B16A16_FLOAT` /// HDR (scRGB FP16) capture state. Set when the duplication surface is `R16G16B16A16_FLOAT`
/// (the desktop has HDR on). The frame can't be `CopyResource`d into a BGRA target, so the HDR /// (the desktop has HDR on). The frame can't be `CopyResource`d into a BGRA target, so the HDR
/// path copies it into an FP16 SRV texture, composites the cursor, then runs [`HdrConverter`] to /// path copies it into an FP16 SRV texture, composites the cursor, then runs [`HdrConverter`] to
@@ -1080,6 +1095,7 @@ impl DuplCapturer {
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
keepalive: Box<dyn Send>, keepalive: Box<dyn Send>,
want_hdr: bool,
) -> Result<Self> { ) -> Result<Self> {
unsafe { unsafe {
// Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs // Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs
@@ -1220,7 +1236,7 @@ impl DuplCapturer {
// (registry-persisted), so the secure desktop has nowhere to render but the output we // (registry-persisted), so the secure desktop has nowhere to render but the output we
// capture — no per-open re-isolation needed. // capture — no per-open re-isolation needed.
attach_input_desktop(); attach_input_desktop();
let dupl = duplicate_output(&output, &device) let dupl = duplicate_output(&output, &device, want_hdr)
.context("DuplicateOutput (already duplicated by another app?)")?; .context("DuplicateOutput (already duplicated by another app?)")?;
// Did DXGI actually call our win32u GPU-pref hook during factory/device/dupl creation? hits==0 // Did DXGI actually call our win32u GPU-pref hook during factory/device/dupl creation? hits==0
// here means the hook is NOT on DXGI's reparenting path on this build → reparenting can't be // here means the hook is NOT on DXGI's reparenting path on this build → reparenting can't be
@@ -1284,6 +1300,7 @@ impl DuplCapturer {
gpu_mode, gpu_mode,
gpu_copy: None, gpu_copy: None,
last_present: None, last_present: None,
want_hdr,
hdr_fp16: dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT, hdr_fp16: dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT,
fp16_src: None, fp16_src: None,
fp16_srv: None, fp16_srv: None,
@@ -1602,7 +1619,7 @@ impl DuplCapturer {
// allows one duplication per output; leaving the stale one alive is exactly why DuplicateOutput1 // allows one duplication per output; leaving the stale one alive is exactly why DuplicateOutput1
// returned E_ACCESSDENIED and the legacy fallback produced a born-lost dup. // returned E_ACCESSDENIED and the legacy fallback produced a born-lost dup.
self.dupl = None; self.dupl = None;
let dupl = match duplicate_output(&self.output, &self.device) { let dupl = match duplicate_output(&self.output, &self.device, self.want_hdr) {
Ok(d) => d, Ok(d) => d,
Err(_) => return false, Err(_) => return false,
}; };
@@ -1664,7 +1681,7 @@ impl DuplCapturer {
// and the new one is born-lost / E_ACCESSDENIED. (On reopen failure self.dupl stays None and // and the new one is born-lost / E_ACCESSDENIED. (On reopen failure self.dupl stays None and
// acquire's None-guard re-drives recovery.) // acquire's None-guard re-drives recovery.)
self.dupl = None; self.dupl = None;
let (dev, ctx, out, dupl) = reopen_duplication(&self.gdi_name)?; // Err → caller repeats + retries let (dev, ctx, out, dupl) = reopen_duplication(&self.gdi_name, self.want_hdr)?; // Err → caller repeats + retries
// (The born-lost guard is now the capture-acquire at the end: we adopt, then grab the current // (The born-lost guard is now the capture-acquire at the end: we adopt, then grab the current
// frame; ACCESS_LOST there means born-lost, and we seed black + let the throttled caller retry.) // frame; ACCESS_LOST there means born-lost, and we seed black + let the throttled caller retry.)
+42 -93
View File
@@ -2385,33 +2385,37 @@ fn virtual_stream_relay(
} }
// Note: takes the dimensions as args rather than capturing `cur_mode` — `cur_mode` is reassigned // Note: takes the dimensions as args rather than capturing `cur_mode` — `cur_mode` is reassigned
// on reconfig, and a closure holding a shared borrow of it for the whole fn would forbid that. // on reconfig, and a closure holding a shared borrow of it for the whole fn would forbid that.
let open_dda = |target: &WinCaptureTarget, w: u32, h: u32, hz: u32| -> Result<DdaPipe> { let open_dda =
// The host already holds the real keepalive (sole isolation owner), so DDA gets a no-op one. |target: &WinCaptureTarget, w: u32, h: u32, hz: u32, hdr: bool| -> Result<DdaPipe> {
let mut cap = crate::capture::dxgi::DuplCapturer::open( // The host already holds the real keepalive (sole isolation owner), so DDA gets a no-op one.
target.clone(), // `hdr` requests an FP16 DuplicateOutput1 so the secure desktop is captured in HDR (→ BT.2020
Some((w, h, hz)), // PQ Main10) instead of black — legacy DuplicateOutput can't capture an HDR/FP16 desktop.
Box::new(()), let mut cap = crate::capture::dxgi::DuplCapturer::open(
) target.clone(),
.context("open DDA for secure desktop")?; Some((w, h, hz)),
cap.set_active(true); Box::new(()),
let frame = cap.next_frame().context("DDA first frame")?; hdr,
let enc = crate::encode::open_video( )
crate::encode::Codec::H265, .context("open DDA for secure desktop")?;
frame.format, cap.set_active(true);
frame.width, let frame = cap.next_frame().context("DDA first frame")?;
frame.height, let enc = crate::encode::open_video(
hz, crate::encode::Codec::H265,
bitrate_kbps as u64 * 1000, frame.format,
frame.is_cuda(), frame.width,
bit_depth, frame.height,
) hz,
.context("open NVENC for DDA")?; bitrate_kbps as u64 * 1000,
Ok(DdaPipe { frame.is_cuda(),
cap: Box::new(cap), bit_depth,
enc, )
frame, .context("open NVENC for DDA")?;
}) Ok(DdaPipe {
}; cap: Box::new(cap),
enc,
frame,
})
};
let perf = std::env::var("PUNKTFUNK_PERF").is_ok(); let perf = std::env::var("PUNKTFUNK_PERF").is_ok();
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB") let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
@@ -2470,10 +2474,6 @@ fn virtual_stream_relay(
// decoder must resume on a keyframe — the two encoders keep independent infinite-GOP state). // decoder must resume on a keyframe — the two encoders keep independent infinite-GOP state).
let mut dda: Option<DdaPipe> = None; let mut dda: Option<DdaPipe> = None;
let mut on_secure = false; let mut on_secure = false;
// Whether we dropped the SudoVDA out of HDR for the secure (DDA) leg, so we know to restore it on
// the way back. Keyed off the monitor's REAL HDR state at the moment of the switch (a user can
// toggle Windows HDR mid-session), not the handshake bit depth.
let mut dropped_hdr_for_secure = false;
let mut next = std::time::Instant::now(); let mut next = std::time::Instant::now();
let mut await_idr = false; let mut await_idr = false;
// Step 6 relaunch watchdog: how many times in a row the helper has died without producing a frame. // Step 6 relaunch watchdog: how many times in a row the helper has died without producing a frame.
@@ -2563,46 +2563,17 @@ fn virtual_stream_relay(
"two-process: source switch" "two-process: source switch"
); );
if secure { if secure {
// SDR-while-secure (HDR sessions ONLY): drop the SudoVDA out of HDR so the secure // Capture the secure (Winlogon) desktop in its NATIVE colorspace. Don't try to drop the
// (Winlogon) desktop renders SDR/composed — HDR fullscreen independent-flip is what made // SudoVDA out of HDR for the DDA leg — display-config changes are denied on the secure
// DDA storm ACCESS_LOST (black). Key off the monitor's REAL HDR state (a user may have // desktop (the drop just churned + still went black). Instead, if the monitor is in HDR,
// toggled Windows HDR on the virtual display), not the negotiated bit depth — the pipeline // open DDA in HDR (FP16 DuplicateOutput1 → BT.2020 PQ Main10); the normal-desktop DDA
// streams HDR whenever the monitor is HDR regardless of the 8/10 handshake. For an SDR // overlay/flip issues that drove us to WGC don't apply to the composed Winlogon UI.
// monitor this is a no-op (no needless topology change, nothing to restore). let hdr =
dropped_hdr_for_secure =
unsafe { crate::vdisplay::sudovda::advanced_color_enabled(target.target_id) }; unsafe { crate::vdisplay::sudovda::advanced_color_enabled(target.target_id) };
if dropped_hdr_for_secure { dda = None; // reopen to capture the secure desktop
// The DDA path is SDR-only (BGRA8) — leaving the SudoVDA in HDR makes the secure match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz, hdr) {
// desktop capture black. Drop to SDR and VERIFY it actually took before opening DDA:
// the CCD advanced-color toggle can transiently fail (rc=5) or lag, so retry until
// advanced_color_enabled() reads false (or we give up and open DDA regardless).
let mut off = false;
for attempt in 0..6 {
unsafe {
crate::vdisplay::sudovda::set_advanced_color(target.target_id, false);
}
std::thread::sleep(std::time::Duration::from_millis(200));
if !unsafe {
crate::vdisplay::sudovda::advanced_color_enabled(target.target_id)
} {
off = true;
tracing::info!(
attempt,
"SudoVDA dropped to SDR for the secure DDA leg"
);
break;
}
}
if !off {
tracing::warn!(
"could not drop the SudoVDA out of HDR for the secure desktop — DDA may \
be black (display-config change likely denied on the Winlogon desktop)"
);
}
}
dda = None; // reopen so we capture the (SDR) output
match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz) {
Ok(mut p) => { Ok(mut p) => {
tracing::info!(hdr, "two-process: opened DDA for the secure desktop");
p.enc.request_keyframe(); p.enc.request_keyframe();
dda = Some(p); dda = Some(p);
} }
@@ -2622,30 +2593,8 @@ fn virtual_stream_relay(
dda = None; // free the secure DDA encoder; the relay (helper) is the source again dda = None; // free the secure DDA encoder; the relay (helper) is the source again
while relay.try_recv().is_ok() {} // drop secure-dwell backlog while relay.try_recv().is_ok() {} // drop secure-dwell backlog
relay.request_keyframe(); // client decoder resumes on the helper's next IDR relay.request_keyframe(); // client decoder resumes on the helper's next IDR
if dropped_hdr_for_secure { // Nothing to restore: we no longer toggle the SudoVDA's HDR state for the DDA leg, so the
// We dropped the SudoVDA to SDR for the DDA leg → restore HDR AND rebuild the helper // monitor's colorspace is unchanged and the still-alive WGC helper just resumes.
// so WGC re-detects the HDR colorspace. (An SDR session never changed the colorspace
// → dropped_hdr_for_secure is false → no rebuild, no recreate.)
dropped_hdr_for_secure = false;
unsafe {
crate::vdisplay::sudovda::set_advanced_color(target.target_id, true);
}
match build(&mut vd, cur_mode) {
Ok((ka, rl, tg, hz)) => {
relay = rl;
_keepalive = ka;
target = tg;
effective_hz = hz;
interval = std::time::Duration::from_secs_f64(1.0 / hz.max(1) as f64);
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"),
"two-process: helper rebuild on secure-exit failed");
while relay.try_recv().is_ok() {}
relay.request_keyframe();
}
}
}
next = std::time::Instant::now(); next = std::time::Instant::now();
} }
} }