Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72959ef07d | ||
|
|
fa0f66e151 | ||
|
|
3fe1af991a | ||
|
|
57444e7be7 | ||
|
|
933074fafc | ||
|
|
54c3414f36 | ||
|
|
637d438532 | ||
|
|
b43363b141 |
@@ -2305,6 +2305,37 @@ 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,6 +672,15 @@ 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.
|
||||
|
||||
@@ -129,9 +129,13 @@ 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 game hitch, a loading
|
||||
/// screen, a menu). Benign for the display path; the content side is where to look if the
|
||||
/// user FELT it.
|
||||
/// 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.
|
||||
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
|
||||
@@ -155,7 +159,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; not the display path)"
|
||||
"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)"
|
||||
}
|
||||
Self::FrameGeneration => {
|
||||
"FRAME-GENERATION (presents FLOWED while the virtual display's kernel queue starved — the OS display path dropped composed frames)"
|
||||
@@ -165,6 +169,41 @@ 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
|
||||
@@ -308,6 +347,13 @@ 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 {
|
||||
@@ -327,6 +373,14 @@ 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 {
|
||||
@@ -338,9 +392,59 @@ 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.
|
||||
@@ -488,6 +592,37 @@ 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() {
|
||||
@@ -497,21 +632,8 @@ 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 = 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]
|
||||
);
|
||||
let verdict_tally = self.verdict_tally();
|
||||
let class_tally = self.class_tally();
|
||||
// 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.
|
||||
@@ -533,33 +655,11 @@ impl StallWatch {
|
||||
suspects)"
|
||||
);
|
||||
} else {
|
||||
// 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)",
|
||||
};
|
||||
// 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();
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
|
||||
@@ -689,7 +689,10 @@ unsafe fn set_prop(
|
||||
result_name(r)
|
||||
))
|
||||
} else {
|
||||
tracing::debug!(
|
||||
// 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!(
|
||||
property = %name,
|
||||
result = result_name(r),
|
||||
amf_code = r,
|
||||
@@ -699,6 +702,18 @@ 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
|
||||
@@ -1266,6 +1281,10 @@ 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,
|
||||
@@ -2198,6 +2217,22 @@ 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);
|
||||
@@ -2652,6 +2687,72 @@ 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]
|
||||
|
||||
@@ -305,7 +305,8 @@ pub struct AmfComponentVtbl {
|
||||
// AMFPropertyStorage
|
||||
pub set_property:
|
||||
unsafe extern "system" fn(*mut AmfComponent, *const u16, AmfVariant) -> AmfResult,
|
||||
pub get_property: Slot,
|
||||
pub get_property:
|
||||
unsafe extern "system" fn(*mut AmfComponent, *const u16, *mut AmfVariant) -> AmfResult,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
|
||||
@@ -90,8 +90,9 @@ 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 `auto`) 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 `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.
|
||||
|
||||
pub struct PyroWaveEncoder {
|
||||
// pyrowave owns the whole Vulkan device (create_device_by_compat) — no ash on this side.
|
||||
|
||||
@@ -406,13 +406,41 @@ pub fn run(action: EmulAction, connector_filter: Option<i32>) -> RunOutcome {
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
|
||||
// One GPU surfaces as many logical adapters — probe each bus once, AMD-present only.
|
||||
// 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.
|
||||
let mut seen_buses: Vec<i32> = Vec::new();
|
||||
for info in &infos {
|
||||
if info.iPresent == 0
|
||||
|| info.iVendorID != AMD_VENDOR_ID
|
||||
|| seen_buses.contains(&info.iBusNumber)
|
||||
{
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
seen_buses.push(info.iBusNumber);
|
||||
|
||||
@@ -558,6 +558,29 @@ impl BitrateController {
|
||||
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 {
|
||||
tracing::info!(
|
||||
ceiling_kbps = self.ceiling_kbps,
|
||||
bound_kbps = bound,
|
||||
"adaptive bitrate: mode switch shrank the stream shape — climb ceiling clamped"
|
||||
);
|
||||
self.ceiling_kbps = bound;
|
||||
}
|
||||
}
|
||||
|
||||
/// Teach the controller this session's refresh rate, so the encode thresholds can be sized in
|
||||
/// FRAME BUDGETS rather than the 120 Hz durations they were calibrated at (see
|
||||
/// [`ENCODE_RISE_US`]). Ignored for a nonsense rate — the defaults are the old behavior, which
|
||||
@@ -1469,6 +1492,34 @@ 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.
|
||||
|
||||
@@ -86,6 +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;
|
||||
// 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.
|
||||
@@ -166,7 +170,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).
|
||||
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
|
||||
// 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).
|
||||
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
|
||||
// outbound `CtrlRequest::Keyframe` — the one choke point all emitters funnel through — and
|
||||
// the pump drains the count per report window.
|
||||
@@ -279,6 +288,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
negotiated_bit_depth,
|
||||
negotiated_chroma,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: mode_slot_pump,
|
||||
|
||||
@@ -15,7 +15,7 @@ pub(super) struct ControlTask {
|
||||
pub(super) mode_slot: Arc<Mutex<Mode>>,
|
||||
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>>,
|
||||
/// The live encoder-target mirror ([`NativeClient::current_bitrate_kbps`]): unlike the
|
||||
/// drain-once ack slot above, this one always holds the latest acked rate for stats HUDs.
|
||||
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||
@@ -200,7 +200,18 @@ impl ControlTask {
|
||||
if ack.bitrate_kbps > 0 {
|
||||
live_bitrate.store(ack.bitrate_kbps, Ordering::Relaxed);
|
||||
}
|
||||
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
} 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
|
||||
|
||||
@@ -27,7 +27,7 @@ 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>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
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.
|
||||
pub(super) recovery_kf: Arc<AtomicU32>,
|
||||
@@ -40,6 +40,10 @@ 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,
|
||||
/// 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.
|
||||
@@ -74,6 +78,8 @@ impl DataPump {
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
negotiated_bit_depth,
|
||||
negotiated_chroma,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: pump_mode_slot,
|
||||
@@ -550,13 +556,33 @@ impl DataPump {
|
||||
if mg != seen_mode_gen {
|
||||
seen_mode_gen = mg;
|
||||
abr.on_mode_switch();
|
||||
// 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(pump_mode_slot.lock().unwrap().refresh_hz);
|
||||
// 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,
|
||||
negotiated_codec,
|
||||
negotiated_bit_depth,
|
||||
negotiated_chroma,
|
||||
));
|
||||
}
|
||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||
abr.on_ack(acked);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
let owd_mean_us =
|
||||
(owd_frames > 0).then(|| (owd_sum_ns / owd_frames as i128 / 1000) as i64);
|
||||
@@ -1028,7 +1054,7 @@ mod tests {
|
||||
refresh_hz: 60,
|
||||
})),
|
||||
probe: Arc::new(Mutex::new(ProbeState::default())),
|
||||
bitrate_ack: Arc::new(Mutex::new(None)),
|
||||
bitrate_ack: Arc::new(Mutex::new(std::collections::VecDeque::new())),
|
||||
live_bitrate: Arc::new(AtomicU32::new(0)),
|
||||
recovery_kf: Arc::new(AtomicU32::new(0)),
|
||||
pipeline_gap: pipeline_gap.clone(),
|
||||
@@ -1064,12 +1090,14 @@ mod tests {
|
||||
mode_gen: Arc::new(AtomicU32::new(0)),
|
||||
frames_dropped: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
fec_recovered: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
bitrate_ack: Arc::new(Mutex::new(None)),
|
||||
bitrate_ack: Arc::new(Mutex::new(std::collections::VecDeque::new())),
|
||||
recovery_kf: Arc::new(AtomicU32::new(0)),
|
||||
pipeline_gap: pipeline_gap.clone(),
|
||||
bitrate_kbps: 20_000,
|
||||
resolved_bitrate_kbps: 20_000,
|
||||
negotiated_codec: crate::quic::CODEC_HEVC,
|
||||
negotiated_bit_depth: 8,
|
||||
negotiated_chroma: 0,
|
||||
stream_cap_kbps: 100_000,
|
||||
refresh_hz: 60,
|
||||
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
|
||||
|
||||
@@ -831,6 +831,10 @@ 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,
|
||||
@@ -906,7 +910,10 @@ fn send_loop(
|
||||
}
|
||||
if let Some(s) = want_shard {
|
||||
match session.set_shard_payload(s) {
|
||||
Ok(()) => tracing::info!(shard_payload = s, "wire shard payload re-keyed"),
|
||||
Ok(()) => {
|
||||
wire_rekeys.fetch_add(1, Ordering::Relaxed);
|
||||
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,
|
||||
@@ -2144,6 +2151,13 @@ 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(),
|
||||
@@ -2166,6 +2180,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
stop,
|
||||
perf,
|
||||
send_spread_send,
|
||||
wire_rekeys_send,
|
||||
slice_wire,
|
||||
burst_cap,
|
||||
fec_target,
|
||||
@@ -2798,6 +2813,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();
|
||||
match crate::encode::open_video(
|
||||
plan.codec,
|
||||
frame.format,
|
||||
@@ -2853,10 +2873,17 @@ 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);
|
||||
}
|
||||
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).
|
||||
let _ = retarget_tx.send(bitrate_kbps);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3092,6 +3119,23 @@ 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()),
|
||||
|
||||
@@ -248,8 +248,9 @@ 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 `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_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_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. |
|
||||
|
||||
@@ -580,11 +580,20 @@ 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: 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).
|
||||
- **…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.
|
||||
|
||||
## Stutter, drops, or high latency
|
||||
|
||||
|
||||
Reference in New Issue
Block a user