Compare commits
21
Commits
@@ -172,24 +172,57 @@ jobs:
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
#
|
||||
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules
|
||||
# are type-checked — the AMF C-ABI layout assertions (`variant_layout_matches_c` and
|
||||
# friends, which are the only guard on a hand-mirrored vtable ABI), the QSV tests, and the
|
||||
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
|
||||
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
|
||||
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
|
||||
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
|
||||
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
|
||||
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
|
||||
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
|
||||
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
|
||||
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
|
||||
# worth it while ci.yml executes the same tests.
|
||||
# pf-encode and pf-capture are linted SEPARATELY with --all-targets so their Windows
|
||||
# `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
|
||||
# (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
|
||||
# vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
|
||||
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
|
||||
# cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
|
||||
# test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
|
||||
# tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
|
||||
# feature juggling and pulls in no extra dep tree.
|
||||
# NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
|
||||
# vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
|
||||
# link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
|
||||
# only against the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can
|
||||
# and does run the tests there.) Running them here would need an `--features amf-qsv,qsv`
|
||||
# build without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 —
|
||||
# not worth it while ci.yml executes the same tests.
|
||||
#
|
||||
# That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
|
||||
# (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
|
||||
# links against nothing this runner lacks, and it reuses the release artifacts the steps
|
||||
# above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
|
||||
# truth table, the IDD generation masking — are Windows-only code that NO other job can
|
||||
# execute, so linting them was leaving real coverage on the table. See the run step below.
|
||||
run: |
|
||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
|
||||
cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Test (pf-capture, Windows)
|
||||
shell: pwsh
|
||||
# The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
|
||||
# test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
|
||||
# 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
|
||||
# (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
|
||||
# `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
|
||||
# (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
|
||||
# `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
|
||||
# adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
|
||||
# the whole set was type-checked by the clippy line above and nothing more.
|
||||
#
|
||||
# --release for the same reason as the clippy step: it reuses C:\t\release instead of
|
||||
# spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
|
||||
# starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
|
||||
#
|
||||
# The link question this step turns on was settled empirically before it was added: the same
|
||||
# command was run on a Windows dev box against a workspace checkout and linked + executed
|
||||
# cleanly, building in ~51 s off an existing release target dir.
|
||||
run: |
|
||||
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
||||
|
||||
@@ -236,6 +236,13 @@ public final class StreamLayerView: NSView {
|
||||
let hotY: Int
|
||||
}
|
||||
private var hostCursors: [UInt32: HostCursorShape] = [:]
|
||||
/// The last shape actually worn. State (`0xD0`, a per-frame datagram) announces a new serial the
|
||||
/// moment the host QUEUES its bitmap on the reliable control stream, so the client routinely
|
||||
/// knows a serial before it holds the pixels — and the shape ring drops the NEWEST under burst
|
||||
/// (`CURSOR_SHAPE_QUEUE`), which the host never re-sends because it only sends on a serial
|
||||
/// CHANGE. Both leave `hostCursors[serial]` empty; wearing the previous pointer through that
|
||||
/// gap degrades it to a briefly-stale shape instead of blinking the pointer out of existence.
|
||||
private var lastWornShape: HostCursorShape?
|
||||
private var cursorState: PunktfunkConnection.CursorStateEvent?
|
||||
/// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip);
|
||||
/// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse
|
||||
@@ -509,10 +516,19 @@ public final class StreamLayerView: NSView {
|
||||
override public func resetCursorRects() {
|
||||
if captured && desktopMouse {
|
||||
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
|
||||
// video); hidden host pointer (or no shape yet) = invisible. Without the channel,
|
||||
// M1 behavior: invisible local cursor, the composited host cursor is the visible one.
|
||||
// video); a HIDDEN host pointer (or nothing seen yet at all) = invisible. Without the
|
||||
// channel, M1 behavior: invisible local cursor, the composited host cursor is the
|
||||
// visible one.
|
||||
//
|
||||
// A visible pointer whose announced serial has no bitmap yet falls back to the last
|
||||
// worn shape (see `lastWornShape`) rather than to `invisibleCursor`. That case is
|
||||
// routine, not degenerate — state outruns its bitmap on every single shape change —
|
||||
// and treating it as "hide the pointer" made the pointer VANISH over anything whose
|
||||
// shape arrived late or got dropped, with no recovery until the next change. Only
|
||||
// `st.visible == false` may hide the pointer; a missing bitmap may not.
|
||||
if cursorChannelActive, let st = cursorState, st.visible,
|
||||
let shape = hostCursors[st.serial] {
|
||||
let shape = hostCursors[st.serial] ?? lastWornShape {
|
||||
lastWornShape = shape
|
||||
addCursorRect(bounds, cursor: scaledCursor(shape))
|
||||
} else {
|
||||
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
||||
@@ -583,6 +599,8 @@ public final class StreamLayerView: NSView {
|
||||
|
||||
private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) {
|
||||
guard let shape = Self.makeShape(ev) else {
|
||||
// Truthful only because `resetCursorRects` falls back to `lastWornShape`: before that,
|
||||
// a rejection here left the announced serial with no bitmap and HID the pointer.
|
||||
streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor")
|
||||
return
|
||||
}
|
||||
|
||||
+103
-25
@@ -7,10 +7,12 @@
|
||||
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
|
||||
//! orchestrator).
|
||||
|
||||
// Scaffold: trait defaults + synthetic sources are defined ahead of the backends that use them.
|
||||
#![allow(dead_code)]
|
||||
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
|
||||
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
|
||||
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use anyhow::Result;
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
@@ -19,9 +21,16 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
#[cfg(target_os = "linux")]
|
||||
use pf_frame::DmabufFrame;
|
||||
|
||||
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
|
||||
/// over a bounded drop-oldest channel (never block the compositor).
|
||||
/// Produces frames from a captured output. Lives on its own thread, handing frames over without
|
||||
/// ever blocking the compositor — the Linux portal publishes into a one-deep OVERWRITING slot
|
||||
/// (drop-oldest), so a stalled consumer costs the intermediate frames and is still handed the
|
||||
/// freshest one.
|
||||
pub trait Capturer: Send {
|
||||
// ---- Frames -----------------------------------------------------------------------------
|
||||
// `next_frame` blocks for one; `try_latest` is the steady-state non-blocking read;
|
||||
// `wait_arrival` + `supports_arrival_wait` are the frame-driven trigger that replaces a
|
||||
// free-running tick.
|
||||
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||
|
||||
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
|
||||
@@ -52,23 +61,47 @@ pub trait Capturer: Send {
|
||||
/// Block until a FRESH frame is available via [`try_latest`](Self::try_latest) or
|
||||
/// `deadline` passes — the encode loop's frame-driven wait (latency plan T1.1): waking on
|
||||
/// the compositor's publish instead of sampling at a free-running tick deletes the
|
||||
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame (the
|
||||
/// loop's `try_latest` call does); backends buffer internally where the arrival channel
|
||||
/// can't be peeked. Only called when [`supports_arrival_wait`](Self::supports_arrival_wait)
|
||||
/// is `true`; errors surface at the following `try_latest`.
|
||||
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame — the
|
||||
/// loop's `try_latest` call does that — so a backend implements this by waiting on a wakeup
|
||||
/// and then PEEKING its hand-off slot. Only called when
|
||||
/// [`supports_arrival_wait`](Self::supports_arrival_wait) is `true`; errors surface at the
|
||||
/// following `try_latest`.
|
||||
fn wait_arrival(&mut self, _deadline: std::time::Instant) {}
|
||||
|
||||
// ---- Lifecycle --------------------------------------------------------------------------
|
||||
// Whether the capturer is being used right now, and whether it can still be used at all.
|
||||
|
||||
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
|
||||
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive;
|
||||
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
|
||||
/// duration of a stream, `false` when it ends.
|
||||
fn set_active(&self, _active: bool) {}
|
||||
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive and
|
||||
/// flushes its frame mailbox on `false`; the default is a no-op (synthetic sources are produced
|
||||
/// on demand). Set `true` for the duration of a stream, `false` when it ends.
|
||||
///
|
||||
/// `&mut self`: it mutates capturer state, and every caller owns the capturer. It took `&self`
|
||||
/// only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking
|
||||
/// into the contract, and one the mailbox flush this now also does would not have shared.
|
||||
fn set_active(&mut self, _active: bool) {}
|
||||
|
||||
/// Whether this capturer can still produce frames — the gate a caller that POOLS capturers
|
||||
/// across streams must consult before reusing one.
|
||||
///
|
||||
/// Some backends have TERMINAL states that are only observable by trying to consume a frame:
|
||||
/// the Linux portal capturer's zero-copy poison flag, a dead PipeWire thread, and a source that
|
||||
/// never returns to `Streaming` are all sticky, and each makes every subsequent
|
||||
/// [`next_frame`](Self::next_frame) / [`try_latest`](Self::try_latest) fail — for that backend
|
||||
/// an `Err` from either is terminal, never transient. A pool that re-admits such a capturer
|
||||
/// wedges the next session permanently (it re-fails at the same point, every reconnect), which
|
||||
/// is why this predicate exists rather than leaving callers to infer liveness from an error they
|
||||
/// have often already discarded.
|
||||
///
|
||||
/// `true` (the default) for backends with no such state: the synthetic sources, and the Windows
|
||||
/// IDD-push capturer, whose failures already end the session through its own rebuild path.
|
||||
fn is_alive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ---- Cursor -----------------------------------------------------------------------------
|
||||
// The out-of-band pointer: where it is, who draws it, and (Linux/gamescope) where to read it.
|
||||
|
||||
/// 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 Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
|
||||
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
|
||||
@@ -90,13 +123,21 @@ pub trait Capturer: Send {
|
||||
|
||||
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
|
||||
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
|
||||
/// portal capturer gamescope's nested Xwayland `(DISPLAY, XAUTHORITY)` targets (it may run
|
||||
/// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11
|
||||
/// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that
|
||||
/// same slot. Called once, after the capturer is built, only for gamescope sessions. Default
|
||||
/// no-op: every non-gamescope capturer already has a cursor source.
|
||||
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {}
|
||||
/// portal capturer a way to reach gamescope's nested Xwaylands (it may run several — one per
|
||||
/// `--xwayland-count`) so it reads the pointer shape/position over X11 (XFixes +
|
||||
/// QueryPointer), following whichever display is focused, and publishes it into that same slot.
|
||||
/// Called once, after the capturer is built, only for gamescope sessions. Default no-op: every
|
||||
/// non-gamescope capturer already has a cursor source.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn attach_gamescope_cursor(&mut self, _targets: GamescopeCursorTargets) {}
|
||||
|
||||
// ---- Stream properties ------------------------------------------------------------------
|
||||
|
||||
/// 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> {
|
||||
None
|
||||
}
|
||||
@@ -111,10 +152,18 @@ pub trait Capturer: Send {
|
||||
1
|
||||
}
|
||||
|
||||
// ---- Host-initiated resize --------------------------------------------------------------
|
||||
// These two are ONE operation split in half and must be implemented together: a backend that
|
||||
// returns `Some` from `capture_target_id` is promising `resize_output` works, and one that
|
||||
// implements `resize_output` without the identity leaves the caller no way to check that the
|
||||
// display it just reconfigured is still this capturer's. Both defaults decline.
|
||||
|
||||
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
|
||||
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
|
||||
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
|
||||
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
|
||||
///
|
||||
/// PAIRED with [`resize_output`](Self::resize_output) — see the cluster note above.
|
||||
fn capture_target_id(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
@@ -125,6 +174,8 @@ pub trait Capturer: Send {
|
||||
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
|
||||
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
|
||||
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
|
||||
///
|
||||
/// PAIRED with [`capture_target_id`](Self::capture_target_id) — see the cluster note above.
|
||||
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -299,6 +350,23 @@ pub struct ZeroCopyPolicy {
|
||||
pub pyrowave_modifiers: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Discovers gamescope's nested Xwayland cursor targets — `(DISPLAY, XAUTHORITY)`, one per
|
||||
/// `--xwayland-count` — for [`Capturer::attach_gamescope_cursor`].
|
||||
///
|
||||
/// A CLOSURE, not the `Vec` it used to be, and re-run on a slow cadence by the cursor worker. The
|
||||
/// snapshot was taken once, before the game launched: gamescope creates a second Xwayland for the
|
||||
/// game but only advertises the FIRST in any child's environ, so the game's display was invisible to
|
||||
/// discovery — and when the connected (Big Picture) display then reported "gamescope is not drawing
|
||||
/// the pointer here", the source blanked the cursor for the whole game session, which is the exact
|
||||
/// regression the module doc says it fixed. A provider also lets the worker retry a display that
|
||||
/// died, and lets a stream that starts BEFORE the game converge instead of staying cursorless.
|
||||
///
|
||||
/// Built by the host facade (it wraps `pf_vdisplay::gamescope_xwayland_cursor_targets`), exactly
|
||||
/// like [`FrameChannelSender`] — so the capture→host edge stays one-way.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type GamescopeCursorTargets =
|
||||
std::sync::Arc<dyn Fn() -> Vec<(String, Option<String>)> + Send + Sync>;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||
true
|
||||
@@ -447,14 +515,24 @@ pub mod synthetic_nv12;
|
||||
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
||||
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
|
||||
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
|
||||
/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
||||
/// `want_metadata_cursor` asks for cursor-as-metadata (`SPA_META_Cursor`) — pass it only when
|
||||
/// the session's encode path composites `CapturedFrame::cursor` (the host consults
|
||||
/// `pf-encode`'s `cursor_blend_capable`); otherwise the portal EMBEDS the pointer so it is
|
||||
/// never silently lost. The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts
|
||||
/// (the one-way edge).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_portal_monitor(
|
||||
anchored: bool,
|
||||
want_hdr: bool,
|
||||
want_metadata_cursor: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy)
|
||||
linux::PortalCapturer::open(
|
||||
anchored,
|
||||
want_hdr && !hdr_capture_failed(),
|
||||
want_metadata_cursor,
|
||||
policy,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
|
||||
+389
-2375
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
//! The portal CONTROL PLANE: the xdg ScreenCast / RemoteDesktop handshake (async, `ashpd` over
|
||||
//! zbus, on its own tokio runtime), the cursor-mode choice, and GNOME's BT.2100 colour-mode probe.
|
||||
//!
|
||||
//! Split out of `linux/mod.rs` (sweep Phase 5.3) to separate the async control plane from the
|
||||
//! realtime half: nothing here runs per frame — the handshake happens once, then the thread parks
|
||||
//! until `PortalSession`'s `Drop` (in the parent) releases it, and DROPPING the zbus
|
||||
//! connection is what ends the compositor's cast. The probe is likewise a one-shot D-Bus round-trip
|
||||
//! for a control-plane caller.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the
|
||||
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
|
||||
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
|
||||
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
|
||||
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
|
||||
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
|
||||
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
|
||||
pub fn gnome_hdr_monitor_active() -> bool {
|
||||
use ashpd::zbus;
|
||||
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
|
||||
// (spec(ssss), modes a(siiddada{sv}), properties a{sv}) — "color-mode" lives in the monitor
|
||||
// properties.
|
||||
type Mode = (
|
||||
String,
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
f64,
|
||||
Vec<f64>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type Monitor = (
|
||||
(String, String, String, String),
|
||||
Vec<Mode>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type LogicalMonitor = (
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
u32,
|
||||
bool,
|
||||
Vec<(String, String, String, String)>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type State = (
|
||||
u32,
|
||||
Vec<Monitor>,
|
||||
Vec<LogicalMonitor>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
let probe = || -> Result<bool> {
|
||||
// zbus is built async-only here (ashpd's tokio integration) — run the one round-trip on
|
||||
// a throwaway current-thread runtime; this is a control-plane call, never per-frame.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build tokio runtime")?;
|
||||
rt.block_on(async {
|
||||
let conn = zbus::Connection::session().await.context("session bus")?;
|
||||
let reply = conn
|
||||
.call_method(
|
||||
Some("org.gnome.Mutter.DisplayConfig"),
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
Some("org.gnome.Mutter.DisplayConfig"),
|
||||
"GetCurrentState",
|
||||
&(),
|
||||
)
|
||||
.await
|
||||
.context("DisplayConfig.GetCurrentState")?;
|
||||
let (_serial, monitors, _logical, _props): State = reply
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("parse GetCurrentState")?;
|
||||
Ok(monitors.iter().any(|(_spec, _modes, props)| {
|
||||
props
|
||||
.get("color-mode")
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100
|
||||
}))
|
||||
})
|
||||
};
|
||||
match probe() {
|
||||
Ok(hdr) => hdr,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "GNOME HDR colour-mode probe failed — SDR");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
|
||||
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
|
||||
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
|
||||
/// an occasional bitmap), which the consumer composites itself — avoiding the producer burning the
|
||||
/// cursor into every frame (`Embedded`), which on gamescope would defeat its HW cursor plane.
|
||||
/// Without it — the session's encode path has no compositing stage for a metadata cursor
|
||||
/// (`pf-encode`'s `cursor_blend_capable` said the resolved backend can't blend) — the ladder
|
||||
/// prefers `Embedded`, so the pointer is in the pixels instead of in metadata nothing would draw.
|
||||
/// Both ladders fall through to the other mode, then `Hidden`; a failed property query (an older
|
||||
/// portal) keeps the prior `Embedded` behavior so the cursor is never silently lost.
|
||||
async fn choose_cursor_mode(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
want_metadata: bool,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match proxy.available_cursor_modes().await {
|
||||
Ok(avail) if want_metadata && avail.contains(CursorMode::Metadata) => {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
|
||||
);
|
||||
CursorMode::Metadata
|
||||
}
|
||||
Ok(avail) if avail.contains(CursorMode::Embedded) => {
|
||||
if want_metadata {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: requesting Embedded cursor (this session's encoder does not \
|
||||
composite a metadata cursor)"
|
||||
);
|
||||
}
|
||||
CursorMode::Embedded
|
||||
}
|
||||
Ok(avail) if avail.contains(CursorMode::Metadata) => {
|
||||
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture
|
||||
// path composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
|
||||
tracing::warn!(
|
||||
?avail,
|
||||
"ScreenCast: Embedded cursor not advertised — requesting cursor-as-metadata \
|
||||
(only CPU-path frames will composite it)"
|
||||
);
|
||||
CursorMode::Metadata
|
||||
}
|
||||
Ok(avail) => {
|
||||
tracing::warn!(
|
||||
?avail,
|
||||
"ScreenCast: neither Metadata nor Embedded cursor advertised — cursor will be hidden"
|
||||
);
|
||||
CursorMode::Hidden
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — defaulting to Embedded cursor"
|
||||
);
|
||||
CursorMode::Embedded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
|
||||
/// PipeWire remote, hand the fd + node id back, then keep the session alive until `quit_rx`
|
||||
/// resolves (the capturer's `Drop` — see [`PortalSession`]).
|
||||
pub(super) fn portal_thread(
|
||||
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||
quit_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
want_metadata_cursor: bool,
|
||||
) {
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus connection's background reader must be pumped
|
||||
// continuously across the create_session → select_sources → start handshake, or the
|
||||
// portal reports "Invalid session". (A current-thread runtime starves it.)
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new()
|
||||
.await
|
||||
.context("connect ScreenCast portal")?;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
let cursor_mode = choose_cursor_mode(&proxy, want_metadata_cursor).await;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// Only MONITOR is offered by the wlroots backend
|
||||
// (AvailableSourceTypes=1); requesting unsupported types
|
||||
// invalidates the session.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected (unsupported source type / cursor mode?)")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser cancelled? portal misconfigured?)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||
|
||||
// Keep `proxy` + `session` (and the underlying zbus connection) alive for the
|
||||
// capture; the cast is torn down when the connection drops (ashpd's `Session`
|
||||
// has no `Drop`) — which now happens when this park returns, not at process exit.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
let _ = quit_rx.await;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
// Drop the runtime HERE, before the caller signals completion: shutting the 2 workers down is
|
||||
// what finishes releasing the zbus connection, so a `done` signal sent after this means the
|
||||
// compositor-side session is really gone (see `PortalSession::drop`).
|
||||
drop(rt);
|
||||
}
|
||||
|
||||
/// Combined RemoteDesktop+ScreenCast portal setup (KWin/GNOME). ScreenCast sources are selected
|
||||
/// on a session created via RemoteDesktop, so a single RemoteDesktop `start` grant —
|
||||
/// pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input
|
||||
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
|
||||
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
|
||||
/// identical, as is the `quit_rx` teardown park (see [`PortalSession`]).
|
||||
pub(super) fn portal_thread_remote_desktop(
|
||||
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||
quit_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
want_metadata_cursor: bool,
|
||||
) {
|
||||
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let remote = RemoteDesktop::new()
|
||||
.await
|
||||
.context("connect RemoteDesktop portal")?;
|
||||
let screencast = Screencast::new()
|
||||
.await
|
||||
.context("connect ScreenCast portal")?;
|
||||
let session = remote
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create RemoteDesktop session")?;
|
||||
// RemoteDesktop requires a device selection; we never connect_to_eis on this session
|
||||
// (input injection runs its own), but selecting devices is what makes `start` the
|
||||
// RemoteDesktop grant the kde-authorized bypass covers.
|
||||
remote
|
||||
.select_devices(
|
||||
&session,
|
||||
SelectDevicesOptions::default()
|
||||
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_devices")?
|
||||
.response()
|
||||
.context("select_devices rejected")?;
|
||||
let cursor_mode = choose_cursor_mode(&screencast, want_metadata_cursor).await;
|
||||
screencast
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected (unsupported source type?)")?;
|
||||
let streams = remote
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start RemoteDesktop+ScreenCast")?
|
||||
.response()
|
||||
.context("start response (grant not pre-authorized / headless dialog?)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no screencast streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = screencast
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||
|
||||
// Keep the proxies + session (and their zbus connection) alive for the capture, until
|
||||
// the capturer's `Drop` fires the quit channel.
|
||||
let _keep_alive = (&remote, &screencast, &session);
|
||||
let _ = quit_rx.await;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
// See `portal_thread`: drop the runtime before the caller's completion signal.
|
||||
drop(rt);
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
//! Cursor-as-metadata: the `SPA_META_Cursor` parser and the CPU-path composite blits.
|
||||
//!
|
||||
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2). Both halves are producer-driven and
|
||||
//! bounds-critical: [`update_cursor_meta`] reads a bitmap at offsets the COMPOSITOR chose (its own
|
||||
//! SAFETY proof notes that a missing bound SIGSEGVs inside the PipeWire `.process` callback, where
|
||||
//! `catch_unwind` cannot help), and the `composite_cursor*` blits clip a caller-positioned bitmap
|
||||
//! into a frame buffer. Separating them from the stream machinery is what makes them testable
|
||||
//! without a compositor.
|
||||
|
||||
use super::PixelFormat;
|
||||
use pipewire as pw;
|
||||
use pw::spa;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Latest cursor state parsed from `SPA_META_Cursor` (cursor-as-metadata mode). Position is
|
||||
/// refreshed every buffer that carries the meta (including Mutter's cursor-only "corrupted"
|
||||
/// buffers we otherwise skip for their stale frame); the RGBA bitmap is cached and only
|
||||
/// replaced when the compositor sends a fresh one (`bitmap_offset != 0`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct CursorState {
|
||||
/// True when the compositor reports a visible pointer (`spa_meta_cursor.id != 0`).
|
||||
visible: bool,
|
||||
/// Top-left where the bitmap is drawn = reported position − hotspot.
|
||||
x: i32,
|
||||
y: i32,
|
||||
/// Cached straight-alpha RGBA pixels (`bw*bh*4`, bytes R,G,B,A). `Arc` so the overlay handed
|
||||
/// to each GPU frame is a refcount bump, not a copy. Empty until the first bitmap arrives.
|
||||
rgba: Arc<Vec<u8>>,
|
||||
bw: u32,
|
||||
bh: u32,
|
||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
||||
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||
serial: u64,
|
||||
/// The compositor-reported hotspot — carried on the overlay for the cursor-forward
|
||||
/// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it).
|
||||
hot_x: i32,
|
||||
hot_y: i32,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
/// client's relative-mode hint (M3) — which is a different fact from "no cursor yet".
|
||||
/// The encode loop strips invisible overlays before any blend path sees the frame.
|
||||
/// Cheap: clones an `Arc` + a few scalars.
|
||||
pub(super) fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
|
||||
if self.rgba.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(pf_frame::CursorOverlay {
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
w: self.bw,
|
||||
h: self.bh,
|
||||
rgba: self.rgba.clone(),
|
||||
serial: self.serial,
|
||||
hot_x: self.hot_x.max(0) as u32,
|
||||
hot_y: self.hot_y.max(0) as u32,
|
||||
visible: self.visible,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract straight (R,G,B,A) from one 4-byte cursor-bitmap pixel, honoring the bitmap's SPA
|
||||
/// video format (portals emit RGBA or BGRA; ARGB/ABGR handled for completeness). Unknown
|
||||
/// 4-byte formats are read as RGBA.
|
||||
pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
match vfmt {
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_RGBA => (s[0], s[1], s[2], s[3]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_BGRA => (s[2], s[1], s[0], s[3]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_ARGB => (s[1], s[2], s[3], s[0]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_ABGR => (s[3], s[2], s[1], s[0]),
|
||||
_ => (s[0], s[1], s[2], s[3]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
||||
pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
||||
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
|
||||
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
|
||||
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
|
||||
// are ALL producer-written, and without a bound against the actual region they drive
|
||||
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
|
||||
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
||||
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
||||
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
||||
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
|
||||
if meta.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
|
||||
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
|
||||
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
|
||||
return;
|
||||
}
|
||||
let cur = data as *const spa::sys::spa_meta_cursor;
|
||||
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
|
||||
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||
(
|
||||
(*cur).id,
|
||||
(*cur).position.x,
|
||||
(*cur).position.y,
|
||||
(*cur).hotspot.x,
|
||||
(*cur).hotspot.y,
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
cursor.hot_y = hot_y;
|
||||
if bmp_off == 0 {
|
||||
// Position-only update — keep the cached bitmap.
|
||||
return;
|
||||
}
|
||||
let bmp_off = bmp_off as usize;
|
||||
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
|
||||
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
|
||||
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
|
||||
Some(end) if end <= region_size => {}
|
||||
_ => return,
|
||||
}
|
||||
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
|
||||
// so the header is fully in bounds for a read of that many bytes. `read_unaligned` is
|
||||
// REQUIRED, not defensive: `bmp_off` is producer-written and nothing in the SPA contract or
|
||||
// in this function establishes that `data + bmp_off` meets `spa_meta_bitmap`'s alignment —
|
||||
// the previous field reads through an aligned `*const` asserted an invariant the code never
|
||||
// proved. The struct is `Copy` POD, so one unaligned read yields an owned, aligned local.
|
||||
let bmp = unsafe { (data.add(bmp_off) as *const spa::sys::spa_meta_bitmap).read_unaligned() };
|
||||
let (vfmt, bw, bh, stride, pix_off) = (
|
||||
bmp.format,
|
||||
bmp.size.width,
|
||||
bmp.size.height,
|
||||
bmp.stride.max(0) as usize,
|
||||
bmp.offset as usize,
|
||||
);
|
||||
// Ignore empty or implausibly large bitmaps (the meta-size request covers <= 1024×1024;
|
||||
// real cursors are ≤96px — the cursor channel downscales >120px for the wire anyway).
|
||||
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
|
||||
return;
|
||||
}
|
||||
let row = bw as usize * 4;
|
||||
let stride = if stride < row { row } else { stride };
|
||||
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// SAFETY: `bitmap_extent` returned `Some`, which means (see its contract) the whole range
|
||||
// `[bmp_off + pix_off, +len)` lies inside `region_size` and `len` is EXACTLY the extent the
|
||||
// strided loop below reads. `data` is the producer's meta-region base, live for this callback.
|
||||
let src = unsafe { std::slice::from_raw_parts(data.add(extent.start), extent.len()) };
|
||||
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
||||
for y in 0..bh as usize {
|
||||
for x in 0..bw as usize {
|
||||
let so = y * stride + x * 4;
|
||||
let (r, g, b, a) = decode_bitmap_pixel(vfmt, &src[so..so + 4]);
|
||||
let d = (y * bw as usize + x) * 4;
|
||||
rgba[d] = r;
|
||||
rgba[d + 1] = g;
|
||||
rgba[d + 2] = b;
|
||||
rgba[d + 3] = a;
|
||||
}
|
||||
}
|
||||
cursor.rgba = Arc::new(rgba);
|
||||
cursor.bw = bw;
|
||||
cursor.bh = bh;
|
||||
cursor.serial = cursor.serial.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The byte range inside the producer's cursor-meta region that a `bh`-row, `row`-wide,
|
||||
/// `stride`-strided bitmap at `bmp_off + pix_off` occupies — or `None` when it does not fit, or when
|
||||
/// any of the arithmetic overflows.
|
||||
///
|
||||
/// THE bound on `update_cursor_meta`. Every input except `region_size` is producer-written, and
|
||||
/// `region_size` is the real byte size of the meta region libspa handed us (which is why the caller
|
||||
/// takes `spa_buffer_find_meta` rather than `find_meta_data`). Without this check the offsets drive
|
||||
/// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read that
|
||||
/// SIGSEGVs inside the PipeWire `.process` callback, where `catch_unwind` cannot help. A stride near
|
||||
/// `i32::MAX` is enough to overflow the multiply on its own, so every step is checked.
|
||||
///
|
||||
/// `len()` of the returned range is EXACTLY `stride·(bh−1) + row`: the last row contributes only its
|
||||
/// `row` visible bytes, not a full stride, so a bitmap that ends flush against the region's end is
|
||||
/// accepted rather than rejected by a padding byte that is never read.
|
||||
///
|
||||
/// Extracted (sweep Phase 6.1) purely so it can be tested — the caller is unreachable without a live
|
||||
/// compositor.
|
||||
fn bitmap_extent(
|
||||
bmp_off: usize,
|
||||
pix_off: usize,
|
||||
stride: usize,
|
||||
row: usize,
|
||||
bh: usize,
|
||||
region_size: usize,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
if bh == 0 || row == 0 || stride < row {
|
||||
return None;
|
||||
}
|
||||
let span = stride.checked_mul(bh - 1)?.checked_add(row)?;
|
||||
let start = bmp_off.checked_add(pix_off)?;
|
||||
let end = start.checked_add(span)?;
|
||||
(end <= region_size).then_some(start..end)
|
||||
}
|
||||
|
||||
/// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`,
|
||||
/// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach
|
||||
/// the CPU de-pad path anyway).
|
||||
pub(super) fn dst_offsets(fmt: PixelFormat) -> Option<(usize, usize, usize, usize)> {
|
||||
Some(match fmt {
|
||||
PixelFormat::Bgrx | PixelFormat::Bgra => (2, 1, 0, 4),
|
||||
PixelFormat::Rgbx | PixelFormat::Rgba => (0, 1, 2, 4),
|
||||
PixelFormat::Rgb => (0, 1, 2, 3),
|
||||
PixelFormat::Bgr => (2, 1, 0, 3),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into a packed 10-bit (`X2Rgb10`/`X2Bgr10`) CPU frame:
|
||||
/// unpack each u32, blend the 8-bit cursor channels scaled to 10 bits (`v<<2 | v>>6`), repack.
|
||||
/// The frame samples are PQ-encoded, so like the 8-bit gamma-space blend this is a display-
|
||||
/// referred approximation — fine for a cursor. `r_shift` is the R channel's bit offset (20 for
|
||||
/// x:R:G:B, 0 for x:B:G:R); G is always at 10 and B mirrors R.
|
||||
pub(super) fn composite_cursor_rgb10(
|
||||
tight: &mut [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
r_shift: u32,
|
||||
cursor: &CursorState,
|
||||
) {
|
||||
let b_shift = 20 - r_shift; // 0 or 20 — the opposite end from R
|
||||
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||
for cy in 0..bh {
|
||||
let dy = cursor.y + cy;
|
||||
if dy < 0 || dy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
for cx in 0..bw {
|
||||
let dx = cursor.x + cx;
|
||||
if dx < 0 || dx as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let s = ((cy * bw + cx) as usize) * 4;
|
||||
let a = cursor.rgba[s + 3] as u32;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
// 8-bit cursor channel → 10-bit (replicate the top bits into the bottom).
|
||||
let up10 = |v: u8| ((v as u32) << 2) | ((v as u32) >> 6);
|
||||
let (sr, sg, sb) = (
|
||||
up10(cursor.rgba[s]),
|
||||
up10(cursor.rgba[s + 1]),
|
||||
up10(cursor.rgba[s + 2]),
|
||||
);
|
||||
let di = (dy as usize * w + dx as usize) * 4;
|
||||
let px = u32::from_le_bytes(tight[di..di + 4].try_into().unwrap());
|
||||
let blend = |dst: u32, src: u32| (src * a + dst * (255 - a)) / 255;
|
||||
let dr = blend((px >> r_shift) & 0x3ff, sr);
|
||||
let dg = blend((px >> 10) & 0x3ff, sg);
|
||||
let db = blend((px >> b_shift) & 0x3ff, sb);
|
||||
let out = (px & 0xc000_0000) | (dr << r_shift) | (dg << 10) | (db << b_shift);
|
||||
tight[di..di + 4].copy_from_slice(&out.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
|
||||
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
|
||||
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
|
||||
pub(super) fn composite_cursor(
|
||||
tight: &mut [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
fmt: PixelFormat,
|
||||
cursor: &CursorState,
|
||||
) {
|
||||
if !cursor.visible || cursor.rgba.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The packed 10-bit HDR layouts blend via bit unpack/repack, not byte offsets.
|
||||
match fmt {
|
||||
PixelFormat::X2Rgb10 => return composite_cursor_rgb10(tight, w, h, 20, cursor),
|
||||
PixelFormat::X2Bgr10 => return composite_cursor_rgb10(tight, w, h, 0, cursor),
|
||||
_ => {}
|
||||
}
|
||||
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
|
||||
return;
|
||||
};
|
||||
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||
for cy in 0..bh {
|
||||
let dy = cursor.y + cy;
|
||||
if dy < 0 || dy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
for cx in 0..bw {
|
||||
let dx = cursor.x + cx;
|
||||
if dx < 0 || dx as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let s = ((cy * bw + cx) as usize) * 4;
|
||||
let a = cursor.rgba[s + 3] as u32;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
let (sr, sg, sb) = (
|
||||
cursor.rgba[s] as u32,
|
||||
cursor.rgba[s + 1] as u32,
|
||||
cursor.rgba[s + 2] as u32,
|
||||
);
|
||||
let di = (dy as usize * w + dx as usize) * bpp;
|
||||
let blend = |dst: u8, src: u32| ((src * a + dst as u32 * (255 - a)) / 255) as u8;
|
||||
tight[di + ri] = blend(tight[di + ri], sr);
|
||||
tight[di + gi] = blend(tight[di + gi], sg);
|
||||
tight[di + bi] = blend(tight[di + bi], sb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A solid-colour cursor state at `(x, y)`, `w`×`h`, alpha `a`.
|
||||
fn cursor(x: i32, y: i32, w: u32, h: u32, rgb: (u8, u8, u8), a: u8) -> CursorState {
|
||||
let mut px = Vec::with_capacity((w * h * 4) as usize);
|
||||
for _ in 0..w * h {
|
||||
px.extend_from_slice(&[rgb.0, rgb.1, rgb.2, a]);
|
||||
}
|
||||
CursorState {
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
rgba: Arc::new(px),
|
||||
bw: w,
|
||||
bh: h,
|
||||
serial: 1,
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_accepts_a_bitmap_that_fits() {
|
||||
// 4×2 RGBA, tightly packed: 32 bytes at offset 0.
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 32), Some(0..32));
|
||||
// …and the same bitmap behind a header + pixel offset.
|
||||
assert_eq!(bitmap_extent(24, 8, 16, 16, 2, 64), Some(32..64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_charges_the_last_row_only_its_visible_bytes() {
|
||||
// stride 32, row 16, 3 rows ⇒ 32*2 + 16 = 80, NOT 96. A bitmap ending flush against the
|
||||
// region must be accepted: the trailing stride padding is never read.
|
||||
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 80), Some(0..80));
|
||||
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 79), None, "one byte short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_rejects_anything_past_the_region() {
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 31), None);
|
||||
// An offset alone can push it out.
|
||||
assert_eq!(bitmap_extent(1, 0, 16, 16, 2, 32), None);
|
||||
assert_eq!(bitmap_extent(0, 1, 16, 16, 2, 32), None);
|
||||
// A region of zero accepts nothing.
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 1, 0), None);
|
||||
}
|
||||
|
||||
/// The producer picks `stride` and both offsets, so each is an overflow vector on its own.
|
||||
#[test]
|
||||
fn bitmap_extent_survives_hostile_arithmetic() {
|
||||
// stride × (bh-1) overflows.
|
||||
assert_eq!(bitmap_extent(0, 0, usize::MAX, 16, 3, usize::MAX), None);
|
||||
// span + row overflows. Needs ≥2 rows so `stride·(bh−1)` is already at the ceiling: with a
|
||||
// SINGLE row `stride·0 == 0`, and even a `usize::MAX`-wide row is then arithmetically in
|
||||
// range — which is correct rather than a miss, since the caller has already capped `bw` at
|
||||
// 1024 and `row` is therefore ≤ 4096.
|
||||
assert_eq!(
|
||||
bitmap_extent(0, 0, usize::MAX, usize::MAX, 2, usize::MAX),
|
||||
None
|
||||
);
|
||||
// bmp_off + pix_off overflows.
|
||||
assert_eq!(bitmap_extent(usize::MAX, 1, 16, 16, 1, usize::MAX), None);
|
||||
// start + span overflows.
|
||||
assert_eq!(
|
||||
bitmap_extent(usize::MAX - 8, 0, 16, 16, 1, usize::MAX),
|
||||
None
|
||||
);
|
||||
// A near-i32::MAX stride — the case the SAFETY comment calls out — must not wrap.
|
||||
assert_eq!(bitmap_extent(0, 0, i32::MAX as usize, 16, 1024, 4096), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_rejects_degenerate_geometry() {
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 0, 4096), None, "zero rows");
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 0, 2, 4096), None, "zero-width row");
|
||||
assert_eq!(bitmap_extent(0, 0, 8, 16, 2, 4096), None, "stride < row");
|
||||
}
|
||||
|
||||
// ---- composite_cursor: clipping, alpha, and every layout --------------------------------
|
||||
|
||||
/// Read pixel `(x, y)`'s (R, G, B) out of a packed frame, honouring the layout's byte order.
|
||||
fn px_rgb(buf: &[u8], w: usize, x: usize, y: usize, fmt: PixelFormat) -> (u8, u8, u8) {
|
||||
let (ri, gi, bi, bpp) = dst_offsets(fmt).expect("packed layout");
|
||||
let i = (y * w + x) * bpp;
|
||||
(buf[i + ri], buf[i + gi], buf[i + bi])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_packed_layout_lands_the_colour_in_its_own_channels() {
|
||||
for fmt in [
|
||||
PixelFormat::Bgrx,
|
||||
PixelFormat::Bgra,
|
||||
PixelFormat::Rgbx,
|
||||
PixelFormat::Rgba,
|
||||
PixelFormat::Rgb,
|
||||
PixelFormat::Bgr,
|
||||
] {
|
||||
let bpp = dst_offsets(fmt).unwrap().3;
|
||||
let (w, h) = (4usize, 4usize);
|
||||
let mut buf = vec![0u8; w * h * bpp];
|
||||
// Opaque pure red at (1, 1).
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(1, 1, 1, 1, (255, 0, 0), 255));
|
||||
assert_eq!(px_rgb(&buf, w, 1, 1, fmt), (255, 0, 0), "{fmt:?}");
|
||||
// Nothing else moved.
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (0, 0, 0), "{fmt:?}");
|
||||
assert_eq!(px_rgb(&buf, w, 2, 1, fmt), (0, 0, 0), "{fmt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cursor_hanging_off_every_edge_is_clipped_not_wrapped() {
|
||||
let (w, h, fmt) = (4usize, 4usize, PixelFormat::Bgrx);
|
||||
// Top-left: only the bottom-right quarter of a 2×2 lands, at (0, 0).
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
fmt,
|
||||
&cursor(-1, -1, 2, 2, (10, 20, 30), 255),
|
||||
);
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (10, 20, 30));
|
||||
assert_eq!(px_rgb(&buf, w, 1, 0, fmt), (0, 0, 0));
|
||||
assert_eq!(px_rgb(&buf, w, 0, 1, fmt), (0, 0, 0));
|
||||
// Bottom-right: only the top-left quarter lands, at (3, 3).
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(3, 3, 2, 2, (10, 20, 30), 255));
|
||||
assert_eq!(px_rgb(&buf, w, 3, 3, fmt), (10, 20, 30));
|
||||
assert_eq!(px_rgb(&buf, w, 2, 3, fmt), (0, 0, 0));
|
||||
// Fully outside in each direction: the frame is untouched.
|
||||
for pos in [(-2, 0), (0, -2), (4, 0), (0, 4), (-9, -9), (99, 99)] {
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
fmt,
|
||||
&cursor(pos.0, pos.1, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert!(buf.iter().all(|&b| b == 0), "drew something at {pos:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_and_hidden_cursors_draw_nothing() {
|
||||
let (w, h, fmt) = (2usize, 2usize, PixelFormat::Bgrx);
|
||||
// Alpha 0 — every pixel skipped.
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 2, 2, (255, 255, 255), 0));
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
// `visible: false` — the whole blit is skipped.
|
||||
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
|
||||
c.visible = false;
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &c);
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
// No bitmap yet — likewise.
|
||||
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
|
||||
c.rgba = Arc::new(Vec::new());
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &c);
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_alpha_blends_toward_the_destination() {
|
||||
let (w, h, fmt) = (1usize, 1usize, PixelFormat::Bgrx);
|
||||
// dst = white, src = black at 50% ⇒ mid grey (integer blend: (0*128 + 255*127)/255 = 127).
|
||||
let mut buf = vec![255u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 1, 1, (0, 0, 0), 128));
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (127, 127, 127));
|
||||
}
|
||||
|
||||
/// A layout the CPU blit cannot address must be declined, not mis-blitted.
|
||||
#[test]
|
||||
fn unsupported_layouts_are_declined() {
|
||||
assert!(dst_offsets(PixelFormat::Nv12).is_none());
|
||||
assert!(dst_offsets(PixelFormat::Yuv444).is_none());
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::Nv12,
|
||||
&cursor(0, 0, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert!(buf.iter().all(|&b| b == 0), "NV12 must not be blitted");
|
||||
}
|
||||
|
||||
// ---- composite_cursor_rgb10: the 10-bit unpack/repack round trip -------------------------
|
||||
|
||||
/// Pack an `x:R:G:B` (`X2Rgb10`) pixel — R at bit 20, G at 10, B at 0 — the way a producer does.
|
||||
fn pack_x2rgb10(r: u32, g: u32, b: u32) -> [u8; 4] {
|
||||
(0xC000_0000 | (r << 20) | (g << 10) | b).to_le_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_round_trips_an_untouched_pixel() {
|
||||
// Alpha 0 ⇒ the blend is skipped entirely, so the packed pixel must come back bit-identical
|
||||
// (including the top two bits, which are alpha and must survive the repack).
|
||||
for (r, g, b) in [(0, 0, 0), (1023, 1023, 1023), (940, 64, 512), (1, 2, 3)] {
|
||||
let src = pack_x2rgb10(r, g, b);
|
||||
let mut buf = src.to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(0, 0, 1, 1, (255, 255, 255), 0),
|
||||
);
|
||||
assert_eq!(buf, src, "({r},{g},{b}) was modified by a zero-alpha blend");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_writes_the_right_channel_at_the_right_shift() {
|
||||
// Opaque pure red, 8-bit 255 → 10-bit 1023 (the `v<<2 | v>>6` expansion).
|
||||
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
|
||||
);
|
||||
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
|
||||
assert_eq!((v >> 20) & 0x3ff, 1023, "R");
|
||||
assert_eq!((v >> 10) & 0x3ff, 0, "G");
|
||||
assert_eq!(v & 0x3ff, 0, "B");
|
||||
assert_eq!(v & 0xc000_0000, 0xc000_0000, "alpha bits preserved");
|
||||
|
||||
// X2Bgr10 puts R at bit 0 and B at 20 — the SAME cursor must land in the other end.
|
||||
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Bgr10,
|
||||
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
|
||||
);
|
||||
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
|
||||
assert_eq!(v & 0x3ff, 1023, "R at bit 0 for x:B:G:R");
|
||||
assert_eq!((v >> 20) & 0x3ff, 0, "B untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_clips_like_the_8bit_one() {
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let mut buf: Vec<u8> = (0..w * h).flat_map(|_| pack_x2rgb10(0, 0, 0)).collect();
|
||||
let before = buf.clone();
|
||||
// Entirely off-frame.
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(-5, -5, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert_eq!(buf, before);
|
||||
// Straddling the top-left corner: only (0, 0) is written.
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(-1, -1, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
let p0 = u32::from_le_bytes(buf[0..4].try_into().unwrap());
|
||||
let p1 = u32::from_le_bytes(buf[4..8].try_into().unwrap());
|
||||
assert_eq!((p0 >> 20) & 0x3ff, 1023);
|
||||
assert_eq!((p1 >> 20) & 0x3ff, 0);
|
||||
}
|
||||
|
||||
// ---- decode_bitmap_pixel: the producer's byte order ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn each_bitmap_format_is_decoded_to_straight_rgba() {
|
||||
let s = [1u8, 2, 3, 4];
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_RGBA, &s),
|
||||
(1, 2, 3, 4)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_BGRA, &s),
|
||||
(3, 2, 1, 4)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ARGB, &s),
|
||||
(2, 3, 4, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ABGR, &s),
|
||||
(4, 3, 2, 1)
|
||||
);
|
||||
// An unknown 4-byte format reads as RGBA rather than being rejected — documented behaviour.
|
||||
assert_eq!(decode_bitmap_pixel(0xdead_beef, &s), (1, 2, 3, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
//! The PipeWire `EnumFormat` / `Buffers` / `Meta` param pods the capture stream offers, and the
|
||||
//! `Pod` serializer they all end in.
|
||||
//!
|
||||
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2) because it is the crate's WIRE surface: what
|
||||
//! these builders put in a pod is what the compositor intersects against, and a missing property is
|
||||
//! not a compile error but a link that silently stalls in `negotiating`. Nothing here touches the
|
||||
//! stream, the buffers or the frames — every function is a pure `facts -> Vec<u8>`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pipewire as pw;
|
||||
use pw::spa;
|
||||
use spa::param::video::VideoFormat;
|
||||
|
||||
pub(super) fn serialize_pod(obj: pw::spa::pod::Object) -> Result<Vec<u8>> {
|
||||
Ok(pw::spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&pw::spa::pod::Value::Object(obj),
|
||||
)
|
||||
.context("serialize pod")?
|
||||
.0
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
|
||||
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
|
||||
pub(super) fn build_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
modifiers: &[u64],
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||
let mut obj = pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
if format == VideoFormat::NV12 {
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
|
||||
)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
|
||||
)),
|
||||
});
|
||||
}
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Long(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
pw::spa::utils::ChoiceEnum::Enum {
|
||||
default: modifiers[0] as i64,
|
||||
alternatives: modifiers.iter().map(|&m| m as i64).collect(),
|
||||
},
|
||||
),
|
||||
)),
|
||||
});
|
||||
serialize_pod(obj)
|
||||
}
|
||||
|
||||
/// Build one GNOME 50+ HDR format pod: `format` (xRGB_210LE / xBGR_210LE) as a LINEAR-only
|
||||
/// dmabuf with **MANDATORY** BT.2020 primaries + SMPTE ST.2084 (PQ) transfer-function props —
|
||||
/// the exact colorimetry Mutter's monitor stream advertises while the mirrored monitor is in
|
||||
/// HDR mode (its HDR pods carry the same props MANDATORY, so both sides must speak them for
|
||||
/// the intersection to exist; an SDR or pre-50 producer can never match this pod).
|
||||
///
|
||||
/// LINEAR-only because every 10-bit consumer we have reads the buffer without a de-tile pass:
|
||||
/// the CPU path mmaps it, and the VAAPI passthrough imports it into a VA surface. The tiled
|
||||
/// EGL de-tile blit renders into an 8-bit `GL_RGBA8` texture — it would silently crush the
|
||||
/// depth — so tiled modifiers are deliberately NOT advertised (a zero-copy 10-bit de-tile is
|
||||
/// the follow-up). SHM is excluded entirely: Mutter's SHM record path paints 8-bit ARGB32
|
||||
/// regardless of the negotiated format.
|
||||
/// `SPA_VIDEO_TRANSFER_SMPTE2084` (PQ) — spelled out rather than taken from `pw::spa::sys`
|
||||
/// because libspa only grew the constant with the BT2020_10/SMPTE2084/ARIB_STD_B67 block, and
|
||||
/// the distro builders (Ubuntu 24.04 noble for the .deb) ship headers predating it — bindgen
|
||||
/// then emits no such constant and the host fails to compile there, even though the code never
|
||||
/// runs on those systems (the HDR path needs GNOME 50+).
|
||||
///
|
||||
/// 14 is the enum's position in `spa/param/video/color.h` and is wire ABI, not a private
|
||||
/// detail: SPA mirrors GStreamer's `GstVideoTransferFunction`, where that block was added
|
||||
/// together, so the value is identical on every libspa that has the symbol at all. On one that
|
||||
/// doesn't, PipeWire simply fails to intersect this format offer and the session negotiates
|
||||
/// SDR — the same outcome as not offering HDR.
|
||||
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
|
||||
|
||||
pub(super) fn build_hdr_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||
let mut obj = pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Long(0), // DRM_FORMAT_MOD_LINEAR
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(SPA_VIDEO_TRANSFER_SMPTE2084)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorPrimaries,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020,
|
||||
)),
|
||||
});
|
||||
serialize_pod(obj)
|
||||
}
|
||||
|
||||
/// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any
|
||||
/// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that).
|
||||
pub(super) fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::MediaType,
|
||||
Id,
|
||||
pw::spa::param::format::MediaType::Video
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::MediaSubtype,
|
||||
Id,
|
||||
pw::spa::param::format::MediaSubtype::Raw
|
||||
),
|
||||
// Offer the layouts the encoder can map to an NVENC input format. wlroots
|
||||
// commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only
|
||||
// these are requested, so negotiation fails loudly rather than handing us a
|
||||
// format we'd misinterpret.
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoFormat,
|
||||
Choice,
|
||||
Enum,
|
||||
Id,
|
||||
VideoFormat::RGB,
|
||||
VideoFormat::RGB,
|
||||
VideoFormat::BGR,
|
||||
VideoFormat::RGBx,
|
||||
VideoFormat::BGRx,
|
||||
VideoFormat::RGBA,
|
||||
VideoFormat::BGRA,
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a Buffers param for the CPU path accepting anything mappable: MemPtr, MemFd, and
|
||||
/// DmaBuf. The DmaBuf bit matters for producers like gamescope whose format intersection
|
||||
/// lands on their modifier-bearing (LINEAR) pod: they then offer *only* DmaBuf buffers, and
|
||||
/// without this bit the buffer-type intersection is empty and the link silently stalls in
|
||||
/// "negotiating". A LINEAR dmabuf is mmap-able by MAP_BUFFERS, so the CPU de-pad copy works.
|
||||
pub(super) fn build_mappable_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(
|
||||
(1i32 << pw::spa::sys::SPA_DATA_MemPtr)
|
||||
| (1i32 << pw::spa::sys::SPA_DATA_MemFd)
|
||||
| (1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||
),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Buffers param for a TRUE SHM path: MemPtr + MemFd only, NO DmaBuf. Forces the
|
||||
/// producer to download into mappable memory (Mutter's `glReadPixels`), which orders against its
|
||||
/// render — so the frame is complete and current by construction. This is the only race-free
|
||||
/// capture of Mutter's virtual monitor on NVIDIA: the compositor renders straight into the buffer
|
||||
/// pool, NVIDIA attaches no implicit dmabuf fence (verified: `EXPORT_SYNC_FILE` waited=false) and
|
||||
/// can't produce an explicit sync_fd, so any dmabuf read (zero-copy OR mmap) races the render and
|
||||
/// flashes the buffer's previous frame. Excluding DmaBuf is what makes the difference vs.
|
||||
/// `build_mappable_buffers` (which still let Mutter hand dmabufs).
|
||||
pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(
|
||||
(1i32 << pw::spa::sys::SPA_DATA_MemPtr) | (1i32 << pw::spa::sys::SPA_DATA_MemFd),
|
||||
),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Buffers param requesting dmabuf-only buffers.
|
||||
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
|
||||
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
|
||||
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
|
||||
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
|
||||
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
|
||||
fn meta_size(w: u32, h: u32) -> i32 {
|
||||
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
|
||||
+ std::mem::size_of::<spa::sys::spa_meta_bitmap>()
|
||||
+ (w as usize * h as usize * 4)) as i32
|
||||
}
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamMeta.as_raw(),
|
||||
id: pw::spa::param::ParamType::Meta.as_raw(),
|
||||
properties: vec![
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_META_type,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(spa::sys::SPA_META_Cursor)),
|
||||
},
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_META_size,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
// The max must cover the producer's offer or the Meta param silently
|
||||
// fails to negotiate and NO buffer ever carries the meta region:
|
||||
// Mutter offers a FIXED `SPA_POD_Int(CURSOR_META_SIZE(384, 384))`
|
||||
// (meta-screen-cast-stream-src.c, GNOME 50) — a 256² max made the
|
||||
// intersection empty, which cost the whole Linux cursor channel
|
||||
// on-glass. 1024² is headroom, not an allocation: the negotiated
|
||||
// region follows the producer's value.
|
||||
pw::spa::utils::ChoiceEnum::Range {
|
||||
default: meta_size(64, 64),
|
||||
min: meta_size(1, 1),
|
||||
max: meta_size(1024, 1024),
|
||||
},
|
||||
),
|
||||
)),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `SPA_PARAM_BUFFERS_dataType` bitmask a serialized Buffers pod carries.
|
||||
///
|
||||
/// A deliberately literal SPA reader rather than a heuristic scan: an object property is
|
||||
/// `{ key: u32, flags: u32, value: spa_pod }` and a `spa_pod` is `{ size: u32, type: u32, body }`,
|
||||
/// so the `i32` sits exactly 16 bytes past the key — and the intervening `size` word is itself
|
||||
/// `4`, which is why "find the first plausible-looking int" reads the wrong field.
|
||||
fn buffers_data_type(pod: &[u8]) -> i32 {
|
||||
let key = spa::sys::SPA_PARAM_BUFFERS_dataType.to_ne_bytes();
|
||||
let at = pod
|
||||
.windows(4)
|
||||
.position(|w| w == key)
|
||||
.expect("dataType key present in the Buffers pod");
|
||||
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
|
||||
assert_eq!(word(at + 8), 4, "dataType's value pod should be 4 bytes");
|
||||
assert_eq!(
|
||||
word(at + 12),
|
||||
spa::sys::SPA_TYPE_Int,
|
||||
"dataType's value pod should be an Int"
|
||||
);
|
||||
i32::from_ne_bytes(pod[at + 16..at + 20].try_into().unwrap())
|
||||
}
|
||||
|
||||
const MEM_PTR: i32 = 1 << spa::sys::SPA_DATA_MemPtr;
|
||||
const MEM_FD: i32 = 1 << spa::sys::SPA_DATA_MemFd;
|
||||
const DMABUF: i32 = 1 << spa::sys::SPA_DATA_DmaBuf;
|
||||
|
||||
/// The three Buffers pods differ ONLY in this bitmask, and each bit is load-bearing:
|
||||
/// `build_mappable_buffers` must include DmaBuf or gamescope's modifier-bearing pod wins the
|
||||
/// format intersection and the BUFFER intersection is then empty (a link stuck in
|
||||
/// "negotiating"); `build_shm_only_buffers` must EXCLUDE it or Mutter hands dmabufs and the
|
||||
/// race-free download path is not race-free; `build_dmabuf_buffers` must exclude the mappable
|
||||
/// types or an HDR session can be handed a MemFd buffer, which Mutter paints 8-bit ARGB32
|
||||
/// regardless of the negotiated 10-bit format.
|
||||
#[test]
|
||||
fn each_buffers_pod_requests_exactly_its_own_data_types() {
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_mappable_buffers().unwrap()),
|
||||
MEM_PTR | MEM_FD | DMABUF,
|
||||
"the CPU path must accept mappable dmabufs too"
|
||||
);
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_shm_only_buffers().unwrap()),
|
||||
MEM_PTR | MEM_FD,
|
||||
"PUNKTFUNK_FORCE_SHM must exclude DmaBuf"
|
||||
);
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_dmabuf_buffers().unwrap()),
|
||||
DMABUF,
|
||||
"the zero-copy/HDR path must exclude SHM"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every pod builder must produce a pod libspa will accept back — a serializer that silently
|
||||
/// emitted a malformed object would fail only at negotiation, on a live compositor.
|
||||
#[test]
|
||||
fn every_pod_round_trips_through_pod_from_bytes() {
|
||||
let mut pods: Vec<(&str, Vec<u8>)> = vec![
|
||||
("mappable buffers", build_mappable_buffers().unwrap()),
|
||||
("shm-only buffers", build_shm_only_buffers().unwrap()),
|
||||
("dmabuf buffers", build_dmabuf_buffers().unwrap()),
|
||||
("cursor meta", build_cursor_meta_param().unwrap()),
|
||||
(
|
||||
"default format",
|
||||
serialize_pod(build_default_format_obj(None)).unwrap(),
|
||||
),
|
||||
(
|
||||
"dmabuf BGRx",
|
||||
build_dmabuf_format(VideoFormat::BGRx, &[0, 1, 2], Some((1920, 1080, 60))).unwrap(),
|
||||
),
|
||||
(
|
||||
"dmabuf NV12",
|
||||
build_dmabuf_format(VideoFormat::NV12, &[0], Some((1280, 720, 60))).unwrap(),
|
||||
),
|
||||
(
|
||||
"hdr xRGB",
|
||||
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, None).unwrap(),
|
||||
),
|
||||
(
|
||||
"hdr xBGR",
|
||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, Some((3840, 2160, 120))).unwrap(),
|
||||
),
|
||||
];
|
||||
for (name, bytes) in &mut pods {
|
||||
assert!(!bytes.is_empty(), "{name} serialized to nothing");
|
||||
assert_eq!(bytes.len() % 8, 0, "{name} is not 8-byte aligned/padded");
|
||||
assert!(
|
||||
spa::pod::Pod::from_bytes(bytes).is_some(),
|
||||
"{name} did not parse back as a pod"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The HDR pods carry BOTH colorimetry properties MANDATORY — Mutter's HDR pods do the same, so
|
||||
/// the intersection only exists if we speak them. Dropping either would negotiate an SDR-labelled
|
||||
/// 10-bit stream (or nothing at all).
|
||||
#[test]
|
||||
fn the_hdr_pods_carry_mandatory_pq_and_bt2020() {
|
||||
for fmt in [VideoFormat::xRGB_210LE, VideoFormat::xBGR_210LE] {
|
||||
let pod = build_hdr_dmabuf_format(fmt, None).unwrap();
|
||||
for (name, key) in [
|
||||
(
|
||||
"transferFunction",
|
||||
spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||
),
|
||||
("colorPrimaries", spa::sys::SPA_FORMAT_VIDEO_colorPrimaries),
|
||||
("modifier", spa::sys::SPA_FORMAT_VIDEO_modifier),
|
||||
] {
|
||||
assert!(
|
||||
pod.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"{fmt:?} pod is missing {name}"
|
||||
);
|
||||
}
|
||||
// The PQ id and BT.2020 id must both appear as values.
|
||||
assert!(
|
||||
pod.windows(4)
|
||||
.any(|w| w == SPA_VIDEO_TRANSFER_SMPTE2084.to_ne_bytes()),
|
||||
"{fmt:?} pod does not carry the PQ transfer id"
|
||||
);
|
||||
assert!(
|
||||
pod.windows(4)
|
||||
.any(|w| w == spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020.to_ne_bytes()),
|
||||
"{fmt:?} pod does not carry BT.2020 primaries"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An NV12 offer pins BT.709 limited so gamescope's producer-side RGB→YUV shader matches OUR
|
||||
/// bitstream colorimetry; the packed-RGB offer must NOT carry those (it is not YUV).
|
||||
#[test]
|
||||
fn only_the_nv12_offer_pins_the_colour_matrix() {
|
||||
let nv12 = build_dmabuf_format(VideoFormat::NV12, &[0], None).unwrap();
|
||||
let bgrx = build_dmabuf_format(VideoFormat::BGRx, &[0], None).unwrap();
|
||||
for (name, key) in [
|
||||
("colorMatrix", spa::sys::SPA_FORMAT_VIDEO_colorMatrix),
|
||||
("colorRange", spa::sys::SPA_FORMAT_VIDEO_colorRange),
|
||||
] {
|
||||
assert!(
|
||||
nv12.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"NV12 offer is missing {name}"
|
||||
);
|
||||
assert!(
|
||||
!bgrx.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"packed-RGB offer should not pin {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
|
||||
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see
|
||||
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol
|
||||
/// DOES exist. Any libspa that renumbers the enum fails this instead of silently tagging
|
||||
/// the HDR offer with the wrong transfer function.
|
||||
///
|
||||
/// Only builds where tests are compiled — the .deb/.rpm builders run plain `cargo build`,
|
||||
/// so this never reintroduces the compile failure it exists to prevent.
|
||||
#[test]
|
||||
fn pq_transfer_id_matches_libspa() {
|
||||
assert_eq!(
|
||||
super::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||
super::pw::spa::sys::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
use std::time::Duration;
|
||||
@@ -51,12 +51,29 @@ use x11rb::protocol::xproto::{
|
||||
Window,
|
||||
};
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
use x11rb::rust_connection::{DefaultStream, RustConnection};
|
||||
|
||||
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
||||
/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds.
|
||||
use crate::GamescopeCursorTargets;
|
||||
|
||||
/// Serializes the `XAUTHORITY` env swap of the LEGACY connect fallback (the var is process-global).
|
||||
///
|
||||
/// The fallback is a last resort now — see [`connect_conn`]. It serialises this source against
|
||||
/// itself and nothing else: `getenv` needs no lock to be racy, so every OTHER thread's read (libspa
|
||||
/// plugin load, EGL/CUDA init — concurrent by construction, since `attach_gamescope_cursor` runs
|
||||
/// while the PipeWire thread is starting) could still observe the swapped value or a torn
|
||||
/// environ. That is why the primary path parses the cookie itself and never touches the
|
||||
/// environment.
|
||||
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// The `MIT-MAGIC-COOKIE-1` auth-protocol name, as it appears in an `.Xauthority` entry.
|
||||
const MIT_MAGIC_COOKIE_1: &[u8] = b"MIT-MAGIC-COOKIE-1";
|
||||
|
||||
/// Re-run the targets provider this often: adopt Xwaylands that appeared after the stream started
|
||||
/// (gamescope spawns the game's on launch and advertises only the first in any child's environ) and
|
||||
/// retry ones whose connection died. Two seconds is far below a human's tolerance for a missing
|
||||
/// pointer and costs one cheap socket probe per known display.
|
||||
const REDISCOVER: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
|
||||
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
|
||||
/// and must out-run a 240 fps session or the pointer stutters.
|
||||
@@ -73,46 +90,42 @@ const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
|
||||
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
|
||||
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
|
||||
|
||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
||||
/// X connections — so it lives exactly as long as the capturer that owns it.
|
||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and waits — bounded — for it
|
||||
/// to release the X connections, so it lives (at most) as long as the capturer that owns it.
|
||||
pub(super) struct XFixesCursorSource {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the worker just before it returns — lets `Drop` bound its wait (see there).
|
||||
done: std::sync::mpsc::Receiver<()>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl XFixesCursorSource {
|
||||
/// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start
|
||||
/// publishing cursor overlays into `slot`, following the focused display's pointer. Returns
|
||||
/// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller
|
||||
/// degrades to no gamescope cursor (today's behaviour) instead of failing the session.
|
||||
/// Start publishing cursor overlays into `slot` from whichever of gamescope's nested Xwaylands
|
||||
/// it is drawing the pointer on. `targets` is re-run every [`REDISCOVER`] to adopt Xwaylands
|
||||
/// that appear later and retry dead ones; `frame_size` is the negotiated capture size, packed
|
||||
/// `(w << 32) | h`, `0` until the first negotiation (see [`scale_to_frame`]).
|
||||
///
|
||||
/// Returns `None` only if the thread cannot be spawned. An empty or entirely-unusable target
|
||||
/// list is NOT a failure: the worker idles and keeps re-running the provider, so a stream that
|
||||
/// starts before the game converges instead of being cursorless for the session.
|
||||
pub(super) fn spawn(
|
||||
targets: Vec<(String, Option<String>)>,
|
||||
targets: GamescopeCursorTargets,
|
||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||
frame_size: Arc<AtomicU64>,
|
||||
) -> Option<Self> {
|
||||
// Connect on the caller's thread so failures degrade cleanly and the displays are validated
|
||||
// before we commit a thread.
|
||||
// First pass on the caller's thread: the common case connects here, so the log line below
|
||||
// reports the real state instead of "starting…".
|
||||
let mut displays = Vec::new();
|
||||
for (dpy, xauth) in targets {
|
||||
match connect(&dpy, xauth.as_deref()) {
|
||||
Ok((conn, root, feedback)) => {
|
||||
displays.push(XDisplay::new(dpy, conn, root, feedback))
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
dpy = %dpy,
|
||||
error = %e,
|
||||
"gamescope cursor: skipping a nested Xwayland we can't use"
|
||||
),
|
||||
}
|
||||
}
|
||||
if displays.is_empty() {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: no usable nested Xwayland — no in-video pointer this session \
|
||||
(falls back to today's cursorless gamescope stream)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
rediscover(&mut displays, &targets, true);
|
||||
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
||||
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
|
||||
if displays.is_empty() {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: no usable nested Xwayland yet — retrying every {}s (a game's \
|
||||
Xwayland appears when it launches)",
|
||||
REDISCOVER.as_secs()
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
displays = ?names,
|
||||
cursor_feedback = feedback,
|
||||
@@ -120,15 +133,21 @@ impl XFixesCursorSource {
|
||||
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \
|
||||
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
|
||||
);
|
||||
}
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_worker = Arc::clone(&stop);
|
||||
let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-gs-cursor".into())
|
||||
.spawn(move || run(displays, slot, stop_worker))
|
||||
.spawn(move || {
|
||||
run(displays, slot, stop_worker, targets, frame_size);
|
||||
let _ = done_tx.send(());
|
||||
})
|
||||
.ok()?;
|
||||
Some(XFixesCursorSource {
|
||||
stop,
|
||||
done: done_rx,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
@@ -137,35 +156,69 @@ impl XFixesCursorSource {
|
||||
impl Drop for XFixesCursorSource {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
// BOUNDED join. The worker blocks in `RustConnection` replies with no read timeout, so a
|
||||
// peer that stops answering while keeping its socket open (a hung Xwayland) would hang
|
||||
// capturer teardown — and teardown runs on the session path. On timeout we detach: the
|
||||
// thread only ever touches its own X connections and an `Arc`'d slot, so leaving it to
|
||||
// finish on its own is safe (it observes `stop` and exits the moment its reply lands).
|
||||
let joinable = self.done.recv_timeout(Duration::from_millis(250)).is_ok();
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
if joinable {
|
||||
let _ = j.join(); // returns at once: `done` already fired
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: worker did not stop within 250ms (blocked on an X reply?) — \
|
||||
detaching it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run the targets provider and reconcile `displays` with it: connect to any target we do not
|
||||
/// track yet, and re-connect one whose display died. Existing healthy displays are left alone, so
|
||||
/// their shape cache and last position survive.
|
||||
fn rediscover(displays: &mut Vec<XDisplay>, targets: &GamescopeCursorTargets, first: bool) {
|
||||
for (dpy, xauth) in targets() {
|
||||
let existing = displays.iter().position(|d| d.name == dpy);
|
||||
if existing.is_some_and(|i| !displays[i].dead) {
|
||||
continue;
|
||||
}
|
||||
match connect(&dpy, xauth.as_deref()) {
|
||||
Ok((conn, root, root_size, feedback)) => {
|
||||
let d = XDisplay::new(dpy.clone(), conn, root, root_size, feedback);
|
||||
match existing {
|
||||
Some(i) => {
|
||||
tracing::info!(dpy = %dpy, "gamescope cursor: reconnected a nested Xwayland");
|
||||
displays[i] = d;
|
||||
}
|
||||
None => {
|
||||
if !first {
|
||||
tracing::info!(dpy = %dpy, "gamescope cursor: adopted a new nested Xwayland");
|
||||
}
|
||||
displays.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Debug, not warn: with a 2 s retry a warn would flood for every Xwayland a
|
||||
// `--xwayland-count` reports but that never comes up.
|
||||
Err(e) if first => tracing::warn!(
|
||||
dpy = %dpy, error = %e,
|
||||
"gamescope cursor: skipping a nested Xwayland we can't use (will retry)"
|
||||
),
|
||||
Err(e) => tracing::debug!(dpy = %dpy, error = %e, "gamescope cursor: retry failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
|
||||
/// returning the connection, root window and this display's initial
|
||||
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None`
|
||||
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the
|
||||
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect,
|
||||
/// then restore.
|
||||
type Connected = (RustConnection, Window, (Atom, Option<bool>));
|
||||
/// returning the connection, root window, the root's pixel size (for [`scale_to_frame`]) and this
|
||||
/// display's initial [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`;
|
||||
/// the value is `None` when gamescope publishes no such property here).
|
||||
type Connected = (RustConnection, Window, (u16, u16), (Atom, Option<bool>));
|
||||
|
||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||
let (conn, screen_num) = {
|
||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var_os("XAUTHORITY");
|
||||
if let Some(x) = xauthority {
|
||||
std::env::set_var("XAUTHORITY", x);
|
||||
}
|
||||
let out = RustConnection::connect(Some(dpy));
|
||||
match (&prev, xauthority) {
|
||||
(Some(p), _) => std::env::set_var("XAUTHORITY", p),
|
||||
(None, Some(_)) => std::env::remove_var("XAUTHORITY"),
|
||||
(None, None) => {}
|
||||
}
|
||||
out.map_err(|e| format!("connect: {e}"))?
|
||||
};
|
||||
let (conn, screen_num) = connect_conn(dpy, xauthority)?;
|
||||
|
||||
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
|
||||
conn.xfixes_query_version(5, 0)
|
||||
@@ -173,12 +226,16 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||
.and_then(|c| c.reply())
|
||||
.map_err(|e| format!("XFixes unavailable: {e}"))?;
|
||||
|
||||
let root = conn
|
||||
let screen = conn
|
||||
.setup()
|
||||
.roots
|
||||
.get(screen_num)
|
||||
.ok_or_else(|| format!("no X screen {screen_num}"))?
|
||||
.root;
|
||||
.ok_or_else(|| format!("no X screen {screen_num}"))?;
|
||||
let root = screen.root;
|
||||
// gamescope's nested root can be a DIFFERENT size from the output/PipeWire node it publishes
|
||||
// (`-w/-h` vs `-W/-H` are independent knobs), and this is the space `QueryPointer` answers in.
|
||||
// Free here — the setup reply is already parsed.
|
||||
let root_size = (screen.width_in_pixels, screen.height_in_pixels);
|
||||
|
||||
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
|
||||
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
|
||||
@@ -203,7 +260,143 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||
}
|
||||
let _ = conn.flush();
|
||||
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
|
||||
Ok((conn, root, (feedback_atom, feedback)))
|
||||
Ok((conn, root, root_size, (feedback_atom, feedback)))
|
||||
}
|
||||
|
||||
/// Establish the X connection for `dpy` using `xauthority`'s cookie, WITHOUT touching this process's
|
||||
/// environment.
|
||||
///
|
||||
/// `RustConnection::connect` reads `XAUTHORITY` from the env, so the original implementation
|
||||
/// `set_var`'d it around each connect under [`XAUTH_LOCK`]. That is unsound from a live
|
||||
/// multithreaded host: the lock serialises this source against itself, but `getenv` takes no lock,
|
||||
/// so any concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment,
|
||||
/// since the PipeWire thread is starting up) could read the swapped value or race the environ
|
||||
/// rewrite outright. The project already has a process-wide env-lock discipline elsewhere, but
|
||||
/// sharing it would be the wrong layer AND would still not fix `getenv`.
|
||||
///
|
||||
/// So: parse the MIT-MAGIC-COOKIE-1 entry out of the file ourselves and hand it to
|
||||
/// `connect_to_stream_with_auth_info`, which is what `RustConnection::connect` does internally with
|
||||
/// the cookie IT found. The env swap survives only as a fallback for a file we cannot parse (an
|
||||
/// unexpected layout, or an auth family whose entry we decline to guess at).
|
||||
fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, usize), String> {
|
||||
let Some(path) = xauthority else {
|
||||
// No per-display cookie file to inject: the ambient environment is already what this
|
||||
// connect should use, so there is nothing to swap and nothing to parse.
|
||||
return RustConnection::connect(Some(dpy)).map_err(|e| format!("connect: {e}"));
|
||||
};
|
||||
match mit_magic_cookie(path, dpy) {
|
||||
Some((name, data)) => match connect_with_cookie(dpy, name, data) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) => tracing::debug!(
|
||||
dpy = %dpy, xauthority = %path, error = %e,
|
||||
"gamescope cursor: cookie connect failed — falling back to the XAUTHORITY env swap"
|
||||
),
|
||||
},
|
||||
None => tracing::debug!(
|
||||
dpy = %dpy, xauthority = %path,
|
||||
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — falling back to the \
|
||||
XAUTHORITY env swap"
|
||||
),
|
||||
}
|
||||
connect_via_env_swap(dpy, path)
|
||||
}
|
||||
|
||||
/// Connect to `dpy` and complete the setup handshake with an explicit cookie — the same two steps
|
||||
/// `RustConnection::connect` performs, minus its env-derived auth lookup.
|
||||
fn connect_with_cookie(
|
||||
dpy: &str,
|
||||
auth_name: Vec<u8>,
|
||||
auth_data: Vec<u8>,
|
||||
) -> Result<(RustConnection, usize), String> {
|
||||
let parsed = x11rb::reexports::x11rb_protocol::parse_display::parse_display(Some(dpy))
|
||||
.map_err(|e| format!("parse display {dpy}: {e}"))?;
|
||||
let screen = usize::from(parsed.screen);
|
||||
let mut stream = None;
|
||||
let mut last_err = None;
|
||||
for addr in parsed.connect_instruction() {
|
||||
match DefaultStream::connect(&addr) {
|
||||
Ok((s, _peer)) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
let stream = stream.ok_or_else(|| match last_err {
|
||||
Some(e) => format!("connect: {e}"),
|
||||
None => "connect: no usable address".to_string(),
|
||||
})?;
|
||||
RustConnection::connect_to_stream_with_auth_info(stream, screen, auth_name, auth_data)
|
||||
.map(|c| (c, screen))
|
||||
.map_err(|e| format!("setup: {e}"))
|
||||
}
|
||||
|
||||
/// LEGACY fallback (see [`connect_conn`]): swap `XAUTHORITY`, connect, restore. Serialised against
|
||||
/// this source's own concurrent connects, but NOT against other threads' `getenv` — which is why it
|
||||
/// is a fallback and not the path taken.
|
||||
fn connect_via_env_swap(dpy: &str, xauthority: &str) -> Result<(RustConnection, usize), String> {
|
||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var_os("XAUTHORITY");
|
||||
std::env::set_var("XAUTHORITY", xauthority);
|
||||
let out = RustConnection::connect(Some(dpy));
|
||||
match prev {
|
||||
Some(p) => std::env::set_var("XAUTHORITY", p),
|
||||
None => std::env::remove_var("XAUTHORITY"),
|
||||
}
|
||||
out.map_err(|e| format!("connect: {e}"))
|
||||
}
|
||||
|
||||
/// The `MIT-MAGIC-COOKIE-1` `(name, data)` for `dpy` from the `.Xauthority`-format file at `path`.
|
||||
///
|
||||
/// Entry layout, all integers big-endian: `u16 family`, then four length-prefixed byte strings —
|
||||
/// `address`, `number` (the display number in ASCII), `name`, `data`. An entry with an empty
|
||||
/// `number` matches any display.
|
||||
///
|
||||
/// Deliberately does NOT match on family/address, unlike libxcb's own lookup. A gamescope Xwayland
|
||||
/// gets its own single-entry cookie file, so there is nothing to disambiguate; and a wrong pick
|
||||
/// cannot do damage — the server rejects the cookie, and [`connect_conn`] falls back to the env
|
||||
/// path, which does the full match. Guessing the peer address for a `LOCAL`-family unix socket, on
|
||||
/// the other hand, is exactly the sort of detail that would rot.
|
||||
fn mit_magic_cookie(path: &str, dpy: &str) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let want = display_number(dpy);
|
||||
// An entry whose `number` is empty matches any display — only used if no exact match is found.
|
||||
let mut wildcard = None;
|
||||
let mut p = 0usize;
|
||||
'entries: while p + 2 <= bytes.len() {
|
||||
p += 2; // family
|
||||
let mut fields: [&[u8]; 4] = [&[]; 4];
|
||||
for f in fields.iter_mut() {
|
||||
let Some(lb) = bytes.get(p..p + 2) else {
|
||||
break 'entries; // truncated — keep whatever we already found
|
||||
};
|
||||
let len = usize::from(u16::from_be_bytes([lb[0], lb[1]]));
|
||||
p += 2;
|
||||
let Some(v) = bytes.get(p..p + len) else {
|
||||
break 'entries;
|
||||
};
|
||||
*f = v;
|
||||
p += len;
|
||||
}
|
||||
let [_address, number, name, data] = fields;
|
||||
if name != MIT_MAGIC_COOKIE_1 {
|
||||
continue;
|
||||
}
|
||||
if want.as_deref().is_some_and(|w| number == w) {
|
||||
return Some((name.to_vec(), data.to_vec()));
|
||||
}
|
||||
if number.is_empty() && wildcard.is_none() {
|
||||
wildcard = Some((name.to_vec(), data.to_vec()));
|
||||
}
|
||||
}
|
||||
wildcard
|
||||
}
|
||||
|
||||
/// The display NUMBER of an X display string as ASCII bytes: `":2"`, `"host:2"` and `":2.0"` all
|
||||
/// yield `b"2"`. `None` when there is no numeric display component to match on.
|
||||
fn display_number(dpy: &str) -> Option<Vec<u8>> {
|
||||
let num = dpy.rsplit_once(':')?.1.split('.').next()?;
|
||||
(!num.is_empty() && num.bytes().all(|b| b.is_ascii_digit())).then(|| num.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
|
||||
@@ -227,6 +420,9 @@ struct XDisplay {
|
||||
name: String,
|
||||
conn: RustConnection,
|
||||
root: Window,
|
||||
/// The nested root's size in pixels — the space `QueryPointer` reports in, which is NOT
|
||||
/// necessarily the captured frame's (see [`scale_to_frame`]).
|
||||
root_size: (u16, u16),
|
||||
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
|
||||
last_pos: Option<(i32, i32)>,
|
||||
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
|
||||
@@ -247,12 +443,14 @@ impl XDisplay {
|
||||
name: String,
|
||||
conn: RustConnection,
|
||||
root: Window,
|
||||
root_size: (u16, u16),
|
||||
(feedback_atom, gs_visible): (Atom, Option<bool>),
|
||||
) -> Self {
|
||||
XDisplay {
|
||||
name,
|
||||
conn,
|
||||
root,
|
||||
root_size,
|
||||
last_pos: None,
|
||||
shape: Shape::default(),
|
||||
need_shape: true,
|
||||
@@ -288,11 +486,39 @@ struct Shape {
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
/// Map a root-space pointer position into FRAME space.
|
||||
///
|
||||
/// `QueryPointer` answers in the nested root's coordinates, but `CursorOverlay::x/y`'s contract is
|
||||
/// FRAME pixels — and gamescope's `-w/-h` (nested root) and `-W/-H` (output + PipeWire node) are
|
||||
/// independent knobs. Measured on this box with gamescope 3.16.23 at `-W 1280 -H 720 -w 640 -h 360`
|
||||
/// the two spaces differ by 2×, so publishing root coordinates verbatim drew the pointer at a
|
||||
/// fraction of its real position. `frame` is `(0, 0)` until the PipeWire format negotiates, in which
|
||||
/// case the position passes through unscaled — the same as before, and correct for the common case
|
||||
/// where the two spaces agree.
|
||||
///
|
||||
/// The cursor BITMAP is not scaled: it stays at the shape's native size, so on a mismatched session
|
||||
/// the pointer lands in the right place but is drawn at root scale.
|
||||
fn scale_to_frame((x, y): (i32, i32), root: (u16, u16), frame: (u32, u32)) -> (i32, i32) {
|
||||
let (rw, rh) = (u32::from(root.0), u32::from(root.1));
|
||||
let (fw, fh) = frame;
|
||||
if rw == 0 || rh == 0 || fw == 0 || fh == 0 || (rw, rh) == (fw, fh) {
|
||||
return (x, y);
|
||||
}
|
||||
(
|
||||
((i64::from(x) * i64::from(fw)) / i64::from(rw)) as i32,
|
||||
((i64::from(y) * i64::from(fh)) / i64::from(rh)) as i32,
|
||||
)
|
||||
}
|
||||
|
||||
fn run(
|
||||
mut displays: Vec<XDisplay>,
|
||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
targets: GamescopeCursorTargets,
|
||||
frame_size: Arc<AtomicU64>,
|
||||
) {
|
||||
let mut last_discover = std::time::Instant::now();
|
||||
let mut warned_scale = false;
|
||||
let mut active = 0usize;
|
||||
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
|
||||
// shape OR which display is active (per-display XFixes serials aren't comparable across
|
||||
@@ -309,6 +535,11 @@ fn run(
|
||||
if resync {
|
||||
last_resync = std::time::Instant::now();
|
||||
}
|
||||
// Adopt Xwaylands that appeared since we started (a game's, above all) and retry dead ones.
|
||||
if last_discover.elapsed() >= REDISCOVER {
|
||||
last_discover = std::time::Instant::now();
|
||||
rediscover(&mut displays, &targets, false);
|
||||
}
|
||||
|
||||
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
|
||||
// signal, used only when this gamescope publishes no cursor verdict).
|
||||
@@ -395,8 +626,27 @@ fn run(
|
||||
// a `None` here would leave the last visible overlay standing on repeat frames.
|
||||
let d = &displays[active];
|
||||
let drawn = d.shape.visible && !hidden_by_gamescope;
|
||||
// Root space → frame space (see `scale_to_frame`). The negotiated size arrives from the
|
||||
// PipeWire thread's `param_changed`, packed `(w << 32) | h`; `0` = not negotiated yet.
|
||||
let packed = frame_size.load(Ordering::Relaxed);
|
||||
let frame = ((packed >> 32) as u32, packed as u32);
|
||||
if !warned_scale
|
||||
&& frame.0 != 0
|
||||
&& (u32::from(d.root_size.0), u32::from(d.root_size.1)) != frame
|
||||
{
|
||||
warned_scale = true;
|
||||
tracing::warn!(
|
||||
dpy = %d.name,
|
||||
root = %format!("{}x{}", d.root_size.0, d.root_size.1),
|
||||
negotiated = %format!("{}x{}", frame.0, frame.1),
|
||||
"gamescope cursor: the nested root and the captured frame are different sizes \
|
||||
(gamescope -w/-h vs -W/-H) — scaling the pointer POSITION into frame space; the \
|
||||
cursor bitmap stays at root scale"
|
||||
);
|
||||
}
|
||||
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
||||
(Some((px, py)), false) => {
|
||||
(Some(pos), false) => {
|
||||
let (px, py) = scale_to_frame(pos, d.root_size, frame);
|
||||
let key = (active, d.shape.serial);
|
||||
if key != last_key {
|
||||
out_serial += 1;
|
||||
@@ -511,7 +761,147 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pick_active;
|
||||
use super::{
|
||||
display_number, mit_magic_cookie, pick_active, scale_to_frame, MIT_MAGIC_COOKIE_1,
|
||||
};
|
||||
|
||||
/// One `.Xauthority` entry in wire form: `u16 family` + four length-prefixed byte strings.
|
||||
fn entry(family: u16, address: &[u8], number: &[u8], name: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = family.to_be_bytes().to_vec();
|
||||
for f in [address, number, name, data] {
|
||||
v.extend_from_slice(&(f.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(f);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn write_xauth(bytes: &[u8]) -> std::path::PathBuf {
|
||||
// The scratch file lives beside the test binary's temp dir; unique per call via the address
|
||||
// of a local (no rand dependency, and the tests do not run concurrently on one path).
|
||||
let mut p = std::env::temp_dir();
|
||||
let uniq = format!(
|
||||
"pf-xauth-test-{}-{:p}",
|
||||
std::process::id(),
|
||||
bytes as *const [u8]
|
||||
);
|
||||
p.push(uniq);
|
||||
std::fs::write(&p, bytes).expect("write scratch xauth");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_number_handles_every_display_spelling() {
|
||||
assert_eq!(display_number(":2").as_deref(), Some(&b"2"[..]));
|
||||
assert_eq!(display_number(":2.0").as_deref(), Some(&b"2"[..]));
|
||||
assert_eq!(display_number("host:13.1").as_deref(), Some(&b"13"[..]));
|
||||
// Nothing to match on — the caller then accepts only a wildcard entry.
|
||||
assert_eq!(display_number("bogus"), None);
|
||||
assert_eq!(display_number(":"), None);
|
||||
assert_eq!(display_number(":abc"), None);
|
||||
}
|
||||
|
||||
/// The cookie reader is the one PARSER in this file fed a file we did not write, so it gets the
|
||||
/// hostile-input treatment: exact-match, wildcard fallback, skipping other auth protocols, and
|
||||
/// truncation.
|
||||
#[test]
|
||||
fn mit_magic_cookie_picks_the_matching_entry() {
|
||||
let mut file = Vec::new();
|
||||
// A different protocol on the display we want — must be skipped, not returned.
|
||||
file.extend(entry(256, b"host", b"2", b"XDM-AUTHORIZATION-1", b"nope"));
|
||||
// Another display's cookie.
|
||||
file.extend(entry(
|
||||
256,
|
||||
b"host",
|
||||
b"7",
|
||||
MIT_MAGIC_COOKIE_1,
|
||||
b"other-display",
|
||||
));
|
||||
// Ours.
|
||||
file.extend(entry(256, b"host", b"2", MIT_MAGIC_COOKIE_1, b"the-cookie"));
|
||||
let p = write_xauth(&file);
|
||||
let got = mit_magic_cookie(p.to_str().unwrap(), ":2");
|
||||
assert_eq!(
|
||||
got,
|
||||
Some((MIT_MAGIC_COOKIE_1.to_vec(), b"the-cookie".to_vec()))
|
||||
);
|
||||
// A display with no entry at all yields nothing (⇒ the caller falls back to the env path).
|
||||
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":9"), None);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mit_magic_cookie_falls_back_to_a_wildcard_entry() {
|
||||
// An empty `number` matches any display — but an exact match still wins.
|
||||
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
|
||||
file.extend(entry(256, b"host", b"3", MIT_MAGIC_COOKIE_1, b"exact"));
|
||||
let p = write_xauth(&file);
|
||||
let path = p.to_str().unwrap();
|
||||
assert_eq!(
|
||||
mit_magic_cookie(path, ":3").map(|(_, d)| d),
|
||||
Some(b"exact".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
mit_magic_cookie(path, ":4").map(|(_, d)| d),
|
||||
Some(b"wildcard".to_vec())
|
||||
);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_xauthority_keeps_what_it_already_parsed() {
|
||||
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
|
||||
// A second entry cut off mid-length-prefix.
|
||||
file.extend(entry(256, b"host", b"5", MIT_MAGIC_COOKIE_1, b"truncated"));
|
||||
file.truncate(file.len() - 4);
|
||||
let p = write_xauth(&file);
|
||||
// No panic, no over-read: the wildcard found before the truncation still serves.
|
||||
assert_eq!(
|
||||
mit_magic_cookie(p.to_str().unwrap(), ":5").map(|(_, d)| d),
|
||||
Some(b"wildcard".to_vec())
|
||||
);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_garbage_xauthority_is_declined_not_fatal() {
|
||||
// A length prefix that claims far more than the file holds.
|
||||
let p = write_xauth(&[0, 0, 0xff, 0xff, 1, 2, 3]);
|
||||
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":0"), None);
|
||||
let _ = std::fs::remove_file(p);
|
||||
// A missing file is simply "no cookie".
|
||||
assert_eq!(mit_magic_cookie("/nonexistent/pf-xauth", ":0"), None);
|
||||
}
|
||||
|
||||
/// L6: gamescope's `-w/-h` (nested root, the space `QueryPointer` answers in) and `-W/-H`
|
||||
/// (output + PipeWire node, the space `CursorOverlay` is contracted in) are independent.
|
||||
#[test]
|
||||
fn pointer_positions_are_mapped_into_frame_space() {
|
||||
// The measured case: root 640x360 inside a 1280x720 output — 2x.
|
||||
assert_eq!(
|
||||
scale_to_frame((320, 180), (640, 360), (1280, 720)),
|
||||
(640, 360)
|
||||
);
|
||||
assert_eq!(scale_to_frame((0, 0), (640, 360), (1280, 720)), (0, 0));
|
||||
// Down-scaling works the same way.
|
||||
assert_eq!(
|
||||
scale_to_frame((1280, 720), (1280, 720), (640, 360)),
|
||||
(640, 360)
|
||||
);
|
||||
// Equal spaces are a pass-through (the common case — no rounding drift).
|
||||
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (1920, 1080)), (7, 9));
|
||||
// Not negotiated yet, or a degenerate root: pass through rather than divide by zero.
|
||||
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (0, 0)), (7, 9));
|
||||
assert_eq!(scale_to_frame((7, 9), (0, 0), (1920, 1080)), (7, 9));
|
||||
}
|
||||
|
||||
/// A 5K frame times a 5K coordinate overflows `i32` — the scale must be computed wide.
|
||||
#[test]
|
||||
fn scaling_does_not_overflow_at_5k() {
|
||||
assert_eq!(
|
||||
scale_to_frame((2879, 1619), (2880, 1620), (5120, 2880)),
|
||||
(5118, 2878)
|
||||
);
|
||||
}
|
||||
|
||||
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
|
||||
const BPM: usize = 0;
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
|
||||
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
|
||||
|
||||
// The P010 colour self-test (sweep Phase 5.5) — the `hdr-p010-selftest` subcommand, its f64
|
||||
// reference math and the f16 uploader. None of it runs in a session; re-exported at the old paths
|
||||
// (`main.rs` drives the subcommand, `pf-encode`'s qsv live e2e uses the bars helper).
|
||||
// Explicit `#[path]`, like `idd_push`'s children: this file is itself reached through a `#[path]`
|
||||
// from `lib.rs`, so a bare `mod selftest;` would resolve to `windows/selftest.rs`.
|
||||
#[path = "dxgi/selftest.rs"]
|
||||
mod selftest;
|
||||
pub use selftest::{hdr_p010_convert_bars_on_luid, hdr_p010_selftest_at};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -28,24 +37,31 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11RenderTargetView, ID3D11SamplerState, ID3D11ShaderResourceView, ID3D11Texture2D,
|
||||
ID3D11VertexShader, D3D11_BIND_CONSTANT_BUFFER, D3D11_BIND_RENDER_TARGET,
|
||||
D3D11_BIND_SHADER_RESOURCE, D3D11_BUFFER_DESC, D3D11_COMPARISON_NEVER, D3D11_CPU_ACCESS_READ,
|
||||
D3D11_CPU_ACCESS_WRITE, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_FILTER_MIN_MAG_MIP_POINT,
|
||||
D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_MAP_WRITE_DISCARD,
|
||||
D3D11_RENDER_TARGET_VIEW_DESC, D3D11_RENDER_TARGET_VIEW_DESC_0, D3D11_RTV_DIMENSION_TEXTURE2D,
|
||||
D3D11_SAMPLER_DESC, D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA, D3D11_TEX2D_RTV,
|
||||
D3D11_TEXTURE2D_DESC, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DEFAULT, D3D11_USAGE_DYNAMIC,
|
||||
D3D11_USAGE_STAGING, D3D11_VIEWPORT,
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_FILTER_MIN_MAG_MIP_POINT, D3D11_MAPPED_SUBRESOURCE,
|
||||
D3D11_MAP_READ, D3D11_RENDER_TARGET_VIEW_DESC, D3D11_RENDER_TARGET_VIEW_DESC_0,
|
||||
D3D11_RTV_DIMENSION_TEXTURE2D, D3D11_SAMPLER_DESC, D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA,
|
||||
D3D11_TEX2D_RTV, D3D11_TEXTURE2D_DESC, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DEFAULT,
|
||||
D3D11_USAGE_IMMUTABLE, D3D11_USAGE_STAGING, D3D11_VIEWPORT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
|
||||
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
|
||||
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`. If this
|
||||
/// stays 0 while DDA churns with ACCESS_LOST, the hook is NOT on DXGI's GPU-preference path on this
|
||||
/// build (so reparenting can't be the cause — look at composition/independent-flip instead). >0 with
|
||||
/// continuing churn means the hook fires but reparenting isn't the trigger here.
|
||||
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`.
|
||||
/// Reported by [`hybrid_hook_hits`] on every IDD-push open, which is the first point at which DXGI
|
||||
/// has been exercised (factory → `EnumAdapterByLuid` → device). The patch-readback check in
|
||||
/// [`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);
|
||||
|
||||
/// 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
|
||||
// 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
|
||||
@@ -65,18 +81,29 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
|
||||
if gpu_preference.is_null() {
|
||||
return 0xC000_000Du32 as i32; // STATUS_INVALID_PARAMETER
|
||||
}
|
||||
*gpu_preference = 3; // D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED
|
||||
// SAFETY: win32u's contract for this export — the caller (DXGI) passes a writable `*mut u32`
|
||||
// out-param — and the null case has just been rejected above, so this is an in-bounds,
|
||||
// 4-aligned single-word store into the caller's live local.
|
||||
unsafe { *gpu_preference = 3 }; // D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED
|
||||
0 // STATUS_SUCCESS
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// (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
|
||||
/// freeze/churn observed on the RTX 4090 + AMD iGPU box; `SET_RENDER_ADAPTER` is ignored there). Faking
|
||||
/// a cached preference of UNSPECIFIED makes DXGI skip the resolution, so the output is NOT reparented
|
||||
/// 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).
|
||||
/// GPU, ignoring `SET_RENDER_ADAPTER` (observed on the RTX 4090 + AMD iGPU box). Faking a cached
|
||||
/// preference of UNSPECIFIED makes DXGI skip that resolution, so an output is NOT reparented and
|
||||
/// stays on one adapter.
|
||||
///
|
||||
/// **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() {
|
||||
use std::sync::Once;
|
||||
static HOOK: Once = Once::new();
|
||||
@@ -100,25 +127,38 @@ pub fn install_gpu_pref_hook() {
|
||||
GetAwarenessFromDpiAwarenessContext, GetThreadDpiAwarenessContext,
|
||||
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||
};
|
||||
// Per-monitor-v2 DPI awareness — REQUIRED for IDXGIOutput5::DuplicateOutput1 (without it the
|
||||
// call returns E_ACCESSDENIED forever, forcing the legacy DuplicateOutput path). Matches
|
||||
// Apollo's startup. SetProcessDpiAwarenessContext fails with E_ACCESS_DENIED if awareness was
|
||||
// already set (manifest / earlier call) — log the outcome AND the effective awareness so a
|
||||
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent.
|
||||
// Per-monitor-v2 DPI awareness. It was originally set here because
|
||||
// `IDXGIOutput5::DuplicateOutput1` returns E_ACCESSDENIED without it; DDA is gone, but the
|
||||
// awareness still matters — an UNAWARE/SYSTEM-aware process gets DPI-VIRTUALIZED window and
|
||||
// cursor coordinates, while every geometry the host computes comes from CCD in PHYSICAL
|
||||
// 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) {
|
||||
Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"),
|
||||
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;
|
||||
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 {
|
||||
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;
|
||||
};
|
||||
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;
|
||||
};
|
||||
let target = target as usize as *mut u8;
|
||||
@@ -132,7 +172,14 @@ pub fn install_gpu_pref_hook() {
|
||||
patch[10] = 0xFF;
|
||||
patch[11] = 0xE0; // jmp rax
|
||||
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");
|
||||
return;
|
||||
}
|
||||
@@ -150,18 +197,33 @@ pub fn install_gpu_pref_hook() {
|
||||
std::ptr::copy_nonoverlapping(target, readback.as_mut_ptr(), 12);
|
||||
if readback == patch {
|
||||
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 {
|
||||
tracing::error!(
|
||||
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)"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Compile one HLSL entry point. Returns the bytecode blob's bytes.
|
||||
///
|
||||
/// # Safety
|
||||
/// `entry` and `target` must be valid NUL-terminated ASCII pointers (an `s!()` literal at every
|
||||
/// call site); `src` is a plain Rust `&str`, read only for the duration of the call.
|
||||
pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> Result<Vec<u8>> {
|
||||
// SAFETY: `D3DCompile` reads `src.as_ptr()` for exactly `src.len()` bytes (a live `&str` that
|
||||
// outlives the synchronous call) plus the two caller-supplied NUL-terminated `PCSTR`s (per the
|
||||
// contract above); `&mut blob` / `Some(&mut errs)` are live out-params. Both
|
||||
// `slice::from_raw_parts` calls pair a blob's OWN `GetBufferPointer` with its OWN
|
||||
// `GetBufferSize` while that blob is still alive, and the slice is copied
|
||||
// (`to_string` / `to_vec`) before it goes out of scope.
|
||||
unsafe {
|
||||
let mut blob: Option<ID3DBlob> = None;
|
||||
let mut errs: Option<ID3DBlob> = None;
|
||||
let r = D3DCompile(
|
||||
@@ -192,6 +254,7 @@ pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> R
|
||||
let p = blob.GetBufferPointer() as *const u8;
|
||||
Ok(std::slice::from_raw_parts(p, blob.GetBufferSize()).to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fullscreen-triangle vertex shader for the HDR conversion pass (3 verts, no input layout).
|
||||
pub(crate) const HDR_VS: &str = r"
|
||||
@@ -316,12 +379,24 @@ pub(crate) struct HdrP010Converter {
|
||||
ps_y: ID3D11PixelShader,
|
||||
ps_uv: ID3D11PixelShader,
|
||||
sampler: ID3D11SamplerState,
|
||||
/// Constant buffer for the chroma pass (inv_src texel size). 16 bytes.
|
||||
/// Constant buffer for the chroma pass: `inv_src` = (1/srcW, 1/srcH), 16 bytes. IMMUTABLE and
|
||||
/// filled at construction — it depends only on the source size, and the converter is already
|
||||
/// rebuilt whenever the mode changes. It used to be a DYNAMIC buffer that `convert` Mapped,
|
||||
/// wrote and Unmapped on EVERY HDR frame, inside the ring slot's keyed-mutex hold, with the
|
||||
/// `Map`'s failure silently ignored (leaving the pass reading a stale/garbage texel size).
|
||||
cbuf: ID3D11Buffer,
|
||||
}
|
||||
|
||||
impl HdrP010Converter {
|
||||
pub(crate) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
/// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the
|
||||
/// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's
|
||||
/// `recreate_ring` already drops it).
|
||||
pub(crate) unsafe fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
|
||||
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader` receives
|
||||
// `s!()` literals (its contract). Each created COM interface owns its own reference, and no
|
||||
// raw pointer outlives the call that produced it.
|
||||
unsafe {
|
||||
// Inline the shared HLSL (D3DCompile has no include handler wired here). The two PS sources
|
||||
// carry a `#include_common` marker we substitute before compiling.
|
||||
let y_src = HDR_P010_Y_PS.replace("#include_common", HDR_P010_COMMON);
|
||||
@@ -349,15 +424,23 @@ impl HdrP010Converter {
|
||||
};
|
||||
let mut sampler = None;
|
||||
device.CreateSamplerState(&sd, Some(&mut sampler))?;
|
||||
// `inv_src` never changes for a given source size — build the buffer IMMUTABLE with its
|
||||
// contents, so the chroma pass needs no per-frame Map (and has no unchecked failure).
|
||||
let inv_src: [f32; 4] = [1.0 / w.max(1) as f32, 1.0 / h.max(1) as f32, 0.0, 0.0];
|
||||
let cbd = D3D11_BUFFER_DESC {
|
||||
ByteWidth: 16, // float2 inv_src + float2 pad
|
||||
Usage: D3D11_USAGE_DYNAMIC,
|
||||
Usage: D3D11_USAGE_IMMUTABLE,
|
||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: inv_src.as_ptr().cast(),
|
||||
SysMemPitch: 0,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut cbuf = None;
|
||||
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
|
||||
device.CreateBuffer(&cbd, Some(&init), Some(&mut cbuf))?;
|
||||
Ok(Self {
|
||||
vs: vs.context("p010 vs")?,
|
||||
ps_y: ps_y.context("p010 y ps")?,
|
||||
@@ -366,15 +449,24 @@ impl HdrP010Converter {
|
||||
cbuf: cbuf.context("p010 cbuf")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a per-plane RTV of the P010 texture `dst` with the given single-plane `format`
|
||||
/// (`R16_UNORM` for plane 0 luma, `R16G16_UNORM` for plane 1 chroma). The plane is selected by the
|
||||
/// view format (planar-RTV semantics); MipSlice 0.
|
||||
unsafe fn plane_rtv(
|
||||
///
|
||||
/// Called ONCE PER OUT-RING SLOT by the owner of the P010 textures, not per frame — see
|
||||
/// [`Self::convert`]. Fails when the driver rejects a planar RTV, which is the one hard
|
||||
/// requirement of this whole path (a D3D11.3+ runtime plus driver support).
|
||||
pub(crate) unsafe fn plane_rtv(
|
||||
device: &ID3D11Device,
|
||||
dst: &ID3D11Texture2D,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<ID3D11RenderTargetView> {
|
||||
// SAFETY: one `?`-checked `CreateRenderTargetView` on the live `device` borrow, with a
|
||||
// fully-initialized `D3D11_RENDER_TARGET_VIEW_DESC` local whose address is taken only for the
|
||||
// duration of the synchronous call, plus a live `Option` out-param.
|
||||
unsafe {
|
||||
let desc = D3D11_RENDER_TARGET_VIEW_DESC {
|
||||
Format: format,
|
||||
ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D,
|
||||
@@ -394,34 +486,31 @@ impl HdrP010Converter {
|
||||
})?;
|
||||
rtv.context("p010 plane rtv null")
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `src_srv` (FP16 scRGB, WxH) into `dst` (a `DXGI_FORMAT_P010` texture with
|
||||
/// `BIND_RENDER_TARGET`). Two opaque passes: full-res luma → plane 0, half-res chroma → plane 1.
|
||||
/// `w`/`h` are the full luma dimensions (must be even). Returns `Err` if a plane RTV can't be
|
||||
/// created (driver) so the caller can fall back to the R10 path.
|
||||
/// Convert `src_srv` (FP16 scRGB, WxH) into a `DXGI_FORMAT_P010` texture through the two plane
|
||||
/// RTVs the CALLER built for it ([`Self::plane_rtv`]): full-res luma → `y_rtv` (plane 0),
|
||||
/// half-res chroma → `uv_rtv` (plane 1). `w`/`h` are the full luma dimensions (must be even) and
|
||||
/// must match the `w`/`h` this converter was constructed with.
|
||||
///
|
||||
/// Takes the views rather than creating them: two `CreateRenderTargetView`s per frame — plus a
|
||||
/// Map/Unmap of a never-changing 16-byte constant buffer — was pure per-frame cost inside the
|
||||
/// ring slot's keyed-mutex hold, i.e. time the DRIVER spent blocked on that slot. Both are
|
||||
/// lifetime-of-mode facts: the views belong to the out-ring slot (built in `ensure_out_ring`),
|
||||
/// the buffer to this converter, which is already rebuilt on every mode change.
|
||||
pub(crate) unsafe fn convert(
|
||||
&self,
|
||||
device: &ID3D11Device,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
src_srv: &ID3D11ShaderResourceView,
|
||||
dst: &ID3D11Texture2D,
|
||||
y_rtv: &ID3D11RenderTargetView,
|
||||
uv_rtv: &ID3D11RenderTargetView,
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<()> {
|
||||
let y_rtv = Self::plane_rtv(device, dst, DXGI_FORMAT_R16_UNORM)?;
|
||||
let uv_rtv = Self::plane_rtv(device, dst, DXGI_FORMAT_R16G16_UNORM)?;
|
||||
|
||||
// Update the chroma constant buffer (inverse source texel size).
|
||||
let cb: [f32; 4] = [1.0 / w as f32, 1.0 / h as f32, 0.0, 0.0];
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
if ctx
|
||||
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
||||
.is_ok()
|
||||
{
|
||||
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
||||
ctx.Unmap(&self.cbuf, 0);
|
||||
}
|
||||
|
||||
// SAFETY: all D3D11 work runs on the caller's live `ctx` borrow (the owning capture thread's
|
||||
// immediate context) over borrowed slices of fully-initialized locals (the viewports) and
|
||||
// clones of the caller's live SRV/RTVs. No raw pointers and no mapping on this path.
|
||||
unsafe {
|
||||
// Shared pipeline state.
|
||||
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
||||
ctx.VSSetShader(&self.vs, None);
|
||||
@@ -466,6 +555,7 @@ impl HdrP010Converter {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PyroWave LUMA pass PS — full-res, writes Y′ to a separate `R8_UNORM` texture. BT.709 limited from
|
||||
/// the 8-bit sRGB (gamma) BGRA slot, BYTE-IDENTICAL to the Linux `rgb2yuv.comp` `lumaY` (so the
|
||||
@@ -604,6 +694,9 @@ pub(crate) struct BgraToYuvPlanes {
|
||||
|
||||
impl BgraToYuvPlanes {
|
||||
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
|
||||
// SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live
|
||||
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
|
||||
unsafe {
|
||||
let (y_src, uv_src) = match (hdr, chroma444) {
|
||||
(false, false) => (PYRO_Y_PS.to_string(), PYRO_UV_PS.to_string()),
|
||||
(false, true) => (PYRO_Y_PS.to_string(), PYRO_UV444_PS.to_string()),
|
||||
@@ -632,6 +725,7 @@ impl BgraToYuvPlanes {
|
||||
chroma444,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `src_srv` (BGRA slot for SDR / scRGB FP16 slot for HDR, WxH) → `y_rtv` (full-res Y
|
||||
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
|
||||
@@ -646,6 +740,10 @@ impl BgraToYuvPlanes {
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<()> {
|
||||
// SAFETY: D3D11 state-setting plus two `Draw`s on the caller's live immediate-context
|
||||
// borrow, over borrowed slices of fully-initialized locals (the viewports) and clones of the
|
||||
// caller's live SRV/RTVs. No raw pointers and no mapping on this path.
|
||||
unsafe {
|
||||
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
||||
ctx.VSSetShader(&self.vs, None);
|
||||
ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())]));
|
||||
@@ -689,540 +787,6 @@ impl BgraToYuvPlanes {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
||||
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
||||
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
||||
/// Used by [`hdr_p010_selftest`].
|
||||
#[cfg(target_os = "windows")]
|
||||
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||
fn pq_oetf(l: f64) -> f64 {
|
||||
let l = l.clamp(0.0, 1.0);
|
||||
let m1 = 0.1593017578125;
|
||||
let m2 = 78.84375;
|
||||
let c1 = 0.8359375;
|
||||
let c2 = 18.8515625;
|
||||
let c3 = 18.6875;
|
||||
let lp = l.powf(m1);
|
||||
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
|
||||
}
|
||||
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
|
||||
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
|
||||
let m = [
|
||||
[0.627403914, 0.329283038, 0.043313048],
|
||||
[0.069097292, 0.919540405, 0.011362303],
|
||||
[0.016391439, 0.088013308, 0.895595253],
|
||||
];
|
||||
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
|
||||
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
|
||||
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
|
||||
// PQ encode (normalize to 10k nits).
|
||||
let pr = pq_oetf(lr / 10000.0);
|
||||
let pg = pq_oetf(lg / 10000.0);
|
||||
let pb = pq_oetf(lb / 10000.0);
|
||||
// BT.2020 non-constant-luminance, limited 10-bit.
|
||||
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
|
||||
let y = kr * pr + kg * pg + kb * pb;
|
||||
let cb = (pb - y) / 1.8814;
|
||||
let cr = (pr - y) / 1.4746;
|
||||
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
|
||||
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
|
||||
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
|
||||
(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/
|
||||
/// 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
|
||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
||||
/// (325,448,598) (226,650,535) (64,512,512).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[doc(hidden)]
|
||||
pub fn hdr_p010_convert_bars_on_luid(
|
||||
luid: [u8; 8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
||||
const BARS: [(f32, f32, f32); 8] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
let bar_w = (w / 8).max(1) as usize;
|
||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
||||
let i = (y * w as usize + x) * 4;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
}
|
||||
}
|
||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
||||
// their references.
|
||||
unsafe {
|
||||
let luid = windows::Win32::Foundation::LUID {
|
||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
||||
};
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
&adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: w * 8,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 bars)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 bars dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device)?;
|
||||
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
|
||||
Ok((device, p010))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
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::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||
#[allow(non_snake_case)]
|
||||
let (W, H) = (w, h);
|
||||
const BLK: u32 = 16;
|
||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||
let named: [(&str, f32, f32, f32); 8] = [
|
||||
("red1.0", 1.0, 0.0, 0.0),
|
||||
("green0.5", 0.0, 0.5, 0.0),
|
||||
("blue4.0", 0.0, 0.0, 4.0),
|
||||
("white1.0", 1.0, 1.0, 1.0),
|
||||
("black", 0.0, 0.0, 0.0),
|
||||
("gray0.5", 0.5, 0.5, 0.5),
|
||||
("white4.0", 4.0, 4.0, 4.0),
|
||||
("amber2.0", 2.0, 1.0, 0.0),
|
||||
];
|
||||
|
||||
let grid_cols = W / BLK; // 4
|
||||
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
|
||||
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
|
||||
if idx < named.len() {
|
||||
let (_, r, g, b) = named[idx];
|
||||
(r, g, b, true)
|
||||
} else {
|
||||
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
|
||||
let r = (x as f32 / W as f32) * 3.0;
|
||||
let g = (y as f32 / H as f32) * 3.0;
|
||||
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
|
||||
(r, g, b, false)
|
||||
}
|
||||
};
|
||||
|
||||
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
|
||||
let mut fp16 = vec![0u16; (W * H * 4) as usize];
|
||||
let mut flat = vec![false; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (r, g, b, is_flat) = pixel_rgb(x, y);
|
||||
let i = ((y * W + x) * 4) as usize;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
flat[(y * W + x) as usize] = is_flat;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||||
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||||
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||||
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||||
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||||
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||
// proven individually at the `read_u16` closure below.
|
||||
unsafe {
|
||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
||||
// the GPU it actually tested.
|
||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
||||
None => None,
|
||||
Some(want) => {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let mut found = None;
|
||||
for i in 0.. {
|
||||
let Ok(a) = factory.EnumAdapters(i) else {
|
||||
break;
|
||||
};
|
||||
let desc = a.GetDesc().context("adapter desc")?;
|
||||
if desc.VendorId == want {
|
||||
found = Some(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
||||
}
|
||||
};
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
adapter.as_ref(),
|
||||
if adapter.is_some() {
|
||||
D3D_DRIVER_TYPE_UNKNOWN
|
||||
} else {
|
||||
D3D_DRIVER_TYPE_HARDWARE
|
||||
},
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
{
|
||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
||||
device.cast().context("device -> IDXGIDevice")?;
|
||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
||||
let name = String::from_utf16_lossy(
|
||||
&desc.Description[..desc
|
||||
.Description
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(desc.Description.len())],
|
||||
);
|
||||
println!(
|
||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||||
);
|
||||
}
|
||||
|
||||
// Source FP16 texture (initialized) + SRV.
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: W * 8, // 4 channels * 2 bytes
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 src)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 src)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// P010 destination texture (render-target bindable).
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device)?;
|
||||
conv.convert(&device, &context, &src_srv, &p010, W, H)?;
|
||||
|
||||
// Staging copy of the whole P010 texture (both planes), MAP_READ.
|
||||
let stage_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut staging: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
|
||||
.context("CreateTexture2D(P010 staging)")?;
|
||||
let staging = staging.context("null staging")?;
|
||||
context.CopyResource(&staging, &p010);
|
||||
|
||||
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
context
|
||||
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
|
||||
.context("Map(P010 staging)")?;
|
||||
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
|
||||
let base = map.pData as *const u8;
|
||||
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
|
||||
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
|
||||
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
|
||||
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
|
||||
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
|
||||
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
|
||||
// these and adjust `chroma_base` (e.g. an aligned luma height).
|
||||
tracing::info!(
|
||||
row_pitch,
|
||||
depth_pitch = map.DepthPitch,
|
||||
expected_chroma_base = row_pitch * H as usize,
|
||||
expected_total = row_pitch * H as usize * 3 / 2,
|
||||
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
|
||||
);
|
||||
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
|
||||
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
|
||||
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
|
||||
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
|
||||
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
|
||||
let read_u16 = |byte_off: usize| -> u16 {
|
||||
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
|
||||
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
|
||||
let p = base.add(byte_off) as *const u16;
|
||||
p.read_unaligned()
|
||||
};
|
||||
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
|
||||
let mut y_codes = vec![0u16; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let off = (y as usize) * row_pitch + (x as usize) * 2;
|
||||
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
|
||||
}
|
||||
}
|
||||
let cw = W / 2;
|
||||
let ch = H / 2;
|
||||
let chroma_base = row_pitch * H as usize; // plane 1 offset
|
||||
let mut cb_codes = vec![0u16; (cw * ch) as usize];
|
||||
let mut cr_codes = vec![0u16; (cw * ch) as usize];
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
|
||||
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
|
||||
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
|
||||
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
|
||||
}
|
||||
}
|
||||
context.Unmap(&staging, 0);
|
||||
|
||||
// Compare Y over every pixel.
|
||||
let mut max_y_err = 0.0f64;
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (r, g, b, _) = pixel_rgb(x, y);
|
||||
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
|
||||
let got = y_codes[(y * W + x) as usize] as f64;
|
||||
max_y_err = max_y_err.max((got - ry).abs());
|
||||
}
|
||||
}
|
||||
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
|
||||
let mut max_u_err = 0.0f64;
|
||||
let mut max_v_err = 0.0f64;
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let (sx, sy) = (cx * 2, cy * 2);
|
||||
let all_flat =
|
||||
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
|
||||
if !all_flat {
|
||||
continue;
|
||||
}
|
||||
let (r, g, b, _) = pixel_rgb(sx, sy);
|
||||
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
|
||||
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
|
||||
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
|
||||
max_u_err = max_u_err.max((gu - rcb).abs());
|
||||
max_v_err = max_v_err.max((gv - rcr).abs());
|
||||
}
|
||||
}
|
||||
|
||||
// Per-colour table.
|
||||
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
|
||||
println!(
|
||||
" {:<10} {:>14} {:>14} {:>14}",
|
||||
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
|
||||
);
|
||||
for (idx, (name, r, g, b)) in named.iter().enumerate() {
|
||||
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
|
||||
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
|
||||
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
|
||||
let gy = y_codes[(by * W + bx) as usize] as f64;
|
||||
let (ccx, ccy) = (bx / 2, by / 2);
|
||||
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
|
||||
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
|
||||
println!(
|
||||
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
|
||||
name, ey, gy, ecb, gu, ecr, gv
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
|
||||
);
|
||||
|
||||
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
|
||||
println!("PASS");
|
||||
Ok(())
|
||||
} else {
|
||||
println!("FAIL");
|
||||
bail!(
|
||||
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
|
||||
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn f32_to_f16(v: f32) -> u16 {
|
||||
let bits = v.to_bits();
|
||||
let sign = ((bits >> 16) & 0x8000) as u16;
|
||||
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
|
||||
let mant = bits & 0x007f_ffff;
|
||||
if exp <= 0 {
|
||||
// Subnormal / zero in half precision.
|
||||
if exp < -10 {
|
||||
return sign; // too small → ±0
|
||||
}
|
||||
let mant = mant | 0x0080_0000; // implicit 1
|
||||
let shift = (14 - exp) as u32;
|
||||
let half_mant = (mant >> shift) as u16;
|
||||
// Round to nearest.
|
||||
let round = ((mant >> (shift - 1)) & 1) as u16;
|
||||
sign | (half_mant + round)
|
||||
} else if exp >= 0x1f {
|
||||
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
|
||||
} else {
|
||||
let half_exp = (exp as u16) << 10;
|
||||
let half_mant = (mant >> 13) as u16;
|
||||
let round = ((mant >> 12) & 1) as u16;
|
||||
sign | half_exp | (half_mant + round)
|
||||
}
|
||||
}
|
||||
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
@@ -1242,8 +806,15 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
||||
/// 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
|
||||
/// 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
|
||||
/// studio-range) — NVENC's native YUV inputs, so it encodes them with no further conversion.
|
||||
/// title pins at ~100%). Output is **always NV12, BT.709 studio-range** — one of NVENC's native YUV
|
||||
/// 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 {
|
||||
vdev: ID3D11VideoDevice,
|
||||
vctx: ID3D11VideoContext1,
|
||||
@@ -1264,8 +835,15 @@ impl VideoConverter {
|
||||
height: u32,
|
||||
scrgb_input: bool,
|
||||
) -> Result<Self> {
|
||||
// SAFETY: the `cast()`s and the `?`-checked video-device factory calls run on the caller's
|
||||
// live `device`/`context` borrows; `&desc` is a fully-initialized stack
|
||||
// `D3D11_VIDEO_PROCESSOR_CONTENT_DESC` read only for the duration of the call, and the
|
||||
// colour-space/frame-format setters take the just-created processor by borrow plus plain
|
||||
// enum values.
|
||||
unsafe {
|
||||
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
|
||||
let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?;
|
||||
let vctx: ID3D11VideoContext1 =
|
||||
context.cast().context("context -> ID3D11VideoContext1")?;
|
||||
let rate = DXGI_RATIONAL {
|
||||
Numerator: 240,
|
||||
Denominator: 1,
|
||||
@@ -1298,8 +876,14 @@ impl VideoConverter {
|
||||
let out_cs = DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709;
|
||||
vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs);
|
||||
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
|
||||
// One frame in, one frame out — no interpolation/auto-processing.
|
||||
// Progressive: one frame in, one frame out — no deinterlace, no frame-rate conversion.
|
||||
vctx.VideoProcessorSetStreamFrameFormat(&vp, 0, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE);
|
||||
// …and no vendor "auto processing" either. Its documented DEFAULT is ENABLED, so until
|
||||
// now the comment above claimed something only this call delivers: denoise, edge
|
||||
// enhancement and whatever else the driver folds in were free to run inside every SDR
|
||||
// `VideoProcessorBlt` — on the desktop-capture hot path, altering the pixels we encode
|
||||
// and costing video-engine time nobody asked for.
|
||||
vctx.VideoProcessorSetStreamAutoProcessingMode(&vp, 0, false);
|
||||
|
||||
Ok(Self {
|
||||
vdev,
|
||||
@@ -1308,14 +892,22 @@ impl VideoConverter {
|
||||
vp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) unsafe fn convert(
|
||||
&self,
|
||||
input: &ID3D11Texture2D,
|
||||
output: &ID3D11Texture2D,
|
||||
) -> Result<()> {
|
||||
// SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized
|
||||
// stack descriptors and live out-params. `stream.pInputSurface` is a `ManuallyDrop` of the
|
||||
// input view just created: `VideoProcessorBlt` only BORROWS it (a COM in-param never transfers
|
||||
// ownership), and the explicit `into_inner` drop below releases that reference exactly once on
|
||||
// both the success and the failure path. `slice::from_ref(&stream)` borrows the live local.
|
||||
unsafe {
|
||||
let in_desc = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC {
|
||||
FourCC: 0,
|
||||
ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D,
|
||||
@@ -1359,16 +951,4 @@ impl VideoConverter {
|
||||
blt.context("VideoProcessorBlt")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_selftests {
|
||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hdr_p010_selftest_intel_1080_live() {
|
||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
//! The P010 colour SELF-TEST and its helpers — the `hdr-p010-selftest` subcommand's whole
|
||||
//! implementation, plus the f64 reference math it compares against and the f16 encoder it uploads
|
||||
//! with.
|
||||
//!
|
||||
//! Split out of `windows/dxgi.rs` in sweep Phase 5.5: it was ~560 of that file's 1,374 lines and
|
||||
//! none of it runs in a session. What remains in the parent is the production path (the win32u hook,
|
||||
//! the shader sources, the three converters); this is the validation path.
|
||||
//!
|
||||
//! `hdr_p010_selftest_at` and `hdr_p010_convert_bars_on_luid` are re-exported by the parent, so
|
||||
//! every existing `crate::capture::dxgi::…` / `pf_capture::dxgi::…` path keeps resolving.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
||||
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
||||
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
||||
/// Used by [`hdr_p010_selftest`].
|
||||
#[cfg(target_os = "windows")]
|
||||
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||
fn pq_oetf(l: f64) -> f64 {
|
||||
let l = l.clamp(0.0, 1.0);
|
||||
let m1 = 0.1593017578125;
|
||||
let m2 = 78.84375;
|
||||
let c1 = 0.8359375;
|
||||
let c2 = 18.8515625;
|
||||
let c3 = 18.6875;
|
||||
let lp = l.powf(m1);
|
||||
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
|
||||
}
|
||||
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
|
||||
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
|
||||
let m = [
|
||||
[0.627403914, 0.329283038, 0.043313048],
|
||||
[0.069097292, 0.919540405, 0.011362303],
|
||||
[0.016391439, 0.088013308, 0.895595253],
|
||||
];
|
||||
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
|
||||
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
|
||||
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
|
||||
// PQ encode (normalize to 10k nits).
|
||||
let pr = pq_oetf(lr / 10000.0);
|
||||
let pg = pq_oetf(lg / 10000.0);
|
||||
let pb = pq_oetf(lb / 10000.0);
|
||||
// BT.2020 non-constant-luminance, limited 10-bit.
|
||||
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
|
||||
let y = kr * pr + kg * pg + kb * pb;
|
||||
let cb = (pb - y) / 1.8814;
|
||||
let cr = (pr - y) / 1.4746;
|
||||
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
|
||||
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
|
||||
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
|
||||
(yc, cbc, crc)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
||||
/// (325,448,598) (226,650,535) (64,512,512).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[doc(hidden)]
|
||||
pub fn hdr_p010_convert_bars_on_luid(
|
||||
luid: [u8; 8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
||||
const BARS: [(f32, f32, f32); 8] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
let bar_w = (w / 8).max(1) as usize;
|
||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
||||
let i = (y * w as usize + x) * 4;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
}
|
||||
}
|
||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
||||
// their references.
|
||||
unsafe {
|
||||
let luid = windows::Win32::Foundation::LUID {
|
||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
||||
};
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
&adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: w * 8,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 bars)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 bars dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device, w, h)?;
|
||||
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||||
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||||
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, w, h)?;
|
||||
Ok((device, p010))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
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::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||
#[allow(non_snake_case)]
|
||||
let (W, H) = (w, h);
|
||||
const BLK: u32 = 16;
|
||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||
let named: [(&str, f32, f32, f32); 8] = [
|
||||
("red1.0", 1.0, 0.0, 0.0),
|
||||
("green0.5", 0.0, 0.5, 0.0),
|
||||
("blue4.0", 0.0, 0.0, 4.0),
|
||||
("white1.0", 1.0, 1.0, 1.0),
|
||||
("black", 0.0, 0.0, 0.0),
|
||||
("gray0.5", 0.5, 0.5, 0.5),
|
||||
("white4.0", 4.0, 4.0, 4.0),
|
||||
("amber2.0", 2.0, 1.0, 0.0),
|
||||
];
|
||||
|
||||
let grid_cols = W / BLK; // 4
|
||||
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
|
||||
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
|
||||
if idx < named.len() {
|
||||
let (_, r, g, b) = named[idx];
|
||||
(r, g, b, true)
|
||||
} else {
|
||||
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
|
||||
let r = (x as f32 / W as f32) * 3.0;
|
||||
let g = (y as f32 / H as f32) * 3.0;
|
||||
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
|
||||
(r, g, b, false)
|
||||
}
|
||||
};
|
||||
|
||||
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
|
||||
let mut fp16 = vec![0u16; (W * H * 4) as usize];
|
||||
let mut flat = vec![false; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (r, g, b, is_flat) = pixel_rgb(x, y);
|
||||
let i = ((y * W + x) * 4) as usize;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
flat[(y * W + x) as usize] = is_flat;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||||
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||||
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||||
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||||
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||||
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||
// proven individually at the `read_u16` closure below.
|
||||
unsafe {
|
||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
||||
// the GPU it actually tested.
|
||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
||||
None => None,
|
||||
Some(want) => {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let mut found = None;
|
||||
for i in 0.. {
|
||||
let Ok(a) = factory.EnumAdapters(i) else {
|
||||
break;
|
||||
};
|
||||
let desc = a.GetDesc().context("adapter desc")?;
|
||||
if desc.VendorId == want {
|
||||
found = Some(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
||||
}
|
||||
};
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
adapter.as_ref(),
|
||||
if adapter.is_some() {
|
||||
D3D_DRIVER_TYPE_UNKNOWN
|
||||
} else {
|
||||
D3D_DRIVER_TYPE_HARDWARE
|
||||
},
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
{
|
||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
||||
device.cast().context("device -> IDXGIDevice")?;
|
||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
||||
let name = String::from_utf16_lossy(
|
||||
&desc.Description[..desc
|
||||
.Description
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(desc.Description.len())],
|
||||
);
|
||||
println!(
|
||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||||
);
|
||||
}
|
||||
|
||||
// Source FP16 texture (initialized) + SRV.
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: W * 8, // 4 channels * 2 bytes
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 src)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 src)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// P010 destination texture (render-target bindable).
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device, W, H)?;
|
||||
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||||
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||||
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, W, H)?;
|
||||
|
||||
// Staging copy of the whole P010 texture (both planes), MAP_READ.
|
||||
let stage_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut staging: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
|
||||
.context("CreateTexture2D(P010 staging)")?;
|
||||
let staging = staging.context("null staging")?;
|
||||
context.CopyResource(&staging, &p010);
|
||||
|
||||
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
context
|
||||
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
|
||||
.context("Map(P010 staging)")?;
|
||||
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
|
||||
let base = map.pData as *const u8;
|
||||
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
|
||||
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
|
||||
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
|
||||
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
|
||||
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
|
||||
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
|
||||
// these and adjust `chroma_base` (e.g. an aligned luma height).
|
||||
tracing::info!(
|
||||
row_pitch,
|
||||
depth_pitch = map.DepthPitch,
|
||||
expected_chroma_base = row_pitch * H as usize,
|
||||
expected_total = row_pitch * H as usize * 3 / 2,
|
||||
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
|
||||
);
|
||||
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
|
||||
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
|
||||
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
|
||||
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
|
||||
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
|
||||
let read_u16 = |byte_off: usize| -> u16 {
|
||||
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
|
||||
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
|
||||
let p = base.add(byte_off) as *const u16;
|
||||
p.read_unaligned()
|
||||
};
|
||||
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
|
||||
let mut y_codes = vec![0u16; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let off = (y as usize) * row_pitch + (x as usize) * 2;
|
||||
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
|
||||
}
|
||||
}
|
||||
let cw = W / 2;
|
||||
let ch = H / 2;
|
||||
let chroma_base = row_pitch * H as usize; // plane 1 offset
|
||||
let mut cb_codes = vec![0u16; (cw * ch) as usize];
|
||||
let mut cr_codes = vec![0u16; (cw * ch) as usize];
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
|
||||
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
|
||||
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
|
||||
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
|
||||
}
|
||||
}
|
||||
context.Unmap(&staging, 0);
|
||||
|
||||
// Compare Y over every pixel.
|
||||
let mut max_y_err = 0.0f64;
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (r, g, b, _) = pixel_rgb(x, y);
|
||||
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
|
||||
let got = y_codes[(y * W + x) as usize] as f64;
|
||||
max_y_err = max_y_err.max((got - ry).abs());
|
||||
}
|
||||
}
|
||||
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
|
||||
let mut max_u_err = 0.0f64;
|
||||
let mut max_v_err = 0.0f64;
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let (sx, sy) = (cx * 2, cy * 2);
|
||||
let all_flat =
|
||||
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
|
||||
if !all_flat {
|
||||
continue;
|
||||
}
|
||||
let (r, g, b, _) = pixel_rgb(sx, sy);
|
||||
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
|
||||
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
|
||||
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
|
||||
max_u_err = max_u_err.max((gu - rcb).abs());
|
||||
max_v_err = max_v_err.max((gv - rcr).abs());
|
||||
}
|
||||
}
|
||||
|
||||
// Per-colour table.
|
||||
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
|
||||
println!(
|
||||
" {:<10} {:>14} {:>14} {:>14}",
|
||||
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
|
||||
);
|
||||
for (idx, (name, r, g, b)) in named.iter().enumerate() {
|
||||
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
|
||||
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
|
||||
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
|
||||
let gy = y_codes[(by * W + bx) as usize] as f64;
|
||||
let (ccx, ccy) = (bx / 2, by / 2);
|
||||
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
|
||||
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
|
||||
println!(
|
||||
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
|
||||
name, ey, gy, ecb, gu, ecr, gv
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
|
||||
);
|
||||
|
||||
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
|
||||
println!("PASS");
|
||||
Ok(())
|
||||
} else {
|
||||
println!("FAIL");
|
||||
bail!(
|
||||
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
|
||||
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn f32_to_f16(v: f32) -> u16 {
|
||||
let bits = v.to_bits();
|
||||
let sign = ((bits >> 16) & 0x8000) as u16;
|
||||
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
|
||||
let mant = bits & 0x007f_ffff;
|
||||
if exp <= 0 {
|
||||
// Subnormal / zero in half precision.
|
||||
if exp < -10 {
|
||||
return sign; // too small → ±0
|
||||
}
|
||||
let mant = mant | 0x0080_0000; // implicit 1
|
||||
let shift = (14 - exp) as u32;
|
||||
let half_mant = (mant >> shift) as u16;
|
||||
// Round to nearest.
|
||||
let round = ((mant >> (shift - 1)) & 1) as u16;
|
||||
sign | (half_mant + round)
|
||||
} else if exp >= 0x1f {
|
||||
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
|
||||
} else {
|
||||
let half_exp = (exp as u16) << 10;
|
||||
let half_mant = (mant >> 13) as u16;
|
||||
let round = ((mant >> 12) & 1) as u16;
|
||||
// ADD, never OR. `half_mant + round` can carry out of the 10-bit mantissa (all ones, then
|
||||
// rounded up), and that carry must INCREMENT the exponent — which is exactly what an
|
||||
// IEEE-754 round-to-nearest overflow means. `sign | half_exp | (…)` instead ORed it into bit
|
||||
// 10, so for every ODD biased exponent (bit 10 already set) the carry vanished and the
|
||||
// result came back a factor of ~2 low: `f32_to_f16(1.9998779) → 0x3C00 = 1.0`,
|
||||
// `0.49996948 → 0.25`. Only values one ULP below a power of two are affected — which is
|
||||
// precisely what a gradient test pattern is full of, so this made `hdr-p010-selftest` FAIL a
|
||||
// correct shader. The subnormal branch above was already additive.
|
||||
sign | (half_exp + half_mant + round)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod f16_tests {
|
||||
use super::f32_to_f16;
|
||||
|
||||
/// Round-trip through the reference conversion the rest of the test uses as an oracle.
|
||||
fn f16_to_f32(h: u16) -> f32 {
|
||||
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
|
||||
let exp = ((h >> 10) & 0x1f) as i32;
|
||||
let mant = (h & 0x3ff) as f32;
|
||||
match exp {
|
||||
0 => sign * mant * 2f32.powi(-24), // subnormal
|
||||
31 => sign * f32::INFINITY, // our encoder never emits NaN
|
||||
e => sign * (1.0 + mant / 1024.0) * 2f32.powi(e - 15),
|
||||
}
|
||||
}
|
||||
|
||||
/// W7: the rounding carry out of the mantissa must INCREMENT the exponent. The composition used
|
||||
/// `sign | half_exp | (half_mant + round)`, which swallowed that carry for every odd biased
|
||||
/// exponent — a silent factor-of-2 error on exactly the values a gradient test pattern is full
|
||||
/// of, which made `hdr-p010-selftest` fail a correct shader.
|
||||
#[test]
|
||||
fn a_rounding_carry_increments_the_exponent() {
|
||||
// The plan's canonical case: biased exponent 127 (2^0) with a mantissa that rounds up out
|
||||
// of 10 bits ⇒ 2.0 = 0x4000, NOT 1.0 = 0x3C00.
|
||||
assert_eq!(f32_to_f16(f32::from_bits((127 << 23) | 0x7FF000)), 0x4000);
|
||||
// The two measured regressions, by value.
|
||||
assert_eq!(
|
||||
f32_to_f16(1.9998779),
|
||||
0x4000,
|
||||
"1.9998779 must not read as 1.0"
|
||||
);
|
||||
assert_eq!(
|
||||
f32_to_f16(0.49996948),
|
||||
0x3800,
|
||||
"0.49996948 must not read as 0.25"
|
||||
);
|
||||
// …and an EVEN biased exponent, where the bug happened to be invisible (bit 10 clear), so
|
||||
// the fix must not change it.
|
||||
assert_eq!(f32_to_f16(f32::from_bits((128 << 23) | 0x7FF000)), 0x4400); // → 4.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_constants_the_selftest_uploads_are_exact() {
|
||||
assert_eq!(f32_to_f16(0.0), 0x0000);
|
||||
assert_eq!(f32_to_f16(-0.0), 0x8000);
|
||||
assert_eq!(f32_to_f16(1.0), 0x3C00);
|
||||
assert_eq!(f32_to_f16(-1.0), 0xBC00);
|
||||
assert_eq!(f32_to_f16(0.5), 0x3800);
|
||||
assert_eq!(f32_to_f16(2.0), 0x4000);
|
||||
assert_eq!(f32_to_f16(4.0), 0x4400);
|
||||
}
|
||||
|
||||
/// Every HDR scRGB value the self-test patterns use must survive the round trip to within one
|
||||
/// f16 ULP — the property the P010 comparison actually depends on.
|
||||
#[test]
|
||||
fn hdr_scrgb_values_round_trip_within_one_ulp() {
|
||||
for &v in &[
|
||||
0.0f32, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 0.1, 0.3, 0.7, 1.9998779, 0.49996948, 2.5,
|
||||
3.999, 0.001,
|
||||
] {
|
||||
let back = f16_to_f32(f32_to_f16(v));
|
||||
// One ULP at this magnitude: f16 carries 11 significand bits.
|
||||
let ulp = (v.abs() / 1024.0).max(2f32.powi(-24));
|
||||
assert!(
|
||||
(back - v).abs() <= ulp,
|
||||
"{v} round-tripped to {back} (ulp {ulp})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_magnitudes_saturate_rather_than_wrap() {
|
||||
// Above f16's max finite (65504) our encoder reports Inf; below its subnormal floor, ±0.
|
||||
assert_eq!(f32_to_f16(1.0e30), 0x7C00);
|
||||
assert_eq!(f32_to_f16(-1.0e30), 0xFC00);
|
||||
assert_eq!(f32_to_f16(1.0e-30), 0x0000);
|
||||
assert_eq!(f32_to_f16(-1.0e-30), 0x8000);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_selftests {
|
||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hdr_p010_selftest_intel_1080_live() {
|
||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
//! The LAST-RESORT DWM compose kick — synthetic pointer input that dirties a specific virtual
|
||||
//! display so DWM presents it.
|
||||
//!
|
||||
//! Split out of `idd_push.rs` in sweep Phase 5.4. It is self-contained (one function plus a
|
||||
//! process-global throttle) and it is the one piece of the capture path that reaches for synthetic
|
||||
//! INPUT, which is worth keeping visibly separate from the frame machinery: it is unreliable by
|
||||
//! nature, user-visible in the sibling-display case, and only ever a fallback for the driver's own
|
||||
//! `FrameStash` republish.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// The cursor only dirties the display it is ON — proven on-glass in the Stage-W3 two-display
|
||||
/// validation: display B's session-open kicks wiggled the cursor on display A and B never composed
|
||||
/// a first frame. So the kick is per-TARGET: when the cursor already sits inside `target_id`'s
|
||||
/// 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
|
||||
/// 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).
|
||||
/// Best-effort — injection can be unavailable on the secure desktop, where a fresh compose just
|
||||
/// 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 600–800 ms schedules bound how often it can happen.
|
||||
///
|
||||
/// **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 device is real input to win32k — delivered regardless of this process's session or the
|
||||
/// active desktop, it wakes a powered-off display subsystem (lid-closed laptop / display idle-off /
|
||||
/// modern standby) and counts as user presence — every condition under which `SendInput` is
|
||||
/// silently impotent (wrong session → wrong input queue; secure desktop → blocked; display off →
|
||||
/// nothing composes at all). That set is exactly the lid-closed field-report state.
|
||||
pub(super) fn kick_dwm_compose(target_id: u32) {
|
||||
// Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own
|
||||
// schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT
|
||||
// (global, user-visible pointer state), so it must not multiply with capturer count. 50 ms
|
||||
// covers every composition interval we ship (≥ 60 Hz) while staying far under the callers' own
|
||||
// 600–800 ms per-capturer schedules.
|
||||
static LAST_KICK: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
{
|
||||
let mut last = LAST_KICK.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
if last.is_some_and(|t| now.duration_since(t) < Duration::from_millis(50)) {
|
||||
return;
|
||||
}
|
||||
*last = Some(now);
|
||||
}
|
||||
// Where is the cursor, and where does the target display live in desktop space?
|
||||
let mut pos = POINT::default();
|
||||
// SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call.
|
||||
let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok();
|
||||
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||
// buffers; the `Copy` target id crosses by value.
|
||||
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||
// HID-first (see the doc comment): the registered virtual-mouse kick works from any
|
||||
// session/desktop and wakes an off display. Both geometries come from CCD (global database),
|
||||
// NOT per-session GDI metrics, so the aim is right even from a non-console session. Fall
|
||||
// through to SendInput only when the hook isn't registered / the mouse isn't up.
|
||||
if let (Some(kick), Some(rect)) = (crate::HID_COMPOSE_KICK.get(), rect) {
|
||||
// SAFETY: `desktop_bounds` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||
// buffers.
|
||||
let bounds = unsafe { pf_win_display::win_display::desktop_bounds() };
|
||||
if let Some(bounds) = bounds {
|
||||
if kick(rect, bounds) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (true, Some((x, y, w, h))) = (have_pos, rect) {
|
||||
let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1);
|
||||
if !inside {
|
||||
// The cursor is on a sibling display — a wiggle there dirties the WRONG display. Jump
|
||||
// to the target's center, DWELL one composition interval, then restore. The dwell is
|
||||
// load-bearing (proven on-glass, Stage W3): DWM computes dirty state from the CURRENT
|
||||
// cursor position at the next vsync tick, so a sub-tick jump-and-return is invisible
|
||||
// and the target never composes — 35 ms covers a 30 Hz tick with margin. The cursor
|
||||
// visibly leaves the sibling display for those ~2 frames; kicks only fire during THIS
|
||||
// display's session-open / recovery windows (throttled), so the blip is rare and brief.
|
||||
// SAFETY: plain FFI; coordinates are plain ints, and the second call restores the
|
||||
// observed original position.
|
||||
unsafe {
|
||||
let _ = SetCursorPos(x + w / 2, y + h / 2);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
// SAFETY: as above.
|
||||
unsafe {
|
||||
let _ = SetCursorPos(pos.x, pos.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mk = |dx: i32| INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: INPUT_0 {
|
||||
mi: MOUSEINPUT {
|
||||
dx,
|
||||
dy: 0,
|
||||
mouseData: 0,
|
||||
dwFlags: MOUSEEVENTF_MOVE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
// SAFETY: plain FFI; the input slice is valid, fully-initialized local data for this synchronous
|
||||
// call, and `cbsize` is the true element size.
|
||||
unsafe {
|
||||
let _ = SendInput(&[mk(1), mk(-1)], std::mem::size_of::<INPUT>() as i32);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,10 @@ pub(super) struct CursorBlendPass {
|
||||
|
||||
impl CursorBlendPass {
|
||||
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
|
||||
// literals (its contract).
|
||||
unsafe {
|
||||
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
|
||||
let mut vs = None;
|
||||
@@ -109,6 +113,7 @@ impl CursorBlendPass {
|
||||
shape: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
||||
unsafe fn ensure_shape(
|
||||
@@ -116,6 +121,11 @@ impl CursorBlendPass {
|
||||
device: &ID3D11Device,
|
||||
ov: &pf_frame::CursorOverlay,
|
||||
) -> Result<()> {
|
||||
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
|
||||
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
|
||||
// holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
|
||||
// outlives the synchronous upload.
|
||||
unsafe {
|
||||
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -153,6 +163,7 @@ impl CursorBlendPass {
|
||||
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
|
||||
/// `linear_scale`: 0 = SDR passthrough; non-zero = the frame is FP16 scRGB (HDR
|
||||
@@ -167,6 +178,13 @@ impl CursorBlendPass {
|
||||
ov: &pf_frame::CursorOverlay,
|
||||
linear_scale: f32,
|
||||
) -> Result<()> {
|
||||
// SAFETY: all D3D11 work on the caller's live `device`/`ctx` borrows. The
|
||||
// `copy_nonoverlapping` writes `cb.len()` `f32`s into the pointer the immediately preceding
|
||||
// `Map` of `self.cbuf` (16 bytes = 4×`f32`, DYNAMIC/WRITE_DISCARD) returned, inside the
|
||||
// `is_ok()` arm and before the paired `Unmap`. `ensure_shape` forwards this fn's `device`
|
||||
// borrow, and every `*Set*`/`Draw` takes borrowed slices of live locals or clones of live COM
|
||||
// interfaces.
|
||||
unsafe {
|
||||
self.ensure_shape(device, ov)?;
|
||||
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
|
||||
if self.cbuf_scale != Some(linear_scale) {
|
||||
@@ -178,9 +196,14 @@ impl CursorBlendPass {
|
||||
{
|
||||
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
||||
ctx.Unmap(&self.cbuf, 0);
|
||||
}
|
||||
// Cache ONLY on a successful upload. Caching unconditionally meant one transient
|
||||
// `Map` failure wedged the HDR/SDR cursor scale for the rest of the session: the
|
||||
// buffer still held the OLD value while this believed it held the new one, and
|
||||
// no later call would retry. On failure the cache is left alone and the next
|
||||
// blend tries again — a stale scale for a frame instead of forever.
|
||||
self.cbuf_scale = Some(linear_scale);
|
||||
}
|
||||
}
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
device
|
||||
.CreateRenderTargetView(dst, None, Some(&mut rtv))
|
||||
@@ -214,3 +237,4 @@ impl CursorBlendPass {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,23 @@ impl CursorPoller {
|
||||
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
|
||||
/// syscalls/s are not.
|
||||
const REATTACH: Duration = Duration::from_millis(250);
|
||||
/// Cadence of the same-handle extent re-probe (see the [`run`] loop). Display-scale changes are
|
||||
/// human/OS-timescale events and the probe reads dimensions only — no pixel copy — so 4 Hz is
|
||||
/// both ample and negligible, and a ≤250 ms lag on the pointer's size is imperceptible.
|
||||
const EXTENT_PROBE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Spawn the poller for the virtual display `target_id`. `rect` = the target's desktop rect
|
||||
/// Spawn the poller for the virtual display `target_id`. `rect` SEEDS the target's desktop rect
|
||||
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
|
||||
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
|
||||
/// (per-output semantics, matching the driver shm path and the Linux portal).
|
||||
///
|
||||
/// A SEED, not the value: the poll thread re-queries the rect on its [`Self::REATTACH`] cadence.
|
||||
/// It used to be captured once here and used forever for BOTH the desktop→frame offset and the
|
||||
/// `in_rect` test, while both mid-session mode-change paths (`resize_output` and
|
||||
/// `poll_display_hdr` → `recreate_ring`) keep the same poller — so after an in-place resize the
|
||||
/// pointer was clipped to the OLD rect and offset by a stale origin. Re-querying on the poll
|
||||
/// thread is what keeps the CCD call off the capture/encode thread, which is the whole reason
|
||||
/// this poller exists (see `DescriptorPoller`).
|
||||
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
|
||||
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
@@ -139,7 +151,7 @@ impl Drop for CursorPoller {
|
||||
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
|
||||
fn run(
|
||||
target_id: u32,
|
||||
rect: (i32, i32, i32, i32),
|
||||
mut rect: (i32, i32, i32, i32),
|
||||
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
||||
stop: &AtomicBool,
|
||||
secure: &AtomicBool,
|
||||
@@ -161,12 +173,35 @@ fn run(
|
||||
let mut failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
|
||||
let mut serial: u64 = 0;
|
||||
let mut logged_live = false;
|
||||
let mut last_extent = Instant::now();
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(CursorPoller::INTERVAL);
|
||||
if last_attach.elapsed() >= CursorPoller::REATTACH {
|
||||
last_attach = Instant::now();
|
||||
publish_secure(secure, desktop.reattach());
|
||||
// …and re-read the target's desktop rect on the same cadence: a mid-session resize (or
|
||||
// an HDR recreate, or the user moving this display in the desktop arrangement) changes
|
||||
// BOTH the origin the position is made relative to and the extent `in_rect` tests
|
||||
// against, and this poller outlives all of them. `None` keeps the last good value — a
|
||||
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
|
||||
// report every position invisible.
|
||||
//
|
||||
// SAFETY: `source_desktop_rect` is an `unsafe fn` running the read-only CCD
|
||||
// `QueryDisplayConfig` over owned local buffers; the `Copy` target id crosses by value
|
||||
// and it returns owned `(x, y, w, h)` values, borrowing nothing.
|
||||
let fresh = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||
if let Some(fresh) = fresh {
|
||||
if fresh != rect {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
from = ?rect,
|
||||
to = ?fresh,
|
||||
"cursor poller: target desktop rect changed — re-basing pointer positions"
|
||||
);
|
||||
rect = fresh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut ci = CURSORINFO {
|
||||
@@ -191,6 +226,42 @@ fn run(
|
||||
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS
|
||||
// behavior); frame cycling via DrawIconEx istep is a known follow-up.
|
||||
let handle = ci.hCursor.0 as isize;
|
||||
|
||||
// …but the handle alone CANNOT see a re-render. Windows rebuilds the system cursors at a
|
||||
// new size whenever the scale under the pointer changes — crossing to a differently-scaled
|
||||
// monitor, or a monitor's own scale settling after a mode change (a fresh virtual display
|
||||
// is created at the RECOMMENDED scale and gets the client's saved `PerMonitorSettings`
|
||||
// override a beat later) — while the SHARED handle stays put for the session's life (the
|
||||
// arrow is 0x10003 throughout). Keyed on the handle alone the cache latched whatever size
|
||||
// the pointer happened to have when the poller started and never let go: a session that
|
||||
// sampled inside that pre-settle window forwarded — and composited — a 96 px pointer over
|
||||
// a 100 % desktop until it ended, which is exactly the "cursor is 3× too big while
|
||||
// everything else is fine" report. Re-read the bitmap's EXTENT on a slow cadence
|
||||
// (dimensions only, no pixel copy) and drop the cache when it moved.
|
||||
if showing && handle != 0 && handle == cached_handle {
|
||||
if last_extent.elapsed() >= CursorPoller::EXTENT_PROBE {
|
||||
last_extent = Instant::now();
|
||||
if let (Some(now), Some(s)) = (cursor_extent(ci.hCursor), shape.as_ref()) {
|
||||
if now != (s.w, s.h) {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
"cursor: the pointer bitmap resized under a stable handle \
|
||||
({}x{} -> {}x{}) — re-rasterising (the scale under the pointer moved)",
|
||||
s.w,
|
||||
s.h,
|
||||
now.0,
|
||||
now.1
|
||||
);
|
||||
cached_handle = 0; // re-rasterise below, on this same tick
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A handle change re-rasterises on its own — hold the probe off so it can't fire on
|
||||
// the very next tick against a shape that is current by construction.
|
||||
last_extent = Instant::now();
|
||||
}
|
||||
|
||||
if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
|
||||
match rasterize(ci.hCursor) {
|
||||
Some((rgba, w, h, hot_x, hot_y)) => {
|
||||
@@ -355,6 +426,57 @@ fn rasterize(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Raste
|
||||
|
||||
type RasterOut = Option<(Vec<u8>, u32, u32, u32, u32)>;
|
||||
|
||||
/// The CURRENT bitmap extent of `hcursor` — exactly the `(w, h)` [`convert`] would derive, without
|
||||
/// the pixel read. Feeds the poll loop's staleness check (a re-render keeps the handle, see there).
|
||||
/// `None` on any failure, which the caller reads as "no verdict" and keeps its cached shape.
|
||||
fn cursor_extent(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Option<(u32, u32)> {
|
||||
// CopyIcon first, for the reason `rasterize` does it: the owning process can destroy its
|
||||
// HCURSOR between GetCursorInfo and the reads below; the copy is ours.
|
||||
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
|
||||
// icons in user32); CopyIcon yields an owned HICON destroyed below.
|
||||
let icon = unsafe { CopyIcon(HICON(hcursor.0)) }.ok()?;
|
||||
let mut ii = ICONINFO::default();
|
||||
// SAFETY: `ii` is a live out-param. On Ok it hands us COPIES of the mask/color bitmaps — both
|
||||
// deleted below (GDI-handle leak otherwise).
|
||||
let got = unsafe { GetIconInfo(icon, &mut ii) };
|
||||
// Mirrors `convert`'s two families: a color cursor's extent is its color bitmap's; a
|
||||
// monochrome one's mask carries the AND plane OVER the XOR plane, so its height is doubled.
|
||||
let extent = got.is_ok().then_some(()).and_then(|()| {
|
||||
if !ii.hbmColor.is_invalid() {
|
||||
bitmap_extent(ii.hbmColor)
|
||||
} else {
|
||||
let (w, h) = bitmap_extent(ii.hbmMask)?;
|
||||
(h >= 2 && h % 2 == 0).then_some((w, h / 2))
|
||||
}
|
||||
});
|
||||
// SAFETY: deleting the two bitmap copies GetIconInfo returned (null-safe: DeleteObject on a
|
||||
// null HGDIOBJ fails harmlessly) and the icon copy — each exactly once.
|
||||
unsafe {
|
||||
let _ = DeleteObject(ii.hbmColor.into());
|
||||
let _ = DeleteObject(ii.hbmMask.into());
|
||||
let _ = DestroyIcon(icon);
|
||||
}
|
||||
extent
|
||||
}
|
||||
|
||||
/// A GDI bitmap's dimensions, under [`read_bitmap_32`]'s sanity caps so the two agree on what a
|
||||
/// plausible cursor is — a bitmap `rasterize` would reject must not read here as a size CHANGE.
|
||||
fn bitmap_extent(hbm: HBITMAP) -> Option<(u32, u32)> {
|
||||
let mut bm = BITMAP::default();
|
||||
// SAFETY: `bm` is a live out-param sized exactly as passed; GetObjectW only writes into it.
|
||||
let n = unsafe {
|
||||
GetObjectW(
|
||||
hbm.into(),
|
||||
std::mem::size_of::<BITMAP>() as i32,
|
||||
Some((&mut bm as *mut BITMAP).cast()),
|
||||
)
|
||||
};
|
||||
if n == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 1024 {
|
||||
return None;
|
||||
}
|
||||
Some((bm.bmWidth as u32, bm.bmHeight as u32))
|
||||
}
|
||||
|
||||
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
|
||||
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
|
||||
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
|
||||
@@ -372,15 +494,13 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let color = read_bitmap_32(dc, ii.hbmColor)?;
|
||||
let (w, h) = (color.w as u32, color.h as u32);
|
||||
let mut rgba = bgra_to_rgba(&color.bgra);
|
||||
if rgba.chunks_exact(4).all(|p| p[3] == 0) {
|
||||
if alpha_is_empty(&rgba) {
|
||||
// Alpha-less color cursor: transparency lives in the AND mask.
|
||||
let mask = read_bitmap_32(dc, ii.hbmMask)?;
|
||||
if mask.w != color.w || mask.h < color.h {
|
||||
return None;
|
||||
}
|
||||
for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) {
|
||||
px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent
|
||||
}
|
||||
apply_and_mask_alpha(&mut rgba, &mask.bgra);
|
||||
}
|
||||
Some((rgba, w, h))
|
||||
} else {
|
||||
@@ -389,42 +509,8 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
|
||||
let row = w * 4;
|
||||
let (and_plane, xor_plane) = mask.bgra.split_at(h * row);
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut invert = vec![false; w * h];
|
||||
for i in 0..w * h {
|
||||
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
|
||||
let px = &mut rgba[i * 4..i * 4 + 4];
|
||||
match (a, x) {
|
||||
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
|
||||
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
|
||||
(true, false) => {} // transparent (already zeroed)
|
||||
(true, true) => {
|
||||
px.copy_from_slice(&[0, 0, 0, 0xFF]);
|
||||
invert[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// White outline around invert regions so the (now black) shape survives dark
|
||||
// backgrounds: any transparent 8-neighbor of an invert pixel turns opaque white.
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
if !invert[(y * w as i32 + x) as usize] {
|
||||
continue;
|
||||
}
|
||||
for (dx, dy) in NEIGHBORS {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let o = (ny * w as i32 + nx) as usize * 4;
|
||||
if rgba[o + 3] == 0 {
|
||||
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (and_plane, xor_plane) = mask.bgra.split_at(h * w * 4);
|
||||
let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h);
|
||||
Some((rgba, w as u32, h as u32))
|
||||
}
|
||||
})();
|
||||
@@ -506,3 +592,204 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a 32bpp RGBA buffer's alpha channel is entirely zero — the "old-style cursor with no
|
||||
/// alpha" test, whose transparency lives in the AND mask instead ([`apply_and_mask_alpha`]).
|
||||
fn alpha_is_empty(rgba: &[u8]) -> bool {
|
||||
rgba.chunks_exact(4).all(|p| p[3] == 0)
|
||||
}
|
||||
|
||||
/// Take alpha from an expanded AND mask: mask WHITE (AND bit 1) means transparent, black opaque.
|
||||
/// `mask_bgra` is the 32bpp expansion `GetDIBits` produces from the 1bpp mask, so any non-zero
|
||||
/// channel byte is "set".
|
||||
fn apply_and_mask_alpha(rgba: &mut [u8], mask_bgra: &[u8]) {
|
||||
for (px, m) in rgba.chunks_exact_mut(4).zip(mask_bgra.chunks_exact(4)) {
|
||||
px[3] = if m[0] != 0 { 0 } else { 0xFF };
|
||||
}
|
||||
}
|
||||
|
||||
/// The monochrome-cursor truth table, plus the white outline that makes an INVERT region legible.
|
||||
///
|
||||
/// A monochrome `HCURSOR` has no colour bitmap: `hbmMask` is DOUBLE height — the AND plane over the
|
||||
/// XOR plane — and the pair encodes four states (the WebRTC/Chromium table):
|
||||
///
|
||||
/// | AND | XOR | meaning | straight-alpha result |
|
||||
/// |-----|-----|-------------|------------------------------------------|
|
||||
/// | 0 | 0 | black | opaque black |
|
||||
/// | 0 | 1 | white | opaque white |
|
||||
/// | 1 | 0 | transparent | fully transparent |
|
||||
/// | 1 | 1 | INVERT dst | opaque black + a grown white outline |
|
||||
///
|
||||
/// INVERT is unrepresentable in straight alpha (it is a per-pixel XOR against whatever is behind
|
||||
/// it), so it becomes opaque black and every TRANSPARENT 8-neighbour of an invert pixel is turned
|
||||
/// opaque white. That outline is what keeps the text I-beam — which is almost entirely invert
|
||||
/// pixels — legible over dark content; the earlier translucent-grey stand-in did not.
|
||||
///
|
||||
/// Extracted from `convert`'s GDI plumbing (sweep Phase 6.6) so the table is testable: the caller
|
||||
/// needs a live `HCURSOR` and a screen DC, this needs two byte slices.
|
||||
fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut invert = vec![false; w * h];
|
||||
for i in 0..w * h {
|
||||
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
|
||||
let px = &mut rgba[i * 4..i * 4 + 4];
|
||||
match (a, x) {
|
||||
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
|
||||
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
|
||||
(true, false) => {} // transparent (already zeroed)
|
||||
(true, true) => {
|
||||
px.copy_from_slice(&[0, 0, 0, 0xFF]);
|
||||
invert[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
if !invert[(y * w as i32 + x) as usize] {
|
||||
continue;
|
||||
}
|
||||
for (dx, dy) in NEIGHBORS {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let o = (ny * w as i32 + nx) as usize * 4;
|
||||
if rgba[o + 3] == 0 {
|
||||
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Expand a 1-bit-per-pixel plane (as `GetDIBits` does) into the 32bpp form the converters read:
|
||||
/// any non-zero channel byte means "bit set".
|
||||
fn plane(bits: &[u8]) -> Vec<u8> {
|
||||
bits.iter()
|
||||
.flat_map(|&b| {
|
||||
let v = if b != 0 { 0xFF } else { 0 };
|
||||
[v, v, v, 0]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn px(rgba: &[u8], i: usize) -> [u8; 4] {
|
||||
rgba[i * 4..i * 4 + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
const OPAQUE_BLACK: [u8; 4] = [0, 0, 0, 0xFF];
|
||||
const OPAQUE_WHITE: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
|
||||
const TRANSPARENT: [u8; 4] = [0, 0, 0, 0];
|
||||
|
||||
/// All four AND/XOR states, in one 4×1 row — the table `mono_planes_to_rgba` documents.
|
||||
#[test]
|
||||
fn the_monochrome_truth_table_is_exact() {
|
||||
// (0,0) black (0,1) white (1,0) transparent (1,1) invert
|
||||
let and = plane(&[0, 0, 1, 1]);
|
||||
let xor = plane(&[0, 1, 0, 1]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
|
||||
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 XOR=0 ⇒ black");
|
||||
assert_eq!(px(&out, 1), OPAQUE_WHITE, "AND=0 XOR=1 ⇒ white");
|
||||
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
|
||||
// so the outline claims it — that IS the documented behaviour.
|
||||
assert_eq!(
|
||||
px(&out, 2),
|
||||
OPAQUE_WHITE,
|
||||
"outline grows into adjacent transparency"
|
||||
);
|
||||
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 XOR=1 ⇒ black + outline");
|
||||
}
|
||||
|
||||
/// Transparency survives when there is no invert pixel next to it.
|
||||
#[test]
|
||||
fn transparent_pixels_stay_transparent_without_an_invert_neighbour() {
|
||||
let and = plane(&[1, 1, 1, 1]);
|
||||
let xor = plane(&[0, 0, 0, 0]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
|
||||
for i in 0..4 {
|
||||
assert_eq!(px(&out, i), TRANSPARENT, "pixel {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline grows into all eight neighbours, and only into TRANSPARENT ones — it must not
|
||||
/// repaint a black or white shape pixel.
|
||||
#[test]
|
||||
fn the_invert_outline_covers_eight_neighbours_and_overwrites_nothing() {
|
||||
// 3×3, invert at the centre, everything else transparent.
|
||||
let and = plane(&[1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
||||
let mut xor = plane(&[0; 9]);
|
||||
for b in &mut xor[4 * 4..4 * 4 + 3] {
|
||||
*b = 0xFF; // centre pixel's XOR bit
|
||||
}
|
||||
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
|
||||
assert_eq!(px(&out, 4), OPAQUE_BLACK, "the invert pixel itself");
|
||||
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
|
||||
assert_eq!(px(&out, i), OPAQUE_WHITE, "neighbour {i} outlined");
|
||||
}
|
||||
|
||||
// Now surround it with BLACK shape pixels (AND=0, XOR=0): the outline must leave them alone.
|
||||
let and = plane(&[0, 0, 0, 0, 1, 0, 0, 0, 0]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
|
||||
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
|
||||
assert_eq!(
|
||||
px(&out, i),
|
||||
OPAQUE_BLACK,
|
||||
"neighbour {i} must not be repainted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline must clip at the bitmap edges rather than wrap to the opposite side.
|
||||
#[test]
|
||||
fn the_outline_clips_at_the_edges() {
|
||||
// 2×2 with the invert at (0, 0): only (1,0), (0,1) and (1,1) can be outlined.
|
||||
let and = plane(&[1, 1, 1, 1]);
|
||||
let mut xor = plane(&[0; 4]);
|
||||
for b in &mut xor[0..3] {
|
||||
*b = 0xFF;
|
||||
}
|
||||
let out = mono_planes_to_rgba(&and, &xor, 2, 2);
|
||||
assert_eq!(px(&out, 0), OPAQUE_BLACK);
|
||||
for i in [1, 2, 3] {
|
||||
assert_eq!(px(&out, i), OPAQUE_WHITE, "in-bounds neighbour {i}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the alpha-less colour path ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn an_empty_alpha_channel_is_detected() {
|
||||
assert!(alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 0]));
|
||||
assert!(!alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 1]));
|
||||
assert!(alpha_is_empty(&[]), "no pixels ⇒ vacuously empty");
|
||||
}
|
||||
|
||||
/// Mask WHITE (AND bit 1) = transparent, black = opaque — and the colour bytes are untouched.
|
||||
#[test]
|
||||
fn the_and_mask_supplies_alpha_for_an_alpha_less_cursor() {
|
||||
let mut rgba = vec![
|
||||
10, 20, 30, 0, // pixel 0
|
||||
40, 50, 60, 0, // pixel 1
|
||||
];
|
||||
let mask = plane(&[1, 0]); // pixel 0 masked out, pixel 1 kept
|
||||
apply_and_mask_alpha(&mut rgba, &mask);
|
||||
assert_eq!(px(&rgba, 0), [10, 20, 30, 0], "masked ⇒ transparent");
|
||||
assert_eq!(px(&rgba, 1), [40, 50, 60, 0xFF], "unmasked ⇒ opaque");
|
||||
}
|
||||
|
||||
/// A mask with FEWER pixels than the colour bitmap must not panic — `zip` stops at the shorter
|
||||
/// side, leaving the tail at whatever alpha it had (the caller has already required
|
||||
/// `mask.h >= color.h`, so this is the belt).
|
||||
#[test]
|
||||
fn a_short_mask_does_not_panic() {
|
||||
let mut rgba = vec![1, 2, 3, 0, 4, 5, 6, 0, 7, 8, 9, 0];
|
||||
apply_and_mask_alpha(&mut rgba, &plane(&[0]));
|
||||
assert_eq!(px(&rgba, 0), [1, 2, 3, 0xFF]);
|
||||
assert_eq!(px(&rgba, 1), [4, 5, 6, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
//! IDD-push CONSTRUCTION: everything that runs once, before frames flow.
|
||||
//!
|
||||
//! The sealed channel's whole bring-up — the render-adapter resolution and its one TEX_FAIL rebind,
|
||||
//! the advanced-colour (HDR) negotiation, the shared header + ring + event creation, the channel
|
||||
//! delivery, the cursor-channel/poller opt-in, and the bounded first-frame gate — plus the two types
|
||||
//! that exist only for it ([`SharedObjectSa`], [`AttachTexFail`]).
|
||||
//!
|
||||
//! Split out of `idd_push.rs` in sweep Phase 5.4. The steady state (`try_consume`, `repeat_last`,
|
||||
//! the pollers, the `Capturer` impl) deliberately stays with the parent: this file is the part you
|
||||
//! read when a session will not START, and the parent is the part you read when one stops flowing.
|
||||
//! A `#[path]` child sees the parent's private items through `use super::*`, so nothing had to be
|
||||
//! made more visible to move here.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
|
||||
/// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
|
||||
/// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
|
||||
/// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
|
||||
/// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
|
||||
/// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
|
||||
/// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
|
||||
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
|
||||
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
|
||||
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
|
||||
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. See
|
||||
/// `design/idd-push-security.md`.
|
||||
///
|
||||
/// RAII, because the descriptor is a `LocalAlloc` the caller must `LocalFree` and the previous
|
||||
/// `(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)` tuple never did — leaking it twice per open (once
|
||||
/// for the header section, once for the frame-ready event) and again on every ring recreate. Pairing
|
||||
/// them in one owner also enforces the "descriptor must outlive the attributes" rule structurally:
|
||||
/// `sa.lpSecurityDescriptor` points at the allocation and [`as_ptr`](Self::as_ptr) only lends a
|
||||
/// borrow, so the attributes cannot escape this value's lifetime. Moving the struct is fine — the
|
||||
/// pointer targets the heap allocation, not a field.
|
||||
struct SharedObjectSa {
|
||||
sa: SECURITY_ATTRIBUTES,
|
||||
psd: PSECURITY_DESCRIPTOR,
|
||||
}
|
||||
|
||||
impl SharedObjectSa {
|
||||
fn new() -> Result<Self> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: `ConvertStringSecurityDescriptorToSecurityDescriptorW` reads the `w!()` literal and
|
||||
// writes the descriptor it allocates into the live local `psd`; `?` rejects a failure before
|
||||
// `psd` is read.
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:P(A;;GA;;;SY)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)
|
||||
.context("build SDDL for IDD-push shared objects")?;
|
||||
}
|
||||
Ok(Self {
|
||||
sa: SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
},
|
||||
psd,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `SECURITY_ATTRIBUTES` to hand a create call, borrowed from this owner.
|
||||
fn as_ptr(&self) -> *const SECURITY_ATTRIBUTES {
|
||||
&self.sa
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SharedObjectSa {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||
// allocated for this value and nothing else owns it; `LocalFree` releases it exactly once
|
||||
// (this `Drop` runs once, and `as_ptr` only ever lends a borrow of `sa`).
|
||||
unsafe {
|
||||
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IddPushCapturer {
|
||||
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
|
||||
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
|
||||
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
|
||||
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
|
||||
pub(super) unsafe fn create_ring_slots(
|
||||
device: &ID3D11Device,
|
||||
w: u32,
|
||||
h: u32,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<Vec<HostSlot>> {
|
||||
// SAFETY: every D3D11/DXGI call is `?`-checked on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live out-params; `&sa` stays valid for the whole loop
|
||||
// because `_psd`, the security descriptor backing it, is held in scope alongside.
|
||||
// `OwnedHandle::from_raw_handle` adopts the handle `CreateSharedHandle` JUST minted for this
|
||||
// slot — a unique, still-open NT handle owned by this process — making the slot its sole owner.
|
||||
unsafe {
|
||||
let sa = SharedObjectSa::new()?;
|
||||
let mut slots = Vec::new();
|
||||
for _ in 0..RING_LEN {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
// Match the OS-composed swap-chain surfaces so the driver's CopyResource into the slot +
|
||||
// its format-guard both succeed.
|
||||
Format: format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
|
||||
| D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0)
|
||||
as u32,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(IDD-push ring slot)")?;
|
||||
let tex = tex.context("null ring texture")?;
|
||||
let res1: IDXGIResource1 = tex.cast()?;
|
||||
let shared = res1
|
||||
.CreateSharedHandle(
|
||||
Some(sa.as_ptr()),
|
||||
DXGI_SHARED_RESOURCE_RW,
|
||||
PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
|
||||
)
|
||||
.context("CreateSharedHandle(IDD-push ring slot)")?;
|
||||
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
|
||||
let shared = OwnedHandle::from_raw_handle(shared.0 as _);
|
||||
let mutex: IDXGIKeyedMutex = tex.cast()?;
|
||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&tex, None, Some(&mut srv))
|
||||
.context("CreateShaderResourceView(IDD-push ring slot)")?;
|
||||
let srv = srv.context("null slot srv")?;
|
||||
slots.push(HostSlot {
|
||||
tex,
|
||||
mutex,
|
||||
shared,
|
||||
srv,
|
||||
});
|
||||
}
|
||||
Ok(slots)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
|
||||
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
|
||||
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
|
||||
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||
pf_win_display::display_events::spawn_once();
|
||||
match Self::open_inner(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
sender,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
) {
|
||||
Ok(mut me) => {
|
||||
me._keepalive = keepalive;
|
||||
Ok(me)
|
||||
}
|
||||
Err(e) => Err((e, keepalive)),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_inner(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> Result<Self> {
|
||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
|
||||
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
|
||||
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
|
||||
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
|
||||
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
|
||||
// this open, or a stale kept monitor across an adapter re-init — the driver reports
|
||||
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
|
||||
let luid = pf_gpu::resolve_render_adapter_luid().unwrap_or(LUID {
|
||||
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
||||
HighPart: (target.adapter_luid >> 32) as i32,
|
||||
});
|
||||
match Self::open_on(
|
||||
target.clone(),
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
luid,
|
||||
sender.clone(),
|
||||
cursor_sender.clone(),
|
||||
cursor_forward.clone(),
|
||||
) {
|
||||
Ok(me) => Ok(me),
|
||||
Err(e) => {
|
||||
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
||||
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
|
||||
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
|
||||
// adapter beats failing the session — the outer pipeline retries would repeat the
|
||||
// exact same mismatch.
|
||||
let driver_luid = e
|
||||
.downcast_ref::<AttachTexFail>()
|
||||
.map(|tf| tf.driver_luid)
|
||||
.filter(|d| *d != 0 && *d != crate::dxgi::pack_luid(luid));
|
||||
let Some(packed) = driver_luid else {
|
||||
return Err(e);
|
||||
};
|
||||
let drv = LUID {
|
||||
LowPart: (packed & 0xffff_ffff) as u32,
|
||||
HighPart: (packed >> 32) as i32,
|
||||
};
|
||||
tracing::warn!(
|
||||
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
|
||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||
driver's reported adapter"
|
||||
);
|
||||
Self::open_on(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
drv,
|
||||
sender,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
)
|
||||
.context("IDD-push rebind to the driver's reported render adapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_on(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
luid: LUID,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> Result<Self> {
|
||||
let (pw, ph, _hz) = preferred
|
||||
.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.
|
||||
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
|
||||
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
|
||||
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
|
||||
let (w, h) = unsafe { pf_win_display::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
|
||||
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
||||
// COMMIT_MODES2 colorspace/rgb_bpc log). For a 10-bit-capable client we PROACTIVELY enable
|
||||
// advanced color so HDR streams without the user toggling anything, then TRACK the display's
|
||||
// actual mode (a mid-session "Use HDR" flip; the driver's format-guard drops a mismatch), polling
|
||||
// the live state here and on every recreate. An SDR-only client instead forces advanced color OFF
|
||||
// and is PINNED there (below + the descriptor poller), so the SDR negotiation is honored and the
|
||||
// encoder never emits the in-band PQ upgrade to a client that asked for SDR.
|
||||
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
||||
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
|
||||
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `SharedObjectSa::new`,
|
||||
// `CreateFileMappingW`, `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all
|
||||
// `?`-checked, so every returned interface/handle/view is non-error before use;
|
||||
// `sa.as_ptr()`/`&adapter`/`&device` are live borrows that outlive each synchronous call, and
|
||||
// `sa.lpSecurityDescriptor` stays valid because the owning `SharedObjectSa` is held in scope
|
||||
// for the whole block (and frees the descriptor on the way out).
|
||||
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
|
||||
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
|
||||
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
|
||||
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
|
||||
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
|
||||
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
|
||||
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
|
||||
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
|
||||
// that orders all preceding writes before the driver may observe `MAGIC`.
|
||||
// - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
|
||||
// created owned section/event for the duration of that synchronous call.
|
||||
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||
unsafe {
|
||||
// An SDR-NEGOTIATED session (either codec) must run on an SDR (BGRA) composition, so
|
||||
// actively turn advanced color OFF — undoing any leftover HDR state from a prior 10-bit
|
||||
// session on a reused/lingering monitor, the driver's default, or the host's global
|
||||
// "Use HDR" — and settle before sizing the ring. Non-optional for two reasons:
|
||||
// - PyroWave: its CSC reads 8-bit BGRA and the NVIDIA D3D11 VideoProcessor can't ingest
|
||||
// the FP16 ring at all.
|
||||
// - H.26x: off an HDR composition the capturer emits P010 and the encoder stamps
|
||||
// Main10 + BT.2020 PQ from the pixel format alone (the in-band HDR upgrade), sending a
|
||||
// 10-bit PQ stream to a client that advertised SDR-only ("HDR off = never send me
|
||||
// 10-bit"). On a client whose monitor is HDR-capable but has "Use HDR" off, that PQ
|
||||
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
|
||||
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
|
||||
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
|
||||
if !client_10bit {
|
||||
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
||||
let settle = Instant::now();
|
||||
while settle.elapsed() < Duration::from_millis(250) {
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(true)
|
||||
{
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
pyrowave,
|
||||
"IDD push: SDR session but advanced color (HDR) could NOT be turned off on the \
|
||||
virtual display (a physical display forcing HDR?) — PyroWave will likely fail \
|
||||
its first frame; H.26x would emit PQ the SDR-only client never asked for"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target = target.target_id,
|
||||
pyrowave,
|
||||
settle_ms = settle.elapsed().as_millis() as u64,
|
||||
"IDD push: SDR-negotiated session — advanced color forced OFF (SDR/BGRA composition)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||
let enabled_hdr = client_10bit
|
||||
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||
if enabled_hdr {
|
||||
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
|
||||
// ceiling = the old 250 ms. A read that never flips within the ceiling proceeds
|
||||
// exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr`
|
||||
// either way (the set succeeded; only the driver's compose flip may lag, which the
|
||||
// stash/format-guard machinery absorbs).
|
||||
let hdr_settle = Instant::now();
|
||||
while hdr_settle.elapsed() < Duration::from_millis(250) {
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(true)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
tracing::debug!(
|
||||
target_id = target.target_id,
|
||||
settle_ms = hdr_settle.elapsed().as_millis() as u64,
|
||||
"IDD push: advanced-color (HDR) enable settle"
|
||||
);
|
||||
}
|
||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
|
||||
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
|
||||
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
|
||||
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
|
||||
// Keep the raw observation so Downgrade point D below can say whether the read reported
|
||||
// OFF or failed outright — "we asked, it said no" and "we could not tell" have different
|
||||
// causes and different fixes.
|
||||
let observed_hdr =
|
||||
pf_win_display::win_display::advanced_color_enabled(target.target_id);
|
||||
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
|
||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||
// BT.709, so the client's label overstates the stream until the descriptor poller sees
|
||||
// HDR come on. Loud, because every frame of this session is affected.
|
||||
if client_10bit && !display_hdr {
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
want_hdr = true,
|
||||
set_advanced_color_returned = enabled_hdr,
|
||||
observed_hdr = ?observed_hdr,
|
||||
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
|
||||
virtual display FAILED — encoding 8-bit SDR while the client was told HDR \
|
||||
(check the display driver / Windows HDR support on this box). \
|
||||
observed_hdr=Some(false) ⇒ the display reports advanced colour OFF after the \
|
||||
set; None ⇒ the CCD read itself failed"
|
||||
);
|
||||
}
|
||||
let ring_fmt = if display_hdr {
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT
|
||||
} else {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
};
|
||||
// Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
|
||||
// `open_inner`; the driver must render the swap-chain on the SAME adapter for the
|
||||
// shared textures to open (it reports its actual render LUID into the header, which
|
||||
// `open_inner` uses to rebind once if this mismatches).
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
let adapter: IDXGIAdapter1 = factory
|
||||
.EnumAdapterByLuid(luid)
|
||||
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
|
||||
let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
|
||||
|
||||
let sa = SharedObjectSa::new()?;
|
||||
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
||||
|
||||
// Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
|
||||
let map = CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(sa.as_ptr()),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
bytes as u32,
|
||||
PCWSTR::null(),
|
||||
)
|
||||
.context("CreateFileMapping(IDD-push header)")?;
|
||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
|
||||
let map = OwnedHandle::from_raw_handle(map.0 as _);
|
||||
let view = MapViewOfFile(
|
||||
HANDLE(map.as_raw_handle()),
|
||||
FILE_MAP_ALL_ACCESS,
|
||||
0,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
if view.Value.is_null() {
|
||||
bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
|
||||
}
|
||||
let section = MappedSection { handle: map, view };
|
||||
let generation = next_generation();
|
||||
let header = section.ptr::<SharedHeader>();
|
||||
std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
|
||||
(*header).version = VERSION;
|
||||
(*header).generation = generation;
|
||||
(*header).ring_len = RING_LEN;
|
||||
(*header).width = w;
|
||||
(*header).height = h;
|
||||
// Ring format = the display's composition format (FP16 in HDR, BGRA in SDR). The driver
|
||||
// reads this into its `ring_format` and drops any surface that doesn't match.
|
||||
(*header).dxgi_format = ring_fmt.0 as u32;
|
||||
// The ring NAMES its monitor (proto v3, `design/idd-push-security.md` invariant #10) —
|
||||
// stamped before the magic (below), never changed for the ring's life (a mid-session
|
||||
// recreate reuses this mapping). The driver refuses to attach a ring naming a different
|
||||
// monitor, so a stash cross-wire fails closed instead of leaking frames cross-client
|
||||
// (fail-closed refusal VALIDATED on-glass 2026-07-10 via a fault-injected build: driver
|
||||
// DRV_STATUS_BIND_FAIL + loud host open failure + sibling stream undisturbed).
|
||||
(*header).target_id = target.target_id;
|
||||
|
||||
// Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
|
||||
let event = CreateEventW(Some(sa.as_ptr()), false, false, PCWSTR::null())
|
||||
.context("CreateEvent(IDD-push)")?;
|
||||
let event = OwnedHandle::from_raw_handle(event.0 as _);
|
||||
|
||||
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
|
||||
let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?;
|
||||
|
||||
// Publish: magic LAST (Release) — the ring must be fully initialized before the driver
|
||||
// (which receives the channel strictly afterwards) can observe MAGIC.
|
||||
std::sync::atomic::fence(Ordering::Release);
|
||||
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
|
||||
.store(MAGIC, Ordering::Release);
|
||||
|
||||
// Deliver the sealed channel: duplicate header + event + every slot texture into the
|
||||
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
|
||||
// broker reaps its remote duplicates on failure), and a failure fails the open — without
|
||||
// the delivery the driver can never attach.
|
||||
let broker = ChannelBroker::open(target.wudf_pid, sender)?;
|
||||
broker
|
||||
.send(
|
||||
target.target_id,
|
||||
generation,
|
||||
HANDLE(section.handle.as_raw_handle()),
|
||||
HANDLE(event.as_raw_handle()),
|
||||
&slots,
|
||||
)
|
||||
.context("deliver IDD-push frame channel to the driver")?;
|
||||
|
||||
// v5 hardware-cursor channel (M2c): create + deliver the CursorShm section. Failure
|
||||
// is NON-fatal — the driver never declares the hardware cursor without this delivery,
|
||||
// so the session degrades to today's composited pointer (and the forwarder simply
|
||||
// never sees a live overlay).
|
||||
let cursor_shared = cursor_sender.as_ref().and_then(|send_cursor| {
|
||||
match cursor::CursorShared::create(target.target_id) {
|
||||
Ok(cs) => {
|
||||
// Deliver via the shared helper (also used for RE-delivery after a
|
||||
// driver-side monitor re-arrival destroyed the worker).
|
||||
deliver_cursor_channel(&broker, target.target_id, &cs, send_cursor)
|
||||
.then_some(cs)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"cursor section creation failed — the driver will not declare a \
|
||||
hardware cursor, so this session cannot forward the pointer: {e:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
// No LIVE channel this session, but the target's sticky declare (an EARLIER session's —
|
||||
// irrevocable, §8.6) keeps DWM's frames pointer-free with no client drawing either:
|
||||
// the only visible pointer is the one composited here, so force composite mode on.
|
||||
//
|
||||
// Gated on `cursor_shared`, NOT on `cursor_sender`. §8.6's rationale is "this session has
|
||||
// no cursor CHANNEL", and the delivery just above is explicitly allowed to fail
|
||||
// non-fatally — which is precisely the state that needs this rescue, yet the
|
||||
// `cursor_sender.is_none()` test was the one state that skipped it: the host negotiated a
|
||||
// channel, failed to create or deliver it, and then declined to composite, leaving a
|
||||
// cursor-excluded target with NO pointer at all.
|
||||
let composite_forced = target.cursor_excluded && cursor_shared.is_none();
|
||||
if composite_forced {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
negotiated_channel = cursor_sender.is_some(),
|
||||
"target carries an irrevocable hardware-cursor declare from an earlier \
|
||||
desktop-mode session and this session has no LIVE cursor channel — the host \
|
||||
composites the pointer into frames (forced, for the session's life). \
|
||||
negotiated_channel=true ⇒ one was negotiated but its creation/delivery failed"
|
||||
);
|
||||
}
|
||||
// The GDI shape poller rides the SAME gate as the delivered channel: with the driver's
|
||||
// hardware cursor keeping the frame cursor-free, the poller supplies the full-fidelity
|
||||
// shape (masked/monochrome included — the IddCx query can't; see cursor_poll.rs).
|
||||
// Forced-composite sessions need it too — it is their only shape/position source.
|
||||
let cursor_poll = (cursor_shared.is_some() || composite_forced).then(|| {
|
||||
// Safety of the CCD call: read-only QueryDisplayConfig over owned locals (same
|
||||
// call CursorShared::create makes) — already inside open_on's unsafe region.
|
||||
let rect = pf_win_display::win_display::source_desktop_rect(target.target_id)
|
||||
.unwrap_or((0, 0, i32::MAX, i32::MAX));
|
||||
cursor_poll::CursorPoller::spawn(target.target_id, rect)
|
||||
});
|
||||
// Heal the driver's persisted cursor-forward state: a session that died on the
|
||||
// secure desktop (client drops at the lock screen — the common case) leaves the
|
||||
// per-target desired state `false`, and the NEXT session's channel delivery would
|
||||
// adopt UNdeclared (the exact cross-session composite trap of §8.6). A fresh
|
||||
// session always starts declared; the secure-desktop guard re-disables if the
|
||||
// secure desktop is (still) up, via its first `poll_secure_desktop` edge.
|
||||
if let (Some(_), Some(fwd)) = (cursor_shared.as_ref(), cursor_forward.as_ref()) {
|
||||
if let Err(e) = fwd(true) {
|
||||
tracing::debug!("cursor-forward reset at open failed (pre-v6 driver?): {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
wudf_pid = target.wudf_pid,
|
||||
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
mode = format!("{w}x{h}"),
|
||||
display_hdr,
|
||||
client_10bit,
|
||||
want_444,
|
||||
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 \
|
||||
to attach + publish"
|
||||
);
|
||||
let mut me = Self {
|
||||
device,
|
||||
context,
|
||||
target_id: target.target_id,
|
||||
section,
|
||||
header,
|
||||
event,
|
||||
broker,
|
||||
width: w,
|
||||
height: h,
|
||||
slots,
|
||||
generation,
|
||||
client_10bit,
|
||||
display_hdr,
|
||||
hdr_pin_warned: false,
|
||||
want_444,
|
||||
pyrowave,
|
||||
pyro_fence: None,
|
||||
pyro_fence_handle: None,
|
||||
pyro_fence_value: 0,
|
||||
pyro_ring: Vec::new(),
|
||||
pyro_conv: None,
|
||||
pyro_last: None,
|
||||
desc_poller: DescriptorPoller::spawn(
|
||||
target.target_id,
|
||||
DisplayDescriptor {
|
||||
hdr: display_hdr,
|
||||
width: w,
|
||||
height: h,
|
||||
},
|
||||
),
|
||||
desc_seq: 0,
|
||||
pending_desc: None,
|
||||
recovering_since: None,
|
||||
last_fresh: Instant::now(),
|
||||
last_liveness: Instant::now(),
|
||||
last_kick: Instant::now(),
|
||||
stall_watch: StallWatch::new(),
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
video_conv: None,
|
||||
hdr_p010_conv: None,
|
||||
last_seq: 0,
|
||||
last_present: None,
|
||||
status_logged: false,
|
||||
cursor_shared,
|
||||
cursor_poll,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
secure_active: false,
|
||||
composite_cursor: composite_forced,
|
||||
composite_forced,
|
||||
cursor_blend: None,
|
||||
cursor_blend_failed: false,
|
||||
cursor_shm_latched: false,
|
||||
blend_scratch: None,
|
||||
last_blend_key: None,
|
||||
last_slot: None,
|
||||
sdr_white_scale: 1.0,
|
||||
// Held from BEFORE the first-frame gate (the display must not idle off while we
|
||||
// wait for the first compose) until the capturer drops with the session.
|
||||
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
|
||||
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
|
||||
// it back to the caller for the DDA fallback (audit §5.1).
|
||||
_keepalive: Box::new(()),
|
||||
};
|
||||
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
|
||||
// from the blend (which holds the ring slot's keyed mutex — see
|
||||
// `refresh_sdr_white_scale`). No-op on an SDR composition.
|
||||
me.refresh_sdr_white_scale();
|
||||
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
|
||||
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
|
||||
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
|
||||
// instead of next_frame's 20 s black-then-bail.
|
||||
me.wait_for_attach()?;
|
||||
Ok(me)
|
||||
}
|
||||
}
|
||||
|
||||
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
|
||||
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
|
||||
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
|
||||
///
|
||||
/// 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).
|
||||
/// 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
|
||||
// host-side bug (a stash/capturer cross-wire) — the exact class the driver-side check
|
||||
// catches from the other end; failing here names the culprit in the same release.
|
||||
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping (same access
|
||||
// pattern as the `driver_status` read below); no reference into the shared region is formed.
|
||||
let stamped = unsafe { (*self.header).target_id };
|
||||
if stamped != self.target_id {
|
||||
bail!(
|
||||
"IDD-push: our ring header names target {stamped} but this capturer serves target \
|
||||
{} — host-side ring↔monitor cross-wire (bug); failing the open",
|
||||
self.target_id
|
||||
);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
// 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
|
||||
// `>= size_of::<SharedHeader>()`, page-aligned), so the field read is in-bounds + aligned, and
|
||||
// no reference into the shared region is formed. Plain read: the driver writes this `u32`
|
||||
// cross-process, but an aligned `u32` read can't tear and `driver_status` is best-effort
|
||||
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
|
||||
// log_driver_status_once).
|
||||
let st = unsafe { (*self.header).driver_status };
|
||||
if st == DRV_STATUS_TEX_FAIL {
|
||||
// The driver wrote its render LUID BEFORE attempting the texture opens
|
||||
// (frame_transport.rs step 2), so it is valid here.
|
||||
let (_, detail, lo, hi) = self.driver_diag();
|
||||
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
|
||||
return Err(anyhow::Error::new(AttachTexFail {
|
||||
detail,
|
||||
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
|
||||
}));
|
||||
}
|
||||
if st == DRV_STATUS_NO_DEVICE1 {
|
||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||
the driver has no ID3D11Device1 to open shared resources)"
|
||||
);
|
||||
}
|
||||
if st == DRV_STATUS_BIND_FAIL {
|
||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
let claimed = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver REFUSED the ring↔monitor binding (DRV_STATUS_BIND_FAIL: the \
|
||||
delivered ring names target {claimed}, the monitor is {}) — host \
|
||||
stash/delivery cross-wire (bug); failing the open loudly (proto v3 §3.2)",
|
||||
self.target_id
|
||||
);
|
||||
}
|
||||
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
||||
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
|
||||
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?)"
|
||||
);
|
||||
// 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);
|
||||
next_kick = Instant::now() + Duration::from_millis(800);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
bail!(
|
||||
"IDD-push: no frame published within 4s (despite compose kicks) — {}; \
|
||||
falling back",
|
||||
self.no_first_frame_diagnosis(st)
|
||||
);
|
||||
}
|
||||
// Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on
|
||||
// every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the
|
||||
// driver_status polls above live (status writes don't signal the event). Consuming a
|
||||
// signal here is fine: `next_frame` re-checks the atomic `latest` token, never the
|
||||
// event, for truth.
|
||||
// SAFETY: `self.event` is this capturer's owned, live auto-reset event handle;
|
||||
// `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait.
|
||||
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Name a first-frame timeout from the driver's own evidence — `driver_status` plus the live
|
||||
/// OPENED detail word (proto `pack_opened_detail`) — instead of guessing. The three no-frames
|
||||
/// states look identical from the host side but have disjoint causes and fixes; the lid-closed
|
||||
/// field report burned days for lack of exactly this line. Appends a console-session hint when
|
||||
/// the host itself is in the wrong session (display writes + input kicks can't work from there).
|
||||
fn no_first_frame_diagnosis(&self, st: u32) -> String {
|
||||
let what = match st {
|
||||
// The delivery was never consumed: no swap-chain worker ran for this monitor at all.
|
||||
DRV_STATUS_NONE => "the driver never attached — the channel delivery was never \
|
||||
consumed, so the OS ran no swap-chain worker for this monitor (display not \
|
||||
composed at all: console display-off / modern standby, or the mode commit \
|
||||
never reached the adapter)"
|
||||
.to_string(),
|
||||
DRV_STATUS_OPENED => {
|
||||
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping
|
||||
// (same best-effort diagnostic access as the `driver_status` read in the caller);
|
||||
// no reference into the shared region is formed.
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
match unpack_opened_detail(detail) {
|
||||
Some((0, _)) => "driver attached with a live swap-chain, but DWM composed \
|
||||
ZERO frames — an undamaged or powered-off desktop, and the compose \
|
||||
kicks didn't bite (synthetic input is blocked on the secure desktop)"
|
||||
.to_string(),
|
||||
Some((offered, mismatched)) => format!(
|
||||
"driver attached and DWM composed {offered} frame(s), but none matched \
|
||||
the ring — {mismatched} dropped for a size/format mismatch (the \
|
||||
display's actual mode differs from what the host sized the ring to: \
|
||||
a mid-open mode-set, a fullscreen game, or a stale GDI view)"
|
||||
),
|
||||
// A pre-detail driver never stamps the live bit — say so rather than guess.
|
||||
None => "driver attached but published nothing; this pf-vdisplay build \
|
||||
predates attach diagnostics, so the cause can't be named — update the \
|
||||
driver for a precise line here"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
other => format!("driver_status={other} (unexpected at this point)"),
|
||||
};
|
||||
match pf_win_display::console_session_mismatch() {
|
||||
Some((own, console)) => format!(
|
||||
"{what} [host is in session {own} but the console is session {console} — display \
|
||||
writes and input kicks cannot work from a non-console session; reconnect the \
|
||||
console or run via the installed service]"
|
||||
),
|
||||
None => what,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
|
||||
/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
|
||||
/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
|
||||
#[derive(Debug)]
|
||||
struct AttachTexFail {
|
||||
detail: u32,
|
||||
driver_luid: i64,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AttachTexFail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
|
||||
detail=0x{:08x}): it could not open the ring textures — its swap-chain renders on \
|
||||
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
|
||||
self.detail,
|
||||
(self.driver_luid >> 32) as i32,
|
||||
(self.driver_luid & 0xffff_ffff) as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AttachTexFail {}
|
||||
@@ -31,6 +31,10 @@ pub(super) struct StallWatch {
|
||||
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||||
recent: std::collections::VecDeque<Instant>,
|
||||
cadence: pf_frame::metronome::Metronome,
|
||||
/// Stalls seen this session, and how many had a coinciding OS display event — the discriminator
|
||||
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
|
||||
seen: u32,
|
||||
with_os_events: u32,
|
||||
}
|
||||
|
||||
impl StallWatch {
|
||||
@@ -48,6 +52,8 @@ impl StallWatch {
|
||||
Self {
|
||||
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||||
cadence: pf_frame::metronome::Metronome::new(),
|
||||
seen: 0,
|
||||
with_os_events: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +85,78 @@ impl StallWatch {
|
||||
metronomic: self.cadence.note(now),
|
||||
})
|
||||
}
|
||||
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
|
||||
/// metronomic — name the class of disturbance and its cures.
|
||||
///
|
||||
/// Lives here rather than in `try_consume` (sweep Phase 5.4): it is ~65 lines of log prose plus
|
||||
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
|
||||
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
|
||||
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
|
||||
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
|
||||
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
|
||||
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
|
||||
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
|
||||
let window = stall.gap + Duration::from_millis(300);
|
||||
let events = now
|
||||
.checked_sub(window)
|
||||
.map(|from| pf_win_display::display_events::events_between(from, now))
|
||||
.unwrap_or_default();
|
||||
self.seen = self.seen.saturating_add(1);
|
||||
if !events.is_empty() {
|
||||
self.with_os_events = self.with_os_events.saturating_add(1);
|
||||
}
|
||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||
// at debug level, and the web-console debug ring captures these.
|
||||
tracing::debug!(
|
||||
gap_ms = stall.gap.as_millis() as u64,
|
||||
os_display_events = %pf_win_display::display_events::summarize(&events),
|
||||
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||
delivered no frame for the gap; the present path stalled below capture"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_externals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
suspects.join(", ")
|
||||
};
|
||||
let correlated = format!("{}/{}", self.with_os_events, self.seen);
|
||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||
// driver. Different classes, different cures — say which one this box has.
|
||||
if self.with_os_events * 2 >= self.seen {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||||
hot-plug/re-enumeration events — a connected display (or its \
|
||||
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||||
each time. Cures, best-first: that display's OSD 'auto input \
|
||||
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
|
||||
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
|
||||
keep it active while streaming; the pnp_disable_monitors policy axis \
|
||||
suppresses the Windows-side reaction (see connected_inactive for the \
|
||||
suspects)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
clock (try a different refresh rate). If connected_inactive lists a \
|
||||
display, its standby probing is the prime suspect: unplug it at the \
|
||||
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
|
||||
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
|
||||
streaming"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@ impl Capturer for SyntheticNv12Capturer {
|
||||
/// # Safety
|
||||
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
||||
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
|
||||
// created here and the adapters it returns own their own COM references. No raw pointers.
|
||||
unsafe {
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
|
||||
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
||||
@@ -148,6 +151,7 @@ unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
}
|
||||
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
||||
///
|
||||
@@ -177,8 +181,12 @@ unsafe fn create_nv12(
|
||||
..Default::default()
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
|
||||
// above), with a fully-initialized stack descriptor and a live `Option` out-param.
|
||||
unsafe {
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(NV12)")?;
|
||||
}
|
||||
tex.context("CreateTexture2D returned a null NV12 texture")
|
||||
}
|
||||
|
||||
@@ -249,9 +249,16 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and
|
||||
/// cursor plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap
|
||||
/// `Copy`; fixed for the session (an HDR toggle re-initialises the encoder — re-query if that
|
||||
/// matters).
|
||||
///
|
||||
/// (There is deliberately NO `supports_hdr_metadata` cap: in-band HDR SEI/OBU embedding needs no
|
||||
/// host-side routing — every first-party client reads the static grade exclusively out-of-band
|
||||
/// (the native 0xCE datagram / the GameStream 0x010e control message), both planes send it
|
||||
/// unconditionally, and the in-band grade is a decoder-side bonus for stock clients. A cap field
|
||||
/// nothing reads is a contract nobody honors; it was deleted after shipping write-only.)
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
@@ -261,10 +268,6 @@ pub struct EncoderCaps {
|
||||
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
|
||||
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
|
||||
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
|
||||
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
|
||||
@@ -304,8 +307,11 @@ pub struct EncoderCaps {
|
||||
///
|
||||
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
|
||||
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
|
||||
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
|
||||
/// `open_video` can only warn, which it does.
|
||||
/// host's call, since only the host can re-plan capture. That call is wired now — the
|
||||
/// negotiation consults the pre-open mirror ([`cursor_blend_capable`](crate::cursor_blend_capable))
|
||||
/// to gate the cursor channel and to keep capture on embedded-cursor / CSC-capable shapes for
|
||||
/// any backend that can't blend; `open_video`'s post-open check remains as the backstop for
|
||||
/// open-time fallbacks the plan can't see.
|
||||
pub blends_cursor: bool,
|
||||
}
|
||||
|
||||
@@ -333,11 +339,10 @@ pub trait Encoder: Send {
|
||||
let _ = wire_index;
|
||||
self.submit(frame)
|
||||
}
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||
/// path overrides it.
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, intra-refresh, chroma, cursor
|
||||
/// blending), so the session glue can route by query rather than rely on the no-op/`false`
|
||||
/// defaults of methods like [`invalidate_ref_frames`](Self::invalidate_ref_frames).
|
||||
/// Default: no optional capabilities (the software / libavcodec backends).
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps::default()
|
||||
}
|
||||
@@ -345,10 +350,12 @@ pub trait Encoder: Send {
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
||||
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
|
||||
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
|
||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
||||
/// every frame; only the direct-NVENC path consumes it.
|
||||
/// in-band (HEVC/H.264 `mastering_display_colour_volume` + `content_light_level_info` SEI, or
|
||||
/// AV1 metadata OBUs) on keyframes so a stock decoder — e.g. stock Moonlight — tone-maps from
|
||||
/// the source's real grade. Default: no-op (SDR encoders / paths that don't attach it).
|
||||
/// Cheap to call every frame; consumed by Windows direct-NVENC, native AMF, and native QSV.
|
||||
/// Every first-party client reads the grade out-of-band (the 0xCE datagram) regardless, so
|
||||
/// this is a bonus for stock decoders, never the primary channel.
|
||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
|
||||
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
|
||||
|
||||
@@ -1850,7 +1850,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
|
||||
blends_cursor: true,
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
chroma_444: self.chroma_444,
|
||||
intra_refresh: false,
|
||||
intra_refresh_recovery: false,
|
||||
|
||||
@@ -94,23 +94,152 @@ type LpKey = (String, &'static str, bool);
|
||||
/// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached
|
||||
/// so a GPU-preference change is picked up on the next open.
|
||||
fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
|
||||
(
|
||||
render_node().to_string_lossy().into_owned(),
|
||||
codec.label(),
|
||||
ten_bit,
|
||||
)
|
||||
lp_key_for(&render_node().to_string_lossy(), codec, ten_bit)
|
||||
}
|
||||
|
||||
/// [`lp_key`] with the render node explicit (device-free for the unit tests). Every part of the
|
||||
/// key is load-bearing — see [`LP_MODE`] for the two field-motivated bugs (node: cross-GPU
|
||||
/// session-killer; depth: HDR under-advertisement).
|
||||
fn lp_key_for(node: &str, codec: Codec, ten_bit: bool) -> LpKey {
|
||||
(node.to_owned(), codec.label(), ten_bit)
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
|
||||
/// only); unset → try full-feature first, fall back to low-power.
|
||||
fn low_power_override() -> Option<bool> {
|
||||
match std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?.trim() {
|
||||
parse_low_power(&std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?)
|
||||
}
|
||||
|
||||
/// [`low_power_override`]'s value grammar (device-free for the unit tests). Anything outside the
|
||||
/// two literal sets means "no pin" — the `[full-feature, low-power]` ladder runs.
|
||||
fn parse_low_power(raw: &str) -> Option<bool> {
|
||||
match raw.trim() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entrypoint modes to attempt, in order (`false` = full-feature `EncSlice`, `true` =
|
||||
/// low-power VDEnc): an operator pin tries exactly that one; a cached resolution ([`LP_MODE`]:
|
||||
/// 1 = full-feature, 2 = low-power) skips the known-failing attempt; anything else runs the full
|
||||
/// ladder — full-feature first so AMD's first-try open stays byte-for-byte unchanged. Never
|
||||
/// empty: [`open_vaapi_encoder`] relies on at least one attempt running.
|
||||
fn entrypoint_ladder(pin: Option<bool>, cached: u8) -> &'static [bool] {
|
||||
match pin {
|
||||
Some(true) => &[true],
|
||||
Some(false) => &[false],
|
||||
None => match cached {
|
||||
1 => &[false],
|
||||
2 => &[true],
|
||||
_ => &[false, true],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`LP_MODE`] value a successful open latches (1 = full-feature, 2 = low-power). Paired with
|
||||
/// [`entrypoint_ladder`], which maps it back to the single-mode retry list.
|
||||
fn latched_mode(low_power: bool) -> u8 {
|
||||
if low_power {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/// The VUI colour metadata the encoder signals for the session's depth.
|
||||
struct Vui {
|
||||
colorspace: ffi::AVColorSpace,
|
||||
range: ffi::AVColorRange,
|
||||
primaries: ffi::AVColorPrimaries,
|
||||
trc: ffi::AVColorTransferCharacteristic,
|
||||
}
|
||||
|
||||
/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the
|
||||
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy
|
||||
/// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709
|
||||
/// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to
|
||||
/// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input
|
||||
/// tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||
fn vui_for(ten_bit: bool) -> Vui {
|
||||
if ten_bit {
|
||||
Vui {
|
||||
colorspace: ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL,
|
||||
range: ffi::AVColorRange::AVCOL_RANGE_MPEG,
|
||||
primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT2020,
|
||||
trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084,
|
||||
}
|
||||
} else {
|
||||
Vui {
|
||||
colorspace: ffi::AVColorSpace::AVCOL_SPC_BT709,
|
||||
range: ffi::AVColorRange::AVCOL_RANGE_MPEG,
|
||||
primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT709,
|
||||
trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit `profile` option for this codec/depth, or `None` to let the encoder derive it.
|
||||
/// HEVC Main10 is pinned explicitly so the depth is never silently dropped (`hevc_vaapi` would
|
||||
/// derive it from the P010 surfaces, but a derivation can regress quietly); 10-bit AV1 is
|
||||
/// input-driven — no profile knob — and every 8-bit open keeps the encoder default.
|
||||
fn explicit_profile(codec: Codec, ten_bit: bool) -> Option<&'static str> {
|
||||
(ten_bit && codec == Codec::H265).then_some("main10")
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 accepted verbatim; unset, junk, and out-of-range
|
||||
/// values all resolve to depth 1 — the lowest-latency structure (see the `async_depth` note at the
|
||||
/// call site for why 1 is the default and what depth ≥ 2 trades).
|
||||
fn async_depth(raw: Option<&str>) -> u32 {
|
||||
raw.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|d| (1..=8).contains(d))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The `scale_vaapi` filter args for the session's depth. The VPP's output colour is pinned to
|
||||
/// exactly what the encoder's VUI signals ([`vui_for`]) — without the explicit options the
|
||||
/// conversion matrix is whatever the driver defaults to for an unspecified output (Mesa: BT.601),
|
||||
/// a hue shift against the signaled VUI. (The PQ transfer is per-channel and rides through the
|
||||
/// matrix untouched.)
|
||||
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
|
||||
if ten_bit {
|
||||
c"format=p010:out_color_matrix=bt2020:out_range=limited"
|
||||
} else {
|
||||
c"format=nv12:out_color_matrix=bt709:out_range=limited"
|
||||
}
|
||||
}
|
||||
|
||||
/// What a (captured format, negotiated bit depth) pair resolves to at open. 10-bit rides on the
|
||||
/// captured PIXELS, not the negotiated depth — see [`crate::ten_bit_input`] for the failure the
|
||||
/// reverse shape produces.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DepthResolution {
|
||||
/// PQ-graded 10-bit frames on a non-10-bit session: refuse the open, so PQ content is never
|
||||
/// mislabeled BT.709.
|
||||
RefuseMislabeledPq,
|
||||
/// 10-bit negotiated but the capture stayed SDR: honestly encode 8-bit (with a warning).
|
||||
SdrDowngrade,
|
||||
/// Format and negotiated depth agree.
|
||||
Agreed,
|
||||
}
|
||||
|
||||
/// See [`DepthResolution`].
|
||||
fn resolve_depth(format: PixelFormat, bit_depth: u8) -> DepthResolution {
|
||||
if format.is_hdr_rgb10() && bit_depth != 10 {
|
||||
DepthResolution::RefuseMislabeledPq
|
||||
} else if bit_depth == 10 && !format.is_hdr_rgb10() {
|
||||
DepthResolution::SdrDowngrade
|
||||
} else {
|
||||
DepthResolution::Agreed
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a codec is even eligible for the 10-bit probe: PyroWave answers 10-bit support on its
|
||||
/// own path (no VAAPI involved), and a codec without a 10-bit profile can never pass.
|
||||
fn ten_bit_probe_eligible(codec: Codec) -> bool {
|
||||
codec.supports_10bit() && codec != Codec::PyroWave
|
||||
}
|
||||
|
||||
/// Open the VAAPI encoder, resolving the entrypoint mode: try the full-feature entrypoint first
|
||||
/// and, if the driver rejects it, retry with `low_power=1` — modern Intel (Gen12+/Arc) exposes
|
||||
/// ONLY the low-power VDEnc entrypoint (ffmpeg's `vaapi_encode` defaults `low_power=0` and errors
|
||||
@@ -135,15 +264,7 @@ unsafe fn open_vaapi_encoder(
|
||||
.lock()
|
||||
.map(|m| m.get(&key).copied().unwrap_or(0))
|
||||
.unwrap_or(0);
|
||||
let modes: &[bool] = match low_power_override() {
|
||||
Some(true) => &[true],
|
||||
Some(false) => &[false],
|
||||
None => match cached {
|
||||
1 => &[false],
|
||||
2 => &[true],
|
||||
_ => &[false, true],
|
||||
},
|
||||
};
|
||||
let modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
|
||||
let mut first_err = None;
|
||||
for &lp in modes {
|
||||
match open_vaapi_encoder_mode(
|
||||
@@ -159,7 +280,7 @@ unsafe fn open_vaapi_encoder(
|
||||
) {
|
||||
Ok(enc) => {
|
||||
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
||||
m.insert(key.clone(), if lp { 2 } else { 1 });
|
||||
m.insert(key.clone(), latched_mode(lp));
|
||||
}
|
||||
if lp {
|
||||
tracing::info!(
|
||||
@@ -216,32 +337,18 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
if ten_bit {
|
||||
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010
|
||||
// the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the
|
||||
// zero-copy path). The client decoder auto-detects PQ from the VUI.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||
} else {
|
||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
||||
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
||||
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
}
|
||||
let vui = vui_for(ten_bit);
|
||||
(*raw).colorspace = vui.colorspace;
|
||||
(*raw).color_range = vui.range;
|
||||
(*raw).color_primaries = vui.primaries;
|
||||
(*raw).color_trc = vui.trc;
|
||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
|
||||
let mut opts = Dictionary::new();
|
||||
if ten_bit && codec == Codec::H265 {
|
||||
// HEVC Main10. `hevc_vaapi` derives it from the P010 surfaces, but pin it explicitly so
|
||||
// the depth is never silently dropped. (10-bit AV1 is input-driven — no profile knob.)
|
||||
opts.set("profile", "main10");
|
||||
if let Some(profile) = explicit_profile(codec, ten_bit) {
|
||||
opts.set("profile", profile);
|
||||
}
|
||||
// async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest
|
||||
// latency structure libavcodec's vaapi_encode offers. Measured on the 780M at 1440p60: depth 1
|
||||
@@ -251,11 +358,7 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
// where depth 2 restores throughput at that one-frame cost. NOTE: the per-frame block tracks
|
||||
// GPU CLOCKS — a paced 60 fps trickle lets the VCN downclock (~8 ms/frame vs ~4.4 ms hot);
|
||||
// see `gpuclocks` for the session clock pin that removes the ramp tax.
|
||||
let depth = std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|d| (1..=8).contains(d))
|
||||
.unwrap_or(1);
|
||||
let depth = async_depth(std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH").ok().as_deref());
|
||||
opts.set("async_depth", &depth.to_string());
|
||||
if low_power {
|
||||
opts.set("low_power", "1"); // VDEnc — the only encode entrypoint on modern Intel
|
||||
@@ -309,7 +412,7 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
/// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before
|
||||
/// the Welcome (honest downgrade).
|
||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if !codec.supports_10bit() || codec == Codec::PyroWave {
|
||||
if !ten_bit_probe_eligible(codec) {
|
||||
return false;
|
||||
}
|
||||
if ffmpeg::init().is_err() {
|
||||
@@ -834,24 +937,7 @@ impl DmabufInner {
|
||||
}
|
||||
init!(src, ptr::null(), "buffer");
|
||||
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
||||
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited SDR,
|
||||
// or BT.2020 limited P010 for HDR — the PQ transfer is per-channel and rides through
|
||||
// the matrix untouched). Without the explicit options the conversion matrix is
|
||||
// whatever the driver defaults to for an unspecified output (Mesa: BT.601) — a hue
|
||||
// shift against the signaled VUI.
|
||||
if ten_bit {
|
||||
init!(
|
||||
scale,
|
||||
c"format=p010:out_color_matrix=bt2020:out_range=limited".as_ptr(),
|
||||
"scale_vaapi"
|
||||
);
|
||||
} else {
|
||||
init!(
|
||||
scale,
|
||||
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
|
||||
"scale_vaapi"
|
||||
);
|
||||
}
|
||||
init!(scale, scale_vaapi_args(ten_bit).as_ptr(), "scale_vaapi");
|
||||
init!(sink, ptr::null(), "buffersink");
|
||||
|
||||
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
||||
@@ -1143,21 +1229,18 @@ impl VaapiEncoder {
|
||||
chroma: super::ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 /
|
||||
// Main10 / PQ-VUI variant of whichever inner path the first frame selects. A 10-bit
|
||||
// request whose capture stayed SDR honestly encodes 8-bit; the reverse (PQ frames on an
|
||||
// 8-bit session) is refused so PQ content is never mislabeled BT.709.
|
||||
if format.is_hdr_rgb10() && bit_depth != 10 {
|
||||
bail!(
|
||||
// Main10 / PQ-VUI variant of whichever inner path the first frame selects.
|
||||
match resolve_depth(format, bit_depth) {
|
||||
DepthResolution::RefuseMislabeledPq => bail!(
|
||||
"captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \
|
||||
refusing to mislabel PQ content"
|
||||
);
|
||||
}
|
||||
if bit_depth == 10 && !format.is_hdr_rgb10() {
|
||||
tracing::warn!(
|
||||
),
|
||||
DepthResolution::SdrDowngrade => tracing::warn!(
|
||||
bit_depth,
|
||||
?format,
|
||||
"10-bit requested but the capture stayed SDR — encoding 8-bit"
|
||||
);
|
||||
),
|
||||
DepthResolution::Agreed => {}
|
||||
}
|
||||
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
|
||||
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
|
||||
@@ -1307,3 +1390,261 @@ impl Encoder for VaapiEncoder {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The operator pin tries exactly one mode; a cached resolution ([`LP_MODE`]) tries only the
|
||||
/// mode that worked; anything else runs the full ladder, full-feature FIRST — AMD's first-try
|
||||
/// open must stay byte-for-byte unchanged.
|
||||
#[test]
|
||||
fn entrypoint_ladder_orders_and_pins() {
|
||||
assert_eq!(entrypoint_ladder(None, 0), &[false, true]);
|
||||
assert_eq!(entrypoint_ladder(None, 1), &[false]);
|
||||
assert_eq!(entrypoint_ladder(None, 2), &[true]);
|
||||
// A corrupt/unknown cache value degrades to the full ladder, never to a wrong pin.
|
||||
assert_eq!(entrypoint_ladder(None, 77), &[false, true]);
|
||||
// The pin beats the cache in BOTH directions — what makes PUNKTFUNK_VAAPI_LOW_POWER a
|
||||
// real escape hatch from a stale latch.
|
||||
for cached in [0u8, 1, 2, 77] {
|
||||
assert_eq!(entrypoint_ladder(Some(true), cached), &[true]);
|
||||
assert_eq!(entrypoint_ladder(Some(false), cached), &[false]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The latch round-trip: what a successful open stores makes the NEXT open attempt exactly
|
||||
/// the mode that worked (the "skip the known-failing attempt and its libav error spew"
|
||||
/// contract — and, per [`LP_MODE`], the shape that must never cross devices or depths).
|
||||
#[test]
|
||||
fn latch_round_trip_pins_the_resolved_mode() {
|
||||
for lp in [false, true] {
|
||||
assert_eq!(entrypoint_ladder(None, latched_mode(lp)), &[lp]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_LOW_POWER` grammar: the two lowercase literal sets pin; anything else —
|
||||
/// including uppercase — means "no pin" (the ladder runs). Case-sensitivity is pinned as
|
||||
/// shipped behavior.
|
||||
#[test]
|
||||
fn low_power_grammar() {
|
||||
for s in ["1", "true", "yes", "on", " on ", "yes\n"] {
|
||||
assert_eq!(parse_low_power(s), Some(true), "{s:?}");
|
||||
}
|
||||
for s in ["0", "false", "no", "off", " off "] {
|
||||
assert_eq!(parse_low_power(s), Some(false), "{s:?}");
|
||||
}
|
||||
for s in ["", "2", "TRUE", "On", "enabled", "low_power"] {
|
||||
assert_eq!(parse_low_power(s), None, "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every part of the LP_MODE key is load-bearing (see the [`LP_MODE`] field comments): the
|
||||
/// node (a GPU-preference switch must re-resolve — the old codec-only key was a
|
||||
/// session-killer), the codec, and the depth (an 8-bit answer must never pin the 10-bit open —
|
||||
/// the HDR under-advertisement).
|
||||
#[test]
|
||||
fn lp_key_separates_node_codec_and_depth() {
|
||||
let base = lp_key_for("/dev/dri/renderD128", Codec::H265, false);
|
||||
assert_ne!(base, lp_key_for("/dev/dri/renderD129", Codec::H265, false));
|
||||
assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H264, false));
|
||||
assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H265, true));
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 verbatim; unset, junk, zero, and past-the-cap
|
||||
/// all resolve to the lowest-latency depth 1.
|
||||
#[test]
|
||||
fn async_depth_grammar() {
|
||||
assert_eq!(async_depth(None), 1);
|
||||
assert_eq!(async_depth(Some("1")), 1);
|
||||
assert_eq!(async_depth(Some("2")), 2);
|
||||
assert_eq!(async_depth(Some("8")), 8);
|
||||
assert_eq!(async_depth(Some("0")), 1);
|
||||
assert_eq!(async_depth(Some("9")), 1);
|
||||
assert_eq!(async_depth(Some("-1")), 1);
|
||||
assert_eq!(async_depth(Some("fast")), 1);
|
||||
}
|
||||
|
||||
/// The swscale source map: packed RGB/BGR and the GNOME 50+ HDR 2:10:10:10 layouts convert;
|
||||
/// planar/video formats are refused (the CPU path is a packed-RGB fallback, not a general
|
||||
/// converter).
|
||||
#[test]
|
||||
fn sws_src_accepts_packed_rgb_only() {
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
|
||||
assert_eq!(
|
||||
vaapi_sws_src(PixelFormat::X2Rgb10).unwrap(),
|
||||
Pixel::X2RGB10LE
|
||||
);
|
||||
assert_eq!(
|
||||
vaapi_sws_src(PixelFormat::X2Bgr10).unwrap(),
|
||||
Pixel::X2BGR10LE
|
||||
);
|
||||
for f in [
|
||||
PixelFormat::Nv12,
|
||||
PixelFormat::P010,
|
||||
PixelFormat::Rgb10a2,
|
||||
PixelFormat::Yuv444,
|
||||
] {
|
||||
assert!(vaapi_sws_src(f).is_err(), "{f:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// VUI ↔ CSC agreement: the signaled VUI and the zero-copy VPP's pinned output colour must
|
||||
/// name the SAME matrix/range per depth — a mismatch is exactly the Mesa-BT.601 hue shift the
|
||||
/// pin exists to prevent.
|
||||
#[test]
|
||||
fn vui_and_scale_args_agree_per_depth() {
|
||||
let sdr = vui_for(false);
|
||||
assert!(matches!(sdr.colorspace, ffi::AVColorSpace::AVCOL_SPC_BT709));
|
||||
assert!(matches!(sdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG));
|
||||
assert!(matches!(
|
||||
sdr.primaries,
|
||||
ffi::AVColorPrimaries::AVCOL_PRI_BT709
|
||||
));
|
||||
assert!(matches!(
|
||||
sdr.trc,
|
||||
ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709
|
||||
));
|
||||
let args = scale_vaapi_args(false).to_str().unwrap();
|
||||
for needle in ["format=nv12", "out_color_matrix=bt709", "out_range=limited"] {
|
||||
assert!(
|
||||
args.contains(needle),
|
||||
"SDR scale args miss {needle}: {args}"
|
||||
);
|
||||
}
|
||||
|
||||
let hdr = vui_for(true);
|
||||
assert!(matches!(
|
||||
hdr.colorspace,
|
||||
ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL
|
||||
));
|
||||
assert!(matches!(hdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG));
|
||||
assert!(matches!(
|
||||
hdr.primaries,
|
||||
ffi::AVColorPrimaries::AVCOL_PRI_BT2020
|
||||
));
|
||||
assert!(matches!(
|
||||
hdr.trc,
|
||||
ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084
|
||||
));
|
||||
let args = scale_vaapi_args(true).to_str().unwrap();
|
||||
for needle in [
|
||||
"format=p010",
|
||||
"out_color_matrix=bt2020",
|
||||
"out_range=limited",
|
||||
] {
|
||||
assert!(
|
||||
args.contains(needle),
|
||||
"HDR scale args miss {needle}: {args}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The honest-downgrade table at open: PQ pixels demand a 10-bit session (refused otherwise —
|
||||
/// PQ content must never be mislabeled BT.709); a 10-bit session over SDR pixels downgrades
|
||||
/// honestly to 8-bit; agreement passes both ways.
|
||||
#[test]
|
||||
fn depth_resolution_table() {
|
||||
use DepthResolution::*;
|
||||
assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 8), RefuseMislabeledPq);
|
||||
assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 8), RefuseMislabeledPq);
|
||||
assert_eq!(resolve_depth(PixelFormat::Bgrx, 10), SdrDowngrade);
|
||||
assert_eq!(resolve_depth(PixelFormat::Bgrx, 8), Agreed);
|
||||
assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 10), Agreed);
|
||||
assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 10), Agreed);
|
||||
}
|
||||
|
||||
/// Main10 is pinned explicitly for 10-bit HEVC ONLY: 10-bit AV1 is input-driven (no profile
|
||||
/// knob), and every 8-bit open keeps the encoder's default profile.
|
||||
#[test]
|
||||
fn explicit_profile_is_hevc_main10_only() {
|
||||
assert_eq!(explicit_profile(Codec::H265, true), Some("main10"));
|
||||
assert_eq!(explicit_profile(Codec::H265, false), None);
|
||||
assert_eq!(explicit_profile(Codec::Av1, true), None);
|
||||
assert_eq!(explicit_profile(Codec::Av1, false), None);
|
||||
assert_eq!(explicit_profile(Codec::H264, true), None);
|
||||
assert_eq!(explicit_profile(Codec::H264, false), None);
|
||||
}
|
||||
|
||||
/// The 10-bit probe gate: HEVC and AV1 are probe-eligible; H.264 (no 10-bit path here) and
|
||||
/// PyroWave (answers 10-bit on its own path, no VAAPI involved) are refused before any device
|
||||
/// is touched — this is what keeps the probe safe on GPU-less CI.
|
||||
#[test]
|
||||
fn ten_bit_probe_gate() {
|
||||
assert!(ten_bit_probe_eligible(Codec::H265));
|
||||
assert!(ten_bit_probe_eligible(Codec::Av1));
|
||||
assert!(!ten_bit_probe_eligible(Codec::H264));
|
||||
assert!(!ten_bit_probe_eligible(Codec::PyroWave));
|
||||
}
|
||||
|
||||
/// Probe smoke on real silicon: H.264 VAAPI encode opens on every supported AMD/Intel GPU;
|
||||
/// the narrower codecs are printed, not asserted (device-dependent). Build anywhere, run on a
|
||||
/// VAAPI host:
|
||||
/// cargo test -p pf-encode --no-run
|
||||
/// <host> target/debug/deps/pf_encode-<hash> --ignored --nocapture vaapi_probe_smoke
|
||||
#[test]
|
||||
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
|
||||
fn vaapi_probe_smoke() {
|
||||
assert!(
|
||||
probe_can_encode(Codec::H264),
|
||||
"H.264 VAAPI encode should open on any supported AMD/Intel GPU"
|
||||
);
|
||||
for codec in [Codec::H265, Codec::Av1] {
|
||||
eprintln!("probe_can_encode({codec:?}) = {}", probe_can_encode(codec));
|
||||
eprintln!(
|
||||
"probe_can_encode_10bit({codec:?}) = {}",
|
||||
probe_can_encode_10bit(codec)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU-path encode round-trip on real silicon: open → BGRX frames → poll AUs → the first AU
|
||||
/// is the IDR. Exercises the swscale CSC, the VA surface upload, and the entrypoint ladder
|
||||
/// end-to-end (same recipe as [`vaapi_probe_smoke`]).
|
||||
#[test]
|
||||
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
|
||||
fn vaapi_cpu_encode_smoke() {
|
||||
let (w, h) = (256u32, 256u32);
|
||||
let mut enc = VaapiEncoder::open(
|
||||
Codec::H264,
|
||||
PixelFormat::Bgrx,
|
||||
w,
|
||||
h,
|
||||
30,
|
||||
2_000_000,
|
||||
8,
|
||||
crate::ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open");
|
||||
let mut aus = Vec::new();
|
||||
for i in 0..30u32 {
|
||||
let mut buf = vec![0u8; (w * h * 4) as usize];
|
||||
for px in buf.chunks_exact_mut(4) {
|
||||
px.copy_from_slice(&[(i * 8) as u8, 0x40, 0xC0, 0xFF]);
|
||||
}
|
||||
let frame = CapturedFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
pts_ns: u64::from(i) * 33_333_333,
|
||||
format: PixelFormat::Bgrx,
|
||||
payload: FramePayload::Cpu(buf),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit(&frame).expect("submit");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus.push(au);
|
||||
}
|
||||
}
|
||||
enc.flush().expect("flush");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus.push(au);
|
||||
}
|
||||
assert!(!aus.is_empty(), "no AUs out of 30 submitted frames");
|
||||
assert!(aus[0].keyframe, "the first AU must be the IDR");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,11 @@ fn quality_request() -> u32 {
|
||||
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the
|
||||
/// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one
|
||||
/// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2
|
||||
/// default: ON wherever the probe passes, EXCEPT sessions that may need the CSC's cursor
|
||||
/// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on
|
||||
/// cursor-blend sessions (the pointer will be missing from the stream); unset = the default.
|
||||
/// default: ON wherever the probe passes, EXCEPT sessions that need the CSC's cursor blend
|
||||
/// (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it on non-cursor
|
||||
/// sessions; unset = the default. A cursor-blend session IGNORES `=1` — EFC cannot composite
|
||||
/// the pointer, and the caps-aware negotiation promised the client a composited one, so the
|
||||
/// pointer outranks the lab pin (the open logs the override).
|
||||
///
|
||||
/// Parses like every sibling knob (`matches!(v.trim(), …)`): anything unrecognised — including
|
||||
/// an empty value and a value that is only whitespace — falls back to the default rather than
|
||||
@@ -614,10 +616,6 @@ pub struct VulkanVideoEncoder {
|
||||
/// GPU reset (those paths keep their aligned-size sources/staging).
|
||||
native_nv12: bool,
|
||||
|
||||
/// One-shot warning latch: a cursor bitmap arrived on an RGB-direct or native-NV12 session
|
||||
/// (neither has a compositing stage — the cursor will be missing from the stream until the
|
||||
/// CSC path is used).
|
||||
warned_cursor: bool,
|
||||
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
|
||||
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
|
||||
/// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into
|
||||
@@ -671,7 +669,16 @@ impl VulkanVideoEncoder {
|
||||
cursor_blend: bool,
|
||||
) -> Result<Self> {
|
||||
let native_nv12 = format == PixelFormat::Nv12;
|
||||
let want_rgb = !native_nv12 && rgb_request().unwrap_or(!cursor_blend);
|
||||
// A cursor-blend session must keep the compute-CSC path — the only arm with the cursor
|
||||
// blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the
|
||||
// negotiation promised the client a composited pointer).
|
||||
if cursor_blend && rgb_request() == Some(true) {
|
||||
tracing::info!(
|
||||
"PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — it composites the \
|
||||
pointer, which the EFC front-end cannot; using the compute-CSC path"
|
||||
);
|
||||
}
|
||||
let want_rgb = !native_nv12 && !cursor_blend && rgb_request().unwrap_or(true);
|
||||
Self::open_opts_inner(
|
||||
codec,
|
||||
width,
|
||||
@@ -1448,7 +1455,6 @@ impl VulkanVideoEncoder {
|
||||
cpu_expand: Vec::new(),
|
||||
rgb: rgb_cfg,
|
||||
native_nv12,
|
||||
warned_cursor: false,
|
||||
pending_bitrate: None,
|
||||
width: w,
|
||||
height: h,
|
||||
@@ -2621,17 +2627,10 @@ impl VulkanVideoEncoder {
|
||||
d.modifier
|
||||
);
|
||||
}
|
||||
// No compositing stage exists here (like RGB-direct/EFC): gamescope embeds its pointer
|
||||
// in the produced pixels, but any other NV12 producer's metadata cursor would be lost —
|
||||
// say so once instead of silently.
|
||||
if frame.cursor.is_some() && !self.warned_cursor {
|
||||
self.warned_cursor = true;
|
||||
tracing::warn!(
|
||||
"cursor bitmap on a native-NV12 session — nothing composites it; the cursor \
|
||||
will be missing from the stream (unset PUNKTFUNK_PIPEWIRE_NV12 for \
|
||||
metadata-cursor captures)"
|
||||
);
|
||||
}
|
||||
// No compositing stage exists here (like RGB-direct/EFC) — and none is needed: the
|
||||
// session plan negotiates native NV12 only for a non-cursor-blend session
|
||||
// (`SessionPlan::output_format` gates `nv12_native` on `!cursor_blend`), so no cursor
|
||||
// bitmap ever reaches this arm. Gamescope embeds its pointer in the produced pixels.
|
||||
let dev = self.device.clone();
|
||||
let cmd = self.frames[slot].cmd;
|
||||
let fence = self.frames[slot].fence;
|
||||
@@ -2691,16 +2690,9 @@ impl VulkanVideoEncoder {
|
||||
let query_pool = self.frames[slot].query_pool;
|
||||
let bs_buf = self.frames[slot].bs_buf;
|
||||
let ts_pool = self.frames[slot].ts_pool;
|
||||
// EFC cannot composite the cursor bitmap the metadata-cursor captures hand us — say so
|
||||
// once instead of silently losing the pointer (gamescope, the flagship, embeds it).
|
||||
if frame.cursor.is_some() && !self.warned_cursor {
|
||||
self.warned_cursor = true;
|
||||
tracing::warn!(
|
||||
"cursor bitmap on an RGB-direct session — EFC cannot composite it; the cursor \
|
||||
will be missing from the stream (unset PUNKTFUNK_VULKAN_RGB_DIRECT for \
|
||||
metadata-cursor captures)"
|
||||
);
|
||||
}
|
||||
// EFC cannot composite a cursor bitmap — and never has to: `open` refuses the RGB-direct
|
||||
// shape for a cursor-blend session (the pin override above), so no cursor bitmap ever
|
||||
// reaches this arm. Gamescope, the flagship, embeds its pointer in the produced pixels.
|
||||
let padded = self.rgb.as_ref().is_some_and(|r| r.padded);
|
||||
// Only the padded Dmabuf arm below records timestamps (via `record_pad_blit`); the
|
||||
// CPU-upload arm records its own command buffer and writes none. Default to "not written"
|
||||
@@ -4486,44 +4478,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CPU-capture source that CHANGES SIZE mid-session must not copy past the cached staging
|
||||
/// image. `ensure_cpu_rgb` keys that image on (format, width, height); when it was keyed on
|
||||
/// format alone the image kept the FIRST frame's size while `cmd_copy_buffer_to_image` used the
|
||||
/// current frame's extent, so a same-format size increase wrote out of bounds — and `submit`
|
||||
/// still returned `Ok`, so nothing upstream noticed.
|
||||
///
|
||||
/// The out-of-bounds copy is only *observable* through the Vulkan validation layers, so run this
|
||||
/// as: `VK_LOADER_LAYERS_ENABLE='*validation*' cargo test ... -- --ignored`. Confirmed on RADV
|
||||
/// PHOENIX 2026-07-25: 8 x VUID-vkCmdCopyBufferToImage-imageSubresource-07971 before the fix
|
||||
/// ("extent.width (512) exceeds imageSubresource width extent (128)"), zero after.
|
||||
/// The CSC arm REFUSES a source that doesn't match the session mode (the e3354b6d guard —
|
||||
/// the check every sibling arm always had; a clamped-texelFetch mismatch used to stream a
|
||||
/// silently cropped/edge-padded picture). This test previously DROVE mismatched sizes through
|
||||
/// the lenient arm to exercise the per-slot `cpu_img` staging across a size change (the
|
||||
/// format-only-keyed cache wrote out of bounds while `submit` returned `Ok`); the guard makes
|
||||
/// that scenario unrepresentable through `submit`, structurally retiring the hazard — the
|
||||
/// size-keyed staging from that fix stays as belt-and-braces. What's left to pin:
|
||||
/// refusal in BOTH directions, and that a refused submit does not WEDGE the session — the
|
||||
/// bail happens after step 1's frame-type bookkeeping, so "the next well-sized frame still
|
||||
/// encodes" is a real property, not a formality (the host routes the error to its
|
||||
/// encoder-rebuild path and the session must be able to continue if that path retries).
|
||||
#[test]
|
||||
#[ignore = "needs a real VK_KHR_video_encode_h265 device; meaningful only under validation layers"]
|
||||
fn vulkan_cpu_img_survives_a_source_size_change() {
|
||||
// CSC mode (rgb=false) — that is the arm where the image is sized to the SOURCE frame.
|
||||
fn vulkan_csc_refuses_a_mismatched_source() {
|
||||
// CSC mode (rgb=false) — the arm the guard covers.
|
||||
let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, 512, 512, 60, 10_000_000, false)
|
||||
.expect("open");
|
||||
// `cpu_img` is cached PER RING SLOT, so the small frames must first populate every slot;
|
||||
// only when the ring wraps does a slot holding a 128x128 image get a 512x512 copy.
|
||||
eprintln!("phase 1: 8x 128x128 — populates every ring slot with a 128x128 cpu_img");
|
||||
for i in 0..8u64 {
|
||||
enc.submit_indexed(&cpu_frame(512, 512, 0, [40, 40, 200, 255]), 0)
|
||||
.expect("well-sized baseline");
|
||||
while enc.poll().expect("poll").is_some() {}
|
||||
// Smaller AND larger both refuse — the guard is equality on the MODE (render size), not
|
||||
// a ceiling; the render-vs-CODED padding tolerance lives in the direct arms, untouched.
|
||||
let e = enc
|
||||
.submit_indexed(&cpu_frame(128, 128, 16_666_667, [200, 40, 40, 255]), 1)
|
||||
.expect_err("smaller source must refuse");
|
||||
assert!(e.to_string().contains("mismatched"), "{e:#}");
|
||||
let e = enc
|
||||
.submit_indexed(&cpu_frame(640, 640, 33_333_334, [200, 40, 40, 255]), 2)
|
||||
.expect_err("larger source must refuse");
|
||||
assert!(e.to_string().contains("mismatched"), "{e:#}");
|
||||
// The refusals must not wedge the session: a well-sized frame still encodes and an AU
|
||||
// still comes out the other end.
|
||||
let mut got_au = false;
|
||||
for i in 3..11u64 {
|
||||
enc.submit_indexed(
|
||||
&cpu_frame(128, 128, i * 16_666_667, [40, 40, 200, 255]),
|
||||
&cpu_frame(512, 512, i * 16_666_667, [40, 200, 40, 255]),
|
||||
i as u32,
|
||||
)
|
||||
.expect("submit small");
|
||||
while enc.poll().expect("poll").is_some() {}
|
||||
.expect("well-sized after refusal");
|
||||
while let Ok(Some(_)) = enc.poll() {
|
||||
got_au = true;
|
||||
}
|
||||
eprintln!("phase 2: 8x 512x512 — SAME format, so each slot REUSES its 128x128 image");
|
||||
for i in 8..16u64 {
|
||||
let r = enc.submit_indexed(
|
||||
&cpu_frame(512, 512, i * 16_666_667, [200, 40, 40, 255]),
|
||||
i as u32,
|
||||
);
|
||||
r.expect("submit after the source grew");
|
||||
while matches!(enc.poll(), Ok(Some(_))) {}
|
||||
}
|
||||
let _ = enc.flush();
|
||||
while matches!(enc.poll(), Ok(Some(_))) {}
|
||||
assert!(got_au, "no AU after the refused submits — session wedged");
|
||||
eprintln!("done — under validation layers this run must report ZERO VUID errors");
|
||||
}
|
||||
|
||||
|
||||
@@ -2014,9 +2014,6 @@ impl Encoder for AmfEncoder {
|
||||
// frame, force a later one to re-reference it). True only when the live driver accepted
|
||||
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
|
||||
supports_rfi: self.ltr_active,
|
||||
// In-band mastering/CLL via `*InHDRMetadata` (HEVC SEI / AV1 metadata OBU); AVC has
|
||||
// no such property (and no HDR sessions negotiate H.264).
|
||||
supports_hdr_metadata: self.ten_bit && self.props.hdr_metadata.is_some(),
|
||||
// Permanent: VCN hardware does not encode 4:4:4.
|
||||
chroma_444: false,
|
||||
// True only when `PUNKTFUNK_INTRA_REFRESH` asked for the wave AND the live driver
|
||||
@@ -2715,10 +2712,6 @@ mod tests {
|
||||
}
|
||||
};
|
||||
enc.set_hdr_meta(Some(sample_hdr_meta()));
|
||||
assert!(
|
||||
enc.caps().supports_hdr_metadata,
|
||||
"HEVC 10-bit reports HDR SEI capability"
|
||||
);
|
||||
let mut aus: Vec<EncodedFrame> = Vec::new();
|
||||
for i in 0..6 {
|
||||
let frame = CapturedFrame {
|
||||
|
||||
@@ -129,9 +129,13 @@ impl WinVendor {
|
||||
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
|
||||
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
|
||||
fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
||||
pf_host_config::config()
|
||||
.zerocopy
|
||||
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
||||
zerocopy_active(pf_host_config::config().zerocopy, vendor)
|
||||
}
|
||||
|
||||
/// The pure half of [`zerocopy_enabled`]: an operator override wins; unset resolves to the
|
||||
/// per-vendor default (AMF on, QSV off — see the validation status above).
|
||||
fn zerocopy_active(override_: Option<bool>, vendor: WinVendor) -> bool {
|
||||
override_.unwrap_or(matches!(vendor, WinVendor::Amf))
|
||||
}
|
||||
|
||||
/// Upper bound on `PUNKTFUNK_FFWIN_POLL_MS`. This knob spins the **encode thread** waiting for an
|
||||
@@ -164,14 +168,19 @@ const MAX_POLL_SPIN_MS: u64 = 1_000;
|
||||
fn poll_spin_cap_us() -> u64 {
|
||||
static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||
*CAP_US.get_or_init(|| {
|
||||
std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
|
||||
.unwrap_or(0) // default: no spin — the libavcodec AMF buffer can't be spun out
|
||||
parse_poll_spin_cap_us(std::env::var("PUNKTFUNK_FFWIN_POLL_MS").ok().as_deref())
|
||||
})
|
||||
}
|
||||
|
||||
/// The pure half of [`poll_spin_cap_us`]: parse, clamp to [`MAX_POLL_SPIN_MS`] BEFORE the µs
|
||||
/// conversion (the ordering the doc above proves is load-bearing), and default to 0 — no spin,
|
||||
/// the libavcodec AMF buffer can't be spun out.
|
||||
fn parse_poll_spin_cap_us(raw: Option<&str>) -> u64 {
|
||||
raw.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||
fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
Ok(match format {
|
||||
@@ -206,6 +215,86 @@ fn is_10bit_format(format: PixelFormat) -> bool {
|
||||
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||
}
|
||||
|
||||
/// Which lane the system-memory path routes a captured D3D11 format through. Device-free — the
|
||||
/// routing DECISION, split from the D3D11 copies so it is testable.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ReadbackRoute {
|
||||
/// Same-format `CopyResource` + plane-by-plane copy (NV12/P010 from the video processor).
|
||||
Yuv,
|
||||
/// BGRA staging + swscale BGRA→NV12 — the 8-bit fallback when the capturer's video
|
||||
/// processor latched off.
|
||||
Bgra,
|
||||
/// R10G10B10A2 staging + swscale X2BGR10→P010 — the HDR twin of that fallback.
|
||||
Rgb10,
|
||||
}
|
||||
|
||||
/// Route a captured format, guarding the mid-stream depth change first: the predicate matches
|
||||
/// what the encoder was built from (`ten_bit_input`), so the guard can only fire on a GENUINE
|
||||
/// depth change under the encoder — never, as it used to, on every frame of a session that
|
||||
/// merely negotiated 10-bit over an 8-bit capture (see [`is_10bit_format`]).
|
||||
fn readback_route(format: PixelFormat, ten_bit: bool) -> Result<ReadbackRoute> {
|
||||
anyhow::ensure!(
|
||||
is_10bit_format(format) == ten_bit,
|
||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||
if ten_bit { 10 } else { 8 }
|
||||
);
|
||||
Ok(match format {
|
||||
PixelFormat::Nv12 | PixelFormat::P010 => ReadbackRoute::Yuv,
|
||||
PixelFormat::Bgra | PixelFormat::Bgrx => ReadbackRoute::Bgra,
|
||||
PixelFormat::Rgb10a2 => ReadbackRoute::Rgb10,
|
||||
other => {
|
||||
bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The vendor-specific low-latency option set for [`open_win_encoder`], pure so the latency
|
||||
/// contract is pinned by tests. Unknown private options are ignored by `avcodec_open2` (left in
|
||||
/// the dict), so vendor/codec-specific keys are safe to set unconditionally.
|
||||
fn vendor_opts(vendor: WinVendor, amf_usage: &str) -> Vec<(&'static str, String)> {
|
||||
match vendor {
|
||||
WinVendor::Amf => vec![
|
||||
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
|
||||
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies
|
||||
// by VCN generation/driver — measured on-box rather than assumed.
|
||||
("usage", amf_usage.to_owned()),
|
||||
("rc", "cbr".into()),
|
||||
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
|
||||
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
|
||||
// low-latency preset choice on the NVENC path).
|
||||
("quality", "speed".into()),
|
||||
("preanalysis", "false".into()),
|
||||
("enforce_hrd", "true".into()),
|
||||
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
|
||||
("latency", "true".into()),
|
||||
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
|
||||
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
|
||||
("bf", "0".into()),
|
||||
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
|
||||
("header_insertion_mode", "idr".into()),
|
||||
],
|
||||
WinVendor::Qsv => vec![
|
||||
("preset", "veryfast".into()),
|
||||
("async_depth", "1".into()), // bound in-flight frames — the big QSV latency lever
|
||||
("low_power", "1".into()), // VDEnc fixed-function path (lower latency)
|
||||
("look_ahead", "0".into()), // (h264_qsv only; ignored on hevc/av1)
|
||||
("forced_idr", "1".into()), // a forced key frame becomes a real IDR
|
||||
("scenario", "displayremoting".into()),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind flags on the FFmpeg-allocated zero-copy pool. AMF reads it as encoder input
|
||||
/// (RENDER_TARGET + SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx
|
||||
/// surface (DECODER | VIDEO_ENCODER). The `CopySubresourceRegion` into the pool works with any
|
||||
/// usable DEFAULT-usage texture regardless.
|
||||
fn pool_bind_flags(vendor: WinVendor) -> u32 {
|
||||
match vendor {
|
||||
WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
||||
/// infinite GOP, the BT.709-limited (SDR) or BT.2020-PQ (HDR) VUI, the given `pix_fmt`, and the
|
||||
/// optional hw device/frames contexts (null for the system path). Returns the opened encoder.
|
||||
@@ -266,40 +355,11 @@ unsafe fn open_win_encoder(
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
}
|
||||
|
||||
// Low-latency tuning. Unknown private options are ignored by avcodec_open2 (left in the dict),
|
||||
// so vendor-specific keys are safe to set unconditionally.
|
||||
// Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
|
||||
let mut opts = Dictionary::new();
|
||||
match vendor {
|
||||
WinVendor::Amf => {
|
||||
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
|
||||
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies by
|
||||
// VCN generation/driver — measured on-box rather than assumed.
|
||||
let usage =
|
||||
std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
|
||||
opts.set("usage", &usage);
|
||||
opts.set("rc", "cbr");
|
||||
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
|
||||
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
|
||||
// low-latency preset choice on the NVENC path).
|
||||
opts.set("quality", "speed");
|
||||
opts.set("preanalysis", "false");
|
||||
opts.set("enforce_hrd", "true");
|
||||
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
|
||||
opts.set("latency", "true");
|
||||
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
|
||||
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
|
||||
opts.set("bf", "0");
|
||||
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
|
||||
opts.set("header_insertion_mode", "idr");
|
||||
}
|
||||
WinVendor::Qsv => {
|
||||
opts.set("preset", "veryfast");
|
||||
opts.set("async_depth", "1"); // bound in-flight frames — the big QSV latency lever
|
||||
opts.set("low_power", "1"); // VDEnc fixed-function path (lower latency)
|
||||
opts.set("look_ahead", "0"); // (h264_qsv only; ignored on hevc/av1)
|
||||
opts.set("forced_idr", "1"); // a forced key frame becomes a real IDR
|
||||
opts.set("scenario", "displayremoting");
|
||||
}
|
||||
let usage = std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
|
||||
for (k, v) in vendor_opts(vendor, &usage) {
|
||||
opts.set(k, &v);
|
||||
}
|
||||
video
|
||||
.open_with(opts)
|
||||
@@ -528,22 +588,10 @@ impl SystemInner {
|
||||
pts: i64,
|
||||
idr: bool,
|
||||
) -> Result<()> {
|
||||
// Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a
|
||||
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that
|
||||
// merely negotiated 10-bit over an 8-bit capture.
|
||||
let fmt_10 = is_10bit_format(format);
|
||||
anyhow::ensure!(
|
||||
fmt_10 == self.ten_bit,
|
||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||
if self.ten_bit { 10 } else { 8 }
|
||||
);
|
||||
match format {
|
||||
PixelFormat::Nv12 | PixelFormat::P010 => self.readback_yuv(frame, pts, idr),
|
||||
PixelFormat::Bgra | PixelFormat::Bgrx => self.readback_bgra(frame, pts, idr),
|
||||
PixelFormat::Rgb10a2 => self.readback_rgb10(frame, pts, idr),
|
||||
other => {
|
||||
bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
|
||||
}
|
||||
match readback_route(format, self.ten_bit)? {
|
||||
ReadbackRoute::Yuv => self.readback_yuv(frame, pts, idr),
|
||||
ReadbackRoute::Bgra => self.readback_bgra(frame, pts, idr),
|
||||
ReadbackRoute::Rgb10 => self.readback_rgb10(frame, pts, idr),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,14 +969,7 @@ impl ZeroCopyInner {
|
||||
} else {
|
||||
PixelFormat::Nv12
|
||||
};
|
||||
// Bind flags on the FFmpeg-allocated pool. AMF reads it as encoder input (RENDER_TARGET +
|
||||
// SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx surface
|
||||
// (DECODER | VIDEO_ENCODER). The CopySubresourceRegion into the pool works with any usable
|
||||
// DEFAULT-usage texture regardless.
|
||||
let bind_flags = match vendor {
|
||||
WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
||||
};
|
||||
let bind_flags = pool_bind_flags(vendor);
|
||||
const POOL: c_int = 8;
|
||||
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
|
||||
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
|
||||
@@ -1402,3 +1443,193 @@ impl Encoder for FfmpegWinEncoder {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Zero-copy default matrix: the operator override wins in both directions; unset resolves
|
||||
/// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the
|
||||
/// probe-never-assume rule).
|
||||
#[test]
|
||||
fn zerocopy_default_is_per_vendor_and_override_wins() {
|
||||
assert!(zerocopy_active(None, WinVendor::Amf));
|
||||
assert!(!zerocopy_active(None, WinVendor::Qsv));
|
||||
for vendor in [WinVendor::Amf, WinVendor::Qsv] {
|
||||
assert!(zerocopy_active(Some(true), vendor));
|
||||
assert!(!zerocopy_active(Some(false), vendor));
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_FFWIN_POLL_MS` grammar: default 0 (no spin), verbatim ms → µs inside the clamp,
|
||||
/// and the clamp applies BEFORE the µs conversion — the slipped-digit value that used to be a
|
||||
/// 27.7-hour spin resolves to the 1 s cap, not a wedged encode thread.
|
||||
#[test]
|
||||
fn poll_spin_cap_clamps_before_the_us_conversion() {
|
||||
assert_eq!(parse_poll_spin_cap_us(None), 0);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some("0")), 0);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some("5")), 5_000);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some(" 12 ")), 12_000);
|
||||
assert_eq!(
|
||||
parse_poll_spin_cap_us(Some("100000000")),
|
||||
MAX_POLL_SPIN_MS * 1000
|
||||
);
|
||||
assert_eq!(
|
||||
parse_poll_spin_cap_us(Some(&u64::MAX.to_string())),
|
||||
MAX_POLL_SPIN_MS * 1000
|
||||
);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some("junk")), 0);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some("-1")), 0);
|
||||
}
|
||||
|
||||
/// The swscale source map: packed RGB/BGR converts; every YUV/10-bit layout is refused (the
|
||||
/// swscale lane is the 8-bit BGRA fallback, not a general converter — the Linux HDR formats
|
||||
/// are listed explicitly so a `PixelFormat` addition re-breaks the match on purpose).
|
||||
#[test]
|
||||
fn sws_src_accepts_packed_rgb_only() {
|
||||
assert_eq!(sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
|
||||
assert_eq!(sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
|
||||
assert_eq!(sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
|
||||
assert_eq!(sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
|
||||
assert_eq!(sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
|
||||
assert_eq!(sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
|
||||
for f in [
|
||||
PixelFormat::Nv12,
|
||||
PixelFormat::P010,
|
||||
PixelFormat::Rgb10a2,
|
||||
PixelFormat::Yuv444,
|
||||
PixelFormat::X2Rgb10,
|
||||
PixelFormat::X2Bgr10,
|
||||
] {
|
||||
assert!(sws_src(f).is_err(), "{f:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// The readback routing table, and its depth guard: NV12/P010 take the plane copy, the
|
||||
/// video-processor fallbacks take their swscale lanes, a depth CHANGE under the encoder is
|
||||
/// refused (in both directions), and depth-consistent routing never trips the guard.
|
||||
#[test]
|
||||
fn readback_routing_and_depth_guard() {
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::Nv12, false).unwrap(),
|
||||
ReadbackRoute::Yuv
|
||||
);
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::P010, true).unwrap(),
|
||||
ReadbackRoute::Yuv
|
||||
);
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::Bgra, false).unwrap(),
|
||||
ReadbackRoute::Bgra
|
||||
);
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::Bgrx, false).unwrap(),
|
||||
ReadbackRoute::Bgra
|
||||
);
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::Rgb10a2, true).unwrap(),
|
||||
ReadbackRoute::Rgb10
|
||||
);
|
||||
// Mid-stream depth changes — the genuine error the guard exists for.
|
||||
assert!(readback_route(PixelFormat::P010, false).is_err());
|
||||
assert!(readback_route(PixelFormat::Rgb10a2, false).is_err());
|
||||
assert!(readback_route(PixelFormat::Nv12, true).is_err());
|
||||
assert!(readback_route(PixelFormat::Bgra, true).is_err());
|
||||
// A format neither lane can read back.
|
||||
assert!(readback_route(PixelFormat::Yuv444, false).is_err());
|
||||
}
|
||||
|
||||
/// The 10-bit predicate follows the PIXELS (P010/Rgb10a2), not the negotiated depth — see
|
||||
/// `ten_bit_input` for the forever-failing-session shape the reverse produced here.
|
||||
#[test]
|
||||
fn ten_bit_follows_the_pixels() {
|
||||
assert!(is_10bit_format(PixelFormat::P010));
|
||||
assert!(is_10bit_format(PixelFormat::Rgb10a2));
|
||||
assert!(!is_10bit_format(PixelFormat::Nv12));
|
||||
assert!(!is_10bit_format(PixelFormat::Bgra));
|
||||
assert!(!is_10bit_format(PixelFormat::Bgrx));
|
||||
}
|
||||
|
||||
/// The QSV low-latency contract, pinned: these five knobs are the difference between
|
||||
/// display-remoting latency and transcode behavior — a silent regression here changes every
|
||||
/// Intel Windows session.
|
||||
#[test]
|
||||
fn qsv_opts_pin_the_latency_contract() {
|
||||
let opts = vendor_opts(WinVendor::Qsv, "ignored");
|
||||
let get = |k: &str| {
|
||||
opts.iter()
|
||||
.find(|(key, _)| *key == k)
|
||||
.map(|(_, v)| v.as_str())
|
||||
};
|
||||
assert_eq!(get("async_depth"), Some("1"));
|
||||
assert_eq!(get("low_power"), Some("1"));
|
||||
assert_eq!(get("look_ahead"), Some("0"));
|
||||
assert_eq!(get("forced_idr"), Some("1"));
|
||||
assert_eq!(get("scenario"), Some("displayremoting"));
|
||||
assert_eq!(get("preset"), Some("veryfast"));
|
||||
assert_eq!(get("usage"), None, "AMF-only knob must not leak into QSV");
|
||||
}
|
||||
|
||||
/// The AMF (benchmark-comparator) contract: usage passes through, B-frames are pinned OFF
|
||||
/// (each one is a full frame period of latency on RDNA3+), and the low-latency submission
|
||||
/// mode + IDR header insertion are requested.
|
||||
#[test]
|
||||
fn amf_opts_pin_no_bframes_and_the_usage_passthrough() {
|
||||
let opts = vendor_opts(WinVendor::Amf, "lowlatency");
|
||||
let get = |k: &str| {
|
||||
opts.iter()
|
||||
.find(|(key, _)| *key == k)
|
||||
.map(|(_, v)| v.as_str())
|
||||
};
|
||||
assert_eq!(get("usage"), Some("lowlatency"));
|
||||
assert_eq!(get("bf"), Some("0"));
|
||||
assert_eq!(get("rc"), Some("cbr"));
|
||||
assert_eq!(get("quality"), Some("speed"));
|
||||
assert_eq!(get("latency"), Some("true"));
|
||||
assert_eq!(get("header_insertion_mode"), Some("idr"));
|
||||
assert_eq!(get("preanalysis"), Some("false"));
|
||||
assert_eq!(get("enforce_hrd"), Some("true"));
|
||||
}
|
||||
|
||||
/// The zero-copy pool's bind flags per vendor — AMF's encoder-input shape vs QSV's mfx
|
||||
/// surface shape (a wrong flag set fails `av_hwframe_ctx_init`, or worse, opens and maps
|
||||
/// wrong).
|
||||
#[test]
|
||||
fn pool_bind_flags_per_vendor() {
|
||||
assert_eq!(
|
||||
pool_bind_flags(WinVendor::Amf),
|
||||
(D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32
|
||||
);
|
||||
assert_eq!(
|
||||
pool_bind_flags(WinVendor::Qsv),
|
||||
(D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32
|
||||
);
|
||||
}
|
||||
|
||||
/// The libavcodec encoder-name dispatch (name-selected — the codec id would pick the
|
||||
/// software encoder).
|
||||
#[test]
|
||||
fn encoder_names_dispatch_by_vendor() {
|
||||
assert_eq!(WinVendor::Qsv.encoder_name(Codec::H264), "h264_qsv");
|
||||
assert_eq!(WinVendor::Qsv.encoder_name(Codec::H265), "hevc_qsv");
|
||||
assert_eq!(WinVendor::Qsv.encoder_name(Codec::Av1), "av1_qsv");
|
||||
assert_eq!(WinVendor::Amf.encoder_name(Codec::H265), "hevc_amf");
|
||||
}
|
||||
|
||||
/// Probe smoke: resolve the QSV probe on this machine without crashing — `false` on a box
|
||||
/// without the Intel runtime is a valid outcome; the value is printed, not asserted. Only
|
||||
/// compiled in the `amf-qsv`-without-`qsv` combo (the shipped combo answers via native VPL).
|
||||
/// Run on the Windows CI runner:
|
||||
/// cargo test -p pf-encode --no-default-features --features amf-qsv -- --ignored ffmpeg_win
|
||||
#[cfg(not(feature = "qsv"))]
|
||||
#[test]
|
||||
#[ignore = "needs a real FFmpeg runtime probe (run on the Windows CI runner, not a dev box)"]
|
||||
fn ffmpeg_win_probe_smoke() {
|
||||
for codec in [Codec::H264, Codec::H265, Codec::Av1] {
|
||||
eprintln!(
|
||||
"probe_can_encode(Qsv, {codec:?}) = {}",
|
||||
probe_can_encode(WinVendor::Qsv, codec)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1690,17 +1690,14 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||
// RFI is probed once at open (`rfi_supported`) — the real capability the session glue
|
||||
// routes on. (In-band HDR SEI needs no cap: it rides keyframes on HEVC/H.264 HDR
|
||||
// sessions — see `submit` — and every first-party client reads the grade out-of-band
|
||||
// via the 0xCE datagram regardless.)
|
||||
EncoderCaps {
|
||||
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
|
||||
blends_cursor: false,
|
||||
supports_rfi: self.rfi_supported,
|
||||
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
|
||||
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
|
||||
// (see `submit`); the grade still reaches punktfunk clients out-of-band via the 0xCE
|
||||
// datagram. Don't claim a capability the AV1 path doesn't have.
|
||||
supports_hdr_metadata: self.hdr && self.codec != Codec::Av1,
|
||||
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
|
||||
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
|
||||
chroma_444: self.chroma_444,
|
||||
|
||||
@@ -1471,9 +1471,6 @@ impl Encoder for QsvEncoder {
|
||||
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||
blends_cursor: false,
|
||||
supports_rfi: self.ltr_active,
|
||||
// In-band mastering/CLL at IDR (HEVC prefix SEI / AV1 metadata OBU); AVC sessions
|
||||
// are never HDR.
|
||||
supports_hdr_metadata: self.ten_bit && self.codec != Codec::H264,
|
||||
chroma_444: false,
|
||||
intra_refresh: self.ir_active,
|
||||
// Unvalidated on-glass — the host keeps the IDR recovery path until then.
|
||||
|
||||
+156
-8
@@ -203,14 +203,15 @@ pub fn open_video(
|
||||
}
|
||||
};
|
||||
// The session asked for a composited pointer; say so loudly if the backend that actually opened
|
||||
// cannot deliver one. `cursor_blend` was a REQUEST with no answer for most of this crate's life
|
||||
// (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing
|
||||
// in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path.
|
||||
//
|
||||
// A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here
|
||||
// would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is
|
||||
// the only layer that can fall back to capturer-side compositing. This makes the condition
|
||||
// visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream.
|
||||
// cannot deliver one. Since the negotiation became caps-aware ([`cursor_blend_capable`] gates
|
||||
// the cursor channel, and the session plan keeps cursor sessions off the native-NV12/RGB-direct
|
||||
// shapes), no PLANNED path reaches this: capture negotiates embedded-cursor mode wherever the
|
||||
// resolved backend can't blend. What remains reachable is the open-time divergence the plan
|
||||
// cannot see — a Vulkan Video open failing back to VAAPI mid-`open_amd_intel`, and the
|
||||
// gamescope residual (gamescope has no embedded mode, so a never-blending backend there —
|
||||
// H.264→VAAPI, software — still streams cursorless). This is the backstop that keeps those
|
||||
// honest in the logs; `open_video` cannot re-plan capture, so a warning is deliberately all
|
||||
// it does.
|
||||
if cursor_blend && !inner.caps().blends_cursor {
|
||||
tracing::warn!(
|
||||
backend,
|
||||
@@ -989,6 +990,87 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
|
||||
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
|
||||
/// delivery honestly instead of discovering a cursorless stream after the fact (the
|
||||
/// `blends_cursor` audit finding): a blend-capable backend takes cursor-as-metadata capture
|
||||
/// (pointer-free frames + host composite on demand — the cursor channel's contract); for
|
||||
/// anything else the host must have the compositor EMBED the pointer. The sibling verdict of
|
||||
/// [`linux_native_nv12_ok`], threaded into the same negotiation.
|
||||
///
|
||||
/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the
|
||||
/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the
|
||||
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session
|
||||
/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool {
|
||||
// A negotiated PyroWave session routes to that backend before the pref is consulted
|
||||
// (`open_video_backend_linux`), and its wavelet CSC composites the metadata cursor.
|
||||
if codec == Codec::PyroWave {
|
||||
return true;
|
||||
}
|
||||
let direct_nvenc = {
|
||||
#[cfg(feature = "nvenc")]
|
||||
{
|
||||
nvenc_direct_enabled()
|
||||
}
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
};
|
||||
let vulkan_csc = {
|
||||
// The compute-CSC arm — the one that blends. Eligibility mirrors `open_amd_intel`;
|
||||
// the device probe runs last (it opens a Vulkan instance, cached per GPU+codec).
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
&& vulkan_encode_available(codec)
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
};
|
||||
let backend = resolve_linux_backend(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
linux_auto_is_vaapi,
|
||||
cuda_planned,
|
||||
);
|
||||
cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc)
|
||||
}
|
||||
|
||||
/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests.
|
||||
/// `direct_nvenc` = the direct-SDK NVENC path is compiled in and enabled; `vulkan_csc` = the
|
||||
/// Vulkan Video compute-CSC arm (the one that blends) is compiled in, enabled, and
|
||||
/// device-supported for the session's codec.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cursor_blend_capable_for(
|
||||
backend: Option<LinuxBackend>,
|
||||
cuda_planned: bool,
|
||||
ten_bit: bool,
|
||||
direct_nvenc: bool,
|
||||
vulkan_csc: bool,
|
||||
) -> bool {
|
||||
match backend {
|
||||
// The wavelet CSC composites the metadata cursor (`linux/pyrowave.rs`).
|
||||
Some(LinuxBackend::Pyrowave) => true,
|
||||
// Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads —
|
||||
// a CPU-payload session stays on libav NVENC, which cannot blend.
|
||||
Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc,
|
||||
// The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav
|
||||
// VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12
|
||||
// and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`),
|
||||
// so CSC eligibility IS the answer.
|
||||
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc,
|
||||
// CPU frames: the capturer composites the metadata cursor inline before the encoder
|
||||
// runs, but the ENCODER blends nothing — the cursor channel's on-demand composite
|
||||
// contract can't be honored. Report the encoder's truth.
|
||||
Some(LinuxBackend::Software) | None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
||||
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
||||
///
|
||||
@@ -1754,6 +1836,72 @@ mod tests {
|
||||
assert_eq!(none.wire_mask(), None);
|
||||
}
|
||||
|
||||
/// The cursor-blend capability mirror, arm by arm — the table the caps-aware negotiation
|
||||
/// (cursor channel grant, metadata-vs-embedded capture) stands on. Each row names the arm's
|
||||
/// blending stage or the reason there is none.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn cursor_blend_capability_mirrors_the_dispatch() {
|
||||
use LinuxBackend::*;
|
||||
// PyroWave: the wavelet CSC composites, always.
|
||||
assert!(cursor_blend_capable_for(
|
||||
Some(Pyrowave),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
));
|
||||
// NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads.
|
||||
assert!(cursor_blend_capable_for(
|
||||
Some(Nvenc),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
));
|
||||
assert!(
|
||||
!cursor_blend_capable_for(Some(Nvenc), false, false, true, false),
|
||||
"a CPU payload stays on libav NVENC, which cannot blend"
|
||||
);
|
||||
assert!(
|
||||
!cursor_blend_capable_for(Some(Nvenc), true, false, false, false),
|
||||
"PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path"
|
||||
);
|
||||
// AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does.
|
||||
assert!(cursor_blend_capable_for(
|
||||
Some(AmdIntel),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(
|
||||
!cursor_blend_capable_for(Some(AmdIntel), false, false, false, false),
|
||||
"no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \
|
||||
device) resolves to libav VAAPI, which cannot blend"
|
||||
);
|
||||
assert!(
|
||||
!cursor_blend_capable_for(Some(AmdIntel), false, true, false, true),
|
||||
"a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend"
|
||||
);
|
||||
assert!(cursor_blend_capable_for(
|
||||
Some(Vulkan),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
));
|
||||
// Software / unknown pref: CPU frames; the encoder blends nothing.
|
||||
assert!(!cursor_blend_capable_for(
|
||||
Some(Software),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!cursor_blend_capable_for(None, false, false, true, true));
|
||||
}
|
||||
|
||||
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
||||
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
|
||||
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
|
||||
|
||||
@@ -34,6 +34,28 @@
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Whether a `PUNKTFUNK_*` env var reads as ON, or `None` when it is unset — the host's
|
||||
/// **explicit-off** grammar: `0` / `false` / `off` / `no` (trimmed, case-insensitive) are off and ANY
|
||||
/// other value is on, so a presence-style `=1` keeps working. Every "default ON" knob below shares
|
||||
/// it.
|
||||
///
|
||||
/// Exported because callers in other crates need the SAME grammar. A hand-rolled
|
||||
/// `var(k).as_deref() != Ok("0")` accepts `"0 "` (trailing space, trivially produced by a systemd
|
||||
/// drop-in or a shell heredoc) and `"false"` as ON — the bug class of ed525c4c, and the reason
|
||||
/// `PUNKTFUNK_PIPEWIRE_NV12` in pf-capture now routes through here.
|
||||
///
|
||||
/// Note this is deliberately NOT the grammar `pf-zerocopy` uses for its own flags (truthy:
|
||||
/// `1|true|yes|on`, everything else off) — see the module docs: independent features that share a
|
||||
/// name prefix.
|
||||
pub fn env_on(name: &str) -> Option<bool> {
|
||||
std::env::var(name).ok().map(|s| {
|
||||
!matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
||||
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||
@@ -128,42 +150,16 @@ impl HostConfig {
|
||||
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(2),
|
||||
zerocopy: val("PUNKTFUNK_ZEROCOPY").map(|s| {
|
||||
!matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
}),
|
||||
zerocopy: env_on("PUNKTFUNK_ZEROCOPY"),
|
||||
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting
|
||||
// is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
|
||||
ten_bit: val("PUNKTFUNK_10BIT")
|
||||
.map(|s| {
|
||||
!matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true),
|
||||
ten_bit: env_on("PUNKTFUNK_10BIT").unwrap_or(true),
|
||||
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
|
||||
// is the real switch; see the field doc).
|
||||
four_four_four: val("PUNKTFUNK_444")
|
||||
.map(|s| {
|
||||
!matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true),
|
||||
four_four_four: env_on("PUNKTFUNK_444").unwrap_or(true),
|
||||
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
|
||||
// per-session switch; see the field doc).
|
||||
chacha20: val("PUNKTFUNK_CHACHA20")
|
||||
.map(|s| {
|
||||
!matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true),
|
||||
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
|
||||
@@ -36,17 +36,6 @@ fn flag(name: &str) -> bool {
|
||||
flag_opt(name).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// One-shot downgrade latch: a VAAPI-passthrough capture whose dmabuf-only offer never negotiated
|
||||
/// (the compositor can't allocate a LINEAR BGRx dmabuf) flips this, so the encode loop's pipeline
|
||||
/// rebuild lands on the CPU offer instead of failing the same negotiation forever. Only consulted
|
||||
/// when `PUNKTFUNK_ZEROCOPY` is unset — an explicit `=1` keeps forcing the dmabuf offer.
|
||||
static VAAPI_DMABUF_FAILED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Record that the VAAPI LINEAR-dmabuf offer failed negotiation (see [`VAAPI_DMABUF_FAILED`]).
|
||||
pub fn note_vaapi_dmabuf_failed() {
|
||||
VAAPI_DMABUF_FAILED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// True when `PUNKTFUNK_ZEROCOPY` is explicitly truthy — the operator forced the dmabuf offer, so
|
||||
/// a failed negotiation keeps erroring loudly instead of silently downgrading to the CPU path.
|
||||
pub fn vaapi_dmabuf_forced() -> bool {
|
||||
@@ -64,11 +53,16 @@ pub fn vaapi_dmabuf_forced() -> bool {
|
||||
/// capture when no importer/importable modifier is available and latches the import off after
|
||||
/// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the
|
||||
/// race-free SHM path.
|
||||
///
|
||||
/// This is the GLOBAL switch and nothing but the env var moves it. It used to fall back to
|
||||
/// `!VAAPI_DMABUF_FAILED`, a latch a capture-side dmabuf *negotiation* timeout set — which made one
|
||||
/// failed LINEAR-dmabuf negotiation disable zero-copy for EVERY later session on the host,
|
||||
/// including the NVENC EGL→CUDA path that shares none of the failing machinery (and, because the
|
||||
/// raw-passthrough offer is also taken for PyroWave sessions on any vendor, one PyroWave timeout on
|
||||
/// an NVIDIA box did it). That downgrade now uses the correctly-scoped
|
||||
/// [`note_raw_dmabuf_negotiation_failed`], which gates only the raw-passthrough offer.
|
||||
pub fn enabled() -> bool {
|
||||
match flag_opt("PUNKTFUNK_ZEROCOPY") {
|
||||
Some(v) => v,
|
||||
None => !VAAPI_DMABUF_FAILED.load(Ordering::Relaxed),
|
||||
}
|
||||
flag_opt("PUNKTFUNK_ZEROCOPY").unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether the tiled-GL zero-copy path converts to NV12 on the GPU and feeds NVENC native YUV —
|
||||
@@ -268,6 +262,27 @@ pub fn note_raw_dmabuf_import_ok() {
|
||||
RAW_DMABUF_FAILURE_STREAK.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Latch the raw-dmabuf passthrough off because its dmabuf-only *offer never negotiated* — the
|
||||
/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak. One
|
||||
/// timeout is conclusive for this offer (a compositor that cannot allocate the requested
|
||||
/// LINEAR/modifier BGRx dmabuf refuses it identically on every retry), so there is no streak to
|
||||
/// count: the next capture skips the passthrough and negotiates SHM/CPU instead of re-running the
|
||||
/// same 10 s timeout on every reconnect.
|
||||
///
|
||||
/// Scoped deliberately. This used to be `note_vaapi_dmabuf_failed`, which fed [`enabled`] and so
|
||||
/// disabled ALL zero-copy host-wide — see [`enabled`]. `RAW_DMABUF_DISABLED` gates only the
|
||||
/// raw-passthrough decision, so the EGL→CUDA importer that a later NVENC session builds is
|
||||
/// untouched.
|
||||
pub fn note_raw_dmabuf_negotiation_failed() {
|
||||
if !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"zero-copy raw-dmabuf passthrough disabled for this host process: the compositor never \
|
||||
accepted the dmabuf-only capture offer, so later captures negotiate the CPU path \
|
||||
instead of repeating that timeout (the EGL→CUDA import path is NOT affected)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
|
||||
/// [`note_raw_dmabuf_import_failure`]).
|
||||
pub fn raw_dmabuf_import_disabled() -> bool {
|
||||
|
||||
@@ -36,8 +36,13 @@ pub(crate) const HOST_TIMING_QUEUE: usize = 512;
|
||||
pub(crate) const CLIP_EVENT_QUEUE: usize = 32;
|
||||
|
||||
/// Cursor-shape plane depth (control-stream [`crate::quic::CursorShape`], one per pointer-bitmap
|
||||
/// change — human-paced). Overflow drops the newest (try_send); the next shape change or a
|
||||
/// serial mismatch against `0xD0` state heals it visually within a shape-change period.
|
||||
/// change — human-paced, but bursty: crossing a toolbar flips arrow/I-beam/hand/resize several
|
||||
/// times a second, and every flip mints a fresh serial and a fresh bitmap). Overflow drops the
|
||||
/// newest (try_send) and the host does NOT re-send it — it only sends on a serial CHANGE — so the
|
||||
/// dropped serial stays un-backed until the pointer changes shape again. Embedders must therefore
|
||||
/// hold their last worn shape when `hostCursors[serial]` misses rather than hiding the pointer
|
||||
/// (the Apple client's `lastWornShape`); healing is bounded by the next shape change, not by this
|
||||
/// ring.
|
||||
pub(crate) const CURSOR_SHAPE_QUEUE: usize = 8;
|
||||
|
||||
/// Cursor-state plane depth (`0xD0`, one datagram per captured frame). Latest-wins state — the
|
||||
|
||||
@@ -16,7 +16,7 @@ pub use pf_capture::{
|
||||
capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer,
|
||||
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.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use pf_capture::{dxgi, synthetic_nv12};
|
||||
@@ -66,8 +66,14 @@ fn zero_copy_policy(
|
||||
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr`
|
||||
/// offers the GNOME 50+ 10-bit PQ/BT.2020 formats (pass it only when the session negotiated HDR
|
||||
/// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]).
|
||||
/// `want_metadata_cursor` asks for cursor-as-metadata — pass it only when the session's encode
|
||||
/// backend composites `CapturedFrame::cursor` (`encode::cursor_blend_capable`); otherwise the
|
||||
/// portal embeds the pointer, so no backend × cursor-mode combination streams cursorless.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
||||
pub fn open_portal_monitor(
|
||||
want_hdr: bool,
|
||||
want_metadata_cursor: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
|
||||
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
|
||||
// so use a plain ScreenCast session there.
|
||||
@@ -76,11 +82,19 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
||||
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
|
||||
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
|
||||
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
|
||||
pf_capture::open_portal_monitor(
|
||||
anchored,
|
||||
want_hdr,
|
||||
want_metadata_cursor,
|
||||
zero_copy_policy(false, false),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn open_portal_monitor(_want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
||||
pub fn open_portal_monitor(
|
||||
_want_hdr: bool,
|
||||
_want_metadata_cursor: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
||||
}
|
||||
|
||||
|
||||
@@ -450,6 +450,25 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
);
|
||||
hdr = false;
|
||||
}
|
||||
// Second half of the same live gate: the process-wide HDR-capture latch. The colour-mode probe
|
||||
// above only says the monitor is in BT.2100 RIGHT NOW — it says nothing about whether the
|
||||
// portal will actually hand us the 10-bit PQ formats. Once a `want_hdr` negotiation has failed
|
||||
// (the monitor left HDR mode between probe and negotiation, NVIDIA EGL not listing LINEAR for
|
||||
// XR30, a pre-50 Mutter), `pf_capture::open_portal_monitor` permanently drops the HDR OFFER and
|
||||
// captures SDR. Without this check we'd keep telling the client HDR while capturing and
|
||||
// encoding SDR: it renders an SDR picture through PQ — washed out, wrong gamut, no error
|
||||
// anywhere — and because the latch is sticky until host restart, every reconnect repeats it.
|
||||
// Consulted here rather than folded into `host_hdr_capable` for the same reason as the probe
|
||||
// above: that fn is the STATIC serverinfo capability, and this is a live per-session fact.
|
||||
#[cfg(target_os = "linux")]
|
||||
if hdr && pf_capture::hdr_capture_failed() {
|
||||
tracing::warn!(
|
||||
"client requested HDR and a monitor is in BT.2100 (HDR) colour mode, but an earlier \
|
||||
HDR capture negotiation on this host failed — the capturer offers SDR only for the \
|
||||
rest of the process lifetime, so streaming 8-bit SDR (restart the host to retry HDR)"
|
||||
);
|
||||
hdr = false;
|
||||
}
|
||||
// The client's requested CSC (moonlight-common-c SdpGenerator.c: `encoderCscMode =
|
||||
// (colorspace << 1) | fullRange` — colorspace 0=Rec601, 1=Rec709, 2=Rec2020). Moonlight
|
||||
// renderers configure their YUV→RGB from this REQUESTED value (not the bitstream VUI), so a
|
||||
|
||||
@@ -33,10 +33,14 @@ pub struct StreamConfig {
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
/// A pooled capturer plus the two PipeWire-negotiation-time properties reuse must match on —
|
||||
/// its HDR-ness and its metadata-cursor mode; a mismatch on either needs a fresh screencast
|
||||
/// session (see `AppState::video_cap`).
|
||||
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool);
|
||||
|
||||
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
||||
/// streams so a reconnect doesn't open a second (conflicting) screencast session. The `bool` is
|
||||
/// the pooled capturer's HDR-ness (see `AppState::video_cap`).
|
||||
pub type CapturerSlot = Arc<std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>>;
|
||||
/// streams so a reconnect doesn't open a second (conflicting) screencast session.
|
||||
pub type CapturerSlot = Arc<std::sync::Mutex<Option<PooledCapturer>>>;
|
||||
|
||||
/// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the
|
||||
/// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
|
||||
@@ -136,7 +140,7 @@ fn run(
|
||||
running: &Arc<AtomicBool>,
|
||||
force_idr: &AtomicBool,
|
||||
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
||||
video_cap: &std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>,
|
||||
video_cap: &std::sync::Mutex<Option<PooledCapturer>>,
|
||||
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
||||
// encode loop); per-frame sample emission is wired by a later pass.
|
||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||
@@ -250,6 +254,12 @@ fn run(
|
||||
return stream_body(
|
||||
&mut capturer,
|
||||
Some(&rebuild),
|
||||
// The virtual-output source never selects cursor-as-metadata (`set_hw_cursor` is
|
||||
// never called → the compositor EMBEDS the pointer where it can), so the encoder
|
||||
// is handed nothing to composite. gamescope remains the pointerless residual —
|
||||
// its capture carries no cursor either way (the native plane's XFixes source is
|
||||
// not wired on this plane).
|
||||
false,
|
||||
&sock,
|
||||
cfg,
|
||||
running,
|
||||
@@ -266,13 +276,34 @@ fn run(
|
||||
// pooled capturer's HDR-ness matching this stream's negotiated `cfg.hdr` — the depth is a
|
||||
// PipeWire-negotiation-time property of the screencast session, so an HDR↔SDR change needs a
|
||||
// fresh session (same pattern as the audio capturer's channel-count gate).
|
||||
// Cursor-as-metadata only where the encode backend this session resolves to composites
|
||||
// `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask
|
||||
// the portal to EMBED the pointer so no backend × cursor-mode combination streams
|
||||
// cursorless. Synthetic frames carry no pointer either way.
|
||||
let metadata_cursor = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Same CUDA-payload prediction SessionPlan/`handshake::cursor_forward` make:
|
||||
// the NVIDIA resolution plus the zero-copy master switch.
|
||||
let cuda_planned =
|
||||
!crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||
crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
false
|
||||
};
|
||||
let pooled = match video_cap.lock().unwrap().take() {
|
||||
Some((c, was_hdr)) if was_hdr == cfg.hdr => Some(c),
|
||||
Some((c, was_hdr)) => {
|
||||
Some((c, was_hdr, was_meta)) if was_hdr == cfg.hdr && was_meta == metadata_cursor => {
|
||||
Some(c)
|
||||
}
|
||||
Some((c, was_hdr, was_meta)) => {
|
||||
tracing::info!(
|
||||
was_hdr,
|
||||
want_hdr = cfg.hdr,
|
||||
"video source: pooled capturer depth mismatch — opening a fresh screencast session"
|
||||
was_metadata_cursor = was_meta,
|
||||
want_metadata_cursor = metadata_cursor,
|
||||
"video source: pooled capturer depth/cursor-mode mismatch — opening a fresh \
|
||||
screencast session"
|
||||
);
|
||||
drop(c);
|
||||
None
|
||||
@@ -285,8 +316,13 @@ fn run(
|
||||
c
|
||||
}
|
||||
None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
|
||||
tracing::info!(hdr = cfg.hdr, "video source: portal desktop capture");
|
||||
capture::open_portal_monitor(cfg.hdr).context("open portal capturer")?
|
||||
tracing::info!(
|
||||
hdr = cfg.hdr,
|
||||
metadata_cursor,
|
||||
"video source: portal desktop capture"
|
||||
);
|
||||
capture::open_portal_monitor(cfg.hdr, metadata_cursor)
|
||||
.context("open portal capturer")?
|
||||
}
|
||||
None => {
|
||||
tracing::info!("video source: synthetic test pattern");
|
||||
@@ -298,6 +334,7 @@ fn run(
|
||||
let result = stream_body(
|
||||
&mut capturer,
|
||||
None,
|
||||
metadata_cursor,
|
||||
&sock,
|
||||
cfg,
|
||||
running,
|
||||
@@ -308,7 +345,24 @@ fn run(
|
||||
on_lost,
|
||||
);
|
||||
capturer.set_active(false);
|
||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr));
|
||||
// Re-pool ONLY a capturer that can still produce frames. Every terminal state of the portal
|
||||
// backend is sticky (`Capturer::is_alive`): a dead zerocopy-import worker, an exited PipeWire
|
||||
// thread, or a compositor that went away all make the NEXT stream fail at exactly the same
|
||||
// point — and this path has no rebuild closure (unlike the virtual-output path above), so a
|
||||
// re-admitted dead capturer wedged GameStream portal video permanently, at 10 s per reconnect
|
||||
// attempt. Dropping it instead costs one fresh screencast session on the next connect. Note
|
||||
// `result` may already be `Err` here, which is itself that signal. (`metadata_cursor` rides
|
||||
// along as the second reuse key, beside HDR-ness — see `PooledCapturer`.)
|
||||
if result.is_ok() && capturer.is_alive() {
|
||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor));
|
||||
} else {
|
||||
tracing::info!(
|
||||
stream_failed = result.is_err(),
|
||||
capturer_alive = capturer.is_alive(),
|
||||
"video source: retiring the pooled capturer — the next stream opens a fresh screencast \
|
||||
session"
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -420,7 +474,7 @@ fn open_gs_virtual_source(
|
||||
// capturer follows the display). No-op on Linux: virtual-output capture is SDR-only upstream
|
||||
// (Mutter RecordVirtual), and `host_hdr_capable` therefore keeps `cfg.hdr` false for this
|
||||
// source — the Linux HDR path is the portal monitor mirror (`video_source=portal`).
|
||||
let capturer = capture::capture_virtual_output(
|
||||
let mut capturer = capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
@@ -646,6 +700,10 @@ fn stream_body(
|
||||
// Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch);
|
||||
// `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
|
||||
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>,
|
||||
// The capture hands the encoder cursor bitmaps to composite (cursor-as-metadata negotiated
|
||||
// because the resolved backend blends — see the callers). `false` = the pointer is embedded
|
||||
// in the pixels (or absent), so the encoder is asked to composite nothing.
|
||||
cursor_blend: bool,
|
||||
sock: &UdpSocket,
|
||||
cfg: StreamConfig,
|
||||
running: &Arc<AtomicBool>,
|
||||
@@ -681,9 +739,9 @@ fn stream_body(
|
||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
||||
encode::ChromaFormat::Yuv420,
|
||||
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder
|
||||
// may be handed cursor bitmaps to composite.
|
||||
true,
|
||||
// True only when THIS session's capture negotiated cursor-as-metadata — which the
|
||||
// callers grant only where the resolved backend composites (`cursor_blend_capable`).
|
||||
cursor_blend,
|
||||
)
|
||||
.context("open video encoder for stream")?;
|
||||
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
|
||||
@@ -855,7 +913,7 @@ fn stream_body(
|
||||
frame.is_cuda(),
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||
true, // metadata-cursor capture — see the first open
|
||||
cursor_blend, // same capture cursor mode — see the first open
|
||||
)
|
||||
.context("reopen encoder after rebuild")?;
|
||||
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||
|
||||
@@ -338,7 +338,15 @@ fn real_main() -> Result<()> {
|
||||
// adapter, which may not be the one that encodes).
|
||||
let mut size = (64u32, 64u32);
|
||||
let mut vendor = None;
|
||||
for a in args.iter().skip(2) {
|
||||
// `args` starts AT the subcommand (main builds it with `env::args().skip(1)`), so the
|
||||
// optional arguments begin at index 1 — cf. `args.get(1)` in the `service` arm. This
|
||||
// read `skip(2)` and so silently swallowed the first optional argument: the documented
|
||||
// `hdr-p010-selftest 1920x1080 nvidia` picked up the vendor but ran at the 64x64
|
||||
// DEFAULT, and a size-only invocation parsed nothing at all and still printed PASS.
|
||||
// The size is the whole point of the flag — 1080 is not 16-aligned and takes a
|
||||
// different driver path — so the self-test was passing on the one geometry that
|
||||
// exercises the least.
|
||||
for a in args.iter().skip(1) {
|
||||
match a.as_str() {
|
||||
"intel" => vendor = Some(0x8086),
|
||||
"nvidia" => vendor = Some(0x10de),
|
||||
|
||||
@@ -1012,9 +1012,10 @@ async fn serve_session(
|
||||
// just never fires then.
|
||||
let (cursor_shape_tx, cursor_shape_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
|
||||
// Negotiated cursor forwarding: MUST match the HOST_CAP_CURSOR bit the Welcome advertised
|
||||
// (handshake::cursor_forward is the single predicate both read).
|
||||
let cursor_forward = handshake::cursor_forward(hello.client_caps, compositor);
|
||||
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
|
||||
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
|
||||
// blend-capability gate — re-running it here could drift, and would re-probe).
|
||||
let cursor_forward = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
|
||||
// Who renders the pointer RIGHT NOW (client `CursorRenderMode`, flipped live by the mouse-
|
||||
// model chord): `true` = client draws (exclude + forward), `false` = host composites (the
|
||||
// capture model). Starts true — the pre-message behavior for cap sessions. Control task
|
||||
|
||||
@@ -12,31 +12,46 @@ use super::*;
|
||||
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the
|
||||
/// capture path can deliver cursor metadata separately from the frame — the Linux portal
|
||||
/// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows
|
||||
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c). THE single
|
||||
/// predicate: the Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off
|
||||
/// wiring both read it, so they can never disagree.
|
||||
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c) — AND, on
|
||||
/// Linux, the encode backend this session resolves to can composite the pointer on demand
|
||||
/// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`,
|
||||
/// `client_draws = false`) makes the HOST draw the pointer, and on Linux the encoder is that
|
||||
/// compositing stage — granting the channel over a backend that can't blend (libav
|
||||
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied, the
|
||||
/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never
|
||||
/// draws — never cursorless, never doubled. THE single predicate: the Welcome's
|
||||
/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back.
|
||||
pub(super) fn cursor_forward(
|
||||
client_caps: u8,
|
||||
compositor: Option<crate::vdisplay::Compositor>,
|
||||
codec: crate::encode::Codec,
|
||||
bit_depth: u8,
|
||||
) -> bool {
|
||||
if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 {
|
||||
return false;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// CUDA-payload prediction — the same one `SessionPlan` makes: the NVIDIA resolution
|
||||
// plus the zero-copy master switch. It decides direct-SDK NVENC (blends) vs libav
|
||||
// NVENC (doesn't) inside the capability mirror.
|
||||
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||
compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope)
|
||||
&& crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel —
|
||||
// DWM composites the pointer into the IDD frame otherwise, and forwarding a second
|
||||
// copy would double it. The probe latches by opening the control device once.
|
||||
let _ = compositor;
|
||||
// copy would double it. The probe latches by opening the control device once. The
|
||||
// encoder is deliberately NOT consulted: the IDD capturer itself composites on the
|
||||
// capture-mouse flip (`set_cursor_forward`), so no Windows encode backend blends.
|
||||
let _ = (compositor, codec, bit_depth);
|
||||
crate::vdisplay::manager::hw_cursor_capable()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
let _ = compositor;
|
||||
let _ = (compositor, codec, bit_depth);
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -252,6 +267,12 @@ pub(super) async fn negotiate(
|
||||
// *monitor* streams only — the GameStream portal-mirror path uses that; see
|
||||
// `gamestream::host_hdr_capable`), so a Linux native session honestly stays 8-bit SDR even
|
||||
// though `can_encode_10bit` now probes true on a Main10-capable GPU.
|
||||
//
|
||||
// That `false` is also why this plane needs no equivalent of the GameStream path's
|
||||
// `pf_capture::hdr_capture_failed()` check (rtsp.rs): that latch is a fact about the PORTAL
|
||||
// capturer's HDR offer, and the native plane captures a virtual output instead — it never
|
||||
// reaches the portal path, and on Linux it never negotiates 10-bit at all. Revisit both halves
|
||||
// together if Mutter ever gains HDR for RecordVirtual streams.
|
||||
let capture_supports_hdr = crate::capture::capturer_supports_hdr();
|
||||
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
|
||||
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
|
||||
@@ -291,9 +312,14 @@ pub(super) async fn negotiate(
|
||||
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;
|
||||
// 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
|
||||
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
|
||||
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
|
||||
// gate. Linux's portal capturer always can (`capturer_supports_444` returns `true`
|
||||
// unconditionally). On WINDOWS the IDD-push path CAN too — for an SDR 4:4:4 session it passes
|
||||
// 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.)
|
||||
// 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
|
||||
@@ -488,9 +514,10 @@ pub(super) async fn negotiate(
|
||||
0
|
||||
}
|
||||
// Cursor channel granted (client asked + this capture path can deliver cursor
|
||||
// metadata out of the frame) — the client turns its local renderer on ONLY when
|
||||
// it sees this bit, and serve_session wires forwarding from the same predicate.
|
||||
| if cursor_forward(hello.client_caps, compositor) {
|
||||
// metadata out of the frame + the resolved encoder can composite on the
|
||||
// capture-mouse flip) — the client turns its local renderer on ONLY when it sees
|
||||
// this bit, and serve_session wires forwarding by reading the bit back.
|
||||
| if cursor_forward(hello.client_caps, compositor, codec, bit_depth) {
|
||||
punktfunk_core::quic::HOST_CAP_CURSOR
|
||||
} else {
|
||||
0
|
||||
@@ -536,9 +563,9 @@ pub(super) async fn negotiate(
|
||||
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
|
||||
let client_identity = endpoint::peer_fingerprint(conn);
|
||||
let client_hdr = hello.display_hdr;
|
||||
// Same predicate the Welcome's HOST_CAP_CURSOR bit used — the prepared display and
|
||||
// the session wiring must agree with what we just advertised.
|
||||
let cursor_fw = cursor_forward(hello.client_caps, Some(comp));
|
||||
// The bit the Welcome just advertised — read back rather than recomputed, so the
|
||||
// prepared display and the session wiring cannot disagree with it.
|
||||
let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
|
||||
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
|
||||
let trace = bringup.clone();
|
||||
std::thread::Builder::new()
|
||||
|
||||
@@ -1022,8 +1022,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
||||
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
||||
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
||||
// the blend must be built for every gamescope session.
|
||||
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward,
|
||||
// the blend must be built for every gamescope session. (`cursor_forward` is already
|
||||
// blend-gated: `handshake::cursor_forward` grants the channel only where
|
||||
// `encode::cursor_blend_capable` says the resolved backend composites.)
|
||||
crate::session_plan::cursor_blend_for(
|
||||
ctx.cursor_forward,
|
||||
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
|
||||
),
|
||||
ctx.cursor_forward,
|
||||
);
|
||||
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
|
||||
@@ -2098,8 +2103,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// capture-mode channel); a switch AWAY restores the prior
|
||||
// gating. `plan` is `Copy` — this is the value the rebuild
|
||||
// (and its `build_pipeline` attach) reads.
|
||||
plan.cursor_blend = plan.cursor_forward
|
||||
|| c == crate::vdisplay::Compositor::Gamescope;
|
||||
plan.cursor_blend = crate::session_plan::cursor_blend_for(
|
||||
plan.cursor_forward,
|
||||
c == crate::vdisplay::Compositor::Gamescope,
|
||||
);
|
||||
plan.gamescope_cursor =
|
||||
c == crate::vdisplay::Compositor::Gamescope;
|
||||
gamescope_composite =
|
||||
@@ -2987,7 +2994,10 @@ pub(super) fn prepare_display(
|
||||
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
||||
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
||||
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
||||
compositor == pf_vdisplay::Compositor::Gamescope || cursor_forward,
|
||||
crate::session_plan::cursor_blend_for(
|
||||
cursor_forward,
|
||||
compositor == pf_vdisplay::Compositor::Gamescope,
|
||||
),
|
||||
cursor_forward,
|
||||
);
|
||||
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
|
||||
@@ -3248,20 +3258,23 @@ fn build_pipeline(
|
||||
let mut capturer =
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer gamescope's
|
||||
// nested Xwayland — it reads the pointer over X11 (XFixes shape + QueryPointer position) and
|
||||
// feeds `cursor()`, which the encode loop composites. A failed discovery/connect leaves the
|
||||
// stream cursorless (today's behaviour); non-gamescope plans skip this entirely.
|
||||
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
|
||||
// reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
|
||||
// QueryPointer position) and feeds `cursor()`, which the encode loop composites.
|
||||
// Non-gamescope plans skip this entirely.
|
||||
//
|
||||
// A PROVIDER, not the discovered list: gamescope creates the game's Xwayland when the game
|
||||
// launches and advertises only the FIRST in any child's environ, so a list captured here misses
|
||||
// it — and the cursor source would then blank the pointer for the whole game session (it asks
|
||||
// the connected display "are you drawing the pointer?" and gets "no"). The source re-runs this
|
||||
// every couple of seconds, so a stream that starts before the game converges, and a display
|
||||
// that dies is retried. Same one-way-edge shape as the Windows channel senders: the closure
|
||||
// wraps the host's discovery, and pf-capture never reaches back into pf-vdisplay.
|
||||
#[cfg(target_os = "linux")]
|
||||
if plan.gamescope_cursor {
|
||||
let targets = pf_vdisplay::gamescope_xwayland_cursor_targets();
|
||||
if targets.is_empty() {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: no nested Xwayland discovered — no in-video pointer this session"
|
||||
);
|
||||
} else {
|
||||
capturer.attach_gamescope_cursor(targets);
|
||||
}
|
||||
capturer.attach_gamescope_cursor(std::sync::Arc::new(
|
||||
pf_vdisplay::gamescope_xwayland_cursor_targets,
|
||||
));
|
||||
}
|
||||
if let Some(t) = trace {
|
||||
t.mark("capture_attached");
|
||||
|
||||
@@ -104,9 +104,13 @@ pub struct SessionPlan {
|
||||
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
|
||||
pub wire_chunk: Option<usize>,
|
||||
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
|
||||
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself).
|
||||
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
|
||||
/// blending path when this is set, so the pointer never silently vanishes from the stream.
|
||||
/// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
|
||||
/// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
|
||||
/// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders
|
||||
/// whose fast path cannot blend (the Vulkan EFC RGB-direct source, native NV12) stay off
|
||||
/// those shapes when this is set — see [`Self::output_format`] and
|
||||
/// `encode::cursor_blend_capable`, the pre-open mirror that gates the cursor channel — so
|
||||
/// the pointer never silently vanishes from the stream.
|
||||
pub cursor_blend: bool,
|
||||
/// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer
|
||||
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
|
||||
@@ -198,13 +202,15 @@ impl SessionPlan {
|
||||
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
||||
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
||||
// into encode (the same one-way edge as `gpu` above). BUT the native-NV12 encode path
|
||||
// has no CSC stage to fold the cursor into (it assumes gamescope embeds its pointer,
|
||||
// which it does NOT into the PipeWire node) — so a gamescope-cursor session (Phase C)
|
||||
// must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend that draws
|
||||
// `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the native-NV12 cursor
|
||||
// blend is the perf-preserving follow-up.
|
||||
// has no CSC stage to fold the cursor into — so ANY cursor-compositing session
|
||||
// (gamescope Phase C, whose XFixes pointer is absent from the PipeWire node, AND a
|
||||
// cursor-forward session, whose capture-mouse flip needs the host composite on
|
||||
// demand) must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend
|
||||
// that draws `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the
|
||||
// native-NV12 cursor blend is the perf-preserving follow-up. (`cursor_blend`
|
||||
// subsumes `gamescope_cursor` — see [`cursor_blend_for`].)
|
||||
#[cfg(target_os = "linux")]
|
||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.gamescope_cursor,
|
||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.cursor_blend,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
nv12_native: false,
|
||||
}
|
||||
@@ -217,6 +223,26 @@ pub(crate) fn resolve_topology() -> SessionTopology {
|
||||
SessionTopology::SingleProcess
|
||||
}
|
||||
|
||||
/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and
|
||||
/// the mid-stream compositor re-gate) so they can't drift:
|
||||
/// * **Linux**: the encoder is the compositing stage — blend for a cursor-forward session (the
|
||||
/// capture-mouse flip needs the host composite on demand) and for gamescope (its capture
|
||||
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video).
|
||||
/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` /
|
||||
/// DWM), and no Windows encode backend reads `frame.cursor`. Asking the encoder anyway made
|
||||
/// `open_video`'s blends-cursor backstop fire spuriously on every cursor-channel session.
|
||||
pub(crate) fn cursor_blend_for(cursor_forward: bool, gamescope: bool) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = (cursor_forward, gamescope);
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
cursor_forward || gamescope
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_encoder() -> EncoderBackend {
|
||||
match crate::encode::windows_resolved_backend() {
|
||||
|
||||
@@ -94,7 +94,9 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
want_hdr,
|
||||
"spike source: xdg ScreenCast portal (live monitor)"
|
||||
);
|
||||
capture::open_portal_monitor(want_hdr).context("open portal capturer")?
|
||||
// Embedded cursor: the spike passes `cursor_blend = false` to its encoder open, so
|
||||
// a metadata pointer would be composited by nothing.
|
||||
capture::open_portal_monitor(want_hdr, false).context("open portal capturer")?
|
||||
}
|
||||
Source::KwinVirtual => {
|
||||
let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);
|
||||
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Type-check and lint punktfunk's WINDOWS-only Rust from a Linux dev box.
|
||||
#
|
||||
# scripts/wincheck.sh # clippy -D warnings, the default
|
||||
# scripts/wincheck.sh check # cargo check instead
|
||||
# scripts/wincheck.sh clippy --fix # extra args are passed through to cargo
|
||||
#
|
||||
# Linux CI never compiles `#[cfg(target_os = "windows")]` code, so a Windows-side edit is otherwise
|
||||
# unverifiable until the Windows CI job runs (minutes) or someone tests on a real box. This gets the
|
||||
# same answer in ~1 s warm, against the WORKING TREE — the generated workspace symlinks each `src`
|
||||
# at the real crate directory, so there is nothing to keep in sync and no copies to go stale.
|
||||
#
|
||||
# WHY A SEPARATE WORKSPACE — the obvious in-tree command
|
||||
#
|
||||
# cargo check --target x86_64-pc-windows-msvc -p pf-capture
|
||||
#
|
||||
# fails, and NOT because of the Windows code: it dies in build scripts that compile C for the
|
||||
# target. `audiopus_sys` first (cmake wants a Visual Studio generator), then `ring` (needs an MSVC C
|
||||
# compiler plus the Windows SDK headers; clang-cl and lld-link exist locally but there is no SDK).
|
||||
# Both enter the graph through `punktfunk-core`'s `quic` feature. Stubbing opus with a fake
|
||||
# `opus.pc` gets past audiopus but not ring.
|
||||
#
|
||||
# The whole Windows capture stack uses exactly TWO items from punktfunk-core — `quic::HdrMeta` and
|
||||
# `Mode`, both plain data (re-verify with:
|
||||
# `grep -rho 'punktfunk_core::[A-Za-z_:]*' crates/pf-capture/src crates/pf-frame/src crates/pf-win-display/src | sort -u`).
|
||||
# So this script generates a ~30-line stub core carrying just those, and rustls/ring/opus never
|
||||
# enter the graph at all. Every path dep that is NOT a generated member points at the REAL crate by
|
||||
# absolute path, so their own relative deps and `version.workspace` inheritance still resolve.
|
||||
#
|
||||
# Note: `cargo fmt` needs none of this — rustfmt follows `mod`/`#[path]` without evaluating cfg, so
|
||||
# it already reaches Windows-only files. Mechanical edits can be normalised with plain `cargo fmt`.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
|
||||
OUT="${WINCHECK_DIR:-$REPO/target/wincheck}"
|
||||
TARGET=x86_64-pc-windows-msvc
|
||||
|
||||
# The generated members: crates whose Windows code we lint, plus the stub core they share. A path
|
||||
# dep naming one of these must stay RELATIVE (resolving to the generated member); anything else is
|
||||
# rewritten to the real crate. Getting that backwards silently drags the real punktfunk-core — and
|
||||
# with it ring and opus — back in through pf-frame, which is the entire thing this avoids.
|
||||
MEMBERS_RE='punktfunk-core|pf-frame|pf-win-display|pf-capture'
|
||||
REAL_MEMBERS=(pf-frame pf-win-display pf-capture)
|
||||
|
||||
cmd="${1:-clippy}"
|
||||
[ $# -gt 0 ] && shift
|
||||
case "$cmd" in
|
||||
check | clippy) ;;
|
||||
*)
|
||||
echo "usage: $(basename "$0") [check|clippy] [extra cargo args...]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! rustc --print target-list | grep -qx "$TARGET"; then
|
||||
echo "wincheck: rustc does not know $TARGET" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! rustup target list --installed 2>/dev/null | grep -qx "$TARGET"; then
|
||||
echo "wincheck: target $TARGET not installed for the active toolchain — run:" >&2
|
||||
echo " rustup target add $TARGET" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# Mirror the real [workspace.package] so the members' `version.workspace = true` resolves.
|
||||
{
|
||||
echo '# GENERATED by scripts/wincheck.sh — do not edit; re-run the script.'
|
||||
echo '[workspace]'
|
||||
echo 'resolver = "2"'
|
||||
echo 'members = ["punktfunk-core", "pf-frame", "pf-win-display", "pf-capture"]'
|
||||
echo
|
||||
echo '[workspace.package]'
|
||||
sed -n '/^\[workspace\.package\]/,/^$/p' "$REPO/Cargo.toml" | sed '1d;/^$/d'
|
||||
} > "$OUT/Cargo.toml"
|
||||
|
||||
mkdir -p "$OUT/punktfunk-core/src"
|
||||
cat > "$OUT/punktfunk-core/Cargo.toml" <<'EOF'
|
||||
# GENERATED by scripts/wincheck.sh — a STUB, not the real crate.
|
||||
[package]
|
||||
name = "punktfunk-core"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Present so dependents' `features = ["quic"]` resolves; carries no quinn/rustls/opus.
|
||||
quic = []
|
||||
EOF
|
||||
|
||||
# Field layout and derives must match the real definitions, or the cross-check goes stale exactly
|
||||
# where it matters (the capture code builds HdrMeta literally and compares Modes).
|
||||
cat > "$OUT/punktfunk-core/src/lib.rs" <<'EOF'
|
||||
//! GENERATED by scripts/wincheck.sh — a STUB of punktfunk-core carrying only the two items the
|
||||
//! Windows capture stack uses. Mirrors crates/punktfunk-core: `Mode` from config.rs, `HdrMeta`
|
||||
//! from quic/datagram.rs. If either grows a field, mirror it here.
|
||||
|
||||
/// Mirrors `punktfunk_core::Mode`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Mode {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: u32,
|
||||
}
|
||||
|
||||
pub mod quic {
|
||||
/// Mirrors `punktfunk_core::quic::HdrMeta`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub struct HdrMeta {
|
||||
pub display_primaries: [[u16; 2]; 3],
|
||||
pub white_point: [u16; 2],
|
||||
pub max_display_mastering_luminance: u32,
|
||||
pub min_display_mastering_luminance: u32,
|
||||
pub max_cll: u16,
|
||||
pub max_fall: u16,
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
for name in "${REAL_MEMBERS[@]}"; do
|
||||
mkdir -p "$OUT/$name"
|
||||
# Park the member paths behind a sentinel first: a plain self-substitution would be a no-op and
|
||||
# the next rule would rewrite them to absolute along with everything else.
|
||||
{
|
||||
echo "# GENERATED by scripts/wincheck.sh from crates/$name/Cargo.toml — src/ is a symlink."
|
||||
sed -E "s#path = \"\.\./($MEMBERS_RE)\"#path = \"@@M@@\1\"#g; \
|
||||
s#path = \"\.\./([A-Za-z0-9_-]+)\"#path = \"$REPO/crates/\1\"#g; \
|
||||
s#path = \"@@M@@#path = \"../#g" "$REPO/crates/$name/Cargo.toml"
|
||||
} > "$OUT/$name/Cargo.toml"
|
||||
ln -sfn "$REPO/crates/$name/src" "$OUT/$name/src"
|
||||
done
|
||||
|
||||
cd "$OUT"
|
||||
for name in "${REAL_MEMBERS[@]}"; do
|
||||
echo "=== $cmd $name ($TARGET) ==="
|
||||
if [ "$cmd" = clippy ]; then
|
||||
cargo clippy --target "$TARGET" -p "$name" --all-targets "$@" -- -D warnings
|
||||
else
|
||||
cargo check --target "$TARGET" -p "$name" --all-targets "$@"
|
||||
fi
|
||||
done
|
||||
Reference in New Issue
Block a user