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
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:
@@ -286,7 +286,7 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
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>);
|
||||
}
|
||||
// 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)) => {
|
||||
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(_) => {
|
||||
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>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_USAGE_DYNAMIC, D3D11_USAGE_STAGING, D3D11_VIEWPORT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
DXGI_SAMPLE_DESC,
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
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.
|
||||
unsafe fn reopen_duplication(
|
||||
gdi_name: &str,
|
||||
want_hdr: bool,
|
||||
) -> Result<(
|
||||
ID3D11Device,
|
||||
ID3D11DeviceContext,
|
||||
@@ -165,7 +166,8 @@ unsafe fn reopen_duplication(
|
||||
)> {
|
||||
let (adapter, out) = find_output(gdi_name)?;
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -179,13 +181,19 @@ unsafe fn reopen_duplication(
|
||||
unsafe fn duplicate_output(
|
||||
output: &IDXGIOutput1,
|
||||
device: &ID3D11Device,
|
||||
want_hdr: bool,
|
||||
) -> Result<IDXGIOutputDuplication> {
|
||||
if let Ok(output5) = output.cast::<IDXGIOutput5>() {
|
||||
// BGRA8 only for now (SDR). NOTE: DuplicateOutput1 returns the FIRST format it can provide and
|
||||
// DXGI will CONVERT to it — so listing FP16 first would hand back FP16 even on an SDR desktop,
|
||||
// wrongly tripping the HDR path. Real HDR capture (FP16 first + IDXGIOutput6 colorspace
|
||||
// detection to pick the path) is the follow-up once the churn is settled.
|
||||
let formats = [DXGI_FORMAT_B8G8R8A8_UNORM];
|
||||
// For an HDR session, request FP16 FIRST so DuplicateOutput1 hands back the desktop's real
|
||||
// scRGB HDR surface → the `hdr_fp16` path converts it to BT.2020 PQ 10-bit for NVENC Main10.
|
||||
// For SDR request BGRA8 only: listing FP16 first would make DXGI hand back FP16 even on an SDR
|
||||
// desktop, wrongly tripping the HDR path. (HDR DDA is used for the secure desktop, where the
|
||||
// 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
|
||||
// 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")
|
||||
@@ -207,14 +215,17 @@ unsafe fn duplicate_output(
|
||||
// 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
|
||||
// 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")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1)
|
||||
.unwrap_or(if want_hdr { 5 } else { 1 })
|
||||
.max(1);
|
||||
let mut last_err = None;
|
||||
for attempt in 0..attempts {
|
||||
match output5.DuplicateOutput1(device, 0, &formats) {
|
||||
match output5.DuplicateOutput1(device, 0, formats) {
|
||||
Ok(d) => {
|
||||
if attempt > 0 {
|
||||
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
|
||||
/// 10-bit `hdr10_out` — the encoder needs the right format on every frame.
|
||||
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`
|
||||
/// (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
|
||||
@@ -1080,6 +1095,7 @@ impl DuplCapturer {
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
keepalive: Box<dyn Send>,
|
||||
want_hdr: bool,
|
||||
) -> Result<Self> {
|
||||
unsafe {
|
||||
// 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
|
||||
// capture — no per-open re-isolation needed.
|
||||
attach_input_desktop();
|
||||
let dupl = duplicate_output(&output, &device)
|
||||
let dupl = duplicate_output(&output, &device, want_hdr)
|
||||
.context("DuplicateOutput (already duplicated by another app?)")?;
|
||||
// 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
|
||||
@@ -1284,6 +1300,7 @@ impl DuplCapturer {
|
||||
gpu_mode,
|
||||
gpu_copy: None,
|
||||
last_present: None,
|
||||
want_hdr,
|
||||
hdr_fp16: dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
fp16_src: None,
|
||||
fp16_srv: None,
|
||||
@@ -1602,7 +1619,7 @@ impl DuplCapturer {
|
||||
// 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.
|
||||
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,
|
||||
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
|
||||
// acquire's None-guard re-drives recovery.)
|
||||
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
|
||||
// frame; ACCESS_LOST there means born-lost, and we seed black + let the throttled caller retry.)
|
||||
|
||||
@@ -2385,33 +2385,37 @@ fn virtual_stream_relay(
|
||||
}
|
||||
// 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.
|
||||
let open_dda = |target: &WinCaptureTarget, w: u32, h: u32, hz: u32| -> Result<DdaPipe> {
|
||||
// The host already holds the real keepalive (sole isolation owner), so DDA gets a no-op one.
|
||||
let mut cap = crate::capture::dxgi::DuplCapturer::open(
|
||||
target.clone(),
|
||||
Some((w, h, hz)),
|
||||
Box::new(()),
|
||||
)
|
||||
.context("open DDA for secure desktop")?;
|
||||
cap.set_active(true);
|
||||
let frame = cap.next_frame().context("DDA first frame")?;
|
||||
let enc = crate::encode::open_video(
|
||||
crate::encode::Codec::H265,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
hz,
|
||||
bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
)
|
||||
.context("open NVENC for DDA")?;
|
||||
Ok(DdaPipe {
|
||||
cap: Box::new(cap),
|
||||
enc,
|
||||
frame,
|
||||
})
|
||||
};
|
||||
let open_dda =
|
||||
|target: &WinCaptureTarget, w: u32, h: u32, hz: u32, hdr: bool| -> Result<DdaPipe> {
|
||||
// The host already holds the real keepalive (sole isolation owner), so DDA gets a no-op one.
|
||||
// `hdr` requests an FP16 DuplicateOutput1 so the secure desktop is captured in HDR (→ BT.2020
|
||||
// PQ Main10) instead of black — legacy DuplicateOutput can't capture an HDR/FP16 desktop.
|
||||
let mut cap = crate::capture::dxgi::DuplCapturer::open(
|
||||
target.clone(),
|
||||
Some((w, h, hz)),
|
||||
Box::new(()),
|
||||
hdr,
|
||||
)
|
||||
.context("open DDA for secure desktop")?;
|
||||
cap.set_active(true);
|
||||
let frame = cap.next_frame().context("DDA first frame")?;
|
||||
let enc = crate::encode::open_video(
|
||||
crate::encode::Codec::H265,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
hz,
|
||||
bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
)
|
||||
.context("open NVENC for DDA")?;
|
||||
Ok(DdaPipe {
|
||||
cap: Box::new(cap),
|
||||
enc,
|
||||
frame,
|
||||
})
|
||||
};
|
||||
|
||||
let perf = std::env::var("PUNKTFUNK_PERF").is_ok();
|
||||
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).
|
||||
let mut dda: Option<DdaPipe> = None;
|
||||
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 await_idr = false;
|
||||
// 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"
|
||||
);
|
||||
if secure {
|
||||
// SDR-while-secure (HDR sessions ONLY): drop the SudoVDA out of HDR so the secure
|
||||
// (Winlogon) desktop renders SDR/composed — HDR fullscreen independent-flip is what made
|
||||
// DDA storm ACCESS_LOST (black). Key off the monitor's REAL HDR state (a user may have
|
||||
// toggled Windows HDR on the virtual display), not the negotiated bit depth — the pipeline
|
||||
// streams HDR whenever the monitor is HDR regardless of the 8/10 handshake. For an SDR
|
||||
// monitor this is a no-op (no needless topology change, nothing to restore).
|
||||
dropped_hdr_for_secure =
|
||||
// Capture the secure (Winlogon) desktop in its NATIVE colorspace. Don't try to drop the
|
||||
// SudoVDA out of HDR for the DDA leg — display-config changes are denied on the secure
|
||||
// desktop (the drop just churned + still went black). Instead, if the monitor is in HDR,
|
||||
// open DDA in HDR (FP16 DuplicateOutput1 → BT.2020 PQ Main10); the normal-desktop DDA
|
||||
// overlay/flip issues that drove us to WGC don't apply to the composed Winlogon UI.
|
||||
let hdr =
|
||||
unsafe { crate::vdisplay::sudovda::advanced_color_enabled(target.target_id) };
|
||||
if dropped_hdr_for_secure {
|
||||
// The DDA path is SDR-only (BGRA8) — leaving the SudoVDA in HDR makes the secure
|
||||
// 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) {
|
||||
dda = None; // reopen to capture the secure desktop
|
||||
match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz, hdr) {
|
||||
Ok(mut p) => {
|
||||
tracing::info!(hdr, "two-process: opened DDA for the secure desktop");
|
||||
p.enc.request_keyframe();
|
||||
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
|
||||
while relay.try_recv().is_ok() {} // drop secure-dwell backlog
|
||||
relay.request_keyframe(); // client decoder resumes on the helper's next IDR
|
||||
if dropped_hdr_for_secure {
|
||||
// We dropped the SudoVDA to SDR for the DDA leg → restore HDR AND rebuild the helper
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nothing to restore: we no longer toggle the SudoVDA's HDR state for the DDA leg, so the
|
||||
// monitor's colorspace is unchanged and the still-alive WGC helper just resumes.
|
||||
next = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user