Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a604cbf3e | ||
|
|
c1bffa9e7b | ||
|
|
8ca86b1682 | ||
|
|
dcedd7147f | ||
|
|
4dcc31dc3b | ||
|
|
2bb6af3b92 | ||
|
|
2a951a6bb5 | ||
|
|
ae13b29abd | ||
|
|
9c278ee351 | ||
|
|
f79e9eb524 | ||
|
|
1511374959 |
@@ -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",
|
||||
|
||||
@@ -795,6 +795,25 @@ fn pump(
|
||||
// rung at all, so advertising HEVC would promise what this build cannot keep.
|
||||
¶ms.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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 1–50 % 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 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_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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user