Compare commits

..
24 Commits
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 ead37d066f perf(encode/nvenc-linux): drop the bring-up probe left on the per-frame blend path
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 58s
decky / build-publish (push) Successful in 17s
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 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 5m27s
ci / bench (push) Successful in 6m10s
deb / build-publish (push) Successful in 9m22s
docker / deploy-docs (push) Successful in 24s
windows-host / package (push) Successful in 10m0s
deb / build-publish-host (push) Successful in 13m54s
android / android (push) Successful in 16m12s
arch / build-publish (push) Successful in 16m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m6s
ci / rust (push) Successful in 21m41s
apple / screenshots (push) Successful in 23m17s
`33121ece` added a block explicitly labelled "TEMP KWin composite probe (DROP BEFORE
MERGE)" to prove the cursor blend was dispatching. It merged, and has been running on
every blended frame since: an atomic fetch_add per frame plus a `tracing::info!` with
seven computed fields every 512 frames, on the submit hot path of the default-on
Linux direct-NVENC backend.

The signal it existed for is already covered — the failure arm above it warns once
with the dispatch error, and the `else` arm warns when an overlay arrives with no
blend at all. What is deleted is only the success-path telemetry.

Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc,vulkan-encode,pyrowave), and `clippy --features vulkan-encode,pyrowave` on real
AMD RDNA3 hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 03:48:59 +02:00
enricobuehlerandClaude Opus 5 310b85f155 fix(encode): a forced-Vulkan pref must not advertise codecs that arm will refuse
ci / docs-site (push) Successful in 54s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 20s
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 11s
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 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
apple / swift (push) Successful in 5m10s
ci / bench (push) Successful in 5m58s
windows-host / package (push) Successful in 10m3s
deb / build-publish (push) Successful in 12m10s
docker / deploy-docs (push) Successful in 27s
deb / build-publish-host (push) Successful in 12m52s
android / android (push) Successful in 15m8s
arch / build-publish (push) Successful in 15m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m51s
ci / rust (push) Successful in 22m34s
apple / screenshots (push) Successful in 24m6s
With `PUNKTFUNK_ENCODER=vulkan`, `open_video_backend`'s vulkan arm bails outright for
anything that is not HEVC/AV1 ("the Vulkan Video encoder supports HEVC + AV1; the
session negotiated {codec:?}"). But `host_wire_caps` for that same pref fell through
to the VAAPI probe / static superset, which includes H.264 — so the host advertised
H.264, a client could negotiate it, and the session died at encoder open.

The pref now contributes a CEILING that is intersected with the device probe, never a
replacement for it. That distinction is the whole fix: pinning a static HEVC|AV1
would have ADDED AV1 on the AMD/Intel hosts whose probe currently withholds it
(pre-RDNA3, pre-Arc), re-creating this very bug for a different codec. Intersecting
can only ever narrow.

Without the `vulkan-encode` feature the pref cannot open anything at all — that arm
bails with "requires a build with --features vulkan-encode" — so the ceiling is
empty there rather than optimistic.

Costs nothing on the handshake path: a `&str` match plus a const-folded `cfg!`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 03:17:45 +02:00
enricobuehlerandClaude Opus 5 087a3e358a fix(encode/vaapi): key the entrypoint cache on the device, not just the codec
`LP_MODE` cached the resolved VAAPI entrypoint (full-feature `EncSlice` vs low-power
`EncSliceLP`/VDEnc) in a process-global array indexed by codec alone. That is not a
staleness bug, it is a session-killer, because the cache is load-bearing: once a mode
is latched the open tries exactly ONE mode and the `[false, true]` fallback is gone.

Latch low-power on an Intel Arc (Gen12+ removed the full-feature entrypoints, so it
is the only one that works there), then switch the web-console GPU preference to an
AMD dGPU. `render_node()` follows that preference, so every VAAPI open now goes to
radeonsi passing `low_power=1`, which it rejects — with no full-feature retry, for
the process lifetime. `probe_can_encode` reports all-false (so the advertisement
silently falls back to the static superset) AND the session's own encoder open fails.

Now keyed on (render node, codec, bit depth):

  - The render node because the entrypoint is a property of the DEVICE libva opens,
    and it is literally what `render_node()` hands libva — so key and device cannot
    describe different GPUs. Deliberately not `pf_gpu::selection_key()`, which can
    name a different adapter than the node actually opened.
  - The bit depth because Main10 and 8-bit can resolve to different entrypoints on
    the same device; one shared slot let an 8-bit answer pin the 10-bit open, i.e.
    HDR under-advertisement.

Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc,vulkan-encode,pyrowave). The multi-GPU switch itself needs a box with two
VAAPI-capable adapters — owed on-glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 03:17:44 +02:00
enricobuehlerandClaude Opus 5 7536f7319a fix(gamestream): a software-encode host must advertise H.264 only
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 20s
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
ci / bench (push) Successful in 7m9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m31s
deb / build-publish-host (push) Successful in 9m36s
docker / deploy-docs (push) Successful in 26s
deb / build-publish (push) Successful in 11m22s
arch / build-publish (push) Successful in 11m56s
android / android (push) Successful in 15m17s
windows-host / package (push) Successful in 17m7s
apple / swift (push) Failing after 17m50s
apple / screenshots (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m41s
ci / rust (push) Successful in 27m13s
`base_codec_mode_support` fell through to the static superset (H.264|HEVC|AV1) on a
GPU-less host, so Moonlight negotiated HEVC or AV1 and the session then died at
encoder open with "the software encoder emits H.264 only" — openh264 encodes nothing
else. The native plane's twin of this function, `pf_encode::Codec::host_wire_caps`,
has gated on exactly this since it was written; this one never did.

Deliberately a local gate rather than delegating wholesale to `host_wire_caps()`.
Delegation is the drift-proof shape and was the first design, but on Windows it
re-runs the DXGI adapter enumeration several times per `/serverinfo` GET — the probe
helpers each sample it uncached — and this endpoint is polled by every client. The
software case is a plain config read, so it costs nothing here.

Recorded in a comment rather than fixed: the static `MaxLumaPixelsHEVC` in the
serverinfo XML still advertises an HEVC limit even when the mask now drops HEVC.
Harmless (Moonlight gates capability on the mask, not the limit) but it is a second,
now-inconsistent advertisement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 02:41:57 +02:00
enricobuehlerandClaude Opus 5 4d9f94e0a4 docs(encode): record why the production loops must not call Encoder::flush
`flush` looked dead — the only caller in the host is the `spike` dev subcommand — so
an audit sweep filed it as "wire it in or delete it". Both are wrong, and without
this note the next sweep will re-file it.

Wiring it in is refuted by control flow: both encode loops reach their exit only
AFTER the transport is gone (client disconnected, or the session stopped), so the AUs
a flush would recover have nowhere to go. And flush is the one call on this trait
that can BLOCK on a wedged encoder, on exactly the teardown path a stopped session
needs to finish promptly — the Linux direct-SDK NVENC retrieve-thread join is untimed,
so flushing there could hang a session that is already ending.

Deleting it is refuted by real consumers: `spike` encodes a FINITE clip and wants the
tail, and the `#[ignore]`d hardware smoke tests across the backends assert the drain
contract on real GPUs. Those are finite-stream users; a live session is not one.

Doc only, no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 02:41:57 +02:00
enricobuehlerandClaude Opus 5 ef239691df feat(encode): make cursor blending a queryable capability, not an assumption
`open_video`'s `cursor_blend` argument was a request with no answer: lib.rs did
`let _ = cursor_blend;` and only three backends ever read `CapturedFrame::cursor`.
So a session could ask for a composited pointer, get a backend that silently
discards it, and stream with no mouse cursor and nothing in the logs. Two
separately-confirmed audit findings — the VAAPI dmabuf path and the libav-NVENC CUDA
path — are symptoms of that one hole.

`EncoderCaps::blends_cursor` makes it a fact each backend states. The four exhaustive
`EncoderCaps { .. }` constructors mean adding the field is a compile error until every
backend answers, which is the enforcement mechanism for future backends rather than a
side effect. Vulkan Video answers from its ACTUAL configured source rather than
statically: only the CSC path composites (`prep_cursor` feeds the compute shader),
while the RGB-direct/EFC front-end and the native-NV12 source have no compositing
stage at all and merely warn once that the pointer is being dropped.

`open_video` warns when a session asked for blending and the opened backend cannot
deliver it. A warning is deliberately all it does: `open_video` cannot re-plan
capture, so refusing would trade a missing pointer for a dead session. The host owns
`plan.cursor_blend` and is the only layer that can fall back to capturer-side
compositing — this gives it something to base that on.

Enforcement is NOT included. The reviewed design proposed refusing the client's
host-composite flip to keep the client drawing its own pointer, but `CursorRenderMode`
is client->host only: there is no host->client counterpart, so refusing yields no
pointer at all — the same failure it claimed to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 02:41:57 +02:00
enricobuehlerandClaude Opus 5 ab63e0dad3 fix(encode/nvenc-windows): fail safe when nobody sets the input ring depth
`set_input_ring_depth` is a DEFAULTED trait method, so a caller that forgets it
fails silently — and one did. The GameStream loop opens an encoder
(gamestream/stream.rs:671 and the rebuild at :831) and never called it, while the
Windows IDD-push capturer declares a ring of 2 and `async_inflight_cap()` defaults
to 4. With PUNKTFUNK_NVENC_ASYNC=1 a Moonlight session therefore pipelined four
encodes against a two-texture ring, letting the capturer rotate a texture out from
under a live encode: torn or mixed frames, never an error — precisely the corruption
the cap exists to prevent, and precisely what the trait doc warns "fails silently and
intermittently".

Guarded at the single point of CONSUMPTION rather than by plumbing the setter into
every loop: `input_ring_depth` has exactly one reader in the workspace, so one
fail-safe covers every caller including ones not yet written, whereas fixing N call
sites only fixes the N someone remembered. An unconfigured ring is now treated as
the shallowest any capturer here declares, so the unconfigured path degrades to less
pipelining — a latency cost, not corruption.

The two GameStream sites also pass the REAL depth, because the fail-safe is a floor,
not a substitute: `idd_depth` is configurable and a deeper ring is free pipelining
the fallback would forfeit.

Default (sync) sessions are unaffected — the cap is only read while the async
retrieve thread exists, and that is opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 02:41:57 +02:00
enricobuehlerandClaude Fable 5 78fe77b049 feat(host/encode): de-escalate the latency escalation once cadence holds clean
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 32s
ci / bench (push) Successful in 6m12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m44s
deb / build-publish (push) Successful in 12m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-host (push) Successful in 13m29s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6m35s
android / android (push) Successful in 15m57s
arch / build-publish (push) Successful in 16m18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m21s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 17m51s
apple / swift (push) Successful in 5m59s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m32s
ci / rust (push) Successful in 21m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m45s
flatpak / build-publish (push) Successful in 6m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m35s
release / apple (push) Successful in 30m44s
apple / screenshots (push) Successful in 24m33s
Escalate-and-hold's missing half. The contention escalation (capture depth,
then the NVENC pipelined retrieve) was permanent: one sustained overrun — even
one CAUSED by the ABR overdrive's rebuild storms — cost the session its
depth-1 latency and its sub-frame streaming forever, and `encode_us` reported
queue depth instead of ASIC time for the rest of the session.

- The leaky bucket now keeps scoring after escalation. A sustained
  every-frame-on-cadence run (~5 s at 120 fps) winds back one stage in reverse
  order: pipelined retrieve first (its rebuild restores the IO-stream binding
  and sub-frame chunked streaming), then capture depth back to 1. Attempts are
  paced by an exponential backoff (1 → 5 → 25 min, capped) — a workload that
  truly needs the escalation converges to keeping it, but never a permanent
  latch.

- NVENC (Linux) implements `set_pipelined(false)`: a `want_sync` latch handled
  at the same drained safe point as the engage side (`maybe_disengage_async`
  mirrors `maybe_engage_async`); the lazy sync re-init re-arms everything and
  opens on an IDR. The stream loop polls until the switch lands, then re-runs
  the escalation warmup so the wind-back's own stall can't re-escalate it.
  `PUNKTFUNK_NVENC_ASYNC=1` (operator-pinned async) refuses the wind-back;
  the trait doc now specifies the two-way contract.

- While escalated, `cadence_degraded` stays latched (bitrate climbs refused)
  even with the bucket drained: the headroom is spent, and climbs resuming
  mid-escalation would saw against it and starve the clean run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:44:54 +02:00
enricobuehlerandClaude Fable 5 1b27706a9b feat(host/native): truthful bitrate state — applied-rate adoption, ceiling pre-clamp, climb refusal
Host half of the §ABR-overdrive fix. The stream loop now reads
`Encoder::applied_bitrate_bps()` after every bitrate apply and stores THAT into
`bitrate_kbps`/`live_bitrate` — the send pacer, web console, mgmt registry and
control-task acks all track what the ASIC really targets instead of the
requested rate (the pre-fix ack promised 1.01 Gbps while the encoder ran
794 Mbps, and the client controller climbed from the phantom base forever).

- A short apply teaches `encoder_ceiling_kbps` (shared atomic): the stream loop
  pre-clamps incoming requests to it and SKIPS the apply when nothing would
  change — ending the reconfigure-reject → full-rebuild(~0.6 s + IDR) storm —
  and the control task resolves future SetBitrate acks against it, so the
  client learns the ceiling through the existing ack path (no wire change; old
  clients converge too).

- `cadence_degraded` (shared flag, leaky-bucket level ≥ 10 or an escalated
  session): while set, the control task resolves climbs to the current applied
  rate — on a fat LAN no network signal ever stops a climb the encoder can't
  serve, and past the compute knee more bits only deepen the cadence miss.
  Descents always pass; they're the cure.

- Escalation-warmup hygiene: an ABR rebuild stall (~70 missed deadlines at
  120 fps, 3.5× the escalate threshold) and the backlog scored against a
  heavier rate no longer feed the latency escalation — rebuilds reset the
  leaky bucket and re-run the warmup; in-place down-steps clear the bucket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:44:54 +02:00
enricobuehlerandClaude Fable 5 f3c3a9427b feat(client/abr): learn the host's rate cap from short acks, and read host encode time as a signal
Two client-side halves of the §ABR-overdrive fix (the 4K120 sessions that
climbed to 1 Gbps against a 794 Mbps encoder and a 8.33 ms budget):

- Short-ack cap learning: the host now resolves a climb it can't serve to what
  the encoder actually runs at (its codec-level ceiling, or the current rate
  while encode is behind cadence). Two consecutive identical short acks latch
  that value as a host cap the climb logic folds into its ceiling — one short
  ack stays a transient (a failed rebuild also acks short once). The cap is
  mode-scoped (cleared on an accepted mode switch, tracked via a mode
  generation counter the control task bumps) and re-probes one step after ~60 s
  parked clean, so a heavy-scene refusal can't quietly cap the whole session.

- Host-encode-latency down-driver: the per-AU 0xCF `encode_us` the host
  already ships (and the overlay already draws) now feeds the controller
  through its own window accumulator — the overlay channel is lossy and
  embedder-drained, so the ABR gets a dedicated mirror of the decode-latency
  path. Baseline-relative like the decode signal (an escalated host reports
  encode_us inflated by ~a frame of queue depth; an absolute budget threshold
  would read permanently red), with the baseline rebased after our own
  decreases so one backoff doesn't train-fire into the floor. This is the only
  signal that can push an already-too-high rate back under the encoder's
  compute knee — host climb refusal stops the climb, but nothing else descends
  on a clean LAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:44:54 +02:00
enricobuehlerandClaude Fable 5 c1d54b835b fix(encode/nvenc): cache the level ceiling, expose the applied bitrate, force 2-way split at 4K120
Three encode-time fixes from the 4K120 field analysis (sessions pinned ~107 of
120 fps, 14-15 ms reported encode):

- Ceiling truth: the codec-level bitrate clamp (binary search at session open)
  now records its result in a process-lifetime advisory cache keyed by
  GPU/config, so an ABR overshoot opens — or in-place reconfigures — straight
  AT the ceiling instead of re-running the ~6-open search (and a full rebuild
  + IDR) on every overshoot. New `Encoder::applied_bitrate_bps()` exposes the
  post-clamp rate so the session loop can stop pacing/acking a phantom
  requested rate (consumed host-side in a follow-up).

- Clamp-search hygiene: only NVENC parameter/caps rejections steer the search
  now (`NvCallError` keeps the raw status downcastable); a transient failure
  (busy engine, session limit, OOM, driver skew) propagates instead of
  shrinking into — and now caching — a bogus ceiling. The floor fallback also
  records the split mode it actually opened with, so a later reconfigure
  re-presents the real session params.

- Split-frame selection: one shared `resolve_split_mode` replaces the two
  byte-identical direct-SDK copies; the force-2-way threshold moves to
  `SPLIT_FORCE_PIXEL_RATE` (950 Mpix/s, shared with the libav path) because
  4K120 = 995,328,000 px/s missed the old `> 1e9` gate by 0.47% and stayed on
  AUTO — which never engages at 2160 px height, leaving the second NVENC
  engine idle in exactly the mode the threshold existed for. The Linux
  session-ready info! line now carries the final split mode (journals are
  INFO+; this was undiagnosable from user logs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:44:54 +02:00
enricobuehlerandClaude Opus 5 42e5f5ad1e fix(encode/windows): two dead items the new lint leg exposed in the shipped combo
windows-host / package (push) Successful in 11m35s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m0s
decky / build-publish (push) Successful in 20s
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 11s
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 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m3s
arch / build-publish (push) Successful in 12m26s
android / android (push) Successful in 15m11s
docker / deploy-docs (push) Successful in 11s
deb / build-publish (push) Successful in 11m16s
deb / build-publish-host (push) Successful in 12m13s
apple / swift (push) Successful in 5m48s
ci / rust (push) Successful in 21m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m52s
apple / screenshots (push) Successful in 24m41s
09aa2db3 turned on `-p pf-encode --all-targets -D warnings` for Windows and removed
the crate-wide `allow(dead_code)`; together those surfaced two genuinely-dead items
in `nvenc,amf-qsv,qsv` — the combination the installer ships — and CI went red on my
own step.

  - `WinVendor::Amf`: native AMF replaced the libavcodec AMF path in production, so
    the only remaining CONSTRUCTOR is the `#[cfg(feature = "amf-qsv")]` latency A/B
    in amf.rs — test code, so the lib target constructs it nowhere. The module
    header already says this machinery is kept deliberately for that measurement, so
    it gets a targeted `#[allow(dead_code)]` naming the reason rather than deletion.
  - `probe_can_encode`: `lib.rs`'s only caller is under
    `cfg(all(not(feature = "qsv"), feature = "amf-qsv"))`, because with the native
    VPL backend compiled in `qsv::probe_can_encode` answers instead. Gated
    `#[cfg(not(feature = "qsv"))]` to match its call site.

Both are real findings the blanket allow had been hiding; neither is a behaviour
change.

WHY THIS ESCAPED MY PRE-PUSH CHECKS, since that is the useful part: the Windows
runner I verify on has no FFmpeg dev tree, so `amf-qsv` could not build there and
`ffmpeg_win.rs` was compiled by NOTHING in my matrix — the same class of blind spot
this whole audit is about, reproduced by me. CI's own FFmpeg turns out to be cached
on that box at C:\Users\Public\ffmpeg, so the verification recipe now sets
FFMPEG_DIR and covers `nvenc,amf-qsv,qsv` (the CI command and my new step) plus
`amf-qsv` without `qsv` — the combo that still uses `probe_can_encode`. All three
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 01:04:04 +02:00
enricobuehlerandClaude Opus 5 d49f1bba49 fix(tray): count native sessions as streaming, and stop hiding "Open web console"
apple / swift (push) Successful in 1m30s
ci / web (push) Successful in 1m1s
windows-host / package (push) Failing after 4m40s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 5m31s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
arch / build-publish (push) Successful in 11m30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
android / android (push) Successful in 15m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m36s
deb / build-publish (push) Successful in 9m54s
deb / build-publish-host (push) Successful in 9m42s
ci / rust (push) Failing after 21m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 9m57s
apple / screenshots (push) Successful in 22m35s
docker / deploy-docs (push) Successful in 25s
The tray sat at "Idle" through an entire stream, and its Windows context menu
could come up without its main action. Two separate causes.

`GET /api/v1/local/summary` reported `video_streaming`/`audio_streaming` straight
from `AppState.streaming` — flags only the GameStream media pipeline raises. The
native punktfunk/1 plane is the DEFAULT (GameStream is opt-in) and never touches
them, so every native session read as idle: idle icon, and a tooltip that printed
that session's own resolution next to the word "idle" (the `session` field was
already native-aware). This is the blind spot `/status` was fixed for — see the
`session_status` module doc — which the summary kept. Both flags now OR in a live
native session, and the tray additionally treats a reported session as streaming,
so a new tray reads an older host correctly too.

The menu built "Open web console" (and the pairing entry) only while a live
loopback probe of the console had just succeeded. A console still starting, or an
SSR render slower than the 2 s probe timeout, therefore deleted the tray's
most-wanted action outright — indistinguishable from a tray that never had one,
with left-clicking the icon as the only, undiscoverable, way in. The entry is now
always present and the default item; a failed probe changes its label to
"(not responding)" instead of hiding it, and takes two consecutive misses to say
so, since one timeout is not "down".

While here: a "Release kept display…" entry when displays are held (the summary
field's own doc promised a one-click release that did not exist), and entries
deep-link to /pairing and /displays instead of all landing on the dashboard. The
Linux SNI menu mirrors all of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:46:30 +02:00
enricobuehlerandClaude Opus 5 09aa2db37c fix(encode): probe Vulkan encode before committing capture to producer-native NV12
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m8s
apple / swift (push) Successful in 1m22s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
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 45s
ci / bench (push) Successful in 6m59s
windows-host / package (push) Failing after 10m45s
deb / build-publish (push) Successful in 11m9s
android / android (push) Successful in 12m59s
deb / build-publish-host (push) Successful in 11m59s
arch / build-publish (push) Successful in 13m21s
apple / screenshots (push) Successful in 16m48s
ci / rust (push) Successful in 22m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m39s
docker / deploy-docs (push) Successful in 28s
`linux_native_nv12_ok` is the verdict the host threads into capture negotiation
(session_plan -> OutputFormat::nv12_native -> ZeroCopyPolicy::native_nv12_session
-> pf-capture's prefer_native_nv12). Its doc claimed "the backend must be eligible
to open", but it asked only three static questions — codec is H265/AV1, an env
default, and a pref denylist — and never whether this GPU has an encode queue at
all. It could not: vulkan_video.rs exposed no probe.

That verdict is uniquely load-bearing. Once the producer has been asked for
two-plane NV12 there is NO fallback: open_video deliberately makes a failed Vulkan
open FATAL for an NV12 capture rather than degrading to libav VAAPI, because VAAPI
would import that buffer as packed RGB and stream silent garbage. So on a gamescope
host whose Mesa lacks Vulkan HEVC encode the session died at its first frame, while
the same host with PUNKTFUNK_PIPEWIRE_NV12=0 streamed fine — and the comment
promising such a device "degrades gracefully to the old backend rather than
breaking the stream" was stale on every gamescope host.

Two changes:

1. The gate. The third conjunct was a denylist of the EXPLICIT prefs that skip
   Vulkan Video ("nvenc"|"nvidia"|"cuda"|"pyrowave"), which silently missed the one
   that matters: the DEFAULT encoder_pref is "", and "" resolves to auto, which on
   an NVIDIA box opens NVENC. A stock NVIDIA host passed this gate. It now consults
   `linux_zero_copy_is_vaapi`, which layers the pref on top of the same auto
   decision open_video makes — what the note on `linux_auto_is_vaapi` says a
   capability probe must use, and what the downstream consumer of this very verdict
   already uses. This alone was worth fixing; a probe added behind the old gate
   would have opened a Vulkan instance on every NVIDIA handshake.

2. The probe. `vulkan_video::probe_encode_support` runs the FIRST check open_inner
   performs and hard-fails on — the physical-device + encode-queue-family scan for
   this codec's op — and nothing more, so it is provably no stricter than the open
   and can never talk a working host out of the fast path. The scan is now a shared
   `find_encode_device` used by BOTH, because a probe that mirrors a dispatch goes
   stale the first time the dispatch grows a case (the failure open_video's
   backend-label note already records). Cost: one instance plus physical-device
   queries — no logical device, no video session, no VRAM — cached per (selected
   GPU, codec) in the can_encode_10bit idiom, with the probe run outside the lock.
   Per codec, not once: codec_op_for selects a different queue-family bit for AV1,
   and HEVC-encode-without-AV1-encode is the common VCN/ANV configuration.

Later stages (create_device, create_video_session, the capability query) can still
fail for reasons the probe does not model. Those are harmless: the capture format
is only committed once the probe says yes, and everything after keeps the ordinary
packed-RGB negotiation.

Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc+vulkan-encode+pyrowave). The behaviour needs an on-glass check on the bazzite
RADV box — including the negative case, which `RADV_DEBUG=novideo` reproduces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Opus 5 4063ddc93d perf(encode/nvenc-linux): swscale the packed 3-bpp expand instead of a per-pixel loop
`submit_cpu`'s RGB24/BGR24 → rgb0/bgr0 expand ran `w*h` iterations per frame, each
building two bounds-checked sub-slices for a 3-byte copy plus a pad-byte store — a
shape LLVM will not vectorise into the byte shuffle it actually is. At 3840x2160
that is 8.3M iterations on the encode thread, per frame.

It is not an edge case. The module header says the portal commonly negotiates
packed 24-bit RGB, and pf-capture offers `VideoFormat::RGB` first because wlroots
commonly fixates it — so this was the mainstream CPU path, running roughly an order
of magnitude slower than the 4-bpp sibling branch (a plain row memcpy) purely
because of how it was written.

swscale's packed-RGB expanders are SIMD, the sibling VAAPI backend already routes
RGB24/BGR24 through them, and this file already owned the `sws_getContext` /
`sws_freeContext` lifecycle — so this is net subtractive rather than new machinery:
the existing CSC condition widens to include `expand`, and the hand-written loop
goes away. No new field, no second context, nothing added to `Drop`: `expand` is
false whenever `want_444` (see the `nvenc_pixel`/`expand` binding), so the three
users are mutually exclusive and one context serves whichever applies. The `expand`
field itself is gone with its only reader.

`sws_setColorspaceDetails` is now applied ONLY for the 4:4:4/HDR users. The expand
is a pure byte shuffle — NVENC does the RGB→YUV itself downstream — and handing it
a matrix and range would have silently range-converted every packed-RGB session,
which is exactly what the module header promises does not happen.

Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc+vulkan-encode+pyrowave). The pixel path itself is unverifiable without an
NVIDIA box: `nvenc_hdr10_smoke` and friends are #[ignore]d, so the channel order
and the pad byte want an on-glass check on the CachyOS 5070 Ti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Opus 5 c7081b2a82 fix(encode/nvenc-linux): stop leaking the SwsContext on open's early returns
`sws_getContext` hands back a raw pointer whose only `sws_freeContext` lives in
`Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED `Self`, which does not
exist on either of `open`'s early returns: the intra-refresh-unsupported path
(which recurses into `Self::open`) and the plain error return. Both sit between
the context's creation and the `Ok(NvencEncoder { … })` that would give it an
owner, so every failed open leaked one.

That is not a once-per-process wart. `open_nvenc_probed` walks an EINVAL bitrate
ladder — requested rate, then the codec-level cap, then ×3/4 down to 50 Mb/s —
re-entering `open` at each step, so a GPU that refuses the requested bitrate leaks
a context per step (up to ~10). The intra-refresh retry adds one more.

Fixed structurally rather than with a guard: the block depends only on values known
before the encoder open (`want_444`, `want_hdr10`, `cuda`, `format`, `nvenc_pixel`,
the dimensions, `full_range_444`), so moving it BELOW `video.open_with(opts)` — to
just above the struct construction, with nothing fallible in between — makes the
leak unrepresentable instead of merely handled. No behaviour change: same context,
same colourspace details, same field.

Found while designing the WP1.4 swscale expand, which would have promoted this from
a 4:4:4/HDR-only leak (the only sessions that build a context today) to one on every
packed-RGB session. Fixing it first is the prerequisite for that change.

Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc+vulkan-encode+pyrowave).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Opus 5 6be174dc9c fix(encode/windows): the encode bit depth must follow the pixels, not the negotiation
All three Windows backends derived it the same wrong way —
`bit_depth >= 10 || matches!(format, P010 | Rgb10a2)` at ffmpeg_win.rs:154,
amf.rs:1275 and qsv.rs:777 — so the NEGOTIATED depth could force a 10-bit encoder
over an 8-bit capture.

That combination is not hypothetical. A client advertises 10-bit, the handshake
negotiates bit_depth=10, and then enabling advanced colour on the IDD virtual
display fails — at which point the capturer says exactly that and delivers 8-bit
NV12 anyway ("10-bit HDR was negotiated but enabling advanced color on the virtual
display FAILED — encoding 8-bit SDR"). Every vendor then lost the session, each in
its own way:

  - native AMF and native QSV derived `expected = P010`, saw Nv12, and bail!'d at
    open;
  - the libavcodec path accepted the open, built a P010 encoder, and then failed
    EVERY submit forever, because its per-frame check recomputes the depth from the
    frame and never matches. reset() could not help: the rebuild re-derived the
    same wrong depth from the same stored bit_depth. The host burned
    MAX_ENCODER_RESETS and ended the session.

The last one is the worst of the three because it IS the fallback: native QSV
correctly refuses this input at open, and lib.rs then falls back to the ffmpeg path
"for robustness" — which accepted it and died per frame instead.

Since the duplication was the bug, the fix is one shared `ten_bit_input()` in
codec.rs that all three call, and it follows the delivered pixels. That also keeps
the stream HONEST rather than merely alive: the depth selects the colour signalling
(BT.2020 PQ vs BT.709) and the staging surface format, so an 8-bit capture now
produces an 8-bit stream that says it is SDR — which is what the capturer already
reported it was sending. It warns when the negotiated depth is discarded.

The negotiated depth stays an upper bound. The session LABEL may still claim HDR;
that mismatch lives in the negotiation, not in the encoder, and is not addressed
here.

`is_10bit_format` keeps its (now format-only) definition and is used by
submit_d3d11's per-frame check, so the predicate the encoder was built from and the
one it re-checks per frame cannot drift. That check can now only fire on a genuine
mid-stream depth change.

Verified `--all-targets -D warnings` on Windows (no features / pyrowave / qsv /
nvenc,qsv; 31 tests) and Linux (default; shipped nvenc+vulkan-encode+pyrowave).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Opus 5 6c97c00add fix(encode/pyrowave): the RDO block-index cap must cover 4:2:0, not just 4:4:4
The vendored rate controller packs the 32x32 block index into the low 16 bits of
`RDOperation::block_offset_saving` (pyrowave-sys patches/0002). Past u16::MAX the
index collides with the `saving` field, the resolve over-credits, and the emitted
payload can overshoot the buffer `pyrowave_encoder_packetize` writes into — whose
only bounds check is an `assert` that the Release (NDEBUG) vendored build compiles
out. There is no Rust-side guard behind it.

All three callers of `pyrowave_mode_fits_rdo` hardcoded 4:4:4, leaving 4:2:0
unchecked — but 4:2:0 overflows too, just later: 8192x6144 is 73728 blocks and
8192x8192 is 98304, while `Codec::PyroWave.max_dimension()` permits 8192 per axis,
so both are reachable from a client-requested `mode=WxHxFPS`. Worse, the host's
only use of the helper (native/handshake.rs) is a 4:4:4 -> 4:2:0 downgrade, so an
oversized mode was actively routed INTO the unguarded branch.

The cap now lives in `validate_dimensions`, checked against 4:2:0 — the most
permissive chroma, so a mode that cannot fit there fits no PyroWave session at any
chroma. That is the single chokepoint both the negotiator (handshake.rs:180) and
`open_video_backend` already run. Both encoder opens additionally check the chroma
actually being opened: the 4:4:4 half, plus defence in depth for the
`PUNKTFUNK_ENCODER=pyrowave` lab override.

Not dormant: punktfunk-host has `default = ["pyrowave"]` and no build passes
`--no-default-features`, so these backends ship in every host binary.

Tests pin the arithmetic (8192x6144 = 73728, 8192x8192 = 98304, 7680x4320 still
fits) and assert the cap does not leak to H.265/AV1, which have no such controller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Opus 5 16fa43da40 ci(encode): lint and test the feature-gated encode backends
pf-encode's GPU backends are off by default, so almost none of them were under
`-D warnings`:

  - ci.yml lints/tests with default features, which do cover VAAPI, libav-NVENC
    and — via punktfunk-host's `default = ["pyrowave"]` — the PyroWave backends,
    but not `nvenc` or `vulkan-encode`.
  - deb.yml builds those two, but with `cargo build`, where warnings are not errors.

That left enc/linux/nvenc_cuda.rs, enc/linux/vulkan_video.rs and the vendored
vk_av1_encode / vk_valve_rgb bindings — ~8,150 lines carrying ~70 `unsafe` blocks —
never linted anywhere, so the crate's own
`#![deny(clippy::undocumented_unsafe_blocks)]`, its stated unsafe-proof gate, was
never actually enforced on them.

Linux gains a clippy+test leg at the SHIPPED feature set: deb.yml builds
`punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
`--no-default-features`, so the .deb carries pyrowave too and that combination is
what deserves the lint. GPU-free — the hardware tests are `#[ignore]`d and
NVENC/CUDA dlopen their entry points, so the test binary links with no driver.

Windows gains a separate `-p pf-encode --all-targets` lint. The existing lint is
`-p punktfunk-host`, which never builds pf-encode's test targets — the blind spot
that let the Linux twin's tests rot. It must be clippy rather than `cargo test`:
on MSVC nvidia-video-codec-sdk link-imports NvEncodeAPICreateInstance /
NvEncodeAPIGetMaxSupportedVersion, so a test binary cannot link without the
driver's import lib. clippy type-checks without linking; ci.yml runs the tests.

`--all-targets` is load-bearing, not decoration: without it the feature-gated
`#[cfg(test)]` modules are never compiled at all.

Also widens windows-host.yml's paths filter, which listed `crates/pf-encode/**`
but none of the crates it compiles against (pf-frame, pf-gpu, pf-zerocopy,
pf-host-config, pf-capture, ...), so a change reaching the Windows host through
one of those triggered no Windows build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Opus 5 07348c0175 refactor(encode): drop the crate-wide allow(dead_code)
Inherited from the pre-extraction host crate root as scaffolding for backend
paths defined ahead of the build that used them. A census across every feature
combination on both platforms (flip it to `warn`, rebuild) found it was hiding
exactly two items — so it bought nothing while blinding the crate to future rot:

  - `vaapi::fourcc` — superseded by `pf_frame::drm_fourcc`; no call sites left.
  - `vulkan_video::open_opts` — test-only (the smoke tests use it to pass the
    RGB-direct request explicitly rather than through the env), now `#[cfg(test)]`.

Removing it surfaced a real latent defect the Linux census could not see:
`amf.rs`'s `percentile` / `drive_and_measure` helpers are used only by the
`#[cfg(feature = "amf-qsv")]` latency A/B benchmark, so a `--features nvenc,qsv`
build compiled the helpers with their caller gated out. They now carry the same
gate as their caller.

Verified `--all-targets -D warnings` on Linux (no features; shipped
nvenc+vulkan-encode+pyrowave) and Windows (no features; pyrowave; qsv; nvenc,qsv).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Opus 5 e30551b1e2 fix(encode/nvenc-linux): unrot the direct-NVENC test module
All ten `NvencCudaEncoder::open` call sites in the `#[cfg(test)]` module passed 9
arguments to a 10-parameter function: `cursor_blend` was added to `open` and the
tests were never updated. The module has been uncompilable ever since —
`cargo clippy -p pf-encode --features nvenc --all-targets` fails with 10x E0061.

Nothing in CI noticed because no job compiles this crate's feature-gated test
targets: ci.yml lints/tests with default features (which do not include `nvenc`),
and windows-host.yml lints `-p punktfunk-host`, which builds pf-encode as a
dependency and so never builds its test targets. The CI commit below closes that
gap; this has to land first or that leg starts red.

`false` is what these tests exercised before `cursor_blend` existed: every frame
they build sets `cursor: None`, and there is no cursor-blend test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Fable 5 8d5a9f66c9 fix(gamescope): ship a packaged privilege path for the DM-stop takeover
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 55s
android / android (push) Successful in 12m5s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / bench (push) Successful in 6m29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 8m40s
arch / build-publish (push) Successful in 14m51s
deb / build-publish-host (push) Successful in 9m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 15m32s
apple / swift (push) Successful in 23m28s
ci / rust (push) Successful in 26m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m37s
apple / screenshots (push) Successful in 2h32m51s
Field report (Nobara, 0.19.2): entering Game Mode mid-stream left the monitor
on and the stream mirroring at the desktop's resolution. Root cause: the
DM-flavor takeover needs privilege to stop plasmalogin, the polkit rule is a
docs-only manual step nobody installs, so every managed entry silently
degraded to ATTACH — and with a physical display connected the attach path
mirrors the box's own session at its own mode.

Ship the privilege with the packages instead: a root helper
(libexec/punktfunk/pf-dm-helper, verbs stop|restore) behind its own polkit
action (io.unom.punktfunk.dm-helper, allow_any — the same mechanism Nobara's
os-session-select uses, and the helper derives the DM unit from the
display-manager.service symlink itself so callers never name a unit across
the privilege boundary). The host tries the plain system-bus verbs first
(root / operator rule still take precedence), then pkexec's the helper; the
restore paths (idle restore + in-stream desktop-switch honor) use the same
ladder so a takeover that needed the helper can always be undone by it.

Packaged in rpm/deb/arch (Arch under /usr/lib/punktfunk with the policy's
exec.path annotation rewritten; the host probes both layouts). Nix is left
out deliberately: store paths can't match the probe, NixOS keeps the manual
rule. Docs updated — the rule snippet stays as the scoped alternative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:41:29 +02:00
enricobuehlerandClaude Opus 5 5c7a9407ff fix(windows/web): start the console once its inputs exist, and verify it started
arch / build-publish (push) Successful in 17m1s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
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 8s
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 47s
ci / bench (push) Successful in 6m19s
deb / build-publish (push) Successful in 9m35s
docker / deploy-docs (push) Successful in 29s
apple / screenshots (push) Successful in 10m56s
deb / build-publish-host (push) Successful in 13m40s
android / android (push) Successful in 16m19s
windows-host / package (push) Successful in 17m13s
ci / rust (push) Successful in 19m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m18s
A fresh install left PunktfunkWeb registered but not running: `web setup` waited
only for the mgmt token before firing `schtasks /run`, while `web-run.cmd` also
requires the host identity cert — and the host writes the token during argument
parsing but `cert.pem` only after the pure-Rust RSA-2048 keygen inside `serve`.
The launcher lost that race, exited 1, and since the task carried no trigger but
boot (Task Scheduler does not reliably restart on a non-zero exit code) the
console stayed down until the next reboot, with the installer still reporting
"web console set up + started".

- `web setup` gates on cert.pem (written last) as well as the token, 90 s budget.
- After `schtasks /run`, poll for the :47992 listener and retry before giving up;
  warn honestly instead of claiming a start that did not happen.
- `web-run.cmd` (installed + dev) waits in-process for the token + cert (~5 min)
  rather than exiting 1 and hoping restart-on-failure retries.
- Register the task with a logon trigger alongside boot, falling back to the
  boot-only XML if a Task Scheduler build rejects it.
- Linux had the same defect: punktfunk-web.service's Restart=on-failure gave up
  permanently after systemd's default 5-starts-in-10 s limit. StartLimitIntervalSec=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 23:22:09 +02:00
enricobuehler 7781d09e26 feat(linux): log if unsupported / too low gamescope version discovered
apple / swift (push) Successful in 2m29s
android / android (push) Successful in 12m52s
arch / build-publish (push) Successful in 12m59s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m6s
ci / bench (push) Successful in 5m30s
ci / rust (push) Successful in 19m51s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
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 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 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
deb / build-publish (push) Successful in 8m43s
apple / screenshots (push) Successful in 20m59s
deb / build-publish-host (push) Successful in 9m25s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m22s
2026-07-24 20:57:44 +02:00
49 changed files with 2538 additions and 471 deletions
+37
View File
@@ -61,6 +61,43 @@ jobs:
- name: Test (unit + loopback + proptest + C ABI harness) - name: Test (unit + loopback + proptest + C ABI harness)
run: cargo test --workspace --locked run: cargo test --workspace --locked
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
# `cargo build`, where warnings are not errors, so pf-encode's own
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
# `default = ["pyrowave"]`, so the steps above already cover it.)
#
# `--all-targets` is load-bearing, not decoration: without it the feature-gated
# `#[cfg(test)]` modules are never compiled, which is exactly how all ten
# `NvencCudaEncoder::open` call sites in nvenc_cuda.rs's tests drifted to the wrong arity
# (E0061 x10) without any job noticing.
#
# GPU-free: every test needing real hardware is `#[ignore]`d, and NVENC/CUDA resolve their
# entry points at RUNTIME (dlopen), so the test binary links without a driver present.
# (On MSVC the same crate link-imports those symbols instead, which is why windows-host.yml
# can only type-check these tests via clippy — see the note there.)
#
# Scoped to `-p pf-encode` with ITS OWN feature names: punktfunk-host has no code gated on
# `nvenc`/`vulkan-encode` (its only `cfg(feature)` sites are the two `pyrowave` ones in
# capture.rs, and pyrowave is default-on, so the steps above already cover them). Going
# through `--features punktfunk-host/...` would force punktfunk-host into the selection and
# re-run its entire test suite a second time for no extra coverage.
#
# `pyrowave` is listed explicitly even though it is punktfunk-host's default: selecting only
# `-p pf-encode` takes the host out of the resolution, and pf-encode's own default is empty.
# Naming it keeps this the SHIPPED Linux feature set — deb.yml builds
# `--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
# `--no-default-features`, so the .deb carries nvenc + vulkan-encode + pyrowave together, and
# that combination is what deserves the lint.
- name: Clippy + test the feature-gated Linux encode backends
run: |
cargo clippy -p pf-encode --all-targets --locked \
--features nvenc,vulkan-encode,pyrowave -- -D warnings
cargo test -p pf-encode --locked --features nvenc,vulkan-encode,pyrowave
- name: C ABI harness (standalone link proof) - name: C ABI harness (standalone link proof)
run: bash crates/punktfunk-core/tests/c/run.sh run: bash crates/punktfunk-core/tests/c/run.sh
+32
View File
@@ -50,6 +50,23 @@ on:
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck. # builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
- 'crates/pf-encode/**' - 'crates/pf-encode/**'
- 'crates/libvpl-sys/**' - 'crates/libvpl-sys/**'
# …and the rest of the W6 subsystem crates this build compiles. pf-encode was listed while
# the crates it speaks (pf-frame's CapturedFrame/PixelFormat/dxgi vocabulary, pf-gpu's
# adapter selection, pf-zerocopy, pf-host-config) were not, so a change that broke the
# Windows host through one of THEM reached main with no Windows build at all — the same
# Cargo.lock-luck gap the two lines above were added to close.
- 'crates/pf-frame/**'
- 'crates/pf-gpu/**'
- 'crates/pf-zerocopy/**'
- 'crates/pf-host-config/**'
- 'crates/pf-capture/**'
- 'crates/pf-win-display/**'
- 'crates/pf-vdisplay/**'
- 'crates/pf-inject/**'
- 'crates/pf-paths/**'
- 'crates/pf-driver-proto/**'
- 'crates/pf-clipboard/**'
- 'crates/pyrowave-sys/**'
- 'packaging/windows/**' - 'packaging/windows/**'
- 'scripts/windows/**' - 'scripts/windows/**'
- 'web/**' - 'web/**'
@@ -154,8 +171,23 @@ jobs:
# build minutes earlier). Linting in release reuses those native build-script artifacts (no # build minutes earlier). Linting in release reuses those native build-script artifacts (no
# 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
# are type-checked — the AMF C-ABI layout assertions (`variant_layout_matches_c` and
# friends, which are the only guard on a hand-mirrored vtable ABI), the QSV tests, and the
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
# worth it while ci.yml executes the same tests.
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 punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" } cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
+3 -3
View File
@@ -5106,7 +5106,7 @@
"properties": { "properties": {
"audio_streaming": { "audio_streaming": {
"type": "boolean", "type": "boolean",
"description": "True while the audio stream thread is running." "description": "True while audio is streaming on either plane (same rule as `video_streaming`)."
}, },
"conflicts": { "conflicts": {
"type": "array", "type": "array",
@@ -5150,7 +5150,7 @@
}, },
{ {
"$ref": "#/components/schemas/SessionInfo", "$ref": "#/components/schemas/SessionInfo",
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming."
} }
] ]
}, },
@@ -5160,7 +5160,7 @@
}, },
"video_streaming": { "video_streaming": {
"type": "boolean", "type": "boolean",
"description": "True while the video stream thread is running." "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone."
} }
} }
}, },
+12
View File
@@ -2652,6 +2652,18 @@ mod pipewire {
build_default_format_obj(preferred) build_default_format_obj(preferred)
}; };
// gamescope trap — the Steam overlay's presence in the stream is decided HERE by omission:
// gamescope's `paint_pipewire()` composites the overlay (Shift+Tab / Quick Access Menu) into
// the node it hands us ONLY when the consumer-negotiated `gamescope_focus_appid` is 0 — the
// default, and the "mirror the focused window + overlay" branch (gamescope ≥ 3.16.23; see
// `MIN_GAMESCOPE_OVERLAY`). None of the EnumFormat pods below advertise the
// `SPA_FORMAT_VIDEO_gamescope_focus_appid` property, so gamescope reads 0 and paints the
// overlay for us for free. DO NOT add a non-zero focus-appid (e.g. to "isolate the game" in a
// dedicated session) — that flips gamescope into the Remote-Play branch that deliberately
// drops the overlay (and all host chrome) back out of the capture. The cursor, external
// overlay (MangoHUD), and notifications are excluded from the node on EVERY gamescope
// version and are composited host-side instead (see `xfixes_cursor.rs`).
//
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers // When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
// (offering shm too makes the compositor pick shm). The modifier list is advertised with // (offering shm too makes the compositor pick shm). The modifier list is advertised with
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in // DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
+137 -3
View File
@@ -7,6 +7,45 @@
use anyhow::Result; use anyhow::Result;
use pf_frame::CapturedFrame; use pf_frame::CapturedFrame;
/// Whether an encoder fed `format` must be built 10-bit — decided by **the pixels that actually
/// arrive**, never by the negotiated `bit_depth`.
///
/// The three Windows backends each derived this as `bit_depth >= 10 || matches!(format, P010 |
/// Rgb10a2)`, i.e. the *negotiated* depth could force a 10-bit encoder over an 8-bit capture. That
/// combination is not hypothetical: a client advertises 10-bit, the handshake negotiates
/// `bit_depth = 10`, and then enabling advanced colour on the IDD virtual display fails — at which
/// point the capturer says so and delivers 8-bit NV12 anyway (`pf-capture`'s idd_push logs "10-bit
/// HDR was negotiated but enabling advanced color on the virtual display FAILED — encoding 8-bit
/// SDR"). Every backend then lost the session, each in its own way: native AMF and native QSV
/// `bail!` at open because the format does not match the P010 they derived, and the libavcodec
/// path accepted the open and then failed EVERY submit forever (its per-frame depth check
/// recomputes from the frame, which never matches), where `reset()` could not help because the
/// rebuild re-derived the same wrong depth.
///
/// Following the pixels keeps the stream alive and, more importantly, keeps it HONEST: the depth
/// also selects the colour signalling (BT.2020 PQ vs BT.709) and the staging surface format, so an
/// 8-bit capture now yields an 8-bit stream that says it is SDR — which is what the capturer
/// already reported it is sending. The negotiated depth remains an upper bound; the session label
/// may still claim HDR, and that mismatch belongs to the negotiation, not to the encoder.
/// Windows-only: the three backends that derive an encoder depth from a capture live there
/// (native AMF, native QSV, libavcodec AMF/QSV). The Linux backends take the depth from the
/// negotiated `bit_depth` alone because their capture formats carry it unambiguously.
#[cfg(target_os = "windows")]
pub(crate) fn ten_bit_input(format: pf_frame::PixelFormat, negotiated_depth: u8) -> bool {
use pf_frame::PixelFormat;
let ten = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
if negotiated_depth >= 10 && !ten {
tracing::warn!(
?format,
negotiated_depth,
"session negotiated 10-bit but the capturer delivers an 8-bit format — encoding 8-bit \
SDR (the stream's colour signalling follows the pixels; check whether advanced colour \
failed to enable on the virtual display)"
);
}
ten
}
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization. /// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each /// `data` is in-band Annex-B (the encoder is opened without a global header), so each
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary /// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
@@ -73,7 +112,7 @@ impl AuChunk {
} }
/// Codec selection negotiated with the client. /// Codec selection negotiated with the client.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Codec { pub enum Codec {
H264, H264,
H265, H265,
@@ -255,6 +294,19 @@ pub struct EncoderCaps {
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh /// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set. /// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
pub intra_refresh_period: u32, pub intra_refresh_period: u32,
/// The encoder composites [`CapturedFrame::cursor`] into the picture it encodes.
///
/// `open_video`'s `cursor_blend` argument is a REQUEST, and for most of this crate's life it was
/// nothing else: `lib.rs` literally did `let _ = cursor_blend;` and only three backends ever read
/// `frame.cursor`. So a session could ask for a composited pointer, get a backend that silently
/// discards it, and stream with no mouse cursor at all — the confirmed symptom on the VAAPI
/// dmabuf path and the libav-NVENC CUDA path.
///
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
/// `open_video` can only warn, which it does.
pub blends_cursor: bool,
} }
/// A hardware encoder. One per session; runs on the encode thread. /// A hardware encoder. One per session; runs on the encode thread.
@@ -313,8 +365,15 @@ pub trait Encoder: Send {
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll` /// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer /// may return `None` while an encode is in flight) in exchange for capture/submit no longer
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the /// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
/// switch may be deferred to the next safe point internally. `false` from the default impl = /// switch may be deferred to the next safe point internally. `set_pipelined(true)` returning
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere. /// `false` (the default impl) = unsupported — the session loop stops asking.
///
/// `set_pipelined(false)` requests the wind-back (de-escalation, latency recovery): the
/// backend restores its sync-retrieve mode — and the latency features that mode carries
/// (IO-stream binding, sub-frame chunking) — at its next safe point, usually via a session
/// rebuild whose first frame is an IDR. The return is still "is pipelined retrieve active":
/// the caller polls until it reads `false`. Backends that never escalate return `false`
/// trivially. An operator pin (`PUNKTFUNK_NVENC_ASYNC=1`) refuses the wind-back.
fn set_pipelined(&mut self, _on: bool) -> bool { fn set_pipelined(&mut self, _on: bool) -> bool {
false false
} }
@@ -360,6 +419,16 @@ pub trait Encoder: Send {
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool { fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
false false
} }
/// The bitrate (bps) the encoder is ACTUALLY running at (or will open at, for a lazily-opened
/// backend) — the encoder-side truth after any internal clamp, e.g. the direct-NVENC
/// codec-level ceiling search. The session loop reads this after every open/reconfigure and
/// stores IT, not the requested rate, as the live bitrate — so the send pacer, the console
/// and the client controller's ack all track what the ASIC really targets (a controller fed
/// the requested rate keeps climbing from a phantom base, §ABR overdrive). `None` (the
/// default) = the backend doesn't track an applied rate; the caller keeps the requested one.
fn applied_bitrate_bps(&self) -> Option<u64> {
None
}
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave /// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU /// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost /// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
@@ -379,6 +448,20 @@ pub trait Encoder: Send {
fn set_input_ring_depth(&mut self, _depth: usize) {} fn set_input_ring_depth(&mut self, _depth: usize) {}
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll) /// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`. /// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
///
/// **The two production encode loops deliberately do not call this**, and that is not an
/// oversight to be "fixed" by a later sweep. Both reach their exit only after the transport is
/// already gone (the client disconnected, or the session was stopped), so the AUs a flush would
/// recover have nowhere to go — while flushing is the one call on this trait that can BLOCK on a
/// wedged encoder, on precisely the teardown path a stopped session needs to complete promptly.
/// The Linux direct-SDK NVENC backend makes that concrete: its retrieve-thread join is untimed
/// (see the note in `enc/linux/nvenc_cuda.rs`), so a flush there could hang a session that is
/// already ending.
///
/// It is kept rather than deleted because it does have real consumers: the `spike` dev
/// subcommand, which encodes a FINITE clip and genuinely wants the tail, and the `#[ignore]`d
/// hardware smoke tests across the backends, which assert the drain contract on real GPUs.
/// Those are finite-stream users; a live session is not one.
fn flush(&mut self) -> Result<()>; fn flush(&mut self) -> Result<()>;
} }
@@ -413,6 +496,17 @@ impl Codec {
} }
} }
/// Pixel rate (luma samples/s) at or above which NVENC split-frame encoding is FORCED 2-way —
/// one number shared by the direct-SDK selector (`nvenc_core::resolve_split_mode`) and the libav
/// `split_encode_mode` option author (`linux::NvencEncoder`), so the two paths can never disagree
/// about which modes split. A single NVENC engine tops out ~1 Gpix/s on HEVC, and AUTO doesn't
/// engage below ~2112 px height, so the sessions that need the second engine must be forced. Set
/// BELOW 1 Gpix/s deliberately: 4K120 — the mode this threshold exists for — is 3840×2160×120 =
/// 995,328,000, which a `> 1_000_000_000` gate missed by 0.47% and left on AUTO (pinned ~107 fps
/// on a 4090). 950 M keeps margin for fractional refresh rates while leaving 1440p240 (884.7 M,
/// comfortably single-engine) on AUTO.
pub const SPLIT_FORCE_PIXEL_RATE: u64 = 950_000_000;
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency /// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for /// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the /// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
@@ -454,6 +548,26 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
(use HEVC/AV1 above 4096, or lower the client resolution)" (use HEVC/AV1 above 4096, or lower the client resolution)"
); );
} }
// PyroWave's vendored rate controller packs the 32×32 block index into the low 16 bits of
// `RDOperation::block_offset_saving` (pyrowave-sys `patches/0002-rdo-saving-clamp.patch`).
// Past `u16::MAX` blocks the index collides with the `saving` field, the resolve over-credits,
// and the emitted payload can overshoot the buffer `pyrowave_encoder_packetize` writes into —
// whose only bounds check is an `assert` that the Release (NDEBUG) vendored build compiles out.
// So this is a hard cap, not a quality knob.
//
// Checked against 4:2:0, the *most permissive* chroma: a mode that cannot fit even there can
// fit no PyroWave session at all, so it belongs at this single chokepoint (which both the
// negotiator and `open_video_backend` run) rather than only in the per-backend opens. 4:4:4
// has twice the block count and is checked again at open, where the real chroma is known —
// and the negotiator's 4:4:4 → 4:2:0 downgrade means an oversized mode arrives at the encoder
// as 4:2:0, which is exactly the case the old open-time guard skipped.
#[cfg(feature = "pyrowave")]
if codec == Codec::PyroWave && !crate::pyrowave_mode_fits_rdo(width, height, false) {
anyhow::bail!(
"invalid PyroWave resolution {width}x{height}: exceeds the rate controller's 16-bit \
block index (pyrowave-sys patches/0002) lower the client resolution"
);
}
Ok(()) Ok(())
} }
@@ -477,6 +591,26 @@ mod tests {
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err()); assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
} }
/// PyroWave's hard cap is the rate controller's 16-bit block index, not just
/// `max_dimension()`. Checked at 4:2:0 (the most permissive chroma), because a mode that
/// cannot fit there cannot fit at any chroma — and because the negotiator's 4:4:4 → 4:2:0
/// downgrade delivers oversized modes to the encoder AS 4:2:0. HEVC/AV1 at the same
/// dimensions must stay unaffected.
#[cfg(feature = "pyrowave")]
#[test]
fn pyrowave_rejects_modes_past_the_rdo_block_index() {
// Fits: 8K 4:2:0 is 49125 blocks.
assert!(validate_dimensions(Codec::PyroWave, 7680, 4320).is_ok());
// Does not fit at 4:2:0 (73728 / 98304 blocks) — must be refused even though both are
// within `Codec::PyroWave.max_dimension()` (8192).
assert!(validate_dimensions(Codec::PyroWave, 8192, 6144).is_err());
assert!(validate_dimensions(Codec::PyroWave, 8192, 8192).is_err());
// The same modes remain legal for the H.26x/AV1 codecs, which have no such rate
// controller — the cap must not leak across codecs.
assert!(validate_dimensions(Codec::H265, 8192, 8192).is_ok());
assert!(validate_dimensions(Codec::Av1, 8192, 6144).is_ok());
}
#[test] #[test]
fn hevc_and_av1_allow_up_to_8192() { fn hevc_and_av1_allow_up_to_8192() {
for c in [Codec::H265, Codec::Av1] { for c in [Codec::H265, Codec::Av1] {
+105 -77
View File
@@ -180,7 +180,6 @@ pub struct NvencEncoder {
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path. /// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
want_444: bool, want_444: bool,
src_format: PixelFormat, src_format: PixelFormat,
expand: bool,
width: u32, width: u32,
height: u32, height: u32,
fps: u32, fps: u32,
@@ -413,57 +412,6 @@ impl NvencEncoder {
None None
}; };
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
// input frame. Two users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag) and HDR
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
// through the matrix untouched). Skipped on the zero-copy path (`cuda`): the worker's GPU
// convert already delivers ready CUDA frames — no CPU pixels exist to scale.
let sws_csc = if (want_444 || want_hdr10) && !cuda {
let src_av = pixel_to_av(sws_src_pixel(format)?);
let dst_av = pixel_to_av(nvenc_pixel);
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
// null-checked below.
let sws = unsafe {
ffi::sws_getContext(
width as c_int,
height as c_int,
src_av,
width as c_int,
height as c_int,
dst_av,
SWS_POINT,
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
)
};
if sws.is_null() {
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
}
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for HDR
// — matching the VUI written above) are process-lifetime libswscale statics, reused for
// src+dst matrices; `sws_setColorspaceDetails` only reads them and writes scalar CSC
// settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
unsafe {
let cs = ffi::sws_getCoefficients(if want_hdr10 {
super::libav::SWS_CS_BT2020
} else {
SWS_CS_ITU709
});
let dst_range = i32::from(full_range_444);
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
}
Some(sws)
} else {
None
};
// Low-latency NVENC tuning (plan §7 / linux-setup doc). // Low-latency NVENC tuning (plan §7 / linux-setup doc).
let mut opts = Dictionary::new(); let mut opts = Dictionary::new();
opts.set("preset", "p1"); // fastest opts.set("preset", "p1"); // fastest
@@ -491,15 +439,19 @@ impl NvencEncoder {
} }
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds // Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it, // a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
// @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px // (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate). // height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
// Output is standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE. // Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set
// so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is
// standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
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), Some(mode) => opts.set("split_encode_mode", mode),
None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => { None if matches!(codec, Codec::H265 | Codec::Av1)
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
{
opts.set("split_encode_mode", "2"); opts.set("split_encode_mode", "2");
tracing::info!( tracing::info!(
pix_rate, pix_rate,
@@ -549,6 +501,84 @@ impl NvencEncoder {
); );
} }
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
// retry, which recurses into `Self::open`, and the plain error return). Creating the
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
// so this placement makes the leak unrepresentable rather than merely unlikely.
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
// through the matrix untouched), and the packed 3-bpp expand (RGB24/BGR24→rgb0/bgr0).
//
// The expand used to be a hand-written per-pixel loop in `submit_cpu`: `w*h` iterations,
// each building two bounds-checked sub-slices for a 3-byte copy — a shape LLVM will not
// vectorise into the byte shuffle it is, on the COMMON CPU path (the portal and wlroots
// both commonly fixate packed 24-bit RGB, and pf-capture offers it first). swscale's
// packed-RGB expanders are SIMD, the sibling VAAPI backend already routes RGB24/BGR24
// through them, and this file already owned the context lifecycle — so the change is net
// subtractive. The three are mutually exclusive by construction: `expand` is only ever
// true on the packed-RGB 4:2:0 path (see `nvenc_pixel`/`expand` above), never with
// `want_444`, so one context serves whichever applies.
//
// Skipped on the zero-copy path (`cuda`): the worker's GPU convert already delivers ready
// CUDA frames — no CPU pixels exist to scale.
let sws_csc = if (want_444 || want_hdr10 || expand) && !cuda {
let src_av = pixel_to_av(sws_src_pixel(format)?);
let dst_av = pixel_to_av(nvenc_pixel);
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
// null-checked below.
let sws = unsafe {
ffi::sws_getContext(
width as c_int,
height as c_int,
src_av,
width as c_int,
height as c_int,
dst_av,
SWS_POINT,
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
)
};
if sws.is_null() {
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
}
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
// here would silently range-convert every packed-RGB session, which is exactly what the
// module header promises does not happen ("no colour math").
if want_444 || want_hdr10 {
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for
// HDR — matching the VUI written above) are process-lifetime libswscale statics,
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads them and writes
// scalar CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
unsafe {
let cs = ffi::sws_getCoefficients(if want_hdr10 {
super::libav::SWS_CS_BT2020
} else {
SWS_CS_ITU709
});
let dst_range = i32::from(full_range_444);
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
}
}
Some(sws)
} else {
None
};
let frame = if cuda { let frame = if cuda {
None None
} else { } else {
@@ -561,7 +591,6 @@ impl NvencEncoder {
sws_csc, sws_csc,
want_444, want_444,
src_format: format, src_format: format,
expand,
width, width,
height, height,
fps, fps,
@@ -591,6 +620,9 @@ impl NvencEncoder {
impl Encoder for NvencEncoder { impl Encoder for NvencEncoder {
fn caps(&self) -> super::EncoderCaps { fn caps(&self) -> super::EncoderCaps {
super::EncoderCaps { super::EncoderCaps {
// libav NVENC hands the frame straight to the encoder — `frame.cursor` is never read,
// so a cursor-as-metadata session loses its pointer on this backend (audit finding).
blends_cursor: false,
// 4:4:4 iff this session opened FREXT — the CPU swscale path or the zero-copy GPU // 4:4:4 iff this session opened FREXT — the CPU swscale path or the zero-copy GPU
// convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults). // convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults).
chroma_444: self.want_444, chroma_444: self.want_444,
@@ -695,8 +727,10 @@ impl NvencEncoder {
bytes.len(), bytes.len(),
src_row * h src_row * h
); );
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited), // swscale the packed RGB straight into the encoder's input frame, then send it. Serves all
// then send it — no byte-expand. The 4:2:0 RGB path (below) feeds NVENC packed RGB directly. // three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
// conversion at all — just a row copy honouring the destination stride.
if let Some(sws) = self.sws_csc { if let Some(sws) = self.sws_csc {
let frame = self let frame = self
.frame .frame
@@ -705,10 +739,13 @@ impl NvencEncoder {
// SAFETY: `format == self.src_format` and `bytes.len() >= src_row * h` (the `ensure!`s // SAFETY: `format == self.src_format` and `bytes.len() >= src_row * h` (the `ensure!`s
// above), so `sws_scale` reads `h` rows of `src_row` bytes from `src_data[0] = bytes` // above), so `sws_scale` reads `h` rows of `src_row` bytes from `src_data[0] = bytes`
// (packed RGB is single-plane; the other src planes are null/0) — all in bounds. `sws` is // (packed RGB is single-plane; the other src planes are null/0) — all in bounds. `sws` is
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`: its // the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`, whose
// `data`/`linesize` in-struct arrays were sized for YUV444P by `VideoFrame::new`, and the // `data`/`linesize` in-struct arrays were sized by `VideoFrame::new` for the very
// 3 planes are each `width`×`height`. All pointers are live locals for this synchronous // `nvenc_pixel` this context was built to output — 3 planes of `width`×`height` for
// call; the encoder runs only on this thread (`unsafe impl Send`), so no aliasing/race. // YUV444P, 2 for P010, 1 packed plane for `rgb0`/`bgr0` — so swscale writes exactly the
// planes it allocated, at the strides it reports. All pointers are live locals for this
// synchronous call; the encoder runs only on this thread (`unsafe impl Send`), so no
// aliasing/race.
unsafe { unsafe {
let dst_av = frame.as_mut_ptr(); let dst_av = frame.as_mut_ptr();
let src_data: [*const u8; 4] = let src_data: [*const u8; 4] =
@@ -724,7 +761,7 @@ impl NvencEncoder {
(*dst_av).linesize.as_ptr(), (*dst_av).linesize.as_ptr(),
); );
if r < 0 { if r < 0 {
bail!("sws_scale(RGB→YUV444P) failed ({r})"); bail!("sws_scale(CPU CSC → encoder input) failed ({r})");
} }
} }
frame.set_pts(Some(pts)); frame.set_pts(Some(pts));
@@ -733,7 +770,7 @@ impl NvencEncoder {
} else { } else {
ffmpeg::picture::Type::None ffmpeg::picture::Type::None
}); });
self.enc.send_frame(frame).context("send_frame(444)")?; self.enc.send_frame(frame).context("send_frame(swscale)")?;
return Ok(()); return Ok(());
} }
let frame = self let frame = self
@@ -742,18 +779,9 @@ impl NvencEncoder {
.context("CPU frame missing (encoder opened in CUDA mode)")?; .context("CPU frame missing (encoder opened in CUDA mode)")?;
let stride = frame.stride(0); // dst is 4-bpp, aligned let stride = frame.stride(0); // dst is 4-bpp, aligned
let dst = frame.data_mut(0); let dst = frame.data_mut(0);
if self.expand { {
// packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte) // 4-bpp → 4-bpp, honoring the (possibly larger) dst stride. The 3-bpp expand that used
for y in 0..h { // to live here as a per-pixel loop is now swscale's job (see the branch above).
let s = &bytes[y * src_row..y * src_row + src_row];
let drow = &mut dst[y * stride..y * stride + w * 4];
for x in 0..w {
drow[x * 4..x * 4 + 3].copy_from_slice(&s[x * 3..x * 3 + 3]);
drow[x * 4 + 3] = 0;
}
}
} else {
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride
for y in 0..h { for y in 0..h {
dst[y * stride..y * stride + src_row] dst[y * stride..y * stride + src_row]
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]); .copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
+159 -62
View File
@@ -61,8 +61,9 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe, apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices,
LowLatencyConfig, NvStatusExt, RFI_DPB, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt,
RFI_DPB,
}; };
use super::nvenc_status; use super::nvenc_status;
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -595,6 +596,12 @@ pub struct NvencCudaEncoder {
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next /// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async). /// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
want_async: bool, want_async: bool,
/// A de-escalation request ([`Encoder::set_pipelined(false)`]) waiting for its safe point:
/// the next drained moment tears the session down and lazily re-inits SYNC (IO-stream
/// binding and sub-frame chunking re-arm at that re-init). Distinct from `!want_async` —
/// an operator-forced async session (`PUNKTFUNK_NVENC_ASYNC=1`) also has `want_async`
/// false, and a de-escalation must never tear THAT down.
want_sync: bool,
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes /// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for /// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the /// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
@@ -702,6 +709,7 @@ impl NvencCudaEncoder {
last_rfi_range: None, last_rfi_range: None,
async_rt: None, async_rt: None,
want_async: false, want_async: false,
want_sync: false,
io_stream: ptr::null_mut(), io_stream: ptr::null_mut(),
stream_ordered: false, stream_ordered: false,
slices: 1, slices: 1,
@@ -735,6 +743,29 @@ impl NvencCudaEncoder {
} }
} }
/// [`maybe_engage_async`](Self::maybe_engage_async)'s inverse — wind the escalated pipelined
/// retrieve back at a safe point: nothing in flight, then a clean session rebuild whose lazy
/// SYNC re-init restores the IO-stream binding and re-arms sub-frame chunking (the two
/// latency features the escalation traded away). No-op until
/// [`want_sync`](Self::want_sync) is set and `pending` drains.
fn maybe_disengage_async(&mut self) {
if !self.want_sync || self.async_rt.is_none() || !self.pending.is_empty() {
return;
}
self.want_sync = false;
if self.inited {
// SAFETY: encode thread, `pending` empty ⇒ no encode in flight (and nothing queued
// to the retrieve thread); `teardown` joins the retrieve thread and handles exactly
// this live-session state — the next submit lazily re-inits sync.
unsafe { self.teardown() };
tracing::info!(
"NVENC pipelined-retrieve de-escalation: rebuilding the session with the sync \
retrieve (IO-stream binding and sub-frame chunking restored); next frame opens \
with an IDR"
);
}
}
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop. /// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
unsafe fn teardown(&mut self) { unsafe fn teardown(&mut self) {
if self.encoder.is_null() { if self.encoder.is_null() {
@@ -1000,6 +1031,23 @@ impl NvencCudaEncoder {
Ok(cfg) Ok(cfg)
} }
/// This session config's identity in the process-lifetime bitrate-ceiling cache
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the process-global shared
/// `CUcontext` pointer — one context per process, stable for its lifetime; only valid once
/// `cu_ctx` is bound (`init_session` start), which every caller is downstream of.
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
CeilingKey {
gpu: self.cu_ctx as u64,
codec: self.codec,
width: self.width,
height: self.height,
fps: self.fps,
bit_depth: self.bit_depth,
chroma_444: self.chroma_444,
split_mode,
}
}
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`. /// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
/// Returns the session handle, or destroys it and returns the error. /// Returns the session handle, or destroys it and returns the error.
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> { unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
@@ -1074,58 +1122,71 @@ impl NvencCudaEncoder {
} }
const FLOOR_BPS: u64 = 10_000_000; const FLOOR_BPS: u64 = 10_000_000;
let requested_bps = self.bitrate_bps; let requested_bps = self.bitrate_bps;
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override // 2-way NVENC split-frame encoding (Ada dual-NVENC) — shared selector, see
// PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only. // [`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 mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
{
Some("0") | Some("disable") => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
}
Some("1") | Some("auto") => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
}
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
_ if self.bit_depth >= 10 => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
}
_ if pixel_rate > 1_000_000_000 => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
}
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
};
const CLAMP_TOL_BPS: u64 = 20_000_000; const CLAMP_TOL_BPS: u64 = 20_000_000;
let mut probe = self.try_open_session(requested_bps, split_mode); // Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
// Disambiguate a forced-split rejection from a bitrate-cap rejection. // this config's max accepted rate — open straight AT the ceiling instead of paying
// the ~6-open binary search (and its session churn) on every ABR overshoot.
let mut target_bps = requested_bps;
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
if requested_bps > ceiling {
tracing::info!(
requested_mbps = requested_bps / 1_000_000,
ceiling_mbps = ceiling / 1_000_000,
"NVENC (Linux): requested bitrate above the cached codec-level ceiling — \
opening at the ceiling"
);
target_bps = ceiling;
}
}
let mut probe = self.try_open_session(target_bps, split_mode);
// 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.
if probe.is_err() && target_bps < requested_bps {
target_bps = requested_bps;
probe = self.try_open_session(requested_bps, split_mode);
}
// Disambiguate a forced-split rejection from a bitrate-cap rejection. `used_split`
// tracks the mode sessions ACTUALLY open with from here on — it feeds
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
let mut used_split = split_mode;
let split_on = let split_on =
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(requested_bps, no_split) { if let Ok(e) = self.try_open_session(target_bps, no_split) {
tracing::warn!( tracing::warn!(
"NVENC (Linux): split-encode rejected by codec/config — disabled" "NVENC (Linux): split-encode rejected by codec/config — disabled"
); );
split_mode = no_split; used_split = no_split;
probe = Ok(e); probe = Ok(e);
} }
} }
let enc = match probe { let enc = match probe {
Ok(enc) => { Ok(enc) => {
self.bitrate_bps = requested_bps; self.bitrate_bps = target_bps;
enc enc
} }
// Only a parameter/caps rejection means "the bitrate is above the codec-level
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
// version skew) must propagate — a search steered by it would discover, and
// cache, a bogus ceiling.
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
Err(_) => { Err(_) => {
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted. // Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
let mut lo = FLOOR_BPS; let mut lo = FLOOR_BPS;
let mut hi = requested_bps; let mut hi = target_bps;
let mut best: *mut c_void = ptr::null_mut(); let mut best: *mut c_void = ptr::null_mut();
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(mid, split_mode) { match self.try_open_session(mid, used_split) {
Ok(e) => { Ok(e) => {
if !best.is_null() { if !best.is_null() {
let _ = (api().destroy_encoder)(best); let _ = (api().destroy_encoder)(best);
@@ -1134,18 +1195,30 @@ impl NvencCudaEncoder {
best_bps = mid; best_bps = mid;
lo = mid; lo = mid;
} }
Err(_) => hi = mid, Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
Err(e) => {
// Environmental mid-search failure: don't let it shrink the
// search — release the partial result and propagate.
if !best.is_null() {
let _ = (api().destroy_encoder)(best);
}
return Err(e);
}
} }
} }
if best.is_null() { if best.is_null() {
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 = self best = match self.try_open_session(FLOOR_BPS, used_split) {
.try_open_session(FLOOR_BPS, split_mode) Ok(e) => e,
.or_else(|_| self.try_open_session(FLOOR_BPS, no_split)) Err(_) => {
.context( let e = self.try_open_session(FLOOR_BPS, no_split).context(
"NVENC initialize_encoder rejected even at the floor bitrate", "NVENC initialize_encoder rejected even at the floor bitrate",
)?; )?;
used_split = no_split;
e
}
};
best_bps = FLOOR_BPS; best_bps = FLOOR_BPS;
} }
tracing::warn!( tracing::warn!(
@@ -1153,15 +1226,13 @@ impl NvencCudaEncoder {
clamped_mbps = best_bps / 1_000_000, clamped_mbps = best_bps / 1_000_000,
"NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped" "NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped"
); );
store_ceiling(self.ceiling_key(used_split), best_bps);
self.bitrate_bps = best_bps; self.bitrate_bps = best_bps;
best best
} }
}; };
self.encoder = enc; self.encoder = enc;
// (Best effort: the floor fallback above may have succeeded split-disabled without self.split_mode = used_split;
// updating `split_mode` — a later reconfigure then presents the forced mode, NVENC
// rejects it, and the caller's rebuild fallback covers the mismatch.)
self.split_mode = split_mode;
// Output bitstream pool. // Output bitstream pool.
for _ in 0..POOL { for _ in 0..POOL {
@@ -1345,6 +1416,10 @@ impl NvencCudaEncoder {
mbps = self.bitrate_bps / 1_000_000, mbps = self.bitrate_bps / 1_000_000,
codec = ?self.codec_guid, codec = ?self.codec_guid,
fmt = ?self.buffer_fmt, fmt = ?self.buffer_fmt,
// The FINAL split mode (post any rejection fallback) at INFO — journals run
// INFO+, and "did 4K120 actually split across engines?" was undiagnosable from
// a user log without it (Windows only had a debug! at selection time).
split_mode = self.split_mode,
"NVENC CUDA session ready" "NVENC CUDA session ready"
); );
Ok(()) Ok(())
@@ -1426,9 +1501,10 @@ impl Encoder for NvencCudaEncoder {
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame" "Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
), ),
}; };
// A pending pipelined-retrieve escalation engages here, at the submit-side safe point // A pending pipelined-retrieve escalation — or de-escalation — engages here, at the
// (nothing in flight after the previous poll drained). // submit-side safe point (nothing in flight after the previous poll drained).
self.maybe_engage_async(); self.maybe_engage_async();
self.maybe_disengage_async();
// Re-init on a size change (the capturer can return at a different resolution after a mode // Re-init on a size change (the capturer can return at a different resolution after a mode
// switch). Format changes (NV12↔YUV444) likewise re-init. // switch). Format changes (NV12↔YUV444) likewise re-init.
let new_fmt = buffer_format(buf); let new_fmt = buffer_format(buf);
@@ -1560,25 +1636,6 @@ impl Encoder for NvencCudaEncoder {
} }
} else { } else {
self.cursor_blend_warned = false; self.cursor_blend_warned = false;
// TEMP KWin composite probe (DROP BEFORE MERGE): prove the blend dispatches
// and with what geometry.
{
use std::sync::atomic::{AtomicU64, Ordering as ProbeOrd};
static PROBE_BLEND: AtomicU64 = AtomicU64::new(0);
let n = PROBE_BLEND.fetch_add(1, ProbeOrd::Relaxed) + 1;
if n == 1 || n % 512 == 0 {
tracing::info!(
n,
fmt = ?self.buffer_fmt,
ov_x = ov.x,
ov_y = ov.y,
ov_w = ov.w,
ov_h = ov.h,
visible = ov.visible,
"cursor blend probe: vulkan dispatch submitted"
);
}
}
} }
} else if !self.cursor_blend_warned { } else if !self.cursor_blend_warned {
self.cursor_blend_warned = true; self.cursor_blend_warned = true;
@@ -1736,12 +1793,25 @@ impl Encoder for NvencCudaEncoder {
fn set_pipelined(&mut self, on: bool) -> bool { fn set_pipelined(&mut self, on: bool) -> bool {
if !on { if !on {
// v1 is escalate-and-hold (no de-escalation), mirroring the depth escalation. // De-escalation (the v2 of escalate-and-hold): latch the wind-back intent; the
// switch itself happens at the next drained safe point
// ([`maybe_disengage_async`](Self::maybe_disengage_async)) — the caller polls
// this same method until it reports inactive.
if async_retrieve_env() == Some(true) {
// Operator pinned async on — de-escalation must not undo an explicit choice.
return self.want_async || self.async_rt.is_some();
}
if self.want_async || self.async_rt.is_some() {
self.want_async = false;
self.want_sync = true;
self.maybe_disengage_async();
}
return self.want_async || self.async_rt.is_some(); return self.want_async || self.async_rt.is_some();
} }
if async_retrieve_env() == Some(false) { if async_retrieve_env() == Some(false) {
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
} }
self.want_sync = false; // latest intent wins — cancel a pending wind-back
if !self.want_async && self.async_rt.is_none() { if !self.want_async && self.async_rt.is_none() {
self.want_async = true; self.want_async = true;
self.maybe_engage_async(); self.maybe_engage_async();
@@ -1751,6 +1821,8 @@ impl Encoder for NvencCudaEncoder {
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
EncoderCaps { EncoderCaps {
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
blends_cursor: true,
supports_rfi: self.rfi_supported, supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr, supports_hdr_metadata: self.hdr,
chroma_444: self.chroma_444, chroma_444: self.chroma_444,
@@ -2061,6 +2133,15 @@ impl Encoder for NvencCudaEncoder {
self.bitrate_bps = bps; self.bitrate_bps = bps;
return true; return true;
} }
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
// [`Encoder::applied_bitrate_bps`].
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
Some(ceiling) => bps.min(ceiling),
None => bps,
};
// SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the // SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the
// encode thread with no NVENC call in flight (the session loop calls this between // encode thread with no NVENC call in flight (the session loop calls this between
// submit/poll). `build_config` only queries the preset on that session; `cfg` outlives // submit/poll). `build_config` only queries the preset on that session; `cfg` outlives
@@ -2108,6 +2189,12 @@ impl Encoder for NvencCudaEncoder {
} }
} }
fn applied_bitrate_bps(&self) -> Option<u64> {
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
Some(self.bitrate_bps)
}
fn flush(&mut self) -> Result<()> { fn flush(&mut self) -> Result<()> {
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain. Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
} }
@@ -2165,6 +2252,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) )
.expect("open NVENC CUDA session"); .expect("open NVENC CUDA session");
@@ -2241,6 +2329,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv444, ChromaFormat::Yuv444,
false,
) )
.expect("open NVENC CUDA 4:4:4 session"); .expect("open NVENC CUDA 4:4:4 session");
@@ -2286,6 +2375,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) )
.expect("open NVENC CUDA session"); .expect("open NVENC CUDA session");
@@ -2343,6 +2433,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) else { ) else {
eprintln!( eprintln!(
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)" "skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
@@ -2369,6 +2460,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) )
.expect("open NVENC CUDA encoder") .expect("open NVENC CUDA encoder")
} }
@@ -2403,6 +2495,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) )
.expect("open"); .expect("open");
for f in 0..4u32 { for f in 0..4u32 {
@@ -2540,6 +2633,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) )
.expect("open NVENC CUDA session"); .expect("open NVENC CUDA session");
let frame = nv12_frame(W, H, 0); let frame = nv12_frame(W, H, 0);
@@ -2581,6 +2675,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) )
.expect("open NVENC CUDA session"); .expect("open NVENC CUDA session");
// Steady sync frames first (stream-ordered mode). // Steady sync frames first (stream-ordered mode).
@@ -2660,6 +2755,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) )
.expect("open NVENC CUDA session"); .expect("open NVENC CUDA session");
@@ -2765,6 +2861,7 @@ mod tests {
true, true,
8, 8,
ChromaFormat::Yuv420, ChromaFormat::Yuv420,
false,
) )
.expect("open NVENC CUDA session"); .expect("open NVENC CUDA session");
+14 -5
View File
@@ -227,12 +227,19 @@ impl PyroWaveEncoder {
if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) { if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) {
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})"); bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
} }
if chroma.is_444() && !crate::pyrowave_mode_fits_rdo(width, height, true) { // Checked against the chroma actually being opened, NOT hardcoded 4:4:4. The 4:2:0 block
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one // count is ~half of 4:4:4's but still unbounded (8192×6144 4:2:0 = 73728 > u16::MAX), and
// slips through (e.g. the lab override) rather than wrap the RDO block index. // the negotiator's 4:4:4 → 4:2:0 downgrade hands oversized modes to this open AS 4:2:0 —
// so a `chroma.is_444()`-gated check is skipped exactly when it is needed. Wrapping the
// index lets the resolve over-credit and `packetize` overshoot our bitstream buffer
// (its own bounds `assert` is compiled out by the Release vendored build).
// `validate_dimensions` rejects the impossible-at-any-chroma modes earlier; this is the
// 4:4:4-specific half plus defence in depth for the lab override.
if !crate::pyrowave_mode_fits_rdo(width, height, chroma.is_444()) {
bail!( bail!(
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \ "pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
block index (see pyrowave-sys patches/0002 note) use 4:2:0 at this size" index (see pyrowave-sys patches/0002 note) lower the resolution",
if chroma.is_444() { "4:4:4" } else { "4:2:0" }
); );
} }
// SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it // SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it
@@ -1202,6 +1209,8 @@ impl Encoder for PyroWaveEncoder {
// hardcoded `default()` here mis-reports a 4:4:4 open as 4:2:0 and fires a spurious // hardcoded `default()` here mis-reports a 4:4:4 open as 4:2:0 and fires a spurious
// "chroma disagrees with the negotiated Welcome" warn. // "chroma disagrees with the negotiated Welcome" warn.
EncoderCaps { EncoderCaps {
// The wavelet CSC composites the metadata cursor.
blends_cursor: true,
chroma_444: self.chroma444, chroma_444: self.chroma444,
..EncoderCaps::default() ..EncoderCaps::default()
} }
+43 -23
View File
@@ -28,22 +28,18 @@ use ffmpeg::format::Pixel;
use ffmpeg::{codec, encoder, Dictionary}; use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg; use ffmpeg_next as ffmpeg;
use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat}; use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
use std::collections::HashMap;
use std::ffi::{CStr, CString}; use std::ffi::{CStr, CString};
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use std::os::raw::c_int; use std::os::raw::c_int;
use std::ptr; use std::ptr;
use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Mutex, OnceLock};
use super::libav::{ use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT, apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
}; };
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
/// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`).
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
}
/// The render node a VAAPI/DRM device should open, from [`pf_gpu::linux_render_node`]: a /// The render node a VAAPI/DRM device should open, from [`pf_gpu::linux_render_node`]: a
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU /// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
/// default. /// default.
@@ -71,21 +67,38 @@ fn vaapi_sws_src(format: PixelFormat) -> Result<Pixel> {
}) })
} }
/// Which VAAPI entrypoint mode opened successfully, cached per codec (index = [`lp_idx`]): /// Which VAAPI entrypoint mode opened successfully: 1 = default (full-feature `EncSlice`),
/// 0 = unknown, 1 = default (full-feature `EncSlice`), 2 = low-power (`EncSliceLP`/VDEnc). /// 2 = low-power (`EncSliceLP`/VDEnc). Modern Intel (Gen12+/Arc) removed the full-feature encode
/// Modern Intel (Gen12+/Arc) removed the full-feature encode entrypoints, so the default open /// entrypoints, so the default open fails there and only `low_power=1` works; AMD (radeonsi) is the
/// fails there and only `low_power=1` works; AMD (radeonsi) is the reverse. Caching the resolved /// reverse. Caching the resolved mode lets later sessions/probes skip the known-failing attempt
/// mode lets later sessions/probes skip the known-failing attempt (and its libav error spew). /// (and its libav error spew).
static LP_MODE: [AtomicU8; 3] = [AtomicU8::new(0), AtomicU8::new(0), AtomicU8::new(0)]; ///
/// Keyed on **(render node, codec, bit depth)**, and every part of that key is load-bearing:
///
/// * The render node, because the entrypoint is a property of the DEVICE libva opens — and
/// `render_node()` follows the web-console GPU preference. This used to be a process-global array
/// keyed by codec alone, which made it a session-killer rather than a staleness bug: once a mode
/// is latched the open tries exactly ONE mode and the `[false, true]` fallback is gone. Latch
/// low-power on an Intel Arc, switch the preference to an AMD dGPU, and every VAAPI open there
/// passes `low_power=1` — which radeonsi rejects — with no full-feature retry, for the process
/// lifetime: the probe reports all-false AND the session's own encoder open fails.
/// Keyed on the node rather than `pf_gpu::selection_key()` on purpose — the node is literally what
/// `render_node()` hands libva, so key and device cannot describe different GPUs.
/// * The bit depth, because Main10 and 8-bit can resolve to different entrypoints on the same
/// device; one shared slot let an 8-bit answer pin the 10-bit open (HDR under-advertisement).
static LP_MODE: OnceLock<Mutex<HashMap<LpKey, u8>>> = OnceLock::new();
fn lp_idx(codec: Codec) -> usize { /// (render-node path, codec label, 10-bit) — see [`LP_MODE`].
match codec { type LpKey = (String, &'static str, bool);
Codec::H264 => 0,
Codec::H265 => 1, /// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached
Codec::Av1 => 2, /// so a GPU-preference change is picked up on the next open.
// Guarded by the open_video dispatch: PyroWave never opens the VAAPI backend. fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
Codec::PyroWave => unreachable!("PyroWave has no VAAPI encoder"), (
} render_node().to_string_lossy().into_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
@@ -116,11 +129,16 @@ unsafe fn open_vaapi_encoder(
frames_ref: *mut ffi::AVBufferRef, frames_ref: *mut ffi::AVBufferRef,
ten_bit: bool, ten_bit: bool,
) -> Result<encoder::video::Encoder> { ) -> Result<encoder::video::Encoder> {
let idx = lp_idx(codec); let key = lp_key(codec, ten_bit);
let cached = LP_MODE
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map(|m| m.get(&key).copied().unwrap_or(0))
.unwrap_or(0);
let modes: &[bool] = match low_power_override() { let modes: &[bool] = match low_power_override() {
Some(true) => &[true], Some(true) => &[true],
Some(false) => &[false], Some(false) => &[false],
None => match LP_MODE[idx].load(Ordering::Relaxed) { None => match cached {
1 => &[false], 1 => &[false],
2 => &[true], 2 => &[true],
_ => &[false, true], _ => &[false, true],
@@ -140,7 +158,9 @@ unsafe fn open_vaapi_encoder(
lp, lp,
) { ) {
Ok(enc) => { Ok(enc) => {
LP_MODE[idx].store(if lp { 2 } else { 1 }, Ordering::Relaxed); if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
m.insert(key.clone(), if lp { 2 } else { 1 });
}
if lp { if lp {
tracing::info!( tracing::info!(
encoder = codec.vaapi_name(), encoder = codec.vaapi_name(),
+101 -29
View File
@@ -221,6 +221,92 @@ impl NativeProfileStack {
/// The Vulkan codec-operation bit for our codec selection (shared by open and the per-import /// The Vulkan codec-operation bit for our codec selection (shared by open and the per-import
/// profile rebuilds — the two must agree, profile identity is by value). /// profile rebuilds — the two must agree, profile identity is by value).
/// The physical device + encode queue family a session runs on: the FIRST device exposing a
/// `VIDEO_ENCODE` queue family that advertises `codec_op` (llvmpipe advertises none, so it drops
/// out implicitly). Shared by [`VulkanVideoEncoder::open_inner`] and [`probe_encode_support`].
///
/// # Safety
/// `instance` must be a live `ash::Instance` and `devices` handles enumerated from it.
unsafe fn find_encode_device(
instance: &ash::Instance,
devices: &[vk::PhysicalDevice],
codec_op: vk::VideoCodecOperationFlagsKHR,
) -> Option<(vk::PhysicalDevice, u32)> {
for &pd in devices {
let qf_len = instance.get_physical_device_queue_family_properties2_len(pd);
let mut video = vec![vk::QueueFamilyVideoPropertiesKHR::default(); qf_len];
let mut qf = vec![vk::QueueFamilyProperties2::default(); qf_len];
for i in 0..qf_len {
qf[i].p_next = &mut video[i] as *mut _ as *mut c_void;
}
instance.get_physical_device_queue_family_properties2(pd, &mut qf);
for i in 0..qf_len {
if qf[i]
.queue_family_properties
.queue_flags
.contains(vk::QueueFlags::VIDEO_ENCODE_KHR)
&& video[i].video_codec_operations.contains(codec_op)
{
return Some((pd, i as u32));
}
}
}
None
}
/// Can this GPU + driver open a Vulkan Video **encode** session for `codec` at all?
///
/// This is the verdict the native-NV12 capture negotiation needs BEFORE it commits
/// ([`crate::linux_native_nv12_ok`]): once the producer hands over two-plane NV12 there is no VAAPI
/// fallback — libav would import it as packed RGB — so [`crate::open_video`] deliberately makes
/// that open-failure fatal, and a Mesa built without `VK_KHR_video_encode_h265` therefore kills the
/// session at its first frame instead of streaming on VAAPI.
///
/// Deliberately the FIRST check `open_inner` performs and hard-fails on, and nothing more: the same
/// [`find_encode_device`] scan, run against the same codec op. That makes it provably no stricter
/// than the open, so a failure here can only ever name a session that would have died anyway — it
/// can never talk a working host out of the fast path. It is also cheap: one instance plus
/// physical-device queries, no logical device, no video session, no VRAM. The later stages
/// (`create_device`, `create_video_session`, the capability query) can still fail for reasons this
/// does not model; those keep the session on the packed-RGB negotiation the ordinary way, because
/// the capture format is only committed once this said yes.
///
/// Probed PER CODEC, not once: `codec_op_for` selects a different queue-family bit for AV1, and
/// HEVC-encode-without-AV1-encode is the common VCN/ANV configuration.
pub(crate) fn probe_encode_support(codec: Codec) -> Result<(), &'static str> {
if !matches!(codec, Codec::H265 | Codec::Av1) {
return Err("the Vulkan Video backend encodes HEVC + AV1 only");
}
let codec_op = codec_op_for(codec == Codec::Av1);
// SAFETY: creates one Vulkan instance and issues only physical-device queries against it, then
// destroys it on EVERY path below before returning — no handle derived from it escapes, and
// nothing outside this call observes it. `Entry::load` only dlopens the loader (a missing
// libvulkan returns `Err`), touching no process state the rest of the crate relies on.
// `find_encode_device` gets a live instance and handles enumerated from it, as it requires.
unsafe {
let Ok(entry) = ash::Entry::load() else {
return Err("no Vulkan loader");
};
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_3);
let Ok(instance) = entry.create_instance(
&vk::InstanceCreateInfo::default().application_info(&app),
None,
) else {
return Err("vkCreateInstance failed");
};
let found = match instance.enumerate_physical_devices() {
Ok(devices) => find_encode_device(&instance, &devices, codec_op).is_some(),
Err(_) => false,
};
instance.destroy_instance(None);
if found {
Ok(())
} else {
Err("no VK_KHR_video_encode queue for this codec on any device")
}
}
}
fn codec_op_for(av1: bool) -> vk::VideoCodecOperationFlagsKHR { fn codec_op_for(av1: bool) -> vk::VideoCodecOperationFlagsKHR {
if av1 { if av1 {
vk::VideoCodecOperationFlagsKHR::from_raw( vk::VideoCodecOperationFlagsKHR::from_raw(
@@ -524,7 +610,9 @@ impl VulkanVideoEncoder {
/// `open` with the RGB-direct request explicit instead of read from the env — the smoke /// `open` with the RGB-direct request explicit instead of read from the env — the smoke
/// tests use this (env mutation races parallel tests). `want_rgb` engages the RGB-direct /// tests use this (env mutation races parallel tests). `want_rgb` engages the RGB-direct
/// source only if [`probe_rgb_direct`] also passes; otherwise the session opens on the CSC /// source only if [`probe_rgb_direct`] also passes; otherwise the session opens on the CSC
/// path with the verdict logged. /// path with the verdict logged. Test-only: the production entry point is [`Self::open`],
/// which resolves `want_rgb` from the env + the `cursor_blend` hint.
#[cfg(test)]
pub(crate) fn open_opts( pub(crate) fn open_opts(
codec: Codec, codec: Codec,
width: u32, width: u32,
@@ -602,34 +690,13 @@ impl VulkanVideoEncoder {
let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance); let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance);
// pick the physical device + encode queue family (skip llvmpipe) // pick the physical device + encode queue family (skip llvmpipe). The scan itself lives in
let (pd, encode_family) = { // [`find_encode_device`] so the negotiation-time probe asks the SAME question this open
let mut found = None; // asks — a probe that MIRRORS a dispatch goes stale the first time the dispatch grows a
for pd in instance.enumerate_physical_devices()? { // case, which is the failure `open_video`'s backend-label note already records.
let qf_len = instance.get_physical_device_queue_family_properties2_len(pd); let (pd, encode_family) =
let mut video = vec![vk::QueueFamilyVideoPropertiesKHR::default(); qf_len]; find_encode_device(&instance, &instance.enumerate_physical_devices()?, codec_op)
let mut qf = vec![vk::QueueFamilyProperties2::default(); qf_len]; .context("no VK_KHR_video_encode queue for the requested codec on any device")?;
for i in 0..qf_len {
qf[i].p_next = &mut video[i] as *mut _ as *mut c_void;
}
instance.get_physical_device_queue_family_properties2(pd, &mut qf);
for i in 0..qf_len {
if qf[i]
.queue_family_properties
.queue_flags
.contains(vk::QueueFlags::VIDEO_ENCODE_KHR)
&& video[i].video_codec_operations.contains(codec_op)
{
found = Some((pd, i as u32));
break;
}
}
if found.is_some() {
break;
}
}
found.context("no VK_KHR_video_encode queue for the requested codec on any device")?
};
let mem_props = instance.get_physical_device_memory_properties(pd); let mem_props = instance.get_physical_device_memory_properties(pd);
// a compute queue family for the CSC (usually family 0) + its timestamp support (the // a compute queue family for the CSC (usually family 0) + its timestamp support (the
@@ -3241,6 +3308,11 @@ impl Encoder for VulkanVideoEncoder {
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
EncoderCaps { EncoderCaps {
supports_rfi: true, supports_rfi: true,
// Only the CSC path composites the metadata cursor (`prep_cursor` feeds the compute
// shader). The RGB-direct/EFC front-end and the native-NV12 source have no compositing
// stage at all — both merely warn once that the pointer is being dropped — so this is
// the encoder's real answer, not a static one.
blends_cursor: self.rgb.is_none() && !self.native_nv12,
..Default::default() ..Default::default()
} }
} }
+149
View File
@@ -67,6 +67,155 @@ pub(super) fn resolve_subframe(default_on: bool) -> bool {
} }
} }
/// Resolved NVENC split-frame encode mode for a session — ONE selector shared by the Windows and
/// Linux direct-SDK backends (they had drifted into byte-identical duplicates, one of which
/// logged and one didn't). Precedence:
/// 1. `PUNKTFUNK_SPLIT_ENCODE` = `0`/`disable` | `1`/`auto` (AUTO_FORCED) | `2` | `3` — operator
/// override, always wins.
/// 2. 10-bit → DISABLE: 2-way split is measurably SLOWER on Ada for Main10 — at 5120×1440@240
/// forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine (the split/merge
/// overhead dominates), and a single engine handles 5K@240 Main10 well under budget. This was
/// the "broken animations in HDR" cap at ~131 fps.
/// 3. Pixel rate ≥ [`super::SPLIT_FORCE_PIXEL_RATE`] → force 2-way (AUTO never engages below
/// ~2112 px height, so 4K120 must be forced onto the second engine).
/// 4. Else AUTO (the ~2% BD-rate split cost isn't worth it at low pixel rates).
///
/// The caller still owns the rejection fallback (retry split-disabled) — a codec/config that
/// rejects the chosen mode downgrades at open, not here.
pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 {
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
let mode = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() {
Some("0") | Some("disable") => M::NV_ENC_SPLIT_DISABLE_MODE as u32,
Some("1") | Some("auto") => M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32,
Some("3") => M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
Some("2") => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
_ if bit_depth >= 10 => M::NV_ENC_SPLIT_DISABLE_MODE as u32,
_ if pixel_rate >= super::SPLIT_FORCE_PIXEL_RATE => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
_ => M::NV_ENC_SPLIT_AUTO_MODE as u32,
};
tracing::debug!(
split_mode = mode,
bit_depth,
pixel_rate,
"NVENC split-encode mode selected"
);
mode
}
/// One session config's identity for the process-lifetime bitrate-ceiling cache
/// ([`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
/// rate selects the level), depth/chroma (they select the profile) and the split mode the
/// sessions ACTUALLY opened with (a split session budgets per engine).
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(super) struct CeilingKey {
/// GPU identity — Linux: the process-global shared `CUcontext` pointer; Windows: the render
/// adapter LUID (0 when unresolved). Best effort: the cache is advisory (see
/// [`cached_ceiling`]), so a colliding identity costs one failed open + re-search, never a
/// wrong session.
pub gpu: u64,
pub codec: Codec,
pub width: u32,
pub height: u32,
pub fps: u32,
pub bit_depth: u8,
pub chroma_444: bool,
pub split_mode: u32,
}
fn ceilings() -> &'static std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>> {
static CEILINGS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>>,
> = std::sync::OnceLock::new();
CEILINGS.get_or_init(Default::default)
}
/// The codec-level bitrate ceiling (bps) a previous clamp search discovered for `key` this
/// process lifetime, if any. ADVISORY: the consumer must treat a failed open at the cached value
/// as a stale entry (fall back to the full search, which rewrites it via [`store_ceiling`]) —
/// that self-healing is what lets the key's GPU identity be best-effort. What this buys: an ABR
/// overshoot on a config whose ceiling is already known opens (or in-place reconfigures) straight
/// AT the ceiling instead of re-running the ~6-open binary search and its ~half-second of session
/// churn per rebuild.
pub(super) fn cached_ceiling(key: &CeilingKey) -> Option<u64> {
ceilings().lock().unwrap().get(key).copied()
}
/// Record the clamp search's discovered max accepted bitrate (bps) for `key`.
pub(super) fn store_ceiling(key: CeilingKey, bps: u64) {
ceilings().lock().unwrap().insert(key, bps);
}
#[cfg(test)]
mod tests {
use super::*;
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
// These assume PUNKTFUNK_SPLIT_ENCODE is unset (CI); an operator override deliberately wins.
#[test]
fn split_forces_two_way_at_4k120() {
// The regression this threshold constant exists for: 3840×2160×120 = 995,328,000 sat
// 0.47% under the old `> 1_000_000_000` gate and stayed AUTO — pinned ~107 fps on a
// 4090 because AUTO never engages at 2160 px height.
let four_k_120 = 3840u64 * 2160 * 120;
assert_eq!(
resolve_split_mode(8, four_k_120),
M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
);
}
#[test]
fn split_leaves_1440p240_auto() {
// 884.7 Mpix/s is comfortably single-engine — the threshold move must not drag it in.
let qhd_240 = 2560u64 * 1440 * 240;
assert_eq!(
resolve_split_mode(8, qhd_240),
M::NV_ENC_SPLIT_AUTO_MODE as u32
);
}
#[test]
fn split_disabled_for_10bit_even_at_high_pixel_rate() {
// The measured Main10 rule: split/merge overhead dominates 10-bit on Ada (7.6 ms forced-2
// vs 2.8 ms single-engine at 5K240) — 10-bit precedes the pixel-rate arm.
let five_k_240 = 5120u64 * 1440 * 240;
assert_eq!(
resolve_split_mode(10, five_k_240),
M::NV_ENC_SPLIT_DISABLE_MODE as u32
);
}
#[test]
fn ceiling_cache_round_trips_and_keys_precisely() {
let key = CeilingKey {
gpu: 0xB0B0,
codec: Codec::H265,
width: 3840,
height: 2160,
fps: 120,
bit_depth: 8,
chroma_444: false,
split_mode: M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
};
assert_eq!(cached_ceiling(&key), None);
store_ceiling(key, 794_000_000);
assert_eq!(cached_ceiling(&key), Some(794_000_000));
// Any config-identity change is a different ceiling — a miss, never a wrong clamp.
assert_eq!(cached_ceiling(&CeilingKey { fps: 60, ..key }), None);
assert_eq!(
cached_ceiling(&CeilingKey {
split_mode: M::NV_ENC_SPLIT_DISABLE_MODE as u32,
..key
}),
None
);
// A re-search overwrites (the advisory-cache stale-entry path).
store_ceiling(key, 620_000_000);
assert_eq!(cached_ceiling(&key), Some(620_000_000));
}
}
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated /// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each /// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames` /// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
+35 -3
View File
@@ -72,9 +72,41 @@ pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
} }
} }
/// Typed root of a failed NVENC entry-point call: carries the raw status so callers can classify
/// the failure class, not just print it — the bitrate-clamp search must only read a
/// parameter/caps rejection as "above the codec-level ceiling"; a transient failure shrinking the
/// search would discover (and cache) a bogus ceiling. Recover it through an `anyhow` chain with
/// `err.downcast_ref::<NvCallError>()` (see [`is_param_rejection`]).
#[derive(Debug)]
pub(super) struct NvCallError(pub(super) nv::NVENCSTATUS);
impl std::fmt::Display for NvCallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?} — {}", self.0, explain(self.0))
}
}
impl std::error::Error for NvCallError {}
/// Whether `err` is an NVENC parameter/capability rejection: the driver understood the request
/// and says THIS config is not encodable — the clamp search's "bitrate above the ceiling"
/// evidence. Everything else (busy engine, session limit, OOM, device loss, version skew) is
/// environmental and must propagate instead of steering the search.
pub(super) fn is_param_rejection(err: &anyhow::Error) -> bool {
matches!(
err.downcast_ref::<NvCallError>(),
Some(NvCallError(
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM
| nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_PARAM
| nv::NVENCSTATUS::NV_ENC_ERR_UNIMPLEMENTED,
))
)
}
/// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API /// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world /// (e.g. `"open_encode_session_ex"`); the chain carries both the raw status and its real-world
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)". /// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)". The
/// [`NvCallError`] root keeps the status downcastable for failure-class checks.
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error { pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status)) anyhow::Error::new(NvCallError(status)).context(format!("NVENC {call} failed"))
} }
+11
View File
@@ -246,6 +246,17 @@ mod tests {
assert!(block_count_32x32(3840, 2160, true) <= u16::MAX as u32); assert!(block_count_32x32(3840, 2160, true) <= u16::MAX as u32);
assert!(block_count_32x32(7680, 4320, true) > u16::MAX as u32); assert!(block_count_32x32(7680, 4320, true) > u16::MAX as u32);
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32); assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
// …and 4:2:0 wraps it too, just later — the hole the old 4:4:4-only open guard left.
// `Codec::max_dimension()` allows PyroWave 8192px per axis, so these modes were
// reachable from a client-requested `mode=WxHxFPS`, and the negotiator's 4:4:4 → 4:2:0
// downgrade routed oversized modes straight into the unguarded branch.
// `validate_dimensions` now rejects them against this 4:2:0 count.
assert_eq!(block_count_32x32(8192, 6144, false), 73728);
assert_eq!(block_count_32x32(8192, 8192, false), 98304);
assert!(block_count_32x32(8192, 6144, false) > u16::MAX as u32);
assert!(block_count_32x32(8192, 8192, false) > u16::MAX as u32);
// The largest 4:2:0 mode that still fits, for the boundary the validator enforces.
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
} }
#[test] #[test]
+13 -1
View File
@@ -1272,7 +1272,11 @@ impl AmfEncoder {
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) { if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
bail!("this GPU/driver declined AV1 encode (RDNA3+ required) — native AMF probe"); bail!("this GPU/driver declined AV1 encode (RDNA3+ required) — native AMF probe");
} }
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2); // Depth follows the delivered pixels, not the negotiated depth ([`crate::ten_bit_input`]).
// With the old `bit_depth >= 10 || …` shape a 10-bit-negotiated session over an 8-bit
// capture derived `expected = P010`, tripped the format check below and ended the session
// at open — on exactly the configuration the capturer had already downgraded on purpose.
let ten_bit = crate::ten_bit_input(format, bit_depth);
// Zero-copy by construction: the input ring is NV12/P010 fed by same-format // Zero-copy by construction: the input ring is NV12/P010 fed by same-format
// CopySubresourceRegion. Any other capture format (Bgra/Rgb10a2 video-processor fallback, // CopySubresourceRegion. Any other capture format (Bgra/Rgb10a2 video-processor fallback,
// CPU frames) has no native input path — and since Phase 3 no ffmpeg readback to degrade // CPU frames) has no native input path — and since Phase 3 no ffmpeg readback to degrade
@@ -2422,6 +2426,8 @@ impl Encoder for AmfEncoder {
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
EncoderCaps { EncoderCaps {
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
blends_cursor: false,
// LTR-RFI: AMD's reference invalidation is the user long-term-reference path (mark a // LTR-RFI: AMD's reference invalidation is the user long-term-reference path (mark a
// 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.
@@ -2760,6 +2766,10 @@ mod tests {
} }
/// The `p`-quantile of `samples` (µs), sorting in place. `0` when empty. /// The `p`-quantile of `samples` (µs), sorting in place. `0` when empty.
/// Gated like its only caller, the `amf-qsv`-only §5.2 latency A/B below — otherwise a
/// `--features nvenc,qsv` build compiles this helper with the benchmark cfg'd out and trips
/// `dead_code` (which the crate root no longer blanket-allows).
#[cfg(feature = "amf-qsv")]
fn percentile(samples: &mut [u128], p: f64) -> u128 { fn percentile(samples: &mut [u128], p: f64) -> u128 {
if samples.is_empty() { if samples.is_empty() {
return 0; return 0;
@@ -2778,6 +2788,8 @@ mod tests {
/// so its submit→AU is the bare ASIC time. The last ~2 unflushed frames on the ffmpeg path /// so its submit→AU is the bare ASIC time. The last ~2 unflushed frames on the ffmpeg path
/// are left unmeasured (dropped with the encoder) so every recorded sample is a genuine paced /// are left unmeasured (dropped with the encoder) so every recorded sample is a genuine paced
/// submit→AU. /// submit→AU.
/// Gated like its only caller (see [`percentile`]).
#[cfg(feature = "amf-qsv")]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn drive_and_measure( fn drive_and_measure(
enc: &mut dyn Encoder, enc: &mut dyn Encoder,
+26 -5
View File
@@ -95,6 +95,13 @@ struct AVD3D11VAFramesContext {
/// AMD AMF vs Intel QSV — the two libavcodec vendor backends this module covers. /// AMD AMF vs Intel QSV — the two libavcodec vendor backends this module covers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WinVendor { pub enum WinVendor {
/// Benchmark-only, as the module header explains: native AMF replaced the libavcodec AMF path
/// in production, and the only remaining CONSTRUCTOR is the `#[cfg(feature = "amf-qsv")]`
/// latency A/B in `amf.rs` — the measurement that justifies the native backend existing. That
/// is test code, so the *lib* target constructs it nowhere and `dead_code` fires on it (the
/// crate root no longer blanket-allows that). Kept deliberately rather than deleted; the arms
/// below are what the benchmark drives.
#[allow(dead_code)]
Amf, Amf,
Qsv, Qsv,
} }
@@ -151,8 +158,14 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
} }
/// Does this captured format imply a 10-bit encode (P010 / Rgb10a2)? /// Does this captured format imply a 10-bit encode (P010 / Rgb10a2)?
fn is_10bit_format(format: PixelFormat, bit_depth: u8) -> bool { ///
bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2) /// Depth follows the PIXELS, not the negotiated `bit_depth` — see
/// [`crate::ten_bit_input`] for why, and for the failure this shape used to produce here in
/// particular: a 10-bit-negotiated session over an 8-bit capture built a P010 encoder whose every
/// `submit_d3d11` then failed the depth check below, forever, with `reset()` unable to help
/// because the rebuild re-derived the same wrong answer.
fn is_10bit_format(format: PixelFormat) -> bool {
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
} }
/// 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,
@@ -271,6 +284,11 @@ pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
false false
} }
/// Gated to the builds that can actually reach it: `lib.rs`'s only caller sits under
/// `cfg(all(not(feature = "qsv"), feature = "amf-qsv"))`, because with the native VPL backend
/// compiled in it is `qsv::probe_can_encode` that answers. So in the SHIPPED Windows combo
/// (`nvenc,amf-qsv,qsv`) this function has no caller at all.
#[cfg(not(feature = "qsv"))]
pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool { pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
// Deliberately NOT pinned to the selected render adapter (unlike `nvenc::probe_can_encode_444`): // Deliberately NOT pinned to the selected render adapter (unlike `nvenc::probe_can_encode_444`):
// the system-input probe passes no hwdevice, and the AMF/QSV runtimes only ever bind their own // the system-input probe passes no hwdevice, and the AMF/QSV runtimes only ever bind their own
@@ -350,7 +368,7 @@ impl SystemInner {
bitrate_bps: u64, bitrate_bps: u64,
bit_depth: u8, bit_depth: u8,
) -> Result<Self> { ) -> Result<Self> {
let ten_bit = is_10bit_format(format, bit_depth); let ten_bit = crate::ten_bit_input(format, bit_depth);
let sw_av = if ten_bit { let sw_av = if ten_bit {
ffi::AVPixelFormat::AV_PIX_FMT_P010LE ffi::AVPixelFormat::AV_PIX_FMT_P010LE
} else { } else {
@@ -472,7 +490,10 @@ impl SystemInner {
pts: i64, pts: i64,
idr: bool, idr: bool,
) -> Result<()> { ) -> Result<()> {
let fmt_10 = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2); // Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that
// merely negotiated 10-bit over an 8-bit capture.
let fmt_10 = is_10bit_format(format);
anyhow::ensure!( anyhow::ensure!(
fmt_10 == self.ten_bit, fmt_10 == self.ten_bit,
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)", "captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
@@ -851,7 +872,7 @@ impl ZeroCopyInner {
bit_depth: u8, bit_depth: u8,
device: &ID3D11Device, device: &ID3D11Device,
) -> Result<Self> { ) -> Result<Self> {
let ten_bit = is_10bit_format(format, bit_depth); let ten_bit = crate::ten_bit_input(format, bit_depth);
let sw_av = if ten_bit { let sw_av = if ten_bit {
ffi::AVPixelFormat::AV_PIX_FMT_P010LE ffi::AVPixelFormat::AV_PIX_FMT_P010LE
} else { } else {
+125 -60
View File
@@ -37,8 +37,9 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe, apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices,
LowLatencyConfig, NvStatusExt, RFI_DPB, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt,
RFI_DPB,
}; };
use super::nvenc_status; use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -754,6 +755,27 @@ impl NvencD3d11Encoder {
Ok(cfg) Ok(cfg)
} }
/// This session config's identity in the process-lifetime bitrate-ceiling cache
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the selected render
/// adapter's LUID — the adapter the capturer's device (and so this session) lives on; `0`
/// when unresolved. Best effort by design: the cache is advisory, a colliding identity costs
/// one failed open + re-search, never a wrong session.
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
let gpu = pf_gpu::resolve_render_adapter_luid()
.map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64)
.unwrap_or(0);
CeilingKey {
gpu,
codec: self.codec,
width: self.width,
height: self.height,
fps: self.fps,
bit_depth: self.bit_depth,
chroma_444: self.chroma_444,
split_mode,
}
}
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns /// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed /// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe. /// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
@@ -834,43 +856,14 @@ impl NvencD3d11Encoder {
// gets the highest the GPU can actually do, not a coarse fraction of it. // gets the highest the GPU can actually do, not a coarse fraction of it.
const FLOOR_BPS: u64 = 10_000_000; const FLOOR_BPS: u64 = 10_000_000;
let requested_bps = self.bitrate_bps; let requested_bps = self.bitrate_bps;
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever // 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever.
// the Linux host enables via libavcodec `split_encode_mode`. A single Ada NVENC session tops // A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240
// out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and // (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across
// the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way // both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the
// above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth // precedence (env override / the measured Main10 don't-split rule / pixel rate).
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. // The init-failure fallback below disables it if a codec/config rejects it.
// HEVC/AV1 only; 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 mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
{
Some("0") | Some("disable") => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
}
Some("1") | Some("auto") => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
}
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
// Main10 (10-bit / HDR): 2-way split is measurably SLOWER on Ada — at 5120x1440@240
// Main10, forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine
// (the split/merge overhead dominates for 10-bit). A single Ada NVENC engine already
// handles 5K@240 Main10 well under the 4.17 ms budget, so DON'T split — splitting was
// the "broken animations in HDR" (the stream capped at ~131 fps). Env still overrides.
_ if self.bit_depth >= 10 => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
}
_ if pixel_rate > 1_000_000_000 => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
}
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
};
tracing::debug!(
split_mode,
bit_depth = self.bit_depth,
pixel_rate,
"NVENC split-encode mode selected"
);
// 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
@@ -884,39 +877,69 @@ impl NvencD3d11Encoder {
// built in the right mode from the start. // built in the right mode from the start.
let use_async = self.async_supported && async_retrieve_requested(); let use_async = self.async_supported && async_retrieve_requested();
let mut probe = self.try_open_session(device, requested_bps, split_mode, use_async); // Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
// this config's max accepted rate — open straight AT the ceiling instead of paying
// the ~6-open binary search (and its session churn) on every ABR overshoot.
let mut target_bps = requested_bps;
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
if requested_bps > ceiling {
tracing::info!(
requested_mbps = requested_bps / 1_000_000,
ceiling_mbps = ceiling / 1_000_000,
"NVENC: requested bitrate above the cached codec-level ceiling — opening \
at the ceiling"
);
target_bps = ceiling;
}
}
let mut probe = self.try_open_session(device, target_bps, split_mode, use_async);
// 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.
if probe.is_err() && target_bps < requested_bps {
target_bps = requested_bps;
probe = self.try_open_session(device, requested_bps, split_mode, use_async);
}
// 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.
// ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole // ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole
// init with INVALID_PARAM on drivers/configs where auto split isn't valid for it, // init with INVALID_PARAM on drivers/configs where auto split isn't valid for it,
// which then masqueraded as a bitrate cap and failed "even at the floor". // which then masqueraded as a bitrate cap and failed "even at the floor".
// `used_split` tracks the mode sessions ACTUALLY open with from here on — it feeds
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
let mut used_split = split_mode;
let split_on = let split_on =
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, requested_bps, no_split, use_async) { if let Ok(e) = self.try_open_session(device, target_bps, no_split, use_async) {
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled"); tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
split_mode = no_split; used_split = no_split;
probe = Ok(e); probe = Ok(e);
} }
} }
let enc = match probe { let enc = match probe {
Ok(enc) => { Ok(enc) => {
self.bitrate_bps = requested_bps; self.bitrate_bps = target_bps;
enc enc
} }
// Only a parameter/caps rejection means "the bitrate is above the codec-level
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
// version skew) must propagate — a search steered by it would discover, and
// cache, a bogus ceiling.
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
Err(_) => { Err(_) => {
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted. // Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
// `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest // `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest
// rejected; `best` holds the live session at `lo` so we end up with the clamped one. // rejected; `best` holds the live session at `lo` so we end up with the clamped one.
let mut lo = FLOOR_BPS; let mut lo = FLOOR_BPS;
let mut hi = requested_bps; let mut hi = target_bps;
let mut best: *mut c_void = ptr::null_mut(); let mut best: *mut c_void = ptr::null_mut();
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, split_mode, use_async) { match self.try_open_session(device, mid, used_split, use_async) {
Ok(e) => { Ok(e) => {
if !best.is_null() { if !best.is_null() {
let _ = (api().destroy_encoder)(best); let _ = (api().destroy_encoder)(best);
@@ -925,7 +948,15 @@ impl NvencD3d11Encoder {
best_bps = mid; best_bps = mid;
lo = mid; lo = mid;
} }
Err(_) => hi = mid, Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
Err(e) => {
// Environmental mid-search failure: don't let it shrink the
// search — release the partial result and propagate.
if !best.is_null() {
let _ = (api().destroy_encoder)(best);
}
return Err(e);
}
} }
} }
if best.is_null() { if best.is_null() {
@@ -933,14 +964,19 @@ 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 = self best = match self.try_open_session(device, FLOOR_BPS, used_split, use_async)
.try_open_session(device, FLOOR_BPS, split_mode, use_async) {
.or_else(|_| { Ok(e) => e,
self.try_open_session(device, FLOOR_BPS, no_split, use_async) Err(_) => {
}) let e = self
.context( .try_open_session(device, FLOOR_BPS, no_split, use_async)
"NVENC initialize_encoder rejected even at the floor bitrate", .context(
)?; "NVENC initialize_encoder rejected even at the floor bitrate",
)?;
used_split = no_split;
e
}
};
best_bps = FLOOR_BPS; best_bps = FLOOR_BPS;
} }
tracing::warn!( tracing::warn!(
@@ -948,21 +984,19 @@ impl NvencD3d11Encoder {
clamped_mbps = best_bps / 1_000_000, clamped_mbps = best_bps / 1_000_000,
"NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted" "NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted"
); );
store_ceiling(self.ceiling_key(used_split), best_bps);
self.bitrate_bps = best_bps; self.bitrate_bps = best_bps;
best best
} }
}; };
self.encoder = enc; self.encoder = enc;
// Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best // Session init params a later `reconfigure_bitrate` must re-present verbatim.
// effort: the floor fallback above may have succeeded split-disabled without updating self.split_mode = used_split;
// `split_mode` — a reconfigure then presents the forced mode, NVENC rejects it, and
// the caller's rebuild fallback covers the mismatch.)
self.split_mode = split_mode;
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
// mode (a split session occupies one hardware session per engine). // mode (a split session occupies one hardware session per engine).
self.session_units = split_mode_units(split_mode); self.session_units = split_mode_units(used_split);
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed); LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
// (The clamp path above already logs the requested→clamped bitrate at warn; no second // (The clamp path above already logs the requested→clamped bitrate at warn; no second
// info line for the same event here.) // info line for the same event here.)
@@ -1181,9 +1215,23 @@ impl Encoder for NvencD3d11Encoder {
// despite this comment previously claiming otherwise. Since this backend encodes the // despite this comment previously claiming otherwise. Since this backend encodes the
// capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it // capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
// rotate a texture out from under a live encode — torn frames, silently. // rotate a texture out from under a live encode — torn frames, silently.
// FAIL SAFE when nobody told us. `set_input_ring_depth` is a defaulted trait method, so a
// caller that forgets it fails SILENTLY — and one did: the GameStream loop opens an encoder
// (`gamestream/stream.rs`) and never calls it, while the Windows IDD-push capturer declares a
// ring of 2 and `async_inflight_cap()` defaults to 4. That let a Moonlight session pipeline
// four encodes against a two-texture ring, i.e. exactly the in-place overwrite this cap
// exists to prevent, as torn/mixed frames and never an error.
//
// Guarding at the single point of CONSUMPTION rather than at each call site is deliberate:
// it covers every present and future caller, including ones that have not been written yet,
// whereas plumbing the setter into N loops only fixes the N sites someone remembered. An
// unknown ring is treated as the shallowest one any capturer in this tree declares, so the
// unconfigured path degrades to less pipelining — a latency cost, not corruption. Callers
// that DO configure it are unaffected.
const UNCONFIGURED_RING_DEPTH: usize = 2;
let cap = match self.input_ring_depth { let cap = match self.input_ring_depth {
Some(d) => async_inflight_cap().min(d.max(1)), Some(d) => async_inflight_cap().min(d.max(1)),
None => async_inflight_cap(), None => async_inflight_cap().min(UNCONFIGURED_RING_DEPTH),
}; };
while self.async_rt.is_some() && self.pending.len() >= cap { while self.async_rt.is_some() && self.pending.len() >= cap {
let done = { let done = {
@@ -1380,6 +1428,8 @@ impl Encoder for NvencD3d11Encoder {
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the // RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
// session is in HDR mode. Both are the real capabilities the session glue routes on. // session is in HDR mode. Both are the real capabilities the session glue routes on.
EncoderCaps { EncoderCaps {
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
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 // 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 // it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
@@ -1552,6 +1602,15 @@ impl Encoder for NvencD3d11Encoder {
self.bitrate_bps = bps; self.bitrate_bps = bps;
return true; return true;
} }
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
// [`Encoder::applied_bitrate_bps`].
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
Some(ceiling) => bps.min(ceiling),
None => bps,
};
// SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode // SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode
// thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the // thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the
// sanctioned side of the two-thread async split — the retrieve thread only ever locks // sanctioned side of the two-thread async split — the retrieve thread only ever locks
@@ -1600,6 +1659,12 @@ impl Encoder for NvencD3d11Encoder {
} }
} }
fn applied_bitrate_bps(&self) -> Option<u64> {
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
Some(self.bitrate_bps)
}
fn flush(&mut self) -> Result<()> { fn flush(&mut self) -> Result<()> {
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain. Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
} }
+10 -5
View File
@@ -189,12 +189,15 @@ impl PyroWaveEncoder {
if !chroma444 && (width % 2 != 0 || height % 2 != 0) { if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})"); bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
} }
if chroma444 && !crate::pyrowave_mode_fits_rdo(width, height, true) { // Checked against the chroma actually being opened, NOT hardcoded 4:4:4 — see the Linux
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one // twin (`enc/linux/pyrowave.rs`) for the full rationale: the negotiator's 4:4:4 → 4:2:0
// slips through (e.g. the lab override) rather than wrap the RDO block index. // downgrade hands oversized modes to this open AS 4:2:0, so the old `chroma444`-gated
// check was skipped exactly when it was needed.
if !crate::pyrowave_mode_fits_rdo(width, height, chroma444) {
bail!( bail!(
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \ "pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
block index (see pyrowave-sys patches/0002 note) use 4:2:0 at this size" index (see pyrowave-sys patches/0002 note) lower the resolution",
if chroma444 { "4:4:4" } else { "4:2:0" }
); );
} }
let fps = fps.max(1); let fps = fps.max(1);
@@ -678,6 +681,8 @@ impl Encoder for PyroWaveEncoder {
// after the caps() default was written — a hardcoded `default()` here mis-reports a 4:4:4 // after the caps() default was written — a hardcoded `default()` here mis-reports a 4:4:4
// open as 4:2:0 and fires a spurious "chroma disagrees with the negotiated Welcome" warn). // open as 4:2:0 and fires a spurious "chroma disagrees with the negotiated Welcome" warn).
EncoderCaps { EncoderCaps {
// The Windows capturer composites the pointer itself; this backend never reads it.
blends_cursor: false,
chroma_444: self.chroma444, chroma_444: self.chroma444,
..EncoderCaps::default() ..EncoderCaps::default()
} }
+8 -1
View File
@@ -774,7 +774,12 @@ impl QsvEncoder {
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) { if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
bail!("this GPU/driver declined AV1 encode (DG2/Arc or MTL+ required) — QSV probe"); bail!("this GPU/driver declined AV1 encode (DG2/Arc or MTL+ required) — QSV probe");
} }
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2); // Depth follows the delivered pixels, not the negotiated depth ([`crate::ten_bit_input`]).
// With the old `bit_depth >= 10 || …` shape a 10-bit-negotiated session over an 8-bit
// capture derived `expected = P010` and hit the bail below, ending the session at open —
// and taking the ffmpeg fallback with it, since that path had the same defect in a worse
// form (it accepted the open and then failed every frame).
let ten_bit = crate::ten_bit_input(format, bit_depth);
if ten_bit && codec == Codec::H264 { if ten_bit && codec == Codec::H264 {
bail!("native QSV: 10-bit is HEVC/AV1-only (H.264 High10 is not negotiated)"); bail!("native QSV: 10-bit is HEVC/AV1-only (H.264 High10 is not negotiated)");
} }
@@ -1402,6 +1407,8 @@ impl Encoder for QsvEncoder {
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
EncoderCaps { EncoderCaps {
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
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 // In-band mastering/CLL at IDR (HEVC prefix SEI / AV1 metadata OBU); AVC sessions
// are never HDR. // are never HDR.
+106 -11
View File
@@ -5,9 +5,11 @@
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. Extracted into a subsystem crate //! VA surface). One [`Encoder`] trait, selected in [`open_video`]. Extracted into a subsystem crate
//! (plan §W6): depends on the shared frame vocabulary (`pf-frame`) + zero-copy plumbing //! (plan §W6): depends on the shared frame vocabulary (`pf-frame`) + zero-copy plumbing
//! (`pf-zerocopy`), never on capture — the capture→encode edge is one-way. //! (`pf-zerocopy`), never on capture — the capture→encode edge is one-way.
// Scaffold: some backend paths + trait defaults are defined ahead of the per-feature build that // NOTE: no crate-wide `#![allow(dead_code)]`. It was inherited from the pre-extraction host crate
// uses them (mirrors the host crate root's allow before the extraction). // root as scaffolding for backend paths defined ahead of the build that used them, but a census
#![allow(dead_code)] // across every feature combination on both platforms found it was hiding exactly two items — so it
// bought nothing and blinded the crate to future rot. Genuinely test-only helpers carry
// `#[cfg(test)]` instead.
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof // Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). As a parent module this also covers the child modules (windows/linux backends). // program). As a parent module this also covers the child modules (windows/linux backends).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
@@ -76,13 +78,34 @@ impl Codec {
) { ) {
return punktfunk_core::quic::CODEC_H264; return punktfunk_core::quic::CODEC_H264;
} }
// A pref that FORCES the raw Vulkan Video backend can only ever serve what that
// backend encodes: `open_video_backend`'s `vulkan` arm bails outright for anything
// that is not HEVC/AV1 ("the Vulkan Video encoder supports HEVC + AV1; the session
// negotiated {codec:?}"). Advertising H.264 there let a client negotiate it and die
// at encoder open. Without the `vulkan-encode` feature that arm cannot open at all,
// so the pref advertises nothing.
//
// This is a CEILING intersected with the device probe below, never a replacement
// for it: pinning a static HEVC|AV1 would ADD AV1 on the AMD/Intel hosts whose probe
// currently withholds it (pre-RDNA3, pre-Arc), i.e. it would re-create this very bug
// for a different codec. Intersecting can only narrow.
let pref_ceiling: u8 = match pf_host_config::config().encoder_pref.as_str() {
"vulkan" | "vulkan-video" => {
if cfg!(feature = "vulkan-encode") {
punktfunk_core::quic::CODEC_HEVC | punktfunk_core::quic::CODEC_AV1
} else {
0
}
}
_ => GPU_SUPERSET,
};
if linux_zero_copy_is_vaapi() { if linux_zero_copy_is_vaapi() {
if let Some(m) = vaapi_codec_support().wire_mask() { if let Some(m) = vaapi_codec_support().wire_mask() {
return m; return m & pref_ceiling;
} }
} }
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above). // NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
GPU_SUPERSET GPU_SUPERSET & pref_ceiling
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -175,6 +198,23 @@ pub fn open_video(
}, },
} }
}; };
// The session asked for a composited pointer; say so loudly if the backend that actually opened
// cannot deliver one. `cursor_blend` was a REQUEST with no answer for most of this crate's life
// (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing
// in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path.
//
// A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here
// would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is
// the only layer that can fall back to capturer-side compositing. This makes the condition
// visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream.
if cursor_blend && !inner.caps().blends_cursor {
tracing::warn!(
backend,
"session negotiated a composited cursor but this encode backend does not blend \
CapturedFrame::cursor the pointer will be MISSING from the stream unless the \
capturer composites it"
);
}
Ok(Box::new(TrackedEncoder { Ok(Box::new(TrackedEncoder {
inner, inner,
_session: pf_gpu::session_begin(gpu), _session: pf_gpu::session_begin(gpu),
@@ -241,6 +281,12 @@ impl Encoder for TrackedEncoder {
fn reconfigure_bitrate(&mut self, bps: u64) -> bool { fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
self.inner.reconfigure_bitrate(bps) self.inner.reconfigure_bitrate(bps)
} }
// Forwarded (the same trap class as `set_wire_chunking`): the unforwarded default `None`
// would tell the session loop "no encoder truth here" and it would keep pacing/acking the
// requested rate even where NVENC clamped to the codec-level ceiling.
fn applied_bitrate_bps(&self) -> Option<u64> {
self.inner.applied_bitrate_bps()
}
fn flush(&mut self) -> Result<()> { fn flush(&mut self) -> Result<()> {
self.inner.flush() self.inner.flush()
} }
@@ -853,18 +899,31 @@ fn vulkan_encode_enabled() -> bool {
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which /// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0` /// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
/// escapes at the capture gate). /// escapes at the capture gate).
///
/// This verdict is load-bearing in a way the other capability helpers are not: once the producer
/// has been asked for two-plane NV12 there is **no fallback**. [`open_video`] deliberately makes a
/// failed Vulkan open FATAL for an NV12 capture rather than degrading to libav VAAPI, because VAAPI
/// would import that buffer as packed RGB and stream silent garbage. So a wrong `true` here does
/// not cost quality — it kills the session at its first frame. Hence the real device probe below,
/// and hence the conjuncts are ordered cheapest-first so it only runs when everything else already
/// said yes.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn linux_native_nv12_ok(codec: Codec) -> bool { pub fn linux_native_nv12_ok(codec: Codec) -> bool {
#[cfg(feature = "vulkan-encode")] #[cfg(feature = "vulkan-encode")]
{ {
matches!(codec, Codec::H265 | Codec::Av1) matches!(codec, Codec::H265 | Codec::Av1)
&& vulkan_encode_enabled() && vulkan_encode_enabled()
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref // Which backend this host actually resolves to. This used to be a denylist of the
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`]. // EXPLICIT prefs that skip Vulkan Video ("nvenc"|"nvidia"|"cuda"|"pyrowave"), which
&& !matches!( // silently missed the one that matters: the DEFAULT `encoder_pref` is `""`, and `""`
pf_host_config::config().encoder_pref.as_str(), // resolves to `auto`, which on an NVIDIA box opens NVENC. So a stock NVIDIA host passed
"nvenc" | "nvidia" | "cuda" | "pyrowave" // this gate. `linux_zero_copy_is_vaapi` layers the pref on top of the same auto decision
) // `open_video` makes, which is exactly what the note on `linux_auto_is_vaapi` says a
// capability probe must consult — and it is what the downstream consumer of this very
// verdict already uses to pick its zero-copy path.
&& linux_zero_copy_is_vaapi()
// …and only then ask the GPU. Ordered last on purpose: it opens a Vulkan instance.
&& vulkan_encode_available(codec)
} }
#[cfg(not(feature = "vulkan-encode"))] #[cfg(not(feature = "vulkan-encode"))]
{ {
@@ -873,6 +932,42 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
} }
} }
/// 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.
///
/// Only [`linux_native_nv12_ok`] consults this, and only for the no-fallback decision described
/// there. It is deliberately NOT wired into [`open_video`]'s dispatch: that path already degrades
/// to VAAPI on a failed open for every non-NV12 capture, so making it pay for a probe would buy
/// nothing. Prediction and truth stay separate — the probe answers "would it open", the open itself
/// reports what actually did.
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
fn vulkan_encode_available(codec: Codec) -> bool {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
let key = (pf_gpu::selection_key(), codec.label());
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(v) = cache.lock().unwrap().get(&key) {
return *v;
}
let verdict = vulkan_video::probe_encode_support(codec);
let ok = verdict.is_ok();
match &verdict {
Ok(()) => tracing::info!(
?codec,
"Vulkan Video encode probed OK — producer-native NV12 capture is eligible"
),
Err(why) => tracing::info!(
?codec,
why = *why,
"Vulkan Video encode unavailable — keeping the packed-RGB capture negotiation \
(the native-NV12 path has no VAAPI fallback)"
),
}
cache.lock().unwrap().insert(key, ok);
ok
}
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA /// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT /// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be /// create a CUDA context (that would allocate GPU state on every host that merely *might* be
@@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool {
dm == "sddm.service" dm == "sddm.service"
} }
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on /// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see /// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the /// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
/// caller degrades to attach. /// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
fn try_stop_display_manager(dm: &str) -> bool { /// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
Command::new("systemctl") /// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
.args(["stop", dm]) /// rpm/deb `libexec` path (what the shipped policy annotates) and Arch's `/usr/lib/<pkg>` (its
/// PKGBUILD rewrites the annotation to match).
const DM_HELPER_PATHS: &[&str] = &[
"/usr/libexec/punktfunk/pf-dm-helper",
"/usr/lib/punktfunk/pf-dm-helper",
];
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
fn dm_helper(verb: &str) -> bool {
let Some(helper) = DM_HELPER_PATHS
.iter()
.find(|p| std::path::Path::new(p).exists())
else {
return false;
};
Command::new("pkexec")
.arg(helper)
.arg(verb)
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
} }
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
/// attach.
fn try_stop_display_manager(dm: &str) -> bool {
let direct = Command::new("systemctl")
.args(["stop", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("stop")
}
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
/// two steps as root.
fn restore_display_manager(dm: &str) -> bool {
let _ = Command::new("systemctl")
.args(["reset-failed", dm])
.status();
let direct = Command::new("systemctl")
.args(["restart", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("restore")
}
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the /// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so /// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but /// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
@@ -1219,10 +1268,13 @@ fn honor_session_select_switch(dm: String) {
clear_takeover(); clear_takeover();
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
let _ = Command::new("systemctl") if !restore_display_manager(&dm) {
.args(["reset-failed", &dm]) tracing::warn!(
.status(); %dm,
let _ = Command::new("systemctl").args(["start", &dm]).status(); "gamescope: display-manager start was denied — the desktop switch may need a manual \
`systemctl restart` of the DM"
);
}
let deadline = Instant::now() + Duration::from_secs(10); let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline { while Instant::now() < deadline {
let active = Command::new("systemctl") let active = Command::new("systemctl")
@@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> {
if !try_stop_display_manager(&dm) { if !try_stop_display_manager(&dm) {
bail!( bail!(
"the box's gaming session is driven by {dm}, which does not survive a masked \ "the box's gaming session is driven by {dm}, which does not survive a masked \
session unit, and stopping it needs privilege install the punktfunk \ session unit, and stopping it needs privilege the packaged pf-dm-helper \
display-manager polkit rule (see docs) to enable the managed takeover" polkit action is missing or was denied (reinstall the punktfunk package, or \
install the display-manager polkit rule from the docs) so the managed takeover \
is unavailable"
); );
} }
tracing::info!( tracing::info!(
@@ -1668,14 +1722,7 @@ fn do_restore_tv_session() {
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin // (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
// session Exec starts the gamescope unit itself. // session Exec starts the gamescope unit itself.
if let Some(dm) = dm { if let Some(dm) = dm {
let _ = Command::new("systemctl") let restart = restore_display_manager(&dm);
.args(["reset-failed", &dm])
.status();
let restart = Command::new("systemctl")
.args(["restart", &dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
if restart { if restart {
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)"); tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
} else if crate::try_recover_session() { } else if crate::try_recover_session() {
@@ -340,9 +340,19 @@ pub(crate) fn is_available() -> bool {
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon. /// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22); const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing /// First gamescope that paints the Steam overlay (Shift+Tab / Quick Access Menu) into its built-in
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a /// PipeWire node. `paint_pipewire()` is a *separate, reduced* composite from the display scanout;
/// gate. Returns the parsed version when it could read one. /// the overlay-window paint (gated on the consumer negotiating `gamescope_focus_appid == 0`, which
/// we do by never advertising that property — see the capturer's EnumFormat builders) first ships
/// in 3.16.23 (gamescope commits `ccd62074` + `f8b33d38`). Below this the overlay is *never* in the
/// node, so it cannot appear in the stream no matter what the host does. The cursor and
/// external-overlay / notification layers are excluded on *every* version (handled host-side).
const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
/// Best-effort: warn if the installed gamescope is older than [`MIN_GAMESCOPE`] (capture is
/// unreliable) or than [`MIN_GAMESCOPE_OVERLAY`] (capture works but the Steam overlay can't reach
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
/// diagnostic, not a gate. Returns the parsed version when it could read one.
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> { pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
let out = Command::new("gamescope").arg("--version").output().ok()?; let out = Command::new("gamescope").arg("--version").output().ok()?;
// gamescope prints the version banner to stderr on some builds, stdout on others. // gamescope prints the version banner to stderr on some builds, stdout on others.
@@ -360,6 +370,18 @@ pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
capture deadlock against PipeWire 1.6 (a wedged link head-blocks the daemon); \ capture deadlock against PipeWire 1.6 (a wedged link head-blocks the daemon); \
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter" upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
); );
} else if ver < MIN_GAMESCOPE_OVERLAY {
// Capture is fine; the Steam overlay just won't be in the frame gamescope hands us.
tracing::warn!(
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
min = %format!(
"{}.{}.{}",
MIN_GAMESCOPE_OVERLAY.0, MIN_GAMESCOPE_OVERLAY.1, MIN_GAMESCOPE_OVERLAY.2
),
"gamescope is older than the first version that paints the Steam overlay (Shift+Tab / \
Quick Access Menu) into its PipeWire node the overlay will be absent from the \
stream until you upgrade gamescope (the cursor is composited host-side regardless)"
);
} }
Some(ver) Some(ver)
} }
@@ -379,7 +401,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE}; use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY};
#[test] #[test]
fn parses_steam_appid_from_launch() { fn parses_steam_appid_from_launch() {
@@ -425,4 +447,15 @@ mod tests {
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE); assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE); assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
} }
#[test]
fn overlay_threshold_brackets_the_fix() {
// 3.16.22 captures fine but predates the overlay-in-pipewire paint — it sits in the
// "capture works, overlay absent" window `[MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY)`, which is
// exactly the `else if` warn arm; 3.16.23 is the first to include the overlay.
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
assert!(parse_version("gamescope version 3.16.22").unwrap() < MIN_GAMESCOPE_OVERLAY);
assert!(parse_version("gamescope version 3.16.23").unwrap() >= MIN_GAMESCOPE_OVERLAY);
assert!(parse_version("gamescope version 3.16.25").unwrap() >= MIN_GAMESCOPE_OVERLAY);
}
} }
+549 -38
View File
@@ -13,7 +13,13 @@
//! its rolling baseline: standing queue growth, the *pre-loss* signature of a saturated link //! its rolling baseline: standing queue growth, the *pre-loss* signature of a saturated link
//! (bufferbloat) — this is the early-warning signal loss-based control lacks; //! (bufferbloat) — this is the early-warning signal loss-based control lacks;
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind" //! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
//! evidence there is. //! evidence there is;
//! - **host-encode-latency rise** — the host's per-AU 0xCF `encode_us` climbing above its rolling
//! baseline: the ENCODER falling behind its frame budget (the compute knee), the one failure a
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
//! encoder off a cliff the network could carry.
//! //!
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency //! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion //! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
@@ -94,6 +100,25 @@ const UTILIZATION_DEN: u64 = 4;
/// cap always leaves ≥ ~12 % of climbing room — the two gates can't deadlock. /// cap always leaves ≥ ~12 % of climbing room — the two gates can't deadlock.
const PROVEN_HEADROOM_NUM: u32 = 3; const PROVEN_HEADROOM_NUM: u32 = 3;
const PROVEN_HEADROOM_DEN: u32 = 2; const PROVEN_HEADROOM_DEN: u32 = 2;
/// How far the window's mean HOST-ENCODE latency (the 0xCF `HostStages::encode_us` the host
/// already ships per AU) may rise above its rolling baseline before the window is bad. This is
/// the down-driver for the ENCODER's compute knee — the failure loss/OWD/decode are all blind
/// to: on a fat LAN the controller can climb to a rate the link carries fine but the ASIC
/// can't encode inside the frame budget (4K120 HEVC at ~800 Mbps ≈ 9.3 ms against 8.33), and
/// the only symptom is encode time. Baseline-RELATIVE on purpose: an escalated host reports
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
const ENCODE_RISE_US: i64 = 4_000;
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
/// queue is growing past the knee; skip the two-window confirmation.
const ENCODE_SEVERE_US: i64 = 12_000;
/// Clean windows parked at the learned [`host cap`](BitrateController::host_cap_kbps) before
/// re-probing above it (~60 s at the 750 ms tick). A cadence-refusal cap is scene-dependent
/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole
/// session. A still-standing limit just re-teaches itself in two short acks, which the host
/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR.
const CAP_REPROBE_WINDOWS: u32 = 80;
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline. /// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes. /// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
const BASELINE_WINDOWS: usize = 40; const BASELINE_WINDOWS: usize = 40;
@@ -121,6 +146,27 @@ pub(crate) struct BitrateController {
/// keeping-up baseline. Empty on embedders that don't report decode latency (the decode /// keeping-up baseline. Empty on embedders that don't report decode latency (the decode
/// signal is then simply absent — identical to the pre-decode-signal behavior). /// signal is then simply absent — identical to the pre-decode-signal behavior).
decode_means: VecDeque<i64>, decode_means: VecDeque<i64>,
/// Recent window mean host-encode latencies (µs, from the 0xCF datagrams); rolling-min
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
encode_means: VecDeque<i64>,
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
/// the probe-measured link authority survives a mode switch's reset. Slowly re-probed
/// ([`CAP_REPROBE_WINDOWS`]) so scene-dependent evidence can't cap the session forever.
host_cap_kbps: Option<u32>,
/// The rate the last [`request`](Self::request) asked for — the reference an ack is judged
/// short against. Taken (not kept) by the ack, so one request is judged at most once.
last_requested_kbps: Option<u32>,
/// Consecutive short-ack streak: the value and how many times in a row it was acked. Two
/// identical short acks latch [`host_cap_kbps`](Self::host_cap_kbps) — one can be a
/// transient (a failed host rebuild keeping the old rate); the host's resolves are
/// deterministic min()s, so a persistent limit reproduces exactly.
short_ack_kbps: u32,
short_acks: u32,
/// Clean windows spent parked at the learned cap (the re-probe clock).
cap_probe_windows: u32,
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat /// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays; /// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On /// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
@@ -147,6 +193,12 @@ impl BitrateController {
probing: true, probing: true,
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS), owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS), decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
host_cap_kbps: None,
last_requested_kbps: None,
short_ack_kbps: 0,
short_acks: 0,
cap_probe_windows: 0,
proven_kbps: 0, proven_kbps: 0,
bad_windows: 0, bad_windows: 0,
clean_windows: 0, clean_windows: 0,
@@ -168,22 +220,68 @@ impl BitrateController {
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the /// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter). /// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
///
/// A SHORT ack (below what we asked) is the host telling us about a limit the network
/// signals can't see — its encoder's codec-level ceiling, or a climb refusal while encode
/// can't hold cadence. Two consecutive short acks at the SAME value latch it as
/// [`host_cap_kbps`](Self::host_cap_kbps), stopping the AIMD sawtooth from re-poking a
/// limit the host already refused; ONE is not enough — a failed host rebuild also acks
/// short once, and latching a transient would cap the session on a hiccup.
pub(crate) fn on_ack(&mut self, kbps: u32) { pub(crate) fn on_ack(&mut self, kbps: u32) {
if kbps > 0 { if kbps > 0 {
if kbps < self.current_kbps {
// Our own decrease changes the encode-time regime (less work per frame; on an
// escalated host the queue offset shifts too) — judging the new regime against
// the old baseline would train-fire the encode down-driver. Re-seed it.
self.encode_means.clear();
}
if let Some(req) = self.last_requested_kbps.take() {
if kbps < req {
if self.short_ack_kbps == kbps {
self.short_acks += 1;
} else {
self.short_ack_kbps = kbps;
self.short_acks = 1;
}
if self.short_acks >= 2 && self.host_cap_kbps.is_none_or(|c| kbps < c) {
tracing::info!(
cap_kbps = kbps,
"adaptive bitrate: host cap learned (encoder ceiling or cadence \
refusal) climbs stop here until it lifts"
);
self.host_cap_kbps = Some(kbps.max(self.floor_kbps));
self.cap_probe_windows = 0;
}
} else {
self.short_acks = 0;
}
}
self.current_kbps = kbps; self.current_kbps = kbps;
} }
self.unacked = 0; self.unacked = 0;
} }
/// An accepted mode switch: the encoder's ceiling and compute knee are properties of the
/// MODE (4K120 caps where 1080p60 never would) — drop the mode-scoped learned state. The
/// probe-measured `ceiling_kbps` (a LINK property) survives.
pub(crate) fn on_mode_switch(&mut self) {
self.host_cap_kbps = None;
self.short_acks = 0;
self.cap_probe_windows = 0;
self.encode_means.clear();
}
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that /// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
/// went FEC-unrecoverable in the window, `loss_ppm` the window's [`crate::quic::LossReport`] /// went FEC-unrecoverable in the window, `loss_ppm` the window's [`crate::quic::LossReport`]
/// figure, `owd_mean_us` the window's mean skew-corrected capture→received latency (`None` /// figure, `owd_mean_us` the window's mean skew-corrected capture→received latency (`None`
/// without a clock handshake), `decode_mean_us` the window's mean client decode-stage latency /// without a clock handshake), `decode_mean_us` the window's mean client decode-stage latency
/// (`None` on an embedder that doesn't report it — the signal is then absent), `actual_kbps` /// (`None` on an embedder that doesn't report it — the signal is then absent),
/// the window's ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline /// `encode_mean_us` the window's mean HOST encode-stage latency (from the per-AU 0xCF
/// really carried, as opposed to the target it was allowed; feeds the utilization climb gate /// datagrams; `None` on an old host that doesn't send them), `actual_kbps` the window's
/// and the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in /// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
/// the window. /// carried, as opposed to the target it was allowed; feeds the utilization climb gate and
/// the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in the
/// window.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) fn on_window( pub(crate) fn on_window(
&mut self, &mut self,
@@ -192,6 +290,7 @@ impl BitrateController {
loss_ppm: u32, loss_ppm: u32,
owd_mean_us: Option<i64>, owd_mean_us: Option<i64>,
decode_mean_us: Option<i64>, decode_mean_us: Option<i64>,
encode_mean_us: Option<i64>,
actual_kbps: u32, actual_kbps: u32,
flushed: bool, flushed: bool,
) -> Option<u32> { ) -> Option<u32> {
@@ -242,6 +341,23 @@ impl BitrateController {
} }
None => (false, false), None => (false, false),
}; };
// Host-encode latency: the same rolling-min-baseline treatment, measuring the HOST'S
// encoder — the compute-knee down-driver (see [`ENCODE_RISE_US`]). This is the only
// signal that can push an already-too-high rate back under the knee: the host refuses
// further climbs while behind cadence, but nothing else ever DESCENDS on a clean LAN.
let (encode_bad, encode_severe) = match encode_mean_us {
Some(mean) => {
let base = self.encode_means.iter().min().copied();
let bad = base.is_some_and(|b| mean > b + ENCODE_RISE_US);
let severe = base.is_some_and(|b| mean > b + ENCODE_SEVERE_US);
if self.encode_means.len() == BASELINE_WINDOWS {
self.encode_means.pop_front();
}
self.encode_means.push_back(mean);
(bad, severe)
}
None => (false, false),
};
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably // The proven-throughput high-water mark: this window's delivered rate is now demonstrably
// digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify — // digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify —
// the bytes that DID arrive still went through the decoder; what loss means for the rate // the bytes that DID arrive still went through the decoder; what loss means for the rate
@@ -253,8 +369,9 @@ impl BitrateController {
// deep decode-latency excursion) or loss far past any blip — one window is enough. // deep decode-latency excursion) or loss far past any blip — one window is enough.
// Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise) // Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise)
// still needs two consecutive windows. // still needs two consecutive windows.
let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe; let severe =
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad; dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe || encode_severe;
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad || encode_bad;
if bad { if bad {
self.bad_windows += 1; self.bad_windows += 1;
self.clean_windows = 0; self.clean_windows = 0;
@@ -264,6 +381,29 @@ impl BitrateController {
self.clean_windows += 1; self.clean_windows += 1;
self.bad_windows = 0; self.bad_windows = 0;
} }
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS`]): after ~60 s of clean
// windows parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a
// scene-dependent refusal can't quietly cap the whole session — a still-standing limit
// just re-latches from the next pair of short acks, at zero encoder cost.
if let Some(cap) = self.host_cap_kbps {
if bad {
self.cap_probe_windows = 0;
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
self.cap_probe_windows += 1;
if self.cap_probe_windows >= CAP_REPROBE_WINDOWS {
self.cap_probe_windows = 0;
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
if lifted > cap {
tracing::debug!(
from_kbps = cap,
to_kbps = lifted,
"adaptive bitrate: re-probing above the learned host cap"
);
self.host_cap_kbps = Some(lifted);
}
}
}
}
let cooled = self let cooled = self
.last_change .last_change
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN); .is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
@@ -284,10 +424,14 @@ impl BitrateController {
// utilized window after a long-enough clean run climbs immediately. // utilized window after a long-enough clean run climbs immediately.
let utilized = let utilized =
actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM; actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM;
let cap = self // The effective ceiling folds in the host-taught cap: the probe measured the LINK, but
// the host's short acks measured the ENCODER — whichever binds first is the limit.
let eff_ceiling = self
.ceiling_kbps .ceiling_kbps
.min(self.host_cap_kbps.unwrap_or(u32::MAX));
let cap = eff_ceiling
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN); .min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
if self.current_kbps < self.ceiling_kbps && utilized && cap > self.current_kbps { if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
// Slow start: double on every cooled clean window until the first congestion signal // Slow start: double on every cooled clean window until the first congestion signal
// (this is how an Automatic session reaches a probe-measured ceiling in seconds). // (this is how an Automatic session reaches a probe-measured ceiling in seconds).
// Congestion avoidance: +~6 % after a sustained clean run. // Congestion avoidance: +~6 % after a sustained clean run.
@@ -308,6 +452,7 @@ impl BitrateController {
fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> { fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> {
self.last_change = Some(now); self.last_change = Some(now);
self.unacked += 1; self.unacked += 1;
self.last_requested_kbps = Some(kbps);
// `current_kbps` is NOT updated here — the host's ack is authoritative. A lost/ignored // `current_kbps` is NOT updated here — the host's ack is authoritative. A lost/ignored
// request just recomputes from the same base next time (and counts toward MAX_UNACKED). // request just recomputes from the same base next time (and counts toward MAX_UNACKED).
Some(kbps) Some(kbps)
@@ -331,7 +476,16 @@ mod tests {
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> { fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
let mut out = None; let mut out = None;
for i in from..from + n { for i in from..from + n {
out = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false); out = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
);
if out.is_some() { if out.is_some() {
return out; return out;
} }
@@ -345,7 +499,7 @@ mod tests {
let mut c = BitrateController::new(0); let mut c = BitrateController::new(0);
let now = Instant::now(); let now = Instant::now();
assert_eq!( assert_eq!(
c.on_window(now, 5, 900_000, Some(500_000), None, 1_000_000, true), c.on_window(now, 5, 900_000, Some(500_000), None, None, 1_000_000, true),
None None
); );
} }
@@ -356,22 +510,58 @@ mod tests {
let start = Instant::now(); let start = Instant::now();
// Heavy-but-recoverable loss (26 %) is ORDINARY: one window is a blip — no reaction. // Heavy-but-recoverable loss (26 %) is ORDINARY: one window is a blip — no reaction.
assert_eq!( assert_eq!(
c.on_window(ticks(start, 0), 0, 25_000, None, None, 1_000_000, false), c.on_window(
ticks(start, 0),
0,
25_000,
None,
None,
None,
1_000_000,
false
),
None None
); );
// The second consecutive bad window backs off ×0.7. // The second consecutive bad window backs off ×0.7.
assert_eq!( assert_eq!(
c.on_window(ticks(start, 1), 0, 25_000, None, None, 1_000_000, false), c.on_window(
ticks(start, 1),
0,
25_000,
None,
None,
None,
1_000_000,
false
),
Some(14_000) Some(14_000)
); );
c.on_ack(14_000); c.on_ack(14_000);
// Still bad after the cooldown → another ×0.7 step from the ACKED rate. // Still bad after the cooldown → another ×0.7 step from the ACKED rate.
assert_eq!( assert_eq!(
c.on_window(ticks(start, 6), 0, 25_000, None, None, 1_000_000, false), c.on_window(
ticks(start, 6),
0,
25_000,
None,
None,
None,
1_000_000,
false
),
None None
); // bad #1 again ); // bad #1 again
assert_eq!( assert_eq!(
c.on_window(ticks(start, 7), 0, 25_000, None, None, 1_000_000, false), c.on_window(
ticks(start, 7),
0,
25_000,
None,
None,
None,
1_000_000,
false
),
Some(9_800) Some(9_800)
); );
} }
@@ -382,19 +572,28 @@ mod tests {
let mut c = BitrateController::new(20_000); let mut c = BitrateController::new(20_000);
let start = Instant::now(); let start = Instant::now();
assert_eq!( assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false), c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
Some(14_000) Some(14_000)
); );
// …and so does a jump-to-live flush. // …and so does a jump-to-live flush.
let mut c = BitrateController::new(20_000); let mut c = BitrateController::new(20_000);
assert_eq!( assert_eq!(
c.on_window(ticks(start, 0), 0, 0, None, None, 1_000_000, true), c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true),
Some(14_000) Some(14_000)
); );
// …and ≥6 % window loss. // …and ≥6 % window loss.
let mut c = BitrateController::new(20_000); let mut c = BitrateController::new(20_000);
assert_eq!( assert_eq!(
c.on_window(ticks(start, 0), 0, 80_000, None, None, 1_000_000, false), c.on_window(
ticks(start, 0),
0,
80_000,
None,
None,
None,
1_000_000,
false
),
Some(14_000) Some(14_000)
); );
} }
@@ -404,18 +603,18 @@ mod tests {
let mut c = BitrateController::new(20_000); let mut c = BitrateController::new(20_000);
let start = Instant::now(); let start = Instant::now();
assert_eq!( assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false), c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
Some(14_000) Some(14_000)
); );
c.on_ack(14_000); c.on_ack(14_000);
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown // A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
// boundary (tick 2 = 1.5 s) it fires. // boundary (tick 2 = 1.5 s) it fires.
assert_eq!( assert_eq!(
c.on_window(ticks(start, 1), 1, 0, None, None, 1_000_000, false), c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false),
None None
); );
assert_eq!( assert_eq!(
c.on_window(ticks(start, 2), 1, 0, None, None, 1_000_000, false), c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false),
Some(9_800) Some(9_800)
); );
} }
@@ -426,17 +625,17 @@ mod tests {
let start = Instant::now(); let start = Instant::now();
// ×0.7 of 6000 = 4200 < floor → clamped to 5000. // ×0.7 of 6000 = 4200 < floor → clamped to 5000.
assert_eq!( assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false), c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
Some(5_000) Some(5_000)
); );
c.on_ack(5_000); c.on_ack(5_000);
// At the floor, further bad windows request nothing. // At the floor, further bad windows request nothing.
assert_eq!( assert_eq!(
c.on_window(ticks(start, 6), 1, 0, None, None, 1_000_000, false), c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false),
None None
); );
assert_eq!( assert_eq!(
c.on_window(ticks(start, 7), 1, 0, None, None, 1_000_000, false), c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false),
None None
); );
} }
@@ -446,7 +645,7 @@ mod tests {
let mut c = BitrateController::new(20_000); let mut c = BitrateController::new(20_000);
let start = Instant::now(); let start = Instant::now();
assert_eq!( assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false), c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
Some(14_000) Some(14_000)
); );
c.on_ack(14_000); c.on_ack(14_000);
@@ -469,9 +668,16 @@ mod tests {
// Every cooled clean window doubles until the ceiling caps the climb, then quiet. // Every cooled clean window doubles until the ceiling caps the climb, then quiet.
let mut got = Vec::new(); let mut got = Vec::new();
for i in 0..14 { for i in 0..14 {
if let Some(k) = if let Some(k) = c.on_window(
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false) ticks(start, i),
{ 0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
) {
c.on_ack(k); c.on_ack(k);
got.push(k); got.push(k);
} }
@@ -485,20 +691,47 @@ mod tests {
c.set_ceiling(300_000); c.set_ceiling(300_000);
let start = Instant::now(); let start = Instant::now();
assert_eq!( assert_eq!(
c.on_window(ticks(start, 0), 0, 0, Some(10_000), None, 1_000_000, false), c.on_window(
ticks(start, 0),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false
),
Some(40_000) Some(40_000)
); );
c.on_ack(40_000); c.on_ack(40_000);
// Severe window → immediate ×0.7, and slow start is over. // Severe window → immediate ×0.7, and slow start is over.
assert_eq!( assert_eq!(
c.on_window(ticks(start, 2), 1, 0, Some(10_000), None, 1_000_000, false), c.on_window(
ticks(start, 2),
1,
0,
Some(10_000),
None,
None,
1_000_000,
false
),
Some(28_000) Some(28_000)
); );
c.on_ack(28_000); c.on_ack(28_000);
// Clean again — but the next climb is additive, after the 6-window clean run. // Clean again — but the next climb is additive, after the 6-window clean run.
let mut next = None; let mut next = None;
for i in 3..12 { for i in 3..12 {
next = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false); next = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
);
if next.is_some() { if next.is_some() {
assert!(i >= 8, "additive climb must wait for the clean run"); assert!(i >= 8, "additive climb must wait for the clean run");
break; break;
@@ -512,7 +745,7 @@ mod tests {
let mut c = BitrateController::new(0); let mut c = BitrateController::new(0);
c.set_ceiling(1_000_000); c.set_ceiling(1_000_000);
assert_eq!( assert_eq!(
c.on_window(Instant::now(), 0, 0, None, None, 1_000_000, false), c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false),
None None
); );
let mut c = BitrateController::new(20_000); let mut c = BitrateController::new(20_000);
@@ -527,17 +760,44 @@ mod tests {
// Establish a ~10 ms baseline over a few clean windows. // Establish a ~10 ms baseline over a few clean windows.
for i in 0..4 { for i in 0..4 {
assert_eq!( assert_eq!(
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false), c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false
),
None None
); );
} }
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off. // Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
assert_eq!( assert_eq!(
c.on_window(ticks(start, 4), 0, 0, Some(50_000), None, 1_000_000, false), c.on_window(
ticks(start, 4),
0,
0,
Some(50_000),
None,
None,
1_000_000,
false
),
None None
); );
assert_eq!( assert_eq!(
c.on_window(ticks(start, 5), 0, 0, Some(52_000), None, 1_000_000, false), c.on_window(
ticks(start, 5),
0,
0,
Some(52_000),
None,
None,
1_000_000,
false
),
Some(14_000) Some(14_000)
); );
} }
@@ -557,6 +817,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(8_000), Some(8_000),
None,
1_000_000, 1_000_000,
false false
), ),
@@ -572,6 +833,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(38_000), Some(38_000),
None,
1_000_000, 1_000_000,
false false
), ),
@@ -584,6 +846,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(40_000), Some(40_000),
None,
1_000_000, 1_000_000,
false false
), ),
@@ -605,6 +868,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(8_000), Some(8_000),
None,
1_000_000, 1_000_000,
false false
), ),
@@ -620,6 +884,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(38_000), Some(38_000),
None,
1_000_000, 1_000_000,
false false
), ),
@@ -634,6 +899,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(40_000), Some(40_000),
None,
1_000_000, 1_000_000,
false false
), ),
@@ -657,6 +923,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(8_000), Some(8_000),
None,
2_000, 2_000,
false false
), ),
@@ -673,6 +940,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(8_000), Some(8_000),
None,
18_000, 18_000,
false false
), ),
@@ -696,6 +964,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(8_000), Some(8_000),
None,
20_000, 20_000,
false false
), ),
@@ -710,6 +979,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(8_000), Some(8_000),
None,
30_000, 30_000,
false false
), ),
@@ -732,6 +1002,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(8_000), Some(8_000),
None,
20_000, 20_000,
false false
), ),
@@ -741,7 +1012,16 @@ mod tests {
// A long calm stretch (2 % utilization, decoder idle): the controller stays silent. // A long calm stretch (2 % utilization, decoder idle): the controller stays silent.
for i in 2..30 { for i in 2..30 {
assert_eq!( assert_eq!(
c.on_window(ticks(start, i), 0, 0, Some(10_000), Some(4_000), 600, false), c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(4_000),
None,
600,
false
),
None None
); );
} }
@@ -761,6 +1041,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(8_000), Some(8_000),
None,
1_000_000, 1_000_000,
false false
), ),
@@ -776,6 +1057,7 @@ mod tests {
0, 0,
Some(10_000), Some(10_000),
Some(60_000), Some(60_000),
None,
1_000_000, 1_000_000,
false false
), ),
@@ -783,6 +1065,235 @@ mod tests {
); );
} }
#[test]
fn two_identical_short_acks_latch_the_host_cap() {
// The 4K120 field failure: the encoder ceilings at ~794 Mbps while the link carries
// more — the host acks short. TWO identical short acks teach the cap; climbs then stop
// poking a limit the host already refused (the rebuild-storm driver).
let mut c = BitrateController::new(400_000);
c.set_ceiling(1_400_000);
let start = Instant::now();
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
// First short ack: current follows (authoritative), but one short ack is not a cap.
c.on_ack(794_000);
assert!(c.host_cap_kbps.is_none());
// The next climb overshoots again and is short-acked at the SAME value: latch.
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
c.on_ack(794_000);
assert_eq!(c.host_cap_kbps, Some(794_000));
// Parked AT the learned cap, nothing left to climb to — no more requests.
assert_eq!(run_clean(&mut c, start, 20, 12), None);
}
#[test]
fn one_short_ack_is_a_transient_not_a_cap() {
// A failed host rebuild acks short once (it kept the old rate) — latching THAT would
// cap the session on a driver hiccup. The streak must survive only identical repeats.
let mut c = BitrateController::new(400_000);
c.set_ceiling(1_400_000);
let start = Instant::now();
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
c.on_ack(400_000); // rebuild failed, host kept the old rate
assert!(c.host_cap_kbps.is_none());
// The retry applies fully: streak broken, still no cap, full authority kept.
assert_eq!(run_clean(&mut c, start, 10, 1), Some(800_000));
c.on_ack(800_000);
assert!(c.host_cap_kbps.is_none());
}
#[test]
fn mode_switch_clears_the_learned_cap() {
let mut c = BitrateController::new(400_000);
c.set_ceiling(1_400_000);
let start = Instant::now();
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
c.on_ack(794_000);
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
c.on_ack(794_000);
assert_eq!(c.host_cap_kbps, Some(794_000));
// 4K120's ceiling means nothing at the new mode — the cap must not survive the switch
// (the probe-measured link ceiling does).
c.on_mode_switch();
assert!(c.host_cap_kbps.is_none());
assert_eq!(c.ceiling_kbps, 1_400_000);
}
#[test]
fn learned_cap_reprobes_after_a_sustained_clean_run() {
// A cadence-refusal cap is scene evidence, not a spec limit: after ~60 s parked clean
// at the cap, lift one step so a one-time heavy scene can't cap the session forever. A
// still-standing limit just re-latches from the next short-ack pair, at zero cost.
let mut c = BitrateController::new(400_000);
c.set_ceiling(1_400_000);
let start = Instant::now();
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
c.on_ack(794_000);
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
c.on_ack(794_000);
assert_eq!(c.host_cap_kbps, Some(794_000));
for i in 0..CAP_REPROBE_WINDOWS {
let _ = c.on_window(
ticks(start, 20 + i),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
);
}
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
}
#[test]
fn host_encode_latency_rise_backs_off() {
// The compute knee: link pristine, client decoder fine — only HOST encode time moves
// (the 4K120 case: ~9.3 ms against an 8.33 ms budget shows up nowhere else). Two risen
// windows → ×0.7, exactly like an OWD/decode rise.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
for i in 0..4 {
assert_eq!(
c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
Some(7_000),
1_000_000,
false
),
None
);
}
assert_eq!(
c.on_window(
ticks(start, 4),
0,
0,
Some(10_000),
None,
Some(11_500),
1_000_000,
false
),
None
);
assert_eq!(
c.on_window(
ticks(start, 6),
0,
0,
Some(10_000),
None,
Some(12_000),
1_000_000,
false
),
Some(14_000)
);
}
#[test]
fn deep_encode_excursion_is_severe() {
// Encode time shooting ≈1.5 frame budgets over baseline = the queue is growing past
// the knee right now — no two-window confirmation.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
for i in 0..4 {
assert_eq!(
c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
Some(7_000),
1_000_000,
false
),
None
);
}
assert_eq!(
c.on_window(
ticks(start, 4),
0,
0,
Some(10_000),
None,
Some(20_000),
1_000_000,
false
),
Some(14_000)
);
}
#[test]
fn rate_decrease_rebases_the_encode_baseline() {
// After OUR OWN decrease the encode regime legitimately changes (less work per frame;
// an escalated host's reported encode_us also carries a queue offset) — the old
// baseline must not train-fire repeated backoffs down to the floor.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
for i in 0..4 {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
Some(7_000),
1_000_000,
false,
);
}
let _ = c.on_window(
ticks(start, 4),
0,
0,
Some(10_000),
None,
Some(12_000),
1_000_000,
false,
);
assert_eq!(
c.on_window(
ticks(start, 6),
0,
0,
Some(10_000),
None,
Some(12_500),
1_000_000,
false
),
Some(14_000)
);
// The decrease applies → rebase. The new regime's ~15 ms means (an escalated host's
// queue offset) would be far over the OLD 7 ms baseline, but must now read clean.
c.on_ack(14_000);
for i in 8..11 {
assert_eq!(
c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
Some(15_000),
1_000_000,
false
),
None
);
}
}
#[test] #[test]
fn ack_silence_disables_the_controller() { fn ack_silence_disables_the_controller() {
let mut c = BitrateController::new(20_000); let mut c = BitrateController::new(20_000);
@@ -791,7 +1302,7 @@ mod tests {
let mut i = 0; let mut i = 0;
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence. // Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
while i < 60 { while i < 60 {
if c.on_window(ticks(start, i), 1, 0, None, None, 1_000_000, false) if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false)
.is_some() .is_some()
{ {
sent += 1; sent += 1;
@@ -224,6 +224,20 @@ pub(crate) struct DecodeLatAcc {
pub(crate) count: u32, pub(crate) count: u32,
} }
/// Host encode-stage latency accumulator — [`DecodeLatAcc`]'s mirror for the HOST side of the
/// pipeline. The datagram task adds one sample per 0xCF `HostStages::encode_us` (host encoder
/// submit → bitstream ready) and the pump drains a window mean into
/// [`crate::abr::BitrateController::on_window`]'s encode signal. Host encode time was measured,
/// shipped and drawn on the overlay, but never an ABR input — which is how a fat-LAN Automatic
/// session drove the encoder past its compute knee with nothing to stop it (§ABR overdrive).
/// Its own accumulator rather than the overlay's `host_timing` channel: that channel is a lossy
/// `try_send` the embedder may never drain, and the controller must not depend on it.
#[derive(Default)]
pub(crate) struct EncodeLatAcc {
pub(crate) sum_us: u64,
pub(crate) count: u32,
}
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes /// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the /// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next /// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
+10
View File
@@ -112,6 +112,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the // Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
// pump's controller drains it on its report tick (`take()` — an ack is consumed once). // pump's controller drains it on its report tick (`take()` — an ack is consumed once).
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None)); let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
// Host-encode-latency accumulator (the ABR encode signal, see [`EncodeLatAcc`]): the
// datagram task adds one sample per 0xCF; the pump drains a window mean per report tick.
let encode_lat = Arc::new(Mutex::new(super::frame_channel::EncodeLatAcc::default()));
// Bumped by the control task on every accepted mode switch (the `clock_gen` pattern): the
// pump resets the controller's mode-scoped learned state (host cap, encode baseline).
let mode_gen = Arc::new(AtomicU32::new(0));
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream // Control task (see [`control_task`]): the handshake stream stays open for mid-stream
// renegotiation, speed tests, clock re-sync, and clipboard metadata. // renegotiation, speed tests, clock re-sync, and clipboard metadata.
@@ -128,6 +134,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clock_gen: clock_gen.clone(), clock_gen: clock_gen.clone(),
clip_event_tx: clip_event_tx.clone(), clip_event_tx: clip_event_tx.clone(),
cursor_shape_tx, cursor_shape_tx,
mode_gen: mode_gen.clone(),
} }
.run(), .run(),
); );
@@ -141,6 +148,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
hidout_tx, hidout_tx,
hdr_meta_tx, hdr_meta_tx,
host_timing_tx, host_timing_tx,
encode_lat.clone(),
cursor_state_tx, cursor_state_tx,
)); ));
@@ -176,6 +184,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clock_offset, clock_offset,
clock_gen, clock_gen,
decode_lat, decode_lat,
encode_lat,
mode_gen,
frames_dropped, frames_dropped,
fec_recovered, fec_recovered,
bitrate_ack, bitrate_ack,
@@ -24,6 +24,10 @@ pub(super) struct ControlTask {
/// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's /// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's
/// shape plane ([`NativeClient::next_cursor_shape`]). /// shape plane ([`NativeClient::next_cursor_shape`]).
pub(super) cursor_shape_tx: std::sync::mpsc::SyncSender<crate::quic::CursorShape>, pub(super) cursor_shape_tx: std::sync::mpsc::SyncSender<crate::quic::CursorShape>,
/// Bumped on every ACCEPTED mode switch (the `clock_gen` pattern): the pump watches it and
/// resets the bitrate controller's mode-scoped learned state — the encoder ceiling / compute
/// knee it was taught belong to the OLD mode.
pub(super) mode_gen: Arc<AtomicU32>,
} }
impl ControlTask { impl ControlTask {
@@ -40,6 +44,7 @@ impl ControlTask {
clock_gen, clock_gen,
clip_event_tx, clip_event_tx,
cursor_shape_tx, cursor_shape_tx,
mode_gen,
} = self; } = self;
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every // Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after // CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
@@ -88,6 +93,7 @@ impl ControlTask {
if let Ok(ack) = Reconfigured::decode(&msg) { if let Ok(ack) = Reconfigured::decode(&msg) {
if ack.accepted { if ack.accepted {
*mode_slot.lock().unwrap() = ack.mode; *mode_slot.lock().unwrap() = ack.mode;
mode_gen.fetch_add(1, Ordering::Relaxed);
tracing::info!(mode = ?ack.mode, "host accepted mode switch"); tracing::info!(mode = ?ack.mode, "host accepted mode switch");
} else { } else {
tracing::warn!(active = ?ack.mode, "host rejected mode switch"); tracing::warn!(active = ?ack.mode, "host rejected mode switch");
+29 -4
View File
@@ -19,6 +19,12 @@ pub(super) struct DataPump {
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>, pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
pub(super) clock_gen: Arc<AtomicU32>, pub(super) clock_gen: Arc<AtomicU32>,
pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>, pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>,
/// Host encode-stage latency window accumulator (the ABR encode signal — see
/// [`super::super::frame_channel::EncodeLatAcc`]); fed by the datagram task.
pub(super) encode_lat: Arc<Mutex<super::super::frame_channel::EncodeLatAcc>>,
/// Accepted-mode-switch generation (control task bumps): a change resets the controller's
/// mode-scoped learned state ([`BitrateController::on_mode_switch`]).
pub(super) mode_gen: Arc<AtomicU32>,
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>, pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>, pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>, pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
@@ -41,6 +47,8 @@ impl DataPump {
clock_offset: pump_clock_offset, clock_offset: pump_clock_offset,
clock_gen: pump_clock_gen, clock_gen: pump_clock_gen,
decode_lat: pump_decode_lat, decode_lat: pump_decode_lat,
encode_lat: pump_encode_lat,
mode_gen: pump_mode_gen,
frames_dropped, frames_dropped,
fec_recovered, fec_recovered,
bitrate_ack, bitrate_ack,
@@ -127,6 +135,7 @@ impl DataPump {
let mut clock_detector_armed = true; let mut clock_detector_armed = true;
let mut resync_wanted = false; let mut resync_wanted = false;
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed); let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
let mut seen_mode_gen = pump_mode_gen.load(Ordering::Relaxed);
// Standing-latency bleed (see StandingLatency): the third detector, for the small, // Standing-latency bleed (see StandingLatency): the third detector, for the small,
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately // constant, loss-free OWD elevation the two jump-to-live detectors deliberately
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing // tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
@@ -338,9 +347,15 @@ impl DataPump {
); );
} }
} }
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then // Adaptive bitrate: an accepted mode switch first (it invalidates the
// feed the controller this window's congestion signals; a decision becomes a // mode-scoped learned state), then drain any host ack (its clamp is
// SetBitrate on the control stream. // authoritative), then feed the controller this window's congestion signals; a
// decision becomes a SetBitrate on the control stream.
let mg = pump_mode_gen.load(Ordering::Relaxed);
if mg != seen_mode_gen {
seen_mode_gen = mg;
abr.on_mode_switch();
}
if let Some(acked) = bitrate_ack.lock().unwrap().take() { if let Some(acked) = bitrate_ack.lock().unwrap().take() {
abr.on_ack(acked); abr.on_ack(acked);
} }
@@ -356,6 +371,14 @@ impl DataPump {
*acc = DecodeLatAcc::default(); *acc = DecodeLatAcc::default();
(count > 0).then(|| (sum / count as u64) as i64) (count > 0).then(|| (sum / count as u64) as i64)
}; };
// Same drain for the host-encode window (0xCF `encode_us` via the datagram
// task) — `None` on an old host that doesn't send stage timings.
let encode_mean_us = {
let mut acc = pump_encode_lat.lock().unwrap();
let (sum, count) = (acc.sum_us, acc.count);
*acc = Default::default();
(count > 0).then(|| (sum / count as u64) as i64)
};
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs // The window's ACTUAL delivered throughput — what the pipeline really carried, vs
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the // the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
// media rate the decoder ingests; acceptable for the climb gate / proven-mark // media rate the decoder ingests; acceptable for the climb gate / proven-mark
@@ -369,17 +392,19 @@ impl DataPump {
loss_ppm, loss_ppm,
owd_mean_us, owd_mean_us,
decode_mean_us, decode_mean_us,
encode_mean_us,
actual_kbps, actual_kbps,
flush_in_window, flush_in_window,
) { ) {
// Log the window's signals alongside the decision so an on-glass session can // Log the window's signals alongside the decision so an on-glass session can
// tell a decode-driven re-target (the new signal — decode_mean_us elevated with // tell a decode-/encode-driven re-target (the new signals — elevated with
// loss/OWD flat) from a network-driven one. // loss/OWD flat) from a network-driven one.
tracing::info!( tracing::info!(
kbps, kbps,
loss_ppm, loss_ppm,
owd_mean_us = owd_mean_us.unwrap_or(-1), owd_mean_us = owd_mean_us.unwrap_or(-1),
decode_mean_us = decode_mean_us.unwrap_or(-1), decode_mean_us = decode_mean_us.unwrap_or(-1),
encode_mean_us = encode_mean_us.unwrap_or(-1),
actual_kbps, actual_kbps,
flushed = flush_in_window, flushed = flush_in_window,
"adaptive bitrate: requesting encoder re-target" "adaptive bitrate: requesting encoder re-target"
@@ -14,6 +14,9 @@ pub(super) async fn run(
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>, hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>, hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>, host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
// `host_timing_tx`: that channel is the overlay's, lossy and embedder-drained.
encode_lat: Arc<Mutex<super::super::frame_channel::EncodeLatAcc>>,
cursor_state_tx: std::sync::mpsc::SyncSender<crate::quic::CursorState>, cursor_state_tx: std::sync::mpsc::SyncSender<crate::quic::CursorState>,
) { ) {
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state // Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
@@ -74,6 +77,11 @@ pub(super) async fn run(
} }
Some(&crate::quic::HOST_TIMING_MAGIC) => { Some(&crate::quic::HOST_TIMING_MAGIC) => {
if let Some(t) = crate::quic::decode_host_timing_datagram(&d) { if let Some(t) = crate::quic::decode_host_timing_datagram(&d) {
if let Some(s) = &t.stages {
let mut acc = encode_lat.lock().unwrap();
acc.sum_us += s.encode_us as u64;
acc.count += 1;
}
let _ = host_timing_tx.try_send(t); let _ = host_timing_tx.try_send(t);
} }
} }
@@ -70,6 +70,24 @@ fn apply_hdr(base: u32, hdr: bool) -> u32 {
/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the /// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`]. /// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
fn base_codec_mode_support() -> u32 { fn base_codec_mode_support() -> u32 {
// A GPU-less host encodes H.264 and nothing else (openh264), so advertising the superset made
// Moonlight negotiate HEVC/AV1 and the session then died at encoder open with "the software
// encoder emits H.264 only". `pf_encode::Codec::host_wire_caps` — the native plane's twin of
// this function — has gated on exactly this since it was written; this one never did.
//
// Deliberately a local gate rather than delegating wholesale to `host_wire_caps()`: that would
// be the drift-proof shape, but on Windows it re-runs the DXGI adapter enumeration several
// times per `/serverinfo` GET (the probe helpers each sample it), and this endpoint is polled.
// The software case is a plain config read, so it costs nothing here. (Follow-up worth doing:
// the static `MaxLumaPixelsHEVC` in the XML above still advertises an HEVC limit even when the
// mask drops HEVC — harmless, since Moonlight gates on the mask, but it is a second and now
// inconsistent advertisement.)
if matches!(
pf_host_config::config().encoder_pref.as_str(),
"software" | "sw" | "openh264"
) {
return super::SCM_H264;
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if crate::encode::linux_zero_copy_is_vaapi() { if crate::encode::linux_zero_copy_is_vaapi() {
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) { if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
@@ -686,6 +686,13 @@ fn stream_body(
true, true,
) )
.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
// (Windows direct-NVENC, which encodes the capturer's textures with no CopyResource) bounds
// itself by an env cap instead of the ring it is actually reading, and the capturer rotates a
// texture out from under a live encode — torn/mixed frames, never an error. The backend now
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
// and a deeper ring is free pipelining the fallback would forfeit.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only). // FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT") let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
.ok() .ok()
@@ -841,6 +848,8 @@ fn stream_body(
true, // metadata-cursor capture — see the first open true, // metadata-cursor capture — 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.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
supports_rfi = enc.caps().supports_rfi; supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe(); enc.request_keyframe();
last_keyframe = Some(Instant::now()); last_keyframe = Some(Instant::now());
+18 -13
View File
@@ -150,11 +150,13 @@ pub(crate) struct StreamInfo {
pub(crate) struct LocalSummary { pub(crate) struct LocalSummary {
/// Host version (mirrors `/health`). /// Host version (mirrors `/health`).
version: String, version: String,
/// True while the video stream thread is running. /// True while video is streaming on EITHER plane: the GameStream media pipeline, or a live
/// native (punktfunk/1) session — the default plane, invisible in the GameStream flag alone.
video_streaming: bool, video_streaming: bool,
/// True while the audio stream thread is running. /// True while audio is streaming on either plane (same rule as `video_streaming`).
audio_streaming: bool, audio_streaming: bool,
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop). /// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first
/// live native session. `null` when nothing is streaming.
session: Option<SessionInfo>, session: Option<SessionInfo>,
/// Number of pinned (paired) GameStream client certificates. /// Number of pinned (paired) GameStream client certificates.
paired_clients: u32, paired_clients: u32,
@@ -391,26 +393,27 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
) )
)] )]
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> { pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
// Native punktfunk/1 plane (the DEFAULT plane; GameStream is opt-in) — read ONCE and used for
// both the session card and the streaming flags below.
let native = crate::session_status::snapshot();
// GameStream launch, else the first live native session — so the tray reflects a native session // GameStream launch, else the first live native session — so the tray reflects a native session
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`). // too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
let session = st let session = st
.app .app
.launch .launch
.lock() .lock()
.unwrap() .unwrap_or_else(|e| e.into_inner())
.map(|l| SessionInfo { .map(|l| SessionInfo {
width: l.width, width: l.width,
height: l.height, height: l.height,
fps: l.fps, fps: l.fps,
}) })
.or_else(|| { .or_else(|| {
crate::session_status::snapshot() native.first().map(|s| SessionInfo {
.first() width: s.width,
.map(|s| SessionInfo { height: s.height,
width: s.width, fps: s.fps,
height: s.height, })
fps: s.fps,
})
}); });
let (native_paired_clients, pending_approvals) = st let (native_paired_clients, pending_approvals) = st
.native .native
@@ -419,8 +422,10 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
.unwrap_or((0, 0)); .unwrap_or((0, 0));
Json(LocalSummary { Json(LocalSummary {
version: env!("PUNKTFUNK_VERSION").into(), version: env!("PUNKTFUNK_VERSION").into(),
video_streaming: st.app.streaming.load(Ordering::SeqCst), // Either plane counts, like `/status`: reading only the GameStream flags made the tray say
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst), // "idle" (and wear the idle icon) through an entire native session.
video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(),
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(),
session, session,
paired_clients: st paired_clients: st
.app .app
+66
View File
@@ -286,11 +286,74 @@ async fn health_is_open_and_versioned() {
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION); assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
} }
/// Serializes the tests that read (or write) the process-global live-session registry
/// ([`crate::session_status`]): a session registered by one test would otherwise make a
/// concurrently running one see a stream it never started.
static SESSION_REGISTRY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// A `/local/summary` request from a loopback peer (the tray's own).
fn summary_req() -> axum::http::Request<Body> {
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
req
}
/// Registers a stand-in live native session; the returned guard removes it on drop.
fn fake_native_session(
width: u32,
height: u32,
fps: u32,
) -> crate::session_status::LiveSessionGuard {
let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64;
crate::session_status::register(
Arc::new(std::sync::atomic::AtomicU64::new(packed)),
Arc::new(std::sync::atomic::AtomicU32::new(20_000)),
Codec::H265,
Arc::new(std::sync::atomic::AtomicBool::new(false)),
Arc::new(std::sync::atomic::AtomicBool::new(false)),
"test-client".into(),
false,
Arc::new(std::sync::atomic::AtomicU32::new(0)),
Arc::new(std::sync::atomic::AtomicU32::new(0)),
)
}
/// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's
/// summary. The GameStream `streaming` flag stays false throughout such a session, and reading it
/// alone left the tray showing "idle" (with the idle icon) for the whole stream: exactly the blind
/// spot `/status` was fixed for in [`crate::session_status`], which `/local/summary` still had.
#[tokio::test]
async fn local_summary_reports_a_native_session_as_streaming() {
let _serial = SESSION_REGISTRY_LOCK.lock().await;
let app = test_app(test_state(), None);
let (status, body) = send(&app, summary_req()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["video_streaming"], false);
assert_eq!(body["session"], serde_json::Value::Null);
let session = fake_native_session(3840, 2160, 120);
let (_, body) = send(&app, summary_req()).await;
assert_eq!(body["video_streaming"], true, "native session: {body}");
assert_eq!(body["audio_streaming"], true, "native session: {body}");
assert_eq!(body["session"]["width"], 3840);
assert_eq!(body["session"]["height"], 2160);
assert_eq!(body["session"]["fps"], 120);
// Session over → back to idle.
drop(session);
let (_, body) = send(&app, summary_req()).await;
assert_eq!(body["video_streaming"], false);
assert_eq!(body["session"], serde_json::Value::Null);
}
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is /// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
/// rejected even though the route needs no bearer token, and the body never carries secret /// rejected even though the route needs no bearer token, and the body never carries secret
/// material (no PIN values, no fingerprints, no device names — counts/booleans only). /// material (no PIN values, no fingerprints, no device names — counts/booleans only).
#[tokio::test] #[tokio::test]
async fn local_summary_is_loopback_only_and_non_sensitive() { async fn local_summary_is_loopback_only_and_non_sensitive() {
let _serial = SESSION_REGISTRY_LOCK.lock().await;
let np = Arc::new( let np = Arc::new(
crate::native_pairing::NativePairing::load_with( crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))), Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))),
@@ -600,6 +663,7 @@ async fn compositors_lists_all_backends_with_flags() {
#[tokio::test] #[tokio::test]
async fn status_reflects_runtime_state() { async fn status_reflects_runtime_state() {
let _serial = SESSION_REGISTRY_LOCK.lock().await;
let state = test_state(); let state = test_state();
let app = test_app(state.clone(), None); let app = test_app(state.clone(), None);
@@ -756,6 +820,8 @@ async fn stop_session_clears_runtime_state() {
#[tokio::test] #[tokio::test]
async fn idr_requires_an_active_stream() { async fn idr_requires_an_active_stream() {
// A live native session (registered by a sibling test) is an active stream to this route.
let _serial = SESSION_REGISTRY_LOCK.lock().await;
let state = test_state(); let state = test_state();
let app = test_app(state.clone(), None); let app = test_app(state.clone(), None);
let post = || { let post = || {
+18
View File
@@ -985,6 +985,18 @@ async fn serve_session(
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC). // full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>(); let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>();
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>(); let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
// Encoder-truth bridge, data plane → control task (§ABR overdrive). The encode loop publishes
// here; the control task reads at `SetBitrate`-resolve time, so the ack the client's
// controller climbs from tracks what the encoder ACTUALLY does, not what was asked:
// - `live_bitrate`: the encoder's applied rate (kbps) — also the send pacer's/console's view.
// - `encoder_ceiling_kbps`: the discovered codec-level ceiling (0 = none discovered yet);
// resolves land at min(policy clamp, ceiling), so overshoots stop costing rebuilds.
// - `cadence_degraded`: encode can't hold the frame cadence — a climb is refused (acked at
// the current rate); the network isn't the bottleneck, more bits are anti-medicine.
// Plain atomics, not a channel: only the freshest value matters, and only at resolve time.
let live_bitrate = Arc::new(AtomicU32::new(welcome.bitrate_kbps));
let encoder_ceiling_kbps = Arc::new(AtomicU32::new(0));
let cadence_degraded = Arc::new(AtomicBool::new(false));
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>(); let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>(); let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept // Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
@@ -1036,6 +1048,9 @@ async fn serve_session(
live_reconfig_ok, live_reconfig_ok,
adaptive_fec, adaptive_fec,
session_bitrate_kbps, session_bitrate_kbps,
live_bitrate.clone(),
encoder_ceiling_kbps.clone(),
cadence_degraded.clone(),
fec_target_ctl, fec_target_ctl,
reconfig_tx, reconfig_tx,
keyframe_tx, keyframe_tx,
@@ -1429,6 +1444,9 @@ async fn serve_session(
bitrate_rx, bitrate_rx,
compositor, compositor,
bitrate_kbps, bitrate_kbps,
live_bitrate,
encoder_ceiling_kbps,
cadence_degraded,
bitrate_auto, bitrate_auto,
bit_depth, bit_depth,
chroma, chroma,
+32 -1
View File
@@ -22,6 +22,13 @@ pub(super) async fn run(
live_reconfig_ok: bool, live_reconfig_ok: bool,
adaptive_fec: bool, adaptive_fec: bool,
session_bitrate_kbps: u32, session_bitrate_kbps: u32,
// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied rate,
// its discovered codec-level ceiling (0 = unknown), and the "encode can't hold cadence"
// flag. Read at `SetBitrate`-resolve time so the ack — the base the client's controller
// climbs from — never promises a rate the encoder won't run at.
live_bitrate: Arc<AtomicU32>,
encoder_ceiling_kbps: Arc<AtomicU32>,
cadence_degraded: Arc<AtomicBool>,
fec_target_ctl: Arc<AtomicU8>, fec_target_ctl: Arc<AtomicU8>,
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>, reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
keyframe_tx: std::sync::mpsc::Sender<()>, keyframe_tx: std::sync::mpsc::Sender<()>,
@@ -161,7 +168,31 @@ pub(super) async fn run(
); );
session_bitrate_kbps session_bitrate_kbps
} else { } else {
resolve_bitrate_kbps(req.bitrate_kbps) let mut r = resolve_bitrate_kbps(req.bitrate_kbps);
// Encoder truth (§ABR overdrive): the ack below is the base the
// client's controller climbs from, so it must not promise past the
// encoder's discovered codec-level ceiling — the pre-fix path acked
// 1.01 Gbps while the ASIC ran 794 Mbps, and the controller climbed
// from the phantom number forever (a ~0.6 s rebuild + IDR per step).
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
if ceiling != 0 && r > ceiling {
r = ceiling;
}
// Climb refusal while encode can't hold cadence: on a fat LAN no
// network signal ever stops the climb, and past the compute knee more
// bits only deepen the miss. Resolve a CLIMB to the current applied
// rate (descents pass — they're the cure); the short ack teaches the
// client controller its ceiling.
let live = live_bitrate.load(Ordering::Relaxed);
if cadence_degraded.load(Ordering::Relaxed) && live != 0 && r > live {
tracing::info!(
requested_kbps = req.bitrate_kbps,
held_kbps = live,
"bitrate climb refused — encode is behind cadence"
);
r = live;
}
r
}; };
tracing::debug!( tracing::debug!(
requested_kbps = req.bitrate_kbps, requested_kbps = req.bitrate_kbps,
+163 -14
View File
@@ -909,6 +909,20 @@ pub(super) struct SessionContext {
pub(super) compositor: crate::vdisplay::Compositor, pub(super) compositor: crate::vdisplay::Compositor,
/// Negotiated encoder bitrate (kbps). /// Negotiated encoder bitrate (kbps).
pub(super) bitrate_kbps: u32, pub(super) bitrate_kbps: u32,
/// The encoder's live APPLIED rate (kbps) — shared with the send pacer, the web console, the
/// mgmt registry AND the control task (which acks climbs against it). The encode loop stores
/// `Encoder::applied_bitrate_bps` here after every apply, so everything downstream tracks
/// what the ASIC really targets, not what was requested (§ABR overdrive).
pub(super) live_bitrate: Arc<AtomicU32>,
/// The encoder's discovered codec-level bitrate ceiling (kbps; 0 = none discovered): written
/// when an apply comes back short, read by this loop (pre-clamp incoming requests — a
/// request already AT the ceiling then costs nothing) and by the control task (truthful
/// acks from the first post-discovery request).
pub(super) encoder_ceiling_kbps: Arc<AtomicU32>,
/// "Encode can't hold the frame cadence" (the escalation leaky bucket is elevated, or the
/// session escalated): while set, the control task refuses bitrate CLIMBS — the network
/// isn't the bottleneck, feeding the encoder more bits deepens the miss.
pub(super) cadence_degraded: Arc<AtomicBool>,
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from /// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of /// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it /// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
@@ -1035,6 +1049,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
bitrate_rx, bitrate_rx,
compositor, compositor,
mut bitrate_kbps, mut bitrate_kbps,
live_bitrate,
encoder_ceiling_kbps,
cadence_degraded,
bitrate_auto, bitrate_auto,
bit_depth, bit_depth,
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here. // The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
@@ -1254,9 +1271,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind, // The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream). // so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3); let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive // `live_bitrate` (SessionContext) is shared with the send thread's stats sample AND the
// bitrate change (bitrate_rx below) updates it so the console shows the actual target. // control task: a mid-stream adaptive bitrate change (bitrate_rx below) stores the
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps)); // encoder-APPLIED rate, so the console, pacer and climb-refusal acks all see the truth.
// Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so // Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so
// a stats capture armed after a resize registers the real mode. Seeded with the refresh the // a stats capture armed after a resize registers the real mode. Seeded with the refresh the
// initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual // initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual
@@ -1436,6 +1453,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
const DEPTH_ESCALATE: u32 = 20; const DEPTH_ESCALATE: u32 = 20;
const DEPTH_BEHIND_CAP: u32 = 60; const DEPTH_BEHIND_CAP: u32 = 60;
const DEPTH_WARMUP_FRAMES: u64 = 60; const DEPTH_WARMUP_FRAMES: u64 = 60;
// Half the escalate threshold: ~10 net behind-frames is already solid "the encoder, not the
// network, is the bottleneck" evidence — enough to flag `cadence_degraded` (the control task
// then refuses bitrate CLIMBS) well before the session pays a latency escalation for it.
const DEPTH_DEGRADE: u32 = 10;
// De-escalation (the escalate-and-hold v1's missing half): a sustained clean run at the
// escalated setting (~5 s at 120 fps, every frame on cadence) earns ONE attempt at winding
// back — reverse order of the escalation, pipelined retrieve first (its rebuild restores
// sub-frame streaming and the IO-stream binding), then capture depth back to 1. Each
// attempt costs the wind-back rebuild's IDR, so attempts are paced by an exponential
// backoff (1 → 5 → 25 min, capped) — a workload that genuinely needs the escalation
// converges to keeping it, but NEVER a permanent latch: a latch plus the ABR sawtooth
// pinned sessions at the floor with the escalation stuck.
const DEESCALATE_CLEAN_FRAMES: u32 = 600;
const DEESCALATE_BACKOFF_START: std::time::Duration = std::time::Duration::from_secs(60);
const DEESCALATE_BACKOFF_MAX: std::time::Duration = std::time::Duration::from_secs(25 * 60);
let mut pipelined_active = false;
let mut deescalating = false;
let mut ahead_run: u32 = 0;
let mut deescalate_not_before: Option<std::time::Instant> = None;
let mut deescalate_backoff = DEESCALATE_BACKOFF_START;
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in // Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
// place — a different compositor at the SAME client mode — keeping the Session + send thread // place — a different compositor at the SAME client mode — keeping the Session + send thread
@@ -1674,15 +1711,49 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
while let Ok(k) = bitrate_rx.try_recv() { while let Ok(k) = bitrate_rx.try_recv() {
want_kbps = Some(k); want_kbps = Some(k);
} }
// Known-ceiling pre-clamp (§ABR overdrive): once the encoder's codec-level ceiling is
// known, resolve an over-asking request HERE — a request that clamps to the rate we're
// already at then skips the whole apply, where the pre-fix path bounced every overshoot
// off the driver into a full rebuild + IDR (~0.6 s each, four in one logged minute).
// (The control task clamps its acks from the same atomic; this covers requests already
// in flight when the ceiling was discovered.)
if let Some(k) = want_kbps.as_mut() {
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
if ceiling != 0 && *k > ceiling {
tracing::info!(
requested_kbps = *k,
ceiling_kbps = ceiling,
"bitrate request clamped to the known encoder ceiling"
);
*k = ceiling;
}
}
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) { if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
if enc.reconfigure_bitrate(new_kbps as u64 * 1000) { if enc.reconfigure_bitrate(new_kbps as u64 * 1000) {
// Adopt the encoder's post-clamp truth, not the request: it feeds the send
// pacer, the console/mgmt view and the control task's acks, and a short apply
// teaches the ceiling used above.
let applied_kbps = enc
.applied_bitrate_bps()
.map(|b| (b / 1000) as u32)
.filter(|&k| k > 0)
.unwrap_or(new_kbps);
tracing::info!( tracing::info!(
from_kbps = bitrate_kbps, from_kbps = bitrate_kbps,
to_kbps = new_kbps, to_kbps = applied_kbps,
requested_kbps = new_kbps,
"encoder bitrate reconfigured in place (adaptive bitrate — no IDR)" "encoder bitrate reconfigured in place (adaptive bitrate — no IDR)"
); );
bitrate_kbps = new_kbps; if applied_kbps < new_kbps {
live_bitrate.store(new_kbps, Ordering::Relaxed); encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
}
if applied_kbps < bitrate_kbps {
// Down-step: the behind-cadence backlog was scored against the old,
// heavier rate — clean slate so it can't feed a false escalation.
behind_score = 0;
}
bitrate_kbps = applied_kbps;
live_bitrate.store(applied_kbps, Ordering::Relaxed);
// Same encoder, same stream: the in-flight AUs and the wire-index prediction // Same encoder, same stream: the in-flight AUs and the wire-index prediction
// stay valid — no inflight forfeit, no IDR-cooldown anchor. // stay valid — no inflight forfeit, no IDR-cooldown anchor.
} else { } else {
@@ -1702,9 +1773,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
plan.cursor_blend, plan.cursor_blend,
) { ) {
Ok(mut new_enc) => { Ok(mut new_enc) => {
// The fresh encoder may have clamped to its codec-level ceiling —
// adopt (and record) ITS rate, not the request; see the in-place arm.
let applied_kbps = new_enc
.applied_bitrate_bps()
.map(|b| (b / 1000) as u32)
.filter(|&k| k > 0)
.unwrap_or(new_kbps);
tracing::info!( tracing::info!(
from_kbps = bitrate_kbps, from_kbps = bitrate_kbps,
to_kbps = new_kbps, to_kbps = applied_kbps,
requested_kbps = new_kbps,
"encoder rebuilt at new bitrate (adaptive bitrate)" "encoder rebuilt at new bitrate (adaptive bitrate)"
); );
if let Some(c) = plan.wire_chunk { if let Some(c) = plan.wire_chunk {
@@ -1714,8 +1793,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// directly so an ABR rebuild re-establishes the bound immediately.) // directly so an ABR rebuild re-establishes the bound immediately.)
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1)); new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
enc = new_enc; enc = new_enc;
bitrate_kbps = new_kbps; if applied_kbps < new_kbps {
live_bitrate.store(new_kbps, Ordering::Relaxed); encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
}
bitrate_kbps = applied_kbps;
live_bitrate.store(applied_kbps, Ordering::Relaxed);
// The owed AUs died with the old encoder — same bookkeeping as a // The owed AUs died with the old encoder — same bookkeeping as a
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the // mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
// IDR cooldown too. // IDR cooldown too.
@@ -1723,6 +1805,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
last_au_at = std::time::Instant::now(); last_au_at = std::time::Instant::now();
encoder_resets = 0; encoder_resets = 0;
last_forced_idr = Some(std::time::Instant::now()); last_forced_idr = Some(std::time::Instant::now());
// The rebuild stall itself (~0.6 s ≈ 70 missed deadlines at 120 fps,
// 3.5× the escalate threshold) must not feed the contention
// escalation — clean slate + re-run the warmup before judging again.
behind_score = 0;
depth_frames = 0;
ahead_run = 0;
} }
Err(e) => { Err(e) => {
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps, tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
@@ -2547,7 +2635,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// two-thread lock moves the encode wait off this loop so capture/submit keep cadence, // two-thread lock moves the encode wait off this loop so capture/submit keep cadence,
// at ~one tick of AU latency. `enc.set_pipelined` may decline (unsupported backend or // at ~one tick of AU latency. `enc.set_pipelined` may decline (unsupported backend or
// an explicit PUNKTFUNK_NVENC_ASYNC=0); either way it is asked exactly once. // an explicit PUNKTFUNK_NVENC_ASYNC=0); either way it is asked exactly once.
if idd_adaptive_enabled() && (cur_depth < max_depth || !pipeline_asked) { if idd_adaptive_enabled() {
depth_frames += 1; depth_frames += 1;
if depth_frames > DEPTH_WARMUP_FRAMES { if depth_frames > DEPTH_WARMUP_FRAMES {
let behind = std::time::Instant::now() >= next; let behind = std::time::Instant::now() >= next;
@@ -2556,28 +2644,89 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
} else { } else {
behind_score.saturating_sub(1) behind_score.saturating_sub(1)
}; };
if behind_score >= DEPTH_ESCALATE { let escalated = cur_depth > 1 || pipelined_active || deescalating;
// Export "encode can't hold cadence" for the control task's climb refusal.
// An escalated session stays flagged even with the bucket drained: its climb
// headroom is spent, and letting climbs resume would saw against the
// escalation and starve the de-escalation clean run below.
cadence_degraded.store(
escalated || behind_score >= DEPTH_DEGRADE,
Ordering::Relaxed,
);
if deescalating {
// A requested wind-back completes at the encoder's drained safe point —
// poll it (the call is a cheap latch check until then).
if !enc.set_pipelined(false) {
deescalating = false;
pipelined_active = false;
// Re-arm the ask: a future sustained overrun may escalate again (the
// backoff below paces how soon another wind-back may follow it).
pipeline_asked = false;
tracing::info!(
"encoder pipelined retrieve de-escalated — sync retrieve (and \
sub-frame streaming, where armed) restored; re-monitoring cadence"
);
// The wind-back rebuild's own stall must not re-escalate on the spot.
behind_score = 0;
depth_frames = 0;
ahead_run = 0;
}
} else if behind_score >= DEPTH_ESCALATE
&& (cur_depth < max_depth || !pipeline_asked)
{
if cur_depth < max_depth { if cur_depth < max_depth {
cur_depth = max_depth; cur_depth = max_depth;
tracing::info!( tracing::info!(
depth = cur_depth, depth = cur_depth,
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \ "IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
(GPU contention); pipelining for the rest of the session (latency \ (GPU contention); pipelining until cadence holds clean (latency \
trade for throughput)" trade for throughput)"
); );
} else { } else {
pipeline_asked = true; pipeline_asked = true;
if enc.set_pipelined(true) { pipelined_active = enc.set_pipelined(true);
if pipelined_active {
tracing::info!( tracing::info!(
"encoder pipelined retrieve escalated — encode can't hold \ "encoder pipelined retrieve escalated — encode can't hold \
cadence and the capturer has no depth to give; the encode wait \ cadence and the capturer has no depth to give; the encode wait \
moves off the loop for the rest of the session (latency trade \ moves off the loop until cadence holds clean (latency trade \
for throughput)" for throughput)"
); );
} }
} }
// Give the action time to take effect before judging again. // Give the action time to take effect before judging again.
behind_score = 0; behind_score = 0;
ahead_run = 0;
} else if escalated {
// De-escalation: a sustained every-frame-on-cadence run at the escalated
// setting is the evidence the contention passed (a lower ABR rate, the
// game scene lightened) — wind back in reverse order, paced by the
// exponential backoff (see the consts above).
ahead_run = if behind { 0 } else { ahead_run + 1 };
if ahead_run >= DEESCALATE_CLEAN_FRAMES
&& deescalate_not_before.is_none_or(|t| std::time::Instant::now() >= t)
{
ahead_run = 0;
deescalate_not_before =
Some(std::time::Instant::now() + deescalate_backoff);
deescalate_backoff = (deescalate_backoff * 5).min(DEESCALATE_BACKOFF_MAX);
if pipelined_active {
tracing::info!(
"cadence held clean while escalated — winding the pipelined \
retrieve back (latency recovery; costs one IDR)"
);
deescalating = true;
} else if cur_depth > 1 {
cur_depth = 1;
tracing::info!(
depth = cur_depth,
"IDD pipeline depth de-escalated — cadence held clean at the \
escalated depth (latency recovery)"
);
behind_score = 0;
depth_frames = 0;
}
}
} }
} }
} }
+108 -32
View File
@@ -431,16 +431,69 @@ fn web_setup(args: &[String]) -> Result<()> {
) { ) {
eprintln!("warning: could not add the firewall rule for TCP 47992"); eprintln!("warning: could not add the firewall rule for TCP 47992");
} }
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise) // 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
for _ in 0..30 { //
if token_path.exists() { // `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
break; // host writes them in that order: the token during argument parsing (`main::parse_serve`,
// milliseconds after `serve` starts), the cert only once `serve` reaches
// `gamestream::cert::ServerIdentity::load_or_create` - behind a pure-Rust RSA-2048 keygen
// whose prime search has a multi-second tail. Gating on the token alone therefore fired
// `schtasks /run` while that keygen was still running: the launcher exited 1, and since the
// task carries no trigger other than boot (and Task Scheduler's restart-on-failure does not
// reliably fire for a plain non-zero exit code), a freshly installed console stayed down
// until the next reboot. Gate on `cert.pem` - written LAST, after `key.pem` - so all three
// files are on disk before the first start.
let cert_path = data_dir.join("cert.pem");
if !wait_for_files(&[token_path.as_path(), cert_path.as_path()], 90) {
eprintln!(
"warning: the host service has not written {} + {} yet - not starting the console now; \
it will start at the next boot (or run: schtasks /run /tn {WEB_TASK})",
token_path.display(),
cert_path.display()
);
println!("web console set up (https://<host-ip>:47992)");
return Ok(());
}
if start_web_task() {
println!("web console set up + started (https://<host-ip>:47992)");
} else {
// Never claim "started" when it isn't: the launcher's own diagnostics go to a task with no
// console, so this warning is the only trace an operator gets at install time.
eprintln!(
"warning: the {WEB_TASK} task did not bring up a listener on :47992 - check its Last Run \
Result in Task Scheduler; the console will be retried at the next boot"
);
println!("web console set up (https://<host-ip>:47992)");
}
Ok(())
}
/// Poll until every path exists, or `secs` elapse. Returns whether they all showed up.
fn wait_for_files(paths: &[&Path], secs: u64) -> bool {
for _ in 0..secs {
if paths.iter().all(|p| p.exists()) {
return true;
} }
std::thread::sleep(std::time::Duration::from_secs(1)); std::thread::sleep(std::time::Duration::from_secs(1));
} }
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]); paths.iter().all(|p| p.exists())
println!("web console set up + started (https://<host-ip>:47992)"); }
Ok(())
/// `schtasks /run` + verify: the command reports only that a start was *attempted*, so poll for the
/// console's own listener and retry a couple of times before giving up. Returns whether :47992 ended
/// up listening.
fn start_web_task() -> bool {
for _ in 0..3 {
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
// Bun binds the port a second or two after launch on a warm box; allow for a cold one.
for _ in 0..10 {
std::thread::sleep(std::time::Duration::from_secs(1));
if !web_listener_pids().is_empty() {
return true;
}
}
}
false
} }
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback. /// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
@@ -505,30 +558,57 @@ fn random_password() -> String {
/// ("LISTENING"/"ABHOEREN"/...) is never parsed. /// ("LISTENING"/"ABHOEREN"/...) is never parsed.
fn stop_web_console() { fn stop_web_console() {
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]); run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() { for pid in web_listener_pids() {
let toks: Vec<&str> = line.split_whitespace().collect(); run_quiet("taskkill", &["/PID", &pid, "/F"]);
if toks.len() >= 5
&& toks[0].eq_ignore_ascii_case("tcp")
&& toks[1].ends_with(":47992")
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0")
{
let pid = toks[toks.len() - 1];
if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) {
run_quiet("taskkill", &["/PID", pid, "/F"]);
}
}
} }
std::thread::sleep(std::time::Duration::from_secs(1)); std::thread::sleep(std::time::Duration::from_secs(1));
} }
/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`, /// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM. fn web_listener_pids() -> Vec<String> {
run_capture("netstat", &["-ano", "-p", "tcp"])
.lines()
.filter_map(|line| {
let toks: Vec<&str> = line.split_whitespace().collect();
let listening = toks.len() >= 5
&& toks[0].eq_ignore_ascii_case("tcp")
&& toks[1].ends_with(":47992")
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0");
let pid = *toks.last()?;
(listening && !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
.then(|| pid.to_string())
})
.collect()
}
/// Register the boot+logon/SYSTEM/restart-on-failure task via a generated Task Scheduler XML
/// (`schtasks /xml`, no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
///
/// Two triggers, because boot alone left too many holes: a console that lost the install-time start
/// (or a box where the host service was started only later) had to wait for a full reboot. Logon is
/// free insurance - `MultipleInstancesPolicy=IgnoreNew` makes a redundant start a no-op. If a
/// Task Scheduler build rejects the two-trigger XML we fall back to the boot-only form rather than
/// fail registration outright (no task at all is strictly worse than a boot-only task).
fn register_web_task(cmd: &Path) -> Result<()> { fn register_web_task(cmd: &Path) -> Result<()> {
let cmd_xml = xml_escape(&cmd.to_string_lossy());
let boot = "<BootTrigger><Enabled>true</Enabled></BootTrigger>";
let boot_and_logon = "<BootTrigger><Enabled>true</Enabled></BootTrigger>\
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>";
if try_register_web_task(&cmd_xml, boot_and_logon) || try_register_web_task(&cmd_xml, boot) {
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
Ok(())
} else {
bail!("schtasks /create {WEB_TASK} failed")
}
}
/// One `schtasks /create /xml` attempt with the given `<Triggers>` body. Returns whether it took.
fn try_register_web_task(cmd_xml: &str, triggers: &str) -> bool {
let xml = format!( let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\ "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\ <Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\ <RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\
<Triggers><BootTrigger><Enabled>true</Enabled></BootTrigger></Triggers>\n\ <Triggers>{triggers}</Triggers>\n\
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\ <Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
<Settings>\n\ <Settings>\n\
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\ <RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
</Settings>\n\ </Settings>\n\
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\ <Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
</Task>", </Task>"
xml_escape(&cmd.to_string_lossy())
); );
let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml"); let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml");
write_utf16le_bom(&xml_path, &xml)?; if write_utf16le_bom(&xml_path, &xml).is_err() {
return false;
}
let ok = run_quiet( let ok = run_quiet(
"schtasks", "schtasks",
&[ &[
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
], ],
); );
let _ = std::fs::remove_file(&xml_path); let _ = std::fs::remove_file(&xml_path);
if ok { ok
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
Ok(())
} else {
bail!("schtasks /create {WEB_TASK} failed")
}
} }
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> { fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
+27 -9
View File
@@ -15,8 +15,8 @@ use crate::status::{self, Poller, TrayStatus};
struct HostTray { struct HostTray {
status: TrayStatus, status: TrayStatus,
web_port: u16, web_port: u16,
/// The console answered the poller's live loopback probe — the "Open web console" entry is /// The console answered the poller's live loopback probe — labels the (always present) "Open
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not). /// web console" entry; it never hides it.
web_console: bool, web_console: bool,
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions /// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
/// force an immediate re-poll instead of waiting out the cadence. /// force an immediate re-poll instead of waiting out the cadence.
@@ -33,8 +33,10 @@ impl HostTray {
} }
} }
fn open_console(&self) { /// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page
let url = format!("https://127.0.0.1:{}", self.web_port); /// the menu entry promised — the pairing queue, the virtual displays.
fn open_console(&self, path: &str) {
let url = format!("https://127.0.0.1:{}/{path}", self.web_port);
let _ = std::process::Command::new("xdg-open").arg(url).spawn(); let _ = std::process::Command::new("xdg-open").arg(url).spawn();
} }
} }
@@ -107,17 +109,33 @@ impl ksni::Tray for HostTray {
} }
.into(), .into(),
MenuItem::Separator, MenuItem::Separator,
// Always present — it is the reason most people open this menu. When the loopback
// probe says the console isn't answering, the label says so rather than the entry
// disappearing (a menu missing its main action reads as a broken tray).
StandardItem { StandardItem {
label: "Open web console".into(), label: if self.web_console {
visible: self.web_console, "Open web console".to_string()
activate: Box::new(|t: &mut Self| t.open_console()), } else {
"Open web console (not responding)".to_string()
},
activate: Box::new(|t: &mut Self| t.open_console("")),
..Default::default() ..Default::default()
} }
.into(), .into(),
StandardItem { StandardItem {
label: "Approve pairing request…".into(), label: "Approve pairing request…".into(),
visible: self.web_console && self.status.pairing_attention(), visible: self.status.pairing_attention(),
activate: Box::new(|t: &mut Self| t.open_console()), activate: Box::new(|t: &mut Self| t.open_console("pairing")),
..Default::default()
}
.into(),
StandardItem {
label: match self.status.kept_displays() {
1 => "Release kept display…".to_string(),
n => format!("Release {n} kept displays…"),
},
visible: self.status.kept_displays() > 0,
activate: Box::new(|t: &mut Self| t.open_console("displays")),
..Default::default() ..Default::default()
} }
.into(), .into(),
+59 -6
View File
@@ -75,7 +75,7 @@ impl TrayStatus {
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(), TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"), TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
TrayStatus::Running(s) => { TrayStatus::Running(s) => {
let base = match (&s.session, s.video_streaming) { let base = match (&s.session, self.is_streaming()) {
(Some(sess), true) => format!( (Some(sess), true) => format!(
"punktfunk host {} — streaming {}×{}@{}", "punktfunk host {} — streaming {}×{}@{}",
s.version, sess.width, sess.height, sess.fps s.version, sess.width, sess.height, sess.fps
@@ -113,8 +113,23 @@ impl TrayStatus {
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty()) matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
} }
/// A client is streaming: the host's flag, OR a live session in the summary.
///
/// The session is checked too because a host from before the `get_local_summary` fix raised
/// `video_streaming` from the GameStream plane only — through a whole session on the native
/// (default) plane it said false while still reporting that session's mode, which is what left
/// the tray sitting at "idle" mid-stream. This keeps a newer tray honest against such a host.
pub fn is_streaming(&self) -> bool { pub fn is_streaming(&self) -> bool {
matches!(self, TrayStatus::Running(s) if s.video_streaming) matches!(self, TrayStatus::Running(s) if s.video_streaming || s.session.is_some())
}
/// Virtual displays held with no live session (lingering/pinned) — offered as a one-click
/// release in the menu, since holding one can also be keeping physical monitors dark.
pub fn kept_displays(&self) -> u32 {
match self {
TrayStatus::Running(s) => s.kept_displays,
_ => 0,
}
} }
/// A pairing attempt is waiting on the operator (shown as an extra menu entry). /// A pairing attempt is waiting on the operator (shown as an extra menu entry).
@@ -156,9 +171,11 @@ struct Shared {
impl Poller { impl Poller {
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever /// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
/// either changes. `console_up` is a live loopback probe of the web console on `web_port` /// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run /// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry
/// from a repo checkout, and shows a dead entry while an installed console is still starting). /// is the tray's most-wanted action, and a menu that silently drops it — because the console
/// was still starting, or the probe timed out — is indistinguishable from a tray that never
/// had one.
pub fn spawn( pub fn spawn(
mgmt_addr: String, mgmt_addr: String,
mgmt_port: u16, mgmt_port: u16,
@@ -203,6 +220,10 @@ fn poll_loop(
// When the summary became unreachable while the service was running (grace anchor). // When the summary became unreachable while the service was running (grace anchor).
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind). // Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
let mut unreachable_since: Option<Instant> = None; let mut unreachable_since: Option<Instant> = None;
// Consecutive failed console probes. One miss is not "down": the console is a bun/Nitro SSR
// whose cold first render can outrun this agent's 2 s timeout, and a menu entry that changes
// its label every few seconds reads as broken.
let mut console_misses = 0u32;
loop { loop {
let svc = probe_service(); let svc = probe_service();
let summary = if svc == ServiceState::Running { let summary = if svc == ServiceState::Running {
@@ -219,7 +240,13 @@ fn poll_loop(
}; };
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE); let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
let status = map_status(&svc, summary, grace_expired); let status = map_status(&svc, summary, grace_expired);
let console_up = probe_console(&agent, &console_url); let console_up = if probe_console(&agent, &console_url) {
console_misses = 0;
true
} else {
console_misses += 1;
console_misses < 2
};
if last.as_ref() != Some(&(status.clone(), console_up)) { if last.as_ref() != Some(&(status.clone(), console_up)) {
on_change(status.clone(), console_up); on_change(status.clone(), console_up);
last = Some((status, console_up)); last = Some((status, console_up));
@@ -478,6 +505,32 @@ mod tests {
.contains("status unavailable")); .contains("status unavailable"));
} }
/// A live session means streaming even if the host's flag says otherwise — a host from before
/// the `get_local_summary` fix only raised `video_streaming` for the GameStream plane, so a
/// native session showed as "idle" with its own mode printed next to it.
#[test]
fn a_live_session_reads_as_streaming_without_the_flag() {
let mut s = summary(true);
s.video_streaming = false; // pre-fix host, native session
let st = TrayStatus::Running(s);
assert!(st.is_streaming());
assert_eq!(
st.headline(),
"punktfunk host 0.5.1 — streaming 2560×1440@120"
);
// No session and no flag is still idle.
assert!(!TrayStatus::Running(summary(false)).is_streaming());
}
#[test]
fn kept_displays_are_reported_for_the_release_action() {
assert_eq!(TrayStatus::Running(summary(false)).kept_displays(), 0);
let mut s = summary(false);
s.kept_displays = 2;
assert_eq!(TrayStatus::Running(s).kept_displays(), 2);
assert_eq!(TrayStatus::Degraded.kept_displays(), 0);
}
#[test] #[test]
fn pairing_attention_flags() { fn pairing_attention_flags() {
let mut s = summary(false); let mut s = summary(false);
+30 -12
View File
@@ -51,6 +51,7 @@ const IDM_RESTART: usize = 0x0104;
const IDM_LOGS: usize = 0x0105; const IDM_LOGS: usize = 0x0105;
const IDM_EXIT: usize = 0x0106; const IDM_EXIT: usize = 0x0106;
const IDM_PAIRING: usize = 0x0107; const IDM_PAIRING: usize = 0x0107;
const IDM_DISPLAYS: usize = 0x0108;
/// Icon resource ordinals (embedded by build.rs). /// Icon resource ordinals (embedded by build.rs).
fn icon_ordinal(status: &TrayStatus) -> u16 { fn icon_ordinal(status: &TrayStatus) -> u16 {
@@ -73,8 +74,9 @@ struct App {
taskbar_created: u32, taskbar_created: u32,
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`). /// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
host_exe: Option<std::path::PathBuf>, host_exe: Option<std::path::PathBuf>,
/// The console answered the poller's live loopback probe — the "Open web console" entry is /// The console answered the poller's live loopback probe. Drives the label of the (always
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not). /// present) "Open web console" entry, and whether a left-click on the icon opens the console
/// or falls back to showing the menu.
web_console: AtomicBool, web_console: AtomicBool,
web_port: u16, web_port: u16,
} }
@@ -305,14 +307,24 @@ fn show_menu(hwnd: HWND) {
}; };
add(IDM_HEADER, &status.headline(), true); add(IDM_HEADER, &status.headline(), true);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
// The console entry is ALWAYS here — it is the reason most people open this menu, and
// left-clicking the icon is not a discoverable substitute. When the loopback probe says
// the console isn't answering the label says so, rather than the entry vanishing.
if app().web_console.load(Ordering::SeqCst) { if app().web_console.load(Ordering::SeqCst) {
add(IDM_OPEN_WEB, "Open web console", false); add(IDM_OPEN_WEB, "Open web console", false);
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0); } else {
if status.pairing_attention() { add(IDM_OPEN_WEB, "Open web console (not responding)", false);
add(IDM_PAIRING, "Approve pairing request…", false);
}
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
} }
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
if status.pairing_attention() {
add(IDM_PAIRING, "Approve pairing request…", false);
}
match status.kept_displays() {
0 => {}
1 => add(IDM_DISPLAYS, "Release kept display…", false),
n => add(IDM_DISPLAYS, &format!("Release {n} kept displays…"), false),
}
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
if can_control { if can_control {
if startable { if startable {
add(IDM_START, "Start host", false); add(IDM_START, "Start host", false);
@@ -385,8 +397,13 @@ fn elevate_service(hwnd: HWND, verb: &str) {
} }
} }
fn open_web_console(hwnd: HWND) { /// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
shell_open(hwnd, &format!("https://localhost:{}", app().web_port)); /// menu entry promised — the pairing queue, the virtual displays — instead of the dashboard.
fn open_web_console(hwnd: HWND, path: &str) {
shell_open(
hwnd,
&format!("https://localhost:{}/{path}", app().web_port),
);
} }
fn open_logs(hwnd: HWND) { fn open_logs(hwnd: HWND) {
@@ -416,7 +433,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
WM_CONTEXTMENU => show_menu(hwnd), WM_CONTEXTMENU => show_menu(hwnd),
x if x == NIN_SELECT || x == NIN_KEYSELECT => { x if x == NIN_SELECT || x == NIN_KEYSELECT => {
if app.web_console.load(Ordering::SeqCst) { if app.web_console.load(Ordering::SeqCst) {
open_web_console(hwnd); open_web_console(hwnd, "");
} else { } else {
show_menu(hwnd); show_menu(hwnd);
} }
@@ -427,8 +444,9 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
} }
WM_COMMAND => { WM_COMMAND => {
match (wparam.0) & 0xffff { match (wparam.0) & 0xffff {
IDM_OPEN_WEB => open_web_console(hwnd), IDM_OPEN_WEB => open_web_console(hwnd, ""),
IDM_PAIRING => open_web_console(hwnd), IDM_PAIRING => open_web_console(hwnd, "pairing"),
IDM_DISPLAYS => open_web_console(hwnd, "displays"),
IDM_START => elevate_service(hwnd, "start"), IDM_START => elevate_service(hwnd, "start"),
IDM_STOP => elevate_service(hwnd, "stop"), IDM_STOP => elevate_service(hwnd, "stop"),
IDM_RESTART => elevate_service(hwnd, "restart"), IDM_RESTART => elevate_service(hwnd, "restart"),
+19 -10
View File
@@ -40,8 +40,16 @@ depends on the display manager driving the autologin:
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup. - **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager - **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
itself for the length of the stream and restart it afterwards, which needs privilege. Allow it itself for the length of the stream and restart it afterwards, which needs privilege. The
with a polkit rule (adjust the unit and user names to your box): packages ship that privilege: a root helper (`/usr/libexec/punktfunk/pf-dm-helper`) behind its
own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically when the plain
`systemctl` verbs are denied — no setup. The helper only stops/restores the unit the
`display-manager.service` symlink points at, the same class of local-seat operation these
distros already authorize for their own session switcher (Nobara's `os-session-select`).
Installed from a tarball, or prefer not to ship the `allow_any` action? Remove the `.policy`
file and use a polkit rule scoped to your user instead (adjust the unit and user names to your
box) — the host tries the plain verbs first, so the rule takes precedence:
```js ```js
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules // /etc/polkit-1/rules.d/49-punktfunk-dm.rules
@@ -54,15 +62,16 @@ depends on the display manager driving the autologin:
}); });
``` ```
Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
display manager. If the display-manager restart ever loses its privilege mid-restore, the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the what happened; check the host log for "managed takeover unavailable"). If the display-manager
fallback. restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
[Configuration](/docs/configuration)) is fired as the fallback.
With the rule in place the **in-stream session switch round-trips** in managed mode: Steam's With the takeover authorized the **in-stream session switch round-trips** in managed mode:
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again. and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
## Session following ## Session following
+1 -1
View File
@@ -66,7 +66,7 @@ the Windows specifics follow.
The installer also sets up the **web management console** (status, paired devices, the PIN pairing The installer also sets up the **web management console** (status, paired devices, the PIN pairing
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
**`https://<this-PC>:47992`**, starting at boot. **`https://<this-PC>:47992`**, starting at boot and at sign-in.
#### Console login password #### Console login password
+8
View File
@@ -129,6 +129,14 @@ package_punktfunk-host() {
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host" install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID) # /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules" install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
# stop/restore the display manager for the stream. Arch has no /usr/libexec — install under
# /usr/lib/punktfunk and rewrite the policy's exec.path annotation to match (the host probes both).
install -Dm0755 "$R/scripts/pf-dm-helper" "$pkgdir/usr/lib/punktfunk/pf-dm-helper"
install -Dm0644 "$R/scripts/io.unom.punktfunk.dm-helper.policy" \
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads) # vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf" install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
# 32 MB UDP socket buffers (send-side headroom at high bitrate) # 32 MB UDP socket buffers (send-side headroom at high bitrate)
+5
View File
@@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG"
# --- file layout (matches the RPM %install) ---------------------------------- # --- file layout (matches the RPM %install) ----------------------------------
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG" install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules" install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
# stop/restore the display manager for the stream (the helper derives the DM unit itself).
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \
"$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads). # vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf" install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB # UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
+9
View File
@@ -249,6 +249,12 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
# udev rule — /dev/uinput access for virtual gamepads (input group). # udev rule — /dev/uinput access for virtual gamepads (input group).
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
# Managed gamescope takeover on DM-autologin boxes (Nobara's plasmalogin): a root helper + polkit
# action let the host stop/restore the display manager for the stream without a hand-installed
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a # vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam). # real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf
@@ -383,6 +389,9 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_bindir}/punktfunk-host %{_bindir}/punktfunk-host
%{_bindir}/punktfunk-tray %{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules %{_udevrulesdir}/60-punktfunk.rules
%dir %{_libexecdir}/punktfunk
%{_libexecdir}/punktfunk/pf-dm-helper
%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
%{_prefix}/lib/modules-load.d/punktfunk.conf %{_prefix}/lib/modules-load.d/punktfunk.conf
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml %{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<vendor>Punktfunk</vendor>
<vendor_url>https://punktfunk.unom.io</vendor_url>
<!-- Stop/restore the box's display manager for a managed gamescope takeover. The helper derives
the unit from the display-manager.service symlink itself (no caller-chosen units), so this
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
these distros already authorize for their session switcher (e.g. Nobara's
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
<action id="io.unom.punktfunk.dm-helper">
<description>Stop or restore the display manager for a Punktfunk stream</description>
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
<defaults>
<allow_any>yes</allow_any>
<allow_inactive>yes</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/libexec/punktfunk/pf-dm-helper</annotate>
</action>
</policyconfig>
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Privileged display-manager verbs for the punktfunk managed gamescope takeover.
#
# On DM-autologin boxes whose display manager does not survive a masked session unit (Nobara's
# plasmalogin, unknown DMs), taking the Gaming Mode session over means stopping the display
# manager for the length of the stream and restarting it afterwards — root-only operations. The
# host invokes this helper via pkexec under its own polkit action
# (io.unom.punktfunk.dm-helper.policy, installed by the packages), the same mechanism these
# distros use for their own session switching (Nobara's os-session-select).
#
# The unit is NEVER caller-controlled: it is derived here from the display-manager.service alias
# symlink, so the polkit grant's blast radius is exactly "the box's own display manager" — a
# local-seat operation, not arbitrary unit management.
set -eu
dm_unit() {
target=$(readlink /etc/systemd/system/display-manager.service) || {
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
exit 1
}
basename "$target"
}
case "${1-}" in
stop)
exec systemctl stop "$(dm_unit)"
;;
restore)
dm=$(dm_unit)
# reset-failed first: a relogin loop may have tripped the unit's start limit, and a plain
# restart would be refused until the accounting is cleared.
systemctl reset-failed "$dm" 2>/dev/null || :
exec systemctl restart "$dm"
;;
*)
echo "usage: pf-dm-helper stop|restore" >&2
exit 2
;;
esac
+5
View File
@@ -12,6 +12,11 @@ Description=punktfunk management web console
# web-init generates the login password; the host writes the mgmt token. Order after both. # web-init generates the login password; the host writes the mgmt token. Order after both.
After=punktfunk-web-init.service punktfunk-host.service After=punktfunk-web-init.service punktfunk-host.service
Wants=punktfunk-web-init.service Wants=punktfunk-web-init.service
# Retry indefinitely while the host is still writing the mgmt token + identity cert. Without this,
# systemd's default rate limit (5 starts / 10 s) plus RestartSec=2 gives up permanently after ~10 s
# - so a console enabled before the host's first run stayed dead until someone restarted it by hand
# (the same defect the Windows PunktfunkWeb task had).
StartLimitIntervalSec=0
[Service] [Service]
Type=simple Type=simple
+18 -9
View File
@@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.pem"
set "KEYFILE=%PFDATA%\key.pem" set "KEYFILE=%PFDATA%\key.pem"
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP.
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than rem
rem silently downgrading to plain HTTP. rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task
if not exist "%TOKENFILE%" ( rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. rem before the host finished writing those files (the token lands at argument parse, the cert only
exit /b 1 rem after the RSA-2048 keygen) used to stay down until the next reboot. ~5 min at 2 s, then give up
) rem so a genuinely broken install still surfaces as a failed task rather than one that runs forever.
if not exist "%CERTFILE%" ( rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep.
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service. set /a PFWAITS=0
:pfwait
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
if %PFWAITS% GEQ 150 (
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host service running?
exit /b 1 exit /b 1
) )
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host service to write the mgmt token + identity cert...
set /a PFWAITS+=1
ping -n 3 127.0.0.1 >nul 2>&1
goto pfwait
:pfready
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment. rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
+1 -1
View File
@@ -54,7 +54,7 @@ export const LaunchSpec = Schema.Struct({ "kind": Schema.String.annotate({ "desc
export type LayoutMode = "auto-row" | "manual" export type LayoutMode = "auto-row" | "manual"
export const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage." }) export const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage." })
export type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray<string>, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean } export type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray<string>, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean }
export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." }) export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." })
export type LogEntry = { readonly "level": string, readonly "msg": string, readonly "seq": number, readonly "target": string, readonly "ts_ms": number } export type LogEntry = { readonly "level": string, readonly "msg": string, readonly "seq": number, readonly "target": string, readonly "ts_ms": number }
export const LogEntry = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." }), "msg": Schema.String.annotate({ "description": "The formatted message, structured fields appended as `key=value`." }), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target": Schema.String.annotate({ "description": "The emitting module path (tracing target)." }), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One captured log event." }) export const LogEntry = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." }), "msg": Schema.String.annotate({ "description": "The formatted message, structured fields appended as `key=value`." }), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target": Schema.String.annotate({ "description": "The emitting module path (tracing target)." }), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One captured log event." })
export type ModeConflict = "separate" | "steal" | "join" | "reject" export type ModeConflict = "separate" | "steal" | "join" | "reject"
+13 -8
View File
@@ -18,16 +18,21 @@ set "CERTFILE=%PFDATA%\cert.pem"
set "KEYFILE=%PFDATA%\key.pem" set "KEYFILE=%PFDATA%\key.pem"
rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather
rem installed launcher / Linux unit) rather than silently serving plain HTTP. rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats
if not exist "%TOKENFILE%" ( rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up.
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. set /a PFWAITS=0
exit /b 1 :pfwait
) if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
if not exist "%CERTFILE%" ( if %PFWAITS% GEQ 150 (
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service. echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host running?
exit /b 1 exit /b 1
) )
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host to write the mgmt token + identity cert...
set /a PFWAITS+=1
ping -n 3 127.0.0.1 >nul 2>&1
goto pfwait
:pfready
rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... . rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... .
rem Split on the first '=' and import each into the environment. rem Split on the first '=' and import each into the environment.