Compare commits

..
Author SHA1 Message Date
enricobuehler 5bf64e07bf The control loop stops believing its own bookkeeping (ABR overhaul Phase 2)
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m13s
ci / bun-nix (pull_request) Successful in 23s
ci / docs-drift (pull_request) Successful in 26s
android / android (pull_request) Successful in 5m44s
ci / docs-site (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 4m35s
ci / rust (pull_request) Successful in 9m53s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m21s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m56s
The four correctness seams from the 08-22 auto-bitrate review §2, chosen
options per the RFC (planning design/abr-stack-overhaul.md §3):

- §2.3: a failed bitrate-change encoder rebuild now snaps the client back
  (retarget_tx) — the control task acks BEFORE the apply, so the client's
  climb base, utilization and proven math tracked a rate the encoder
  never ran until some later event happened to correct them.
- §2.2: the ABR rebuild announces PipelineGap on success, like the
  mode-switch and topology rebuilds already do — a ~0.6 s host-local
  stall read as congestion killed slow start for the session (the 401 ms
  field case: minutes at ~15 Mbps on a clean link).
- §2.1: an accepted mode switch re-teaches the stream-shape cap —
  computed once from the Welcome mode, 1080p→4K kept a 1080p-sized climb
  ceiling and 4K→720p left an oversized one standing. A re-set
  set_stream_cap also rebinds the already-learned ceiling downward
  (set_ceiling deliberately never lowers); the FIRST set keeps the
  founding semantics, pinned by the existing stream-bound test.
- §2.4: the bitrate_ack slot becomes a queue drained in arrival order —
  latest-wins collapsed a full resolve ack + corrective short retarget
  landing in the same 750 ms window, and host-cap learning needs two
  CONSECUTIVE short acks.

punktfunk-core --features quic: 490 tests green natively, including the
new a_mode_switch_reteaches_the_stream_cap_both_ways.
2026-08-26 19:10:28 +02:00
14 changed files with 181 additions and 491 deletions
-31
View File
@@ -2305,37 +2305,6 @@ mod tests {
);
}
/// The repeated-stall (non-metronomic) WARN's window arithmetic: fires at the third reported
/// stall inside 60 s, stays quiet through the 300 s re-warn spacing, and re-arms on a fresh
/// burst after old entries age out.
#[test]
fn stall_rate_warn_window_and_rewarn() {
let base = Instant::now();
let at = |s: u64| base + Duration::from_secs(s);
let mut w = StallWatch::new();
assert_eq!(w.note_for_rate_warn(at(0)), None);
assert_eq!(w.note_for_rate_warn(at(10)), None);
assert_eq!(
w.note_for_rate_warn(at(20)),
Some(3),
"third stall in 60 s warns"
);
assert_eq!(
w.note_for_rate_warn(at(30)),
None,
"inside the re-warn spacing the arm stays quiet"
);
// A fresh burst well past the spacing: the old entries have aged out of the window,
// so it takes a full RATE_MIN_STALLS again — and then warns again.
assert_eq!(w.note_for_rate_warn(at(400)), None);
assert_eq!(w.note_for_rate_warn(at(401)), None);
assert_eq!(
w.note_for_rate_warn(at(402)),
Some(3),
"re-warns after the spacing"
);
}
/// [`stall::attribute`]'s verdict table — the Branch-1/Branch-2 fork, per evidence shape.
#[test]
fn stall_attribution_verdicts() {
@@ -672,15 +672,6 @@ impl IddPushCapturer {
// it back to the caller to retire or reuse the display (audit §5.1).
_keepalive: Box::new(()),
};
// The two REALTIME GPU-priority opt-ins, stamped once per capture session so EVERY
// field log self-describes its posture — the stall WARNs repeat them, but only when
// they fire, and the 7700 XT case (2026-08-26) showed a stalling log where they
// never did (design: windows-amd-host-program §3.1 Gap B).
tracing::info!(
rt_gpu_driver = super::stall::rt_gpu_driver_posture(),
rt_gpu_host = super::stall::rt_gpu_host_posture(),
"GPU-priority posture for this capture session"
);
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
// from the blend (which holds the ring slot's keyed mutex — see
// `refresh_sdr_white_scale`). No-op on an SDR composition.
+46 -146
View File
@@ -129,13 +129,9 @@ pub(super) enum StallClass {
CompositorBlocked,
/// Engines alive, DWM's clock ticking, driver drained E_PENDING, and the ETW present witness
/// saw (essentially) NO swapchain presents from ANY process across the hole: the content
/// stopped presenting — no damage, DWM correctly composed nothing. A ONE-OFF here is benign
/// (a game hitch, a loading screen, a menu). But this class is also what a frozen *presenter*
/// looks like (disturbance-immunity Flavor 3: the display stack — win32k CCD lock, UMD
/// serialization against display events, vblank-wait limiters — stops the content's present
/// loop), and every probe we run sits at the host's elevated GPU priority, so normal-band
/// starvation reads healthy. REPEATED holes under active load do NOT exonerate the display
/// path — the repeated-stall / metronomic WARNs carry that triage.
/// stopped presenting — no damage, DWM correctly composed nothing (a game hitch, a loading
/// screen, a menu). Benign for the display path; the content side is where to look if the
/// user FELT it.
ContentSilence,
/// Engines alive, DWM ticking, driver drained E_PENDING — and the ETW present witness saw
/// presents FLOWING through the hole while the virtual display's kernel queue
@@ -159,7 +155,7 @@ impl std::fmt::Display for StallClass {
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
}
Self::ContentSilence => {
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; a one-off is a game hitch/menu, but REPEATED holes under load can equally be the display stack freezing the presenter)"
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; not the display path)"
}
Self::FrameGeneration => {
"FRAME-GENERATION (presents FLOWED while the virtual display's kernel queue starved — the OS display path dropped composed frames)"
@@ -169,41 +165,6 @@ impl std::fmt::Display for StallClass {
}
}
/// The vdisplay driver's GPU-priority lever (`PFVD_RT_GPU` / legacy opt-out `PFVD_NO_RT_GPU`) as
/// configured in THIS process's environment (machine env; the WUDFHost driver process resolves
/// the pair the same way, so this read mirrors what the driver decided — modulo a machine env
/// edited after either process started, which a restart heals). The RX 9070 XT field A/B
/// (2026-08-12) convicted EXACTLY this lever's REALTIME rung of the metronomic stall signature,
/// so every stall-triage line must say whether it is engaged before anyone chases display
/// hardware.
pub(super) fn rt_gpu_driver_posture() -> &'static str {
if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
"off (PFVD_NO_RT_GPU)"
} else {
match std::env::var_os("PFVD_RT_GPU") {
None => "off (default)",
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
Some(_) => "REALTIME (PFVD_RT_GPU)",
}
}
}
/// The host's own GPU scheduling-priority policy (`PUNKTFUNK_GPU_PRIORITY_CLASS`) as prose —
/// [`rt_gpu_driver_posture`]'s twin for the second convicted REALTIME lever (the `auto` gate's
/// HIGH→REALTIME upgrade, ~3.6 s beat in the same A/B).
pub(super) fn rt_gpu_host_posture() -> &'static str {
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
.as_deref()
{
Some("off") => "off",
Some("normal") => "normal",
Some("realtime") => "REALTIME (pinned)",
Some("auto") => "auto (gated REALTIME upgrade)",
_ => "high (default)",
}
}
/// How many window presents acquit the content: ≥8 presents across the hole mirrors
/// [`attribute`]'s offered-frames bar and [`StallWatch::RECENT`]'s sustained-flow definition —
/// a caret blink or a stall-ending frame stays under it, a game presenting through the hole
@@ -347,13 +308,6 @@ pub(super) struct StallWatch {
episode: Option<Episode>,
/// A closed episode's summary, parked for the caller ([`Self::take_recovery`]).
pending_recovery: Option<Recovery>,
/// Instants of REPORTED stalls inside the last [`Self::RATE_WINDOW`] — the repeated-stall
/// (non-metronomic) WARN's evidence. The metronome needs a stable period; the 2026-08-26
/// 7700 XT field case showed 6 stall-sized holes in 8 s with none, and its log carried zero
/// triage guidance as a result.
rate_window: std::collections::VecDeque<Instant>,
/// When the repeated-stall WARN last fired (spacing: [`Self::RATE_REWARN`]).
last_rate_warn: Option<Instant>,
}
impl StallWatch {
@@ -373,14 +327,6 @@ impl StallWatch {
/// Episodes with fewer holes than this dissolve silently — the single stall's own report
/// line already covers them.
const EPISODE_MIN_HOLES: u32 = 2;
/// Rolling window for the repeated-stall (non-metronomic) WARN.
const RATE_WINDOW: Duration = Duration::from_secs(60);
/// Reported stalls inside [`Self::RATE_WINDOW`] that make the session WARN-worthy — well
/// above the ~1-per-minute a busy desktop legitimately produces, well under a degraded
/// session's dozens.
const RATE_MIN_STALLS: usize = 3;
/// Re-WARN spacing for the rate arm (the metronomic arms pace themselves via the metronome).
const RATE_REWARN: Duration = Duration::from_secs(300);
pub(super) fn new() -> Self {
Self {
@@ -392,59 +338,9 @@ impl StallWatch {
classes: [0; 7],
episode: None,
pending_recovery: None,
rate_window: std::collections::VecDeque::new(),
last_rate_warn: None,
}
}
/// Feed one REPORTED stall at `now` into the rate window; `Some(count)` exactly when the
/// repeated-stall WARN is due (≥ [`Self::RATE_MIN_STALLS`] inside [`Self::RATE_WINDOW`],
/// spaced by [`Self::RATE_REWARN`]). Pure — unit-tested beside the verdict tests.
pub(super) fn note_for_rate_warn(&mut self, now: Instant) -> Option<usize> {
self.rate_window.push_back(now);
while let Some(front) = self.rate_window.front() {
if now.duration_since(*front) > Self::RATE_WINDOW {
self.rate_window.pop_front();
} else {
break;
}
}
if self.rate_window.len() < Self::RATE_MIN_STALLS {
return None;
}
if self
.last_rate_warn
.is_some_and(|t| now.duration_since(t) < Self::RATE_REWARN)
{
return None;
}
self.last_rate_warn = Some(now);
Some(self.rate_window.len())
}
/// The session's per-verdict tally as one log token ([`StallVerdict`] order).
fn verdict_tally(&self) -> String {
format!(
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
)
}
/// The session's per-class tally as one log token ([`StallClass`] order).
fn class_tally(&self) -> String {
format!(
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
content-silence {}, frame-generation {}, unattributed {}",
self.classes[0],
self.classes[1],
self.classes[2],
self.classes[3],
self.classes[4],
self.classes[5],
self.classes[6]
)
}
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
/// the reset the first post-recreate frame would read as one). An open episode is closed and
/// summarized: its holes predate the recreate and are real evidence.
@@ -592,37 +488,6 @@ impl StallWatch {
"IDD-push capture stall — the desktop was composing at speed, then the ring \
delivered no frame for the gap; the class names the leg that lost them"
);
// The repeated-stall arm: the metronome needs a stable period, but a session losing
// frames to 150+ ms holes every few seconds without one (the 2026-08-26 7700 XT case)
// deserves the same triage payload — otherwise the log's only guidance is per-stall
// DEBUG lines nobody is told to read. Skipped when THIS stall completed a metronomic
// cycle: the arms below carry strictly richer prose.
if stall.metronomic.is_none() {
if let Some(stalls_in_window) = self.note_for_rate_warn(now) {
let suspects = pf_win_display::display_events::connected_inactive_physicals();
let suspects = if suspects.is_empty() {
"none".to_string()
} else {
suspects.join(", ")
};
tracing::warn!(
stalls_in_window = stalls_in_window as u64,
os_correlated = format!("{}/{}", self.with_os_events, self.seen),
connected_inactive = %suspects,
rt_gpu_driver = rt_gpu_driver_posture(),
rt_gpu_host = rt_gpu_host_posture(),
verdicts = %self.verdict_tally(),
classes = %self.class_tally(),
"capture stalls are REPEATING without a stable period — same triage as the \
metronomic class: if rt_gpu_driver or rt_gpu_host shows a REALTIME opt-in, \
clear it first (unset PFVD_RT_GPU / set PUNKTFUNK_GPU_PRIORITY_CLASS=high); \
then a connected-but-inactive display's standby servicing (see \
connected_inactive), then display-poller software (the SteelSeries GG / \
SignalRGB class). A content-silence class tally does NOT exonerate the \
display stack a frozen presenter reads identically (Flavor 3)"
);
}
}
if let Some(period) = stall.metronomic {
let suspects = pf_win_display::display_events::connected_inactive_physicals();
let suspects = if suspects.is_empty() {
@@ -632,8 +497,21 @@ impl StallWatch {
};
let correlated = format!("{}/{}", self.with_os_events, self.seen);
// The session's attribution in one token: which leg the evidence convicted, per stall.
let verdict_tally = self.verdict_tally();
let class_tally = self.class_tally();
let verdict_tally = format!(
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
);
let class_tally = format!(
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
content-silence {}, frame-generation {}, unattributed {}",
self.classes[0],
self.classes[1],
self.classes[2],
self.classes[3],
self.classes[4],
self.classes[5],
self.classes[6]
);
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
// cascade is OS-visible; otherwise the disturbance never surfaces above the
// driver. Different classes, different cures — say which one this box has.
@@ -655,11 +533,33 @@ impl StallWatch {
suspects)"
);
} else {
// The two REALTIME GPU-priority opt-ins (see the posture helpers' docs for the
// 2026-08-12 A/B that convicted both) — a log carrying this warning must say
// whether either lever is engaged before anyone chases display hardware.
let rt_gpu_driver = rt_gpu_driver_posture();
let rt_gpu_host = rt_gpu_host_posture();
// The two REALTIME GPU-priority opt-ins, as configured in THIS process's
// environment (machine env; the WUDFHost driver process resolves the PFVD pair
// the same way, so this read mirrors what the driver decided — modulo a machine
// env edited after either process started, which a restart heals). The RX 9070
// XT field A/B (2026-08-12) convicted EXACTLY this warning's signature twice
// over: the driver's swap-chain REALTIME raise beat at ~1.8 s, the host
// auto-gate's REALTIME upgrade at ~3.6 s — so a log carrying this warning must
// say whether either lever is engaged before anyone chases display hardware.
let rt_gpu_driver = if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
"off (PFVD_NO_RT_GPU)"
} else {
match std::env::var_os("PFVD_RT_GPU") {
None => "off (default)",
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
Some(_) => "REALTIME (PFVD_RT_GPU)",
}
};
let rt_gpu_host = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
.as_deref()
{
Some("off") => "off",
Some("normal") => "normal",
Some("realtime") => "REALTIME (pinned)",
Some("auto") => "auto (gated REALTIME upgrade)",
_ => "high (default)",
};
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
+1 -102
View File
@@ -689,10 +689,7 @@ unsafe fn set_prop(
result_name(r)
))
} else {
// INFO, not debug: which optional properties a VCN generation/driver rejects is exactly
// the per-box capability matrix no lab hardware covers (design: windows-amd-host-program
// §3.3) — field logs at default level must carry it.
tracing::info!(
tracing::debug!(
property = %name,
result = result_name(r),
amf_code = r,
@@ -702,18 +699,6 @@ unsafe fn set_prop(
}
}
/// Read one INT64 component property back (`GetProperty`, prefix vtable) — the encoder-side truth
/// after any internal clamp. `None` when the runtime declines the read or hands back a non-INT64
/// variant: callers treat that as "no readback", never as zero.
unsafe fn get_prop_i64(comp: *mut sys::AmfComponent, name: PCWSTR) -> Option<i64> {
let mut v = AmfVariant::zeroed();
let r = ((*(*comp).vtbl).get_property)(comp, name.0, &mut v);
if r != sys::AMF_OK {
return None;
}
v.as_i64()
}
// ---------------------------------------------------------------------------------------------
/// Input texture ring depth. Every submitted surface wraps a ring slot that AMF keeps reading
@@ -1281,10 +1266,6 @@ impl AmfEncoder {
height = self.height,
fps = self.fps,
ring = if self.ten_bit { "P010" } else { "NV12" },
// The two driver-answered capabilities (design: windows-amd-host-program §3.3);
// the rejected optional properties behind a `false` have their own INFO lines.
ltr = ltr_active,
intra_refresh = ir_active,
runtime = %format_args!(
"{}.{}.{}",
(lib.version >> 48) & 0xffff,
@@ -2217,22 +2198,6 @@ impl Encoder for AmfEncoder {
true
}
/// The rate the component actually runs at: `TargetBitrate` read back via `GetProperty`.
/// Without this the session adopted the *requested* rate on AMD, `encoder_ceiling_kbps` was
/// never learned, and the ABR overdrive guard was structurally inert on the one backend with
/// no other rate feedback (design: windows-amd-host-program §3.3). `None` before the lazy
/// open or when the runtime declines the read — the caller keeps the requested rate, exactly
/// the pre-readback behavior.
fn applied_bitrate_bps(&self) -> Option<u64> {
let inner = self.inner.as_ref()?;
// SAFETY: `inner.comp.0` is the live component, only ever used on the session thread
// with no AMF call in flight (the loop is synchronous); `get_prop_i64` is a read-only
// prefix-vtable call whose out-param is a local this frame owns.
unsafe { get_prop_i64(inner.comp.0, self.props.target_bitrate) }
.filter(|&b| b > 0)
.map(|b| b as u64)
}
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
let bps_i = bps.min(i64::MAX as u64) as i64;
let vbv = self.vbv_bits(bps);
@@ -2687,72 +2652,6 @@ mod tests {
}
}
/// Live `applied_bitrate_bps` readback (windows-amd-host-program §3.3): the typed
/// `GetProperty` vtable slot must return the rate the component actually accepted — before
/// the lazy open it is `None`, after the first submit it reads the open rate, and after a
/// dynamic retarget it reads the NEW rate. This is the whole ABR-ceiling feedback path on
/// AMD, and the vtable typing is the concentrated FFI risk — so prove it on real hardware.
/// Skips cleanly without the AMD runtime/GPU.
#[test]
fn amf_applied_bitrate_readback_live() {
if let Err(e) = try_factory() {
eprintln!("skipping: AMF runtime unavailable ({e})");
return;
}
let Some(device) = amd_d3d11_device() else {
eprintln!("skipping: no AMD adapter on this box");
return;
};
let (w, h, fps) = (640u32, 480u32, 60u32);
let tex = nv12_texture(&device, w, h);
let mut enc = AmfEncoder::open(
Codec::H265,
PixelFormat::Nv12,
w,
h,
fps,
2_000_000,
8,
ChromaFormat::Yuv420,
)
.expect("native AMF open");
assert_eq!(
enc.applied_bitrate_bps(),
None,
"no readback before the lazy open — the caller must keep the requested rate"
);
let frame = CapturedFrame {
width: w,
height: h,
pts_ns: 1,
format: PixelFormat::Nv12,
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(),
device: device.clone(),
pyro: None,
}),
cursor: None,
};
enc.submit(&frame).expect("submit");
let opened = enc.applied_bitrate_bps();
assert_eq!(
opened,
Some(2_000_000),
"post-open readback must be the accepted open rate"
);
assert!(
enc.reconfigure_bitrate(8_000_000),
"dynamic retarget declined on live hardware"
);
let retargeted = enc.applied_bitrate_bps();
assert_eq!(
retargeted,
Some(8_000_000),
"post-retarget readback must be the accepted NEW rate"
);
eprintln!("live AMF applied-bitrate readback: open {opened:?} -> retarget {retargeted:?}");
}
/// Live native codec probe (design §4): on a box with the AMD runtime, AVC and HEVC must
/// probe true (every VCN generation encodes both); AV1's answer is hardware truth (RDNA3+).
#[test]
+1 -2
View File
@@ -305,8 +305,7 @@ pub struct AmfComponentVtbl {
// AMFPropertyStorage
pub set_property:
unsafe extern "system" fn(*mut AmfComponent, *const u16, AmfVariant) -> AmfResult,
pub get_property:
unsafe extern "system" fn(*mut AmfComponent, *const u16, *mut AmfVariant) -> AmfResult,
pub get_property: Slot,
pub has_property: Slot,
pub get_property_count: Slot,
pub get_property_at: Slot,
+2 -3
View File
@@ -90,9 +90,8 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize {
// GPU-saturated workload PyroWave cares about most.
//
// The old `PUNKTFUNK_GPU_PRIORITY` knob went with it; `PUNKTFUNK_GPU_PRIORITY_CLASS`
// (`off|normal|high|realtime|auto`, default `high` — `auto` is opt-in since the 2026-08-12 AMD
// field A/B convicted its REALTIME upgrade, see `pf-frame/src/dxgi.rs`) is the one that survives
// and it is strictly more capable — the removed knob could not express the auto gate at all.
// (`off|normal|high|realtime|auto`, default `auto`) is the one that survives and it is strictly
// more capable — the removed knob could not express the auto gate at all.
pub struct PyroWaveEncoder {
// pyrowave owns the whole Vulkan device (create_device_by_compat) — no ash on this side.
+5 -33
View File
@@ -406,41 +406,13 @@ pub fn run(action: EmulAction, connector_filter: Option<i32>) -> RunOutcome {
return RunOutcome::InitFailed(recs);
}
// What the filter below will see, one record per distinct (bus, vendor, present) — a probe
// that walks nothing must SAY why (first .173 run: 15 adapters enumerated, zero walked,
// zero explanation; "silence is not success" applies to the probe itself).
let mut seen_shapes: Vec<(i32, i32, i32)> = Vec::new();
for info in &infos {
let shape = (info.iBusNumber, info.iVendorID, info.iPresent);
if seen_shapes.contains(&shape) {
continue;
}
seen_shapes.push(shape);
rec(
"adl-adapter-seen",
&format!("bus{}", info.iBusNumber),
0,
ADL_OK,
format!(
"vendor_id={} present={} name={}",
info.iVendorID,
info.iPresent,
c_str(&info.strAdapterName).trim()
),
);
}
// One GPU surfaces as many logical adapters — probe each bus once, AMD only. The read-only
// Probe walks NON-present adapters too (real buses only): a headless iGPU reports
// `iPresent=0` yet its driver still answers `EDIDManagement_Caps`, and that headless iGPU
// is precisely the lab rung of the edid_lock ladder (.173, 2026-08-26 — the old
// present-only gate walked nothing there). Lock/Unlock keep the present requirement: a
// connector pin on an adapter without displays is a different experiment.
// One GPU surfaces as many logical adapters — probe each bus once, AMD-present only.
let mut seen_buses: Vec<i32> = Vec::new();
for info in &infos {
let present_ok =
info.iPresent != 0 || (matches!(action, EmulAction::Probe) && info.iBusNumber >= 0);
if !present_ok || info.iVendorID != AMD_VENDOR_ID || seen_buses.contains(&info.iBusNumber) {
if info.iPresent == 0
|| info.iVendorID != AMD_VENDOR_ID
|| seen_buses.contains(&info.iBusNumber)
{
continue;
}
seen_buses.push(info.iBusNumber);
+56 -48
View File
@@ -552,32 +552,26 @@ impl BitrateController {
}
/// Teach the controller what this session's mode and codec could plausibly use (see
/// [`stream_ceiling_kbps`]). Applied to LEARNED ceilings only, at the same funnel as the
/// [`stream_ceiling_kbps`]). Bounds future LEARNED ceilings at the same funnel as the
/// operator's env cap.
///
/// The FIRST set is the session's negotiated shape and keeps the founding semantics — a
/// negotiated start rate above it stands, the host resolved that number (pinned by
/// `the_stream_bound_clamps_a_learned_ceiling_only`). A RE-set is a mode switch, and
/// there a DROP in pixel rate also rebinds the already-standing ceiling: `set_ceiling`
/// clamps only at learn time and deliberately never lowers, so a 4K-learned ceiling
/// would otherwise stand over a 720p stream with only the reactive loss/decode signals
/// to bound the climb (review §2.1).
pub(crate) fn set_stream_cap(&mut self, kbps: u32) {
let mode_switch = self.stream_cap_kbps.is_some();
self.stream_cap_kbps = Some(kbps);
}
/// A MODE SWITCH re-teaches the stream cap — and unlike [`set_stream_cap`] at session
/// start, it also CLAMPS an already-learned ceiling down to the new shape: `set_ceiling` is
/// deliberately monotonic, so without this a 4K→720p switch keeps authorizing 4K-sized
/// climbs and only the reactive loss/decode signals rein them in (08-22 ABR review §2.1).
/// The current rate is untouched — descent stays the congestion signals' job — and an
/// up-switch only lifts the cap for FUTURE learning (the trimmed ceiling has no untrimmed
/// measurement to restore from; the §3.3 re-probe item owns that half).
pub(crate) fn rebind_stream_cap(&mut self, kbps: u32) {
self.stream_cap_kbps = Some(kbps);
if !self.enabled {
return;
}
let bound = kbps.min(self.ceiling_cap_kbps.unwrap_or(u32::MAX));
if self.ceiling_kbps > bound {
if mode_switch && self.enabled && self.ceiling_kbps > kbps {
tracing::info!(
ceiling_kbps = self.ceiling_kbps,
bound_kbps = bound,
"adaptive bitrate: mode switch shrank the stream shape — climb ceiling clamped"
stream_cap_kbps = kbps,
"adaptive bitrate: ceiling rebound to the switched mode's stream shape"
);
self.ceiling_kbps = bound;
self.ceiling_kbps = kbps;
}
}
@@ -1492,34 +1486,6 @@ mod tests {
assert_eq!(c.ceiling_kbps, 20_000);
}
/// A mode switch that shrinks the stream shape must also shrink an already-learned climb
/// ceiling — `set_ceiling` is monotonic, so before `rebind_stream_cap` a 4K→720p switch
/// kept authorizing 4K-sized climbs for the session (08-22 ABR review §2.1). An up-switch
/// lifts only the cap: with no untrimmed measurement stored, a higher ceiling would be
/// evidence-free (the §3.3 re-probe item owns that half). The negotiated-start ceiling
/// (nothing learned yet) stays clampable — the shape is real evidence, unlike the
/// below-start `set_ceiling` case the monotonic test pins.
#[test]
fn a_mode_switch_rebind_clamps_the_learned_ceiling_but_never_raises_it() {
let mut c = BitrateController::new(20_000);
c.set_stream_cap(400_000);
c.set_ceiling(300_000); // probe-learned, inside the 4K-ish shape
assert_eq!(c.ceiling_kbps, 300_000);
// Switch down: the 720p-ish shape must bind the learned ceiling immediately.
c.rebind_stream_cap(60_000);
assert_eq!(c.ceiling_kbps, 60_000, "down-switch clamps the ceiling");
// Switch back up: the cap lifts, but the ceiling holds — no measurement authorizes more.
c.rebind_stream_cap(400_000);
assert_eq!(c.ceiling_kbps, 60_000, "up-switch alone raises nothing");
// …until the next learned ceiling passes through the (now wider) funnel.
c.set_ceiling(300_000);
assert_eq!(c.ceiling_kbps, 300_000, "future learning uses the new cap");
// Disabled controller: the rebind records the cap and touches nothing else.
let mut d = BitrateController::new(0);
d.rebind_stream_cap(1);
assert_eq!(d.ceiling_kbps, 0);
}
/// The stream bound must cut the field runaway and must NOT touch a session anyone
/// actually runs. Both halves matter: a cap that silently trims a happy user is a
/// regression nobody reports.
@@ -1602,6 +1568,48 @@ mod tests {
);
}
/// Review §2.1: the stream-shape cap was computed once from the Welcome mode and never
/// again — 1080p→4K kept a 1080p-sized climb ceiling, 4K→720p left an oversized one
/// standing. A mode switch now re-teaches the cap: an upswitch opens room for the probe's
/// measurement to authorize more, a downswitch rebinds the already-learned ceiling.
#[test]
fn a_mode_switch_reteaches_the_stream_cap_both_ways() {
// 1080p session, probe measured a fat link: ceiling bound at the 1080p shape.
let mut c = BitrateController::new(20_000);
c.set_stream_cap(100_000);
c.set_ceiling(657_000);
assert_eq!(c.ceiling_kbps, 100_000);
// Switch UP to 4K: the new shape allows more, and the probe's measurement (already
// taken this session) may re-authorize up to it.
c.on_mode_switch();
c.set_stream_cap(400_000);
assert_eq!(
c.ceiling_kbps, 100_000,
"an upswitch alone raises nothing — authority still needs a measurement"
);
c.set_ceiling(657_000);
assert_eq!(
c.ceiling_kbps, 400_000,
"the 4K shape no longer pins the session to the 1080p bound"
);
// Switch DOWN to 720p: the learned 4K ceiling must not stand over the small stream —
// `set_ceiling` never lowers, so the re-taught cap is what rebinds it.
c.on_mode_switch();
c.set_stream_cap(42_000);
assert_eq!(
c.ceiling_kbps, 42_000,
"a downswitch rebinds the already-learned ceiling"
);
// A disabled controller (explicit bitrate) is untouched by all of it.
let mut d = BitrateController::new(0);
d.set_stream_cap(100_000);
d.set_stream_cap(42_000);
assert_eq!(d.ceiling_kbps, 0);
}
#[test]
fn owd_rise_alone_is_a_congestion_signal() {
let mut c = BitrateController::new(20_000);
+12 -12
View File
@@ -86,10 +86,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
let clock_rtt_ns = negotiated.clock_rtt_ns;
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
let negotiated_codec = negotiated.codec;
// Depth/chroma are session-negotiated (a mode switch changes geometry, not these) — copied
// so the data pump can re-size the stream cap when the accepted mode changes (§2.1).
let negotiated_bit_depth = negotiated.bit_depth;
let negotiated_chroma = negotiated.chroma_format;
// 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;
let chroma_format = negotiated.chroma_format;
// What this session's mode + codec could plausibly use — the bound the ABR holds its
// probe-measured link ceiling to. Computed here because this is where the Welcome-resolved
// geometry lives; the data pump stays codec-agnostic.
@@ -168,12 +168,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
}
});
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
// A QUEUE, not a latest-wins slot: a full resolve ack and a corrective short retarget can
// land in the same 750 ms report window, and host-cap learning needs to see BOTH in order
// (two consecutive short acks teach the cap — 08-22 ABR review §2.4; the collapsed slot
// could reintroduce the overdrive sawtooth the encoder-ceiling path exists to stop).
// Adaptive bitrate ack queue: the control task pushes every BitrateChanged; the pump's
// controller drains them in arrival order on its report tick. A QUEUE, not a latest-wins
// slot (review §2.4): a full resolve ack plus a corrective short retarget in the same
// 750 ms window used to collapse to whichever arrived last, and host-cap learning needs
// two CONSECUTIVE short acks — losing one delayed or prevented the cap and could
// reintroduce the encoder-overdrive sawtooth.
let bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>> =
Arc::new(Mutex::new(std::collections::VecDeque::new()));
// Decode-recovery keyframe asks (the ABR recovery signal): the control task counts every
@@ -288,8 +288,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
negotiated_bit_depth,
negotiated_chroma,
bit_depth,
chroma_format,
stream_cap_kbps,
refresh_hz,
mode_slot: mode_slot_pump,
@@ -200,18 +200,7 @@ impl ControlTask {
if ack.bitrate_kbps > 0 {
live_bitrate.store(ack.bitrate_kbps, Ordering::Relaxed);
}
{
// Queued in arrival order — a full resolve ack plus a corrective
// short retarget in one report window must BOTH reach the
// controller (§2.4; the old latest-wins slot dropped whichever
// came first). The cap is a can't-happen bound: the host sends at
// most a handful per window.
let mut acks = bitrate_ack.lock().unwrap();
if acks.len() >= 8 {
acks.pop_front();
}
acks.push_back(ack.bitrate_kbps);
}
bitrate_ack.lock().unwrap().push_back(ack.bitrate_kbps);
} else if let Ok(gap) = crate::quic::PipelineGap::decode(&msg) {
// The host rebuilt its capture ring + encoder in place and nothing flowed
// while it did. Park it for the pump, which discards the report window in
+32 -33
View File
@@ -27,6 +27,9 @@ pub(super) struct DataPump {
pub(super) mode_gen: Arc<AtomicU32>,
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
/// Host `BitrateChanged` acks since the last report tick, drained in arrival order — a
/// queue so a corrective short retarget can't be clobbered by a full resolve ack in the
/// same window (review §2.4; host-cap learning needs two CONSECUTIVE short acks).
pub(super) bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>>,
/// Outbound decode-recovery keyframe asks, counted by the control task at its send choke
/// point; drained per report window as the ABR's recovery signal.
@@ -40,13 +43,15 @@ pub(super) struct DataPump {
/// The rate the host actually configured (echoed in Welcome).
pub(super) resolved_bitrate_kbps: u32,
pub(super) negotiated_codec: u8,
/// Session-negotiated depth/chroma (a mode switch changes geometry, not these) — inputs to
/// the stream-cap re-size when the accepted mode changes.
pub(super) negotiated_bit_depth: u8,
pub(super) negotiated_chroma: u8,
/// The negotiated encode bit depth and chroma wire byte — session constants a mode switch
/// does NOT change, carried so the stream-shape cap can be recomputed for a new geometry
/// (review §2.1).
pub(super) bit_depth: u8,
pub(super) chroma_format: u8,
/// 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, so this module stays codec-agnostic.
/// to. Computed where the negotiated geometry lives; recomputed here on an accepted mode
/// switch (review §2.1).
pub(super) stream_cap_kbps: u32,
/// The negotiated refresh, which sets the frame budget the ABR sizes its host-encode
/// thresholds against (see [`crate::abr::BitrateController::set_frame_budget`]).
@@ -78,8 +83,8 @@ impl DataPump {
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
negotiated_bit_depth,
negotiated_chroma,
bit_depth,
chroma_format,
stream_cap_kbps,
refresh_hz,
mode_slot: pump_mode_slot,
@@ -556,33 +561,27 @@ impl DataPump {
if mg != seen_mode_gen {
seen_mode_gen = mg;
abr.on_mode_switch();
// The frame budget and the stream-shape cap are properties of the MODE: a
// switch that changes the refresh changes what one frame of encode time
// costs, and a switch that changes the geometry changes what the stream
// could plausibly use — without the rebind a 4K→720p switch kept a 4K-sized
// climb ceiling for the rest of the session (08-22 ABR review §2.1).
let (w, h, hz) = {
let m = pump_mode_slot.lock().unwrap();
(m.width, m.height, m.refresh_hz)
};
abr.set_frame_budget(hz);
abr.rebind_stream_cap(crate::abr::stream_ceiling_kbps(
w,
h,
hz,
let m = *pump_mode_slot.lock().unwrap();
// The frame budget is a property of the MODE: a switch that changes the
// refresh changes what one frame of encode time costs, and the encode
// thresholds are sized in those.
abr.set_frame_budget(m.refresh_hz);
// So is the stream-shape cap (review §2.1): computed once from the
// Welcome mode, 1080p→4K kept a 1080p-sized climb ceiling (under-running
// quality on a fat link) and 4K→720p left an oversized cap standing with
// only the reactive loss/decode signals to bound the climb.
// `set_stream_cap` also rebinds an already-learned ceiling downward.
abr.set_stream_cap(crate::abr::stream_ceiling_kbps(
m.width,
m.height,
m.refresh_hz,
negotiated_codec,
negotiated_bit_depth,
negotiated_chroma,
bit_depth,
chroma_format,
));
}
// Drain ALL acks in arrival order — host-cap learning counts consecutive short
// acks, so a full resolve plus its corrective retarget in one window must both
// land (§2.4; the old latest-wins slot collapsed them to one).
{
let drained: Vec<u32> = bitrate_ack.lock().unwrap().drain(..).collect();
for acked in drained {
abr.on_ack(acked);
}
for acked in bitrate_ack.lock().unwrap().drain(..) {
abr.on_ack(acked);
}
let owd_mean_us =
(owd_frames > 0).then(|| (owd_sum_ns / owd_frames as i128 / 1000) as i64);
@@ -1096,8 +1095,8 @@ mod tests {
bitrate_kbps: 20_000,
resolved_bitrate_kbps: 20_000,
negotiated_codec: crate::quic::CODEC_HEVC,
negotiated_bit_depth: 8,
negotiated_chroma: 0,
bit_depth: 8,
chroma_format: 0,
stream_cap_kbps: 100_000,
refresh_hz: 60,
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
+19 -44
View File
@@ -831,10 +831,6 @@ fn send_loop(
// costs on HEVC (sub-frame readback, and with it the send/encode overlap) — a number the
// encoder cannot observe. Written here because this is the only thread that sees a send.
send_spread_us: Arc<AtomicU32>,
// Wire-MTU re-keys applied here, published for the encode loop's metronomic-recovery
// attribution (a re-keyed path was black-holing full-size video — name it before the
// display suspects).
wire_rekeys: Arc<AtomicU32>,
// Streamed AUs go out as slice-granularity blocks ([`USER_FLAG_SLICE_STREAM`]'s contract)
// instead of the legacy full-FEC-block shape.
slice_wire: bool,
@@ -910,10 +906,7 @@ fn send_loop(
}
if let Some(s) = want_shard {
match session.set_shard_payload(s) {
Ok(()) => {
wire_rekeys.fetch_add(1, Ordering::Relaxed);
tracing::info!(shard_payload = s, "wire shard payload re-keyed");
}
Ok(()) => tracing::info!(shard_payload = s, "wire shard payload re-keyed"),
// Can't fire for a watcher-driven value (it validates the same bounds) —
// belt-and-suspenders for a future driver.
Err(e) => tracing::warn!(shard_payload = s, error = ?e,
@@ -2151,13 +2144,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// loop is the only place the encoder can be touched.
let send_spread_us = Arc::new(AtomicU32::new(0));
let send_spread_send = Arc::clone(&send_spread_us);
// Wire-MTU re-keys applied this session, published by the send thread (it owns the
// packetizer) for the encode loop's metronomic-recovery attribution: a path that needed a
// re-key was black-holing full-size video, and a client re-asking through that is periodic
// by construction — the 2026-08-26 lab sessions drew the "display disturbance" warn on
// exactly such a path, at a period (1.7 s) the client cooldown bands narrowly miss.
let wire_rekeys = Arc::new(AtomicU32::new(0));
let wire_rekeys_send = Arc::clone(&wire_rekeys);
let send_stats = SendStats {
rec: stats.clone(),
mode: live_mode.clone(),
@@ -2180,7 +2166,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
stop,
perf,
send_spread_send,
wire_rekeys_send,
slice_wire,
burst_cap,
fec_target,
@@ -2813,11 +2798,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer
// rate.
let hz = interval_hz(interval);
// Time the rebuild: its few hundred ms of nothing straddles a client report
// window and reads as a collapsed link (§announce_pipeline_gap — the mode-switch
// and eviction rebuilds already announce; this one was the 08-22 ABR review's
// §2.2 hole).
let t_rebuild = std::time::Instant::now();
// Timed for the `PipelineGap` below: the rebuild stalls capture for ~0.6 s,
// and a client that isn't told discards its starved windows as congestion
// (the 401 ms field case: slow start killed, minutes at ~15 Mbps on a clean
// link — review §2.2). The mode-switch and topology rebuilds already announce.
let rebuild_t0 = std::time::Instant::now();
match crate::encode::open_video(
plan.codec,
frame.format,
@@ -2873,16 +2858,23 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
behind_score = 0;
depth_frames = 0;
ahead_run = 0;
// The client must not score the rebuild's dead air as congestion.
announce_pipeline_gap(&gap_tx, t_rebuild.elapsed().as_millis() as u32);
// …and it must not feed the CLIENT's controller either: announce the
// host-local gap so the starved window is discarded, exactly as a
// mode-switch rebuild does (review §2.2 — this arm was the one rebuild
// that never told the client).
announce_pipeline_gap(
&gap_tx,
rebuild_t0.elapsed().as_millis().min(u32::MAX as u128) as u32,
);
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
"bitrate-change encoder rebuild failed — keeping the current rate");
// The control task already acked `new_kbps`; the encoder still runs the
// old rate. Without this correction the client's controller, HUD and
// climb base all track a rate that never existed (08-22 ABR review
// §2.3 — the phantom-rate hole).
// The control task acked the resolved rate BEFORE this apply — with
// the rebuild failed, the client's controller now tracks a rate the
// encoder never ran: its climb base, utilization and proven math all
// drift from a phantom number (review §2.3). Snap it back, same
// channel as the short-apply correction above.
let _ = retarget_tx.send(bitrate_kbps);
}
}
@@ -3119,23 +3111,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
a host display disturbance"
);
}
} else if wire_rekeys.load(Ordering::Relaxed) > 0 {
// A deterministic NETWORK pathology, not a display one: this session's
// path dropped full-size video until the wire-MTU watcher re-keyed the
// shards, and a client re-asking through a black-holing hop is periodic
// by construction (its retry cadence, skewed by the path — 2026-08-26
// lab data landed at 1.7 s, just outside the cooldown bands). Name the
// path before anyone chases display hardware.
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
wire_rekeys = wire_rekeys.load(Ordering::Relaxed),
"client keyframe recoveries are METRONOMIC on a session whose wire \
MTU had to be re-keyed mid-stream a constrained path (VPN/overlay \
adapter, lowered NIC MTU) black-holing full-size video is the prime \
suspect, NOT a host/display disturbance; see the 'wire MTU' lines \
above, and pin PUNKTFUNK_WIRE_MTU to skip the lossy discovery window \
on this path"
);
} else {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
+1 -2
View File
@@ -248,9 +248,8 @@ notes for context.
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. H.264 never splits (not applicable per the SDK); on HEVC a *forced* split disables sub-frame readback (mutually unsupported) — set `0` to choose sub-frame instead. |
| `PUNKTFUNK_NVENC_SUBFRAME` | `0` · `1` | NVENC sub-frame (slice-level) readback for lower latency on sync sessions. Default: on where the GPU supports it (Linux direct NVENC). `0` = never; `1` = force. On HEVC it yields to a forced split-encode (the SDK documents the pair unsupported). |
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE` | `1` | Opt-in: let the host change its split-encode decision **live**, mid-session, as the pixel rate moves, instead of only choosing once at session start. Currently wired on the Linux direct-NVENC path. Only interesting alongside `PUNKTFUNK_SPLIT_ENCODE=auto` at very high pixel rates. |
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default **`high`**. `auto` (opt-in) starts `high` and upgrades to `realtime` when it looks safe (HAGS off, or VRAM headroom) — opt-in because on AMD a punktfunk process holding `realtime` produced the very metronomic stalls the log's `METRONOMIC` warning describes; `realtime` pins the strongest lever unconditionally (same AMD hazard, and can freeze NVENC on some HAGS setups). Every capture session logs the resolved posture as `GPU-priority posture for this capture session`. |
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
| `PUNKTFUNK_IDD_ADAPTIVE` | `1` *(default)* · `0` | **(Windows)** The adaptive pipeline-depth machinery: the host walks the depth up under sustained encode overrun and back down when clean. `0` pins the full configured depth **and disables the whole encode-cadence detector with it** — including the "encode behind cadence" ABR climb refusal — so leave it on unless you are deliberately A/B-ing that machinery. |
| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only — the *intent*, forwarded to whichever process does the encode. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. Granting the request needs the `CAP_SYS_NICE` capability, which the Linux packages give to `punktfunk-encode-worker` and **never** to `punktfunk-host` — a host holding any capability cannot be identified by KWin and loses desktop streaming entirely. Do not `setcap` the host to "make this work"; see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority). Set `off` if you see the desktop stutter while streaming. |
| `PUNKTFUNK_ENCODE_WORKER` | path · `off` | Where the host looks for `punktfunk-encode-worker`, the small capability-carrying helper that owns the priority-elevated [PyroWave](/docs/pyrowave) encode (previous row). Unset, the host looks beside its own binary and then on `PATH`, which is right for every package — set it only when the worker lives somewhere unusual. **NixOS needs it and the module sets it for you:** a file capability cannot live on a read-only nix store path, so the worker is exposed through `security.wrappers` and this points the host at that wrapper. `off` forces the encode back into the host process at default priority — a debug escape hatch, not a tuning knob. Every failure short of that is already handled: a missing binary, a worker that will not start, or one that dies mid-session falls back to encoding in-process with one line in the log, and never drops the session. |
| `PUNKTFUNK_SCRIPTING` | path | Where the host looks for `punktfunk-scripting`, the runner that performs every [plugin](/docs/plugins) package op (`plugins add`/`remove`/`list`, and the console's store installs). Unset, the host looks beside its own binary, then on `PATH`, then in the packaged `/usr` and `~/.local` layouts — right for every package, so set it only when the runner lives somewhere unusual. Like the row above it is **not** existence-checked: a path you name is a path you get, so a typo fails naming itself instead of quietly running a different runner. Worth knowing: the console runs installs inside the host *service*, whose `PATH` is normally much shorter than your login shell's — if `punktfunk-host plugins add` works and the console says the runner isn't installed, that gap is why, and this is the fix. |
+5 -14
View File
@@ -580,20 +580,11 @@ Open the web console's **Logs** page and search for `METRONOMIC`. You'll get one
dummy plug, or simply keep the display active while you stream. The console's **Virtual displays**
page also has a *Disable monitor devices while streaming (PnP)* toggle that suppresses the
Windows-side reaction; the log line's `connected_inactive` field names the displays it suspects.
- **…with NO coinciding OS display event** — the disturbance is below Windows. **Check the
line's `rt_gpu_driver` / `rt_gpu_host` fields first**: if either shows a REALTIME opt-in,
clear it (unset `PFVD_RT_GPU` / set `PUNKTFUNK_GPU_PRIORITY_CLASS=high`) — a punktfunk process
holding REALTIME GPU priority is the field-proven amplifier of exactly this pattern on AMD.
Otherwise: a connected but sleeping screen being serviced by the GPU driver, display-poller
software (the SteelSeries GG / SignalRGB class), or the desktop present clock — try a different
refresh rate. On a laptop panel that the host deactivated, keeping it active with the
**primary** topology usually settles it — see
[Virtual displays → Topology](/docs/virtual-displays#topology).
Freezes that repeat *without* a steady rhythm are caught too: search the log for
`REPEATING without a stable period` — that warning carries the same fields and the same cure
list. Every session also stamps one `GPU-priority posture for this capture session` line near
its start, so a log shows the levers even before any stall fires.
- **…with NO coinciding OS display event** — the disturbance is below Windows: a connected but
sleeping screen being serviced by the GPU driver, display-poller software (the SteelSeries GG /
SignalRGB class), or the desktop present clock — try a different refresh rate. On a laptop panel
that the host deactivated, keeping it active with the **primary** topology usually settles it —
see [Virtual displays → Topology](/docs/virtual-displays#topology).
## Stutter, drops, or high latency