Compare commits

...
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 6a9b3b0f28 fix(windows/cursor): a re-rendered pointer keeps its handle — re-probe the extent
android / android (push) In progress
apple / screenshots (push) Blocked by required conditions
arch / build-publish (push) In progress
ci / rust (push) In progress
ci / rust-arm64 (push) In progress
decky / build-publish (push) Waiting to run
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Waiting to run
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Waiting to run
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Waiting to run
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Waiting to run
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Waiting to run
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Waiting to run
docker / build-push-arm64cross (push) Blocked by required conditions
docker / deploy-docs (push) Blocked by required conditions
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Waiting to run
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
windows-host / package (push) In progress
ci / web (push) Successful in 56s
deb / build-publish (push) In progress
ci / docs-site (push) Successful in 1m9s
deb / build-publish-host (push) In progress
apple / swift (push) In progress
ci / bench (push) Successful in 7m23s
deb / build-publish-client-arm64 (push) In progress
The GDI shape poller rasterises only when the HCURSOR VALUE changes. But Windows
rebuilds the system cursors at a new size whenever the display scale under the
pointer changes, and it does that behind a handle that never moves — the shared
arrow is 0x10003 for the whole session. So the cache latched whatever size the
pointer happened to have when the poller started and never let go.

That window is not rare, it is the norm: a fresh virtual display is created at
Windows' RECOMMENDED scale and only picks up the client's saved
PerMonitorSettings override a beat later, while the poller starts within a
second of the monitor appearing. Sample inside it and the session forwarded —
and composited — a 96 px pointer over a 100 % desktop for its entire life, which
reads on the client as a pointer 3x too large while every other thing on the
streamed desktop is correctly sized. Scaling on the client cannot undo it: the
bitmap is proportional to the video, it is just proportional to the WRONG scale.

Re-read the bitmap's extent on a slow cadence whenever the handle is unchanged —
dimensions only, no pixel copy, 4 Hz — and drop the cache when it moved, so the
next tick re-rasterises and publishes a new serial. Observing the extent itself
rather than a DPI proxy also covers the accessibility pointer-size slider and any
other cause of a same-handle re-render.

Windows-side clippy -D warnings green via scripts/wincheck.sh; on-glass owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:22:53 +02:00
enricobuehlerandClaude Opus 5 1421e235f2 fix(host): hdr-p010-selftest silently ignored its size argument
apple / swift (push) Successful in 5m41s
ci / web (push) Successful in 1m24s
ci / docs-site (push) Successful in 2m15s
windows-host / package (push) Successful in 11m2s
arch / build-publish (push) Successful in 12m41s
ci / rust-arm64 (push) Successful in 9m56s
ci / bench (push) Successful in 7m52s
android / android (push) Successful in 17m58s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Successful in 9m2s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
ci / rust (push) Successful in 21m47s
deb / build-publish-host (push) Successful in 9m27s
deb / build-publish-client-arm64 (push) Successful in 8m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m31s
apple / screenshots (push) Successful in 29m0s
`args` starts AT the subcommand (main builds it with `env::args().skip(1)`), so a subcommand's own
optional arguments begin at index 1 — cf. `args.get(1)` in the `service` arm. This arm read
`skip(2)`, swallowing the first optional argument.

The documented invocation `hdr-p010-selftest 1920x1080 nvidia` therefore picked up the vendor (it is
in the second slot) and ran at the 64x64 DEFAULT. A size-only `hdr-p010-selftest 1920x1080` parsed
nothing whatsoever and still printed PASS. Nothing warned, because an unrecognised token is the only
thing that errors and no token was ever inspected.

The size is the entire point of the flag: the arm's own comment says heights like 1080 are not
16-aligned and exercise a different driver path. So the self-test has only ever validated the one
geometry that exercises the least, and the sweep's W7 gate — 1920x1080 and 5120x2880 on the RTX box
— could not actually run as written.

Found by running that gate: both sizes printed "HDR P010 self-test (64x64 ...)" with byte-identical
plane layouts (row_pitch=128, expected_total=12288 — those are 64x64's).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:12:20 +02:00
enricobuehlerandClaude Opus 5 28bbaa5250 tools: check and lint Windows-only Rust from a Linux dev box (scripts/wincheck.sh)
ci / web (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 9m50s
ci / bench (push) Successful in 7m15s
apple / swift (push) Successful in 5m41s
android / android (push) Successful in 12m23s
arch / build-publish (push) Successful in 17m26s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-host / package (push) Successful in 18m55s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m29s
ci / rust (push) Successful in 22m14s
deb / build-publish-host (push) Successful in 11m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m15s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 23s
apple / screenshots (push) Successful in 26m13s
Linux CI never compiles `#[cfg(target_os = "windows")]` code, so a Windows-side edit was
unverifiable until the Windows CI job ran or someone tested on a real box. The pf-capture sweep's
Phases 3–6 used an ad-hoc version of this harness in a scratch directory; committing it means the
next Windows change does not have to rediscover the recipe.

The obvious in-tree command fails, and not because of the Windows code:

    cargo check --target x86_64-pc-windows-msvc -p pf-capture

dies in build scripts that compile C for the target — audiopus_sys (cmake wants a VS generator),
then ring (needs an MSVC C compiler plus the Windows SDK headers). Both enter through
punktfunk-core's `quic` feature.

So the script generates a workspace whose members are Cargo.toml copies with `src` SYMLINKED at the
real crate directories — nothing to keep in sync, no copies to go stale — plus a ~30-line stub
punktfunk-core carrying only `Mode` and `quic::HdrMeta`, which is provably all the Windows capture
stack uses. rustls, ring and opus never enter the graph. Every path dep that is not a generated
member points at the real crate.

Covers pf-frame, pf-win-display and pf-capture with --all-targets, so the Windows `#[cfg(test)]`
modules are type-checked too. ~10 s cold, ~1 s warm, into target/wincheck (gitignored).

Verified non-vacuous: a deliberate type error planted in a windows-only file
(idd_push/stall.rs) is caught and fails the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:22 +02:00
enricobuehlerandClaude Opus 5 0d69ebf60b ci(windows): run pf-capture's tests instead of only type-checking them
Phase 0 of the sweep added `clippy -p pf-capture --all-targets` to this workflow, which type-checks
all 19 Windows `#[test]`s and executes none of them. They were decorative.

The workflow's own comment explains why pf-encode's tests cannot run here — nvenc link-imports
NvEncodeAPICreateInstance, which resolves only against the driver's import lib. 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 the runner lacks.

Confirmed empirically rather than reasoned: `cargo test --release -p pf-capture` was run on a
Windows dev box against a workspace checkout — it linked, executed and passed, building in ~51 s off
an existing release target dir. --release keeps it in the one C:\t\release tree, so it does not
trip the C1069 disk exhaustion a second debug dep tree causes.

18 of the 19 run here (StallWatch + ring-generation masking, the cursor shape→wire truth table, and
`f32_to_f16`'s rounding-carry/saturation edges); all 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.

This is Windows-only code no other job can execute, so it was the cheapest coverage still on the
table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:22 +02:00
enricobuehlerandClaude Opus 5 1423b63333 fix(gamestream): the HDR SDR-downgrade latch gated the capture offer but not the negotiation (X6)
`pf_capture::hdr_capture_failed()` had exactly one consumer: `open_portal_monitor`, which uses it to
drop the HDR offer (`want_hdr && !hdr_capture_failed()`). The RTSP negotiation consulted only
`gnome_hdr_monitor_active()`. So once the latch was set — an HDR negotiation timed out, the monitor
left HDR mode between probe and negotiation, NVIDIA EGL not listing LINEAR for XR30, a pre-50 Mutter
— the host kept telling the client HDR while capturing and encoding SDR: `cfg.hdr` flowed to
`open_portal_monitor`, which then silently opened the SDR offer.

The client renders an SDR stream as PQ. Washed out, wrong gamut, and no error anywhere. The latch is
sticky until host restart, so every reconnect repeated it.

Consulted at RTSP honor time rather than folded into `host_hdr_capable()` for the same reason as the
colour-mode probe next to it: that fn is the STATIC serverinfo capability (it decides whether to
advertise SCM_HEVC_MAIN10 at all), and this is a live per-session fact.

The native plane needs no equivalent — `capturer_supports_hdr()` is hard-false on Linux, and the
latch is a fact about the portal capturer, which that plane never uses. Noted in handshake.rs so it
isn't re-derived.

Listed in the sweep's confirmed-defect register as cross-cutting/medium, then absent from every
phase of its implementation plan. Verification is on-glass (force the latch, reconnect a client that
requests HDR, confirm the Welcome/SDP reports SDR).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:04 +02:00
enricobuehlerandClaude Opus 5 59e33ad11d Merge origin/main into the pf-capture sweep
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
windows-host / package (push) Successful in 10m48s
ci / rust-arm64 (push) Successful in 13m38s
decky / build-publish (push) Successful in 34s
deb / build-publish (push) Successful in 12m32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
android / android (push) Successful in 17m7s
deb / build-publish-host (push) Successful in 9m49s
arch / build-publish (push) Successful in 17m59s
ci / rust (push) Successful in 21m32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m51s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-client-arm64 (push) Successful in 9m55s
apple / swift (push) Successful in 6m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m24s
apple / screenshots (push) Successful in 24m29s
Upstream landed `fc335b39` (negotiate the cursor around what the encoder can blend) on the same
files this sweep restructured. Merged rather than rebased: the sweep MOVED ~2,000 lines out of
`linux/mod.rs` into `pipewire.rs`/`portal.rs`/`pw_*.rs`, so a rebase would have re-fought the same
conflict in up to nine commits; merging resolves it once with both sides in view. The repo already
carries merge commits (`land/sweep-all`).

Two conflicts, both resolved by keeping BOTH changes:

* `linux/mod.rs` — `PortalCapturer::open` gains upstream's `want_metadata_cursor` AND keeps the
  sweep's `PortalSession` teardown, so the portal threads now take `(setup_tx, quit_rx,
  want_metadata_cursor)`. The second conflict was upstream editing inside the region Phase 5.1/5.3
  moved out; the split wins and upstream's change is ported to where the code now lives:
  `choose_cursor_mode`'s new `want_metadata` ladder (4 arms — prefer Embedded when the encoder can't
  blend, and warn-then-take Metadata when Embedded isn't advertised) is now in `portal.rs`, taken
  verbatim.
* `gamestream/stream.rs` — the pooled-capturer slot keeps upstream's third reuse key
  (`metadata_cursor`, beside HDR-ness) AND the sweep's `result.is_ok() && capturer.is_alive()`
  re-pool gate. Both guard the same slot against different failures: theirs against reusing a
  session negotiated for the wrong cursor mode, the sweep's (L2) against reusing a dead one.

Everything else auto-merged, including the `want_metadata_cursor` thread through
`host/capture.rs`'s facade and `native/stream.rs`'s `session_plan::cursor_blend_for`.

Verified after the merge: workspace `cargo test` green, `clippy --workspace --all-targets` clean on
Linux AND on x86_64-pc-windows-msvc, `cargo fmt --check --all` clean, pf-capture 38/38.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:44:22 +02:00
enricobuehlerandClaude Opus 5 14914bc72f test(pf-capture): the suite the splits enable — 38 Linux + 19 Windows tests (Phase 6)
The plan's priority order was "every producer- or OS-controlled parser, blend, geometry guard or
negotiation decision", none of which had a test before this sweep (X3). Phase 5's splits are what
made most of them reachable without a compositor or a driver.

**6.1 `bitmap_extent`** — extracted from `update_cursor_meta`, which is the guard whose own SAFETY
proof says a missing bound "SIGSEGVs inside the PipeWire `.process` callback (a segfault
`catch_unwind` cannot catch)". Every input except the region size is producer-written. 5 tests:
a bitmap that fits (with and without header/pixel offsets); the last-row rule (`stride·(bh−1) + row`,
so a bitmap ending flush against the region is accepted rather than rejected by padding that is
never read — and one byte short is rejected); anything past the region; degenerate geometry; and four
distinct overflow vectors including the near-`i32::MAX` stride the SAFETY comment calls out. One
assertion I first wrote was wrong, not the code — with a single row `stride·0 == 0`, so even a
`usize::MAX`-wide row is arithmetically in range; the test now exercises the ≥2-row case that really
overflows and says why the other is fine (the caller has already capped `bw` at 1024).

**6.2 the composite blits** — 8 tests over `composite_cursor` / `composite_cursor_rgb10`: every
packed `PixelFormat` arm lands the colour in its OWN channels (so a byte-order slip cannot pass);
clipping off all four edges plus six fully-outside positions; zero-alpha, `visible: false` and
no-bitmap-yet all drawing nothing; the integer alpha blend; NV12/Yuv444 declined rather than
mis-blitted; and for the 10-bit path a bit-exact round trip of an untouched pixel (including the two
alpha bits the repack must preserve), the R-at-bit-20 vs R-at-bit-0 distinction between `X2Rgb10` and
`X2Bgr10`, and the same clipping. Plus `decode_bitmap_pixel`'s four byte orders.

**6.4 the pod builders** — 4 tests. The `dataType` bitmask is pinned per Buffers pod, because each
bit is load-bearing in a different direction: the mappable pod MUST include DmaBuf (or gamescope's
modifier-bearing format pod wins and the buffer intersection is empty — a link silently stuck in
"negotiating"), the SHM-only pod MUST exclude it (or Mutter hands dmabufs and the race-free download
path is not), and the dmabuf pod 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). Also:
every pod parses back through `Pod::from_bytes`; the HDR pods carry MANDATORY PQ + BT.2020 +
LINEAR-modifier; and only the NV12 offer pins the colour matrix/range. The `dataType` reader parses
the SPA property layout literally (`key, flags, size, type, value`) — a "find the first
plausible-looking int" heuristic read the `size` word, which is also 4, and reported the wrong mask.

**6.5 `FrameToken` generation masking (W14)** — 2 tests, with `IDD_GENERATION` parked two below the
24-bit boundary so the WRAP is what gets exercised: every minted generation is non-zero, fits the
token's field, and survives the pack/unpack round trip `try_consume` performs; and the cleared-
`latest` 0 sentinel never matches a live generation.

**6.6 the cursor conversion (Windows)** — `convert`'s pixel logic extracted from its GDI plumbing
into `mono_planes_to_rgba` / `apply_and_mask_alpha` / `alpha_is_empty`, which is what makes it
testable at all (the caller needs a live `HCURSOR` and a screen DC). 6 tests: the four-state AND/XOR
truth table in one row; transparency surviving without an invert neighbour; the invert outline
covering all eight neighbours, overwriting only TRANSPARENT ones, and clipping at the edges; the
alpha-less colour cursor taking alpha from the AND mask; and a short mask not panicking.

**6.3 / 6.7** landed with the fixes they guard (`negotiation_plan` in 2.3, `f32_to_f16` in 3.5).

38 Linux tests, up from 6 at the start of the sweep — ci.yml already runs them. 19 `#[test]`s on the
Windows side; Phase 0.1's `--all-targets` lint type-checks them all, but note it does not RUN them
(the workflow lints only), so the Windows ones execute on a Windows box. clippy --all-targets clean
on both targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:31:34 +02:00
enricobuehlerandClaude Opus 5 306f4a514d refactor(pf-capture): structural splits + collapse the restated signal set (Phase 5)
Refactors LAST, after every defect fix, so no behaviour change hides in a move diff — and so the
newly-exposed review surface was already clean when it landed (the WP7 discipline). 28f8fc71's
recorded trap (an inline `use super::X` inside a moved body silently changing meaning) was audited
first: every `use super::` in the moved code is either at module level — where `super` still means
`linux` — or inside a `mod tests` that moved with its parent.

5.1 `mod pipewire` → `linux/pipewire.rs`. It was 1,900 of that file's lines. `mod.rs` is a directory
module, so a plain `mod pipewire;` resolves with no `#[path]`.

5.2 out of that, `linux/pw_pods.rs` (the 7 param-pod builders + `serialize_pod` + the PQ constant
and its test) and `linux/pw_cursor.rs` (`CursorState`, `decode_bitmap_pixel`, `update_cursor_meta`,
`dst_offsets`, both `composite_cursor*`). The pods are the crate's WIRE surface — a missing property
is not a compile error but a link that stalls in `negotiating`; the cursor half is producer-driven
and bounds-critical. Both are now pure enough to unit-test without a compositor, which is what
Phase 6 needs.

5.3 `linux/portal.rs`: the ScreenCast/RemoteDesktop handshake, the cursor-mode choice and GNOME's
BT.2100 probe — the async/tokio/zbus control plane, separated from the realtime half. Zero
re-exports changed: `mod.rs` re-exports `gnome_hdr_monitor_active` at its old path.

5.4 `idd_push/open.rs` (the whole one-time construction path + `SharedObjectSa` + `AttachTexFail`)
and `idd_push/compose_kick.rs`. Read `open.rs` when a session will not START and the parent when one
stops flowing; the steady state stays with the parent by decision. Also moved the ~65-line stall
REPORTING block out of `try_consume` (the hot loop) into `StallWatch::report`, taking its two
correlation counters with it — they were capturer fields nothing else touched.

5.5 `windows/dxgi/selftest.rs`: `p010_reference`, both self-tests, `hdr_p010_convert_bars_on_luid`,
`f32_to_f16` and the two test modules — the validation path, none of which runs in a session. The
two `pub` entry points are re-exported, so `main.rs`'s subcommand and pf-encode's live e2e keep
their paths. (An explicit `#[path]`, like `idd_push`'s children: this file is itself reached through
a `#[path]`, so a bare `mod` would resolve to `windows/selftest.rs`.)

5.6 `CaptureSignals`. The seven shared flags/slots were created in `spawn_pipewire`, `_cb`-cloned one
by one, passed as seven of `pipewire_thread`'s parameters, and then re-declared as fields on
`PwHandles`, `PortalCapturer` AND `UserData` — the same list written four times, each with its own
drifting copy of the doc comment. One `#[derive(Clone)]` struct instead (a refcount bump per field),
which also makes it structurally obvious that producer and consumer share ONE set. And `CaptureOpts`
for the four trailing `bool`s: four adjacent same-typed positional arguments are a
silent-transposition footgun — swap `want_444` and `want_hdr` and it compiles, negotiates the wrong
pod family, and surfaces as a black screen ten seconds later.

5.7 trait shape, targeted: `set_active(&self)` → `&mut self` (it took `&self` only because the flag
happened to be an `Arc<AtomicBool>` — an implementation detail leaking into the contract);
`capture_target_id` ⇒ `resize_output`'s pairing rule stated on both methods and in a cluster note;
one-line cluster headers over the 13 methods.

NOT done from 5.7: taking the gamescope targets as an `open_*` option instead of the trait method.
It would put a Linux-only parameter on the cross-platform `open_virtual_output` and on the host's
`capture_virtual_output` facade — a `#[cfg]`-shaped wart in two shared signatures to delete one
already-`cfg`'d defaulted method whose lifecycle rule 2.5c had already made harmless. Wrong trade
under 5.7's own "no churn for no defect fixed" principle.

File sizes: `linux/mod.rs` 2,778 → 770 (target ≤900 ✓), `windows/dxgi.rs` 1,374 → 954, and
`windows/idd_push.rs` 2,694 → 1,922. The last two miss the plan's ≤850/≤1,300 targets, which were
computed against the ORIGINAL line counts — Phases 1–4 added several hundred lines of SAFETY proofs
and defect rationale to exactly these files before the split. The moves themselves are the ones the
plan specifies; closing the rest would mean inventing new splits it does not call for.

Zero behaviour delta. pf-capture 20/20; workspace clippy --all-targets clean on Linux and on
windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:25:39 +02:00
enricobuehlerandClaude Opus 5 530909a154 perf(pf-capture): take three per-frame costs off the Windows HDR hot path (Phase 4)
**4.1 (W10) — the HDR convert allocated two views and mapped a constant buffer per frame, inside
the ring slot's keyed-mutex hold.** So the driver's publisher sat blocked on that slot while the
host created `CreateRenderTargetView`s and Mapped/Unmapped a 16-byte buffer whose contents never
change. Both are lifetime-of-mode facts, not per-frame ones:
  - the two P010 plane RTVs now belong to the out-ring SLOT, built once in `ensure_out_ring`
    (`out_ring` becomes `Vec<OutSlot>`). A driver that rejects planar RTVs — the one hard
    requirement of this path — therefore fails at ring build, where such a failure belongs, instead
    of on the first frame.
  - `inv_src` becomes an IMMUTABLE constant buffer filled in `HdrP010Converter::new(device, w, h)`.
    The converter is already dropped by `recreate_ring` on every mode change, so it cannot go
    stale. This also deletes the `Map`'s silently-ignored failure: it could leave the chroma pass
    sampling at a garbage texel size with no error anywhere.

**4.2 (W15) — `sdr_white_level_scale` ran inside the keyed-mutex hold too.** It is a CCD query that
contends the display-config lock — precisely the contention `DescriptorPoller`'s doc says must stay
off the frame path — and `prepare_blend_scratch` called it while holding the slot. Moved to
`refresh_sdr_white_scale`, called at open and after each ring recreate (where the scratch is
invalidated anyway). "Only on scratch rebuilds" is not the same as harmless when the thing being
blocked is the producer.

**4.3 (W11) — `VideoProcessorSetStreamAutoProcessingMode(vp, 0, false)`.** The comment claimed "no
interpolation/auto-processing" while only setting the frame format; auto-processing's documented
default is ENABLED, so vendor denoise/edge-enhancement was free to run inside every SDR
`VideoProcessorBlt` — altering the pixels we encode and spending video-engine time on the
desktop-capture hot path. Now actually disabled, with each call's purpose stated separately.

**4.4 (the Linux CPU-path map cache) is deliberately NOT done.** The plan gates it on measurement
("measure first, and only if the CPU path still matters") and this box cannot measure a live capture
session; it also needs a frame-buffer return path through `FramePayload::Cpu`, which is a larger
change than the rest of this phase. Left for Phase 7's measured pass.

Compile-verified for windows-msvc locally; pf-capture 20/20 on Linux; workspace clippy
--all-targets clean on both targets. The GPU-capture check and the measured `cap_us` delta are owed
to Phase 7.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:11:46 +02:00
enricobuehlerandClaude Opus 5 bc45287dc6 fix(pf-capture): Windows IDD-push defects — HDR pin, cursor, recreate, handles, f16 (Phase 3)
Compile-verified for x86_64-pc-windows-msvc locally (a scratch workspace symlinking the real
sources against a stub punktfunk-core — the `quic` feature's ring/opus C builds are what blocks an
in-tree cross-check). Behaviour is owed the on-glass validation in sweep Phase 7.3.

**3.1 (W1/F3) — the HDR pin asserted a flip it never verified.** `poll_display_hdr` discarded
`set_advanced_color`'s `bool` and then wrote `now.hdr = self.client_10bit` — the DESIRED state in
place of the observed one. On a display that cannot be flipped (the state this file already logs as
"Downgrade point D" at open) that broke in both directions: wanting HDR, the fabricated `true`
differed from `current`, so two poller samples drove `recreate_ring(true, …)` and rebuilt the ring
FP16 while the driver composed 8-bit BGRA — every publish dropped by the driver's format guard,
`recovering_since` expiring, `try_consume` bailing: a permanent 3-second reconnect loop. Wanting
SDR, the fabricated `false` MATCHED `current`, so no recreate ever fired and the ring stayed BGRA
against an FP16 composition — the same dropped-publish outcome, silently. Now it re-reads
`advanced_color_enabled` and follows what the display actually composes, with a one-shot error
naming want/observed/returned. A not-yet-settled read costs one debounce cycle, never a wrong ring,
which is why this does not block the frame path on a settle poll the way `open_on` does. Downgrade
point D's error now carries the same pair, so `Some(false)` (display says no) and `None` (the CCD
read failed) are distinguishable.

**3.2 (W2, W3, W6) — cursor correctness.**
  - W2: the poller's desktop rect was captured once at open and used forever, for BOTH the
    desktop→frame offset and the `in_rect` visibility test — while both mid-session mode-change
    paths (`resize_output`, `poll_display_hdr` → `recreate_ring`) keep the same poller. After an
    in-place resize the pointer was clipped to the old rect and offset by a stale origin. It is now
    a SEED: the poll thread re-queries on its existing 250 ms reattach cadence, keeping the last
    good value on `None` (a transient CCD failure must not park the rect at zero and report every
    position invisible), which keeps the CCD call off the encode thread as `DescriptorPoller`
    demands.
  - W3: `composite_forced` tested `cursor_sender.is_none()`, but §8.6's rationale is "no cursor
    CHANNEL" — and the delivery just above it is explicitly allowed to fail non-fatally, which is
    precisely the state needing the rescue. It was the one state that skipped it: a negotiated
    channel that failed to create or deliver left a cursor-excluded target with NO pointer at all.
    Now `cursor_shared.is_none()`, evaluated after that binding.
  - W6: `cursor()` degraded poller→shm correctly, but the BLEND path — the only consumer that
    matters in the composite model, since the Windows encode loop never attaches `frame.cursor` —
    read the poller directly with no `alive()` check and no fallback, so the documented fallback and
    the spawn-failure warning were both untrue for exactly those sessions (a dead poller meant
    pointer-less frames, not a degraded pointer). One `live_cursor()` now serves all three
    consumers and LATCHES the source, because the two keep independent serial namespaces and
    interleaving them poisons the client's shape cache.

**3.3 (W4, W5, W14) — recreate hardening.**
  - W4: `recreate_ring` committed `display_hdr`/`width`/`height` BEFORE the fallible
    `create_ring_slots` (VRAM pressure at a large new mode — exactly when resizes happen), leaving
    a failed recreate emitting frames stamped with the new geometry against the old ring, the old
    generation and an unchanged header. Slots are built first; nothing after the commit point fails.
  - W5: a recreate never cleared the driver's status words, and `wait_for_attach` — the only
    classifier of TEX_FAIL/BIND_FAIL and the only source of the LUID rebind — runs at open ONLY. A
    stale `OPENED` therefore made a failed re-attach look healthy while the recover-or-drop bail
    reported nothing. Now cleared before the Release generation store (plus `status_logged`), and
    the 3 s bail prints the live `(driver_status, detail, render_luid)` the way `next_frame`'s 20 s
    bail already did. The four-field read is one `driver_diag()` helper instead of four copies of
    the same unsafe block.
  - W14: `IDD_GENERATION` is a full `u32` but the publish token carries 24 bits and `unpack` masks
    what it reads, so past 2²⁴ recreates `tok.generation != self.generation` would be permanently
    true — every frame rejected. Masked at the single mint point, and 0 skipped (it is also the
    cleared-`latest` sentinel).

**3.4 (W8, W9, W12) — handle hygiene.** `shared_object_sa`'s security descriptor is a `LocalAlloc`
nobody freed: leaked twice per open and once per ring recreate. It is now an RAII `SharedObjectSa`
whose `Drop` `LocalFree`s it and whose `as_ptr()` only lends a borrow — which also makes the
"descriptor must outlive the attributes" rule structural instead of a comment. The PyroWave fence's
shared NT handle, created per capturer and never closed, becomes an `OwnedHandle` (the encoder holds
its own duplicate, so closing ours is safe). `cursor_blend`'s `cbuf_scale` is cached only on a
successful `Map` — caching unconditionally wedged the HDR/SDR cursor scale for the session after one
transient failure.

**3.5 (W7) — `f32_to_f16` swallowed the rounding carry.** `sign | half_exp | (half_mant + round)`
ORs a mantissa carry into bit 10, so for every ODD biased exponent (bit 10 already set) the carry
vanished and the result came back ~2× low: `1.9998779 → 1.0`, `0.49996948 → 0.25`. Only values one
ULP below a power of two are affected — precisely what a gradient test pattern is full of — so this
made `hdr-p010-selftest` FAIL a correct shader. Composed additively, with 4 tests (18 asserted bit
patterns, a round-trip property over the self-test's scRGB values, saturation) — all verified
numerically against a standalone reference on this box, including a scan confirming old-vs-new
diverges ONLY on the carry cases. Phase 0.1's `--all-targets` lint is what lets these compile in CI
at all.

pf-capture 20/20 on Linux; workspace clippy --all-targets clean on Linux and windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:06:26 +02:00
enricobuehlerandClaude Opus 5 9a7b3a46d0 fix(pf-capture): gamescope cursor — auth without setenv, frame-space coords, live targets (2.5)
**2.5a (L4) — stop `set_var`ing `XAUTHORITY` from a live multithreaded host.** The old connect
swapped the process-global var around each `RustConnection::connect` under a mutex. That lock
serialises this source against itself and nothing else: `getenv` takes no lock, so every OTHER
thread's read raced it — and the concurrency is by construction, since `attach_gamescope_cursor`
runs while the PipeWire thread is starting up (libspa plugin load, EGL/CUDA init). The primary path
now parses the MIT-MAGIC-COOKIE-1 entry out of the given file itself and hands it to
`DefaultStream::connect` + `RustConnection::connect_to_stream_with_auth_info` — the same two steps
`RustConnection::connect` performs internally, minus its env-derived auth lookup — so nothing in
this process touches the environment. The env swap survives only as a fallback for a file we cannot
parse, and a wrong cookie pick cannot do damage: the server rejects it and the fallback (which does
libxcb's full family/address match) takes over. Sharing `pf_vdisplay`'s process-wide env lock was
not the fix — wrong layer, and it would still not fix `getenv`.

**2.5b (L6) — publish the pointer in FRAME coordinates.** `QueryPointer` answers in the nested
root's space, 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 — at `-W 1280 -H 720 -w 640 -h 360`
they differ by 2×, so the pointer drew at a fraction of its real position. The root size comes free
off the setup reply we already parse; the negotiated frame size arrives from the PipeWire thread's
`param_changed` through a new `Arc<AtomicU64>` (`0` = not negotiated ⇒ pass through, as before).
Scaling is computed in `i64` — a 5K coordinate times a 5K width overflows `i32`. Position only: the
bitmap stays at root scale, warned once so a mismatched session is visible.

**2.5c (L7) — the targets are a PROVIDER, not a snapshot.** The list was discovered once, before
the game launched. gamescope creates the game's Xwayland at launch and advertises only the FIRST in
any child's environ (verified on this box: `--xwayland-count 2` makes `:2` and `:3`, only `:2` is
advertised), so the game's display was invisible — 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. The worker now re-runs the
provider every 2 s: it adopts new Xwaylands, reconnects dead ones, and `spawn` no longer returns
`None` on an empty list — a stream that starts before the game converges instead of staying
cursorless. The provider is a host-facade closure, same one-way-edge shape as `FrameChannelSender`.

**2.5d (L8) — bound the teardown join.** `Drop` joined the worker unbounded while the worker blocks
in `RustConnection` replies with NO read timeout, so a peer that stops answering but keeps its
socket open hung capturer teardown — on the session path. Now: a completion channel,
`recv_timeout(250 ms)`, then detach with a warning (the thread only touches its own X connections
and an `Arc`'d slot).

7 new tests, all host-runnable: the cookie reader against a hostile `.Xauthority` (wrong protocol,
wrong display, wildcard entry, truncation mid-length-prefix, an over-long length prefix, a missing
file), display-string parsing, and the root→frame mapping incl. the 5K overflow. pf-capture 20/20;
workspace clippy --all-targets clean on Linux and windows-msvc. Phase 7.2 owes the on-glass
nested-gamescope validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:56:05 +02:00
enricobuehlerandClaude Fable 5 3f4ad08869 test(encode/ffmpeg_win): extract the decision logic and pin it with unit tests
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Successful in 9m9s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 24s
deb / build-publish-host (push) Successful in 10m47s
android / android (push) Successful in 14m53s
deb / build-publish-client-arm64 (push) Successful in 10m38s
windows-host / package (push) Successful in 17m40s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m50s
arch / build-publish (push) Successful in 11m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m11s
apple / swift (push) Successful in 6m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m15s
ci / bench (push) Successful in 5m47s
ci / rust-arm64 (push) Successful in 9m11s
ci / rust (push) Successful in 21m50s
apple / screenshots (push) Successful in 25m14s
The QSV open-failure fallback (1,400 lines, 23 unsafe, 0 tests) follows the
vaapi.rs treatment: the device-free decisions now live in named functions
with their contracts pinned — the per-vendor zero-copy default matrix (AMF
on-glass-validated on, QSV opt-in), the PUNKTFUNK_FFWIN_POLL_MS
clamp-before-µs-conversion (the 27.7-hour-spin class), the readback routing
table with its mid-stream depth-change guard, the swscale source map, the
QSV display-remoting latency contract (async_depth=1/low_power=1/
look_ahead=0/forced_idr=1/scenario) and the AMF no-B-frames contract, and
the per-vendor zero-copy pool bind flags. A probe smoke rides along
#[ignore]d for the runner. No FFI plumbing chased; no behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:50:33 +02:00
enricobuehlerandClaude Fable 5 fc335b39e9 fix(host/encode): negotiate the cursor around what the encoder can blend
EncoderCaps::blends_cursor's contract said the HOST must fall back to
capturer-side compositing when a cursor-as-metadata session lands on an
encoder that can't composite — but that host half was never built: open_video
warned and the session streamed WITHOUT a pointer (confirmed on the VAAPI
dmabuf and libav-NVENC CUDA paths; latent on vulkan RGB-direct/native-NV12).
The negotiation is now caps-aware, ahead of capture, on both planes:

* pf-encode grows cursor_blend_capable() — the pre-open dispatch mirror
  (sibling of linux_native_nv12_ok) answering whether the resolved backend
  composites frame.cursor; its pure core is test-pinned arm by arm.
* Native plane: handshake::cursor_forward grants the cursor channel only
  where the resolved backend can blend (the capture-mouse flip makes the host
  draw the pointer on demand); denied sessions keep the pre-channel path —
  the compositor EMBEDS the pointer, never cursorless, never doubled. The
  Welcome's HOST_CAP_CURSOR bit is computed once and read back at both
  session-wiring sites instead of recomputed. SessionPlan::output_format
  additionally keeps every cursor-blend session off producer-native NV12 (the
  arm with no CSC to fold a cursor into), and vulkan RGB-direct now yields to
  a cursor-blend session even when pinned (EFC cannot composite; the open
  logs the override). Windows plans cursor_blend=false via the new shared
  cursor_blend_for() rule — the IDD capturer composites the pointer itself,
  and asking the encoder anyway fired the blends-cursor warn spuriously on
  every cursor-channel session.
* GameStream plane: the hardcoded cursor_blend=true is gone. The portal
  source asks for cursor-as-metadata only when the resolved backend blends,
  otherwise negotiates an Embedded pointer (choose_cursor_mode's new ladder);
  the capturer pool now also keys on that mode. The virtual-output source
  passes false — its capture embeds the pointer where it can.

The per-arm warns in vulkan_video (RGB-direct, native-NV12) are now
structurally unreachable and removed. open_video's post-open check stays as
the single backstop for what planning cannot see: a Vulkan-open falling back
to VAAPI mid-session, and the gamescope residual (no embedded mode exists
there, so a never-blending backend — H.264-on-AMD VAAPI, software — still
streams cursorless; fixing that needs a compositing stage, deliberately not
built in this pass). Zero-copy is preserved throughout — every fallback is a
capture-negotiation change, never a readback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:50:33 +02:00
enricobuehlerandClaude Opus 5 759fdf767c fix(pf-capture): buffer-geometry guards + a genuine drop-oldest hand-off (sweep 2.4, 2.6)
**2.4 — buffer geometry correctness on the Linux CPU path.**

L5: the self-mmap de-pad mapped the fd from offset 0 and then indexed by `chunk.offset()` ALONE,
ignoring `spa_data.mapoffset` — where that spa_data's region actually begins inside the fd. For a
pooled-memfd producer (every buffer a slice of one fd) it therefore read the WRONG buffer, and the
`needed > avail` guard could not catch it: `avail` came from the whole-fd mapping, so it was ample
for any single buffer's span. PipeWire's own MAP_BUFFERS slice (the fallback) was always correct
because it already starts at `mapoffset` — only the hand-rolled "fix" path was wrong, so the two
paths now compute their base separately (checked add; both halves are producer-controlled).

L15: when `fstat` failed, the mmap length was invented as `offset + needed` — producer-controlled
geometry. That defeats the entire point of the fstat (the "buffer smaller than the frame span"
guard then compares against the number the producer just supplied) and can map, and read, past the
end of the object — a SIGBUS, not an `Err`. Without a real length we now decline to self-map and
let PipeWire's own slice serve, which is bounded by construction.

L12: `bytes_per_pixel()`'s catch-all answers 4 for NV12, so a producer-native NV12 buffer computed
`row = 4w` against a stride of ~w and ALWAYS tripped the stride guard — reporting "chunk stride <
row", i.e. blaming the producer for a host limitation. NV12 cannot be de-padded on this path at all
(plane 1 is not even in `datas[0]`'s span), so reject it with a message that says which side is at
fault and under what condition the NV12 offer is valid.

L14: `spa_meta_bitmap` was read field-by-field through an aligned `*const` at a
PRODUCER-controlled offset, with a SAFETY comment asserting an alignment the code never
established. One `read_unaligned` of the `Copy` POD instead.

**2.6 — hand-off semantics (L9, L10).** The frame channel was `sync_channel(8)` + `try_send`, i.e.
drop-NEWEST, while the trait documented drop-oldest. Neither remedy the plan offered works: a
`SyncSender` cannot pop, so "drain one and retry" is not expressible, and shrinking the bound makes
staleness WORSE, not better (with bound N the queue holds the first N frames of a stall and
everything after is discarded, so a larger N actually yields a fresher frame — the defect is the
discarding, not the depth). Replaced with a one-deep OVERWRITING mailbox plus a depth-1 wakeup
channel: publishing overwrites, so a stalled consumer loses the intermediate frames and is always
handed the freshest one. `next_frame` used to return the OLDEST of eight stale frames.

Falls out of it:
  - `wait_arrival` now honours its documented "must NOT consume" contract by peeking the slot. The
    `pending` field existed only to stash a frame the un-peekable channel forced it to consume;
    it is gone.
  - a queued `CapturedFrame` can own a dup'd dmabuf fd or a CUDA buffer, so the old 8-deep backlog
    could pin eight compositor buffers. One-deep by construction now.
  - L10: `set_active(false)` flushes the mailbox, so a reused capturer no longer opens the next
    stream with the previous session's last frame (wrong content, and a `pts_ns` from the old
    clock). Two lines, where flushing a queue + a `pending` stash was fiddlier.
  - the producer-liveness signal moves to the wakeup channel's `Disconnected`, and a final frame
    left in the slot as the thread exits is still served before the error.

pf-capture 13/13; workspace clippy --all-targets clean on Linux and windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:48:18 +02:00
enricobuehlerandClaude Fable 5 f495b201e1 fix(encode): delete the write-only EncoderCaps::supports_hdr_metadata
A caps field nothing reads is a contract nobody honors — and this one shipped
write-only: its single reader anywhere in the workspace was a hardware-gated
assertion inside pf-encode's own AMF smoke test. Both planes send the static
HDR grade out-of-band unconditionally (the native 0xCE datagram per keyframe,
the GameStream 0x010e control message), every first-party client reads
exclusively that path, and none parse in-band SEI — so the host decision the
field was reserved for (suppress out-of-band when the encoder embeds) can
never validly exist. The field's doc contract had also rotted in two
directions: it claimed set_hdr_meta no-ops when false (native AMF and QSV
consume it regardless) and that only Windows direct-NVENC attaches in-band
metadata (AMF and QSV do too). The in-band SEI/OBU emission itself is
untouched — it stays a bonus for stock decoders, documented at the emit
sites; the trait docs now describe the real routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:48:07 +02:00
enricobuehlerandClaude Opus 5 17eb8100a3 fix(pf-capture): portal teardown, observable death, one negotiation resolver (sweep 2.1–2.3)
**2.1 (L1) — the portal thread leaked a live screencast session.** It parked on
`future::pending()` and `Drop` stopped only the PipeWire thread, so every dropped portal capturer
permanently leaked a thread + a 2-worker tokio runtime + a zbus connection — and because dropping
that connection is what ends the compositor's cast (ashpd's `Session` has no `Drop`), the
COMPOSITOR-side session leaked too. GameStream drops the pooled capturer on every HDR↔SDR
reconnect flip and opens a fresh one, so the sessions accumulated: exactly the "second conflicting
screencast" the pooling exists to prevent. Both portal threads now park on a `oneshot::Receiver`
and `PortalSession::drop` fires it, then waits — BOUNDED (750 ms, then detach with a warning) —
for a completion signal the thread sends after its runtime has been dropped. Bounded because
teardown runs on the session path and the thread may be inside a D-Bus round-trip: an unbounded
`join()` there would hang the host rather than leak. Every early-return path in `open` drops the
session too, so a failed PipeWire spawn no longer strands a fresh cast.

**2.2 (L2) — a dead capturer was pooled forever.** All three of the portal backend's terminal
states are sticky and observable only by consuming a frame, and `Capturer` exposed no health
predicate, so GameStream re-pooled unconditionally — after `result` was already an `Err`. One
zerocopy-worker death or one compositor restart therefore wedged GameStream portal video at 10 s
per reconnect attempt, permanently (that path has no rebuild closure). Adds
`Capturer::is_alive()` (default `true`), implemented for `PortalCapturer` as
`!broken && streaming && !thread.is_finished()` — the third term catches a dead PipeWire thread,
which is otherwise indistinguishable from an idle desktop. A static desktop stays `Streaming`, so
an idle-but-healthy capture is never reported dead. The host re-pools only on
`result.is_ok() && capturer.is_alive()` and logs which of the two retired it. Both halves ship
together: either alone leaves the wedge reachable.

**2.3 (L3/F1) — a "VAAPI" downgrade latch was really a global zero-copy kill switch.**
`spawn_pipewire` hand-mirrored the thread's `vaapi_passthrough` decision and the copy omitted
`raw_dmabuf_import_disabled`, so once that latch fired the capturer still believed it had made the
passthrough offer while the thread had already fallen back — and its timeout branch then latched a
downgrade for an offer nobody made. That downgrade was `note_vaapi_dmabuf_failed`, which fed
`pf_zerocopy::enabled()`, so ONE failed negotiation dropped every later session on the host — the
NVENC EGL→CUDA path included — to CPU capture until restart. And since the raw-passthrough offer
is also the PyroWave one on ANY vendor, a single PyroWave negotiation timeout was enough to do it
on an NVIDIA box.

Two fixes, both structural:
  - `negotiation_plan(NegotiationInputs) -> NegotiationPlan` is now the ONE resolver, consumed by
    the thread AND by `spawn_pipewire` — the mirror is deleted, not re-synced (WP7.6's shape), so
    the drift class is unrepresentable. It is pure (every env/latch read is an input), which is
    what makes it testable; the runtime-dependent half stays a method (`want_dmabuf` needs the
    modifier list the importer's construction actually yielded). The redundant `importer.is_none()`
    term goes: `build_importer` already excludes the passthrough, and that term is what made the
    decision look impure enough to "need" a mirror. `PUNKTFUNK_FORCE_SHM` was ALSO read in both
    places; now once.
  - the capture-side downgrade becomes `pf_zerocopy::note_raw_dmabuf_negotiation_failed`, which
    latches `RAW_DMABUF_DISABLED` — gating only the raw-passthrough decision. `enabled()` is now
    the env var alone, and `VAAPI_DMABUF_FAILED` is deleted.

Also (L13, needed by 2.3's plan inputs): `PUNKTFUNK_PIPEWIRE_NV12` honoured only the exact string
`"0"`, so `"0 "` or `"false"` read as force-ON — the bug class of ed525c4c. pf-host-config's
explicit-off grammar is now exported as `env_on()` and the four in-crate copies (`PUNKTFUNK_
ZEROCOPY`/`_10BIT`/`_444`/`_CHACHA20`) plus this new caller share it.

7 new tests pin the four invariants that were prose-only comments, plus the latch property that
made the mirror wrong. pf-capture 13/13; workspace clippy --all-targets clean on Linux and on
x86_64-pc-windows-msvc. On-glass validation of 2.1/2.2/2.3 is owed (sweep Phase 7.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:41:53 +02:00
enricobuehlerandClaude Fable 5 232b6d6be2 test(encode/vaapi): extract the open-time decision logic and pin it with unit tests
The fallback backend under Vulkan Video — and the only AMD/Intel H.264 and
10-bit/HDR encoder — had 1,300 lines, 26 unsafe blocks, and zero tests. The
device-free decisions now live in named functions with their contracts pinned:
the entrypoint ladder + LP_MODE latch round-trip (the cross-GPU session-killer
and the 8-bit-pins-10-bit under-advertisement are both key'd tests now), the
PUNKTFUNK_VAAPI_LOW_POWER / _ASYNC_DEPTH grammars, the VUI ↔ scale_vaapi
colour agreement (the Mesa-BT.601 hue-shift pin), the honest-downgrade depth
table, the HEVC-Main10-only explicit profile, and the 10-bit probe gate.
Probe + CPU-path encode round-trip ride along as #[ignore]d hardware smokes
in the house style. No FFI plumbing was chased; no behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:29:31 +02:00
enricobuehlerandClaude Opus 5 54a37aeb46 docs(pf-capture): truth pass over comments, docs and log strings (sweep Phase 1)
Every item here is prose that actively misdirects a maintainer or an operator. Landing it before
the defect fixes means those diffs get reviewed against accurate comments.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:27:06 +02:00
enricobuehlerandClaude Opus 5 cc848479c4 test(pf-encode): re-point the cpu_img size-change smoke at the CSC guard's contract
arch / build-publish (push) Failing after 58s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 53s
android / android (push) Failing after 7m59s
ci / bench (push) Failing after 4m9s
ci / rust-arm64 (push) Failing after 6m44s
ci / rust (push) Failing after 6m45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 24s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
apple / swift (push) Successful in 5m28s
deb / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 10m44s
docker / deploy-docs (push) Successful in 31s
docker / build-push-arm64cross (push) Successful in 5m33s
deb / build-publish-client-arm64 (push) Successful in 8m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m4s
deb / build-publish-host (push) Successful in 12m7s
apple / screenshots (push) Successful in 25m11s
vulkan_cpu_img_survives_a_source_size_change drove MISMATCHED source
sizes through the then-lenient CSC arm as its vehicle for the staging
cache hazard (format-only-keyed cpu_img → OOB copy while submit said
Ok). e3354b6d's guard — correctly the equality check every sibling arm
always had, against the MODE, not the coded extent, so the padded
render-vs-coded tolerance in the direct arms is untouched — makes that
scenario unrepresentable through submit and broke the test on main
(.25 layers baseline read 13/13 instead of 14/12).

Replaced by vulkan_csc_refuses_a_mismatched_source: refusal pinned in
BOTH directions, plus the property that actually needs proving — a
refused submit does not WEDGE the session (the bail lands after step
1's frame-type bookkeeping; the next well-sized frame must still
encode, and an AU must come out). Verified on the 780M under
validation layers: 8/8 vulkan tests, full-suite baseline restored to
14/12.

The WP4.2 size-keyed staging stays as belt-and-braces; the hazard it
fixed is now structurally unreachable through submit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:20:37 +02:00
enricobuehlerandClaude Opus 5 6a2a153c0b chore(pf-capture): make the crate verifiable — Windows CI lint + two denies (sweep Phase 0)
Gate for the rest of design/pf-capture-sweep.md: today half this crate is compiled by no CI job
at all, so the Windows-side findings can't even be type-checked.

0.1 (X1) — windows-host.yml lints `-p pf-capture --all-targets`. The host lint above it builds
pf-capture only as a DEPENDENCY, so its `#[cfg(test)]` modules were never compiled anywhere: the
5 StallWatch tests, the DXGI HDR self-tests and the cursor-conversion tables were dead code in
CI. The workflow's own comment already diagnosed this blind spot and fixed it for pf-encode;
pf-capture never got the same treatment. No features on this crate, so no feature juggling and
no extra dep tree.

0.2 (X2) — `#![deny(unsafe_op_in_unsafe_fn)]` beside the existing
`deny(clippy::undocumented_unsafe_blocks)`. The crate declares "every unsafe block carries a
SAFETY proof; enforce it" in ten file headers, but in edition 2021 that lint has nothing to fire
on inside an `unsafe fn` — so the hardest FFI in the crate was exempt from its own program: the
ring/slot construction, the fence signal, the blend scratch, and every D3D converter ctor/convert.
119 operations across 16 functions now carry a proof. `shared_object_sa` loses its `unsafe`
marker instead (it states no precondition — only its RESULT is a raw pointer, which is the
caller's business).

0.3 (X4) — drop the crate-wide `#![allow(dead_code)]`. Verified: zero warnings on either target
without it, so it was hiding nothing the lint can see (W16's dead items are `pub`/written-once,
so they need Phase 1.7's deletion instead).

No behaviour change: only lint attributes, `unsafe { }` scoping and comments.

Linux `cargo test -p pf-capture` 6/6, clippy --all-targets clean on both targets (Windows
verified by cross-checking x86_64-pc-windows-msvc locally).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:18:48 +02:00
enricobuehlerandClaude Opus 5 bf9386ecb2 test(pf-encode): pin the typed-EINVAL classifier's chain-survival contract (Phase 8)
ci / docs-site (push) Failing after 52s
android / android (push) Failing after 6m28s
ci / web (push) Successful in 1m5s
ci / bench (push) Successful in 6m40s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
arch / build-publish (push) Successful in 12m51s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / rust-arm64 (push) Successful in 9m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 17s
deb / build-publish-client-arm64 (push) Successful in 8m54s
deb / build-publish-host (push) Successful in 10m31s
deb / build-publish (push) Successful in 11m17s
windows-host / package (push) Successful in 10m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m25s
docker / build-push-arm64cross (push) Skipped
docker / deploy-docs (push) Skipped
apple / swift (push) Successful in 5m25s
ci / rust (push) Successful in 22m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m7s
apple / screenshots (push) Successful in 23m50s
Rides on 25765c53 (the concurrent session landed the same typed-errno
fix first — this branch's twin commit was dropped in the rebase; these
are its surviving residuals):
- Two tests pinning what the ladder's step-down rests on: the typed
  ffmpeg::Error survives with_context layers as a downcastable source
  (an eager format! anywhere between open_with and the ladder would
  silently break it), and the phrase WITHOUT the type no longer
  classifies.
- The CudaHw::new eager-format guard note: those bail!s must NOT be
  converted to typed errors — a hwdevice/hwframes EINVAL is a config
  error no bitrate can fix, and enrolling it would burn ~10 doomed
  opens before surfacing the real failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:13:49 +02:00
enricobuehlerandClaude Opus 5 bf9fb3fb22 fix(pf-encode): enable VK_EXT_queue_family_foreign for the dmabuf acquires (Phase 8)
Both Linux Vulkan encode backends named QUEUE_FAMILY_FOREIGN_EXT as the
acquire barriers' src family without ever enabling the extension —
spec-invalid on every device, tolerated by RADV. The audit filed
vulkan_video's three sites; pyrowave's fresh-import acquire had the
identical defect on its own device (critic catch).

Enable when advertised (a fresh open-time enumerate — the rgb probe's
is a probe-local and skipped entirely on native-NV12, so there was
nothing to reuse; pf-presenter/dmabuf.rs is the in-repo precedent that
already enables this extension). Not advertised → the core-1.1
QUEUE_FAMILY_EXTERNAL conservative substitute, chosen once at open and
warn-logged (no fleet hardware takes that arm; such devices were never
valid targets before). All four sites are acquire-only (src=FOREIGN,
EXCLUSIVE images, oldLayout=UNDEFINED) — the swap is index-only.

On-glass: 780M under validation layers — vulkan smokes + pyrowave
smokes green, FOREIGN advertised and enabled, no fallback engaged.
Shared ext_advertised helper in vk_util (cfg = the union of both
consumers) with a unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:08:44 +02:00
enricobuehlerandClaude Opus 5 1cefd37603 fix(pf-encode): arbitrate NVENC split-encode vs sub-frame readback (Phase 8)
Verified against nvEncodeAPI.h's own splitEncodeMode doc (user-prompted
— the audit's 'exclusive for HEVC' one-liner deserved checking):
- H.264: split 'is not applicable' — hard-DISABLE the mode so the
  written config, CeilingKey, the split diagnostic log and the
  rejection-retry stay truthful (the retry used to re-open a
  byte-identical session after an H.264 'split rejection'). The libav
  path's operator arm gains the codec gate its auto arm always had.
- HEVC: split 'not supported if … subframe mode' — when WE force split
  (TWO/THREE/AUTO_FORCED, the 4K120 throughput lever), sub-frame yields
  with a logged escape (PUNKTFUNK_SPLIT_ENCODE=0 chooses sub-frame).
  ⚠ Keyed on FORCED modes only, never != DISABLE: AUTO(0) is the
  resolver's fallthrough for every sub-950Mpix session, and the wider
  key would have disarmed the Phase-3 chunked-poll feature fleet-wide
  (critic catch). Under AUTO the driver arbitrates — the shipped state.
- AV1: untouched — per-tile sub-frame + split are legal together.

The arbitration is a pure nvenc_core fn called by each backend BEFORE
the ladder, the ceiling key and the chunked-poll latch — all three see
the post-arbitration truth. A drop inside build_init_params would have
left poll_chunk busy-polling its whole budget every AU (numSlices stays
0 without reportSliceOffsets; both loop exits dead — critic catch).
Linux latches subframe_forced beside subframe_on at query_caps (no env
re-reads after open); Windows records the arbitrated state so
reconfigure presents exactly the params the open had (also closes the
pre-existing mid-session env-flip hazard there).

Truth-table tests in nvenc_core; PUNKTFUNK_NVENC_SUBFRAME documented
(it never was); PUNKTFUNK_SPLIT_ENCODE row updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:08:44 +02:00
enricobuehlerandClaude Fable 5 25765c53ec fix(encode): classify libav-NVENC open failures by errno, not English strerror text
ci / docs-site (push) Successful in 58s
ci / web (push) Successful in 59s
apple / swift (push) Successful in 5m17s
ci / bench (push) Successful in 7m27s
deb / build-publish (push) Failing after 8m0s
ci / rust-arm64 (push) Failing after 9m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
ci / rust (push) Failing after 9m31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish-host (push) Successful in 10m23s
arch / build-publish (push) Successful in 12m54s
android / android (push) Successful in 15m8s
deb / build-publish-client-arm64 (push) Successful in 7m54s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 5m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 6m35s
windows-host / package (push) Successful in 19m10s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 24s
apple / screenshots (push) Successful in 23m39s
The bitrate-probe ladder stepped down on format!("{e:#}").contains(
"Invalid argument") — an English substring over the WHOLE context chain,
which also fired on any other wrapped EINVAL (e.g. a CUDA-context errno)
and gated a ~10-step ladder on strerror wording. The root ffmpeg::Error
survives the anyhow chain; downcast and match Error::Other{errno:EINVAL}
instead. Same fix for the intra-refresh ENOSYS probe in the open path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:05:24 +02:00
enricobuehlerandClaude Fable 5 36589c39da fix(gamestream): stall-recovery ladder — a wedged encoder no longer ends the stream
The backends deliberately turn a wedged GPU into a bounded error so the
caller can reset in place, but only the native plane ever did: on the
GameStream path every submit/poll error propagated straight out of the
video loop, costing Moonlight clients a full disconnect/reconnect. Port
the native ladder (bounded resets + backoff + silent-wedge watchdog,
same field-lesson backoff curve).

Also keep the drop-recovery keyframe armed until it actually EMITS: it
was consumed on read, so the coalesce gate could swallow it for good —
leaving duplicate wire indices in the encoder's reference table for a
later RFI to anchor on, exactly the stale-anchor case rfi.rs exists to
prevent, and it opened only under congestion + loss, which is when RFI
fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:05:23 +02:00
enricobuehlerandClaude Fable 5 e3354b6d5d fix(encode/vulkan): guard the CSC source dimensions, and bound reset()'s wait
The CSC path was the only backend arm that took frame.width/height on
trust: the shader samples with clamped 1:1 texelFetch, so a mismatched
frame silently streamed a cropped/edge-padded picture where every
sibling errors into the encoder-rebuild path. The import cache now also
carries the extent it imported at (a (st_dev, st_ino) hit alone doesn't
prove the allocation still matches) and is dropped on reset().

reset() opened with an untimed device_wait_idle on the one thread whose
every other wait is capped at ENCODE_FENCE_TIMEOUT_NS for exactly this
reason — reset() runs BECAUSE the GPU looks wedged. Both vulkan-video
and pyrowave now bound the wait and report "no in-place rebuild" on
timeout instead of parking recovery on the suspect device; Drop keeps
the unbounded wait (teardown must stay memory-safe against a wedged
device).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:05:10 +02:00
43 changed files with 9265 additions and 5122 deletions
+46 -13
View File
@@ -172,24 +172,57 @@ jobs:
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason # openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
# pf-vkhdr-layer's clippy below runs --release. # pf-vkhdr-layer's clippy below runs --release.
# #
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules # pf-encode and pf-capture are linted SEPARATELY with --all-targets so their Windows
# are type-checked — the AMF C-ABI layout assertions (`variant_layout_matches_c` and # `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
# friends, which are the only guard on a hand-mirrored vtable ABI), the QSV tests, and the # (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host` # vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
# only builds pf-encode as a dependency, so its test targets are never compiled, and that # tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed. # cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test` # test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports # tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against # feature juggling and pulls in no extra dep tree.
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does # NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build # vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not # link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
# worth it while ci.yml executes the same tests. # 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: | run: |
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" } 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-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" } 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) - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh shell: pwsh
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games # Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
+104 -26
View File
@@ -7,10 +7,12 @@
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host //! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
//! orchestrator). //! 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). // Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![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 anyhow::Result;
use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
@@ -19,9 +21,16 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use pf_frame::DmabufFrame; use pf_frame::DmabufFrame;
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder /// Produces frames from a captured output. Lives on its own thread, handing frames over without
/// over a bounded drop-oldest channel (never block the compositor). /// 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 { 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>; fn next_frame(&mut self) -> Result<CapturedFrame>;
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the /// [`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 /// 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 /// `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 /// 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 /// 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 /// loop's `try_latest` call does that — so a backend implements this by waiting on a wakeup
/// can't be peeked. Only called when [`supports_arrival_wait`](Self::supports_arrival_wait) /// and then PEEKING its hand-off slot. Only called when
/// is `true`; errors surface at the following `try_latest`. /// [`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) {} 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 /// 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; /// streams without burning CPU. The portal capturer skips the de-pad copy while inactive and
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the /// flushes its frame mailbox on `false`; the default is a no-op (synthetic sources are produced
/// duration of a stream, `false` when it ends. /// on demand). Set `true` for the duration of a stream, `false` when it ends.
fn set_active(&self, _active: bool) {} ///
/// `&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 capture source's LIVE cursor state, when it arrives out-of-band from the frames
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and /// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves /// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
@@ -90,13 +123,21 @@ pub trait Capturer: Send {
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no /// 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 /// `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 /// portal capturer a way to reach gamescope's nested Xwaylands (it may run several — one per
/// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11 /// `--xwayland-count`) so it reads the pointer shape/position over X11 (XFixes +
/// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that /// QueryPointer), following whichever display is focused, and publishes it into that same slot.
/// same slot. Called once, after the capturer is built, only for gamescope sessions. Default /// Called once, after the capturer is built, only for gamescope sessions. Default no-op: every
/// no-op: every non-gamescope capturer already has a cursor source. /// non-gamescope capturer already has a cursor source.
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {} #[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> { fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
None None
} }
@@ -111,10 +152,18 @@ pub trait Capturer: Send {
1 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 /// 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 /// 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 /// 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). /// 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> { fn capture_target_id(&self) -> Option<u32> {
None None
} }
@@ -125,6 +174,8 @@ pub trait Capturer: Send {
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive; /// 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 /// 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. /// `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 { fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
false false
} }
@@ -299,6 +350,23 @@ pub struct ZeroCopyPolicy {
pub pyrowave_modifiers: Vec<u64>, 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")] #[cfg(target_os = "linux")]
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool { pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
true true
@@ -447,15 +515,25 @@ pub mod synthetic_nv12;
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR /// `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 /// 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. /// 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")] #[cfg(target_os = "linux")]
pub fn open_portal_monitor( pub fn open_portal_monitor(
anchored: bool, anchored: bool,
want_hdr: bool, want_hdr: bool,
want_metadata_cursor: bool,
policy: ZeroCopyPolicy, policy: ZeroCopyPolicy,
) -> Result<Box<dyn Capturer>> { ) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy) linux::PortalCapturer::open(
.map(|c| Box::new(c) as Box<dyn Capturer>) anchored,
want_hdr && !hdr_capture_failed(),
want_metadata_cursor,
policy,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
} }
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The /// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+357
View File
@@ -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);
}
+634
View File
@@ -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·(bh1) + 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·(bh1)` 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));
}
}
+515
View File
@@ -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"
);
}
}
+457 -67
View File
@@ -37,7 +37,7 @@
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had. //! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
use std::sync::{ use std::sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex, Arc, Mutex,
}; };
use std::time::Duration; use std::time::Duration;
@@ -51,12 +51,29 @@ use x11rb::protocol::xproto::{
Window, Window,
}; };
use x11rb::protocol::Event; 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 use crate::GamescopeCursorTargets;
/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds.
/// 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(()); 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. /// 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 /// 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. /// and must out-run a 240 fps session or the pointer stutters.
@@ -73,62 +90,64 @@ const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll. /// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250); const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the /// A running XFixes cursor reader. Dropping it stops the worker thread and waits — bounded — for it
/// X connections so it lives exactly as long as the capturer that owns it. /// to release the X connections, so it lives (at most) as long as the capturer that owns it.
pub(super) struct XFixesCursorSource { pub(super) struct XFixesCursorSource {
stop: Arc<AtomicBool>, 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<()>>, join: Option<std::thread::JoinHandle<()>>,
} }
impl XFixesCursorSource { impl XFixesCursorSource {
/// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start /// Start publishing cursor overlays into `slot` from whichever of gamescope's nested Xwaylands
/// publishing cursor overlays into `slot`, following the focused display's pointer. Returns /// it is drawing the pointer on. `targets` is re-run every [`REDISCOVER`] to adopt Xwaylands
/// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller /// that appear later and retry dead ones; `frame_size` is the negotiated capture size, packed
/// degrades to no gamescope cursor (today's behaviour) instead of failing the session. /// `(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( pub(super) fn spawn(
targets: Vec<(String, Option<String>)>, targets: GamescopeCursorTargets,
slot: Arc<Mutex<Option<CursorOverlay>>>, slot: Arc<Mutex<Option<CursorOverlay>>>,
frame_size: Arc<AtomicU64>,
) -> Option<Self> { ) -> Option<Self> {
// Connect on the caller's thread so failures degrade cleanly and the displays are validated // First pass on the caller's thread: the common case connects here, so the log line below
// before we commit a thread. // reports the real state instead of "starting…".
let mut displays = Vec::new(); let mut displays = Vec::new();
for (dpy, xauth) in targets { rediscover(&mut displays, &targets, true);
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;
}
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect(); let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
let feedback = displays.iter().any(|d| d.gs_visible.is_some()); let feedback = displays.iter().any(|d| d.gs_visible.is_some());
tracing::info!( if displays.is_empty() {
displays = ?names, tracing::warn!(
cursor_feedback = feedback, "gamescope cursor: no usable nested Xwayland yet — retrying every {}s (a game's \
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \ Xwayland appears when it launches)",
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \ REDISCOVER.as_secs()
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)" );
); } else {
tracing::info!(
displays = ?names,
cursor_feedback = feedback,
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
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 = Arc::new(AtomicBool::new(false));
let stop_worker = Arc::clone(&stop); let stop_worker = Arc::clone(&stop);
let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(1);
let join = std::thread::Builder::new() let join = std::thread::Builder::new()
.name("pf-gs-cursor".into()) .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()?; .ok()?;
Some(XFixesCursorSource { Some(XFixesCursorSource {
stop, stop,
done: done_rx,
join: Some(join), join: Some(join),
}) })
} }
@@ -137,35 +156,69 @@ impl XFixesCursorSource {
impl Drop for XFixesCursorSource { impl Drop for XFixesCursorSource {
fn drop(&mut self) { fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed); 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() { 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 — /// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
/// returning the connection, root window and this display's initial /// returning the connection, root window, the root's pixel size (for [`scale_to_frame`]) and this
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None` /// display's initial [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`;
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the /// the value is `None` when gamescope publishes no such property here).
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect, type Connected = (RustConnection, Window, (u16, u16), (Atom, Option<bool>));
/// then restore.
type Connected = (RustConnection, Window, (Atom, Option<bool>));
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> { fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
let (conn, screen_num) = { let (conn, screen_num) = connect_conn(dpy, xauthority)?;
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}"))?
};
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get. // XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
conn.xfixes_query_version(5, 0) 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()) .and_then(|c| c.reply())
.map_err(|e| format!("XFixes unavailable: {e}"))?; .map_err(|e| format!("XFixes unavailable: {e}"))?;
let root = conn let screen = conn
.setup() .setup()
.roots .roots
.get(screen_num) .get(screen_num)
.ok_or_else(|| format!("no X screen {screen_num}"))? .ok_or_else(|| format!("no X screen {screen_num}"))?;
.root; 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). // Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR) 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 _ = conn.flush();
let feedback = read_cursor_feedback(&conn, root, feedback_atom); 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 /// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
@@ -227,6 +420,9 @@ struct XDisplay {
name: String, name: String,
conn: RustConnection, conn: RustConnection,
root: Window, 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 polled pointer position — a change since the previous tick marks this display FOCUSED.
last_pos: Option<(i32, i32)>, last_pos: Option<(i32, i32)>,
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`. /// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
@@ -247,12 +443,14 @@ impl XDisplay {
name: String, name: String,
conn: RustConnection, conn: RustConnection,
root: Window, root: Window,
root_size: (u16, u16),
(feedback_atom, gs_visible): (Atom, Option<bool>), (feedback_atom, gs_visible): (Atom, Option<bool>),
) -> Self { ) -> Self {
XDisplay { XDisplay {
name, name,
conn, conn,
root, root,
root_size,
last_pos: None, last_pos: None,
shape: Shape::default(), shape: Shape::default(),
need_shape: true, need_shape: true,
@@ -288,11 +486,39 @@ struct Shape {
visible: bool, 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( fn run(
mut displays: Vec<XDisplay>, mut displays: Vec<XDisplay>,
slot: Arc<Mutex<Option<CursorOverlay>>>, slot: Arc<Mutex<Option<CursorOverlay>>>,
stop: Arc<AtomicBool>, 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; let mut active = 0usize;
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's // 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 // shape OR which display is active (per-display XFixes serials aren't comparable across
@@ -309,6 +535,11 @@ fn run(
if resync { if resync {
last_resync = std::time::Instant::now(); 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 // 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). // 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. // a `None` here would leave the last visible overlay standing on repeat frames.
let d = &displays[active]; let d = &displays[active];
let drawn = d.shape.visible && !hidden_by_gamescope; 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()) { 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); let key = (active, d.shape.serial);
if key != last_key { if key != last_key {
out_serial += 1; out_serial += 1;
@@ -511,7 +761,147 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
#[cfg(test)] #[cfg(test)]
mod tests { 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. /// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
const BPM: usize = 0; const BPM: usize = 0;
File diff suppressed because it is too large Load Diff
@@ -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 600800 ms schedules bound how often it can happen.
///
/// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual
/// HID 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
// 600800 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,57 +57,62 @@ pub(super) struct CursorBlendPass {
impl CursorBlendPass { impl CursorBlendPass {
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> { pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?; // SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?; // fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
let mut vs = None; // literals (its contract).
device.CreateVertexShader(&vsb, None, Some(&mut vs))?; unsafe {
let mut ps = None; let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
device.CreatePixelShader(&psb, None, Some(&mut ps))?; let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
let sd = D3D11_SAMPLER_DESC { let mut vs = None;
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
// half-texel edges; linear keeps them soft instead of ringing. let mut ps = None;
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR, device.CreatePixelShader(&psb, None, Some(&mut ps))?;
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, let sd = D3D11_SAMPLER_DESC {
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, // LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP, // half-texel edges; linear keeps them soft instead of ringing.
ComparisonFunc: D3D11_COMPARISON_NEVER, Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
MaxLOD: f32::MAX, AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
..Default::default() AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
}; AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
let mut sampler = None; ComparisonFunc: D3D11_COMPARISON_NEVER,
device.CreateSamplerState(&sd, Some(&mut sampler))?; MaxLOD: f32::MAX,
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha. ..Default::default()
let mut bd = D3D11_BLEND_DESC::default(); };
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC { let mut sampler = None;
BlendEnable: true.into(), device.CreateSamplerState(&sd, Some(&mut sampler))?;
SrcBlend: D3D11_BLEND_SRC_ALPHA, // Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
DestBlend: D3D11_BLEND_INV_SRC_ALPHA, let mut bd = D3D11_BLEND_DESC::default();
BlendOp: D3D11_BLEND_OP_ADD, bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
SrcBlendAlpha: D3D11_BLEND_ONE, BlendEnable: true.into(),
DestBlendAlpha: D3D11_BLEND_ONE, SrcBlend: D3D11_BLEND_SRC_ALPHA,
BlendOpAlpha: D3D11_BLEND_OP_ADD, DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
RenderTargetWriteMask: 0x0F, BlendOp: D3D11_BLEND_OP_ADD,
}; SrcBlendAlpha: D3D11_BLEND_ONE,
let mut blend = None; DestBlendAlpha: D3D11_BLEND_ONE,
device.CreateBlendState(&bd, Some(&mut blend))?; BlendOpAlpha: D3D11_BLEND_OP_ADD,
let cbd = D3D11_BUFFER_DESC { RenderTargetWriteMask: 0x0F,
ByteWidth: 16, // float to_linear + float3 pad };
Usage: D3D11_USAGE_DYNAMIC, let mut blend = None;
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, device.CreateBlendState(&bd, Some(&mut blend))?;
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, let cbd = D3D11_BUFFER_DESC {
..Default::default() ByteWidth: 16, // float to_linear + float3 pad
}; Usage: D3D11_USAGE_DYNAMIC,
let mut cbuf = None; BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?; CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
Ok(Self { ..Default::default()
vs: vs.context("cursor blend vs")?, };
ps: ps.context("cursor blend ps")?, let mut cbuf = None;
sampler: sampler.context("cursor blend sampler")?, device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
blend: blend.context("cursor blend state")?, Ok(Self {
cbuf: cbuf.context("cursor blend cbuf")?, vs: vs.context("cursor blend vs")?,
cbuf_scale: None, ps: ps.context("cursor blend ps")?,
shape: None, sampler: sampler.context("cursor blend sampler")?,
}) blend: blend.context("cursor blend state")?,
cbuf: cbuf.context("cursor blend cbuf")?,
cbuf_scale: None,
shape: None,
})
}
} }
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise. /// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
@@ -116,42 +121,48 @@ impl CursorBlendPass {
device: &ID3D11Device, device: &ID3D11Device,
ov: &pf_frame::CursorOverlay, ov: &pf_frame::CursorOverlay,
) -> Result<()> { ) -> Result<()> {
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) { // SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
return Ok(()); // `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(());
}
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
}
let desc = D3D11_TEXTURE2D_DESC {
Width: ov.w,
Height: ov.h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
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: ov.rgba.as_ptr().cast(),
SysMemPitch: ov.w * 4,
SysMemSlicePitch: 0,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
.context("CreateTexture2D(cursor shape)")?;
let tex = tex.context("null cursor shape texture")?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&tex, None, Some(&mut srv))
.context("CreateShaderResourceView(cursor shape)")?;
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
Ok(())
} }
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
}
let desc = D3D11_TEXTURE2D_DESC {
Width: ov.w,
Height: ov.h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
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: ov.rgba.as_ptr().cast(),
SysMemPitch: ov.w * 4,
SysMemSlicePitch: 0,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
.context("CreateTexture2D(cursor shape)")?;
let tex = tex.context("null cursor shape texture")?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&tex, None, Some(&mut srv))
.context("CreateShaderResourceView(cursor shape)")?;
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). /// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
@@ -167,50 +178,63 @@ impl CursorBlendPass {
ov: &pf_frame::CursorOverlay, ov: &pf_frame::CursorOverlay,
linear_scale: f32, linear_scale: f32,
) -> Result<()> { ) -> Result<()> {
self.ensure_shape(device, ov)?; // SAFETY: all D3D11 work on the caller's live `device`/`ctx` borrows. The
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured"); // `copy_nonoverlapping` writes `cb.len()` `f32`s into the pointer the immediately preceding
if self.cbuf_scale != Some(linear_scale) { // `Map` of `self.cbuf` (16 bytes = 4×`f32`, DYNAMIC/WRITE_DISCARD) returned, inside the
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0]; // `is_ok()` arm and before the paired `Unmap`. `ensure_shape` forwards this fn's `device`
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); // borrow, and every `*Set*`/`Draw` takes borrowed slices of live locals or clones of live COM
if ctx // interfaces.
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped)) unsafe {
.is_ok() self.ensure_shape(device, ov)?;
{ let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len()); if self.cbuf_scale != Some(linear_scale) {
ctx.Unmap(&self.cbuf, 0); let cb: [f32; 4] = [linear_scale, 0.0, 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);
// 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);
}
} }
self.cbuf_scale = Some(linear_scale); let mut rtv: Option<ID3D11RenderTargetView> = None;
} device
let mut rtv: Option<ID3D11RenderTargetView> = None; .CreateRenderTargetView(dst, None, Some(&mut rtv))
device .context("CreateRenderTargetView(cursor blend scratch)")?;
.CreateRenderTargetView(dst, None, Some(&mut rtv)) let rtv = rtv.context("null cursor blend rtv")?;
.context("CreateRenderTargetView(cursor blend scratch)")?;
let rtv = rtv.context("null cursor blend rtv")?;
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None); ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff); ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
ctx.VSSetShader(&self.vs, None); ctx.VSSetShader(&self.vs, None);
ctx.PSSetShader(&self.ps, None); ctx.PSSetShader(&self.ps, None);
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())])); ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())])); ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
ctx.IASetInputLayout(None); ctx.IASetInputLayout(None);
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// Placement IS the viewport: the VS fills it, the OS clips it to the target. // Placement IS the viewport: the VS fills it, the OS clips it to the target.
let vp = D3D11_VIEWPORT { let vp = D3D11_VIEWPORT {
TopLeftX: ov.x as f32, TopLeftX: ov.x as f32,
TopLeftY: ov.y as f32, TopLeftY: ov.y as f32,
Width: *w as f32, Width: *w as f32,
Height: *h as f32, Height: *h as f32,
MinDepth: 0.0, MinDepth: 0.0,
MaxDepth: 1.0, MaxDepth: 1.0,
}; };
ctx.RSSetViewports(Some(&[vp])); ctx.RSSetViewports(Some(&[vp]));
ctx.Draw(3, 0); ctx.Draw(3, 0);
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning. // Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
ctx.OMSetRenderTargets(None, None); ctx.OMSetRenderTargets(None, None);
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None]; let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
ctx.PSSetShaderResources(0, Some(&none_srv)); ctx.PSSetShaderResources(0, Some(&none_srv));
Ok(()) Ok(())
}
} }
} }
@@ -85,11 +85,23 @@ impl CursorPoller {
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop` /// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
/// syscalls/s are not. /// syscalls/s are not.
const REATTACH: Duration = Duration::from_millis(250); 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 /// (`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` /// 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). /// (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 { 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 slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
let stop = Arc::new(AtomicBool::new(false)); 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. /// The poll loop. Owns the thread's input-desktop binding and the shape cache.
fn run( fn run(
target_id: u32, target_id: u32,
rect: (i32, i32, i32, i32), mut rect: (i32, i32, i32, i32),
slot: &Mutex<Option<pf_frame::CursorOverlay>>, slot: &Mutex<Option<pf_frame::CursorOverlay>>,
stop: &AtomicBool, stop: &AtomicBool,
secure: &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 failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
let mut serial: u64 = 0; let mut serial: u64 = 0;
let mut logged_live = false; let mut logged_live = false;
let mut last_extent = Instant::now();
while !stop.load(Ordering::Relaxed) { while !stop.load(Ordering::Relaxed) {
std::thread::sleep(CursorPoller::INTERVAL); std::thread::sleep(CursorPoller::INTERVAL);
if last_attach.elapsed() >= CursorPoller::REATTACH { if last_attach.elapsed() >= CursorPoller::REATTACH {
last_attach = Instant::now(); last_attach = Instant::now();
publish_secure(secure, desktop.reattach()); 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 { let mut ci = CURSORINFO {
@@ -191,6 +226,42 @@ fn run(
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS // 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. // behavior); frame cycling via DrawIconEx istep is a known follow-up.
let handle = ci.hCursor.0 as isize; 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 { if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
match rasterize(ci.hCursor) { match rasterize(ci.hCursor) {
Some((rgba, w, h, hot_x, hot_y)) => { 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)>; 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: /// Convert the ICONINFO bitmaps to straight RGBA. Two families:
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style /// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
/// cursors) the AND mask supplies it (mask bit 1 = transparent). /// 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 color = read_bitmap_32(dc, ii.hbmColor)?;
let (w, h) = (color.w as u32, color.h as u32); let (w, h) = (color.w as u32, color.h as u32);
let mut rgba = bgra_to_rgba(&color.bgra); 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. // Alpha-less color cursor: transparency lives in the AND mask.
let mask = read_bitmap_32(dc, ii.hbmMask)?; let mask = read_bitmap_32(dc, ii.hbmMask)?;
if mask.w != color.w || mask.h < color.h { if mask.w != color.w || mask.h < color.h {
return None; return None;
} }
for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) { apply_and_mask_alpha(&mut rgba, &mask.bgra);
px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent
}
} }
Some((rgba, w, h)) Some((rgba, w, h))
} else { } else {
@@ -389,42 +509,8 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
return None; return None;
} }
let (w, h) = (mask.w as usize, (mask.h / 2) as usize); 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 * w * 4);
let (and_plane, xor_plane) = mask.bgra.split_at(h * row); let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h);
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]);
}
}
}
}
Some((rgba, w as u32, h as u32)) Some((rgba, w as u32, h as u32))
} }
})(); })();
@@ -506,3 +592,204 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
} }
out 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 ringmonitor 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). /// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
recent: std::collections::VecDeque<Instant>, recent: std::collections::VecDeque<Instant>,
cadence: pf_frame::metronome::Metronome, 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 { impl StallWatch {
@@ -48,6 +52,8 @@ impl StallWatch {
Self { Self {
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1), recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
cadence: pf_frame::metronome::Metronome::new(), cadence: pf_frame::metronome::Metronome::new(),
seen: 0,
with_os_events: 0,
} }
} }
@@ -79,4 +85,78 @@ impl StallWatch {
metronomic: self.cadence.note(now), 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,13 +140,17 @@ impl Capturer for SyntheticNv12Capturer {
/// # Safety /// # Safety
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error. /// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> { unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?; // SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() { // created here and the adapters it returns own their own COM references. No raw pointers.
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) { unsafe {
return Ok(a); let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
return Ok(a);
}
} }
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
} }
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
} }
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags. /// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
@@ -177,8 +181,12 @@ unsafe fn create_nv12(
..Default::default() ..Default::default()
}; };
let mut tex: Option<ID3D11Texture2D> = None; let mut tex: Option<ID3D11Texture2D> = None;
device // SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
.CreateTexture2D(&desc, None, Some(&mut tex)) // above), with a fully-initialized stack descriptor and a live `Option` out-param.
.context("CreateTexture2D(NV12)")?; unsafe {
device
.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(NV12)")?;
}
tex.context("CreateTexture2D returned a null NV12 texture") tex.context("CreateTexture2D returned a null NV12 texture")
} }
+25 -18
View File
@@ -249,9 +249,16 @@ impl Codec {
} }
} }
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR /// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed /// cursor plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters). /// `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)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EncoderCaps { pub struct EncoderCaps {
/// The encoder can perform real reference-frame invalidation — i.e. /// 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 /// 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. /// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
pub supports_rfi: bool, 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. /// 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 /// `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`) /// 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 /// 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 /// 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). /// host's call, since only the host can re-plan capture. That call is wired now — the
/// `open_video` can only warn, which it does. /// 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, pub blends_cursor: bool,
} }
@@ -333,11 +339,10 @@ pub trait Encoder: Send {
let _ = wire_index; let _ = wire_index;
self.submit(frame) self.submit(frame)
} }
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can /// This encoder's static [capabilities](EncoderCaps) (RFI, intra-refresh, chroma, cursor
/// route by query rather than rely on the no-op/`false` defaults of /// blending), so the session glue can route by query rather than rely on the no-op/`false`
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta). /// defaults of methods like [`invalidate_ref_frames`](Self::invalidate_ref_frames).
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC /// Default: no optional capabilities (the software / libavcodec backends).
/// path overrides it.
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
EncoderCaps::default() EncoderCaps::default()
} }
@@ -345,10 +350,12 @@ pub trait Encoder: Send {
/// reference-frame-invalidation request). Default: no-op. /// reference-frame-invalidation request). Default: no-op.
fn request_keyframe(&mut self) {} fn request_keyframe(&mut self) {}
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it /// 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 /// in-band (HEVC/H.264 `mastering_display_colour_volume` + `content_light_level_info` SEI, or
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade. /// AV1 metadata OBUs) on keyframes so a stock decoder — e.g. stock Moonlight — tone-maps from
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call /// the source's real grade. Default: no-op (SDR encoders / paths that don't attach it).
/// every frame; only the direct-NVENC path consumes 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>) {} fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers /// 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 /// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
+25 -2
View File
@@ -66,6 +66,11 @@ struct CudaHw {
impl CudaHw { impl CudaHw {
/// Build a CUDA hwdevice wrapping `cu_ctx` and a frames pool (`sw_format` = `pixel`). /// Build a CUDA hwdevice wrapping `cu_ctx` and a frames pool (`sw_format` = `pixel`).
///
/// The `bail!`s below format raw AVERROR ints eagerly BY DESIGN — do not convert them to
/// typed errors: `open_nvenc_probed`'s bitrate ladder steps down on a typed EINVAL
/// (`nvenc_open_einval`), and a hwdevice/hwframes EINVAL is a config error no bitrate can
/// fix — enrolling it would burn ~10 doomed encoder opens before surfacing the real failure.
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> { unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
let mut device_ref = ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA); let mut device_ref = ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA);
if device_ref.is_null() { if device_ref.is_null() {
@@ -448,7 +453,17 @@ impl NvencEncoder {
let pix_rate = width as u64 * height as u64 * fps as u64; let pix_rate = width as u64 * height as u64 * fps as u64;
let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok(); let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
match split.as_deref() { match split.as_deref() {
Some(mode) => opts.set("split_encode_mode", mode), // The operator arm gains the codec gate the auto arm always had (Phase 8): split
// "is not applicable to H264" per nvEncodeAPI.h, and h264_nvenc has no such AVOption
// — setting it would fail the open on a leftover dict entry.
Some(mode) if matches!(codec, Codec::H265 | Codec::Av1) => {
opts.set("split_encode_mode", mode)
}
Some(_) => tracing::warn!(
codec = codec.nvenc_name(),
"PUNKTFUNK_SPLIT_ENCODE ignored — split encoding is not applicable to H.264 \
(nvEncodeAPI.h)"
),
None if matches!(codec, Codec::H265 | Codec::Av1) None if matches!(codec, Codec::H265 | Codec::Av1)
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE => && pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
{ {
@@ -468,7 +483,15 @@ impl NvencEncoder {
// sessions) and reopen this session without intra-refresh; any other failure — and // sessions) and reopen this session without intra-refresh; any other failure — and
// any failure when IR wasn't requested — propagates untouched (the bitrate probe // any failure when IR wasn't requested — propagates untouched (the bitrate probe
// keys on EINVAL, which must not trip the latch). // keys on EINVAL, which must not trip the latch).
Err(e) if intra_refresh && format!("{e:#}").contains("Function not implemented") => { Err(e)
if intra_refresh
&& matches!(
e,
ffmpeg::Error::Other {
errno: ffmpeg::util::error::ENOSYS
}
) =>
{
tracing::warn!( tracing::warn!(
encoder = name, encoder = name,
"NVENC intra-refresh not supported by this GPU — falling back to IDR-only \ "NVENC intra-refresh not supported by this GPU — falling back to IDR-only \
+18 -3
View File
@@ -62,8 +62,8 @@
use super::nvenc_core::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery, apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
resolve_slices, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling,
LowLatencyConfig, NvStatusExt, RangePlan, subframe_env_forced, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
}; };
use super::nvenc_status; use super::nvenc_status;
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -629,6 +629,10 @@ pub struct NvencCudaEncoder {
/// [`subframe_cap`](Self::subframe_cap)); consumed by every `build_init_params` call so the /// [`subframe_cap`](Self::subframe_cap)); consumed by every `build_init_params` call so the
/// open and the in-place reconfigure present identical init params. /// open and the in-place reconfigure present identical init params.
subframe_on: bool, subframe_on: bool,
/// Whether `PUNKTFUNK_NVENC_SUBFRAME=1` was EXPLICITLY forced — latched with `subframe_on`
/// (same invariant: open and reconfigure must present identical init params, so no env
/// re-reads after `query_caps`). Only consumed by [`resolve_split_subframe`]'s log severity.
subframe_forced: bool,
/// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice + /// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice +
/// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`]. /// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`].
subframe_chunks: bool, subframe_chunks: bool,
@@ -721,6 +725,7 @@ impl NvencCudaEncoder {
slices: 1, slices: 1,
subframe_cap: false, subframe_cap: false,
subframe_on: false, subframe_on: false,
subframe_forced: false,
subframe_chunks: false, subframe_chunks: false,
chunk: None, chunk: None,
}) })
@@ -921,6 +926,7 @@ impl NvencCudaEncoder {
// escapes. // escapes.
self.slices = resolve_slices(self.codec, 4); self.slices = resolve_slices(self.codec, 4);
self.subframe_on = resolve_subframe(self.subframe_cap); self.subframe_on = resolve_subframe(self.subframe_cap);
self.subframe_forced = subframe_env_forced();
tracing::info!( tracing::info!(
rfi = self.rfi_supported, rfi = self.rfi_supported,
custom_vbv = self.custom_vbv, custom_vbv = self.custom_vbv,
@@ -1136,6 +1142,16 @@ impl NvencCudaEncoder {
// [`resolve_split_mode`] for the precedence (env override / 10-bit / pixel rate). // [`resolve_split_mode`] for the precedence (env override / 10-bit / pixel rate).
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64; let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate); let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
// Split × sub-frame arbitration (Phase 8) BEFORE the ladder, the ceiling key and the
// chunked-poll latch — all three must see the post-arbitration truth (a drop inside
// build_init_params would leave poll_chunk busy-polling its whole budget per AU).
let (split_mode, subframe_on) = resolve_split_subframe(
self.codec,
split_mode,
self.subframe_on,
self.subframe_forced,
);
self.subframe_on = subframe_on;
const CLAMP_TOL_BPS: u64 = 20_000_000; const CLAMP_TOL_BPS: u64 = 20_000_000;
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found // Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
@@ -1834,7 +1850,6 @@ impl Encoder for NvencCudaEncoder {
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot. // Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
blends_cursor: true, blends_cursor: true,
supports_rfi: self.rfi_supported, supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr,
chroma_444: self.chroma_444, chroma_444: self.chroma_444,
intra_refresh: false, intra_refresh: false,
intra_refresh_recovery: false, intra_refresh_recovery: false,
+70 -12
View File
@@ -314,7 +314,9 @@ struct DeviceHold {
instance_ci: Box<vk::InstanceCreateInfo<'static>>, instance_ci: Box<vk::InstanceCreateInfo<'static>>,
_queue_prio: Box<[f32; 1]>, _queue_prio: Box<[f32; 1]>,
_queue_ci: Box<[vk::DeviceQueueCreateInfo<'static>; 1]>, _queue_ci: Box<[vk::DeviceQueueCreateInfo<'static>; 1]>,
_dev_exts: Box<[*const c_char; 3]>, // A plain Vec (not Box<[_; N]> like its siblings): Phase 8 pushes queue_family_foreign
// conditionally. The heap buffer as_ptr() feeds device_ci is move-stable like the Boxes.
_dev_exts: Vec<*const c_char>,
_feat2: Box<vk::PhysicalDeviceFeatures2<'static>>, _feat2: Box<vk::PhysicalDeviceFeatures2<'static>>,
_v12: Box<vk::PhysicalDeviceVulkan12Features<'static>>, _v12: Box<vk::PhysicalDeviceVulkan12Features<'static>>,
_v13: Box<vk::PhysicalDeviceVulkan13Features<'static>>, _v13: Box<vk::PhysicalDeviceVulkan13Features<'static>>,
@@ -329,6 +331,9 @@ pub struct PyroWaveEncoder {
ext_fd: ash::khr::external_memory_fd::Device, ext_fd: ash::khr::external_memory_fd::Device,
queue: vk::Queue, queue: vk::Queue,
family: u32, family: u32,
/// `src` family for the fresh-dmabuf acquire barrier: FOREIGN when the extension is
/// enabled, else the core EXTERNAL substitute (Phase 8 — see `open_inner`).
foreign_qfi: u32,
mem_props: vk::PhysicalDeviceMemoryProperties, mem_props: vk::PhysicalDeviceMemoryProperties,
_hold: DeviceHold, _hold: DeviceHold,
@@ -372,6 +377,10 @@ pub struct PyroWaveEncoder {
cmd_pool: vk::CommandPool, cmd_pool: vk::CommandPool,
cmd: vk::CommandBuffer, cmd: vk::CommandBuffer,
fence: vk::Fence, fence: vk::Fence,
/// True between a successful `queue_submit` and its successful fence wait — i.e. exactly when
/// GPU work may still be executing. `reset()` keys its bounded wait on this: a never-submitted
/// fence would otherwise read as "wedged" (fences start unsignaled).
gpu_pending: bool,
// --- state --- // --- state ---
width: u32, width: u32,
@@ -448,11 +457,11 @@ impl PyroWaveEncoder {
instance_ci: Box::new(vk::InstanceCreateInfo::default()), instance_ci: Box::new(vk::InstanceCreateInfo::default()),
_queue_prio: Box::new([1.0f32]), _queue_prio: Box::new([1.0f32]),
_queue_ci: Box::new([vk::DeviceQueueCreateInfo::default()]), _queue_ci: Box::new([vk::DeviceQueueCreateInfo::default()]),
_dev_exts: Box::new([ _dev_exts: vec![
ash::khr::external_memory_fd::NAME.as_ptr(), ash::khr::external_memory_fd::NAME.as_ptr(),
ash::ext::external_memory_dma_buf::NAME.as_ptr(), ash::ext::external_memory_dma_buf::NAME.as_ptr(),
ash::ext::image_drm_format_modifier::NAME.as_ptr(), ash::ext::image_drm_format_modifier::NAME.as_ptr(),
]), ],
_feat2: Box::new(vk::PhysicalDeviceFeatures2::default()), _feat2: Box::new(vk::PhysicalDeviceFeatures2::default()),
_v12: Box::new(vk::PhysicalDeviceVulkan12Features::default()), _v12: Box::new(vk::PhysicalDeviceVulkan12Features::default()),
_v13: Box::new(vk::PhysicalDeviceVulkan13Features::default()), _v13: Box::new(vk::PhysicalDeviceVulkan13Features::default()),
@@ -544,6 +553,28 @@ impl PyroWaveEncoder {
hold._feat2.p_next = &mut *hold._v12 as *mut _ as *mut std::ffi::c_void; hold._feat2.p_next = &mut *hold._v12 as *mut _ as *mut std::ffi::c_void;
hold._v12.p_next = &mut *hold._v13 as *mut _ as *mut std::ffi::c_void; hold._v12.p_next = &mut *hold._v13 as *mut _ as *mut std::ffi::c_void;
// VK_EXT_queue_family_foreign (Phase 8): the fresh-import acquire barrier names
// FOREIGN as src — enable the extension when advertised (`pf-presenter/dmabuf.rs`
// precedent), else fall back to the core QUEUE_FAMILY_EXTERNAL substitute. Must be
// pushed BEFORE the count/as_ptr wiring below.
let dev_ext_props = instance
.enumerate_device_extension_properties(pd)
.unwrap_or_default();
let foreign_qfi = if crate::vk_util::ext_advertised(
&dev_ext_props,
ash::ext::queue_family_foreign::NAME,
) {
hold._dev_exts
.push(ash::ext::queue_family_foreign::NAME.as_ptr());
vk::QUEUE_FAMILY_FOREIGN_EXT
} else {
tracing::warn!(
"pyrowave: VK_EXT_queue_family_foreign not advertised — dmabuf acquires \
use the core QUEUE_FAMILY_EXTERNAL substitute (no fleet hardware takes \
this arm; report it)"
);
vk::QUEUE_FAMILY_EXTERNAL
};
hold._queue_ci[0] = vk::DeviceQueueCreateInfo::default().queue_family_index(family); hold._queue_ci[0] = vk::DeviceQueueCreateInfo::default().queue_family_index(family);
hold._queue_ci[0].queue_count = 1; hold._queue_ci[0].queue_count = 1;
hold._queue_ci[0].p_queue_priorities = hold._queue_prio.as_ptr(); hold._queue_ci[0].p_queue_priorities = hold._queue_prio.as_ptr();
@@ -556,9 +587,9 @@ impl PyroWaveEncoder {
let device = instance let device = instance
.create_device(pd, &hold.device_ci, None) .create_device(pd, &hold.device_ci, None)
.context("create device")?; .context("create device")?;
Ok((pd, family, device)) Ok((pd, family, device, foreign_qfi))
})(); })();
let (pd, family, device) = match selected { let (pd, family, device, foreign_qfi) = match selected {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
instance.destroy_instance(None); instance.destroy_instance(None);
@@ -584,6 +615,7 @@ impl PyroWaveEncoder {
ext_fd, ext_fd,
queue, queue,
family, family,
foreign_qfi,
mem_props, mem_props,
_hold: hold, _hold: hold,
pw_dev: std::ptr::null_mut(), pw_dev: std::ptr::null_mut(),
@@ -614,6 +646,7 @@ impl PyroWaveEncoder {
cmd_pool: vk::CommandPool::null(), cmd_pool: vk::CommandPool::null(),
cmd: vk::CommandBuffer::null(), cmd: vk::CommandBuffer::null(),
fence: vk::Fence::null(), fence: vk::Fence::null(),
gpu_pending: false,
width: w, width: w,
height: h, height: h,
fps, fps,
@@ -1158,11 +1191,7 @@ impl PyroWaveEncoder {
FramePayload::Dmabuf(d) => { FramePayload::Dmabuf(d) => {
let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?; let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?;
let (old, src_qf, dst_qf) = if fresh { let (old, src_qf, dst_qf) = if fresh {
( (vk::ImageLayout::UNDEFINED, self.foreign_qfi, self.family)
vk::ImageLayout::UNDEFINED,
vk::QUEUE_FAMILY_FOREIGN_EXT,
self.family,
)
} else { } else {
( (
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
@@ -1399,8 +1428,10 @@ impl PyroWaveEncoder {
let _ = dev.reset_command_buffer(self.cmd, vk::CommandBufferResetFlags::empty()); let _ = dev.reset_command_buffer(self.cmd, vk::CommandBufferResetFlags::empty());
return Err(e); return Err(e);
} }
self.gpu_pending = true;
dev.wait_for_fences(&[self.fence], true, 5_000_000_000) dev.wait_for_fences(&[self.fence], true, 5_000_000_000)
.context("pyrowave encode fence")?; .context("pyrowave encode fence")?;
self.gpu_pending = false;
// ---- packetize ---- // ---- packetize ----
// Dense (default): boundary = whole buffer → the AU is exactly one pyrowave packet. // Dense (default): boundary = whole buffer → the AU is exactly one pyrowave packet.
@@ -1492,8 +1523,35 @@ impl Encoder for PyroWaveEncoder {
fn reset(&mut self) -> bool { fn reset(&mut self) -> bool {
// Cheap in-place rebuild: recreate only the pyrowave encoder object — there is no // Cheap in-place rebuild: recreate only the pyrowave encoder object — there is no
// rate-control history or reference state worth preserving (plan §4.3). // rate-control history or reference state worth preserving (plan §4.3).
// SAFETY: the device is idle for this encoder's work (submit waits its fence) and the //
// pyrowave device outlives the encoder object being swapped. // Bounded wait first: the only work possibly still executing is the one submitted frame
// whose synchronous fence wait timed out (`gpu_pending`). Re-wait it under the same 5 s
// cap as `encode_frame` — an untimed `device_wait_idle` here would park the recovery
// thread on the exact device it suspects is wedged, until the kernel's GPU reset, if
// ever. If the fence still won't signal, destroying the pyrowave encoder under live GPU
// work would be a use-after-free, so report "no in-place rebuild" and let the session
// surface a real error (`Drop`'s unbounded idle covers teardown, where blocking on the
// kernel is acceptable).
if self.gpu_pending {
// SAFETY: waiting this encoder's own fence under `&mut self`.
if unsafe {
self.device
.wait_for_fences(&[self.fence], true, 5_000_000_000)
}
.is_err()
{
tracing::error!(
"pyrowave: in-flight encode did not complete within the reset budget — GPU \
or driver wedged; in-place rebuild abandoned"
);
self.pending.clear();
return false;
}
self.gpu_pending = false;
}
// SAFETY: the device is idle for this encoder's work (the fence wait above, or no submit
// outstanding) — this sweep-up is instant — and the pyrowave device outlives the encoder
// object being swapped.
unsafe { unsafe {
self.device.device_wait_idle().ok(); self.device.device_wait_idle().ok();
pw::pyrowave_encoder_destroy(self.pw_enc); pw::pyrowave_encoder_destroy(self.pw_enc);
+412 -71
View File
@@ -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 /// 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. /// so a GPU-preference change is picked up on the next open.
fn lp_key(codec: Codec, ten_bit: bool) -> LpKey { fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
( lp_key_for(&render_node().to_string_lossy(), codec, ten_bit)
render_node().to_string_lossy().into_owned(), }
codec.label(),
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 /// `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. /// only); unset → try full-feature first, fall back to low-power.
fn low_power_override() -> Option<bool> { 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), "1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false), "0" | "false" | "no" | "off" => Some(false),
_ => None, _ => 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 /// 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 /// 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 /// 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() .lock()
.map(|m| m.get(&key).copied().unwrap_or(0)) .map(|m| m.get(&key).copied().unwrap_or(0))
.unwrap_or(0); .unwrap_or(0);
let modes: &[bool] = match low_power_override() { let modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
Some(true) => &[true],
Some(false) => &[false],
None => match cached {
1 => &[false],
2 => &[true],
_ => &[false, true],
},
};
let mut first_err = None; let mut first_err = None;
for &lp in modes { for &lp in modes {
match open_vaapi_encoder_mode( match open_vaapi_encoder_mode(
@@ -159,7 +280,7 @@ unsafe fn open_vaapi_encoder(
) { ) {
Ok(enc) => { Ok(enc) => {
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() { 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 { if lp {
tracing::info!( tracing::info!(
@@ -216,32 +337,18 @@ unsafe fn open_vaapi_encoder_mode(
apply_low_latency_rc(&mut video, fps, bitrate_bps); apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI) (*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit { let vui = vui_for(ten_bit);
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 (*raw).colorspace = vui.colorspace;
// the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the (*raw).color_range = vui.range;
// zero-copy path). The client decoder auto-detects PQ from the VUI. (*raw).color_primaries = vui.primaries;
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL; (*raw).color_trc = vui.trc;
(*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;
}
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI; (*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref); (*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref); (*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
let mut opts = Dictionary::new(); let mut opts = Dictionary::new();
if ten_bit && codec == Codec::H265 { if let Some(profile) = explicit_profile(codec, ten_bit) {
// HEVC Main10. `hevc_vaapi` derives it from the P010 surfaces, but pin it explicitly so opts.set("profile", profile);
// the depth is never silently dropped. (10-bit AV1 is input-driven — no profile knob.)
opts.set("profile", "main10");
} }
// async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest // 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 // 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 // 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); // 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. // see `gpuclocks` for the session clock pin that removes the ramp tax.
let depth = std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH") let depth = async_depth(std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH").ok().as_deref());
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|d| (1..=8).contains(d))
.unwrap_or(1);
opts.set("async_depth", &depth.to_string()); opts.set("async_depth", &depth.to_string());
if low_power { if low_power {
opts.set("low_power", "1"); // VDEnc — the only encode entrypoint on modern Intel 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 /// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before
/// the Welcome (honest downgrade). /// the Welcome (honest downgrade).
pub fn probe_can_encode_10bit(codec: Codec) -> bool { pub fn probe_can_encode_10bit(codec: Codec) -> bool {
if !codec.supports_10bit() || codec == Codec::PyroWave { if !ten_bit_probe_eligible(codec) {
return false; return false;
} }
if ffmpeg::init().is_err() { if ffmpeg::init().is_err() {
@@ -834,24 +937,7 @@ impl DmabufInner {
} }
init!(src, ptr::null(), "buffer"); init!(src, ptr::null(), "buffer");
init!(hwmap, c"mode=read".as_ptr(), "hwmap"); 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, init!(scale, scale_vaapi_args(ten_bit).as_ptr(), "scale_vaapi");
// 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!(sink, ptr::null(), "buffersink"); init!(sink, ptr::null(), "buffersink");
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int { let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
@@ -1143,21 +1229,18 @@ impl VaapiEncoder {
chroma: super::ChromaFormat, chroma: super::ChromaFormat,
) -> Result<Self> { ) -> Result<Self> {
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 / // 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 // Main10 / PQ-VUI variant of whichever inner path the first frame selects.
// request whose capture stayed SDR honestly encodes 8-bit; the reverse (PQ frames on an match resolve_depth(format, bit_depth) {
// 8-bit session) is refused so PQ content is never mislabeled BT.709. DepthResolution::RefuseMislabeledPq => bail!(
if format.is_hdr_rgb10() && bit_depth != 10 {
bail!(
"captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \ "captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \
refusing to mislabel PQ content" refusing to mislabel PQ content"
); ),
} DepthResolution::SdrDowngrade => tracing::warn!(
if bit_depth == 10 && !format.is_hdr_rgb10() {
tracing::warn!(
bit_depth, bit_depth,
?format, ?format,
"10-bit requested but the capture stayed SDR — encoding 8-bit" "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 // 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 // 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(()) 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");
}
}
+30
View File
@@ -7,6 +7,18 @@ use anyhow::Result;
use ash::vk; use ash::vk;
use pf_frame::PixelFormat; use pf_frame::PixelFormat;
/// Whether a device extension is in an enumerated properties list — the gate both Vulkan encode
/// backends use before enabling `VK_EXT_queue_family_foreign` (Phase 8: the FOREIGN queue-family
/// barriers were used without the extension ever being enabled; `pf-presenter/dmabuf.rs` is the
/// in-repo precedent that enables it).
pub(super) fn ext_advertised(exts: &[vk::ExtensionProperties], name: &std::ffi::CStr) -> bool {
exts.iter().any(|e| {
// SAFETY: `extension_name` is a spec-guaranteed NUL-terminated UTF-8 byte array inside
// the driver-filled `VkExtensionProperties` (VK_MAX_EXTENSION_NAME_SIZE bound).
unsafe { std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == name }
})
}
pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange { pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange { vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR, aspect_mask: vk::ImageAspectFlags::COLOR,
@@ -398,6 +410,24 @@ pub(crate) unsafe fn make_plain_image(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn ext_advertised_matches_exact_name() {
let mut e = ash::vk::ExtensionProperties::default();
let name = b"VK_EXT_queue_family_foreign\0";
for (i, b) in name.iter().enumerate() {
e.extension_name[i] = *b as std::ffi::c_char;
}
let exts = [ash::vk::ExtensionProperties::default(), e];
assert!(super::ext_advertised(
&exts,
ash::ext::queue_family_foreign::NAME
));
assert!(!super::ext_advertised(
&exts[..1],
ash::ext::queue_family_foreign::NAME
));
}
use super::*; use super::*;
/// CSC mode (`bgra_target = false`): the 3→4 expand is a pure byte shuffle — no channel /// CSC mode (`bgra_target = false`): the 3→4 expand is a pure byte shuffle — no channel
+198 -78
View File
@@ -76,9 +76,11 @@ fn quality_request() -> u32 {
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the /// (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 /// 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 /// 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 /// default: ON wherever the probe passes, EXCEPT sessions that need the CSC's cursor blend
/// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on /// (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it on non-cursor
/// cursor-blend sessions (the pointer will be missing from the stream); unset = the default. /// 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 /// 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 /// an empty value and a value that is only whitespace — falls back to the default rather than
@@ -508,6 +510,12 @@ pub struct VulkanVideoEncoder {
compute_queue: vk::Queue, compute_queue: vk::Queue,
encode_family: u32, encode_family: u32,
compute_family: u32, compute_family: u32,
/// The queue family the dmabuf-acquire barriers name as `src`: `QUEUE_FAMILY_FOREIGN_EXT`
/// when `VK_EXT_queue_family_foreign` is advertised+enabled (Phase 8 — the barriers used it
/// without the enable, tolerated by RADV), else the core-1.1 `QUEUE_FAMILY_EXTERNAL`
/// conservative substitute (adds a same-driver precondition FOREIGN doesn't have — the
/// pragmatic fallback for devices that were never valid targets before).
foreign_qfi: u32,
mem_props: vk::PhysicalDeviceMemoryProperties, mem_props: vk::PhysicalDeviceMemoryProperties,
// --- codec --- // --- codec ---
@@ -540,7 +548,7 @@ pub struct VulkanVideoEncoder {
// Per-buffer dmabuf-import cache, keyed by (st_dev, st_ino) — PipeWire cycles a small fixed pool, // Per-buffer dmabuf-import cache, keyed by (st_dev, st_ino) — PipeWire cycles a small fixed pool,
// so each underlying buffer is imported ONCE and reused (no per-frame VkImage create/import/destroy). // so each underlying buffer is imported ONCE and reused (no per-frame VkImage create/import/destroy).
// Imports are read-only per frame, so the ring shares them (concurrent frames read distinct buffers). // Imports are read-only per frame, so the ring shares them (concurrent frames read distinct buffers).
import_cache: Vec<(u64, u64, vk::Image, vk::DeviceMemory, vk::ImageView)>, import_cache: Vec<CachedImport>,
// --- in-flight frame ring (pipelining) --- // --- in-flight frame ring (pipelining) ---
frames: Vec<Frame>, // per-slot private resources frames: Vec<Frame>, // per-slot private resources
@@ -608,10 +616,6 @@ pub struct VulkanVideoEncoder {
/// GPU reset (those paths keep their aligned-size sources/staging). /// GPU reset (those paths keep their aligned-size sources/staging).
native_nv12: bool, 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 /// 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 /// 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 /// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into
@@ -665,7 +669,16 @@ impl VulkanVideoEncoder {
cursor_blend: bool, cursor_blend: bool,
) -> Result<Self> { ) -> Result<Self> {
let native_nv12 = format == PixelFormat::Nv12; 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( Self::open_opts_inner(
codec, codec,
width, width,
@@ -995,6 +1008,24 @@ impl VulkanVideoEncoder {
} else { } else {
bitrate bitrate
}; };
// VK_EXT_queue_family_foreign: enable when advertised so the dmabuf-acquire barriers'
// FOREIGN src family is spec-legal (Phase 8; `pf-presenter/dmabuf.rs` precedent). The
// rgb probe's enumerate is a probe-local (and skipped on native-NV12), so this is a
// fresh open-time query.
let dev_ext_props = instance
.enumerate_device_extension_properties(pd)
.unwrap_or_default();
let foreign_ok =
crate::vk_util::ext_advertised(&dev_ext_props, ash::ext::queue_family_foreign::NAME);
let foreign_qfi = if foreign_ok {
vk::QUEUE_FAMILY_FOREIGN_EXT
} else {
tracing::warn!(
"VK_EXT_queue_family_foreign not advertised — dmabuf acquires use the core \
QUEUE_FAMILY_EXTERNAL substitute (this arm has no fleet hardware; report it)"
);
vk::QUEUE_FAMILY_EXTERNAL
};
// logical device: encode + compute queues + video extensions (AV1 ext name is raw — ash lacks it) // logical device: encode + compute queues + video extensions (AV1 ext name is raw — ash lacks it)
let mut dev_exts = vec![ let mut dev_exts = vec![
ash::khr::video_queue::NAME.as_ptr(), ash::khr::video_queue::NAME.as_ptr(),
@@ -1011,6 +1042,9 @@ impl VulkanVideoEncoder {
if rgb_cfg.is_some() { if rgb_cfg.is_some() {
dev_exts.push(vrgb::EXTENSION_NAME.as_ptr()); dev_exts.push(vrgb::EXTENSION_NAME.as_ptr());
} }
if foreign_ok {
dev_exts.push(ash::ext::queue_family_foreign::NAME.as_ptr());
}
let prio = [1.0f32]; let prio = [1.0f32];
let mut qcis = vec![vk::DeviceQueueCreateInfo::default() let mut qcis = vec![vk::DeviceQueueCreateInfo::default()
.queue_family_index(encode_family) .queue_family_index(encode_family)
@@ -1383,6 +1417,7 @@ impl VulkanVideoEncoder {
encode_queue, encode_queue,
compute_queue, compute_queue,
encode_family, encode_family,
foreign_qfi,
compute_family, compute_family,
mem_props, mem_props,
codec, codec,
@@ -1420,7 +1455,6 @@ impl VulkanVideoEncoder {
cpu_expand: Vec::new(), cpu_expand: Vec::new(),
rgb: rgb_cfg, rgb: rgb_cfg,
native_nv12, native_nv12,
warned_cursor: false,
pending_bitrate: None, pending_bitrate: None,
width: w, width: w,
height: h, height: h,
@@ -1654,8 +1688,22 @@ impl VulkanVideoEncoder {
// frame imports fresh (as before) but is still owned by the cache and freed on evict/Drop. // frame imports fresh (as before) but is still owned by the cache and freed on evict/Drop.
(u64::MAX, self.enc_count) (u64::MAX, self.enc_count)
}; };
if let Some(&(_, _, img, _, view)) = self.import_cache.iter().find(|e| (e.0, e.1) == key) { if let Some(pos) = self.import_cache.iter().position(|e| e.key == key) {
return Ok((img, view, false)); let e = &self.import_cache[pos];
if e.extent == (cw, ch) {
return Ok((e.img, e.view, false));
}
// Key hit, wrong extent: the (st_dev, st_ino) now names a different allocation than
// the one imported. Unreachable while every submit path guards frame-vs-session
// dimensions first — but the cache must never hand out a stale-sized image if a
// future path forgets, so evict and fall through to a fresh import. In-flight frames
// may still read the old image (same hazard as the FIFO eviction below), so idle the
// device before destroying. At most once per reallocation.
let _ = self.device.device_wait_idle();
let e = self.import_cache.remove(pos);
self.device.destroy_image_view(e.view, None);
self.device.destroy_image(e.img, None);
self.device.free_memory(e.mem, None);
} }
// Feed pf-zerocopy's raw-dmabuf degrade latch (wired for the libav path in 3efbe416): // Feed pf-zerocopy's raw-dmabuf degrade latch (wired for the libav path in 3efbe416):
// a deterministic import refusal repeats identically forever, and the latch — capture // a deterministic import refusal repeats identically forever, and the latch — capture
@@ -1690,12 +1738,18 @@ impl VulkanVideoEncoder {
let _ = self.device.device_wait_idle(); let _ = self.device.device_wait_idle();
} }
while self.import_cache.len() >= IMPORT_CACHE_CAP { while self.import_cache.len() >= IMPORT_CACHE_CAP {
let (_, _, oi, om, ov) = self.import_cache.remove(0); let e = self.import_cache.remove(0);
self.device.destroy_image_view(ov, None); self.device.destroy_image_view(e.view, None);
self.device.destroy_image(oi, None); self.device.destroy_image(e.img, None);
self.device.free_memory(om, None); self.device.free_memory(e.mem, None);
} }
self.import_cache.push((key.0, key.1, img, mem, view)); self.import_cache.push(CachedImport {
key,
extent: (cw, ch),
img,
mem,
view,
});
// Fires once per distinct pool buffer then goes quiet in steady state — the signal the cache // Fires once per distinct pool buffer then goes quiet in steady state — the signal the cache
// is hitting (a per-frame log here would mean inode keying failed and we're re-importing). // is hitting (a per-frame log here would mean inode keying failed and we're re-importing).
tracing::debug!( tracing::debug!(
@@ -2006,6 +2060,20 @@ impl VulkanVideoEncoder {
} }
// ---- 2. RGB source -> compute_cmd: prep barriers + CSC + copy into nv12_src ---- // ---- 2. RGB source -> compute_cmd: prep barriers + CSC + copy into nv12_src ----
// The CSC twin of the sibling guards (native NV12, RGB-direct's two arms, every other
// backend's submit): the shader samples the source with clamped 1:1 texelFetch, so a
// mismatched frame doesn't fault — it silently streams a cropped/edge-padded picture.
// Refuse it into the encoder-rebuild path instead.
if frame.width != self.render_w || frame.height != self.render_h {
bail!(
"vulkan-encode (csc): frame {}x{} != mode {}x{} — refusing a mismatched CSC \
source",
frame.width,
frame.height,
self.render_w,
self.render_h
);
}
let dev = self.device.clone(); // cheap handle clone -> lets us also call &mut self helpers let dev = self.device.clone(); // cheap handle clone -> lets us also call &mut self helpers
dev.begin_command_buffer( dev.begin_command_buffer(
compute_cmd, compute_cmd,
@@ -2049,7 +2117,7 @@ impl VulkanVideoEncoder {
let (old, src_qf, dst_qf) = if fresh { let (old, src_qf, dst_qf) = if fresh {
( (
vk::ImageLayout::UNDEFINED, vk::ImageLayout::UNDEFINED,
vk::QUEUE_FAMILY_FOREIGN_EXT, self.foreign_qfi,
self.compute_family, self.compute_family,
) )
} else { } else {
@@ -2398,7 +2466,7 @@ impl VulkanVideoEncoder {
.dst_access_mask(vk::AccessFlags2::TRANSFER_READ) .dst_access_mask(vk::AccessFlags2::TRANSFER_READ)
.old_layout(vk::ImageLayout::UNDEFINED) .old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
.src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT) .src_queue_family_index(self.foreign_qfi)
.dst_queue_family_index(self.compute_family) .dst_queue_family_index(self.compute_family)
} else { } else {
vk::ImageMemoryBarrier2::default() vk::ImageMemoryBarrier2::default()
@@ -2559,17 +2627,10 @@ impl VulkanVideoEncoder {
d.modifier d.modifier
); );
} }
// No compositing stage exists here (like RGB-direct/EFC): gamescope embeds its pointer // No compositing stage exists here (like RGB-direct/EFC) — and none is needed: the
// in the produced pixels, but any other NV12 producer's metadata cursor would be lost — // session plan negotiates native NV12 only for a non-cursor-blend session
// say so once instead of silently. // (`SessionPlan::output_format` gates `nv12_native` on `!cursor_blend`), so no cursor
if frame.cursor.is_some() && !self.warned_cursor { // bitmap ever reaches this arm. Gamescope embeds its pointer in the produced pixels.
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)"
);
}
let dev = self.device.clone(); let dev = self.device.clone();
let cmd = self.frames[slot].cmd; let cmd = self.frames[slot].cmd;
let fence = self.frames[slot].fence; let fence = self.frames[slot].fence;
@@ -2629,16 +2690,9 @@ impl VulkanVideoEncoder {
let query_pool = self.frames[slot].query_pool; let query_pool = self.frames[slot].query_pool;
let bs_buf = self.frames[slot].bs_buf; let bs_buf = self.frames[slot].bs_buf;
let ts_pool = self.frames[slot].ts_pool; let ts_pool = self.frames[slot].ts_pool;
// EFC cannot composite the cursor bitmap the metadata-cursor captures hand us — say so // EFC cannot composite a cursor bitmap — and never has to: `open` refuses the RGB-direct
// once instead of silently losing the pointer (gamescope, the flagship, embeds it). // shape for a cursor-blend session (the pin override above), so no cursor bitmap ever
if frame.cursor.is_some() && !self.warned_cursor { // reaches this arm. Gamescope, the flagship, embeds its pointer in the produced pixels.
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)"
);
}
let padded = self.rgb.as_ref().is_some_and(|r| r.padded); 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 // 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" // CPU-upload arm records its own command buffer and writes none. Default to "not written"
@@ -2939,7 +2993,7 @@ impl VulkanVideoEncoder {
.src_stage_mask(vk::PipelineStageFlags2::NONE) .src_stage_mask(vk::PipelineStageFlags2::NONE)
.src_access_mask(vk::AccessFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE)
.old_layout(vk::ImageLayout::UNDEFINED) .old_layout(vk::ImageLayout::UNDEFINED)
.src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT) .src_queue_family_index(self.foreign_qfi)
.dst_queue_family_index(self.encode_family), .dst_queue_family_index(self.encode_family),
SrcAcquire::DmabufCached => src_base SrcAcquire::DmabufCached => src_base
.src_stage_mask(vk::PipelineStageFlags2::NONE) .src_stage_mask(vk::PipelineStageFlags2::NONE)
@@ -3794,9 +3848,58 @@ impl Encoder for VulkanVideoEncoder {
fn reset(&mut self) -> bool { fn reset(&mut self) -> bool {
// Abandon everything in flight: wait the GPU idle, discard unread slots + queued output, and // Abandon everything in flight: wait the GPU idle, discard unread slots + queued output, and
// restart GOP/DPB state so the next frame is a fresh IDR. // restart GOP/DPB state so the next frame is a fresh IDR.
// SAFETY: `device_wait_idle` guarantees no slot's fence is still pending before we drop them. //
// The wait is BOUNDED, like every other wait on this thread (`ENCODE_FENCE_TIMEOUT_NS`,
// same reasoning): reset() runs precisely because something upstream looks wedged, and an
// untimed `device_wait_idle` would park the recovery path on the suspect device until the
// kernel's GPU reset — if that ever comes. A timeout means the GPU really is wedged:
// report "no in-place rebuild" so the caller ends the session with a real error instead.
// (`Drop` still waits unbounded — teardown must be memory-safe even against a wedged
// device, and by then the session is already over.)
let fences: Vec<vk::Fence> = self
.in_flight
.iter()
.map(|&s| self.frames[s].fence)
.collect();
if !fences.is_empty() {
// SAFETY: every in-flight slot's fence was submitted with its batch (`enqueue` pushes
// only after a successful submit), and we hold `&mut self`.
match unsafe {
self.device
.wait_for_fences(&fences, true, ENCODE_FENCE_TIMEOUT_NS)
} {
Ok(()) => {}
Err(e) => {
tracing::error!(
error = ?e,
"vulkan-encode: in-flight work did not go idle within {} ms — GPU or \
driver wedged; in-place rebuild abandoned",
ENCODE_FENCE_TIMEOUT_NS / 1_000_000
);
return false;
}
}
}
// The fences cover the encode queue, and each encode waited its slot's `csc_sem`, so the
// paired compute batches are done too. The residual — a compute batch whose encode-queue
// submit failed (so it was never fenced or enqueued) — is what this sweep-up covers; it
// is instant unless that rare orphan is itself wedged (then the kernel's GPU reset bounds
// it, as before). An error here is a lost device: no rebuild on top of that.
// SAFETY: waiting this encoder's own device idle under `&mut self`.
if unsafe { self.device.device_wait_idle() }.is_err() {
return false;
}
// Drop the dmabuf import cache while the device is provably idle (the only safe point
// outside teardown): whatever wedged the GPU may be a capture-side reallocation, and the
// rebuilt stream must re-import from live buffers rather than trust images imported for
// the old pool.
// SAFETY: device idle (waits above); each entry is owned by the cache, destroyed once.
unsafe { unsafe {
let _ = self.device.device_wait_idle(); for e in std::mem::take(&mut self.import_cache) {
self.device.destroy_image_view(e.view, None);
self.device.destroy_image(e.img, None);
self.device.free_memory(e.mem, None);
}
} }
self.in_flight.clear(); self.in_flight.clear();
self.pending.clear(); self.pending.clear();
@@ -3857,6 +3960,17 @@ impl Encoder for VulkanVideoEncoder {
} }
} }
/// One cached dmabuf import (see `import_cached`): keyed by `(st_dev, st_ino)`, carrying the
/// extent it was imported at — a key hit must also prove the cached image still matches the
/// caller's frame — plus the owning Vulkan objects.
struct CachedImport {
key: (u64, u64),
extent: (u32, u32),
img: vk::Image,
mem: vk::DeviceMemory,
view: vk::ImageView,
}
/// Every destructible Vulkan object the encoder owns, with the one `Drop` that destroys them in /// Every destructible Vulkan object the encoder owns, with the one `Drop` that destroys them in
/// dependency order. Both teardown paths run through it so they cannot drift: /// dependency order. Both teardown paths run through it so they cannot drift:
/// ///
@@ -3874,7 +3988,7 @@ struct VkTeardown {
// infallible), so device-level objects can only exist once both are `Some`. // infallible), so device-level objects can only exist once both are `Some`.
device: Option<ash::Device>, device: Option<ash::Device>,
vq_dev: Option<ash::khr::video_queue::Device>, vq_dev: Option<ash::khr::video_queue::Device>,
import_cache: Vec<(u64, u64, vk::Image, vk::DeviceMemory, vk::ImageView)>, import_cache: Vec<CachedImport>,
frames: Vec<Frame>, frames: Vec<Frame>,
compute_pool: vk::CommandPool, compute_pool: vk::CommandPool,
cmd_pool: vk::CommandPool, cmd_pool: vk::CommandPool,
@@ -3933,10 +4047,10 @@ impl Drop for VkTeardown {
unsafe { unsafe {
if let Some(device) = self.device.take() { if let Some(device) = self.device.take() {
let _ = device.device_wait_idle(); let _ = device.device_wait_idle();
for (_, _, img, mem, view) in std::mem::take(&mut self.import_cache) { for e in std::mem::take(&mut self.import_cache) {
device.destroy_image_view(view, None); device.destroy_image_view(e.view, None);
device.destroy_image(img, None); device.destroy_image(e.img, None);
device.free_memory(mem, None); device.free_memory(e.mem, None);
} }
// Per-frame ring resources (command buffers, descriptor sets freed with their pools). // Per-frame ring resources (command buffers, descriptor sets freed with their pools).
for f in std::mem::take(&mut self.frames) { for f in std::mem::take(&mut self.frames) {
@@ -4364,44 +4478,50 @@ mod tests {
} }
} }
/// A CPU-capture source that CHANGES SIZE mid-session must not copy past the cached staging /// The CSC arm REFUSES a source that doesn't match the session mode (the e3354b6d guard —
/// image. `ensure_cpu_rgb` keys that image on (format, width, height); when it was keyed on /// the check every sibling arm always had; a clamped-texelFetch mismatch used to stream a
/// format alone the image kept the FIRST frame's size while `cmd_copy_buffer_to_image` used the /// silently cropped/edge-padded picture). This test previously DROVE mismatched sizes through
/// current frame's extent, so a same-format size increase wrote out of bounds — and `submit` /// the lenient arm to exercise the per-slot `cpu_img` staging across a size change (the
/// still returned `Ok`, so nothing upstream noticed. /// 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
/// The out-of-bounds copy is only *observable* through the Vulkan validation layers, so run this /// size-keyed staging from that fix stays as belt-and-braces. What's left to pin:
/// as: `VK_LOADER_LAYERS_ENABLE='*validation*' cargo test ... -- --ignored`. Confirmed on RADV /// refusal in BOTH directions, and that a refused submit does not WEDGE the session — the
/// PHOENIX 2026-07-25: 8 x VUID-vkCmdCopyBufferToImage-imageSubresource-07971 before the fix /// bail happens after step 1's frame-type bookkeeping, so "the next well-sized frame still
/// ("extent.width (512) exceeds imageSubresource width extent (128)"), zero after. /// 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] #[test]
#[ignore = "needs a real VK_KHR_video_encode_h265 device; meaningful only under validation layers"] #[ignore = "needs a real VK_KHR_video_encode_h265 device; meaningful only under validation layers"]
fn vulkan_cpu_img_survives_a_source_size_change() { fn vulkan_csc_refuses_a_mismatched_source() {
// CSC mode (rgb=false) — that is the arm where the image is sized to the SOURCE frame. // CSC mode (rgb=false) — the arm the guard covers.
let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, 512, 512, 60, 10_000_000, false) let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, 512, 512, 60, 10_000_000, false)
.expect("open"); .expect("open");
// `cpu_img` is cached PER RING SLOT, so the small frames must first populate every slot; enc.submit_indexed(&cpu_frame(512, 512, 0, [40, 40, 200, 255]), 0)
// only when the ring wraps does a slot holding a 128x128 image get a 512x512 copy. .expect("well-sized baseline");
eprintln!("phase 1: 8x 128x128 — populates every ring slot with a 128x128 cpu_img"); while enc.poll().expect("poll").is_some() {}
for i in 0..8u64 { // 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( 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, i as u32,
) )
.expect("submit small"); .expect("well-sized after refusal");
while enc.poll().expect("poll").is_some() {} while let Ok(Some(_)) = enc.poll() {
got_au = true;
}
} }
eprintln!("phase 2: 8x 512x512 — SAME format, so each slot REUSES its 128x128 image"); assert!(got_au, "no AU after the refused submits — session wedged");
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(_))) {}
eprintln!("done — under validation layers this run must report ZERO VUID errors"); eprintln!("done — under validation layers this run must report ZERO VUID errors");
} }
+135
View File
@@ -102,6 +102,141 @@ pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 {
mode mode
} }
/// Whether the operator EXPLICITLY forced sub-frame readback on (`PUNKTFUNK_NVENC_SUBFRAME=1`)
/// — the log-severity input to [`resolve_split_subframe`]: a forced knob being overridden
/// deserves a `warn`, a default being tuned an `info`. Callers LATCH this once next to their
/// resolved subframe state (an env re-read at reconfigure would violate the "open and
/// reconfigure present identical init params" invariant).
/// Linux-cfg'd like its ONLY caller (the `nvenc_cuda` query_caps latch) — Windows sessions have
/// `subframe == forced` by construction (env opt-in only) and never consult this; without the
/// cfg it is dead code on every Windows leg (item-level dead_code, the recurring trap).
#[cfg(target_os = "linux")]
pub(super) fn subframe_env_forced() -> bool {
matches!(
std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref(),
Ok("1")
)
}
/// The split-encode × sub-frame arbitration (Phase 8; verified against `nvEncodeAPI.h`'s own
/// `splitEncodeMode` doc, not folklore):
/// - **H.264**: split "is not applicable" — hard-DISABLE the mode so the written config, the
/// ceiling-cache key, the split diagnostic log and the rejection-retry all stay truthful (the
/// retry used to re-open a byte-identical session after an H.264 "split rejection").
/// - **HEVC**: split is "not supported if … subframe mode" — when WE force split
/// (TWO/THREE/AUTO_FORCED, e.g. the 4K120 throughput requirement), sub-frame yields. Under
/// plain AUTO the driver arbitrates — the shipped fleet state (1080p1440p240 all run
/// AUTO+subframe); keying on `!= DISABLE` here would have disarmed the Phase-3 chunked-poll
/// feature fleet-wide.
/// - **AV1**: both legal (sub-frame is per-tile; split is constrained only by
/// output-into-vidmem, which we never use) — untouched.
///
/// Returns the `(split_mode, subframe)` to ACTUALLY configure. The caller must store BOTH back
/// (the chunked-poll latch and `CeilingKey` key on them) — a silent in-params drop would leave
/// `poll_chunk` busy-polling its full budget every AU (`numSlices` stays 0 without
/// `reportSliceOffsets`, so neither loop exit ever fires).
pub(super) fn resolve_split_subframe(
codec: Codec,
split_mode: u32,
subframe: bool,
subframe_forced: bool,
) -> (u32, bool) {
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
if codec == Codec::H264 {
return (M::NV_ENC_SPLIT_DISABLE_MODE as u32, subframe);
}
let split_forced = split_mode == M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|| split_mode == M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32
|| split_mode == M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32;
if codec == Codec::H265 && split_forced && subframe {
if subframe_forced {
tracing::warn!(
split_mode,
"HEVC forced split-encode and PUNKTFUNK_NVENC_SUBFRAME=1 are mutually \
unsupported (nvEncodeAPI.h) sub-frame readback disabled for this session; \
set PUNKTFUNK_SPLIT_ENCODE=0 to choose sub-frame instead"
);
} else {
tracing::info!(
split_mode,
"HEVC forced split-encode supersedes default-on sub-frame readback (mutually \
unsupported per nvEncodeAPI.h; split is the 4K120 throughput lever) set \
PUNKTFUNK_SPLIT_ENCODE=0 to choose sub-frame instead"
);
}
return (split_mode, false);
}
(split_mode, subframe)
}
#[cfg(test)]
mod split_subframe_tests {
use super::{resolve_split_subframe, Codec};
use nvidia_video_codec_sdk::sys::nvEncodeAPI::NV_ENC_SPLIT_ENCODE_MODE as M;
const AUTO: u32 = M::NV_ENC_SPLIT_AUTO_MODE as u32;
const TWO: u32 = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
const AUTO_F: u32 = M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32;
const DISABLE: u32 = M::NV_ENC_SPLIT_DISABLE_MODE as u32;
/// THE FLEET CASE: plain AUTO + default-on sub-frame must pass through untouched — the
/// driver arbitrates. Keying the rule on `!= DISABLE` would disarm sub-frame on every
/// default Linux HEVC session (AUTO == 0 is the resolver's fallthrough).
#[test]
fn hevc_auto_keeps_subframe() {
assert_eq!(
resolve_split_subframe(Codec::H265, AUTO, true, false),
(AUTO, true)
);
}
#[test]
fn hevc_forced_split_drops_subframe() {
assert_eq!(
resolve_split_subframe(Codec::H265, TWO, true, false),
(TWO, false)
);
assert_eq!(
resolve_split_subframe(Codec::H265, AUTO_F, true, true),
(AUTO_F, false)
);
// No sub-frame to drop → nothing changes.
assert_eq!(
resolve_split_subframe(Codec::H265, TWO, false, false),
(TWO, false)
);
// Explicitly disabled split → sub-frame kept (the documented escape).
assert_eq!(
resolve_split_subframe(Codec::H265, DISABLE, true, true),
(DISABLE, true)
);
}
/// H.264: split "is not applicable" (nvEncodeAPI.h) — hard-DISABLE regardless of the
/// resolved mode; sub-frame (H.264 slices) is unaffected.
#[test]
fn h264_split_hard_disabled() {
assert_eq!(
resolve_split_subframe(Codec::H264, TWO, true, false),
(DISABLE, true)
);
assert_eq!(
resolve_split_subframe(Codec::H264, AUTO, false, false),
(DISABLE, false)
);
}
/// AV1: both features are legal together (per-tile sub-frame; split constrained only by
/// output-into-vidmem) — the arbitration must not touch it.
#[test]
fn av1_untouched() {
assert_eq!(
resolve_split_subframe(Codec::Av1, TWO, true, true),
(TWO, true)
);
}
}
/// One session config's identity for the process-lifetime bitrate-ceiling cache /// One session config's identity for the process-lifetime bitrate-ceiling cache
/// ([`cached_ceiling`]/[`store_ceiling`]). Everything the driver's codec-level validation keys /// ([`cached_ceiling`]/[`store_ceiling`]). Everything the driver's codec-level validation keys
/// off: the GPU (different NVENC generations have different level ceilings), dims/fps (the luma /// off: the GPU (different NVENC generations have different level ceilings), dims/fps (the luma
-7
View File
@@ -2014,9 +2014,6 @@ impl Encoder for AmfEncoder {
// frame, force a later one to re-reference it). True only when the live driver accepted // 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. // the LTR slots at open — otherwise loss recovery falls back to a full IDR.
supports_rfi: self.ltr_active, 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. // Permanent: VCN hardware does not encode 4:4:4.
chroma_444: false, chroma_444: false,
// True only when `PUNKTFUNK_INTRA_REFRESH` asked for the wave AND the live driver // 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())); 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(); let mut aus: Vec<EncodedFrame> = Vec::new();
for i in 0..6 { for i in 0..6 {
let frame = CapturedFrame { let frame = CapturedFrame {
+296 -65
View File
@@ -129,9 +129,13 @@ impl WinVendor {
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would /// 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). /// corrupt silently, so it stays opt-in per the probe-never-assume rule).
fn zerocopy_enabled(vendor: WinVendor) -> bool { fn zerocopy_enabled(vendor: WinVendor) -> bool {
pf_host_config::config() zerocopy_active(pf_host_config::config().zerocopy, vendor)
.zerocopy }
.unwrap_or(matches!(vendor, WinVendor::Amf))
/// 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 /// 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 { fn poll_spin_cap_us() -> u64 {
static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new(); static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*CAP_US.get_or_init(|| { *CAP_US.get_or_init(|| {
std::env::var("PUNKTFUNK_FFWIN_POLL_MS") parse_poll_spin_cap_us(std::env::var("PUNKTFUNK_FFWIN_POLL_MS").ok().as_deref())
.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
}) })
} }
/// 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). /// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
fn sws_src(format: PixelFormat) -> Result<Pixel> { fn sws_src(format: PixelFormat) -> Result<Pixel> {
Ok(match format { Ok(match format {
@@ -206,6 +215,86 @@ fn is_10bit_format(format: PixelFormat) -> bool {
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2) 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, /// 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 /// 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. /// 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); (*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), // Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
// so vendor-specific keys are safe to set unconditionally.
let mut opts = Dictionary::new(); let mut opts = Dictionary::new();
match vendor { let usage = std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
WinVendor::Amf => { for (k, v) in vendor_opts(vendor, &usage) {
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality | opts.set(k, &v);
// 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");
}
} }
video video
.open_with(opts) .open_with(opts)
@@ -528,22 +588,10 @@ impl SystemInner {
pts: i64, pts: i64,
idr: bool, idr: bool,
) -> Result<()> { ) -> Result<()> {
// Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a match readback_route(format, self.ten_bit)? {
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that ReadbackRoute::Yuv => self.readback_yuv(frame, pts, idr),
// merely negotiated 10-bit over an 8-bit capture. ReadbackRoute::Bgra => self.readback_bgra(frame, pts, idr),
let fmt_10 = is_10bit_format(format); ReadbackRoute::Rgb10 => self.readback_rgb10(frame, pts, idr),
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:?}")
}
} }
} }
@@ -921,14 +969,7 @@ impl ZeroCopyInner {
} else { } else {
PixelFormat::Nv12 PixelFormat::Nv12
}; };
// Bind flags on the FFmpeg-allocated pool. AMF reads it as encoder input (RENDER_TARGET + let bind_flags = pool_bind_flags(vendor);
// 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,
};
const POOL: c_int = 8; const POOL: c_int = 8;
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an // 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 // owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
@@ -1402,3 +1443,193 @@ impl Encoder for FfmpegWinEncoder {
Ok(()) 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)
);
}
}
}
+60 -20
View File
@@ -38,8 +38,8 @@
use super::nvenc_core::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery, apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
resolve_slices, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling,
LowLatencyConfig, NvStatusExt, RangePlan, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
}; };
use super::nvenc_status; use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -603,6 +603,11 @@ pub struct NvencD3d11Encoder {
/// `reconfigure_bitrate` must present the SAME init params as the open (only the config's /// `reconfigure_bitrate` must present the SAME init params as the open (only the config's
/// rate fields may move). Meaningless while `inited` is false. /// rate fields may move). Meaningless while `inited` is false.
split_mode: u32, split_mode: u32,
/// The session's arbitrated sub-frame state ([`resolve_split_subframe`] at open) — recorded
/// so reconfigure presents EXACTLY the init params the session opened with (an env re-read
/// there could flip `enableSubFrameWrite` mid-session, and would re-log the arbitration on
/// every ABR retarget).
subframe_on: bool,
session_async: bool, session_async: bool,
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same /// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same
/// loss event (the client resends until it sees recovery). /// loss event (the client resends until it sees recovery).
@@ -686,6 +691,7 @@ impl NvencD3d11Encoder {
rfi_supported: false, rfi_supported: false,
custom_vbv: false, custom_vbv: false,
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32, split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
subframe_on: false,
session_async: false, session_async: false,
last_rfi_range: None, last_rfi_range: None,
init_device: ptr::null_mut(), init_device: ptr::null_mut(),
@@ -980,6 +986,7 @@ impl NvencD3d11Encoder {
bitrate: u64, bitrate: u64,
split_mode: u32, split_mode: u32,
enable_async: bool, enable_async: bool,
subframe: bool,
) -> Result<*mut c_void> { ) -> Result<*mut c_void> {
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS { let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER, version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
@@ -1014,9 +1021,10 @@ impl NvencD3d11Encoder {
&mut cfg, &mut cfg,
split_mode, split_mode,
enable_async, enable_async,
// Windows: env opt-in only ("1"), never a default — and build_init_params // Windows: env opt-in only ("1"), never a default; arbitrated against split by the
// additionally refuses it on an async session. // caller (resolve_split_subframe) — and build_init_params additionally refuses it
resolve_subframe(false), // on an async session.
subframe,
); );
match (api().initialize_encoder)(enc, &mut init).nv_ok() { match (api().initialize_encoder)(enc, &mut init).nv_ok() {
@@ -1069,6 +1077,11 @@ impl NvencD3d11Encoder {
// The init-failure fallback below disables it if a codec/config rejects it. // The init-failure fallback below disables it if a codec/config rejects it.
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64; let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate); let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
// Split × sub-frame arbitration (Phase 8) before the ladder/ceiling key. On Windows
// sub-frame is env-opt-in only, so resolved == forced by construction.
let subframe_req = resolve_subframe(false);
let (split_mode, subframe_req) =
resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_req);
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects // Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a // `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced // 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
@@ -1098,12 +1111,19 @@ impl NvencD3d11Encoder {
} }
} }
let mut probe = self.try_open_session(device, target_bps, split_mode, use_async); let mut probe =
self.try_open_session(device, target_bps, split_mode, use_async, subframe_req);
// The cache is advisory: a stale entry (driver change, identity collision) must not // The cache is advisory: a stale entry (driver change, identity collision) must not
// wedge the open — retry the requested rate and let the search below rediscover. // wedge the open — retry the requested rate and let the search below rediscover.
if probe.is_err() && target_bps < requested_bps { if probe.is_err() && target_bps < requested_bps {
target_bps = requested_bps; target_bps = requested_bps;
probe = self.try_open_session(device, requested_bps, split_mode, use_async); probe = self.try_open_session(
device,
requested_bps,
split_mode,
use_async,
subframe_req,
);
} }
// Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the // Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the
// requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate. // requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate.
@@ -1117,7 +1137,9 @@ impl NvencD3d11Encoder {
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
if probe.is_err() && split_on { if probe.is_err() && split_on {
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
if let Ok(e) = self.try_open_session(device, target_bps, no_split, use_async) { if let Ok(e) =
self.try_open_session(device, target_bps, no_split, use_async, subframe_req)
{
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled"); tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
used_split = no_split; used_split = no_split;
probe = Ok(e); probe = Ok(e);
@@ -1144,7 +1166,13 @@ impl NvencD3d11Encoder {
let mut best_bps = 0u64; let mut best_bps = 0u64;
while hi > lo + CLAMP_TOL_BPS { while hi > lo + CLAMP_TOL_BPS {
let mid = lo + (hi - lo) / 2; let mid = lo + (hi - lo) / 2;
match self.try_open_session(device, mid, used_split, use_async) { match self.try_open_session(
device,
mid,
used_split,
use_async,
subframe_req,
) {
Ok(e) => { Ok(e) => {
if !best.is_null() { if !best.is_null() {
let _ = (api().destroy_encoder)(best); let _ = (api().destroy_encoder)(best);
@@ -1169,12 +1197,23 @@ impl NvencD3d11Encoder {
// trying split-disabled in case a forced split (not the bitrate) is the blocker. // trying split-disabled in case a forced split (not the bitrate) is the blocker.
let no_split = let no_split =
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
best = match self.try_open_session(device, FLOOR_BPS, used_split, use_async) best = match self.try_open_session(
{ device,
FLOOR_BPS,
used_split,
use_async,
subframe_req,
) {
Ok(e) => e, Ok(e) => e,
Err(_) => { Err(_) => {
let e = self let e = self
.try_open_session(device, FLOOR_BPS, no_split, use_async) .try_open_session(
device,
FLOOR_BPS,
no_split,
use_async,
subframe_req,
)
.context( .context(
"NVENC initialize_encoder rejected even at the floor bitrate", "NVENC initialize_encoder rejected even at the floor bitrate",
)?; )?;
@@ -1200,6 +1239,7 @@ impl NvencD3d11Encoder {
self.init_device_com = Some(device.clone()); self.init_device_com = Some(device.clone());
// Session init params a later `reconfigure_bitrate` must re-present verbatim. // Session init params a later `reconfigure_bitrate` must re-present verbatim.
self.split_mode = used_split; self.split_mode = used_split;
self.subframe_on = subframe_req;
self.session_async = use_async; self.session_async = use_async;
// Session-budget accounting (Stage W3): record what this open holds so admission can // Session-budget accounting (Stage W3): record what this open holds so admission can
// decline a parallel display the hardware can't afford. Weighted by the FINAL split // decline a parallel display the hardware can't afford. Weighted by the FINAL split
@@ -1650,17 +1690,14 @@ impl Encoder for NvencD3d11Encoder {
} }
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the // RFI is probed once at open (`rfi_supported`) — the real capability the session glue
// session is in HDR mode. Both are the real capabilities the session glue routes on. // 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 { EncoderCaps {
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`. // The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
blends_cursor: false, blends_cursor: false,
supports_rfi: self.rfi_supported, 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 // 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. // YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
chroma_444: self.chroma_444, chroma_444: self.chroma_444,
@@ -1855,7 +1892,10 @@ impl Encoder for NvencD3d11Encoder {
&mut cfg, &mut cfg,
self.split_mode, self.split_mode,
self.session_async, self.session_async,
resolve_subframe(false), // The SESSION's recorded state, not a fresh env read: reconfigure must
// present exactly the init params the open arbitrated (an env re-read could
// flip enableSubFrameWrite mid-session — the Linux latch invariant).
self.subframe_on,
), ),
..Default::default() ..Default::default()
}; };
-3
View File
@@ -1471,9 +1471,6 @@ impl Encoder for QsvEncoder {
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`. // As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
blends_cursor: false, blends_cursor: false,
supports_rfi: self.ltr_active, 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, chroma_444: false,
intra_refresh: self.ir_active, intra_refresh: self.ir_active,
// Unvalidated on-glass — the host keeps the IDR recovery path until then. // Unvalidated on-glass — the host keeps the IDR recovery path until then.
+205 -9
View File
@@ -203,14 +203,15 @@ pub fn open_video(
} }
}; };
// The session asked for a composited pointer; say so loudly if the backend that actually opened // 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 // cannot deliver one. Since the negotiation became caps-aware ([`cursor_blend_capable`] gates
// (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing // the cursor channel, and the session plan keeps cursor sessions off the native-NV12/RGB-direct
// in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path. // 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
// A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here // cannot see — a Vulkan Video open failing back to VAAPI mid-`open_amd_intel`, and the
// would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is // gamescope residual (gamescope has no embedded mode, so a never-blending backend there —
// the only layer that can fall back to capturer-side compositing. This makes the condition // H.264→VAAPI, software — still streams cursorless). This is the backstop that keeps those
// visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream. // 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 { if cursor_blend && !inner.caps().blends_cursor {
tracing::warn!( tracing::warn!(
backend, backend,
@@ -897,13 +898,29 @@ fn open_nvenc_probed(
} }
// EINVAL = above this GPU's level ceiling → step down. Any other failure (no GPU, // EINVAL = above this GPU's level ceiling → step down. Any other failure (no GPU,
// bad mode, OOM) is real — surface it rather than masking it with bitrate retries. // bad mode, OOM) is real — surface it rather than masking it with bitrate retries.
Err(e) if format!("{e:#}").contains("Invalid argument") => last = Some(e), Err(e) if nvenc_open_einval(&e) => last = Some(e),
Err(e) => return Err(e), Err(e) => return Err(e),
} }
} }
Err(last.unwrap_or_else(|| anyhow::anyhow!("encoder open failed at every probed bitrate"))) Err(last.unwrap_or_else(|| anyhow::anyhow!("encoder open failed at every probed bitrate")))
} }
/// Whether a libav NVENC open failed with EINVAL — the "bitrate above this GPU's level ceiling"
/// signal [`open_nvenc_probed`]'s ladder steps down on. Typed: the root `ffmpeg::Error` survives
/// the `anyhow` context chain, so match it there instead of substring-matching the English
/// strerror rendering of the whole chain — which also fired on any OTHER wrapped EINVAL (e.g. a
/// CUDA-context errno) and steered the ladder on failures that have nothing to do with bitrate.
#[cfg(target_os = "linux")]
fn nvenc_open_einval(e: &anyhow::Error) -> bool {
use ffmpeg_next as ffmpeg;
matches!(
e.downcast_ref::<ffmpeg::Error>(),
Some(ffmpeg::Error::Other {
errno: ffmpeg::util::error::EINVAL
})
)
}
/// Whether the direct-SDK NVENC path is active. **Default ON** — on-glass validated 2026-07-12: /// Whether the direct-SDK NVENC path is active. **Default ON** — on-glass validated 2026-07-12:
/// real RFI landed 73/73 as clean P-frame recovery anchors (never IDR) on an RTX host with a real /// real RFI landed 73/73 as clean P-frame recovery anchors (never IDR) on an RTX host with a real
/// Steam Deck client (design/linux-direct-nvenc.md §9). `PUNKTFUNK_NVENC_DIRECT=0` (also `false`/ /// Steam Deck client (design/linux-direct-nvenc.md §9). `PUNKTFUNK_NVENC_DIRECT=0` (also `false`/
@@ -973,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 /// 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. /// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
/// ///
@@ -1738,6 +1836,72 @@ mod tests {
assert_eq!(none.wire_mask(), None); 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 /// 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 /// `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, /// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
@@ -1798,6 +1962,38 @@ mod tests {
assert_eq!(trait_fns, impl_fns); assert_eq!(trait_fns, impl_fns);
} }
/// The typed-EINVAL classifier the bitrate ladder keys on (Phase 8): the `ffmpeg::Error`
/// must survive `with_context` layers as a downcastable source — pinned here because the
/// entire ladder's step-down behavior rests on it, and an eager `format!` anywhere between
/// `open_with` and the ladder would silently break it (the ladder would stop stepping and
/// 4K sessions would surface errors instead of degrading).
#[cfg(target_os = "linux")]
#[test]
fn nvenc_open_einval_survives_context_layers() {
use ffmpeg_next as ffmpeg;
let e = anyhow::Error::from(ffmpeg::Error::Other {
errno: ffmpeg::util::error::EINVAL,
})
.context("open hevc_nvenc (3840x2160@120, 400000000 bps)")
.context("outer");
assert!(nvenc_open_einval(&e));
// ENOSYS (or any other errno) must not step the ladder.
let e = anyhow::Error::from(ffmpeg::Error::Other {
errno: ffmpeg::util::error::ENOSYS,
})
.context("open");
assert!(!nvenc_open_einval(&e));
}
/// The phrase WITHOUT the type no longer classifies — the fragility that was removed: the
/// old string match trusted any error whose rendering contained "Invalid argument".
#[cfg(target_os = "linux")]
#[test]
fn nvenc_open_einval_ignores_untyped_text() {
let e = anyhow::anyhow!("driver said: Invalid argument (not a typed libav errno)");
assert!(!nvenc_open_einval(&e));
}
/// WP7.6: the resolver's full alias table, pinned. The panicking closure is the laziness /// WP7.6: the resolver's full alias table, pinned. The panicking closure is the laziness
/// contract — an explicit pref must resolve WITHOUT running the auto probe (`/serverinfo` /// contract — an explicit pref must resolve WITHOUT running the auto probe (`/serverinfo`
/// polls through the mirrors; `gamestream/serverinfo.rs` litigated exactly that cost). /// polls through the mirrors; `gamestream/serverinfo.rs` litigated exactly that cost).
+26 -30
View File
@@ -34,6 +34,28 @@
use std::sync::OnceLock; 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 /// 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 /// 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. /// 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") idd_depth: val("PUNKTFUNK_IDD_DEPTH")
.and_then(|s| s.parse::<usize>().ok()) .and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2), .unwrap_or(2),
zerocopy: val("PUNKTFUNK_ZEROCOPY").map(|s| { zerocopy: env_on("PUNKTFUNK_ZEROCOPY"),
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
}),
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting // 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). // is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
ten_bit: val("PUNKTFUNK_10BIT") ten_bit: env_on("PUNKTFUNK_10BIT").unwrap_or(true),
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF — // Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
// is the real switch; see the field doc). // is the real switch; see the field doc).
four_four_four: val("PUNKTFUNK_444") four_four_four: env_on("PUNKTFUNK_444").unwrap_or(true),
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real // Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
// per-session switch; see the field doc). // per-session switch; see the field doc).
chacha20: val("PUNKTFUNK_CHACHA20") chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
perf: flag("PUNKTFUNK_PERF"), perf: flag("PUNKTFUNK_PERF"),
video_source: val("PUNKTFUNK_VIDEO_SOURCE"), video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
compositor: val("PUNKTFUNK_COMPOSITOR"), compositor: val("PUNKTFUNK_COMPOSITOR"),
+30 -15
View File
@@ -36,17 +36,6 @@ fn flag(name: &str) -> bool {
flag_opt(name).unwrap_or(false) 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 /// 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. /// a failed negotiation keeps erroring loudly instead of silently downgrading to the CPU path.
pub fn vaapi_dmabuf_forced() -> bool { 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 /// 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 /// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the
/// race-free SHM path. /// 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 { pub fn enabled() -> bool {
match flag_opt("PUNKTFUNK_ZEROCOPY") { flag_opt("PUNKTFUNK_ZEROCOPY").unwrap_or(true)
Some(v) => v,
None => !VAAPI_DMABUF_FAILED.load(Ordering::Relaxed),
}
} }
/// Whether the tiled-GL zero-copy path converts to NV12 on the GPU and feeds NVENC native YUV — /// 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); 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 EGLCUDA import path is NOT affected)"
);
}
}
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see /// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
/// [`note_raw_dmabuf_import_failure`]). /// [`note_raw_dmabuf_import_failure`]).
pub fn raw_dmabuf_import_disabled() -> bool { pub fn raw_dmabuf_import_disabled() -> bool {
+18 -4
View File
@@ -16,7 +16,7 @@ pub use pf_capture::{
capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer, capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer,
SyntheticCapturer, SyntheticCapturer,
}; };
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest}` (main.rs subcommands) and // `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules. // `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub use pf_capture::{dxgi, synthetic_nv12}; pub use pf_capture::{dxgi, synthetic_nv12};
@@ -66,8 +66,14 @@ fn zero_copy_policy(
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr` /// 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 /// 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`]). /// 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")] #[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 // On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal, // session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
// so use a plain ScreenCast session there. // 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. // 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 // 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. // 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"))] #[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)") 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; 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 = // The client's requested CSC (moonlight-common-c SdpGenerator.c: `encoderCscMode =
// (colorspace << 1) | fullRange` — colorspace 0=Rec601, 1=Rec709, 2=Rec2020). Moonlight // (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 // renderers configure their YUV→RGB from this REQUESTED value (not the bitstream VUI), so a
+171 -20
View File
@@ -33,10 +33,14 @@ pub struct StreamConfig {
pub hdr: bool, 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 /// 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 /// streams so a reconnect doesn't open a second (conflicting) screencast session.
/// the pooled capturer's HDR-ness (see `AppState::video_cap`). pub type CapturerSlot = Arc<std::sync::Mutex<Option<PooledCapturer>>>;
pub type CapturerSlot = Arc<std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>>;
/// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the /// 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)). /// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
@@ -136,7 +140,7 @@ fn run(
running: &Arc<AtomicBool>, running: &Arc<AtomicBool>,
force_idr: &AtomicBool, force_idr: &AtomicBool,
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>, 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 // 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. // encode loop); per-frame sample emission is wired by a later pass.
stats: &Arc<crate::stats_recorder::StatsRecorder>, stats: &Arc<crate::stats_recorder::StatsRecorder>,
@@ -250,6 +254,12 @@ fn run(
return stream_body( return stream_body(
&mut capturer, &mut capturer,
Some(&rebuild), 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, &sock,
cfg, cfg,
running, running,
@@ -266,13 +276,34 @@ fn run(
// pooled capturer's HDR-ness matching this stream's negotiated `cfg.hdr` — the depth is a // 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 // 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). // 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() { let pooled = match video_cap.lock().unwrap().take() {
Some((c, was_hdr)) if was_hdr == cfg.hdr => Some(c), Some((c, was_hdr, was_meta)) if was_hdr == cfg.hdr && was_meta == metadata_cursor => {
Some((c, was_hdr)) => { Some(c)
}
Some((c, was_hdr, was_meta)) => {
tracing::info!( tracing::info!(
was_hdr, was_hdr,
want_hdr = cfg.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); drop(c);
None None
@@ -285,8 +316,13 @@ fn run(
c c
} }
None if pf_host_config::config().video_source.as_deref() == Some("portal") => { None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
tracing::info!(hdr = cfg.hdr, "video source: portal desktop capture"); tracing::info!(
capture::open_portal_monitor(cfg.hdr).context("open portal capturer")? hdr = cfg.hdr,
metadata_cursor,
"video source: portal desktop capture"
);
capture::open_portal_monitor(cfg.hdr, metadata_cursor)
.context("open portal capturer")?
} }
None => { None => {
tracing::info!("video source: synthetic test pattern"); tracing::info!("video source: synthetic test pattern");
@@ -298,6 +334,7 @@ fn run(
let result = stream_body( let result = stream_body(
&mut capturer, &mut capturer,
None, None,
metadata_cursor,
&sock, &sock,
cfg, cfg,
running, running,
@@ -308,7 +345,24 @@ fn run(
on_lost, on_lost,
); );
capturer.set_active(false); 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 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 // 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 // (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`). // 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, vout,
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()), capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
crate::session_plan::CaptureBackend::resolve(), 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); // 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). // `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>, 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, sock: &UdpSocket,
cfg: StreamConfig, cfg: StreamConfig,
running: &Arc<AtomicBool>, 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 // 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. // Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
encode::ChromaFormat::Yuv420, encode::ChromaFormat::Yuv420,
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder // True only when THIS session's capture negotiated cursor-as-metadata — which the
// may be handed cursor bitmaps to composite. // callers grant only where the resolved backend composites (`cursor_blend_capable`).
true, cursor_blend,
) )
.context("open video encoder for stream")?; .context("open video encoder for stream")?;
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend // Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
@@ -757,6 +815,16 @@ fn stream_body(
// dead source can't loop forever — it ends the stream after the cap, falling back to a reconnect. // dead source can't loop forever — it ends the stream after the cap, falling back to a reconnect.
const MAX_REBUILDS: u32 = 5; const MAX_REBUILDS: u32 = 5;
let mut rebuilds: u32 = 0; let mut rebuilds: u32 = 0;
// Encode-stall recovery, the GameStream twin of the native path's ladder (native/stream.rs,
// `reset_stalled_encoder`): a submit/poll failure or a silent stall rebuilds the encoder in
// place — bounded — instead of ending the stream. The backends deliberately turn a wedged
// GPU into a bounded error so the caller can do exactly this; without the ladder here, every
// such error cost a Moonlight client a full disconnect/reconnect. `last_au_at` feeds the
// silent-wedge watchdog below (backends whose non-blocking poll returns `None` forever
// instead of erroring); every received AU clears the reset budget.
const MAX_ENCODER_RESETS: u32 = 5;
let mut encoder_resets: u32 = 0;
let mut last_au_at = Instant::now();
// Coalesce forced keyframes. Under loss Moonlight spams IDR/RFI requests; on an encoder without // Coalesce forced keyframes. Under loss Moonlight spams IDR/RFI requests; on an encoder without
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request // RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
@@ -845,7 +913,7 @@ fn stream_body(
frame.is_cuda(), frame.is_cuda(),
gs_bit_depth(frame.format), gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 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")?; .context("reopen encoder after rebuild")?;
// A rebuilt encoder starts unconfigured — same reason as the first open above. // A rebuilt encoder starts unconfigured — same reason as the first open above.
@@ -866,9 +934,10 @@ fn stream_body(
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder // Honor a client recovery request. Prefer reference-frame invalidation (the encoder
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't // re-references an older still-valid frame — no costly IDR spike); if the encoder can't
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe. // invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
// A prior pipeline drop needs a fresh keyframe to re-anchor the reference chain (see below). // A prior pipeline drop needs a fresh keyframe to re-anchor the reference chain (see
// below). Consumed only when the keyframe is actually EMITTED (in the coalesce gate) —
// read-and-clear here let the gate swallow the request for good.
let mut want_keyframe = recover_after_drop; let mut want_keyframe = recover_after_drop;
recover_after_drop = false;
if let Some((first, last)) = rfi_range.lock().unwrap().take() { if let Some((first, last)) = rfi_range.lock().unwrap().take() {
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR // Prefer reference-frame invalidation when the encoder supports it (no costly IDR
// spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe. // spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe.
@@ -898,12 +967,48 @@ fn stream_body(
if emit { if emit {
enc.request_keyframe(); enc.request_keyframe();
last_keyframe = Some(now); last_keyframe = Some(now);
// A drop-recovery request is satisfied by an EMITTED keyframe, not by being
// read: coalesced away it would be lost — never retried — leaving duplicate wire
// indices in the encoder's reference table for a later RFI to anchor on (the
// stale-anchor case rfi.rs exists to prevent). Keep it armed until this point.
recover_after_drop = false;
} else { } else {
tracing::debug!("video: keyframe request coalesced (IDR still in flight)"); tracing::debug!("video: keyframe request coalesced (IDR still in flight)");
} }
} }
enc.submit_indexed(&frame, au_seq.wrapping_add(enc_inflight)) if let Err(e) = enc.submit_indexed(&frame, au_seq.wrapping_add(enc_inflight)) {
.context("encoder submit")?; // The input half of an encode stall (see native/stream.rs): rebuild the encoder in
// place instead of ending the stream. A backend without an in-place rebuild
// (`reset` = false) or an exhausted budget still fails the session, with the cause.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS || !enc.reset() {
tracing::error!(
error = %format!("{e:#}"),
resets = encoder_resets,
"encoder did not recover after repeated in-place rebuilds — ending the \
stream (see the error above for the cause)"
);
return Err(e).context("encoder submit");
}
// The owed AUs died with the discarded encoder state; numbering restarts at `au_seq`,
// and the rebuilt encoder's reference state is empty so the reused predictions meet
// no stale bookkeeping (same reasoning as the capture rebuild above). The IDR
// bypasses the coalesce gate: a rebuilt encoder MUST resync the client.
enc_inflight = 0;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
last_au_at = Instant::now();
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"encoder submit failed — encoder rebuilt in place, forcing an IDR");
// Real backoff between attempts, not a frame period: five instant retries burn out
// inside one driver hiccup (the native ladder's 2026-07 field lesson).
let backoff =
frame_interval.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
next_frame = Instant::now() + backoff;
std::thread::sleep(backoff);
continue;
}
enc_inflight = enc_inflight.wrapping_add(1); enc_inflight = enc_inflight.wrapping_add(1);
let t_enc = tick.elapsed(); let t_enc = tick.elapsed();
@@ -914,7 +1019,19 @@ fn stream_body(
// stamped with its wire frameIndex here (`au_seq + position`); the numbering only // stamped with its wire frameIndex here (`au_seq + position`); the numbering only
// ADVANCES if the batch is actually enqueued below (a dropped batch consumes none). // ADVANCES if the batch is actually enqueued below (a dropped batch consumes none).
let mut aus: Vec<(Vec<u8>, FrameType, u32)> = Vec::new(); let mut aus: Vec<(Vec<u8>, FrameType, u32)> = Vec::new();
while let Some(au) = enc.poll().context("encoder poll")? { // A poll error is the output half of an encode stall (e.g. a bounded fence timeout from
// a wedged GPU) — carry it to the shared stall recovery below, after the AUs already
// drained are handed off, instead of killing the session outright.
let mut poll_err: Option<anyhow::Error> = None;
loop {
let au = match enc.poll() {
Ok(Some(au)) => au,
Ok(None) => break,
Err(e) => {
poll_err = Some(e);
break;
}
};
let ft = if au.keyframe { let ft = if au.keyframe {
FrameType::Idr FrameType::Idr
} else { } else {
@@ -923,6 +1040,9 @@ fn stream_body(
let idx = au_seq.wrapping_add(aus.len() as u32); let idx = au_seq.wrapping_add(aus.len() as u32);
aus.push((au.data, ft, idx)); aus.push((au.data, ft, idx));
enc_inflight = enc_inflight.saturating_sub(1); enc_inflight = enc_inflight.saturating_sub(1);
// Every AU proves the encoder is alive.
last_au_at = Instant::now();
encoder_resets = 0;
} }
let t_pkt = tick.elapsed(); let t_pkt = tick.elapsed();
@@ -951,6 +1071,37 @@ fn stream_body(
} }
} }
} }
// Encode-stall recovery, the poll half (mirrors the native path's watchdog): an explicit
// poll error, or no AU within the window while frames are owed — the silent wedge, where
// a non-blocking poll returns `None` forever and nothing else ever errors. The window
// scales with the frame interval so low-fps modes can't false-trip.
let stall_window = Duration::from_secs(2).max(frame_interval * 8);
if poll_err.is_some() || (enc_inflight > 0 && last_au_at.elapsed() >= stall_window) {
let why = match &poll_err {
Some(e) => format!("poll failed: {e:#}"),
None => format!(
"no AU for {} ms with {} frame(s) owed",
last_au_at.elapsed().as_millis(),
enc_inflight
),
};
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS || !enc.reset() {
return Err(poll_err.unwrap_or_else(|| anyhow::anyhow!("{why}")))
.context("encoder stalled — in-place rebuild unavailable or exhausted");
}
enc_inflight = 0;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
last_au_at = Instant::now();
tracing::warn!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why,
"encode stall detected — encoder rebuilt in place, forcing an IDR");
let backoff =
frame_interval.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
next_frame = Instant::now() + backoff;
std::thread::sleep(backoff);
continue;
}
if measure { if measure {
let t_send = tick.elapsed(); let t_send = tick.elapsed();
let cap_us = t_cap.as_micros(); let cap_us = t_cap.as_micros();
+9 -1
View File
@@ -338,7 +338,15 @@ fn real_main() -> Result<()> {
// adapter, which may not be the one that encodes). // adapter, which may not be the one that encodes).
let mut size = (64u32, 64u32); let mut size = (64u32, 64u32);
let mut vendor = None; 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() { match a.as_str() {
"intel" => vendor = Some(0x8086), "intel" => vendor = Some(0x8086),
"nvidia" => vendor = Some(0x10de), "nvidia" => vendor = Some(0x10de),
+4 -3
View File
@@ -1012,9 +1012,10 @@ async fn serve_session(
// just never fires then. // just never fires then.
let (cursor_shape_tx, cursor_shape_rx) = let (cursor_shape_tx, cursor_shape_rx) =
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>(); tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
// Negotiated cursor forwarding: MUST match the HOST_CAP_CURSOR bit the Welcome advertised // Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
// (handshake::cursor_forward is the single predicate both read). // rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
let cursor_forward = handshake::cursor_forward(hello.client_caps, compositor); // 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- // Who renders the pointer RIGHT NOW (client `CursorRenderMode`, flipped live by the mouse-
// model chord): `true` = client draws (exclude + forward), `false` = host composites (the // model chord): `true` = client draws (exclude + forward), `false` = host composites (the
// capture model). Starts true — the pre-message behavior for cap sessions. Control task // capture model). Starts true — the pre-message behavior for cap sessions. Control task
+42 -15
View File
@@ -12,31 +12,46 @@ use super::*;
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the /// 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 /// 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 /// `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 /// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c) — AND, on
/// predicate: the Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off /// Linux, the encode backend this session resolves to can composite the pointer on demand
/// wiring both read it, so they can never disagree. /// (`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( pub(super) fn cursor_forward(
client_caps: u8, client_caps: u8,
compositor: Option<crate::vdisplay::Compositor>, compositor: Option<crate::vdisplay::Compositor>,
codec: crate::encode::Codec,
bit_depth: u8,
) -> bool { ) -> bool {
if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 { if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 {
return false; return false;
} }
#[cfg(target_os = "linux")] #[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) 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")] #[cfg(target_os = "windows")]
{ {
// Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel — // 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 // 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. // copy would double it. The probe latches by opening the control device once. The
let _ = compositor; // 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() crate::vdisplay::manager::hw_cursor_capable()
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
{ {
let _ = compositor; let _ = (compositor, codec, bit_depth);
false false
} }
} }
@@ -252,6 +267,12 @@ pub(super) async fn negotiate(
// *monitor* streams only — the GameStream portal-mirror path uses that; see // *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 // `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. // 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(); 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 // 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 // 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 host_wants_444 = pf_host_config::config().four_four_four;
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0; let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade // The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
// gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010 // gate. Linux's portal capturer always can (`capturer_supports_444` returns `true`
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host // unconditionally). On WINDOWS the IDD-push path CAN too — for an SDR 4:4:4 session it passes
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only // the BGRA ring slot straight through, skipping the NV12 VideoConverter — but only a backend
// that ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards
// `resolved_backend_ingests_rgb_444()` (today: direct-NVENC only; AMF can't 4:4:4 at all and
// the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR display still downgrades to 4:2:0
// at capture time — there is no 10-bit full-chroma source — and the encoder's caps cross-check
// reports that truth. (Replaces the old `single_process` gate — single-process is now the only
// topology, and 4:4:4 routed to DDA, which was removed.) // topology, and 4:4:4 routed to DDA, which was removed.)
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma // PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real // (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
@@ -488,9 +514,10 @@ pub(super) async fn negotiate(
0 0
} }
// Cursor channel granted (client asked + this capture path can deliver cursor // 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 // metadata out of the frame + the resolved encoder can composite on the
// it sees this bit, and serve_session wires forwarding from the same predicate. // capture-mouse flip) — the client turns its local renderer on ONLY when it sees
| if cursor_forward(hello.client_caps, compositor) { // 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 punktfunk_core::quic::HOST_CAP_CURSOR
} else { } else {
0 0
@@ -536,9 +563,9 @@ pub(super) async fn negotiate(
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1); let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
let client_identity = endpoint::peer_fingerprint(conn); let client_identity = endpoint::peer_fingerprint(conn);
let client_hdr = hello.display_hdr; let client_hdr = hello.display_hdr;
// Same predicate the Welcome's HOST_CAP_CURSOR bit used — the prepared display and // The bit the Welcome just advertised — read back rather than recomputed, so the
// the session wiring must agree with what we just advertised. // prepared display and the session wiring cannot disagree with it.
let cursor_fw = cursor_forward(hello.client_caps, Some(comp)); let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
let (mode, shard_payload) = (hello.mode, welcome.shard_payload); let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
let trace = bringup.clone(); let trace = bringup.clone();
std::thread::Builder::new() std::thread::Builder::new()
+30 -17
View File
@@ -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 // 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): // 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 — // it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
// the blend must be built for every gamescope session. // the blend must be built for every gamescope session. (`cursor_forward` is already
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward, // 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, ctx.cursor_forward,
); );
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after // 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 // capture-mode channel); a switch AWAY restores the prior
// gating. `plan` is `Copy` — this is the value the rebuild // gating. `plan` is `Copy` — this is the value the rebuild
// (and its `build_pipeline` attach) reads. // (and its `build_pipeline` attach) reads.
plan.cursor_blend = plan.cursor_forward plan.cursor_blend = crate::session_plan::cursor_blend_for(
|| c == crate::vdisplay::Compositor::Gamescope; plan.cursor_forward,
c == crate::vdisplay::Compositor::Gamescope,
);
plan.gamescope_cursor = plan.gamescope_cursor =
c == crate::vdisplay::Compositor::Gamescope; c == crate::vdisplay::Compositor::Gamescope;
gamescope_composite = gamescope_composite =
@@ -2987,7 +2994,10 @@ pub(super) fn prepare_display(
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the // non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel // mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor. // 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, cursor_forward,
); );
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope; plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
@@ -3248,20 +3258,23 @@ fn build_pipeline(
let mut capturer = let mut capturer =
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture) crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
.context("capture virtual output")?; .context("capture virtual output")?;
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer gamescope's // gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
// nested Xwayland — it reads the pointer over X11 (XFixes shape + QueryPointer position) and // reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
// feeds `cursor()`, which the encode loop composites. A failed discovery/connect leaves the // QueryPointer position) and feeds `cursor()`, which the encode loop composites.
// stream cursorless (today's behaviour); non-gamescope plans skip this entirely. // 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")] #[cfg(target_os = "linux")]
if plan.gamescope_cursor { if plan.gamescope_cursor {
let targets = pf_vdisplay::gamescope_xwayland_cursor_targets(); capturer.attach_gamescope_cursor(std::sync::Arc::new(
if targets.is_empty() { pf_vdisplay::gamescope_xwayland_cursor_targets,
tracing::warn!( ));
"gamescope cursor: no nested Xwayland discovered — no in-video pointer this session"
);
} else {
capturer.attach_gamescope_cursor(targets);
}
} }
if let Some(t) = trace { if let Some(t) = trace {
t.mark("capture_attached"); t.mark("capture_attached");
+35 -9
View File
@@ -104,9 +104,13 @@ pub struct SessionPlan {
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs. /// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
pub wire_chunk: Option<usize>, pub wire_chunk: Option<usize>,
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata /// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself). /// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their /// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
/// blending path when this is set, so the pointer never silently vanishes from the stream. /// 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, pub cursor_blend: bool,
/// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer /// 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 /// 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 // 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 // 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 // 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, // has no CSC stage to fold the cursor into — so ANY cursor-compositing session
// which it does NOT into the PipeWire node) — so a gamescope-cursor session (Phase C) // (gamescope Phase C, whose XFixes pointer is absent from the PipeWire node, AND a
// must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend that draws // cursor-forward session, whose capture-mouse flip needs the host composite on
// `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the native-NV12 cursor // demand) must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend
// blend is the perf-preserving follow-up. // 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")] #[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"))] #[cfg(not(target_os = "linux"))]
nv12_native: false, nv12_native: false,
} }
@@ -217,6 +223,26 @@ pub(crate) fn resolve_topology() -> SessionTopology {
SessionTopology::SingleProcess 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")] #[cfg(target_os = "windows")]
fn resolve_encoder() -> EncoderBackend { fn resolve_encoder() -> EncoderBackend {
match crate::encode::windows_resolved_backend() { match crate::encode::windows_resolved_backend() {
+3 -1
View File
@@ -94,7 +94,9 @@ pub fn run(opts: Options) -> Result<()> {
want_hdr, want_hdr,
"spike source: xdg ScreenCast portal (live monitor)" "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 => { Source::KwinVirtual => {
let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin); let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);
+2 -1
View File
@@ -143,7 +143,8 @@ notes for context.
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). | | `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. | | `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. H.264 never splits (not applicable per the SDK); on HEVC a *forced* split disables sub-frame readback (mutually unsupported) — set `0` to choose sub-frame instead. |
| `PUNKTFUNK_NVENC_SUBFRAME` | `0` · `1` | NVENC sub-frame (slice-level) readback for lower latency on sync sessions. Default: on where the GPU supports it (Linux direct NVENC). `0` = never; `1` = force. On HEVC it yields to a forced split-encode (the SDK documents the pair unsupported). |
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. | | `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. | | `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
+146
View File
@@ -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