Compare commits

...
Author SHA1 Message Date
enricobuehler 72959ef07d fix(core): bitrate acks queue in arrival order instead of a latest-wins slot
08-22 ABR review §2.4: a full resolve ack plus a corrective short retarget in
the same 750 ms report window collapsed to whichever arrived last — host-cap
learning needs two consecutive short acks, so a lost correction delayed or
prevented the cap and could reintroduce the overdrive sawtooth. Bounded queue
(8), drained fully per window.
2026-08-26 21:20:45 +02:00
enricobuehler fa0f66e151 fix(core): a mode switch re-sizes the ABR stream cap, and clamps the learned ceiling with it
08-22 ABR review §2.1: the stream-shape cap was computed once from the Welcome
mode and never again, so a 4K→720p switch kept authorizing 4K-sized climbs for
the whole session (only the reactive loss/decode signals reined them in). The
mode-gen site now recomputes the cap from the accepted mode (depth/chroma are
session-negotiated and ride along) and rebind_stream_cap clamps an already-
learned ceiling down to it. Up-switches lift only the cap — with no untrimmed
measurement stored, a higher ceiling would be evidence-free (§3.3 re-probe owns
that half). Pinned by a_mode_switch_rebind_clamps_the_learned_ceiling_but_never_raises_it.
2026-08-26 21:15:45 +02:00
enricobuehler 3fe1af991a fixup: publish wire_rekeys from the send thread (it owns the packetizer) 2026-08-26 21:08:04 +02:00
enricobuehler 57444e7be7 fix(host): a wire-MTU-re-keyed session stops blaming its metronomic recoveries on the display
The 2026-08-26 lab sessions over an overlay hop (udp_mtu 1336) produced the
'host/display disturbance' warn at period 1.7 s — just outside both client
cooldown bands — while the real cause was the path black-holing full-size video
until the re-key. Period alone cannot make this call; the session's transport
context can. New first-priority arm: wire_rekeys > 0 names the constrained path
and points at PUNKTFUNK_WIRE_MTU.
2026-08-26 21:03:36 +02:00
enricobuehler 933074fafc feat(win-display): the adl-emul Probe walks headless AMD adapters — the lab rung needs it 2026-08-26 18:12:05 +02:00
enricobuehler 54c3414f36 fix(win-display): the adl-emul probe says which adapters it skipped, and why
First .173 run: 15 logical adapters enumerated, zero connectors walked, exit 0,
no explanation — a probe whose deliverable is an rc must never end silent. One
record per distinct (bus, vendor, present) shape before the filter.
2026-08-26 18:11:07 +02:00
enricobuehler 637d438532 test(amf): live readback proof for applied_bitrate_bps on real VCN hardware 2026-08-26 18:00:27 +02:00
enricobuehler b43363b141 feat(capture,encode): AMD program wave 2 — the field log self-describes, and AMF reports its applied bitrate
- Every capture session stamps a 'GPU-priority posture' INFO line (both REALTIME
  opt-ins) at open, so a stalling log carries the levers even when no WARN fires.
- Repeated stalls WITHOUT a stable period now WARN with the full triage payload
  (tallies, suspects, levers) — the 2026-08-26 7700 XT log had 6 holes in 8 s and
  zero guidance because only the metronomic arms spoke.
- CONTENT-SILENCE prose stops overselling benignity: a frozen presenter
  (disturbance-immunity Flavor 3) reads identically, and our probes all run at the
  host's elevated GPU priority.
- Native AMF implements applied_bitrate_bps via a GetProperty readback (typed the
  existing vtable slot), so encoder_ceiling learning / the ABR overdrive guard stop
  being inert on AMD; optional-property rejections log at INFO and the encode-active
  line carries ltr/intra_refresh — the VCN capability matrix builds itself from
  field logs.
- Doc drift: PUNKTFUNK_GPU_PRIORITY_CLASS default is high (not auto) everywhere it
  is described; PUNKTFUNK_IDD_ADAPTIVE documented; troubleshooting names the
  REALTIME-lever first step and the new repeating-stall warning.

Design: punktfunk-planning design/windows-amd-host-program.md §3.1–§3.3 wave 2.
2026-08-26 17:32:26 +02:00
14 changed files with 500 additions and 74 deletions
+31
View File
@@ -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.
+146 -46
View File
@@ -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,
+102 -1
View File
@@ -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]
+2 -1
View File
@@ -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,
+3 -2
View File
@@ -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.
+33 -5
View File
@@ -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);
+51
View File
@@ -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.
+12 -1
View File
@@ -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
+37 -9
View File
@@ -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 {
+45 -1
View File
@@ -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()),
+2 -1
View File
@@ -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. |
+14 -5
View File
@@ -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