Compare commits

..
17 Commits
Author SHA1 Message Date
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
release / apple (push) In progress
apple / screenshots (push) Waiting to run
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
windows / build (x86_64-pc-windows-msvc) (push) In progress
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
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
apple / screenshots (push) In progress
ci / rust (push) Successful in 26m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m37s
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
47 changed files with 2366 additions and 431 deletions
+37
View File
@@ -61,6 +61,43 @@ jobs:
- name: Test (unit + loopback + proptest + C ABI harness)
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)
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.
- 'crates/pf-encode/**'
- '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/**'
- 'scripts/windows/**'
- 'web/**'
@@ -154,8 +171,23 @@ jobs:
# 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
# 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: |
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" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
+3 -3
View File
@@ -5106,7 +5106,7 @@
"properties": {
"audio_streaming": {
"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": {
"type": "array",
@@ -5150,7 +5150,7 @@
},
{
"$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": {
"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)
};
// 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
// (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
+110 -3
View File
@@ -7,6 +7,45 @@
use anyhow::Result;
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.
/// `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
@@ -73,7 +112,7 @@ impl AuChunk {
}
/// Codec selection negotiated with the client.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Codec {
H264,
H265,
@@ -313,8 +352,15 @@ pub trait Encoder: Send {
/// 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
/// 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 =
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
/// switch may be deferred to the next safe point internally. `set_pipelined(true)` returning
/// `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 {
false
}
@@ -360,6 +406,16 @@ pub trait Encoder: Send {
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
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
/// 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
@@ -413,6 +469,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
/// 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
@@ -454,6 +521,26 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
(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(())
}
@@ -477,6 +564,26 @@ mod tests {
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]
fn hevc_and_av1_allow_up_to_8192() {
for c in [Codec::H265, Codec::Av1] {
+102 -77
View File
@@ -180,7 +180,6 @@ pub struct NvencEncoder {
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
want_444: bool,
src_format: PixelFormat,
expand: bool,
width: u32,
height: u32,
fps: u32,
@@ -413,57 +412,6 @@ impl NvencEncoder {
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).
let mut opts = Dictionary::new();
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
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
// @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
// a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
// (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).
// 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 split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
match split.as_deref() {
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");
tracing::info!(
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 {
None
} else {
@@ -561,7 +591,6 @@ impl NvencEncoder {
sws_csc,
want_444,
src_format: format,
expand,
width,
height,
fps,
@@ -695,8 +724,10 @@ impl NvencEncoder {
bytes.len(),
src_row * h
);
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited),
// then send it — no byte-expand. The 4:2:0 RGB path (below) feeds NVENC packed RGB directly.
// swscale the packed RGB straight into the encoder's input frame, then send it. Serves all
// 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 {
let frame = self
.frame
@@ -705,10 +736,13 @@ impl NvencEncoder {
// 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`
// (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
// `data`/`linesize` in-struct arrays were sized for YUV444P by `VideoFrame::new`, and the
// 3 planes are each `width`×`height`. All pointers are live locals for this synchronous
// call; the encoder runs only on this thread (`unsafe impl Send`), so no aliasing/race.
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`, whose
// `data`/`linesize` in-struct arrays were sized by `VideoFrame::new` for the very
// `nvenc_pixel` this context was built to output — 3 planes of `width`×`height` for
// 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 {
let dst_av = frame.as_mut_ptr();
let src_data: [*const u8; 4] =
@@ -724,7 +758,7 @@ impl NvencEncoder {
(*dst_av).linesize.as_ptr(),
);
if r < 0 {
bail!("sws_scale(RGB→YUV444P) failed ({r})");
bail!("sws_scale(CPU CSC → encoder input) failed ({r})");
}
}
frame.set_pts(Some(pts));
@@ -733,7 +767,7 @@ impl NvencEncoder {
} else {
ffmpeg::picture::Type::None
});
self.enc.send_frame(frame).context("send_frame(444)")?;
self.enc.send_frame(frame).context("send_frame(swscale)")?;
return Ok(());
}
let frame = self
@@ -742,18 +776,9 @@ impl NvencEncoder {
.context("CPU frame missing (encoder opened in CUDA mode)")?;
let stride = frame.stride(0); // dst is 4-bpp, aligned
let dst = frame.data_mut(0);
if self.expand {
// packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte)
for y in 0..h {
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
{
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride. The 3-bpp expand that used
// to live here as a per-pixel loop is now swscale's job (see the branch above).
for y in 0..h {
dst[y * stride..y * stride + src_row]
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
+157 -43
View File
@@ -61,8 +61,9 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
LowLatencyConfig, NvStatusExt, RFI_DPB,
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices,
resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt,
RFI_DPB,
};
use super::nvenc_status;
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
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
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
/// 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
@@ -702,6 +709,7 @@ impl NvencCudaEncoder {
last_rfi_range: None,
async_rt: None,
want_async: false,
want_sync: false,
io_stream: ptr::null_mut(),
stream_ordered: false,
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.
unsafe fn teardown(&mut self) {
if self.encoder.is_null() {
@@ -1000,6 +1031,23 @@ impl NvencCudaEncoder {
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`.
/// 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> {
@@ -1074,58 +1122,71 @@ impl NvencCudaEncoder {
}
const FLOOR_BPS: u64 = 10_000_000;
let requested_bps = self.bitrate_bps;
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
// PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only.
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — shared selector, see
// [`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 mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
{
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,
};
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
const CLAMP_TOL_BPS: u64 = 20_000_000;
let mut probe = self.try_open_session(requested_bps, split_mode);
// Disambiguate a forced-split rejection from a bitrate-cap rejection.
// 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 (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 =
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
if probe.is_err() && split_on {
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!(
"NVENC (Linux): split-encode rejected by codec/config — disabled"
);
split_mode = no_split;
used_split = no_split;
probe = Ok(e);
}
}
let enc = match probe {
Ok(enc) => {
self.bitrate_bps = requested_bps;
self.bitrate_bps = target_bps;
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(_) => {
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
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_bps = 0u64;
while hi > lo + CLAMP_TOL_BPS {
let mid = lo + (hi - lo) / 2;
match self.try_open_session(mid, split_mode) {
match self.try_open_session(mid, used_split) {
Ok(e) => {
if !best.is_null() {
let _ = (api().destroy_encoder)(best);
@@ -1134,18 +1195,30 @@ impl NvencCudaEncoder {
best_bps = 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() {
let no_split =
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
best = self
.try_open_session(FLOOR_BPS, split_mode)
.or_else(|_| self.try_open_session(FLOOR_BPS, no_split))
.context(
"NVENC initialize_encoder rejected even at the floor bitrate",
)?;
best = match self.try_open_session(FLOOR_BPS, used_split) {
Ok(e) => e,
Err(_) => {
let e = self.try_open_session(FLOOR_BPS, no_split).context(
"NVENC initialize_encoder rejected even at the floor bitrate",
)?;
used_split = no_split;
e
}
};
best_bps = FLOOR_BPS;
}
tracing::warn!(
@@ -1153,15 +1226,13 @@ impl NvencCudaEncoder {
clamped_mbps = best_bps / 1_000_000,
"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;
best
}
};
self.encoder = enc;
// (Best effort: the floor fallback above may have succeeded split-disabled without
// 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;
self.split_mode = used_split;
// Output bitstream pool.
for _ in 0..POOL {
@@ -1345,6 +1416,10 @@ impl NvencCudaEncoder {
mbps = self.bitrate_bps / 1_000_000,
codec = ?self.codec_guid,
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"
);
Ok(())
@@ -1426,9 +1501,10 @@ impl Encoder for NvencCudaEncoder {
"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
// (nothing in flight after the previous poll drained).
// A pending pipelined-retrieve escalation — or de-escalation — engages here, at the
// submit-side safe point (nothing in flight after the previous poll drained).
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
// switch). Format changes (NV12↔YUV444) likewise re-init.
let new_fmt = buffer_format(buf);
@@ -1736,12 +1812,25 @@ impl Encoder for NvencCudaEncoder {
fn set_pipelined(&mut self, on: bool) -> bool {
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();
}
if async_retrieve_env() == Some(false) {
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() {
self.want_async = true;
self.maybe_engage_async();
@@ -2061,6 +2150,15 @@ impl Encoder for NvencCudaEncoder {
self.bitrate_bps = bps;
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
// 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
@@ -2108,6 +2206,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<()> {
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
}
@@ -2165,6 +2269,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
)
.expect("open NVENC CUDA session");
@@ -2241,6 +2346,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv444,
false,
)
.expect("open NVENC CUDA 4:4:4 session");
@@ -2286,6 +2392,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
)
.expect("open NVENC CUDA session");
@@ -2343,6 +2450,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
) else {
eprintln!(
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
@@ -2369,6 +2477,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
)
.expect("open NVENC CUDA encoder")
}
@@ -2403,6 +2512,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
)
.expect("open");
for f in 0..4u32 {
@@ -2540,6 +2650,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
)
.expect("open NVENC CUDA session");
let frame = nv12_frame(W, H, 0);
@@ -2581,6 +2692,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
)
.expect("open NVENC CUDA session");
// Steady sync frames first (stream-ordered mode).
@@ -2660,6 +2772,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
)
.expect("open NVENC CUDA session");
@@ -2765,6 +2878,7 @@ mod tests {
true,
8,
ChromaFormat::Yuv420,
false,
)
.expect("open NVENC CUDA session");
+12 -5
View File
@@ -227,12 +227,19 @@ impl PyroWaveEncoder {
if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) {
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
}
if chroma.is_444() && !crate::pyrowave_mode_fits_rdo(width, height, true) {
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one
// slips through (e.g. the lab override) rather than wrap the RDO block index.
// Checked against the chroma actually being opened, NOT hardcoded 4:4:4. The 4:2:0 block
// count is ~half of 4:4:4's but still unbounded (8192×6144 4:2:0 = 73728 > u16::MAX), and
// 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!(
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \
block index (see pyrowave-sys patches/0002 note) use 4:2:0 at this size"
"pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
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
-5
View File
@@ -39,11 +39,6 @@ use super::libav::{
};
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
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
/// default.
+96 -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
/// 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 {
if av1 {
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
/// 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
/// 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(
codec: Codec,
width: u32,
@@ -602,34 +690,13 @@ impl VulkanVideoEncoder {
let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance);
// pick the physical device + encode queue family (skip llvmpipe)
let (pd, encode_family) = {
let mut found = None;
for pd in instance.enumerate_physical_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)
{
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")?
};
// pick the physical device + encode queue family (skip llvmpipe). The scan itself lives in
// [`find_encode_device`] so the negotiation-time probe asks the SAME question this open
// asks — a probe that MIRRORS a dispatch goes stale the first time the dispatch grows a
// case, which is the failure `open_video`'s backend-label note already records.
let (pd, encode_family) =
find_encode_device(&instance, &instance.enumerate_physical_devices()?, codec_op)
.context("no VK_KHR_video_encode queue for the requested codec on any device")?;
let mem_props = instance.get_physical_device_memory_properties(pd);
// a compute queue family for the CSC (usually family 0) + its timestamp support (the
+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 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`
+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
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)".
/// (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?)". The
/// [`NvCallError`] root keeps the status downcastable for failure-class checks.
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(7680, 4320, true) > 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]
+11 -1
View File
@@ -1272,7 +1272,11 @@ impl AmfEncoder {
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
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
// 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
@@ -2760,6 +2764,10 @@ mod tests {
}
/// 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 {
if samples.is_empty() {
return 0;
@@ -2778,6 +2786,8 @@ mod tests {
/// 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
/// submit→AU.
/// Gated like its only caller (see [`percentile`]).
#[cfg(feature = "amf-qsv")]
#[allow(clippy::too_many_arguments)]
fn drive_and_measure(
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.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
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,
Qsv,
}
@@ -151,8 +158,14 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
}
/// 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,
@@ -271,6 +284,11 @@ pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
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 {
// 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
@@ -350,7 +368,7 @@ impl SystemInner {
bitrate_bps: u64,
bit_depth: u8,
) -> 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 {
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
} else {
@@ -472,7 +490,10 @@ impl SystemInner {
pts: i64,
idr: bool,
) -> 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!(
fmt_10 == self.ten_bit,
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
@@ -851,7 +872,7 @@ impl ZeroCopyInner {
bit_depth: u8,
device: &ID3D11Device,
) -> 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 {
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
} else {
+108 -59
View File
@@ -37,8 +37,9 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
LowLatencyConfig, NvStatusExt, RFI_DPB,
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices,
resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt,
RFI_DPB,
};
use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -754,6 +755,27 @@ impl NvencD3d11Encoder {
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
/// 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.
@@ -834,43 +856,14 @@ impl NvencD3d11Encoder {
// gets the highest the GPU can actually do, not a coarse fraction of it.
const FLOOR_BPS: u64 = 10_000_000;
let requested_bps = self.bitrate_bps;
// 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
// out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and
// the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way
// above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3.
// HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it.
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever.
// A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240
// (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across
// both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the
// precedence (env override / the measured Main10 don't-split rule / pixel rate).
// 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 mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
{
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"
);
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
// 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
// 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.
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
// 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
// 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".
// `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 =
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
if probe.is_err() && split_on {
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");
split_mode = no_split;
used_split = no_split;
probe = Ok(e);
}
}
let enc = match probe {
Ok(enc) => {
self.bitrate_bps = requested_bps;
self.bitrate_bps = target_bps;
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(_) => {
// 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
// rejected; `best` holds the live session at `lo` so we end up with the clamped one.
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_bps = 0u64;
while hi > lo + CLAMP_TOL_BPS {
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) => {
if !best.is_null() {
let _ = (api().destroy_encoder)(best);
@@ -925,7 +948,15 @@ impl NvencD3d11Encoder {
best_bps = 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() {
@@ -933,14 +964,19 @@ impl NvencD3d11Encoder {
// trying split-disabled in case a forced split (not the bitrate) is the blocker.
let no_split =
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
best = self
.try_open_session(device, FLOOR_BPS, split_mode, use_async)
.or_else(|_| {
self.try_open_session(device, FLOOR_BPS, no_split, use_async)
})
.context(
"NVENC initialize_encoder rejected even at the floor bitrate",
)?;
best = match self.try_open_session(device, FLOOR_BPS, used_split, use_async)
{
Ok(e) => e,
Err(_) => {
let e = self
.try_open_session(device, FLOOR_BPS, no_split, use_async)
.context(
"NVENC initialize_encoder rejected even at the floor bitrate",
)?;
used_split = no_split;
e
}
};
best_bps = FLOOR_BPS;
}
tracing::warn!(
@@ -948,21 +984,19 @@ impl NvencD3d11Encoder {
clamped_mbps = best_bps / 1_000_000,
"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;
best
}
};
self.encoder = enc;
// Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best
// effort: the floor fallback above may have succeeded split-disabled without updating
// `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;
// Session init params a later `reconfigure_bitrate` must re-present verbatim.
self.split_mode = used_split;
self.session_async = use_async;
// 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
// 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);
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
// info line for the same event here.)
@@ -1552,6 +1586,15 @@ impl Encoder for NvencD3d11Encoder {
self.bitrate_bps = bps;
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
// 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
@@ -1600,6 +1643,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<()> {
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
}
+8 -5
View File
@@ -189,12 +189,15 @@ impl PyroWaveEncoder {
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
}
if chroma444 && !crate::pyrowave_mode_fits_rdo(width, height, true) {
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one
// slips through (e.g. the lab override) rather than wrap the RDO block index.
// Checked against the chroma actually being opened, NOT hardcoded 4:4:4 — see the Linux
// twin (`enc/linux/pyrowave.rs`) for the full rationale: the negotiator's 4:4:4 → 4:2:0
// 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!(
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \
block index (see pyrowave-sys patches/0002 note) use 4:2:0 at this size"
"pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
index (see pyrowave-sys patches/0002 note) lower the resolution",
if chroma444 { "4:4:4" } else { "4:2:0" }
);
}
let fps = fps.max(1);
+6 -1
View File
@@ -774,7 +774,12 @@ impl QsvEncoder {
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
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 {
bail!("native QSV: 10-bit is HEVC/AV1-only (H.264 High10 is not negotiated)");
}
+66 -9
View File
@@ -5,9 +5,11 @@
//! 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
//! (`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
// uses them (mirrors the host crate root's allow before the extraction).
#![allow(dead_code)]
// NOTE: no crate-wide `#![allow(dead_code)]`. It was inherited from the pre-extraction host crate
// root as scaffolding for backend paths defined ahead of the build that used them, but a census
// 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
// program). As a parent module this also covers the child modules (windows/linux backends).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -241,6 +243,12 @@ impl Encoder for TrackedEncoder {
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
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<()> {
self.inner.flush()
}
@@ -853,18 +861,31 @@ fn vulkan_encode_enabled() -> bool {
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
/// 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")]
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
#[cfg(feature = "vulkan-encode")]
{
matches!(codec, Codec::H265 | Codec::Av1)
&& vulkan_encode_enabled()
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
&& !matches!(
pf_host_config::config().encoder_pref.as_str(),
"nvenc" | "nvidia" | "cuda" | "pyrowave"
)
// Which backend this host actually resolves to. This used to be 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. So a stock NVIDIA host passed
// 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"))]
{
@@ -873,6 +894,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
/// 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
@@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool {
dm == "sddm.service"
}
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the
/// caller degrades to attach.
fn try_stop_display_manager(dm: &str) -> bool {
Command::new("systemctl")
.args(["stop", dm])
/// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
/// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
/// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
/// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
/// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
/// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
/// 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()
.map(|s| s.success())
.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
/// 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
@@ -1219,10 +1268,13 @@ fn honor_session_select_switch(dm: String) {
clear_takeover();
*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
let _ = Command::new("systemctl")
.args(["reset-failed", &dm])
.status();
let _ = Command::new("systemctl").args(["start", &dm]).status();
if !restore_display_manager(&dm) {
tracing::warn!(
%dm,
"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);
while Instant::now() < deadline {
let active = Command::new("systemctl")
@@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> {
if !try_stop_display_manager(&dm) {
bail!(
"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 \
display-manager polkit rule (see docs) to enable the managed takeover"
session unit, and stopping it needs privilege the packaged pf-dm-helper \
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!(
@@ -1668,14 +1722,7 @@ fn do_restore_tv_session() {
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
// session Exec starts the gamescope unit itself.
if let Some(dm) = dm {
let _ = Command::new("systemctl")
.args(["reset-failed", &dm])
.status();
let restart = Command::new("systemctl")
.args(["restart", &dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
let restart = restore_display_manager(&dm);
if restart {
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
} 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.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. 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.
/// First gamescope that paints the Steam overlay (Shift+Tab / Quick Access Menu) into its built-in
/// PipeWire node. `paint_pipewire()` is a *separate, reduced* composite from the display scanout;
/// 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)> {
let out = Command::new("gamescope").arg("--version").output().ok()?;
// 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); \
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)
}
@@ -379,7 +401,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
#[cfg(test)]
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]
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.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
//! (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"
//! 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
//! 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.
const PROVEN_HEADROOM_NUM: u32 = 3;
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.
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
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
/// signal is then simply absent — identical to the pre-decode-signal behavior).
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
/// 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
@@ -147,6 +193,12 @@ impl BitrateController {
probing: true,
owd_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,
bad_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
/// 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) {
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.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
/// 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`
/// 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`
/// the window's ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline
/// really 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.
/// (`None` on an embedder that doesn't report it — the signal is then absent),
/// `encode_mean_us` the window's mean HOST encode-stage latency (from the per-AU 0xCF
/// datagrams; `None` on an old host that doesn't send them), `actual_kbps` the window's
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
/// 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)]
pub(crate) fn on_window(
&mut self,
@@ -192,6 +290,7 @@ impl BitrateController {
loss_ppm: u32,
owd_mean_us: Option<i64>,
decode_mean_us: Option<i64>,
encode_mean_us: Option<i64>,
actual_kbps: u32,
flushed: bool,
) -> Option<u32> {
@@ -242,6 +341,23 @@ impl BitrateController {
}
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
// 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
@@ -253,8 +369,9 @@ impl BitrateController {
// 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)
// still needs two consecutive windows.
let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe;
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad;
let severe =
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 {
self.bad_windows += 1;
self.clean_windows = 0;
@@ -264,6 +381,29 @@ impl BitrateController {
self.clean_windows += 1;
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
.last_change
.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.
let utilized =
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
.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);
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
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
// Congestion avoidance: +~6 % after a sustained clean run.
@@ -308,6 +452,7 @@ impl BitrateController {
fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> {
self.last_change = Some(now);
self.unacked += 1;
self.last_requested_kbps = Some(kbps);
// `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).
Some(kbps)
@@ -331,7 +476,16 @@ mod tests {
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
let mut out = None;
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() {
return out;
}
@@ -345,7 +499,7 @@ mod tests {
let mut c = BitrateController::new(0);
let now = Instant::now();
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
);
}
@@ -356,22 +510,58 @@ mod tests {
let start = Instant::now();
// Heavy-but-recoverable loss (26 %) is ORDINARY: one window is a blip — no reaction.
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
);
// The second consecutive bad window backs off ×0.7.
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)
);
c.on_ack(14_000);
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
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
); // bad #1 again
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)
);
}
@@ -382,19 +572,28 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
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)
);
// …and so does a jump-to-live flush.
let mut c = BitrateController::new(20_000);
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)
);
// …and ≥6 % window loss.
let mut c = BitrateController::new(20_000);
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)
);
}
@@ -404,18 +603,18 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
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)
);
c.on_ack(14_000);
// 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.
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
);
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)
);
}
@@ -426,17 +625,17 @@ mod tests {
let start = Instant::now();
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
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)
);
c.on_ack(5_000);
// At the floor, further bad windows request nothing.
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
);
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
);
}
@@ -446,7 +645,7 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
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)
);
c.on_ack(14_000);
@@ -469,9 +668,16 @@ mod tests {
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
let mut got = Vec::new();
for i in 0..14 {
if let Some(k) =
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false)
{
if let Some(k) = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
) {
c.on_ack(k);
got.push(k);
}
@@ -485,20 +691,47 @@ mod tests {
c.set_ceiling(300_000);
let start = Instant::now();
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)
);
c.on_ack(40_000);
// Severe window → immediate ×0.7, and slow start is over.
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)
);
c.on_ack(28_000);
// Clean again — but the next climb is additive, after the 6-window clean run.
let mut next = None;
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() {
assert!(i >= 8, "additive climb must wait for the clean run");
break;
@@ -512,7 +745,7 @@ mod tests {
let mut c = BitrateController::new(0);
c.set_ceiling(1_000_000);
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
);
let mut c = BitrateController::new(20_000);
@@ -527,17 +760,44 @@ mod tests {
// Establish a ~10 ms baseline over a few clean windows.
for i in 0..4 {
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
);
}
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
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
);
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)
);
}
@@ -557,6 +817,7 @@ mod tests {
0,
Some(10_000),
Some(8_000),
None,
1_000_000,
false
),
@@ -572,6 +833,7 @@ mod tests {
0,
Some(10_000),
Some(38_000),
None,
1_000_000,
false
),
@@ -584,6 +846,7 @@ mod tests {
0,
Some(10_000),
Some(40_000),
None,
1_000_000,
false
),
@@ -605,6 +868,7 @@ mod tests {
0,
Some(10_000),
Some(8_000),
None,
1_000_000,
false
),
@@ -620,6 +884,7 @@ mod tests {
0,
Some(10_000),
Some(38_000),
None,
1_000_000,
false
),
@@ -634,6 +899,7 @@ mod tests {
0,
Some(10_000),
Some(40_000),
None,
1_000_000,
false
),
@@ -657,6 +923,7 @@ mod tests {
0,
Some(10_000),
Some(8_000),
None,
2_000,
false
),
@@ -673,6 +940,7 @@ mod tests {
0,
Some(10_000),
Some(8_000),
None,
18_000,
false
),
@@ -696,6 +964,7 @@ mod tests {
0,
Some(10_000),
Some(8_000),
None,
20_000,
false
),
@@ -710,6 +979,7 @@ mod tests {
0,
Some(10_000),
Some(8_000),
None,
30_000,
false
),
@@ -732,6 +1002,7 @@ mod tests {
0,
Some(10_000),
Some(8_000),
None,
20_000,
false
),
@@ -741,7 +1012,16 @@ mod tests {
// A long calm stretch (2 % utilization, decoder idle): the controller stays silent.
for i in 2..30 {
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
);
}
@@ -761,6 +1041,7 @@ mod tests {
0,
Some(10_000),
Some(8_000),
None,
1_000_000,
false
),
@@ -776,6 +1057,7 @@ mod tests {
0,
Some(10_000),
Some(60_000),
None,
1_000_000,
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]
fn ack_silence_disables_the_controller() {
let mut c = BitrateController::new(20_000);
@@ -791,7 +1302,7 @@ mod tests {
let mut i = 0;
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
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()
{
sent += 1;
@@ -224,6 +224,20 @@ pub(crate) struct DecodeLatAcc {
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
/// (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
+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
// 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));
// 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
// 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(),
clip_event_tx: clip_event_tx.clone(),
cursor_shape_tx,
mode_gen: mode_gen.clone(),
}
.run(),
);
@@ -141,6 +148,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
hidout_tx,
hdr_meta_tx,
host_timing_tx,
encode_lat.clone(),
cursor_state_tx,
));
@@ -176,6 +184,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clock_offset,
clock_gen,
decode_lat,
encode_lat,
mode_gen,
frames_dropped,
fec_recovered,
bitrate_ack,
@@ -24,6 +24,10 @@ pub(super) struct ControlTask {
/// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's
/// shape plane ([`NativeClient::next_cursor_shape`]).
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 {
@@ -40,6 +44,7 @@ impl ControlTask {
clock_gen,
clip_event_tx,
cursor_shape_tx,
mode_gen,
} = self;
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
// 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 ack.accepted {
*mode_slot.lock().unwrap() = ack.mode;
mode_gen.fetch_add(1, Ordering::Relaxed);
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
} else {
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_gen: Arc<AtomicU32>,
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) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
@@ -41,6 +47,8 @@ impl DataPump {
clock_offset: pump_clock_offset,
clock_gen: pump_clock_gen,
decode_lat: pump_decode_lat,
encode_lat: pump_encode_lat,
mode_gen: pump_mode_gen,
frames_dropped,
fec_recovered,
bitrate_ack,
@@ -127,6 +135,7 @@ impl DataPump {
let mut clock_detector_armed = true;
let mut resync_wanted = false;
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,
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
// 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
// feed the controller this window's congestion signals; a decision becomes a
// SetBitrate on the control stream.
// Adaptive bitrate: an accepted mode switch first (it invalidates the
// mode-scoped learned state), then drain any host ack (its clamp is
// 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() {
abr.on_ack(acked);
}
@@ -356,6 +371,14 @@ impl DataPump {
*acc = DecodeLatAcc::default();
(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 target it was allowed. Wire bytes (headers + FEC) slightly overstate the
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
@@ -369,17 +392,19 @@ impl DataPump {
loss_ppm,
owd_mean_us,
decode_mean_us,
encode_mean_us,
actual_kbps,
flush_in_window,
) {
// 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.
tracing::info!(
kbps,
loss_ppm,
owd_mean_us = owd_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,
flushed = flush_in_window,
"adaptive bitrate: requesting encoder re-target"
@@ -14,6 +14,9 @@ pub(super) async fn run(
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
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>,
) {
// 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) => {
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);
}
}
+18 -13
View File
@@ -150,11 +150,13 @@ pub(crate) struct StreamInfo {
pub(crate) struct LocalSummary {
/// Host version (mirrors `/health`).
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,
/// True while the audio stream thread is running.
/// True while audio is streaming on either plane (same rule as `video_streaming`).
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>,
/// Number of pinned (paired) GameStream client certificates.
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> {
// 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
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
let session = st
.app
.launch
.lock()
.unwrap()
.unwrap_or_else(|e| e.into_inner())
.map(|l| SessionInfo {
width: l.width,
height: l.height,
fps: l.fps,
})
.or_else(|| {
crate::session_status::snapshot()
.first()
.map(|s| SessionInfo {
width: s.width,
height: s.height,
fps: s.fps,
})
native.first().map(|s| SessionInfo {
width: s.width,
height: s.height,
fps: s.fps,
})
});
let (native_paired_clients, pending_approvals) = st
.native
@@ -419,8 +422,10 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
.unwrap_or((0, 0));
Json(LocalSummary {
version: env!("PUNKTFUNK_VERSION").into(),
video_streaming: st.app.streaming.load(Ordering::SeqCst),
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
// Either plane counts, like `/status`: reading only the GameStream flags made the tray say
// "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,
paired_clients: st
.app
+66
View File
@@ -286,11 +286,74 @@ async fn health_is_open_and_versioned() {
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
/// 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).
#[tokio::test]
async fn local_summary_is_loopback_only_and_non_sensitive() {
let _serial = SESSION_REGISTRY_LOCK.lock().await;
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
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]
async fn status_reflects_runtime_state() {
let _serial = SESSION_REGISTRY_LOCK.lock().await;
let state = test_state();
let app = test_app(state.clone(), None);
@@ -756,6 +820,8 @@ async fn stop_session_clears_runtime_state() {
#[tokio::test]
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 app = test_app(state.clone(), None);
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).
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, 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_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
@@ -1036,6 +1048,9 @@ async fn serve_session(
live_reconfig_ok,
adaptive_fec,
session_bitrate_kbps,
live_bitrate.clone(),
encoder_ceiling_kbps.clone(),
cadence_degraded.clone(),
fec_target_ctl,
reconfig_tx,
keyframe_tx,
@@ -1429,6 +1444,9 @@ async fn serve_session(
bitrate_rx,
compositor,
bitrate_kbps,
live_bitrate,
encoder_ceiling_kbps,
cadence_degraded,
bitrate_auto,
bit_depth,
chroma,
+32 -1
View File
@@ -22,6 +22,13 @@ pub(super) async fn run(
live_reconfig_ok: bool,
adaptive_fec: bool,
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>,
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
keyframe_tx: std::sync::mpsc::Sender<()>,
@@ -161,7 +168,31 @@ pub(super) async fn run(
);
session_bitrate_kbps
} 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!(
requested_kbps = req.bitrate_kbps,
+163 -14
View File
@@ -909,6 +909,20 @@ pub(super) struct SessionContext {
pub(super) compositor: crate::vdisplay::Compositor,
/// Negotiated encoder bitrate (kbps).
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 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
@@ -1035,6 +1049,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
bitrate_rx,
compositor,
mut bitrate_kbps,
live_bitrate,
encoder_ceiling_kbps,
cadence_degraded,
bitrate_auto,
bit_depth,
// 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,
// 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);
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
// `live_bitrate` (SessionContext) is shared with the send thread's stats sample AND the
// control task: a mid-stream adaptive bitrate change (bitrate_rx below) stores the
// 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
// 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
@@ -1436,6 +1453,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
const DEPTH_ESCALATE: u32 = 20;
const DEPTH_BEHIND_CAP: u32 = 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 {
// 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
@@ -1674,15 +1711,49 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
while let Ok(k) = bitrate_rx.try_recv() {
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 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!(
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)"
);
bitrate_kbps = new_kbps;
live_bitrate.store(new_kbps, Ordering::Relaxed);
if applied_kbps < new_kbps {
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
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
} else {
@@ -1702,9 +1773,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
plan.cursor_blend,
) {
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!(
from_kbps = bitrate_kbps,
to_kbps = new_kbps,
to_kbps = applied_kbps,
requested_kbps = new_kbps,
"encoder rebuilt at new bitrate (adaptive bitrate)"
);
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.)
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
enc = new_enc;
bitrate_kbps = new_kbps;
live_bitrate.store(new_kbps, Ordering::Relaxed);
if applied_kbps < new_kbps {
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
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
// IDR cooldown too.
@@ -1723,6 +1805,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
last_au_at = std::time::Instant::now();
encoder_resets = 0;
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) => {
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,
// 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.
if idd_adaptive_enabled() && (cur_depth < max_depth || !pipeline_asked) {
if idd_adaptive_enabled() {
depth_frames += 1;
if depth_frames > DEPTH_WARMUP_FRAMES {
let behind = std::time::Instant::now() >= next;
@@ -2556,28 +2644,89 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
} else {
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 {
cur_depth = max_depth;
tracing::info!(
depth = cur_depth,
"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)"
);
} else {
pipeline_asked = true;
if enc.set_pipelined(true) {
pipelined_active = enc.set_pipelined(true);
if pipelined_active {
tracing::info!(
"encoder pipelined retrieve escalated — encode can't hold \
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)"
);
}
}
// Give the action time to take effect before judging again.
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");
}
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise)
for _ in 0..30 {
if token_path.exists() {
break;
// 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
//
// `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
// 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));
}
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
println!("web console set up + started (https://<host-ip>:47992)");
Ok(())
paths.iter().all(|p| p.exists())
}
/// `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.
@@ -505,30 +558,57 @@ fn random_password() -> String {
/// ("LISTENING"/"ABHOEREN"/...) is never parsed.
fn stop_web_console() {
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() {
let toks: Vec<&str> = line.split_whitespace().collect();
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"]);
}
}
for pid in web_listener_pids() {
run_quiet("taskkill", &["/PID", &pid, "/F"]);
}
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`,
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
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<()> {
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!(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\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\
<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\
<Settings>\n\
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
</Settings>\n\
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\
</Task>",
xml_escape(&cmd.to_string_lossy())
<Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
</Task>"
);
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(
"schtasks",
&[
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
],
);
let _ = std::fs::remove_file(&xml_path);
if ok {
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
Ok(())
} else {
bail!("schtasks /create {WEB_TASK} failed")
}
ok
}
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 {
status: TrayStatus,
web_port: u16,
/// The console answered the poller's live loopback probe — the "Open web console" entry is
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
/// The console answered the poller's live loopback probe — labels the (always present) "Open
/// web console" entry; it never hides it.
web_console: bool,
/// 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.
@@ -33,8 +33,10 @@ impl HostTray {
}
}
fn open_console(&self) {
let url = format!("https://127.0.0.1:{}", self.web_port);
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page
/// 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();
}
}
@@ -107,17 +109,33 @@ impl ksni::Tray for HostTray {
}
.into(),
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 {
label: "Open web console".into(),
visible: self.web_console,
activate: Box::new(|t: &mut Self| t.open_console()),
label: if self.web_console {
"Open web console".to_string()
} else {
"Open web console (not responding)".to_string()
},
activate: Box::new(|t: &mut Self| t.open_console("")),
..Default::default()
}
.into(),
StandardItem {
label: "Approve pairing request…".into(),
visible: self.web_console && self.status.pairing_attention(),
activate: Box::new(|t: &mut Self| t.open_console()),
visible: self.status.pairing_attention(),
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()
}
.into(),
+59 -6
View File
@@ -75,7 +75,7 @@ impl TrayStatus {
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
TrayStatus::Running(s) => {
let base = match (&s.session, s.video_streaming) {
let base = match (&s.session, self.is_streaming()) {
(Some(sess), true) => format!(
"punktfunk host {} — streaming {}×{}@{}",
s.version, sess.width, sess.height, sess.fps
@@ -113,8 +113,23 @@ impl TrayStatus {
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 {
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).
@@ -156,9 +171,11 @@ struct Shared {
impl Poller {
/// 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`
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
/// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It
/// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry
/// 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(
mgmt_addr: String,
mgmt_port: u16,
@@ -203,6 +220,10 @@ fn poll_loop(
// 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).
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 {
let svc = probe_service();
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 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)) {
on_change(status.clone(), console_up);
last = Some((status, console_up));
@@ -478,6 +505,32 @@ mod tests {
.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]
fn pairing_attention_flags() {
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_EXIT: usize = 0x0106;
const IDM_PAIRING: usize = 0x0107;
const IDM_DISPLAYS: usize = 0x0108;
/// Icon resource ordinals (embedded by build.rs).
fn icon_ordinal(status: &TrayStatus) -> u16 {
@@ -73,8 +74,9 @@ struct App {
taskbar_created: u32,
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
host_exe: Option<std::path::PathBuf>,
/// The console answered the poller's live loopback probe — the "Open web console" entry is
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
/// The console answered the poller's live loopback probe. Drives the label of the (always
/// 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_port: u16,
}
@@ -305,14 +307,24 @@ fn show_menu(hwnd: HWND) {
};
add(IDM_HEADER, &status.headline(), true);
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) {
add(IDM_OPEN_WEB, "Open web console", false);
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
if status.pairing_attention() {
add(IDM_PAIRING, "Approve pairing request…", false);
}
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
} else {
add(IDM_OPEN_WEB, "Open web console (not responding)", false);
}
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 startable {
add(IDM_START, "Start host", false);
@@ -385,8 +397,13 @@ fn elevate_service(hwnd: HWND, verb: &str) {
}
}
fn open_web_console(hwnd: HWND) {
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
/// 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) {
@@ -416,7 +433,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
WM_CONTEXTMENU => show_menu(hwnd),
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
if app.web_console.load(Ordering::SeqCst) {
open_web_console(hwnd);
open_web_console(hwnd, "");
} else {
show_menu(hwnd);
}
@@ -427,8 +444,9 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
}
WM_COMMAND => {
match (wparam.0) & 0xffff {
IDM_OPEN_WEB => open_web_console(hwnd),
IDM_PAIRING => open_web_console(hwnd),
IDM_OPEN_WEB => 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_STOP => elevate_service(hwnd, "stop"),
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.
- **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
with a polkit rule (adjust the unit and user names to your box):
itself for the length of the stream and restart it afterwards, which needs privilege. The
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
// /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
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the
display manager. If the display-manager restart ever loses its privilege mid-restore,
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the
fallback.
With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
what happened; check the host log for "managed takeover unavailable"). If the display-manager
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
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
## 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
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
+8
View File
@@ -129,6 +129,14 @@ package_punktfunk-host() {
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
# /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"
# 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)
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)
+5
View File
@@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG"
# --- file layout (matches the RPM %install) ----------------------------------
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
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).
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
+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).
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
# 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
@@ -383,6 +389,9 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_bindir}/punktfunk-host
%{_bindir}/punktfunk-tray
%{_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/sysctl.d/99-punktfunk-net.conf
%{_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.
After=punktfunk-web-init.service punktfunk-host.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]
Type=simple
+18 -9
View File
@@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.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 we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than
rem silently downgrading to plain HTTP.
if not exist "%TOKENFILE%" (
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
exit /b 1
)
if not exist "%CERTFILE%" (
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP.
rem
rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task
rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started
rem before the host finished writing those files (the token lands at argument parse, the cert only
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.
rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep.
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
)
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 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 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 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 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"
+13 -8
View File
@@ -18,16 +18,21 @@ set "CERTFILE=%PFDATA%\cert.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 has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the
rem installed launcher / Linux unit) rather than silently serving plain HTTP.
if not exist "%TOKENFILE%" (
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
exit /b 1
)
if not exist "%CERTFILE%" (
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather
rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats
rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up.
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 running?
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 Split on the first '=' and import each into the environment.