Compare commits

...
Author SHA1 Message Date
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
10 changed files with 168 additions and 39 deletions
@@ -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
+26 -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()
@@ -2035,8 +2049,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 +2516,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 +2526,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 +2575,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") };
@@ -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())
+6 -6
View File
@@ -1762,8 +1762,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);
@@ -2512,10 +2512,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 {
+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. |
+1 -1
View File
@@ -133,7 +133,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `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_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` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. 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. |