The bitrate becomes the wire budget it always claimed to be (ABR overhaul Phase 4)
ci / bun-nix (pull_request) Successful in 18s
ci / docs-drift (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 1m48s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m10s
android / android (pull_request) Successful in 10m41s
ci / rust (pull_request) Successful in 7m57s
ci / bun-nix (pull_request) Successful in 18s
ci / docs-drift (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 1m48s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m10s
android / android (pull_request) Successful in 10m41s
ci / rust (pull_request) Successful in 7m57s
'20 Mbps' used to mean 22-30+ Mbps on the wire: FEC parity (1-50 %), packet framing (~4.5 %) and the audio plane all rode on top of the number the user set — and on the constrained links where the setting matters most, that overshoot WAS the failure. Per the signed-off RFC §5.1 (amended 2026-08-27: no capability handshake — the semantics just change; new-client→old-host is byte-for-byte today's behavior, and old-client→new-host shifts conservative by the overhead share): Host: every number on the control plane — Hello resolve, SetBitrate, acks, live_bitrate, the console — is the TOTAL WIRE BUDGET. Encoder opens/reconfigures convert through EncDerive (budget − audio, over framing + FEC) snapshotted at the live FEC percent, and read-backs convert back so a short apply reports budget truth. A FEC step re-derives the encoder rate in place, so parity reallocates WITHIN the budget instead of inflating the wire. The audio reservation comes from the resolved plane (exact PCM cost, or the shared Opus budget ladder). PyroWave is the identity (its pin is an encoder operating point); GameStream keeps its historical semantics untouched. Client: the controller's actual becomes the WIRE measure — received bytes with headers, seals and parity (they spend the budget), minus probe filler, plus the mirrored audio reservation — so utilization and the proven mark compare like with like against budget targets. The derivation is pure and unit-tested: roundtrips never inflate the budget, reallocation is monotone in FEC, and a budget too small for its own audio floors honestly. punktfunk-core --features quic: all 500 lib tests green natively; punktfunk-host clippy -D warnings green on the CI image. Docs: the bitrate sections say what the number now means.
This commit is contained in:
@@ -90,6 +90,27 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
// then can the ABR trust an unflagged AU as new content; an older host leaves the window
|
||||
// arithmetic in its legacy form.
|
||||
let marks_repeats = negotiated.host_caps2 & crate::quic::HOST_CAP2_REPEAT_MARK != 0;
|
||||
// RFC §5.1: the bitrate numbers are WIRE BUDGETS, so the controller's `actual` measures
|
||||
// wire bytes plus this reservation for the audio plane — which rides the control
|
||||
// connection and is spent whether or not video flows. Exact for PCM (the negotiated
|
||||
// plane names its rate); the Opus mirror runs the host's own shared budget ladder at the
|
||||
// default tier — an operator-pinned tier skews it by a few hundred kbps, well inside the
|
||||
// utilization gate's ¾ tolerance.
|
||||
let audio_reserved_kbps = if negotiated.audio_codec == crate::quic::AUDIO_CODEC_PCM {
|
||||
crate::audio::pcm::bitrate_kbps(
|
||||
negotiated.audio_rate_hz,
|
||||
negotiated.audio_bits,
|
||||
negotiated.audio_channels,
|
||||
)
|
||||
} else {
|
||||
crate::audio::plan_audio_budget(
|
||||
negotiated.bitrate_kbps,
|
||||
negotiated.audio_channels,
|
||||
crate::audio::AudioTier::default(),
|
||||
host_caps & crate::quic::HOST_CAP_AUDIO_RED != 0,
|
||||
)
|
||||
.kbps
|
||||
};
|
||||
// Session constants a mode switch does not change — the pump recomputes the stream-shape
|
||||
// cap from them for the switched geometry (review §2.1).
|
||||
let bit_depth = negotiated.bit_depth;
|
||||
@@ -295,6 +316,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
bit_depth,
|
||||
chroma_format,
|
||||
marks_repeats,
|
||||
audio_reserved_kbps,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: mode_slot_pump,
|
||||
|
||||
@@ -53,6 +53,9 @@ pub(super) struct DataPump {
|
||||
/// per-window active/repeat counts below mean anything; against an older host the ABR is
|
||||
/// handed `None` and keeps its legacy window arithmetic.
|
||||
pub(super) marks_repeats: bool,
|
||||
/// The audio plane's wire reservation (RFC §5.1) — added to the window's wire-byte
|
||||
/// `actual` so the controller's domain matches the budget its targets are in.
|
||||
pub(super) audio_reserved_kbps: u32,
|
||||
/// What this session's mode + codec could plausibly use (see
|
||||
/// [`crate::abr::stream_ceiling_kbps`]) — the bound the probe-measured link ceiling is held
|
||||
/// to. Computed where the negotiated geometry lives; recomputed here on an accepted mode
|
||||
@@ -91,6 +94,7 @@ impl DataPump {
|
||||
bit_depth,
|
||||
chroma_format,
|
||||
marks_repeats,
|
||||
audio_reserved_kbps,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: pump_mode_slot,
|
||||
@@ -333,7 +337,7 @@ impl DataPump {
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.media_bytes_received;
|
||||
last_bytes = wire_bytes(&st);
|
||||
last_report = Instant::now();
|
||||
discard_abr_window = true;
|
||||
flush_in_window = false;
|
||||
@@ -437,12 +441,12 @@ impl DataPump {
|
||||
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
|
||||
);
|
||||
}
|
||||
// Rebase the ABR window's byte anchor past the burst. (Probe filler is
|
||||
// routed out of `media_bytes_received` at the reassembler, so it can no
|
||||
// longer read as the burst rate on its own — but the anchor still has to
|
||||
// skip the video that landed around the burst under a suppressed report
|
||||
// tick, which would otherwise divide a long span's bytes by one window.)
|
||||
last_bytes = st.media_bytes_received;
|
||||
// Rebase the ABR window's byte anchor past the burst. (The wire measure
|
||||
// nets the probe filler out by construction — see `wire_bytes` — but the
|
||||
// anchor still has to skip the video that landed around the burst under a
|
||||
// suppressed report tick, which would otherwise divide a long span's
|
||||
// bytes by one window.)
|
||||
last_bytes = wire_bytes(&st);
|
||||
} else if Instant::now() >= deadline {
|
||||
// The host never answered (a build that ignores ProbeRequest): clear the
|
||||
// stuck-active state so LossReports resume, keep the negotiated ceiling.
|
||||
@@ -626,18 +630,17 @@ impl DataPump {
|
||||
// the next one.
|
||||
let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed);
|
||||
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||
// the target it was allowed. MEDIA bytes (data-shard payload: no headers, no FEC
|
||||
// parity, no probe filler, no audio), because both consumers compare it against
|
||||
// the ENCODER's target: the utilization gate asks "was the target genuinely
|
||||
// tested?" and the proven mark bounds every later climb. Wire bytes answered a
|
||||
// different question — they rise with the redundancy the host adds in answer to
|
||||
// loss, so the gate read ~25 % high precisely on the links it exists for.
|
||||
// the target it was allowed. WIRE bytes (RFC §5.1): the targets are wire
|
||||
// BUDGETS now, so `actual` measures what the budget actually buys — headers,
|
||||
// seals and FEC parity included (the redundancy the host adds in answer to loss
|
||||
// SPENDS the budget; under the old encoder-domain targets that read ~25 % high,
|
||||
// which is why this used to be media bytes), minus the speed-test filler (the
|
||||
// probe's spend, not the stream's), plus the audio plane's reservation — audio
|
||||
// rides the control connection and is spent whether or not video flows.
|
||||
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
|
||||
let actual_kbps = (st
|
||||
.media_bytes_received
|
||||
.wrapping_sub(last_bytes)
|
||||
.saturating_mul(8)
|
||||
/ window_ms) as u32;
|
||||
let actual_kbps = ((wire_bytes(&st).wrapping_sub(last_bytes).saturating_mul(8)
|
||||
/ window_ms) as u32)
|
||||
.saturating_add(audio_reserved_kbps);
|
||||
// A discard window feeds the controller NOTHING — its signals are probe-tail
|
||||
// residue, and one "congestion" verdict here ends slow start for good.
|
||||
let verdict = if discard {
|
||||
@@ -687,7 +690,7 @@ impl DataPump {
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.media_bytes_received;
|
||||
last_bytes = wire_bytes(&st);
|
||||
if pump_perf_on {
|
||||
if let Some(p) = session.take_pump_perf() {
|
||||
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
|
||||
@@ -912,6 +915,13 @@ fn probe_target_kbps(stream_cap_kbps: u32) -> u32 {
|
||||
stream_cap_kbps.saturating_mul(2).min(2_000_000)
|
||||
}
|
||||
|
||||
/// The controller's WIRE measure (RFC §5.1): every received media-plane byte — headers, seals
|
||||
/// and FEC parity included, because they spend the budget the targets are denominated in —
|
||||
/// minus the speed-test filler (the probe's spend, not the stream's).
|
||||
fn wire_bytes(st: &crate::stats::Stats) -> u64 {
|
||||
st.bytes_received.wrapping_sub(st.probe_bytes_received)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1125,6 +1135,7 @@ mod tests {
|
||||
bit_depth: 8,
|
||||
chroma_format: 0,
|
||||
marks_repeats: false,
|
||||
audio_reserved_kbps: 256,
|
||||
stream_cap_kbps: 100_000,
|
||||
refresh_hz: 60,
|
||||
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
|
||||
|
||||
@@ -987,6 +987,134 @@ fn resolve_bitrate_kbps_for(
|
||||
resolve_bitrate_kbps(requested)
|
||||
}
|
||||
|
||||
/// One shard's wire overhead: the 40-byte punktfunk header + the 24-byte crypto seal that ride
|
||||
/// INSIDE the UDP payload next to every `shard_payload` bytes of media (~4.5 % at 1408).
|
||||
const SHARD_WIRE_OVERHEAD: u64 =
|
||||
(punktfunk_core::packet::HEADER_LEN + punktfunk_core::packet::CRYPTO_OVERHEAD) as u64;
|
||||
|
||||
/// The wire-budget → encoder-rate derivation (ABR overhaul RFC §5.1, amended 2026-08-27: no
|
||||
/// capability handshake — on current builds the client's bitrate number IS a total session
|
||||
/// wire budget, full stop).
|
||||
///
|
||||
/// What rides on the wire for `video` kbps of encoder output: FEC parity (`fec_percent` more
|
||||
/// shards), packet framing ([`SHARD_WIRE_OVERHEAD`] per shard), and the audio plane's
|
||||
/// reservation off the top. So:
|
||||
///
|
||||
/// ```text
|
||||
/// wire = video × (payload+64)/payload × (100+fec)/100 + audio
|
||||
/// video = (wire − audio) × payload/(payload+64) × 100/(100+fec)
|
||||
/// ```
|
||||
///
|
||||
/// "20 Mbps" used to mean 22–30+ Mbps on the wire — and on the constrained links where the
|
||||
/// setting matters most, the overshoot WAS the failure. Adaptive FEC now reallocates *within*
|
||||
/// the budget: the encode loop re-derives this on every FEC step, so more parity means a
|
||||
/// lower encoder rate, never a fatter wire. Floored at [`MIN_BITRATE_KBPS`] — a budget too
|
||||
/// small for its own audio still streams, it just overshoots honestly (the resolve gates keep
|
||||
/// audio ≤ ~25 % of the number, so this is corrupt-input territory).
|
||||
///
|
||||
/// PyroWave sessions bypass this entirely (callers pass their rate through): the pin is an
|
||||
/// all-intra ENCODER operating point (bpp), and the controller is off for that codec anyway.
|
||||
fn encoder_kbps_for_budget(
|
||||
budget_kbps: u32,
|
||||
audio_kbps: u32,
|
||||
fec_percent: u8,
|
||||
shard_payload: u16,
|
||||
) -> u32 {
|
||||
let payload = shard_payload.max(1) as u64;
|
||||
let video_wire = budget_kbps.saturating_sub(audio_kbps) as u64;
|
||||
let video =
|
||||
video_wire * payload * 100 / ((payload + SHARD_WIRE_OVERHEAD) * (100 + fec_percent as u64));
|
||||
u32::try_from(video)
|
||||
.unwrap_or(u32::MAX)
|
||||
.max(MIN_BITRATE_KBPS)
|
||||
}
|
||||
|
||||
/// [`encoder_kbps_for_budget`]'s inverse: the wire budget an encoder rate actually spends —
|
||||
/// what a short apply (the encoder clamped below the ask) must report BACK to the client, so
|
||||
/// its controller's climb base tracks wire truth. Rounds up where the derivation rounds down,
|
||||
/// so `budget_kbps_for_encoder(encoder_kbps_for_budget(b)) ≤ b` always holds: a roundtrip
|
||||
/// never inflates the budget the client believes.
|
||||
fn budget_kbps_for_encoder(
|
||||
encoder_kbps: u32,
|
||||
audio_kbps: u32,
|
||||
fec_percent: u8,
|
||||
shard_payload: u16,
|
||||
) -> u32 {
|
||||
let payload = shard_payload.max(1) as u64;
|
||||
let wire = encoder_kbps as u64 * (payload + SHARD_WIRE_OVERHEAD) * (100 + fec_percent as u64)
|
||||
/ (payload * 100);
|
||||
u32::try_from(wire.saturating_add(audio_kbps as u64)).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// The budget↔encoder conversion at one moment in time (RFC §5.1): the session constants
|
||||
/// plus a SNAPSHOT of the adaptive FEC percent, taken by the caller at each encoder touch —
|
||||
/// the FEC-step watcher in the stream loop re-derives when the live percent moves on. `Copy`,
|
||||
/// so it threads next to the `Copy` [`SessionPlan`](crate::session_plan::SessionPlan) through
|
||||
/// the build chain without ceremony.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct EncDerive {
|
||||
audio_kbps: u32,
|
||||
shard_payload: u16,
|
||||
fec_percent: u8,
|
||||
/// PyroWave: the pin IS an encoder rate — both directions are the identity.
|
||||
identity: bool,
|
||||
}
|
||||
|
||||
impl EncDerive {
|
||||
/// The encoder video rate a wire budget affords right now.
|
||||
fn enc_kbps(&self, budget_kbps: u32) -> u32 {
|
||||
if self.identity {
|
||||
budget_kbps
|
||||
} else {
|
||||
encoder_kbps_for_budget(
|
||||
budget_kbps,
|
||||
self.audio_kbps,
|
||||
self.fec_percent,
|
||||
self.shard_payload,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire budget an encoder rate actually spends — for read-backs (a short apply must
|
||||
/// report budget truth to the client).
|
||||
fn budget_kbps(&self, encoder_kbps: u32) -> u32 {
|
||||
if self.identity {
|
||||
encoder_kbps
|
||||
} else {
|
||||
budget_kbps_for_encoder(
|
||||
encoder_kbps,
|
||||
self.audio_kbps,
|
||||
self.fec_percent,
|
||||
self.shard_payload,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio plane's wire reservation for the budget derivation, from the RESOLVED Welcome:
|
||||
/// the exact PCM cost on the lossless plane, else the Opus ladder's planned cost — the same
|
||||
/// shared [`plan_audio_budget`](punktfunk_core::audio::plan_audio_budget) rung the audio
|
||||
/// thread runs, with redundancy exactly when the session GRANTED it (`HOST_CAP_AUDIO_RED`).
|
||||
/// The client mirrors this from the same Welcome fields; an operator-pinned non-default tier
|
||||
/// skews its mirror by a few hundred kbps, well inside the utilization gate's ¾ tolerance.
|
||||
fn audio_reserved_kbps(welcome: &punktfunk_core::quic::Welcome) -> u32 {
|
||||
if welcome.audio_codec == punktfunk_core::quic::AUDIO_CODEC_PCM {
|
||||
punktfunk_core::audio::pcm::bitrate_kbps(
|
||||
welcome.audio_rate_hz,
|
||||
welcome.audio_bits,
|
||||
welcome.audio_channels,
|
||||
)
|
||||
} else {
|
||||
punktfunk_core::audio::plan_audio_budget(
|
||||
welcome.bitrate_kbps,
|
||||
welcome.audio_channels,
|
||||
punktfunk_core::audio::AudioTier::default(),
|
||||
welcome.host_caps & punktfunk_core::quic::HOST_CAP_AUDIO_RED != 0,
|
||||
)
|
||||
.kbps
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Consulted for every PyroWave session — an explicit client bitrate resolves through the
|
||||
@@ -2122,10 +2250,16 @@ async fn serve_session(
|
||||
(!cmds.is_empty())
|
||||
.then(|| tokio::task::block_in_place(|| crate::hooks::run_prep(&cmds, &env)))
|
||||
});
|
||||
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. PyroWave is Automatic
|
||||
// unconditionally (`resolve_bitrate_kbps_for` overrode any explicit rate — RFC §5.2).
|
||||
// The resolved number is the session's TOTAL WIRE budget (RFC §5.1): the Welcome, every
|
||||
// ack, the HUD and this whole control plane speak budget — only the encoder opens are
|
||||
// handed the derived video rate (`EncDerive` in the stream loop: budget minus the audio
|
||||
// reservation, over framing + FEC overhead). PyroWave is the exception (its pin is an
|
||||
// all-intra encoder operating point): budget == encoder rate there.
|
||||
let bitrate_kbps = welcome.bitrate_kbps;
|
||||
let audio_reserved_kbps = audio_reserved_kbps(&welcome);
|
||||
// "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. 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
|
||||
@@ -2312,6 +2446,8 @@ async fn serve_session(
|
||||
compositor,
|
||||
gamescope_route,
|
||||
bitrate_kbps,
|
||||
audio_reserved_kbps,
|
||||
shard_payload: welcome.shard_payload,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
@@ -2678,6 +2814,50 @@ mod tests {
|
||||
/// 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 wire_budget_derivation_never_overshoots() {
|
||||
// RFC §5.1 worked example: 20 Mbps budget, a 300 kbps Opus reservation, 10 % FEC,
|
||||
// 1408-byte shards → video = (20000−300) × 1408×100 / (1472×110) = 17 130 kbps.
|
||||
assert_eq!(encoder_kbps_for_budget(20_000, 300, 10, 1408), 17_130);
|
||||
// …whose wire spend rounds back UNDER the budget, never over.
|
||||
assert_eq!(budget_kbps_for_encoder(17_130, 300, 10, 1408), 19_999);
|
||||
|
||||
// The invariant across the operating range: any non-floored derivation's roundtrip
|
||||
// spends within the budget — a short-ack conversion can never report a budget the
|
||||
// wire would exceed.
|
||||
for budget in [2_000u32, 5_000, 20_000, 100_000, 1_000_000] {
|
||||
for fec in [1u8, 5, 10, 25, 50] {
|
||||
for audio in [0u32, 256, 512, 8_500] {
|
||||
for payload in [1388u16, 1408, 8896] {
|
||||
let e = encoder_kbps_for_budget(budget, audio, fec, payload);
|
||||
if e > MIN_BITRATE_KBPS {
|
||||
let back = budget_kbps_for_encoder(e, audio, fec, payload);
|
||||
assert!(
|
||||
back <= budget,
|
||||
"budget {budget} fec {fec} audio {audio} payload {payload}: \
|
||||
derived {e} spends {back}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A budget too small for its own audio floors at the encoder minimum and overshoots
|
||||
// HONESTLY — the resolve gates keep audio ≤ ~25 % of the number, so this is
|
||||
// corrupt-input armor, not an operating point.
|
||||
assert_eq!(
|
||||
encoder_kbps_for_budget(500, 8_500, 50, 1408),
|
||||
MIN_BITRATE_KBPS
|
||||
);
|
||||
|
||||
// FEC reallocation is monotone: more parity ⇒ a lower video rate, same budget.
|
||||
let calm = encoder_kbps_for_budget(20_000, 300, 1, 1408);
|
||||
let burned = encoder_kbps_for_budget(20_000, 300, 5, 1408);
|
||||
let stormy = encoder_kbps_for_budget(20_000, 300, 50, 1408);
|
||||
assert!(calm > burned && burned > stormy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fec_floor_burns_reearns_and_doubles_on_early_reburn() {
|
||||
let mut f = FecFloor::default();
|
||||
|
||||
@@ -1113,6 +1113,16 @@ pub(super) async fn negotiate(
|
||||
// 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;
|
||||
// The budget→encoder conversion for the prep build (RFC §5.1): `bitrate_kbps` is
|
||||
// the session's WIRE BUDGET; the prep's encoder opens at the derived video rate,
|
||||
// snapshotted at the Welcome's initial FEC percent. The stream loop's FEC watcher
|
||||
// re-derives from there.
|
||||
let enc_of = super::EncDerive {
|
||||
audio_kbps: super::audio_reserved_kbps(&welcome),
|
||||
shard_payload: welcome.shard_payload,
|
||||
fec_percent: welcome.fec.fec_percent,
|
||||
identity: codec == crate::encode::Codec::PyroWave,
|
||||
};
|
||||
let trace = bringup.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-stream".into())
|
||||
@@ -1126,6 +1136,7 @@ pub(super) async fn negotiate(
|
||||
multi_slice,
|
||||
bitrate_kbps,
|
||||
bitrate_auto,
|
||||
enc_of,
|
||||
bit_depth,
|
||||
chroma,
|
||||
codec,
|
||||
|
||||
@@ -1378,8 +1378,18 @@ pub(super) struct SessionContext {
|
||||
/// (and on to the backend instance) rather than through `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION`:
|
||||
/// two sessions connecting at once used to overwrite each other's decision in the process env.
|
||||
pub(super) gamescope_route: Option<crate::vdisplay::GamescopeRoute>,
|
||||
/// Negotiated encoder bitrate (kbps).
|
||||
/// The session's TOTAL WIRE budget (kbps, RFC §5.1) — what the client asked for and what
|
||||
/// every ack, adopt and `live_bitrate` store echoes: video plus FEC parity, packet
|
||||
/// framing and the audio reservation. Encoder opens/reconfigures convert at the live FEC
|
||||
/// percent through [`super::EncDerive`]; PyroWave is the identity (its pin IS an encoder
|
||||
/// rate).
|
||||
pub(super) bitrate_kbps: u32,
|
||||
/// The audio plane's reservation off the top of the budget (see
|
||||
/// `native::audio_reserved_kbps`).
|
||||
pub(super) audio_reserved_kbps: u32,
|
||||
/// The negotiated shard payload — the framing-overhead denominator in the budget
|
||||
/// derivation.
|
||||
pub(super) shard_payload: u16,
|
||||
/// The encoder's live APPLIED rate (kbps) — shared with the send pacer, the web console, the
|
||||
/// mgmt registry AND the control task (which acks climbs against it). The encode loop stores
|
||||
/// `Encoder::applied_bitrate_bps` here after every apply, so everything downstream tracks
|
||||
@@ -1682,6 +1692,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
compositor,
|
||||
gamescope_route,
|
||||
mut bitrate_kbps,
|
||||
audio_reserved_kbps,
|
||||
shard_payload,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
@@ -1753,6 +1765,18 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
|
||||
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
||||
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
||||
// RFC §5.1: `bitrate_kbps` — and every number that reaches the client (acks, adopts,
|
||||
// `live_bitrate`, the console) — is the session's TOTAL WIRE BUDGET. Only the encoder
|
||||
// opens/reconfigures below convert, through an [`super::EncDerive`] snapshotted at the
|
||||
// live adaptive-FEC percent; the FEC-step watcher in the loop re-derives when it moves,
|
||||
// so parity reallocates WITHIN the budget instead of inflating the wire.
|
||||
let budget_identity = plan.codec == crate::encode::Codec::PyroWave;
|
||||
let enc_derive = move |fec: u8| super::EncDerive {
|
||||
audio_kbps: audio_reserved_kbps,
|
||||
shard_payload,
|
||||
fec_percent: fec,
|
||||
identity: budget_identity,
|
||||
};
|
||||
let streamed_wire = streamed_au && std::env::var("PUNKTFUNK_STREAMED_AU").as_deref() != Ok("0");
|
||||
// Slice-granularity streamed blocks (P2): needs the streamed wire AND the client's
|
||||
// multi-slice tolerance (the slices only exist when the encoder splits the frame, which
|
||||
@@ -1935,6 +1959,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bitrate_kbps,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
enc_derive(fec_target.load(Ordering::Relaxed)),
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
@@ -2238,6 +2263,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
.spawn({
|
||||
let stop = stop.clone();
|
||||
let phase_send = phase.clone();
|
||||
// The encode loop keeps its own handle: the RFC §5.1 FEC-step watcher
|
||||
// re-derives the encoder rate off the same target the send loop applies.
|
||||
let fec_target_send = fec_target.clone();
|
||||
move || {
|
||||
send_loop(
|
||||
session,
|
||||
@@ -2249,7 +2277,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
send_spread_send,
|
||||
slice_wire,
|
||||
burst_cap,
|
||||
fec_target,
|
||||
fec_target_send,
|
||||
shard_rx,
|
||||
send_stats,
|
||||
timing_conn,
|
||||
@@ -2422,6 +2450,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// `depth_frames` skips the startup warmup so first-frame bring-up cost can't false-escalate.
|
||||
let mut cur_depth: usize = 1;
|
||||
let mut behind_score: u32 = 0;
|
||||
// The FEC percent the encoder's video rate was last derived at (RFC §5.1) — the FEC-step
|
||||
// watcher in the loop re-derives when the live target moves off this.
|
||||
let mut last_fec = fec_target.load(Ordering::Relaxed);
|
||||
let mut depth_frames: u64 = 0;
|
||||
// Observed source-delivery period (EMA over REAL frames' arrival spacing, ns) — the budget
|
||||
// the behind test scores encode work against. The negotiated refresh alone is the wrong
|
||||
@@ -2522,6 +2553,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bitrate_kbps,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
enc_derive(fec_target.load(Ordering::Relaxed)),
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
@@ -2622,6 +2654,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
new_mode,
|
||||
mode_bitrate,
|
||||
bit_depth,
|
||||
enc_derive(fec_target.load(Ordering::Relaxed)),
|
||||
plan,
|
||||
&quit,
|
||||
resize_trace.as_ref(),
|
||||
@@ -2645,6 +2678,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
mode_bitrate,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
enc_derive(fec_target.load(Ordering::Relaxed)),
|
||||
plan,
|
||||
&quit,
|
||||
// The display this rebuild supersedes (retired below once the new pipeline is
|
||||
@@ -2785,6 +2819,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
cur_mode,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
enc_derive(fec_target.load(Ordering::Relaxed)),
|
||||
plan,
|
||||
&quit,
|
||||
trace.as_ref(),
|
||||
@@ -2823,6 +2858,27 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// with (the same resync discipline as a mode switch, minus the pipeline churn) and owns
|
||||
// the bitrate clamping. Rates arrive pre-clamped by the control task
|
||||
// (`resolve_bitrate_kbps`).
|
||||
// RFC §5.1: adaptive FEC reallocates WITHIN the wire budget — when the target percent
|
||||
// moves, the encoder's video rate re-derives so parity never inflates the wire. In
|
||||
// place only (a FEC step is not worth a rebuild stall); a backend without in-place
|
||||
// retarget catches up at the next real bitrate change. Runs BEFORE the drain so a
|
||||
// request landing this iteration supersedes it. PyroWave: identity — nothing to do.
|
||||
if !budget_identity {
|
||||
let fec_now = fec_target.load(Ordering::Relaxed);
|
||||
if fec_now != last_fec {
|
||||
let prev = enc_derive(last_fec).enc_kbps(bitrate_kbps);
|
||||
let want = enc_derive(fec_now).enc_kbps(bitrate_kbps);
|
||||
last_fec = fec_now;
|
||||
if want != prev && enc.reconfigure_bitrate(want as u64 * 1000) {
|
||||
tracing::debug!(
|
||||
fec_pct = fec_now,
|
||||
encoder_kbps = want,
|
||||
budget_kbps = bitrate_kbps,
|
||||
"adaptive FEC moved — encoder rate re-derived within the wire budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut want_kbps = None;
|
||||
while let Ok(k) = bitrate_rx.try_recv() {
|
||||
want_kbps = Some(k);
|
||||
@@ -2848,7 +2904,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
}
|
||||
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
||||
if enc.reconfigure_bitrate(new_kbps as u64 * 1000) {
|
||||
// The request (and everything reported back) is a WIRE BUDGET; the encoder is
|
||||
// handed the derived video rate, and its applied read-back converts BACK to
|
||||
// budget so acks, the ceiling and the console keep speaking the user's unit.
|
||||
let ed = enc_derive(fec_target.load(Ordering::Relaxed));
|
||||
if enc.reconfigure_bitrate(ed.enc_kbps(new_kbps) as u64 * 1000) {
|
||||
// Adopt the encoder's post-clamp truth, not the request: it feeds the send
|
||||
// pacer, the console/mgmt view and the control task's acks, and a short apply
|
||||
// teaches the ceiling used above.
|
||||
@@ -2856,6 +2916,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
.applied_bitrate_bps()
|
||||
.map(|b| (b / 1000) as u32)
|
||||
.filter(|&k| k > 0)
|
||||
.map(|k| ed.budget_kbps(k))
|
||||
.unwrap_or(new_kbps);
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
@@ -2895,7 +2956,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
frame.width,
|
||||
frame.height,
|
||||
hz,
|
||||
new_kbps as u64 * 1000,
|
||||
ed.enc_kbps(new_kbps) as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
@@ -2909,6 +2970,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
.applied_bitrate_bps()
|
||||
.map(|b| (b / 1000) as u32)
|
||||
.filter(|&k| k > 0)
|
||||
.map(|k| ed.budget_kbps(k))
|
||||
.unwrap_or(new_kbps);
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
@@ -3462,6 +3524,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bitrate_kbps,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
enc_derive(fec_target.load(Ordering::Relaxed)),
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
@@ -4534,8 +4597,10 @@ fn try_inplace_resize(
|
||||
frame: &mut crate::capture::CapturedFrame,
|
||||
interval: &mut std::time::Duration,
|
||||
new_mode: punktfunk_core::Mode,
|
||||
// The session's wire budget (kbps) — the fresh encoder below opens at the DERIVED rate.
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
enc_of: super::EncDerive,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
trace: &crate::bringup::Trace,
|
||||
@@ -4650,7 +4715,7 @@ fn try_inplace_resize(
|
||||
new_frame.width,
|
||||
new_frame.height,
|
||||
effective_hz,
|
||||
bitrate_kbps as u64 * 1000,
|
||||
enc_of.enc_kbps(bitrate_kbps) as u64 * 1000,
|
||||
new_frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
@@ -4718,6 +4783,9 @@ pub(super) fn prepare_display(
|
||||
// Passed through to [`build_pipeline`] — see its parameter of the same name.
|
||||
bitrate_auto: bool,
|
||||
bit_depth: u8,
|
||||
// The budget→encoder conversion for the prep build (Welcome-time FEC snapshot — the
|
||||
// session loop's FEC watcher takes over once streaming).
|
||||
enc_of: super::EncDerive,
|
||||
chroma: crate::encode::ChromaFormat,
|
||||
codec: crate::encode::Codec,
|
||||
shard_payload: u16,
|
||||
@@ -4769,6 +4837,7 @@ pub(super) fn prepare_display(
|
||||
bitrate_kbps,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
enc_of,
|
||||
plan,
|
||||
quit,
|
||||
stop,
|
||||
@@ -4796,6 +4865,8 @@ fn build_pipeline_with_retry(
|
||||
// Passed through to [`build_pipeline`] — see its parameter of the same name.
|
||||
bitrate_auto: bool,
|
||||
bit_depth: u8,
|
||||
// Passed through to [`build_pipeline`] — the budget→encoder conversion for its open.
|
||||
enc_of: super::EncDerive,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
stop: &Arc<AtomicBool>,
|
||||
@@ -4863,6 +4934,7 @@ fn build_pipeline_with_retry(
|
||||
bitrate_kbps,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
enc_of,
|
||||
plan,
|
||||
quit,
|
||||
None, // fresh bring-up — no display superseded
|
||||
@@ -5125,12 +5197,16 @@ fn reset_stalled_encoder(
|
||||
fn build_pipeline(
|
||||
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||
mode: punktfunk_core::Mode,
|
||||
// The session's WIRE BUDGET (kbps, RFC §5.1) — the number returned in the Pipeline and
|
||||
// adopted by the caller stays budget-domain; only the `open_video` below converts (`enc`).
|
||||
bitrate_kbps: u32,
|
||||
// The client asked for "Automatic", so `bitrate_kbps` is the host's own codec-aware answer for
|
||||
// `mode` — and may be re-resolved below when the source delivers a different size than `mode`.
|
||||
// An explicit client rate is left exactly as given.
|
||||
bitrate_auto: bool,
|
||||
bit_depth: u8,
|
||||
// The budget→encoder conversion at this build's moment (live FEC snapshotted by the caller).
|
||||
enc_of: super::EncDerive,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
// The pool gen of the display this build REPLACES (`Some` only on the mode-switch full
|
||||
@@ -5306,7 +5382,7 @@ fn build_pipeline(
|
||||
frame.width,
|
||||
frame.height,
|
||||
effective_hz,
|
||||
bitrate_kbps as u64 * 1000,
|
||||
enc_of.enc_kbps(bitrate_kbps) as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
|
||||
@@ -44,13 +44,15 @@ is always fullscreen, so there it lands on native); not Android.
|
||||
stores an explicit rate (60 Hz default): iPhone and iPad offer the device's displayable rates, on a
|
||||
Mac you type one in, and Apple TV's rate rides with the Stream mode preset.
|
||||
|
||||
**Bitrate** — *default: Automatic.* For H.264, HEVC and AV1, Automatic means the host's default,
|
||||
**20 Mbps**, plus two things an explicit rate switches off: adaptive bitrate, and a link-capacity
|
||||
probe about two seconds in that lets the rate climb past 20 Mbps. Automatic never descends below
|
||||
**2 Mbps** — on a link that thin the picture goes visibly soft rather than lossy, and the client
|
||||
logs a one-line warning the first time it happens. An explicit rate is fixed for the session,
|
||||
clamped to **500 kbps – 8 Gbps**. A host card's menu has **Test network speed…** to suggest a
|
||||
value.
|
||||
**Bitrate** — *default: Automatic.* The number is a **total wire budget**: everything the session
|
||||
sends — video, error-correction parity, packet framing, and the audio plane's share — fits inside
|
||||
it, so "20 Mbps" means 20 Mbps on your network, not 20 Mbps of video plus overheads on top. For
|
||||
H.264, HEVC and AV1, Automatic means the host's default, **20 Mbps**, plus two things an explicit
|
||||
rate switches off: adaptive bitrate, and a link-capacity probe about two seconds in that lets the
|
||||
rate climb past 20 Mbps. Automatic never descends below **2 Mbps** — on a link that thin the
|
||||
picture goes visibly soft rather than lossy, and the client logs a one-line warning the first time
|
||||
it happens. An explicit rate is fixed for the session, clamped to **500 kbps – 8 Gbps**. A host
|
||||
card's menu has **Test network speed…** to suggest a value.
|
||||
|
||||
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
|
||||
|
||||
@@ -129,7 +129,7 @@ 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 1–50 % 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_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 1–50 % 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. Under the wire-budget bitrate (see [Bitrate](#bitrate)) the pinned percent is still carved out of the budget, it just never moves. 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. |
|
||||
@@ -288,8 +288,12 @@ table, where client and host read the *same* variable name for their own half of
|
||||
|
||||
## Bitrate
|
||||
|
||||
The client requests a bitrate; the host encodes to it. There's no host-side bitrate knob. To find a
|
||||
good value:
|
||||
The client requests a bitrate; there's no host-side bitrate knob. On the native `punktfunk/1`
|
||||
plane the number is a **total wire budget**: what actually leaves the host — video, error-correction
|
||||
parity, packet framing, and the audio plane's share — fits inside it, and when adaptive FEC adds
|
||||
parity in answer to loss, the video rate comes down so the wire doesn't go up. (Older pairs, and
|
||||
the GameStream/Moonlight plane, keep the historical meaning: the number programs the encoder, and
|
||||
overheads ride on top.) To find a good value:
|
||||
|
||||
- **Native clients (Apple, Linux, Windows, Android):** use the built-in **speed test** (from a
|
||||
host's menu). It measures your link, suggests a bitrate, and applies it.
|
||||
|
||||
Reference in New Issue
Block a user