docs(pf-capture): truth pass over comments, docs and log strings (sweep Phase 1)

Every item here is prose that actively misdirects a maintainer or an operator. Landing it before
the defect fixes means those diffs get reviewed against accurate comments.

1.1 (X5) — split two merged doc blocks that documented the item ABOVE them: `hdr_meta`'s whole
contract was glued onto `fn cursor()` in lib.rs (leaving `hdr_meta` undocumented), and
`hdr_p010_selftest_at`'s was glued onto `hdr_p010_convert_bars_on_luid` in dxgi.rs. rustdoc for
the trait's central HDR contract was simply wrong.

1.2 (L9 doc half) — the `Capturer` trait doc advertised a "bounded drop-oldest channel". The
Linux portal's `sync_channel(8)` + `try_send` is drop-NEWEST: under a consumer stall the arriving
frame is discarded and the encoder gets the stalest queued one. Say so. (Phase 2.6 changes the
behaviour; this commit only stops the doc from lying about today's code.)

1.3 — delete the DONT_FIXATE claim from the dmabuf offer comment. `build_dmabuf_format` emits a
plain MANDATORY `ChoiceEnum::Enum` and `param_changed` re-emits nothing, so there is no two-step
DMA-BUF handshake here; libspa 0.9's `ChoiceFlags` cannot even express DONT_FIXATE. Recorded as a
real follow-up instead of an implemented thing.

1.4 — `mainloop.run()` no longer "blocks until process exit": the quit channel attached above it
stops the loop from `PortalCapturer::drop`.

1.5 (L11) — the 6-arm negotiation log ladder told a fully zero-copy PyroWave session "VAAPI
encode with the CPU capture path — zero-copy was disabled", one line after logging that it had
advertised the PyroWave device's dmabuf modifiers: the passthrough arm was gated on
`pyrowave_modifiers.is_empty()`, so the pyrowave case fell through to the CPU warning. Ungate it;
the CPU-path arm is now reachable only when no dmabuf is advertised at all, which is what it
claims. Its parenthetical also gains the third real cause (the session asked for CPU frames).
Control-flow-neutral for every other combination.

1.6 (W13) — `kick_dwm_compose` claimed a "sub-millisecond" round trip while its
cursor-on-a-sibling-display branch sleeps 35 ms on the CALLER's thread. State the cost on the
function and at both call sites, including which one is on the live frame path.

1.7 (W16) — DDA-era residue, DDA having been removed:
  - retarget the win32u hook's rationale + all four log strings: its job is no longer "keep DDA on
    one adapter" but "keep the virtual display on the adapter SET_RENDER_ADAPTER pinned" (a DXGI
    reparent surfaces as the driver's TEX_FAIL render-adapter mismatch). The hook stays installed.
  - the per-monitor-v2 DPI awareness rationale likewise: not DuplicateOutput1's E_ACCESSDENIED any
    more, but keeping cursor/window coordinates in the PHYSICAL pixels the host's CCD geometry
    (`source_desktop_rect`, `desktop_bounds`) is already in.
  - `HYBRID_HOOK_HITS` was write-only. Surface it as `hybrid_hook_hits()` on the IDD-push open
    line, which is the first point where DXGI has actually been exercised — the patch-readback
    check proves the bytes landed, only this proves DXGI reaches the export.
  - delete `hdr_p010_selftest()`: an unreachable 64×64 wrapper (main.rs calls
    `hdr_p010_selftest_at`); its description moves onto the function that survives.
  - `VideoConverter`'s docs promised a P010/BT.2020 output and a live scRGB input path. It pins
    `YCBCR_STUDIO_G22_LEFT_P709` unconditionally and its only caller always passes
    `scrgb_input: false`.

1.8 (X7) — the native handshake's 4:4:4 gate comment stated the OPPOSITE of what
`pf_capture::capturer_supports_444` returns on Windows ("delivers subsampled NV12/P010 today, so
it returns false there" — it forwards `resolved_backend_ingests_rgb_444()` and the IDD ring passes
BGRA through for an SDR 4:4:4 session).

No `.rs` behaviour delta: comments, doc comments and log strings, plus 1.5's control-flow-neutral
ladder and 1.7's counter accessor. Linux tests 6/6; clippy --all-targets clean on both targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:27:06 +02:00
co-authored by Claude Opus 5
parent 6a2a153c0b
commit 54a37aeb46
6 changed files with 153 additions and 69 deletions
+8 -6
View File
@@ -22,7 +22,9 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
use pf_frame::DmabufFrame; use pf_frame::DmabufFrame;
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder /// Produces frames from a captured output. Lives on its own thread, feeding the encoder
/// over a bounded drop-oldest channel (never block the compositor). /// over a bounded channel that never blocks the compositor — the Linux portal's is drop-**newest**
/// (a full channel discards the ARRIVING frame; `linux/mod.rs`'s `try_send`), so under a consumer
/// stall the encoder is handed the stalest queued frame.
pub trait Capturer: Send { pub trait Capturer: Send {
fn next_frame(&mut self) -> Result<CapturedFrame>; fn next_frame(&mut self) -> Result<CapturedFrame>;
@@ -66,11 +68,6 @@ pub trait Capturer: Send {
/// duration of a stream, `false` when it ends. /// duration of a stream, `false` when it ends.
fn set_active(&self, _active: bool) {} fn set_active(&self, _active: bool) {}
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
/// The capture source's LIVE cursor state, when it arrives out-of-band from the frames /// The capture source's LIVE cursor state, when it arrives out-of-band from the frames
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and /// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves /// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
@@ -99,6 +96,11 @@ pub trait Capturer: Send {
/// no-op: every non-gamescope capturer already has a cursor source. /// no-op: every non-gamescope capturer already has a cursor source.
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {} fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {}
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> { fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
None None
} }
+28 -14
View File
@@ -2265,29 +2265,38 @@ mod pipewire {
); );
} else if zerocopy && !want_dmabuf { } else if zerocopy && !want_dmabuf {
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path"); tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() { } else if vaapi_passthrough {
// The raw-passthrough advertisement. Covers the PyroWave case too: its extra
// Vulkan-importable modifiers were appended (and logged) just above, so this arm must
// NOT be gated on `pyrowave_modifiers.is_empty()` — that gate is what dropped a fully
// zero-copy PyroWave session through to the CPU-path warning below (L11).
tracing::info!( tracing::info!(
native_nv12_preferred = prefer_native_nv12, native_nv12_preferred = prefer_native_nv12,
"zero-copy: advertising LINEAR DMA-BUF for encoder import (native NV12 first \ modifier_count = modifiers.len(),
when enabled, packed RGB fallback)" pyrowave_extended = !policy.pyrowave_modifiers.is_empty(),
"zero-copy: advertising DMA-BUF modifiers for direct encoder import (LINEAR \
always; native NV12 first when enabled, packed RGB fallback)"
); );
} else if want_dmabuf && !vaapi_passthrough { } else if want_dmabuf {
tracing::info!( tracing::info!(
count = modifiers.len(), count = modifiers.len(),
sample = ?&modifiers[..modifiers.len().min(6)], sample = ?&modifiers[..modifiers.len().min(6)],
"zero-copy: advertising EGL-importable dmabuf modifiers" "zero-copy: advertising EGL-importable dmabuf modifiers"
); );
} else if backend_is_vaapi && policy.backend_is_gpu { } else if backend_is_vaapi && policy.backend_is_gpu {
// A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad + // Reached only when no dmabuf is advertised at all (every arm above rules out a
// swscale RGB→NV12 + surface upload) — make the silent fallback visible. // zero-copy path), so this genuinely IS the CPU capture path: a VAAPI session then pays
// three full-frame CPU touches (mmap de-pad + swscale RGB→NV12 + surface upload) —
// make the silent fallback visible.
tracing::warn!( tracing::warn!(
"VAAPI encode with the CPU capture path (per-frame de-pad + swscale CSC + \ "VAAPI encode with the CPU capture path (per-frame de-pad + swscale CSC + \
upload) — zero-copy was disabled ({}); clear PUNKTFUNK_ZEROCOPY to restore \ upload) — zero-copy is off for this capture ({}); clear PUNKTFUNK_ZEROCOPY to \
the dmabuf default", restore the dmabuf default",
if std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some() { if std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some() {
"PUNKTFUNK_ZEROCOPY is set falsy" "PUNKTFUNK_ZEROCOPY is set falsy"
} else { } else {
"downgraded after a failed dmabuf negotiation" "a latched downgrade after a failed dmabuf negotiation, or this session's \
output format asked for CPU frames"
} }
); );
} }
@@ -2679,10 +2688,13 @@ mod pipewire {
// version and are composited host-side instead (see `xfixes_cursor.rs`). // version and are composited host-side instead (see `xfixes_cursor.rs`).
// //
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers // When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
// (offering shm too makes the compositor pick shm). The modifier list is advertised with // (offering shm too makes the compositor pick shm). The modifier list goes out as a plain
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in // MANDATORY `ChoiceEnum::Enum` and the producer fixates one of the alternatives directly —
// `param_changed` (the two-step DMA-BUF handshake). Otherwise offer the multi-format shm // this is NOT the two-step DONT_FIXATE handshake (libspa 0.9's `ChoiceFlags` cannot express
// pod and let MAP_BUFFERS map it. An HDR session replaces ALL of this with the two 10-bit // `SPA_POD_PROP_FLAG_DONT_FIXATE`, and `param_changed` only READS the fixated format, it
// re-emits nothing). Worth revisiting if a multi-modifier offer is ever seen to fail
// negotiation on a compositor that needs the allocator round-trip. Otherwise offer the
// multi-format shm pod and let MAP_BUFFERS map it. An HDR session replaces ALL of this with the two 10-bit
// PQ pods (LINEAR dmabuf, MANDATORY colorimetry — see `build_hdr_dmabuf_format`): offering // PQ pods (LINEAR dmabuf, MANDATORY colorimetry — see `build_hdr_dmabuf_format`): offering
// SDR alongside would make the producer pick its earlier-listed SDR format, and the // SDR alongside would make the producer pick its earlier-listed SDR format, and the
// negotiation-timeout path latches the process-wide SDR downgrade if nothing matches. // negotiation-timeout path latches the process-wide SDR downgrade if nothing matches.
@@ -2751,7 +2763,9 @@ mod pipewire {
) )
.context("pw stream connect")?; .context("pw stream connect")?;
// Blocks this thread, pumping frame callbacks until process exit. // Blocks this thread, pumping frame callbacks until the capturer's `Drop` fires the quit
// channel attached above (`_quit_attach` → `quit_loop.quit()`), at which point `run()`
// returns and the thread unwinds — releasing the importer / CUDA context deterministically.
mainloop.run(); mainloop.run();
Ok(()) Ok(())
} }
+84 -41
View File
@@ -40,12 +40,20 @@ use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC, DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
}; };
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`. If this /// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`.
/// stays 0 while DDA churns with ACCESS_LOST, the hook is NOT on DXGI's GPU-preference path on this /// Reported by [`hybrid_hook_hits`] on every IDD-push open, which is the first point at which DXGI
/// build (so reparenting can't be the cause — look at composition/independent-flip instead). >0 with /// has been exercised (factory → `EnumAdapterByLuid` → device). The patch-readback check in
/// continuing churn means the hook fires but reparenting isn't the trigger here. /// [`install_gpu_pref_hook`] proves the BYTES landed; only this counter proves DXGI actually
/// reaches the export on this build — so `0` here means the hook is inert and a
/// reparenting-flavoured symptom (see [`install_gpu_pref_hook`]) has some other cause.
static HYBRID_HOOK_HITS: AtomicU64 = AtomicU64::new(0); static HYBRID_HOOK_HITS: AtomicU64 = AtomicU64::new(0);
/// See [`HYBRID_HOOK_HITS`] — surfaced in the IDD-push open log so a dead hook is visible in the
/// field instead of being a silent write-only counter.
pub(crate) fn hybrid_hook_hits() -> u64 {
HYBRID_HOOK_HITS.load(Ordering::Relaxed)
}
// kernel32 — declared directly so we don't pull the whole Win32_System_Diagnostics_Debug feature for // kernel32 — declared directly so we don't pull the whole Win32_System_Diagnostics_Debug feature for
// one call. FlushInstructionCache serializes the i-cache after the inline patch: the patch is written // one call. FlushInstructionCache serializes the i-cache after the inline patch: the patch is written
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a // on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
@@ -75,11 +83,19 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
/// The win32u GPU-preference hook (the same technique Apollo applies, reimplemented here from the /// The win32u GPU-preference hook (the same technique Apollo applies, reimplemented here from the
/// documented DDI — no GPL source copied). On a HYBRID-GPU box DXGI resolves a GPU preference /// documented DDI — no GPL source copied). On a HYBRID-GPU box DXGI resolves a GPU preference
/// (registry + power settings + the hybrid-adapter DDI) and REPARENTS outputs onto the chosen render /// (registry + power settings + the hybrid-adapter DDI) and REPARENTS outputs onto the chosen render
/// GPU — which constantly invalidates Desktop Duplication (DXGI_ERROR_ACCESS_LOST 0x887A0026, the /// GPU, ignoring `SET_RENDER_ADAPTER` (observed on the RTX 4090 + AMD iGPU box). Faking a cached
/// freeze/churn observed on the RTX 4090 + AMD iGPU box; `SET_RENDER_ADAPTER` is ignored there). Faking /// preference of UNSPECIFIED makes DXGI skip that resolution, so an output is NOT reparented and
/// a cached preference of UNSPECIFIED makes DXGI skip the resolution, so the output is NOT reparented /// stays on one adapter.
/// and DDA stays stable on one adapter (this is what makes Apollo's DDA work on this hardware). ///
/// Installed once, before the first DXGI factory/enumeration; lasts the process lifetime (like Apollo). /// **Why it is still installed now that DXGI Desktop Duplication is gone:** the IDD-push ring and
/// the driver's swap-chain must live on the SAME adapter, and the host pins that with
/// `SET_RENDER_ADAPTER` at monitor ADD (see `idd_push::open_inner`). A DXGI reparent moves the
/// virtual output off the pinned GPU behind the host's back — which surfaces as the driver's
/// `DRV_STATUS_TEX_FAIL` ("could not open our textures — render-adapter mismatch") and costs a
/// ring rebind. So the hook's job changed from "keep DDA on one adapter" to "keep the VIRTUAL
/// DISPLAY on the adapter we pinned"; the mechanism is unchanged. Installed once from
/// `main.rs`, before the virtual-display setup creates the first DXGI factory; lasts the process
/// lifetime. [`hybrid_hook_hits`] reports whether DXGI ever actually calls it.
pub fn install_gpu_pref_hook() { pub fn install_gpu_pref_hook() {
use std::sync::Once; use std::sync::Once;
static HOOK: Once = Once::new(); static HOOK: Once = Once::new();
@@ -103,25 +119,38 @@ pub fn install_gpu_pref_hook() {
GetAwarenessFromDpiAwarenessContext, GetThreadDpiAwarenessContext, GetAwarenessFromDpiAwarenessContext, GetThreadDpiAwarenessContext,
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
}; };
// Per-monitor-v2 DPI awareness — REQUIRED for IDXGIOutput5::DuplicateOutput1 (without it the // Per-monitor-v2 DPI awareness. It was originally set here because
// call returns E_ACCESSDENIED forever, forcing the legacy DuplicateOutput path). Matches // `IDXGIOutput5::DuplicateOutput1` returns E_ACCESSDENIED without it; DDA is gone, but the
// Apollo's startup. SetProcessDpiAwarenessContext fails with E_ACCESS_DENIED if awareness was // awareness still matters — an UNAWARE/SYSTEM-aware process gets DPI-VIRTUALIZED window and
// already set (manifest / earlier call) — log the outcome AND the effective awareness so a // cursor coordinates, while every geometry the host computes comes from CCD in PHYSICAL
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent. // pixels (`source_desktop_rect`, `desktop_bounds`). Mixing the two mis-aims the compose
// kick's `SetCursorPos` and the cursor-blend placement on any scaled display. Set here
// because this is the earliest process-wide hook point. `SetProcessDpiAwarenessContext`
// fails with E_ACCESS_DENIED if awareness was already set (manifest / earlier call) — log
// the outcome AND the effective awareness so a mis-scaled pointer is diagnosable.
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) { match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"), Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"),
Err(e) => tracing::warn!(error = ?e, Err(e) => tracing::warn!(error = ?e,
"SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"), "SetProcessDpiAwarenessContext failed (already set?) — cursor/desktop coordinates \
may be DPI-virtualized against the host's physical-pixel CCD geometry"),
} }
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2. // 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). Physical-pixel coordinates need 2.
let awareness = GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext()).0; let awareness = GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext()).0;
tracing::info!(awareness, "effective DPI awareness (need 2=PER_MONITOR for DuplicateOutput1)"); tracing::info!(
awareness,
"effective DPI awareness (need 2=PER_MONITOR for physical-pixel coordinates)"
);
let Ok(lib) = LoadLibraryA(s!("win32u.dll")) else { let Ok(lib) = LoadLibraryA(s!("win32u.dll")) else {
tracing::warn!("GPU-pref hook: win32u.dll not loadable — skipping (DDA may churn on hybrid GPUs)"); tracing::warn!(
"GPU-pref hook: win32u.dll not loadable — skipping (on a hybrid-GPU box DXGI may \
reparent the virtual display off the pinned render adapter TEX_FAIL rebinds)"
);
return; return;
}; };
let Some(target) = GetProcAddress(lib, s!("NtGdiDdDDIGetCachedHybridQueryValue")) else { let Some(target) = GetProcAddress(lib, s!("NtGdiDdDDIGetCachedHybridQueryValue")) else {
tracing::warn!("GPU-pref hook: NtGdiDdDDIGetCachedHybridQueryValue not exported — skipping"); tracing::warn!(
"GPU-pref hook: NtGdiDdDDIGetCachedHybridQueryValue not exported — skipping"
);
return; return;
}; };
let target = target as usize as *mut u8; let target = target as usize as *mut u8;
@@ -135,7 +164,14 @@ pub fn install_gpu_pref_hook() {
patch[10] = 0xFF; patch[10] = 0xFF;
patch[11] = 0xE0; // jmp rax patch[11] = 0xE0; // jmp rax
let mut old = PAGE_PROTECTION_FLAGS(0); let mut old = PAGE_PROTECTION_FLAGS(0);
if VirtualProtect(target as *const c_void, 12, PAGE_EXECUTE_READWRITE, &mut old).is_err() { if VirtualProtect(
target as *const c_void,
12,
PAGE_EXECUTE_READWRITE,
&mut old,
)
.is_err()
{
tracing::warn!("GPU-pref hook: VirtualProtect failed — skipping"); tracing::warn!("GPU-pref hook: VirtualProtect failed — skipping");
return; return;
} }
@@ -153,12 +189,15 @@ pub fn install_gpu_pref_hook() {
std::ptr::copy_nonoverlapping(target, readback.as_mut_ptr(), 12); std::ptr::copy_nonoverlapping(target, readback.as_mut_ptr(), 12);
if readback == patch { if readback == patch {
tracing::info!( tracing::info!(
"GPU-pref hook installed + verified (win32u hybrid-query -> UNSPECIFIED): reparenting disabled" "GPU-pref hook installed + verified (win32u hybrid-query -> UNSPECIFIED): DXGI \
output reparenting disabled. Whether DXGI actually CALLS it shows up as \
hybrid_hook_hits on the IDD-push open line."
); );
} else { } else {
tracing::error!( tracing::error!(
want = %format!("{patch:02x?}"), got = %format!("{readback:02x?}"), want = %format!("{patch:02x?}"), got = %format!("{readback:02x?}"),
"GPU-pref hook patch did NOT land — hook is DEAD (DXGI will still reparent → ACCESS_LOST churn)" "GPU-pref hook patch did NOT land — hook is DEAD (on a hybrid-GPU box DXGI can \
still reparent the virtual display off the pinned render adapter)"
); );
} }
}); });
@@ -775,22 +814,6 @@ fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
(yc, cbc, crc) (yc, cbc, crc)
} }
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
#[cfg(target_os = "windows")]
pub fn hdr_p010_selftest() -> Result<()> {
hdr_p010_selftest_at(64, 64, None)
}
/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor
/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on
/// top of the 64×64 default because the field sessions run at capture resolutions whose height
/// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar
/// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default
/// adapter is not the one the session encodes on.
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/ /// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the /// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with /// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
@@ -920,6 +943,18 @@ pub fn hdr_p010_convert_bars_on_luid(
} }
} }
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
///
/// `w`/`h` must be even and non-zero. Run it at the FIELD capture size, not a toy one: sessions run
/// at resolutions whose height is not 16-aligned (1080 → the encoder's align16 pool seam) and a
/// driver may treat the planar RTVs differently at real sizes. `vendor` pins the adapter by PCI
/// vendor id (`0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD) — it matters on dual-GPU boxes where
/// the default adapter is not the one the session encodes on. The chosen adapter is always printed,
/// because a PASS only means anything for the GPU it actually ran on.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> { pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN}; use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
@@ -1286,8 +1321,15 @@ use windows::Win32::Graphics::Dxgi::Common::{
/// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT /// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT
/// the 3D engine, so the per-frame RGB→YUV conversion does not contend with a GPU-saturating game (the /// the 3D engine, so the per-frame RGB→YUV conversion does not contend with a GPU-saturating game (the
/// HDR pixel-shader path and NVENC's internal RGB→YUV both use the 3D/compute engine, which an AAA /// HDR pixel-shader path and NVENC's internal RGB→YUV both use the 3D/compute engine, which an AAA
/// title pins at ~100%). Output is NV12 (SDR, BT.709 studio-range) or P010 (HDR, BT.2020 PQ /// title pins at ~100%). Output is **always NV12, BT.709 studio-range** — one of NVENC's native YUV
/// studio-range) — NVENC's native YUV inputs, so it encodes them with no further conversion. /// inputs, so it encodes with no further conversion.
///
/// It does NOT produce P010/BT.2020 PQ: [`VideoConverter::new`] pins the output colour space to
/// `YCBCR_STUDIO_G22_LEFT_P709` unconditionally, and NVIDIA's video processor cannot do RGB→P010 at
/// all (it renders green) — the HDR path is [`HdrP010Converter`]'s shader instead. The `scrgb_input`
/// arm of `new` is likewise the only part of the HDR story here (an FP16 desktop tone-mapped DOWN to
/// 8-bit BT.709), and it currently has no caller: `idd_push::ensure_converter` builds this converter
/// only on the SDR/BGRA path and always passes `false`.
pub(crate) struct VideoConverter { pub(crate) struct VideoConverter {
vdev: ID3D11VideoDevice, vdev: ID3D11VideoDevice,
vctx: ID3D11VideoContext1, vctx: ID3D11VideoContext1,
@@ -1361,7 +1403,8 @@ impl VideoConverter {
} }
} }
/// Convert `input` (BGRA or scRGB FP16) → `output` (NV12 or P010) on the video engine. Views are /// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output`
/// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame. /// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
pub(crate) unsafe fn convert( pub(crate) unsafe fn convert(
&self, &self,
+24 -4
View File
@@ -234,9 +234,16 @@ impl Drop for KeyedMutexGuard<'_> {
/// desktop region (always true single-display), two net-zero 1 px relative moves (the historical /// desktop region (always true single-display), two net-zero 1 px relative moves (the historical
/// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the /// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the
/// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties /// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties
/// the cursor layer of the display it lands on, so the target composes at least one frame; the /// the cursor layer of the display it lands on, so the target composes at least one frame).
/// round trip is sub-millisecond and throttled). Best-effort — injection can be unavailable on the /// Best-effort — injection can be unavailable on the secure desktop, where a fresh compose just
/// secure desktop, where a fresh compose just happened anyway. /// happened anyway.
///
/// **COST:** the sibling-display branch SLEEPS 35 ms on the calling thread between the two
/// `SetCursorPos`es. The dwell is load-bearing (see the comment at that branch: a sub-tick
/// jump-and-return never dirties anything), but the caller is the capture/encode thread, so a kick
/// on that branch costs ~2 frames of latency at 60 Hz. Every call site is a first-frame or
/// post-recreate recovery window where no frames are flowing anyway, and the global 50 ms throttle
/// plus the callers' own 600800 ms schedules bound how often it can happen.
/// ///
/// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual /// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual
/// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a /// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a
@@ -1097,6 +1104,12 @@ impl IddPushCapturer {
client_10bit, client_10bit,
want_444, want_444,
ring_fp16 = display_hdr, ring_fp16 = display_hdr,
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
// factory + `EnumAdapterByLuid` + `make_device` above have exercised DXGI, so a
// 0 here means the hook is inert on this build — the first thing to check if a
// hybrid-GPU box keeps reporting TEX_FAIL render-adapter mismatches
// (`dxgi::install_gpu_pref_hook`).
hybrid_hook_hits = crate::dxgi::hybrid_hook_hits(),
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \ "IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
to attach + publish" to attach + publish"
); );
@@ -1273,6 +1286,9 @@ impl IddPushCapturer {
"IDD push: no first frame after attach delivery — falling back to a synthetic \ "IDD push: no first frame after attach delivery — falling back to a synthetic \
compose kick (stash-capable drivers republish instantly; old driver?)" compose kick (stash-capable drivers republish instantly; old driver?)"
); );
// May BLOCK this thread ~35 ms (the cursor-on-a-sibling-display branch — see
// `kick_dwm_compose`'s COST note). Fine here: we are inside the open-time
// first-frame gate, so no frames are flowing yet.
kick_dwm_compose(self.target_id); kick_dwm_compose(self.target_id);
next_kick = Instant::now() + Duration::from_millis(800); next_kick = Instant::now() + Duration::from_millis(800);
} }
@@ -2022,7 +2038,11 @@ impl IddPushCapturer {
// never sees a frame and the 3 s recover-or-drop above kills a healthy session. A // 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 // 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 // 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. // post-recreate compose (and the stash republish) has had its chance. This is the ONE
// call site on the live frame path: the kick may BLOCK this (capture/encode) thread
// ~35 ms on its cursor-on-a-sibling-display branch (see `kick_dwm_compose`'s COST
// note) — acceptable only because we are already ≥600 ms into a recovery window with
// no frames arriving, and the 800 ms schedule below bounds the repeat rate.
if since.elapsed() > Duration::from_millis(600) if since.elapsed() > Duration::from_millis(600)
&& self.last_kick.elapsed() > Duration::from_millis(800) && self.last_kick.elapsed() > Duration::from_millis(800)
{ {
+1 -1
View File
@@ -16,7 +16,7 @@ pub use pf_capture::{
capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer, capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer,
SyntheticCapturer, SyntheticCapturer,
}; };
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest}` (main.rs subcommands) and // `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules. // `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub use pf_capture::{dxgi, synthetic_nv12}; pub use pf_capture::{dxgi, synthetic_nv12};
@@ -291,9 +291,14 @@ pub(super) async fn negotiate(
let host_wants_444 = pf_host_config::config().four_four_four; let host_wants_444 = pf_host_config::config().four_four_four;
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0; let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade // The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
// gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010 // gate. Linux's portal capturer always can (`capturer_supports_444` returns `true`
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host // unconditionally). On WINDOWS the IDD-push path CAN too — for an SDR 4:4:4 session it passes
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only // the BGRA ring slot straight through, skipping the NV12 VideoConverter — but only a backend
// that ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards
// `resolved_backend_ingests_rgb_444()` (today: direct-NVENC only; AMF can't 4:4:4 at all and
// the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR display still downgrades to 4:2:0
// at capture time — there is no 10-bit full-chroma source — and the encoder's caps cross-check
// reports that truth. (Replaces the old `single_process` gate — single-process is now the only
// topology, and 4:4:4 routed to DDA, which was removed.) // topology, and 4:4:4 routed to DDA, which was removed.)
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma // PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real // (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real