Compare commits

..
Author SHA1 Message Date
enricobuehler 1a604cbf3e Merge remote-tracking branch 'origin/main' into worktree-abr-dscp-default
ci / rust-arm64 (pull_request) Successful in 3m24s
ci / web (pull_request) Successful in 2m0s
ci / docs-site (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-drift (pull_request) Successful in 30s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m18s
ci / rust (pull_request) Successful in 7m2s
android / android (pull_request) Successful in 6m30s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m57s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
# Conflicts:
#	docs-site/content/docs/configuration.md
2026-08-26 20:16:31 +02:00
enricobuehler c1bffa9e7b Merge pull request 'The control loop stops believing its own bookkeeping' (#403) from worktree-abr-control-loop into main
apple / swift (push) Successful in 2m7s
ci / bun-nix (push) Successful in 22s
ci / docs-drift (push) Successful in 27s
ci / docs-site (push) Successful in 1m13s
deb / build-publish-gamescope (push) Successful in 43s
ci / web (push) Successful in 1m43s
deb / build-publish-client-arm64 (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 3m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
deb / build-publish-host (push) Successful in 5m7s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m47s
ci / rust (push) Successful in 8m21s
docker / builders-arm64cross (push) Successful in 35s
arch / build-publish (push) Successful in 11m0s
deb / build-publish (push) Successful in 4m20s
docker / deploy-docs (push) Successful in 40s
flatpak / build-publish (push) Failing after 3m45s
apple / distribute (push) Successful in 11m50s
android / android (push) Successful in 10m18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 11m40s
windows-host / package (push) Successful in 12m12s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 42s
deb / smoke-install (push) Successful in 4m48s
apple / screenshots (push) Successful in 10m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 16m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 5m13s
2026-08-26 18:14:54 +00:00
enricobuehler 8ca86b1682 Merge pull request 'The transmission plane stops decaying to a blast' (#401) from worktree-abr-transmission-plane into main
android / android (push) Canceled after 46s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 50s
ci / web (push) Canceled after 44s
ci / docs-site (push) Canceled after 24s
ci / bun-nix (push) Canceled after 10s
ci / docs-drift (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 22s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 4s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 27s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-26 18:14:29 +00:00
enricobuehler dcedd7147f Merge pull request 'PyroWave forces Automatic bitrate' (#399) from worktree-pyrowave-forces-auto into main
android / android (push) Canceled after 22s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 4s
ci / rust-arm64 (push) Canceled after 36s
ci / bun-nix (push) Canceled after 30s
ci / docs-site (push) Canceled after 31s
ci / web (push) Canceled after 34s
ci / docs-drift (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
apple / swift (push) Canceled after 1m52s
apple / distribute (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 50s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
deb / build-publish (push) Canceled after 1m36s
deb / build-publish-host (push) Canceled after 1m35s
deb / build-publish-client-arm64 (push) Canceled after 1m33s
deb / smoke-install (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m24s
2026-08-26 18:14:10 +00:00
enricobuehler 4dcc31dc3b Merge pull request 'Verify NVENC sub-frame readback in release, and stop trusting a driver that lied' (#400) from worktree-nvenc-subframe-verify into main
ci / web (push) Successful in 1m11s
ci / rust-arm64 (push) Successful in 1m34s
ci / bun-nix (push) Successful in 37s
ci / docs-site (push) Successful in 1m15s
ci / docs-drift (push) Successful in 37s
deb / build-publish-gamescope (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 2m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 24s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
deb / build-publish (push) Successful in 4m51s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m54s
docker / builders-arm64cross (push) Successful in 20s
deb / build-publish-host (push) Successful in 6m8s
docker / deploy-docs (push) Successful in 52s
ci / rust (push) Successful in 6m53s
arch / build-publish (push) Successful in 10m32s
android / android (push) Successful in 11m7s
deb / smoke-install (push) Successful in 3m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 16m5s
windows-host / package (push) Canceled after 6m53s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-26 17:52:06 +00:00
enricobuehler 2bb6af3b92 Merge pull request 'A launcher tile can open Heroic's console mode' (#402) from worktree-heroic-console-and-runners into main
ci / bun-nix (push) Successful in 39s
ci / web (push) Successful in 1m17s
ci / docs-site (push) Successful in 1m18s
ci / docs-drift (push) Successful in 32s
ci / rust-arm64 (push) Successful in 1m49s
deb / build-publish-gamescope (push) Successful in 34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 35s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 1m48s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m38s
docker / builders-arm64cross (push) Successful in 13s
ci / rust (push) Successful in 6m59s
deb / build-publish (push) Successful in 5m49s
deb / build-publish-host (push) Successful in 6m14s
docker / deploy-docs (push) Successful in 39s
android / android (push) Successful in 9m57s
arch / build-publish (push) Successful in 10m13s
deb / smoke-install (push) Successful in 5m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 19m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 20m43s
windows-host / package (push) Canceled after 8m57s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-26 17:26:18 +00:00
enricobuehler 2a951a6bb5 DSCP marks the peers the local network owns (ABR overhaul Phase 1, §2.5)
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m19s
ci / web (pull_request) Successful in 2m1s
ci / docs-site (pull_request) Successful in 2m4s
ci / docs-drift (pull_request) Successful in 38s
ci / bun-nix (pull_request) Successful in 1m13s
android / android (pull_request) Successful in 6m37s
ci / rust (pull_request) Successful in 7m40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m53s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m13s
The marking was opt-in because some consumer ISPs/routers bleach or
reject DSCP-tagged packets — but that risk lives on WAN paths, and the
win (APs map DSCP to WMM airtime priority — real for exactly the Wi-Fi
downlink field case) lives on local ones. The default becomes AUTO:
mark toward RFC1918 / ULA / link-local / loopback peers, stay off
toward anything routable.

PUNKTFUNK_DSCP=1 still forces it on everywhere, =0 stays the kill
switch, and the embedder hook (the Android low-latency tie-in) still
forces on regardless of the peer — for paths like VPNs the address
math can't recognize as local. Decision + classifier are pure and
unit-tested; the connected-loopback test covers the AUTO path end to
end. Windows qWAVE mechanics unchanged.
2026-08-26 19:16:31 +02:00
enricobuehler ae13b29abd feat(host/library): launcher_ui can open Heroic's console mode
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m21s
ci / docs-drift (pull_request) Successful in 28s
ci / bun-nix (pull_request) Successful in 1m54s
ci / rust-arm64 (pull_request) Successful in 2m45s
android / android (pull_request) Successful in 5m18s
ci / rust (pull_request) Successful in 10m21s
Heroic 2.21 added a fullscreen gamepad UI — the Big Picture of that launcher,
and the tile you actually want on a stream. Nothing could publish it: a plugin
names a launcher UI and the host builds the line, and the only Heroic value the
host knew was `heroic`, which opens the ordinary window.

`launcher_ui` now also takes `heroic-console` on Linux, resolving to the same
prefix `heroic` does — native binary if on PATH, else the Flatpak — plus
`--console --fullscreen`. Both flags, because Heroic reads them separately:
`--console` only routes the UI to that front end (`isCLIConsoleMode`) and
`--fullscreen` is what fills the screen (`isCLIFullscreen`). No URI can do it —
`heroic://` speaks `ping` and `launch` and nothing else — which is the same
reason Playnite's fullscreen tile spawns its exe directly rather than going
through the registered protocol handler. An older Heroic ignores the unknown
`--console` and still honours `--fullscreen`, so the tile degrades to a
fullscreen desktop UI rather than to nothing.

That makes the value a launcher UI rather than a launcher. It already was one on
Windows, where `playnite` has always meant `Playnite.FullscreenApp.exe`; the doc
comment claiming otherwise is corrected. A `heroic_ui` kind mirroring `steam_ui`
would have been tidier and was rejected on the compatibility asymmetry: an
unknown KIND degrades to an unlaunchable tile, but an unknown VALUE is a hard
400 that refuses the whole reconcile, so either shape has to be gated on
`minHost` in the plugin index and the value is much the smaller change. A plugin
publishing `heroic-console` must set `minHost` to the release carrying this.

Second fix, from the same reading: `resolvable_launcher_ui` special-cased only
Playnite-on-Windows and answered `true` for everything else, so a Heroic tile
survived `sanitize_launcher_entries` even when `heroic_launch_prefix()` resolves
to nothing. Keeping `~/.config/heroic` after uninstalling Heroic is enough to
reach that — the plugin's `detect` only looks for the directory — and the
operator got a launcher tile that does nothing. Both Heroic values now probe the
prefix, exactly as Playnite probes for its exe.
2026-08-26 19:09:56 +02:00
enricobuehler 9c278ee351 The transmission plane stops decaying to a blast (ABR overhaul Phase 1)
ci / bun-nix (pull_request) Successful in 23s
ci / docs-drift (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 1m42s
ci / rust (pull_request) Successful in 6m18s
android / android (pull_request) Successful in 6m32s
Static content used to strip every defense before motion: the unpaced
microburst allowance was an absolute max(128 KiB, wire/4) — sized for
gigabit LAN, so at Wi-Fi bitrates every frame went out back-to-back —
and adaptive FEC decayed to 1 %, so the first big motion frame died
unprotected (the 2026-08-26 field case; RFC design/abr-stack-overhaul.md
in punktfunk-planning, §2.1–2.4).

- The allowance is now TIME at the pace rate: 10 ms, clamped to
  [16 KiB, 256 KiB] (send_pacing::auto_burst_bytes). One constant lines
  up both proven ends — ~19 KiB at the field case's 15 Mbps pace (its
  discriminator was PACE_BURST_KB=16) and ~112 KiB at 90 Mbps LAN pace,
  so LAN latency does not regress. PACE_FACTOR=0 keeps the legacy rule.
- A streamed AU consumes ONE allowance across its block flushes instead
  of a fresh one per block (the latent per-block multiplication).
- One frame's paced spread is bounded to ~2 frame intervals so an IDR
  can't back the encode|send sync_channel(3) up into cadence_degraded,
  which refuses every climb; MAX_PACE_SPREAD stays the absolute bound.
- Adaptive FEC gains a burned floor: any window with real shard loss
  raises the decay floor to 5 %; ~2 clean minutes re-earn 1 %, an early
  re-burn doubles the requirement (bounded), a durable step-down resets
  it — no session-permanent latch, per the encode stand-down's house
  rule. PUNKTFUNK_FEC_PCT pin semantics and the GameStream plane are
  untouched.

Pure logic (FecFloor, auto_burst_bytes, native_budget) is unit-tested;
the burned-floor band is noted in the FEC_PCT docs.
2026-08-26 18:58:28 +02:00
enricobuehler f79e9eb524 fix(encode): verify NVENC sub-frame readback in release, and stop trusting a driver that lied
ci / docs-drift (pull_request) Successful in 36s
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m12s
ci / rust (pull_request) Successful in 5m34s
The chunked poll ships slice bytes the moment the doNotWait lock publishes
them, trusting that they are a byte-exact prefix of the AU the finishing
blocking lock returns. The check for that trust — the ChunkState shadow
comparison — was compiled out of release builds, so a driver branch whose
sub-frame accounting runs ahead of the flushed bytes shipped not-yet-written
buffer content straight to the wire: self-consistent tiling, wrong bytes,
no client counter moving. That is the black-band field report (Windows
client, 4090 host, identical across all three decoders, Android immune —
it never negotiates the slice wire), and most likely the Linux/AMD grey
smears too.

Make the shadow prefix check unconditional (one AU-sized copy + compare
per frame — noise next to the encode), and on divergence latch
subframe_broken before bailing into the existing encode-stall recovery:
the in-place rebuild now resolves sub-frame OFF for every later session
open on this encoder, so the driver that lied is disarmed after exactly
one rebuild instead of looping into MAX_ENCODER_RESETS. The client sees
the abandoned-AU path it already handles (ages the frame out, IDR
re-anchors) — a one-second hiccup, once, instead of endless corruption.
Both direct-NVENC backends (Windows D3D11, Linux CUDA) get the identical
change; the bail path also stops leaking the popped frame's input mapping.
2026-08-26 18:45:20 +02:00
enricobuehler 1511374959 PyroWave forces Automatic bitrate (ABR overhaul RFC §5.2)
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m27s
ci / rust-arm64 (pull_request) Successful in 3m37s
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 55s
ci / docs-site (pull_request) Successful in 2m24s
android / android (pull_request) Successful in 7m51s
ci / rust (pull_request) Successful in 8m18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 5m55s
An explicit client rate under PyroWave was ill-defined (all-intra bpp
semantics — the operating point is bits per pixel, not kbps) and bypassed
the PUNKTFUNK_PYROWAVE_MAX_MBPS operator ceiling entirely.

Host: resolve_bitrate_kbps_for ignores the requested rate under PyroWave
(warn when overriding) so every PyroWave session goes through the per-mode
bpp pin + ceiling, and bitrate_auto treats PyroWave sessions as Automatic
so mode switches re-resolve the pin whatever the Hello carried.

Clients: pf-client-core sends bitrate 0 when the preference is an
ADVERTISED PyroWave (a failed decode probe falls back to H.26x, where the
user's rate must survive); the Apple client mirrors the same gate at its
own Hello site. The console UI and the Apple settings dim the bitrate
control with a one-line explanation instead of offering an inert rate.
The stored setting is preserved everywhere — switching codecs back
restores it.
2026-08-26 17:43:31 +02:00
19 changed files with 773 additions and 129 deletions
+25
View File
@@ -420,6 +420,31 @@ feature surfaces ("--browse needs the console UI", exit non-zero).
identity-selection gate runs where the origin-isolation gate already did. Both have the same
property: a failure mode only a browser would catch.
### `launcher_ui` grows a second Heroic value, for its console mode
**Plugin-facing.** `launcher_ui` accepts **`heroic-console`** on Linux, alongside `heroic` and
`lutris`. It resolves to the same prefix `heroic` does — the native binary if on `PATH`, else the
Flatpak — plus `--console --fullscreen`.
Heroic 2.21 added a fullscreen gamepad UI, and it takes **two** flags: `--console` only routes the
UI to that front end (`isCLIConsoleMode`), and `--fullscreen` is what fills the screen
(`isCLIFullscreen`). Neither is reachable by URI — `heroic://` speaks only `ping` and `launch` — so
this is the same shape as Playnite's fullscreen tile on Windows, where the registered protocol
handler can only open the desktop app.
That makes `launcher_ui`'s value a launcher **UI** rather than a launcher, which it already was on
Windows (`playnite` has always meant `Playnite.FullscreenApp.exe`). A `heroic_ui` kind mirroring
`steam_ui` would have been tidier and was rejected: an unknown *kind* degrades to an unlaunchable
tile on an N-1 host, but an unknown *value* is a hard 400 that refuses the whole reconcile — so
either shape has to be gated on `minHost` in the plugin index, and the value is the smaller change.
**A plugin publishing `heroic-console` must set `minHost` to this release.**
Also here: `resolvable_launcher_ui` now probes `heroic_launch_prefix()` for both Heroic values, the
way it already did for Playnite. Both tiles are dropped from a reconcile on a box where Heroic
cannot be resolved, instead of being published as tiles that do nothing — reachable by keeping
`~/.config/heroic` after uninstalling Heroic, since the plugin's `detect` only looks for that
directory.
---
## v0.31.3
@@ -399,7 +399,7 @@ final class SessionModel: ObservableObject {
let hz = UInt32(clamping: effective.refreshHz)
let compositor = PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: effective.compositor)) ?? .auto
let bitrateKbps = UInt32(clamping: effective.bitrateKbps)
var bitrateKbps = UInt32(clamping: effective.bitrateKbps)
let audioChannels = UInt8(clamping: effective.audioChannels)
// The audio format this session ASKS for the user's choice, at every channel count.
//
@@ -419,6 +419,15 @@ final class SessionModel: ObservableObject {
let (audioRateHz, audioBits) = audioFormat.wire
let hdrEnabled = effective.hdrEnabled
let preferredCodec = PunktfunkConnection.codecByte(effective.codec)
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): a fixed kbps is
// ill-defined for the all-intra codec (bpp is the operating point) and used to bypass
// the host's operator ceiling send 0 and let the host pin its per-mode rate. Gated
// like the advertisement below: a device that failed the Metal probe never offers the
// codec, falls back to H.26x, and the user's rate must survive there. The stored
// setting is untouched, so switching codecs back restores it.
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
bitrateKbps = 0
}
let pin = host.pinnedSHA256
// Capability gate (main-actor screen APIs): only advertise HDR when this display can
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
@@ -256,11 +256,25 @@ extension SettingsView {
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
@ViewBuilder private var bitrateRows: some View {
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
field: "bitrate_kbps") {
Toggle("Automatic bitrate", isOn: automaticBitrate)
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0 and the
// host pins a per-mode rate, so a live rate control here would change nothing. Same
// support gate as the codec picker offering the option; the stored rate is untouched,
// so switching the codec back restores it.
if effective.codec == "pyrowave", MetalWaveletDecoder.supported {
described("PyroWave sets its own rate from the stream mode — a fixed bitrate "
+ "doesn't apply.",
field: "bitrate_kbps") {
Toggle("Automatic bitrate", isOn: .constant(true))
.disabled(true)
}
} else {
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
field: "bitrate_kbps") {
Toggle("Automatic bitrate", isOn: automaticBitrate)
}
}
if effective.bitrateKbps != 0 {
if effective.codec != "pyrowave" || !MetalWaveletDecoder.supported,
effective.bitrateKbps != 0 {
HStack(spacing: 12) {
Slider(value: bitrateSlider, in: 0...1) {
Text("Bitrate")
@@ -450,15 +450,24 @@ struct SettingsView: View {
title: "Render scale",
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
selection: $renderScale)
TVSelectionRow(
title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
selection: $bitrateKbps)
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0
// and the host pins a per-mode rate. tvOS has no codec picker, so this only
// fires on a codec synced from another device but the row must not offer a
// rate the session ignores. The stored value is kept.
if codec == "pyrowave", MetalWaveletDecoder.supported {
tvCaption("PyroWave sets its own rate from the stream mode — the bitrate "
+ "setting doesn't apply.")
} else {
TVSelectionRow(
title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
selection: $bitrateKbps)
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
}
}
TVSelectionRow(
title: "10-bit HDR",
+20 -1
View File
@@ -795,6 +795,25 @@ fn pump(
// rung at all, so advertising HEVC would promise what this build cannot keep.
&params.decoder,
) & !params.exclude_codecs;
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): a fixed kbps is
// ill-defined for the all-intra codec (bpp is the operating point) and used to bypass
// the host's `PUNKTFUNK_PYROWAVE_MAX_MBPS` ceiling. Send 0 and let the host pin; the
// stored profile value is untouched, so switching codecs back restores it. Gated on the
// codec actually being ADVERTISED: a pyrowave preference on a device that failed the
// decode probe falls back to H.26x, where the user's explicit rate must survive.
let bitrate_kbps = if preferred == punktfunk_core::quic::CODEC_PYROWAVE
&& advertised_codecs & punktfunk_core::quic::CODEC_PYROWAVE != 0
{
if params.bitrate_kbps != 0 {
tracing::info!(
stored_kbps = params.bitrate_kbps,
"PyroWave forces Automatic bitrate — asking the host for its per-mode pin"
);
}
0
} else {
params.bitrate_kbps
};
if params.exclude_codecs != 0 {
tracing::info!(
excluded = params.exclude_codecs,
@@ -840,7 +859,7 @@ fn pump(
params.mode,
params.compositor,
params.gamepad,
params.bitrate_kbps,
bitrate_kbps,
params.video_caps,
params.audio_channels,
audio_rate_hz,
+61 -4
View File
@@ -677,9 +677,12 @@ impl SettingsScreen {
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
// Y on the Bitrate row opens the typed rate; on every other row it means nothing,
// and the hint bar only offers it where it does.
// and the hint bar only offers it where it does. Not under PyroWave — the row is
// dimmed (see `row_spec`) and a typed rate would be as inert as the ladder.
if ev == MenuEvent::Secondary {
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate) {
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate)
&& ctx.settings.codec != "pyrowave"
{
self.custom_bitrate = Some(String::new());
Some(MenuPulse::Confirm)
} else {
@@ -824,6 +827,11 @@ impl SettingsScreen {
Hint::new(HintKey::Confirm, "Open"),
Hint::new(HintKey::Back, "Done"),
],
// Dimmed under PyroWave (row_spec): offering "Adjust" on an inert row would
// teach a control that answers with a thud.
Some(RowId::Bitrate) if ctx.settings.codec == "pyrowave" => {
vec![Hint::new(HintKey::Back, "Done")]
}
// The one row with a value the ladder cannot name every version of.
Some(RowId::Bitrate) => vec![
Hint::new(HintKey::Adjust, "Adjust"),
@@ -910,7 +918,7 @@ impl SettingsScreen {
let detail = ids
.get(self.list.cursor)
.copied()
.map_or("", |id| detail(id, ctx.platform));
.map_or("", |id| detail(id, ctx));
fonts.centered(
canvas,
detail,
@@ -1042,6 +1050,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// that one is different.
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): the session sends 0
// whatever this row stores and the host pins a per-mode bpp rate. Dimmed, not live —
// a control that changes nothing must say so. The stored rate is kept: switching the
// codec back restores it.
RowId::Bitrate => s.codec != "pyrowave",
// ⚠ Lossless follows the channel count for a reason that has MOVED, and the old reason
// is still written down in several places that are now wrong (`hi-res-audio.md` §4.2's
// blanket "surround does not fit a datagram", and `trust::Settings::audio_format`'s doc
@@ -1301,8 +1314,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
/// The focused row's one-line explainer. Takes the platform because two desktop rows
/// advertise desktop-only live chords (Ctrl+Alt+Shift+…) that no Android build has — a
/// shortcut the device cannot press must not be taught.
fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
fn detail(id: RowId, ctx: &Ctx) -> &'static str {
use crate::platform::Platform;
let platform = ctx.platform;
match id {
RowId::Resolution => {
"The host creates a virtual display at exactly this size — no scaling. \
@@ -1313,6 +1327,10 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
"The host renders larger or smaller than the stream mode and this window \
resamples above 1× supersamples, below saves bandwidth."
}
RowId::Bitrate if ctx.settings.codec == "pyrowave" => {
"PyroWave sets its own rate from the stream mode (all-intra) — a fixed bitrate \
doesn't apply. Pick another codec to use this setting."
}
RowId::Bitrate => {
"Automatic uses the host's default (20 Mbps). Y types an exact rate, up to 2 Gbps."
}
@@ -1573,6 +1591,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
.map(|i| s.render_scale = RENDER_SCALES[i])
}
RowId::Bitrate => {
// Inert under PyroWave — a boundary thud, matching what the dimmed row shows
// (the host pins the rate; see `row_spec`).
if s.codec == "pyrowave" {
return false;
}
// A typed rate (or one a desktop shell's spinner stored) sits BETWEEN rungs, and
// the generic step snaps a value it cannot find to the first option — which here
// is Automatic, i.e. one nudge throws the custom rate away. Step to the rung the
@@ -2145,6 +2168,40 @@ pub(super) mod tests {
assert!(ctx.settings.echo_cancel);
}
/// Bitrate follows the codec: dimmed and inert under PyroWave (the host pins a per-mode
/// rate and the session sends 0 — ABR overhaul RFC §5.2), live for every other codec,
/// and the stored rate survives the dim so switching back restores it.
#[test]
fn bitrate_dims_under_pyrowave() {
let (mut settings, pads) = ctx_parts();
settings.codec = "pyrowave".into();
settings.bitrate_kbps = 80_000;
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::Bitrate, &ctx, &[]).enabled);
assert!(
!adjust(RowId::Bitrate, 1, false, &mut ctx),
"pyrowave = thud"
);
assert!(!adjust(RowId::Bitrate, 1, true, &mut ctx), "A too");
assert_eq!(ctx.settings.bitrate_kbps, 80_000, "the stored rate is kept");
ctx.settings.codec = "hevc".into();
assert!(row_spec(RowId::Bitrate, &ctx, &[]).enabled);
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
}
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
+41 -15
View File
@@ -715,10 +715,12 @@ struct ChunkState {
slices_out: u32,
/// The AU-opening chunk (`AuChunk::first`) has been handed out.
opened: bool,
/// Debug-build shadow of every emitted byte, cross-checked against the finishing blocking
/// lock's full AU — a mis-cut chunk fails loudly in the on-hw tests instead of silently
/// corrupting the wire. Compiled out of release builds.
#[cfg(debug_assertions)]
/// Shadow of every emitted byte, cross-checked against the finishing blocking lock's
/// full AU. Release-mode since the Strix-Halo field report (black bands "like an
/// equalizer"): a driver branch whose doNotWait `bitstreamSizeInBytes` runs ahead of the
/// flushed slice bytes ships not-yet-written buffer content, and only this comparison
/// can see it — the wire stays self-consistent, so no client counter ever moves. Costs
/// one AU-sized copy + compare per frame, noise next to the encode itself.
shadow: Vec<u8>,
}
@@ -728,7 +730,6 @@ impl ChunkState {
emitted: 0,
slices_out: 0,
opened: false,
#[cfg(debug_assertions)]
shadow: Vec::new(),
}
}
@@ -871,6 +872,12 @@ pub struct NvencCudaEncoder {
/// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice +
/// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`].
subframe_chunks: bool,
/// This driver's sub-frame readback was caught publishing bytes the finished AU disowns
/// (the `poll_chunk` finish-lock prefix check) — every later `query_caps` resolve on this
/// encoder turns sub-frame OFF, so the stall-recovery rebuild the divergence bails into
/// heals permanently instead of re-arming the same broken path (which would loop rebuilds
/// into `MAX_ENCODER_RESETS` and end the session). Never cleared: a fresh encoder retests.
subframe_broken: bool,
/// `NV_ENC_CAPS_NUM_ENCODER_ENGINES` — how many NVENC engines this GPU has, probed in
/// [`query_caps`]. `0` = not probed / unreadable. The split-encode ceiling: the driver accepts
/// a split wider than the hardware and silently encodes narrower, so this is the only honest
@@ -978,6 +985,7 @@ impl NvencCudaEncoder {
subframe_on: false,
subframe_forced: false,
subframe_chunks: false,
subframe_broken: false,
encoder_engines: 0,
last_submit_at: None,
send_spread_us: 0,
@@ -1191,7 +1199,11 @@ impl NvencCudaEncoder {
// regression). PUNKTFUNK_NVENC_SLICES / PUNKTFUNK_NVENC_SUBFRAME stay the explicit
// operator overrides in both directions.
self.slices = resolve_slices(self.codec, 4.min(self.max_slices));
self.subframe_on = resolve_subframe(self.subframe_cap);
// `subframe_broken` wins over everything, the operator force included: it is only
// ever set after this session PROVED the driver's sub-frame accounting corrupt
// (the finish-lock prefix check), and re-arming would corrupt again. Per-encoder,
// so a fresh session retests the driver.
self.subframe_on = resolve_subframe(self.subframe_cap) && !self.subframe_broken;
self.subframe_forced = subframe_env_forced();
tracing::info!(
rfi = self.rfi_supported,
@@ -2571,7 +2583,6 @@ impl Encoder for NvencCudaEncoder {
.nv_ok()
.map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk)", e))?;
let cs = self.chunk.get_or_insert_with(ChunkState::new);
#[cfg(debug_assertions)]
cs.shadow.extend_from_slice(&data);
let first = !cs.opened;
cs.opened = true;
@@ -2621,20 +2632,35 @@ impl Encoder for NvencCudaEncoder {
let total = lock.bitstreamSizeInBytes as usize;
let full = std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, total);
let cs = self.chunk.take().unwrap_or_else(ChunkState::new);
if cs.emitted > total {
// The completion authority judges the sampler: bytes the doNotWait locks published
// must be a byte-exact prefix of the finished AU, or the wire already carries
// corruption no client can detect (self-consistent tiling, wrong content — the
// black-band field report). On divergence, latch sub-frame off for every later
// session open and bail into the encode-stall recovery: it rebuilds in place
// (now WITHOUT sub-frame, so once per session at most) and forces an IDR — the
// client ages out the partial AU and re-anchors. Short-circuit order matters:
// `emitted > total` makes the prefix slice below ill-formed.
let diverged = cs.emitted > total || cs.shadow.as_slice() != &full[..cs.emitted];
if diverged {
let _ = (api().unlock_bitstream)(self.encoder, bs);
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
self.subframe_broken = true;
tracing::warn!(
emitted = cs.emitted,
total,
"NVENC sub-frame readback diverged from the finished AU — this driver's \
early slice publishes cannot be trusted; disarming sub-frame for every \
later session open and rebuilding the encoder"
);
bail!(
"NVENC chunked poll: {} bytes already emitted but the finished AU is only \
{} sub-frame readback reported bytes the final lock disowns",
"NVENC chunked poll: sub-frame readback diverged from the finished AU \
({} bytes emitted, {} total) sub-frame disarmed, rebuild required",
cs.emitted,
total
);
}
#[cfg(debug_assertions)]
if cs.shadow.as_slice() != &full[..cs.emitted] {
let _ = (api().unlock_bitstream)(self.encoder, bs);
bail!("NVENC chunked poll: emitted chunks diverge from the finished AU prefix");
}
let data = full[cs.emitted..].to_vec();
let keyframe = matches!(
lock.pictureType,
+41 -15
View File
@@ -655,6 +655,12 @@ pub struct NvencD3d11Encoder {
/// SYNC retrieve — the async retrieve owns the bitstream from its thread, a doNotWait
/// sampler here would race it).
subframe_chunks: bool,
/// This driver's sub-frame readback was caught publishing bytes the finished AU disowns
/// (the `poll_chunk` finish-lock prefix check) — every later session open on this encoder
/// resolves sub-frame OFF, so the stall-recovery rebuild the divergence bails into heals
/// permanently instead of re-arming the same broken path (which would loop rebuilds into
/// `MAX_ENCODER_RESETS` and end the session). Never cleared: a fresh encoder retests.
subframe_broken: bool,
/// In-progress chunked readback of the FRONT `pending` AU (see [`ChunkState`]).
chunk: Option<ChunkState>,
session_async: bool,
@@ -706,10 +712,12 @@ struct ChunkState {
slices_out: u32,
/// The AU-opening chunk (`AuChunk::first`) has been handed out.
opened: bool,
/// Debug-build shadow of every emitted byte, cross-checked against the finishing blocking
/// lock's full AU — a mis-cut chunk fails loudly in debug instead of silently corrupting
/// the wire. Compiled out of release builds.
#[cfg(debug_assertions)]
/// Shadow of every emitted byte, cross-checked against the finishing blocking lock's
/// full AU. Release-mode since the Strix-Halo field report (black bands "like an
/// equalizer"): a driver branch whose doNotWait `bitstreamSizeInBytes` runs ahead of the
/// flushed slice bytes ships not-yet-written buffer content, and only this comparison
/// can see it — the wire stays self-consistent, so no client counter ever moves. Costs
/// one AU-sized copy + compare per frame, noise next to the encode itself.
shadow: Vec<u8>,
}
@@ -719,7 +727,6 @@ impl ChunkState {
emitted: 0,
slices_out: 0,
opened: false,
#[cfg(debug_assertions)]
shadow: Vec::new(),
}
}
@@ -789,6 +796,7 @@ impl NvencD3d11Encoder {
slices: 1,
max_slices: max_slices.max(1),
subframe_chunks: false,
subframe_broken: false,
chunk: None,
session_async: false,
last_rfi_range: None,
@@ -1315,7 +1323,11 @@ impl NvencD3d11Encoder {
// the 2026-07-31 .173 on-glass A/B — no regression, and slice-progressive clients
// gain the encode/wire overlap); `PUNKTFUNK_NVENC_SUBFRAME` stays the tri-state
// operator escape in both directions.
let subframe_req = resolve_subframe(self.subframe_cap);
// `subframe_broken` wins over everything, the operator force included: it is only
// ever set after this session PROVED the driver's sub-frame accounting corrupt
// (the finish-lock prefix check), and re-arming would corrupt again. Per-encoder,
// so a fresh session retests the driver.
let subframe_req = resolve_subframe(self.subframe_cap) && !self.subframe_broken;
let (split_mode, subframe_req) =
resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_env_forced());
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
@@ -2188,7 +2200,6 @@ impl Encoder for NvencD3d11Encoder {
.nv_ok()
.map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk)", e))?;
let cs = self.chunk.get_or_insert_with(ChunkState::new);
#[cfg(debug_assertions)]
cs.shadow.extend_from_slice(&data);
let first = !cs.opened;
cs.opened = true;
@@ -2238,20 +2249,35 @@ impl Encoder for NvencD3d11Encoder {
let total = lock.bitstreamSizeInBytes as usize;
let full = std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, total);
let cs = self.chunk.take().unwrap_or_else(ChunkState::new);
if cs.emitted > total {
// The completion authority judges the sampler: bytes the doNotWait locks published
// must be a byte-exact prefix of the finished AU, or the wire already carries
// corruption no client can detect (self-consistent tiling, wrong content — the
// black-band field report). On divergence, latch sub-frame off for every later
// session open and bail into the encode-stall recovery: it rebuilds in place
// (now WITHOUT sub-frame, so once per session at most) and forces an IDR — the
// client ages out the partial AU and re-anchors. Short-circuit order matters:
// `emitted > total` makes the prefix slice below ill-formed.
let diverged = cs.emitted > total || cs.shadow.as_slice() != &full[..cs.emitted];
if diverged {
let _ = (api().unlock_bitstream)(self.encoder, bs);
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
self.subframe_broken = true;
tracing::warn!(
emitted = cs.emitted,
total,
"NVENC sub-frame readback diverged from the finished AU — this driver's \
early slice publishes cannot be trusted; disarming sub-frame for every \
later session open and rebuilding the encoder"
);
bail!(
"NVENC chunked poll: {} bytes already emitted but the finished AU is only \
{} sub-frame readback reported bytes the final lock disowns",
"NVENC chunked poll: sub-frame readback diverged from the finished AU \
({} bytes emitted, {} total) sub-frame disarmed, rebuild required",
cs.emitted,
total
);
}
#[cfg(debug_assertions)]
if cs.shadow.as_slice() != &full[..cs.emitted] {
let _ = (api().unlock_bitstream)(self.encoder, bs);
bail!("NVENC chunked poll: emitted chunks diverge from the finished AU prefix");
}
let data = full[cs.emitted..].to_vec();
let keyframe = matches!(
lock.pictureType,
+132 -27
View File
@@ -6,10 +6,13 @@
//!
//! [`set_media_qos`] DSCP-tags the latency-sensitive video/audio traffic (+ Linux `SO_PRIORITY`) so a
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
//! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client
//! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer
//! ISPs/routers. On Windows a plain `IP_TOS` is silently stripped from the wire, so the marking
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio.
//! Default: **on toward private-network peers** (RFC1918 / ULA / link-local / loopback — ABR
//! overhaul RFC §2.5), where the AP-side WMM mapping is a real airtime-priority win and the
//! documented bleach/reject risk (some consumer ISPs/routers on the WAN path) cannot apply; off
//! toward anything routable. `PUNKTFUNK_DSCP=1` forces it on everywhere, `=0` is the kill switch,
//! and [`set_dscp_default`] (the Android low-latency tie-in) still forces it on regardless of the
//! peer. On Windows a plain `IP_TOS` is silently stripped from the wire, so the marking
//! goes through qWAVE flows instead (see [`super::qos_windows`]) — the caller holds the returned
//! [`QosFlow`] guard for as long as the socket sends media.
@@ -69,27 +72,58 @@ impl MediaClass {
}
}
/// Runtime default for DSCP marking when `PUNKTFUNK_DSCP` is unset (see [`set_dscp_default`]).
/// Off unless an embedder opts in — on Wi-Fi, access points commonly map DSCP to WMM access
/// categories (a real airtime-priority win), but wired paths rarely honour it and some bleach or
/// reject marked packets, so it never turns on by itself.
/// Embedder force-on for DSCP marking when `PUNKTFUNK_DSCP` is unset (see [`set_dscp_default`]).
/// `false` (the default) is AUTO — marking toward private-network peers only, the RFC §2.5
/// default; `true` marks toward every peer (the caller has judged its path, e.g. the Android
/// low-latency mode over a VPN the address math can't recognize as local).
static DSCP_DEFAULT: AtomicBool = AtomicBool::new(false);
/// Opt in to (or back out of) DSCP marking for sockets created from now on. Must be called BEFORE
/// connecting — the tag is applied at socket creation. The Android client ties this to its
/// experimental low-latency mode; `PUNKTFUNK_DSCP` still overrides in either direction.
/// Force DSCP marking on for sockets created from now on (or back to the private-peer AUTO
/// default with `false`). Must be called BEFORE connecting — the tag is applied at socket
/// creation. The Android client ties this to its experimental low-latency mode;
/// `PUNKTFUNK_DSCP` still overrides in either direction.
pub fn set_dscp_default(enabled: bool) {
DSCP_DEFAULT.store(enabled, Ordering::Relaxed);
}
/// Whether DSCP/QoS marking is enabled: `PUNKTFUNK_DSCP` when set (`1`/`true`/`on` forces it on,
/// `0`/`false`/`off` forces it off — e.g. to rule QoS out while debugging a flaky AP), else the
/// [`set_dscp_default`] runtime default.
pub(crate) fn dscp_enabled() -> bool {
match std::env::var("PUNKTFUNK_DSCP").as_deref() {
Ok("1") | Ok("true") | Ok("on") => true,
Ok("0") | Ok("false") | Ok("off") => false,
_ => DSCP_DEFAULT.load(Ordering::Relaxed),
/// The DSCP decision (pure — unit-tested): the env override wins in either direction
/// (`1`/`true`/`on` forces on everywhere, `0`/`false`/`off` is the kill switch — e.g. to rule
/// QoS out while debugging a flaky AP); else the embedder's force-on; else AUTO — mark exactly
/// when the peer is a private-network address.
fn dscp_decision(env: Option<&str>, embedder_on: bool, peer_private: bool) -> bool {
match env {
Some("1") | Some("true") | Some("on") => true,
Some("0") | Some("false") | Some("off") => false,
_ => embedder_on || peer_private,
}
}
/// [`dscp_decision`] against the process env, the embedder default, and the socket's connected
/// peer.
pub(crate) fn dscp_enabled_for(peer: Option<std::net::SocketAddr>) -> bool {
dscp_decision(
std::env::var("PUNKTFUNK_DSCP").ok().as_deref(),
DSCP_DEFAULT.load(Ordering::Relaxed),
peer.is_some_and(|p| is_private_peer(&p)),
)
}
/// A peer address the local network owns: RFC1918 / link-local / loopback IPv4, and
/// loopback / ULA (`fc00::/7`) / link-local (`fe80::/10`) IPv6, including v4-mapped forms.
/// The DSCP bleach/reject risk lives on ISP paths — none of these ever cross one.
fn is_private_peer(addr: &std::net::SocketAddr) -> bool {
fn v4(ip: std::net::Ipv4Addr) -> bool {
ip.is_private() || ip.is_loopback() || ip.is_link_local()
}
match addr.ip() {
std::net::IpAddr::V4(ip) => v4(ip),
std::net::IpAddr::V6(ip) => {
let seg0 = ip.segments()[0];
ip.is_loopback()
|| (seg0 & 0xfe00) == 0xfc00
|| (seg0 & 0xffc0) == 0xfe80
|| ip.to_ipv4_mapped().is_some_and(v4)
}
}
}
@@ -104,16 +138,18 @@ pub struct QosFlow {
_never: std::convert::Infallible,
}
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
/// unless `PUNKTFUNK_DSCP=1`. Every step is best-effort (failures logged at debug, never fatal) — QoS
/// is a nicety, not required for correctness.
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A
/// no-op toward a non-private peer unless forced (see [`dscp_decision`]). Every step is
/// best-effort (failures logged at debug, never fatal) — QoS is a nicety, not required for
/// correctness.
///
/// The socket must already be `connect`ed (Windows derives the qWAVE flow from the connected
/// 5-tuple). IPv4 only (all current media sockets bind `0.0.0.0`); a v6 socket simply isn't
/// tagged. Returns the [`QosFlow`] guard on Windows — keep it alive with the socket; `None`
/// elsewhere (the marking is a plain socket option) and whenever a step refused.
/// 5-tuple; the private-peer default reads the same connected address). IPv4 only (all current
/// media sockets bind `0.0.0.0`); a v6 socket simply isn't tagged. Returns the [`QosFlow`]
/// guard on Windows — keep it alive with the socket; `None` elsewhere (the marking is a plain
/// socket option) and whenever a step refused.
pub fn set_media_qos(socket: &UdpSocket, class: MediaClass) -> Option<QosFlow> {
if !dscp_enabled() {
if !dscp_enabled_for(socket.peer_addr().ok()) {
return None;
}
#[cfg(windows)]
@@ -166,12 +202,81 @@ mod tests {
#[test]
fn qos_and_buffer_growth_are_best_effort_and_never_panic() {
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
// No PUNKTFUNK_DSCP in the test env → early return; must not panic regardless.
// Unconnected socket: no peer → no locality → the AUTO default stays off (and no
// PUNKTFUNK_DSCP in the test env); must not panic regardless.
assert!(set_media_qos(&sock, MediaClass::Video).is_none());
assert!(set_media_qos(&sock, MediaClass::Audio).is_none());
grow_socket_buffers(&sock);
}
/// RFC §2.5: the default marks exactly the peers the local network owns — the env still
/// wins in either direction and the embedder hook still forces on (a VPN path the address
/// math can't recognize).
#[test]
fn dscp_defaults_on_for_private_peers_only() {
// The pure decision.
assert!(dscp_decision(Some("1"), false, false));
assert!(
!dscp_decision(Some("0"), true, true),
"the kill switch beats everything"
);
assert!(dscp_decision(None, true, false), "embedder force-on");
assert!(
dscp_decision(None, false, true),
"AUTO: a private peer marks"
);
assert!(
!dscp_decision(None, false, false),
"AUTO: a routable peer does not"
);
// The address classifier.
use std::net::SocketAddr;
for a in [
"192.168.1.20:47999",
"10.0.0.7:1",
"172.16.3.4:5",
"169.254.10.1:2",
"127.0.0.1:9",
"[fe80::1]:1",
"[fd12:3456::1]:1",
"[::1]:1",
"[::ffff:192.168.1.2]:1",
] {
assert!(
is_private_peer(&a.parse::<SocketAddr>().unwrap()),
"{a} is local"
);
}
for a in [
"1.1.1.1:53",
"84.23.10.9:47999",
"172.32.0.1:1", // one past RFC1918's 172.16/12
"[2001:db8::1]:1",
"[::ffff:8.8.8.8]:1",
] {
assert!(
!is_private_peer(&a.parse::<SocketAddr>().unwrap()),
"{a} is routable"
);
}
}
/// The AUTO path end to end: a CONNECTED loopback socket has a private peer, so the
/// default now marks it (the unconnected socket above stays unmarked — no peer).
#[test]
fn a_connected_loopback_socket_marks_by_default() {
let target = UdpSocket::bind("127.0.0.1:0").unwrap();
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
sock.connect(target.local_addr().unwrap()).unwrap();
let _ = set_media_qos(&sock, MediaClass::Video);
#[cfg(target_os = "linux")]
{
let s = socket2::SockRef::from(&sock);
assert_eq!(s.tos_v4().unwrap(), 0xA0, "AUTO marked the private peer");
}
}
#[test]
fn apply_qos_tags_the_socket() {
// Exercise the enabled path directly (no env), and read the options back where we can.
+60 -4
View File
@@ -165,6 +165,21 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
// The same resolution the `heroic` game launches use (native binary, else Flatpak), just
// without `--no-gui` and without a URI: that opens Heroic's window, which IS the tile.
"heroic" => heroic_launch_prefix(),
// Heroic's console mode — its couch front end, the Big Picture of this launcher.
//
// It takes TWO flags, which is not obvious and is why this is the host's business and
// not a plugin's: `--console` only routes the UI to that front end, and `--fullscreen`
// is what actually fills the screen (Heroic reads them separately —
// `isCLIConsoleMode` / `isCLIFullscreen`). Neither is a URI: `heroic://` speaks only
// `ping` and `launch`, so a protocol hand-off cannot reach console mode at all — the
// same reason Playnite's fullscreen tile spawns its exe directly.
//
// Console mode arrived in Heroic 2.21.0. An older Heroic ignores the unknown
// `--console` and honours `--fullscreen`, so the tile degrades to a fullscreen desktop
// UI rather than to nothing.
"heroic-console" => {
heroic_launch_prefix().map(|p| format!("{p} --console --fullscreen"))
}
// Bare `lutris` opens the Lutris window; with a `lutris:rungameid/…` URI it launches a
// game instead (the `lutris_id` kind above).
"lutris" => Some("lutris".into()),
@@ -532,9 +547,16 @@ pub(crate) fn valid_playnite_id(value: &str) -> bool {
/// The launcher UIs **this host** can open, as `launcher_ui` values (D4).
///
/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single
/// UI to open, so the value is just which launcher. Steam keeps its own [`valid_steam_ui`] kind
/// because it has two (Big Picture and the desktop client), which is a genuinely different choice.
/// One kind for every launcher but Steam, rather than one kind each. A value names a launcher *UI*,
/// which for most of them is the same thing as naming the launcher — and where it is not, the value
/// says which one: `heroic` opens Heroic's window, `heroic-console` its couch front end, and on
/// Windows `playnite` has always meant Playnite's **Fullscreen** app rather than its desktop one.
/// Steam keeps its own [`valid_steam_ui`] kind because it was the first launcher with two UIs worth
/// opening, and because both of its values are the same length of word — splitting Heroic's two out
/// into a `heroic_ui` kind today would buy symmetry and cost every N-1 host: an unknown *kind*
/// degrades to an unlaunchable tile, but an unknown *value* is a hard 400 that refuses the whole
/// reconcile, so a new value is the shape that has to be gated on `minHost` in the plugin index
/// either way.
///
/// Platform-gated, because a value naming a launcher this OS cannot run is not a tile that merely
/// looks odd — it is one that fails at launch. Validated inbound too, so a plugin gets a 400 it can
@@ -548,7 +570,7 @@ pub(crate) fn valid_playnite_id(value: &str) -> bool {
fn launcher_ui_stores() -> &'static [&'static str] {
#[cfg(target_os = "linux")]
{
&["heroic", "lutris"]
&["heroic", "heroic-console", "lutris"]
}
// Playnite's activation is verified (2026-08-06, on the .173 box); Epic, GOG Galaxy and the
// Xbox app are still unwired — each needs its own verified activation, and an unverified guess
@@ -594,6 +616,14 @@ pub(crate) fn resolvable_launcher_ui(value: &str) -> bool {
if value == "playnite" {
return playnite_fullscreen_exe().is_some();
}
// Same question for both Heroic tiles, and the same answer: they resolve to whatever
// `heroic_launch_prefix` finds, so when that finds nothing the tile is dead and must not be
// published. Keeping `~/.config/heroic` around after uninstalling Heroic is enough to reach
// this — the plugin's `detect` only looks for that directory.
#[cfg(target_os = "linux")]
if matches!(value, "heroic" | "heroic-console") {
return heroic_launch_prefix().is_some();
}
true
}
@@ -1123,10 +1153,21 @@ mod tests {
#[cfg(target_os = "linux")]
{
assert!(known_launcher_ui("heroic"));
assert!(known_launcher_ui("heroic-console"));
assert!(known_launcher_ui("lutris"));
// Not wired on this OS — outside the vocabulary, so it is refused inbound rather than
// becoming a tile that does nothing.
assert!(!known_launcher_ui("gog"));
// Both Heroic tiles resolve through the same probe, so a box without Heroic drops both
// rather than publishing one dead tile beside the other.
assert_eq!(
resolvable_launcher_ui("heroic"),
heroic_launch_prefix().is_some()
);
assert_eq!(
resolvable_launcher_ui("heroic-console"),
heroic_launch_prefix().is_some()
);
}
#[cfg(windows)]
{
@@ -1253,6 +1294,21 @@ mod tests {
if let Some(cmd) = ui("heroic") {
assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}");
assert!(!cmd.contains("heroic://"), "no game URI: {cmd:?}");
assert!(
!cmd.contains("--console"),
"that is the other tile: {cmd:?}"
);
}
// Console mode needs BOTH flags — `--console` alone routes the UI without filling the
// screen, which from a couch is the bug this tile exists to avoid. Same prefix as the
// window tile, so it is `None` on the same boxes.
assert_eq!(ui("heroic-console").is_some(), ui("heroic").is_some());
if let Some(cmd) = ui("heroic-console") {
assert!(cmd.contains("--console"), "{cmd:?}");
assert!(cmd.contains("--fullscreen"), "{cmd:?}");
assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}");
// Gamescope spawns by `split_whitespace`, so every token has to stand alone.
assert!(cmd.split_whitespace().any(|t| t == "--console"), "{cmd:?}");
}
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
+152 -9
View File
@@ -931,7 +931,13 @@ fn resolve_bitrate_kbps(requested: u32) -> u32 {
/// an Automatic client (`0`) gets the codec's ~1.6 bpp operating point for the negotiated
/// mode instead of the 20 Mbps H.26x default. The rate is then PINNED for the session:
/// the client's ABR controller stays off for this codec and the host refuses mid-stream
/// retargets. An explicit client rate is honored unchanged (the operator knows the link).
/// retargets.
///
/// PyroWave ignores an explicit client rate too (ABR overhaul RFC §5.2): a fixed rate is
/// ill-defined for an all-intra codec (bpp is the operating point, not kbps) and it used to
/// bypass the `PUNKTFUNK_PYROWAVE_MAX_MBPS` operator ceiling. Clients grey the control out;
/// this arm is the belt-and-braces for embedders that never update their UI. H.26x/AV1
/// explicit rates are honored unchanged (the operator knows the link).
fn resolve_bitrate_kbps_for(
codec: crate::encode::Codec,
requested: u32,
@@ -939,7 +945,14 @@ fn resolve_bitrate_kbps_for(
chroma: crate::encode::ChromaFormat,
bit_depth: u8,
) -> u32 {
if requested == 0 && codec == crate::encode::Codec::PyroWave {
if codec == crate::encode::Codec::PyroWave {
if requested != 0 {
tracing::warn!(
requested_kbps = requested,
"an explicit bitrate is ill-defined under PyroWave (all-intra bpp semantics) — \
treating it as Automatic and resolving the per-mode pin"
);
}
// ~1.6 bpp for 4:2:0. 4:4:4 doubles the samples per pixel (3 vs 1.5) but chroma
// compresses better than luma → ×1.625 ≈ 2.6 bpp; 16-bit planes add ~15 % (both
// factors measured against the Phase-0 fixture matrix, design/pyrowave-444-hdr.md).
@@ -976,7 +989,8 @@ fn resolve_bitrate_kbps_for(
/// Operator ceiling for PyroWave's open-loop Automatic bitrate pin: `PUNKTFUNK_PYROWAVE_MAX_MBPS`
/// (megabits/s) → kbps, or `None` when unset/zero/invalid (no cap — the raw bpp pin stands).
/// Only consulted for `requested == 0` PyroWave sessions; an explicit client bitrate bypasses it.
/// Consulted for every PyroWave session an explicit client bitrate resolves through the
/// pin too (RFC §5.2), so nothing bypasses the ceiling.
fn pyrowave_auto_pin_ceiling_kbps() -> Option<u32> {
std::env::var("PUNKTFUNK_PYROWAVE_MAX_MBPS")
.ok()
@@ -1022,6 +1036,81 @@ fn adapt_fec(loss_ppm: u32) -> u8 {
target.clamp(FEC_MIN as u32, FEC_MAX as u32) as u8
}
/// The decay floor once a session has seen real loss ("burned", ABR overhaul RFC §2.4).
const FEC_BURNED_MIN: u8 = 5;
/// Clean 750 ms report windows (~2 min) a burned session must string together before the
/// floor steps back to [`FEC_MIN`].
const FEC_REEARN_WINDOWS: u32 = 160;
/// Ceiling on the doubled re-earn requirement (~16 min) — a periodic-burst link converges to
/// "armored for the session" without the counter running away.
const FEC_REEARN_MAX: u32 = 1280;
/// Adaptive-FEC decay floor with a memory of real loss (ABR overhaul RFC §2.4; pure —
/// unit-tested).
///
/// The plain 1 pt/window decay converges on [`FEC_MIN`] (1 %) over any clean stretch — on a
/// lossy link that is precisely backwards: static content strips the armor, then the first
/// motion frame arrives essentially unprotected and dies (the 2026-08-26 motion-onset field
/// chain, step 2). Once a window reports ANY shard loss (`loss_ppm > 0` — loss is discrete,
/// so nonzero means real lost shards, not noise), the floor rises to [`FEC_BURNED_MIN`].
///
/// Not session-permanent — the controller's house rule (the encode stand-down, `7246f0fe`)
/// is that nothing learned from evidence is forever: [`FEC_REEARN_WINDOWS`] clean windows
/// re-earn the 1 % floor, a link that re-burns before the step-down has proven itself
/// doubles the next requirement (to [`FEC_REEARN_MAX`]), and a step-down that survives its
/// own horizon resets the requirement to base.
#[derive(Debug)]
struct FecFloor {
floor: u8,
clean_windows: u32,
reearn: u32,
/// Clean windows since the floor last stepped down; `None` = no step-down on probation.
since_stepdown: Option<u32>,
}
impl Default for FecFloor {
fn default() -> Self {
Self {
floor: FEC_MIN,
clean_windows: 0,
reearn: FEC_REEARN_WINDOWS,
since_stepdown: None,
}
}
}
impl FecFloor {
/// Feed one report window; returns the floor the adaptive target must not decay below.
fn on_report(&mut self, loss_ppm: u32) -> u8 {
if loss_ppm > 0 {
if let Some(w) = self.since_stepdown.take() {
if w < self.reearn {
// The clean run that earned the step-down was luck — demand double.
self.reearn = (self.reearn * 2).min(FEC_REEARN_MAX);
}
}
self.clean_windows = 0;
self.floor = FEC_BURNED_MIN;
} else {
self.clean_windows = self.clean_windows.saturating_add(1);
if let Some(w) = self.since_stepdown.as_mut() {
*w = w.saturating_add(1);
if *w >= self.reearn {
// The step-down outlived its probation — the link really recovered.
self.reearn = FEC_REEARN_WINDOWS;
self.since_stepdown = None;
}
}
if self.floor > FEC_MIN && self.clean_windows >= self.reearn {
self.floor = FEC_MIN;
self.clean_windows = 0;
self.since_stepdown = Some(0);
}
}
self.floor
}
}
/// Apply the latest adaptive-FEC target to the session if it changed (cheap relaxed load + compare),
/// called once per frame on the data-plane send path.
fn apply_fec_target(session: &mut Session, fec_target: &AtomicU8) {
@@ -2035,8 +2124,9 @@ async fn serve_session(
});
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
// "Automatic" request: the resolved rate is a host default — for PyroWave a per-mode
// bpp pin the data plane re-resolves on a mid-stream mode switch.
let bitrate_auto = hello.bitrate_kbps == 0;
// bpp pin the data plane re-resolves on a mid-stream mode switch. PyroWave is Automatic
// unconditionally (`resolve_bitrate_kbps_for` overrode any explicit rate — RFC §5.2).
let bitrate_auto = hello.bitrate_kbps == 0 || codec == crate::encode::Codec::PyroWave;
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
// session uses exactly what the client was told). `Yuv444` only when the handshake gate passed.
@@ -2501,7 +2591,8 @@ mod tests {
),
(1920u64 * 1080 * 60 * 26 / 10 * 115 / 100 / 1000) as u32
);
// An explicit client rate is honored (clamped like any other codec)...
// An explicit client rate is overridden to the same pin — a fixed kbps is ill-defined
// for the all-intra codec, and it used to skip the operator ceiling (RFC §5.2)...
assert_eq!(
resolve_bitrate_kbps_for(
crate::encode::Codec::PyroWave,
@@ -2510,7 +2601,7 @@ mod tests {
ChromaFormat::Yuv420,
8
),
130_000
1920 * 1080 * 60 * 16 / 10 / 1000
);
// ...and the H.26x codecs keep the legacy default.
assert_eq!(
@@ -2559,10 +2650,11 @@ mod tests {
resolve_bitrate_kbps_for(Codec::PyroWave, 0, &small, ChromaFormat::Yuv420, 8),
1920 * 1080 * 60 * 16 / 10 / 1000
);
// ...and an explicit client rate bypasses the ceiling entirely.
// ...and an explicit client rate no longer bypasses it: PyroWave resolves through the
// pin + ceiling whatever the Hello carried (RFC §5.2 — this bypass was the bug).
assert_eq!(
resolve_bitrate_kbps_for(Codec::PyroWave, 6_000_000, &mode, ChromaFormat::Yuv444, 10),
6_000_000
4_500_000
);
// SAFETY: as the set above — single writer, and the readers run on this thread.
unsafe { std::env::remove_var("PUNKTFUNK_PYROWAVE_MAX_MBPS") };
@@ -2582,6 +2674,57 @@ mod tests {
assert!(adapt_fec(u32::MAX) <= FEC_MAX);
}
/// [`FecFloor`] (RFC §2.4): real loss raises the decay floor to 5 % so a static stretch
/// can't strip the armor before motion; a long clean run re-earns 1 %; a link that
/// re-burns before the step-down has proven itself doubles the next requirement; a
/// step-down that survives resets it.
#[test]
fn fec_floor_burns_reearns_and_doubles_on_early_reburn() {
let mut f = FecFloor::default();
// Untouched sessions decay to the 1 % floor as ever.
assert_eq!(f.on_report(0), FEC_MIN);
// One lost shard anywhere = burned: the floor is 5 % and clean windows hold it there.
assert_eq!(f.on_report(2_270), FEC_BURNED_MIN); // one packet at 5 Mbps
for _ in 0..FEC_REEARN_WINDOWS - 1 {
assert_eq!(f.on_report(0), FEC_BURNED_MIN);
}
// The 160th clean window re-earns the 1 % floor.
assert_eq!(f.on_report(0), FEC_MIN);
// A re-burn INSIDE the step-down's probation doubles the next requirement…
assert_eq!(f.on_report(500), FEC_BURNED_MIN);
for _ in 0..2 * FEC_REEARN_WINDOWS - 1 {
assert_eq!(f.on_report(0), FEC_BURNED_MIN);
}
assert_eq!(f.on_report(0), FEC_MIN);
// …and the ceiling bounds the doubling ladder.
let mut g = FecFloor {
reearn: FEC_REEARN_MAX,
since_stepdown: Some(0),
..FecFloor::default()
};
g.on_report(1);
assert_eq!(g.reearn, FEC_REEARN_MAX);
// A step-down that survives its probation resets the requirement to base.
let mut h = FecFloor::default();
h.on_report(1);
for _ in 0..FEC_REEARN_WINDOWS {
h.on_report(0);
}
assert_eq!(h.floor, FEC_MIN);
for _ in 0..FEC_REEARN_WINDOWS {
h.on_report(0);
}
assert_eq!(h.reearn, FEC_REEARN_WINDOWS);
assert!(
h.since_stepdown.is_none(),
"probation over — durable recovery"
);
// A burn AFTER a durable recovery is a fresh burn, not a double.
h.on_report(1);
assert_eq!(h.reearn, FEC_REEARN_WINDOWS);
assert_eq!(h.floor, FEC_BURNED_MIN);
}
#[test]
fn data_socket_defaults_to_random_hole_punch() {
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
+11 -1
View File
@@ -102,6 +102,9 @@ pub(super) async fn run(
// result / reconfigure / clip offer, so the read future is dropped routinely. `io::read_msg`
// would lose the partial frame and misalign the stream for the rest of the session.
let mut ctrl_reader = io::MsgReader::new(ctrl_recv);
// Burned decay floor for adaptive FEC (RFC §2.4): once this session has reported real
// loss, the decay below stops at 5 % instead of 1 % — see `FecFloor`.
let mut fec_floor = FecFloor::default();
loop {
tokio::select! {
msg = ctrl_reader.read_msg() => {
@@ -191,7 +194,14 @@ pub(super) async fn run(
// the stream covered across the gap while still converging to FEC_MIN
// on a genuinely clean link.
let prev = fec_target_ctl.load(Ordering::Relaxed);
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
// The burned floor (RFC §2.4) binds the decay, not the attack: real
// loss raises it to 5 % for as long as the link keeps proving lossy,
// so a static stretch can no longer strip the armor the first motion
// frame needs. ~2 clean minutes re-earn the 1 % floor.
let floor = fec_floor.on_report(rep.loss_ppm);
let target = adapt_fec(rep.loss_ppm)
.max(prev.saturating_sub(1))
.max(floor);
fec_target_ctl.store(target, Ordering::Relaxed);
if prev != target {
tracing::debug!(
@@ -1106,7 +1106,9 @@ pub(super) async fn negotiate(
// "Automatic" — `bitrate_kbps` above is the host's own answer for `mode`, so the build
// may re-resolve it if the source turns out to deliver a different size. Sampled here
// rather than in the thread body so the closure doesn't have to capture `hello`.
let bitrate_auto = hello.bitrate_kbps == 0;
// PyroWave is Automatic unconditionally (`resolve_bitrate_kbps_for` overrode any
// explicit rate — RFC §5.2).
let bitrate_auto = hello.bitrate_kbps == 0 || codec == crate::encode::Codec::PyroWave;
let trace = bringup.clone();
std::thread::Builder::new()
.name("punktfunk1-stream".into())
+88 -22
View File
@@ -525,13 +525,14 @@ fn idd_adaptive_enabled() -> bool {
/// ADAPTIVE chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment
/// cap) once the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace
/// instead of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto:
/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a
/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` =
/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in
/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame
/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced
/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze
/// until the next keyframe).
/// 10 ms at the pace rate, clamped to [16 KiB, 256 KiB]
/// ([`crate::send_pacing::auto_burst_bytes`], ABR overhaul RFC §2.1) — time-based so Wi-Fi
/// bitrates get a burst their link can drain instead of the old gigabit-sized 128 KiB floor
/// that swallowed every sub-LAN frame whole; `Some` = PUNKTFUNK_PACE_BURST_KB pinned an
/// absolute cap. So a normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added
/// latency, while a genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads —
/// keeping the freeze fix exactly where it's needed (an unpaced line-rate burst overruns the
/// kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze until the next keyframe).
///
/// `pace_rate_bps` (latency plan T1.2; resume-safe form, stall program T2): the caller passes
/// ~3× the live encoder bitrate — a rate the link is proven to carry sustained — and the
@@ -552,11 +553,19 @@ fn paced_submit(
deadline: std::time::Instant,
burst_cap: Option<usize>,
pace_rate_bps: u64,
max_spread: std::time::Duration,
) -> Result<PaceStat> {
let wires = session
.seal_frame_at(data, pts_ns, flags, frame_index)
.map_err(|e| anyhow!("seal_frame: {e:?}"))?;
pace_sealed(session, wires, deadline, burst_cap, pace_rate_bps)
pace_sealed(
session,
wires,
deadline,
burst_cap,
pace_rate_bps,
max_spread,
)
}
/// The pace-and-send half of [`paced_submit`], for wires that are ALREADY sealed — shared with
@@ -568,12 +577,14 @@ fn pace_sealed(
deadline: std::time::Instant,
burst_cap: Option<usize>,
pace_rate_bps: u64,
max_spread: std::time::Duration,
) -> Result<PaceStat> {
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
crate::send_pacing::inject_video_drop(&mut refs);
let wire_bytes: usize = refs.iter().map(|p| p.len()).sum();
let burst_bytes = burst_cap.unwrap_or_else(|| (wire_bytes / 4).max(128 * 1024));
let burst_bytes = burst_cap
.unwrap_or_else(|| crate::send_pacing::auto_burst_bytes(pace_rate_bps, wire_bytes));
let cfg = crate::send_pacing::PaceCfg {
burst_bytes: Some(burst_bytes),
chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 },
@@ -583,9 +594,12 @@ fn pace_sealed(
// `pace_rate_bps` IS the budget — the deadline no longer under-cuts it, so an oversized
// frame (a stall-resume scene delta, a cold IDR) paces at the proven 3× rate instead of
// collapsing into a line-rate blast that overruns the socket buffer and loses the very
// frame that ends a freeze. See `send_pacing::native_budget` for the full argument.
// frame that ends a freeze — bounded by `max_spread` (~2 frame intervals, RFC §2.2) so
// the spread can't back the encode|send channel up into `cadence_degraded`. See
// `send_pacing::native_budget` for the full argument.
let overflow_bytes = wire_bytes.saturating_sub(burst_bytes) as u64;
let budget = crate::send_pacing::native_budget(deadline, pace_rate_bps, overflow_bytes);
let budget =
crate::send_pacing::native_budget(deadline, pace_rate_bps, overflow_bytes, max_spread);
// Time the socket handoff per chunk and fold it into the session's SealPerf split — the
// sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time.
let mut sock_ns = 0u64;
@@ -682,6 +696,12 @@ struct StreamedOpen {
au: punktfunk_core::packet::StreamedAu,
spread_us: u32,
paced: bool,
/// The AU's remaining unpaced allowance (ABR overhaul RFC §2.3): ONE microburst budget
/// per AU, consumed across its block flushes. The old shape passed the auto rule per
/// flush, granting every block its own fresh 128 KiB — a streamed AU's burst multiplied
/// by its block count. `None` = legacy per-flush behavior (PUNKTFUNK_PACE_FACTOR=0 with
/// no PUNKTFUNK_PACE_BURST_KB pin — pacing off).
burst_left: Option<usize>,
}
/// Feed one [`ChunkMsg`] through the streamed sealer: open at `first`, seal + pace every FEC
@@ -695,6 +715,7 @@ fn handle_chunk(
slice_wire: bool,
burst_cap: Option<usize>,
pace_rate_bps: u64,
max_spread: std::time::Duration,
) -> Result<Option<(FrameMsg, PaceStat)>> {
if c.first {
if open.take().is_some() {
@@ -721,6 +742,16 @@ fn handle_chunk(
.map_err(|e| anyhow!("begin_streamed_frame: {e:?}"))?,
spread_us: 0,
paced: false,
// The AU's whole-life burst budget (RFC §2.3). The AU's total size is unknown at
// open, but the auto rule is time-at-pace-rate and doesn't need it.
burst_left: if pace_rate_bps == 0 && burst_cap.is_none() {
None
} else {
Some(
burst_cap
.unwrap_or_else(|| crate::send_pacing::auto_burst_bytes(pace_rate_bps, 0)),
)
},
});
}
let Some(s) = open.as_mut() else {
@@ -735,7 +766,22 @@ fn handle_chunk(
.seal_streamed_chunk(&mut s.au, &c.data, true)
.map_err(|e| anyhow!("seal_streamed_chunk: {e:?}"))?;
if !wires.is_empty() {
let stat = pace_sealed(session, wires, c.deadline, burst_cap, pace_rate_bps)?;
// Consume the AU budget by the flush's full wire size — even the part that was paced,
// not burst. Over-counting only makes LATER blocks pace sooner, which is the safe
// direction, and it keeps the accounting a subtraction instead of a round trip
// through the pacer's burst/overflow split.
let flush_bytes: usize = wires.iter().map(|w| w.len()).sum();
let stat = pace_sealed(
session,
wires,
c.deadline,
s.burst_left.or(burst_cap),
pace_rate_bps,
max_spread,
)?;
if let Some(left) = s.burst_left.as_mut() {
*left = left.saturating_sub(flush_bytes);
}
s.spread_us = s.spread_us.saturating_add(stat.spread_us);
s.paced |= stat.paced;
}
@@ -746,7 +792,14 @@ fn handle_chunk(
let tail = session
.seal_streamed_finish(s.au)
.map_err(|e| anyhow!("seal_streamed_finish: {e:?}"))?;
let stat = pace_sealed(session, tail, c.deadline, burst_cap, pace_rate_bps)?;
let stat = pace_sealed(
session,
tail,
c.deadline,
s.burst_left.or(burst_cap),
pace_rate_bps,
max_spread,
)?;
Ok(Some((
FrameMsg {
data: Vec::new(), // already on the wire — accounting only
@@ -921,6 +974,16 @@ fn send_loop(
let pace_rate = (stats.bitrate_kbps.load(Ordering::Relaxed) as f64
* 1000.0
* pace_factor) as u64;
// RFC §2.2: one frame's paced spread is bounded to ~2 frame intervals so a
// big IDR can't back the encode|send channel up into `cadence_degraded`
// (which refuses every climb). The live refresh rides `stats.mode` (reconfigs
// republish it); 0 (not yet known) = the absolute ceiling alone.
let (_, _, hz) = unpack_mode(stats.mode.load(Ordering::Relaxed));
let max_spread = if hz > 0 {
std::time::Duration::from_secs_f64(2.0 / hz as f64)
} else {
crate::send_pacing::MAX_PACE_SPREAD
};
// `Ok(Some(..))` = an AU fully left the socket (a whole frame, or a streamed
// AU's last chunk) — run the per-AU accounting; `Ok(None)` = mid-AU chunk.
let outcome = match send_msg {
@@ -933,6 +996,7 @@ fn send_loop(
msg.deadline,
burst_cap,
pace_rate,
max_spread,
)
.map(|stat| Some((msg, stat))),
SendMsg::Chunk(c) => handle_chunk(
@@ -942,6 +1006,7 @@ fn send_loop(
slice_wire,
burst_cap,
pace_rate,
max_spread,
),
};
match outcome {
@@ -1762,8 +1827,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
instead of building twice"
);
mode = m;
// Mirror the loop's rebuild: PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin, so
// a resolution change moves the operating point. Explicit client rates stay put.
// Mirror the loop's rebuild: PyroWave's bitrate is a per-mode ~1.6 bpp pin, so a
// resolution change moves the operating point (PyroWave is always Automatic — RFC §5.2).
if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
bitrate_kbps =
resolve_bitrate_kbps_for(plan.codec, 0, &mode, plan.chroma, plan.bit_depth);
@@ -2096,9 +2161,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
let perf = pf_host_config::config().perf;
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out
// immediately; only a bigger frame's overflow is spread. `None` = auto — max(128 KB, the
// AU's wire bytes / 4), so the burst stays a bounded fraction of high-rate frames instead
// of swallowing them whole (plan Phase 1.3). PUNKTFUNK_PACE_BURST_KB pins an absolute cap.
// immediately; only a bigger frame's overflow is spread. `None` = auto — 10 ms at the
// pace rate, clamped to [16 KiB, 256 KiB] (`send_pacing::auto_burst_bytes`, RFC §2.1) so
// Wi-Fi links get a burst they can drain instead of the old gigabit-sized 128 KiB floor.
// PUNKTFUNK_PACE_BURST_KB pins an absolute cap.
let burst_cap: Option<usize> = std::env::var("PUNKTFUNK_PACE_BURST_KB")
.ok()
.and_then(|s| s.parse::<usize>().ok())
@@ -2512,10 +2578,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// new-mode frame — `build_pipeline` waits for it). Total lands in the shared
// `resize_ms` slot (→ `session_status`); a failed rebuild abandons it silently.
let resize_trace = crate::bringup::Trace::start("resize", resize_ms.clone());
// PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) —
// a resolution change moves the operating point (1080p→4K quadruples the pixel rate),
// so re-resolve it for the new mode. Explicit client rates stay put (the operator knows
// the link), and the H.26x codecs keep their mode-independent rate (ABR owns it).
// PyroWave's bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) — a
// resolution change moves the operating point (1080p→4K quadruples the pixel rate),
// so re-resolve it for the new mode (PyroWave is always Automatic — RFC §5.2). The
// H.26x codecs keep their mode-independent rate (ABR owns it).
let mode_bitrate = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
resolve_bitrate_kbps_for(plan.codec, 0, &new_mode, plan.chroma, plan.bit_depth)
} else {
+79 -6
View File
@@ -96,16 +96,23 @@ pub(crate) const MAX_PACE_SPREAD: Duration = Duration::from_millis(100);
///
/// `pace_rate_bps == 0` (PUNKTFUNK_PACE_FACTOR=0) or an overflow-free frame keeps the legacy
/// deadline-only spread.
///
/// `max_spread` (ABR overhaul RFC §2.2) bounds one frame's spread to what the encode|send
/// `sync_channel(3)` can absorb — the caller passes ~2 frame intervals. A spread past that
/// backs the channel up into `cadence_degraded`, and the host then refuses every climb; a
/// shorter budget over the same overflow IS the lifted effective rate the RFC asks for.
/// Pass [`MAX_PACE_SPREAD`] when the interval is unknown.
pub(crate) fn native_budget(
deadline: Instant,
pace_rate_bps: u64,
overflow_bytes: u64,
max_spread: Duration,
) -> PaceBudget {
if pace_rate_bps > 0 && overflow_bytes > 0 {
let cap = Duration::from_nanos(
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
);
PaceBudget::Fixed(cap.min(MAX_PACE_SPREAD))
PaceBudget::Fixed(cap.min(max_spread).min(MAX_PACE_SPREAD))
} else {
PaceBudget::UntilDeadline {
deadline,
@@ -115,6 +122,31 @@ pub(crate) fn native_budget(
}
}
/// The native plane's automatic microburst allowance (pure — unit-tested): the bytes that may
/// leave unpaced before the spread starts, sized in TIME at the pace rate (ABR overhaul RFC
/// §2.1) — "what the link drains in ≤10 ms" — instead of the old absolute
/// `max(128 KiB, wire/4)`, which was sized for gigabit LAN: at Wi-Fi bitrates every frame sat
/// under it, so the whole packet train went out back-to-back and the first motion frame after
/// a static stretch was lost to the burst (the 2026-08-26 field case; `PACE_BURST_KB=16` was
/// its discriminator, and the 16 KiB clamp floor below is exactly that value).
///
/// One constant lines up both known-good ends: 5 Mbps stream (~15 Mbps pace) → ~19 KiB ≈ the
/// field discriminator; 30 Mbps LAN (~90 Mbps pace) → ~112 KiB ≈ the old 128 KiB floor, so
/// LAN latency does not regress; ≥205 Mbps pace clamps at 256 KiB and the rest rides the
/// 3×-rate spread. `pace_rate_bps == 0` (PUNKTFUNK_PACE_FACTOR=0 — pacing off) keeps the
/// legacy fraction-of-frame burst.
pub(crate) fn auto_burst_bytes(pace_rate_bps: u64, wire_bytes: usize) -> usize {
const BURST_MS: u64 = 10;
const BURST_MIN: usize = 16 * 1024;
const BURST_MAX: usize = 256 * 1024;
if pace_rate_bps == 0 {
return (wire_bytes / 4).max(128 * 1024);
}
usize::try_from(pace_rate_bps * BURST_MS / 8000)
.unwrap_or(BURST_MAX)
.clamp(BURST_MIN, BURST_MAX)
}
/// Per-plane pacing parameters. See the module doc for the two canonical values.
#[derive(Clone, Copy, Debug)]
pub(crate) struct PaceCfg {
@@ -649,21 +681,21 @@ mod tests {
// The stall-resume case the fix exists for: a 3 MB overflow at 3×240 Mbps needs
// ~33 ms — an IMMINENT deadline (the old min() made this a blast) must not shrink it.
let deadline = Instant::now() + Duration::from_millis(4); // 240 fps interval
let b = native_budget(deadline, 720_000_000, 3_000_000);
let b = native_budget(deadline, 720_000_000, 3_000_000, MAX_PACE_SPREAD);
assert_eq!(b, PaceBudget::Fixed(Duration::from_nanos(33_333_333)));
// A steady-state frame: overflow 90 KB at 3×240 Mbps = 1 ms — identical to what the
// old min(slack, cap) chose (cap was the smaller term), so nothing regresses.
let b = native_budget(deadline, 720_000_000, 90_000);
let b = native_budget(deadline, 720_000_000, 90_000, MAX_PACE_SPREAD);
assert_eq!(b, PaceBudget::Fixed(Duration::from_micros(1_000)));
// A crater-rate resume (ABR backed off to 20 Mbps, pace 60 Mbps): the raw rate math
// says 400 ms for 3 MB — the absolute ceiling bounds the send thread's stall.
let b = native_budget(deadline, 60_000_000, 3_000_000);
let b = native_budget(deadline, 60_000_000, 3_000_000, MAX_PACE_SPREAD);
assert_eq!(b, PaceBudget::Fixed(MAX_PACE_SPREAD));
// Rate cap off (PUNKTFUNK_PACE_FACTOR=0): the legacy deadline-only spread, uncapped.
let b = native_budget(deadline, 0, 3_000_000);
let b = native_budget(deadline, 0, 3_000_000, MAX_PACE_SPREAD);
assert!(matches!(
b,
PaceBudget::UntilDeadline {
@@ -674,10 +706,51 @@ mod tests {
));
// No overflow (the whole frame bursts): budget is never consulted — legacy shape.
let b = native_budget(deadline, 720_000_000, 0);
let b = native_budget(deadline, 720_000_000, 0, MAX_PACE_SPREAD);
assert!(matches!(b, PaceBudget::UntilDeadline { .. }));
}
/// [`native_budget`] with the RFC §2.2 spread cap: a paced IDR must not park the send
/// thread past ~2 frame intervals (encode|send `sync_channel(3)` backpressure →
/// `cadence_degraded` → the host refuses every climb) — the shorter budget over the same
/// overflow IS the lifted effective rate.
#[test]
fn native_budget_spread_capped_to_frame_intervals() {
let deadline = Instant::now() + Duration::from_millis(8);
// A 3 MB IDR at 60 Mbps pace wants 400 ms; two 120 Hz intervals (16.6 ms) win.
let two_intervals = Duration::from_nanos(2 * 8_333_333);
let b = native_budget(deadline, 60_000_000, 3_000_000, two_intervals);
assert_eq!(b, PaceBudget::Fixed(two_intervals));
// A steady-state frame under the cap is untouched by it.
let b = native_budget(deadline, 720_000_000, 90_000, two_intervals);
assert_eq!(b, PaceBudget::Fixed(Duration::from_micros(1_000)));
// The absolute ceiling still binds when the interval cap is the larger one
// (a 5 fps virtual mode must not re-license a 400 ms stall).
let b = native_budget(deadline, 60_000_000, 3_000_000, Duration::from_millis(400));
assert_eq!(b, PaceBudget::Fixed(MAX_PACE_SPREAD));
}
/// [`auto_burst_bytes`] (RFC §2.1): the unpaced allowance is 10 ms at the pace rate,
/// clamped to [16 KiB, 256 KiB] — the constants that line up the Wi-Fi field
/// discriminator on one end and the old LAN behavior on the other.
#[test]
fn auto_burst_is_time_at_pace_rate() {
// 5 Mbps stream × 3 = 15 Mbps pace → 18.75 KiB: the Wi-Fi field case, where the old
// 128 KiB floor swallowed every frame whole and the motion-onset burst was lost.
assert_eq!(auto_burst_bytes(15_000_000, 40_000), 18_750);
// 30 Mbps LAN × 3 = 90 Mbps → ~112 KiB ≈ the old 128 KiB floor: no LAN regression.
assert_eq!(auto_burst_bytes(90_000_000, 250_000), 112_500);
// Below ~13 Mbps pace the floor is the field-proven 16 KiB.
assert_eq!(auto_burst_bytes(3_000_000, 10_000), 16 * 1024);
// Gigabit-class pace clamps at 256 KiB — the rest rides the 3×-rate spread.
assert_eq!(auto_burst_bytes(3_000_000_000, 4_000_000), 256 * 1024);
// PUNKTFUNK_PACE_FACTOR=0 (pacing off): the legacy fraction-of-frame burst.
assert_eq!(auto_burst_bytes(0, 4_000_000), 1_000_000);
assert_eq!(auto_burst_bytes(0, 40_000), 128 * 1024);
}
/// `inject_video_drop` is a no-op when the knob is off (the default test env).
#[test]
fn drop_injection_off_by_default() {
+5 -3
View File
@@ -50,8 +50,10 @@ probe about two seconds in that lets the rate climb past 20 Mbps. An explicit ra
session, clamped to **500 kbps 8 Gbps**. A host card's menu has **Test network speed…** to suggest
a value.
PyroWave has no useful low-rate regime: its Automatic is a fixed per-pixel budget for the negotiated
mode (hundreds of Mbps), with adaptive bitrate and the probe off for the whole session.
PyroWave is **always Automatic**: the rate is a fixed per-pixel budget for the negotiated mode
(hundreds of Mbps), with adaptive bitrate and the probe off for the whole session. A fixed kbps
is meaningless for the all-intra codec, so the bitrate setting is disabled while PyroWave is
selected — your stored value is kept, and picking another codec restores it.
**Render scale** — *default: Native (1×).* The host renders and encodes at your mode times this;
your device resamples to its window. Above 1× supersamples at more bandwidth and decode work; below
@@ -298,7 +300,7 @@ exactly [what a profile can't change](/docs/profiles-and-links#what-a-profile-ca
| You ask for | What the host does |
|---|---|
| Resolution and refresh | Builds a display at exactly that mode. A host pinned to a real monitor keeps that monitor's resolution and you scale locally. A size the encoder can't take — odd, or past the codec's per-axis limit — fails the connect rather than being quietly changed. |
| A bitrate | Clamps it to 500 kbps 8 Gbps, or uses its 20 Mbps default for Automatic (a per-pixel budget for Automatic PyroWave). |
| A bitrate | Clamps it to 500 kbps 8 Gbps, or uses its 20 Mbps default for Automatic. PyroWave ignores the number entirely — every PyroWave session gets the per-pixel budget. |
| A codec | Honors it when it can encode it, else the best shared codec in the order HEVC → AV1 → H.264. |
| 10-bit HDR | Upgrades only for HDR content on an encoder that can do 10-bit; otherwise 8-bit SDR. |
| 4:4:4 chroma | Sends it only when every gate passes; otherwise 4:2:0. |
+3 -3
View File
@@ -129,12 +129,12 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_FEC_PCT` | `0``90` (percent) | **Pins** forward-error-correction redundancy and turns adaptive FEC **off**. Leave it unset on the native protocol: the host normally sizes recovery to the loss the client reports (a 150 % band, starting at 10 %), so pinning a number can leave a lossy link *worse* off than letting it adapt. Set it only when a fixed, known overhead matters — a measurement or a speed test; `0` disables FEC entirely. On the GameStream/Moonlight plane it is a plain override of that plane's fixed 20 %. |
| `PUNKTFUNK_FEC_PCT` | `0``90` (percent) | **Pins** forward-error-correction redundancy and turns adaptive FEC **off**. Leave it unset on the native protocol: the host normally sizes recovery to the loss the client reports (a 150 % band, starting at 10 %; once a session has seen real loss the quiet-time floor is 5 % rather than 1 %, and a couple of clean minutes earn 1 % back), so pinning a number can leave a lossy link *worse* off than letting it adapt. Set it only when a fixed, known overhead matters — a measurement or a speed test; `0` disables FEC entirely. On the GameStream/Moonlight plane it is a plain override of that plane's fixed 20 %. |
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | Allow 10-bit (HEVC Main10 / AV1 10-bit) sessions at all; `0` forces every session to 8-bit SDR. Which hosts can actually deliver it, and the client half of the switch, are on [HDR](/docs/hdr). |
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Host **policy gate** for full chroma 4:4:4 — sharper text and thin lines, no chroma loss. **On by default**; `0` forces every session to 4:2:0. It only ever *allows*: the client's own 4:4:4 setting (default off) is the real per-session switch, and the codec, capture-path and GPU gates behind it are on [Client settings → Full chroma](/docs/client-settings#video). Which GPUs and which clients can actually do it is in the [support matrix](/docs/support-matrix#encoders); how it interacts with HDR is on [HDR](/docs/hdr). **punktfunk/1 native only** — Moonlight stays 4:2:0. |
| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. |
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Applies to every PyroWave session — a client-requested bitrate is treated as Automatic under PyroWave, so nothing bypasses the ceiling. |
| `PUNKTFUNK_DSCP` | `1` · `0` | DSCP / `SO_PRIORITY` QoS tagging on the media sockets. Default: **on toward private-network peers** (a LAN/Wi-Fi client — access points map DSCP to WMM airtime priority), off toward routable addresses (some ISP paths bleach or reject marked packets). `1` forces it on everywhere, `0` turns it off entirely. No-op on the wire on Windows without a qWAVE policy. |
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length in frames (unset = about ten minutes' worth, `fps × 600`; set `0` for encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. ⚠️ On gamescope that one number is also the refresh the session **reports**: Steam's in-session display settings and every game will read the display as `N` Hz, and a game that paces itself to the display will hold itself there. If you want a quieter box without games believing the panel changed, cap the client's requested refresh instead. |
| `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` | e.g. `60,90,120` *(default: just the session's own rate)* | Extra refresh rates a gamescope session **offers** in its in-session display settings. A headless gamescope has no EDID, so it cannot work out what else the display could run at — without this it advertises exactly one rate and Steam's refresh menu has a single entry. The rate the session actually runs at is always included, so this can only add options. Needs the `punktfunk-gamescope` build (`+pfhdr3`); ignored on a stock gamescope, which has no flag to take it. |
+5 -3
View File
@@ -113,9 +113,11 @@ A [plugin](/docs/plugins) can own a slice of the library and keep it in sync —
Manager and Playnite plugins get your collection into the grid, box art and all.
A library plugin can also publish a **launcher tile** — an entry that opens Steam Big Picture,
Heroic, Lutris or Playnite itself rather than a game, so you can install or fix something from the
couch. Clients group those into their own row above your titles, each drawing its launcher's logo.
A launcher tile you don't want is a switch in that plugin's settings.
Heroic's console mode, Lutris or Playnite Fullscreen itself rather than a game, so you can install
or fix something from the couch. Where a launcher has both a couch UI and an ordinary window, they
are separate tiles: Steam Big Picture beside the Steam client, Heroic Console Mode beside the Heroic
window. Clients group them all into their own row above your titles, each drawing its launcher's
logo. A launcher tile you don't want is a switch in that plugin's settings.
Entries a plugin owns are read-only to you. The host refuses a hand edit or delete of one, because
the next sync would overwrite it anyway — change the title at its source and let the plugin sync
+1 -1
View File
@@ -28,7 +28,7 @@ export type Artwork = typeof Artwork.Type;
* | `command` | a shell command (operator-trust tier) | both |
* | `steam_appid` | digits an appid, or a 64-bit non-Steam-shortcut game id | both |
* | `steam_ui` | `bigpicture` \| `desktop` opens the Steam client itself | both |
* | `launcher_ui` | a store id (`heroic`, `lutris`) opens that launcher's own UI | linux |
* | `launcher_ui` | which launcher UI to open: `heroic` \| `heroic-console` \| `lutris` | linux |
* | `lutris_id` | digits a pga.db game id | linux |
* | `heroic` | `<runner>:<appName>`, runner legendary/gog/nile | linux |
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |