Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0af3b558 | ||
|
|
535e95c4c0 | ||
|
|
28b6633058 | ||
|
|
c58217e403 | ||
|
|
23f9b1130e | ||
|
|
c2c71f0ac5 | ||
|
|
6a506a8fa9 | ||
|
|
f4e39a442b | ||
|
|
a0577cb86e | ||
|
|
2a62fe7857 |
@@ -184,6 +184,20 @@ but latches nothing; only the full-length attempts that follow hand down negotia
|
||||
classification is a pure function with tests
|
||||
(`pf_capture::linux::first_frame_timeout_tests`).
|
||||
|
||||
### Windows host — an idle box can sleep again (virtual-mic stream idle-stop)
|
||||
|
||||
🛑 **Installing the host blocked system sleep forever, client connected or not.** The
|
||||
host-lifetime mic pump kept a WASAPI render stream RUNNING on the virtual-mic device
|
||||
(typically the Steam Streaming Microphone), writing silence 24/7 — and any running stream makes
|
||||
the Windows audio stack hold a kernel power request ("An audio stream is currently in use" in
|
||||
`powercfg /requests`, attributed to that device) that vetoes sleep. The render loop now stops
|
||||
the stream (`IAudioClient::Stop`; the client stays initialized and the mic *endpoint* keeps
|
||||
existing for apps to bind) after 10 s of silence-only output and resumes on the next mic frame
|
||||
within one device period — below the jitter buffer's prime depth, so nothing is audible.
|
||||
Streaming sessions still hold the box awake through their own `PowerRequest` assertions, as
|
||||
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
|
||||
third-party virtual audio driver misbehaves while its render side is paused.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
Generated
+1
@@ -1116,6 +1116,7 @@ dependencies = [
|
||||
name = "display-disturb"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
|
||||
@@ -4753,6 +4753,10 @@
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||
},
|
||||
"edid_lock": {
|
||||
"type": "boolean",
|
||||
"description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
|
||||
},
|
||||
"game_session": {
|
||||
"$ref": "#/components/schemas/GameSession",
|
||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||
|
||||
@@ -43,10 +43,12 @@ struct OutputReady {
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||
Au(Frame, bool),
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
|
||||
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
|
||||
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
|
||||
/// request).
|
||||
Au(Frame, u32),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
@@ -603,7 +605,11 @@ fn feeder_loop(
|
||||
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
|
||||
// counting AUs.
|
||||
let au_first = frame.part.is_none_or(|p| p.first);
|
||||
let gap = au_first && client.note_frame_index(frame.frame_index);
|
||||
let gap = if au_first {
|
||||
client.note_frame_index(frame.frame_index)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
@@ -691,9 +697,12 @@ fn dispatch_event(
|
||||
match ev {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
|
||||
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
|
||||
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
|
||||
// race — see `ReanchorGate::arm_expecting_drops`).
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
}
|
||||
// One entry per AU (parts share the pts): the completing delivery carries it.
|
||||
if f.complete {
|
||||
|
||||
@@ -222,8 +222,13 @@ pub(super) fn run_sync(
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||
if client.note_frame_index(frame.frame_index) {
|
||||
gate.arm(Instant::now());
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
}
|
||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||
|
||||
@@ -774,12 +774,22 @@ public final class PunktfunkConnection {
|
||||
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
|
||||
/// after close.
|
||||
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
|
||||
noteFrameIndexGapWidth(frameIndex) > 0
|
||||
}
|
||||
|
||||
/// Like `noteFrameIndexGap`, but reports the gap's WIDTH — how many frames this arrival revealed
|
||||
/// as missing (0 = none). The post-loss re-anchor gate arms with the width
|
||||
/// (`ReanchorGate.arm(expectingDrops:)`) so the reassembler's later `framesDropped` climb for
|
||||
/// the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm race).
|
||||
/// Same core side effect as `noteFrameIndex` (the throttled RFI request); call it for every
|
||||
/// received AU. Returns 0 after close.
|
||||
public func noteFrameIndexGapWidth(_ frameIndex: UInt32) -> UInt32 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return false }
|
||||
var gap = false
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
|
||||
return gap
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var width: UInt32 = 0
|
||||
_ = punktfunk_connection_note_frame_index_ex(h, frameIndex, &width)
|
||||
return width
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
|
||||
@@ -55,6 +55,16 @@ final class ReanchorGate: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// `arm()` for a loss detected as a frame-index gap of a known width
|
||||
/// (`PunktfunkConnection.noteFrameIndexGapWidth`). Pre-credits the reassembler's later
|
||||
/// `framesDropped` climb for the same lost frames, so `poll` doesn't re-freeze a stream an
|
||||
/// RFI anchor already healed (the double-arm race — the Rust gate's docs tell the story).
|
||||
func arm(expectingDrops: UInt64) {
|
||||
lock.lock()
|
||||
punktfunk_reanchor_gate_arm_expecting_drops(ptr, expectingDrops)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
||||
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
||||
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
||||
|
||||
@@ -923,7 +923,11 @@ public final class Stage2Pipeline {
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze —
|
||||
// the following concealed frames are withheld until a clean re-anchor.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race).
|
||||
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
|
||||
if gapWidth > 0 { reanchorGate.arm(expectingDrops: UInt64(gapWidth)) }
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
|
||||
@@ -100,7 +100,11 @@ final class StreamPump {
|
||||
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race).
|
||||
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
|
||||
if gapWidth > 0 { gate.arm(expectingDrops: UInt64(gapWidth)) }
|
||||
onFrame?(au)
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
|
||||
@@ -533,14 +533,47 @@ 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)",
|
||||
};
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
rt_gpu_driver,
|
||||
rt_gpu_host,
|
||||
verdicts = %verdict_tally,
|
||||
classes = %class_tally,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
the disturbance is BELOW Windows. FIRST: if rt_gpu_driver or \
|
||||
rt_gpu_host 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 \
|
||||
signature on AMD. Otherwise: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
|
||||
@@ -885,7 +885,12 @@ fn pump(
|
||||
Some(exp) => {
|
||||
if let Some(gap) = index_gap(exp, frame.frame_index) {
|
||||
let now = Instant::now();
|
||||
gate.arm(now);
|
||||
// Credited arm: the reassembler books these same lost frames into
|
||||
// `frames_dropped` up to ~120 ms from now; the credit keeps that
|
||||
// delayed climb from re-freezing a stream the RFI anchor healed in
|
||||
// between (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
gate.arm_expecting_drops(now, u64::from(gap));
|
||||
next_expected_index = Some(frame.frame_index.wrapping_add(1));
|
||||
// The gap carries the PRECISE lost range — [first missing, newest
|
||||
// received - 1] — so this is the one recovery signal that can drive true
|
||||
|
||||
+42
-24
@@ -155,18 +155,26 @@ enum PrioMode {
|
||||
Off,
|
||||
/// A fixed class the operator pinned (`normal`=2 / `high`=4 / `realtime`=5).
|
||||
Static(i32),
|
||||
/// The default: HIGH immediately, then upgrade to REALTIME when it is safe — HAGS off, or
|
||||
/// Opt-in (`auto`): HIGH immediately, then upgrade to REALTIME when it is safe — HAGS off, or
|
||||
/// HAGS on with comfortable VRAM headroom (with a monitor that downgrades the moment VRAM
|
||||
/// tightens). REALTIME is the proven ceiling-raiser (it is how our brief encode preempts a
|
||||
/// saturating game), but REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC
|
||||
/// hang — the gate takes the win everywhere it cannot hit the hazard.
|
||||
/// tightens). REALTIME is the T2.3 ceiling-raiser (a higher-priority context preempts at
|
||||
/// pixel granularity), but it carries TWO field-proven hazards: REALTIME + NVIDIA + HAGS +
|
||||
/// near-full VRAM is a documented NVENC hang (the VRAM gate covers that one), and on AMD the
|
||||
/// upgrade itself produced a metronomic content-starving stall class (~3.6 s period, RX 9070
|
||||
/// XT, 2026-08-12 A/B: pinning `high` removed it) that no VRAM gate can see — which is why
|
||||
/// `auto` is no longer the default.
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Resolve `PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default **auto**).
|
||||
/// Resolve `PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default **high**).
|
||||
/// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4,
|
||||
/// REALTIME 5. `realtime` pins REALTIME statically (no gate — the operator owns the hazard);
|
||||
/// `high` restores the pre-T2.3 static default.
|
||||
/// `auto` is the T2.3 gated-REALTIME mode, opt-in since the 2026-08-12 field A/B convicted the
|
||||
/// REALTIME upgrade of its own metronomic stall class on AMD (see [`PrioMode::Auto`]) — HIGH is
|
||||
/// the Sunshine/Apollo-parity lever that delivered the original decisive win, and the default
|
||||
/// must not hold REALTIME anywhere (the same inversion as the vdisplay driver's `PFVD_RT_GPU`
|
||||
/// ladder, which fixed the faster ~1.8 s metronome the same day). Unrecognized values read as
|
||||
/// the default, not as `auto` — a typo must not opt a box into the hazard.
|
||||
fn configured_gpu_priority_mode() -> PrioMode {
|
||||
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
||||
.ok()
|
||||
@@ -174,9 +182,10 @@ fn configured_gpu_priority_mode() -> PrioMode {
|
||||
{
|
||||
Some("off") => PrioMode::Off,
|
||||
Some("normal") => PrioMode::Static(2),
|
||||
Some("high") => PrioMode::Static(4),
|
||||
Some("realtime") => PrioMode::Static(5),
|
||||
_ => PrioMode::Auto,
|
||||
Some("auto") => PrioMode::Auto,
|
||||
// `high`, unset, and anything unrecognized all land on the HIGH default.
|
||||
_ => PrioMode::Static(4),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,14 +284,17 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
|
||||
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
|
||||
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
|
||||
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
|
||||
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is the
|
||||
/// T2.3 `auto` mode: HIGH immediately here, then [`auto_priority_gate`] upgrades to REALTIME
|
||||
/// where the NVIDIA+HAGS+full-VRAM NVENC-hang hazard cannot bite (and a monitor downgrades when
|
||||
/// it could). Runs once per process; best-effort.
|
||||
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default auto; `high` = the
|
||||
/// pre-gate static behavior; `realtime` = pinned, operator owns the hazard). Best-effort:
|
||||
/// silently no-ops under a UAC-filtered token (the process will not hold SE_INC_BASE_PRIORITY,
|
||||
/// so the D3DKMT call is a no-op).
|
||||
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is a
|
||||
/// static HIGH — the class that delivered that win. The T2.3 `auto` mode (HIGH here, then
|
||||
/// [`auto_priority_gate`] upgrades to REALTIME behind the NVENC-hang VRAM gate) is opt-in since
|
||||
/// the 2026-08-12 field A/B: on AMD the REALTIME upgrade generated its own metronomic
|
||||
/// content-starving stall class (~3.6 s period) that the VRAM gate cannot see, and pinning HIGH
|
||||
/// removed it. Runs once per process; best-effort.
|
||||
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default high; `auto` = the
|
||||
/// gated-REALTIME upgrade, operator opts into the AMD stall hazard for the extra ceiling;
|
||||
/// `realtime` = pinned, operator owns every hazard). Best-effort: silently no-ops under a
|
||||
/// UAC-filtered token (the process will not hold SE_INC_BASE_PRIORITY, so the D3DKMT call is a
|
||||
/// no-op).
|
||||
fn elevate_process_gpu_priority() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
@@ -316,17 +328,23 @@ fn elevate_process_gpu_priority() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) --------------------------------
|
||||
// --- REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) — OPT-IN since 2026-08-12 ------
|
||||
//
|
||||
// REALTIME GPU scheduling priority is the genuine cross-process ceiling-raiser under a saturating
|
||||
// game (a higher-priority context preempts at pixel granularity — the Async-TimeWarp mechanism),
|
||||
// and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. The one documented
|
||||
// hazard: REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC. So: probe HAGS once via
|
||||
// D3DKMT; HAGS off ⇒ REALTIME unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM
|
||||
// headroom, with a monitor thread that downgrades to HIGH the moment usage crosses
|
||||
// [`VRAM_DOWNGRADE_PCT`] of the OS budget and restores REALTIME after it has stayed under
|
||||
// [`VRAM_RESTORE_PCT`] for [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping
|
||||
// on the boundary of the hazard window).
|
||||
// and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. Two field-proven
|
||||
// hazards bound it. (1) REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC — the VRAM
|
||||
// gate below exists for that one: probe HAGS once via D3DKMT; HAGS off ⇒ REALTIME
|
||||
// unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM headroom, with a monitor
|
||||
// thread that downgrades to HIGH the moment usage crosses [`VRAM_DOWNGRADE_PCT`] of the OS
|
||||
// budget and restores REALTIME after it has stayed under [`VRAM_RESTORE_PCT`] for
|
||||
// [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping on the boundary of the
|
||||
// hazard window). (2) On AMD (RX 9070 XT A/B), a punktfunk process holding REALTIME generated a
|
||||
// metronomic content-starving stall class — every ~3.6 s ALL processes' presents paused
|
||||
// 150–800 ms with the GPU responsive — that no VRAM gate can see, and the vdisplay driver's
|
||||
// REALTIME swap-chain raise produced the same pathology on its own ~1.8 s beat. That second
|
||||
// hazard is why the whole gate now runs only under an explicit `auto`, and the default stays a
|
||||
// static HIGH.
|
||||
|
||||
/// Downgrade REALTIME→HIGH when local VRAM usage exceeds this share of the OS budget.
|
||||
const VRAM_DOWNGRADE_PCT: u64 = 92;
|
||||
|
||||
@@ -267,6 +267,16 @@ pub struct DisplayPolicy {
|
||||
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
|
||||
#[serde(default)]
|
||||
pub pnp_disable_monitors: bool,
|
||||
/// **EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the
|
||||
/// software equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at
|
||||
/// the first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its
|
||||
/// live-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks
|
||||
/// on its next start. Targets the standby-sink stall class at its SOURCE: with emulation
|
||||
/// pinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD
|
||||
/// driver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like
|
||||
/// `game_session`); `#[serde(default)]` = off.
|
||||
#[serde(default)]
|
||||
pub edid_lock: bool,
|
||||
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
|
||||
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
|
||||
///
|
||||
@@ -318,6 +328,7 @@ impl Default for DisplayPolicy {
|
||||
game_session: GameSession::default(),
|
||||
ddc_power_off: false,
|
||||
pnp_disable_monitors: false,
|
||||
edid_lock: false,
|
||||
capture_monitor: None,
|
||||
}
|
||||
}
|
||||
@@ -454,6 +465,7 @@ impl EffectivePolicy {
|
||||
game_session: GameSession,
|
||||
ddc_power_off: bool,
|
||||
pnp_disable_monitors: bool,
|
||||
edid_lock: bool,
|
||||
capture_monitor: Option<String>,
|
||||
) -> DisplayPolicy {
|
||||
DisplayPolicy {
|
||||
@@ -474,6 +486,7 @@ impl EffectivePolicy {
|
||||
game_session,
|
||||
ddc_power_off,
|
||||
pnp_disable_monitors,
|
||||
edid_lock,
|
||||
capture_monitor,
|
||||
}
|
||||
}
|
||||
@@ -739,6 +752,13 @@ impl DisplayPolicyStore {
|
||||
self.get().pnp_disable_monitors
|
||||
}
|
||||
|
||||
/// The experimental AMD connector-EDID-emulation axis — orthogonal to the preset (like
|
||||
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||
/// unconfigured).
|
||||
pub fn edid_lock(&self) -> bool {
|
||||
self.get().edid_lock
|
||||
}
|
||||
|
||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||
/// write succeeds, so a full disk can't leave memory and file disagreeing — and the whole
|
||||
/// transaction runs under [`Self::write`], so neither can two concurrent PUTs.
|
||||
@@ -1318,13 +1338,16 @@ mod tests {
|
||||
GameSession::Dedicated,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Some("DP-2".into()),
|
||||
);
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
|
||||
// preserved through the transform — arranging displays must not clear an unrelated setting.
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable, EDID lock,
|
||||
// capture-monitor pin) are preserved through the transform — arranging displays must not
|
||||
// clear an unrelated setting.
|
||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||
assert!(p.ddc_power_off);
|
||||
assert!(p.pnp_disable_monitors);
|
||||
assert!(p.edid_lock);
|
||||
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
|
||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||
assert_eq!(p.preset, Preset::Custom);
|
||||
@@ -1405,7 +1428,7 @@ mod tests {
|
||||
let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
12,
|
||||
13,
|
||||
"a display-policy axis was added or removed: {keys:?} — wire it into the mgmt PUT's \
|
||||
per-axis merge (and into `EffectivePolicy` if it is a behavior axis) before bumping this"
|
||||
);
|
||||
|
||||
@@ -178,6 +178,10 @@ struct GroupState {
|
||||
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at
|
||||
/// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore.
|
||||
pnp_disabled: Vec<String>,
|
||||
/// Whether the EXPERIMENTAL `edid_lock` axis pinned AMD connector emulation at the group's
|
||||
/// first isolate (`pf_win_display::adl_emul::lock_for_stream`) — last-member teardown owes the
|
||||
/// unlock (pinned emulation outlives the process, so a crash journal backs this flag up).
|
||||
edid_locked: bool,
|
||||
/// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also
|
||||
/// snapshots but deliberately keeps the physical displays active) — gates the re-assert
|
||||
/// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore.
|
||||
@@ -1400,6 +1404,18 @@ impl VirtualDisplayManager {
|
||||
if crate::policy::prefs().ddc_power_off() {
|
||||
inner.group.ddc_panels_off = crate::ddc::panel_off_except(n);
|
||||
}
|
||||
// EXPERIMENTAL `edid_lock` policy axis (AMD only): pin connector EDID
|
||||
// emulation BEFORE the isolate deactivates the physicals — an awake
|
||||
// sink still answers the live-EDID read the lock pins (asleep sinks
|
||||
// fall back to the driver's stored emulation data). With emulation at
|
||||
// ADL_EMUL_MODE_ALWAYS the KMD stops servicing the sleeping sink's
|
||||
// HPD/DDC/link — the standby-sink stall class at its source
|
||||
// (rationale + crash journal in `pf_win_display::adl_emul`). First
|
||||
// member only, like the DDC leg: the connectors are host-wide.
|
||||
if crate::policy::prefs().edid_lock() {
|
||||
inner.group.edid_locked =
|
||||
pf_win_display::adl_emul::lock_for_stream();
|
||||
}
|
||||
inner.group.ccd_saved = isolate_displays_ccd_seam(&keep);
|
||||
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
|
||||
// additionally disable the deactivated monitors' PnP devnodes (persistent
|
||||
@@ -1958,6 +1974,15 @@ impl VirtualDisplayManager {
|
||||
);
|
||||
inner.group.ddc_panels_off = 0;
|
||||
}
|
||||
// EXPERIMENTAL `edid_lock` unlock. AFTER the CCD restore + DDC wake: the re-activated
|
||||
// physical paths do not depend on it (the pinned emulation IS the real monitor's
|
||||
// EDID), and unlocking last keeps the driver from re-probing the sinks mid-restore.
|
||||
// OUTSIDE the `ccd_saved` gate for the same reason as the DDC wake above — the lock
|
||||
// was applied BEFORE the isolate, whose snapshot capture can have failed.
|
||||
if inner.group.edid_locked {
|
||||
pf_win_display::adl_emul::unlock_after_stream();
|
||||
inner.group.edid_locked = false;
|
||||
}
|
||||
} else {
|
||||
match shrink_action(inner.group.ccd_exclusive, inner.group.ccd_saved.is_some()) {
|
||||
// Re-issue the isolate over the shrunk set (defensive — the departing monitor's
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
//! AMD ADL connector/EDID emulation — the shared implementation behind the `display-disturb
|
||||
//! adl-emul` probe AND the `edid_lock` display-policy axis (the software equivalent of an
|
||||
//! HPD-holding dummy plug).
|
||||
//!
|
||||
//! Three field cases (ASUS VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI — all
|
||||
//! RX 9070 XT hosts) share one mechanism: a connected-but-asleep sink whose standby HPD/DDC/link
|
||||
//! servicing the KMD performs below every OS lever (CCD deactivation, devnode disable and CRU
|
||||
//! EDID overrides are confirmed no-ops — `design/vdisplay-disturbance-immunity.md` §2a/§3). The
|
||||
//! one software lever that can stop the servicing at its SOURCE is the driver's own connector
|
||||
//! emulation: pin the live EDID with `ADL2_Adapter_ConnectionData_Set`, then
|
||||
//! `ADL2_Adapter_EmulationMode_Set(ADL_EMUL_MODE_ALWAYS)` so the driver stops caring what the
|
||||
//! physical pins report.
|
||||
//!
|
||||
//! [`run`] performs one action across every AMD adapter's connectors and returns the per-op
|
||||
//! [`OpRecord`]s — the probe tool prints them as bench lines, the host tracing-logs them. The
|
||||
//! `edid_lock` axis drives [`lock_for_stream`]/[`unlock_after_stream`] at the Exclusive isolate
|
||||
//! (`pf-vdisplay`'s Windows manager), with a crash journal ([`startup_recover`]) because pinned
|
||||
//! emulation persists across host restarts — and can persist across REBOOTS, so every lock ships
|
||||
//! with its unlock (driver reinstall = the escape hatch of last resort).
|
||||
//!
|
||||
//! Everything is best-effort by design: no AMD driver (`atiadlxx.dll` absent) means the axis is
|
||||
//! inert, and each rc is preserved so a field log answers the consumer-vs-Pro gating question
|
||||
//! (`ADL_ERR_NOT_SUPPORTED(-8)` vs `ADL_OK`).
|
||||
|
||||
// FFI mirrors of ADL's C structs — keep AMD's field names verbatim so the header diff is
|
||||
// mechanical.
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::time::Instant;
|
||||
|
||||
use windows::core::{s, PCSTR};
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||
|
||||
// ---- ADL constants (adl_defines.h, GPUOpen display-library) ----
|
||||
|
||||
const ADL_OK: i32 = 0;
|
||||
const ADL_MAX_PATH: usize = 256;
|
||||
const ADL_MAX_DISPLAY_EDID_DATA_SIZE: usize = 1024;
|
||||
const ADL_MAX_RAD_LINK_COUNT: usize = 15;
|
||||
|
||||
const ADL_EMUL_MODE_OFF: i32 = 0;
|
||||
const ADL_EMUL_MODE_ALWAYS: i32 = 3;
|
||||
|
||||
const ADL_QUERY_REAL_DATA: i32 = 0;
|
||||
const ADL_QUERY_EMULATED_DATA: i32 = 1;
|
||||
|
||||
const ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED: i32 = 0x1;
|
||||
const ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT: i32 = 0x2;
|
||||
const ADL_EMUL_STATUS_EMULATED_DEVICE_USED: i32 = 0x4;
|
||||
|
||||
const AMD_VENDOR_ID: i32 = 1002;
|
||||
|
||||
/// Decode the rc values a field log will actually contain (adl_defines.h) — `-8` vs `-1` is the
|
||||
/// whole consumer-vs-Pro question, so spell them out.
|
||||
pub fn rc_str(rc: i32) -> &'static str {
|
||||
match rc {
|
||||
0 => "ADL_OK",
|
||||
1..=4 => "ADL_OK_(warning-class)",
|
||||
-1 => "ADL_ERR",
|
||||
-2 => "ADL_ERR_NOT_INIT",
|
||||
-3 => "ADL_ERR_INVALID_PARAM",
|
||||
-5 => "ADL_ERR_INVALID_ADL_IDX",
|
||||
-8 => "ADL_ERR_NOT_SUPPORTED",
|
||||
-9 => "ADL_ERR_NULL_POINTER",
|
||||
-10 => "ADL_ERR_DISABLED_ADAPTER",
|
||||
-22 => "ADL_ERR_CALL_TO_INCOMPATIABLE_DRIVER",
|
||||
-23 => "ADL_ERR_NO_ADMINISTRATOR_PRIVILEGES",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_type_str(t: i32) -> &'static str {
|
||||
match t {
|
||||
1 => "VGA",
|
||||
2 => "DVI-D",
|
||||
3 => "DVI-I",
|
||||
8 => "HDMI-A",
|
||||
9 => "HDMI-B",
|
||||
10 => "DP",
|
||||
11 => "eDP",
|
||||
12 => "miniDP",
|
||||
13 => "VIRTUAL",
|
||||
14 => "USB-C",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ADL structs (adl_structures.h, verbatim layouts) ----
|
||||
|
||||
#[repr(C)]
|
||||
struct AdapterInfo {
|
||||
iSize: i32,
|
||||
iAdapterIndex: i32,
|
||||
strUDID: [u8; ADL_MAX_PATH],
|
||||
iBusNumber: i32,
|
||||
iDeviceNumber: i32,
|
||||
iFunctionNumber: i32,
|
||||
iVendorID: i32,
|
||||
strAdapterName: [u8; ADL_MAX_PATH],
|
||||
strDisplayName: [u8; ADL_MAX_PATH],
|
||||
iPresent: i32,
|
||||
// _WIN32 tail — this tool only builds for Windows.
|
||||
iExist: i32,
|
||||
strDriverPath: [u8; ADL_MAX_PATH],
|
||||
strDriverPathExt: [u8; ADL_MAX_PATH],
|
||||
strPNPString: [u8; ADL_MAX_PATH],
|
||||
iOSDisplayIndex: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLMSTRad {
|
||||
iLinkNumber: i32,
|
||||
rad: [u8; ADL_MAX_RAD_LINK_COUNT],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLDevicePort {
|
||||
iConnectorIndex: i32,
|
||||
aMSTRad: ADLMSTRad,
|
||||
}
|
||||
|
||||
impl ADLDevicePort {
|
||||
/// A non-MST port at `connector` (MST RAD all-zero = "DP root / non-DP ignored" per header).
|
||||
fn root(connector: i32) -> Self {
|
||||
Self {
|
||||
iConnectorIndex: connector,
|
||||
aMSTRad: ADLMSTRad {
|
||||
iLinkNumber: 0,
|
||||
rad: [0; ADL_MAX_RAD_LINK_COUNT],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectionProperties {
|
||||
iValidProperties: i32,
|
||||
iBitrate: i32,
|
||||
iNumberOfLanes: i32,
|
||||
iColorDepth: i32,
|
||||
iStereo3DCaps: i32,
|
||||
iOutputBandwidth: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectionData {
|
||||
iConnectionType: i32,
|
||||
aConnectionProperties: ADLConnectionProperties,
|
||||
iNumberofPorts: i32,
|
||||
iActiveConnections: i32,
|
||||
iDataSize: i32,
|
||||
EdidData: [u8; ADL_MAX_DISPLAY_EDID_DATA_SIZE],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ADLConnectionState {
|
||||
iEmulationStatus: i32,
|
||||
iEmulationMode: i32,
|
||||
iDisplayIndex: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectorInfo {
|
||||
iConnectorIndex: i32,
|
||||
iConnectorId: i32,
|
||||
iSlotIndex: i32,
|
||||
iType: i32,
|
||||
iOffset: i32,
|
||||
iLength: i32,
|
||||
}
|
||||
|
||||
// ---- dynamic binding (atiadlxx.dll ships with every AMD driver; absent elsewhere) ----
|
||||
|
||||
type AdlContext = *mut c_void;
|
||||
type MallocCb = unsafe extern "C" fn(i32) -> *mut c_void;
|
||||
|
||||
type FnMainCreate = unsafe extern "C" fn(MallocCb, i32, *mut AdlContext) -> i32;
|
||||
type FnMainDestroy = unsafe extern "C" fn(AdlContext) -> i32;
|
||||
type FnNumAdapters = unsafe extern "C" fn(AdlContext, *mut i32) -> i32;
|
||||
type FnAdapterInfoGet = unsafe extern "C" fn(AdlContext, *mut AdapterInfo, i32) -> i32;
|
||||
type FnEdidMgmtCaps = unsafe extern "C" fn(AdlContext, i32, *mut i32) -> i32;
|
||||
type FnBoardLayoutGet = unsafe extern "C" fn(
|
||||
AdlContext,
|
||||
i32,
|
||||
*mut i32,
|
||||
*mut i32,
|
||||
*mut *mut c_void,
|
||||
*mut i32,
|
||||
*mut *mut ADLConnectorInfo,
|
||||
) -> i32;
|
||||
type FnConnStateGet =
|
||||
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, *mut ADLConnectionState) -> i32;
|
||||
type FnConnDataGet =
|
||||
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32, *mut ADLConnectionData) -> i32;
|
||||
type FnConnDataSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, ADLConnectionData) -> i32;
|
||||
type FnConnDataRemove = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort) -> i32;
|
||||
type FnEmulModeSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32) -> i32;
|
||||
|
||||
struct Adl {
|
||||
create: FnMainCreate,
|
||||
destroy: FnMainDestroy,
|
||||
num_adapters: FnNumAdapters,
|
||||
adapter_info: FnAdapterInfoGet,
|
||||
edid_caps: FnEdidMgmtCaps,
|
||||
board_layout: FnBoardLayoutGet,
|
||||
conn_state: FnConnStateGet,
|
||||
conn_data_get: FnConnDataGet,
|
||||
conn_data_set: FnConnDataSet,
|
||||
conn_data_remove: FnConnDataRemove,
|
||||
emul_mode_set: FnEmulModeSet,
|
||||
}
|
||||
|
||||
/// ADL's application-provided allocator: it hands buffers (board-layout arrays) back through
|
||||
/// out-pointers and expects the app to own them.
|
||||
unsafe extern "C" fn adl_malloc(size: i32) -> *mut c_void {
|
||||
let size = size.max(1) as usize;
|
||||
// SAFETY: non-zero size with a fixed valid alignment; the resulting buffers are deliberately
|
||||
// never freed — ADL's contract wants an ADL_Main_Memory_Free symmetry, and leaking the <1 KiB
|
||||
// of board-layout arrays in a one-shot probe is simpler than proving allocator parity.
|
||||
unsafe {
|
||||
std::alloc::alloc(std::alloc::Layout::from_size_align(size, 16).expect("tiny ADL alloc"))
|
||||
as *mut c_void
|
||||
}
|
||||
}
|
||||
|
||||
impl Adl {
|
||||
fn load() -> Option<Self> {
|
||||
// SAFETY: plain LoadLibrary of the AMD-driver-installed ADL runtime by its well-known
|
||||
// name; a foreign-DLL search-path attack would require writing to System32.
|
||||
let lib: HMODULE = unsafe { LoadLibraryA(s!("atiadlxx.dll")) }.ok()?;
|
||||
// One unsafe helper: resolve `name` or bail. Every Fn* type above matches the ADL
|
||||
// header's C signature (x64 has a single calling convention, so `extern "C"` is exact).
|
||||
unsafe fn sym<T: Copy>(lib: HMODULE, name: PCSTR) -> Option<T> {
|
||||
debug_assert_eq!(std::mem::size_of::<T>(), std::mem::size_of::<usize>());
|
||||
// SAFETY: caller passes a fn-pointer type T of pointer size (asserted above);
|
||||
// GetProcAddress yields the export's address or None.
|
||||
let f = unsafe { GetProcAddress(lib, name) }?;
|
||||
// SAFETY: reinterpreting one non-null fn pointer as the export's true C signature.
|
||||
Some(unsafe { std::mem::transmute_copy::<_, T>(&f) })
|
||||
}
|
||||
// SAFETY: `lib` is the live module handle from the successful load above.
|
||||
unsafe {
|
||||
Some(Self {
|
||||
create: sym(lib, s!("ADL2_Main_Control_Create"))?,
|
||||
destroy: sym(lib, s!("ADL2_Main_Control_Destroy"))?,
|
||||
num_adapters: sym(lib, s!("ADL2_Adapter_NumberOfAdapters_Get"))?,
|
||||
adapter_info: sym(lib, s!("ADL2_Adapter_AdapterInfo_Get"))?,
|
||||
edid_caps: sym(lib, s!("ADL2_Adapter_EDIDManagement_Caps"))?,
|
||||
board_layout: sym(lib, s!("ADL2_Adapter_BoardLayout_Get"))?,
|
||||
conn_state: sym(lib, s!("ADL2_Adapter_ConnectionState_Get"))?,
|
||||
conn_data_get: sym(lib, s!("ADL2_Adapter_ConnectionData_Get"))?,
|
||||
conn_data_set: sym(lib, s!("ADL2_Adapter_ConnectionData_Set"))?,
|
||||
conn_data_remove: sym(lib, s!("ADL2_Adapter_ConnectionData_Remove"))?,
|
||||
emul_mode_set: sym(lib, s!("ADL2_Adapter_EmulationMode_Set"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the library surface ----
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmulAction {
|
||||
/// Read-only: caps + layout + per-connector state. Always safe.
|
||||
Probe,
|
||||
/// Pin live EDID + `ADL_EMUL_MODE_ALWAYS` on occupied (or named) connectors.
|
||||
Lock,
|
||||
/// `ADL_EMUL_MODE_OFF` + remove pinned EDID on all (or named) connectors.
|
||||
Unlock,
|
||||
}
|
||||
|
||||
/// One ADL call's outcome — op name, target, duration, rc (decoded via [`rc_str`]) and the
|
||||
/// op-specific fields. The probe tool prints these as its bench correlation lines; the host
|
||||
/// tracing-logs them. The rc IS the deliverable of a field run.
|
||||
pub struct OpRecord {
|
||||
pub op: &'static str,
|
||||
pub target: String,
|
||||
pub took_ms: u128,
|
||||
pub rc: i32,
|
||||
pub extra: String,
|
||||
}
|
||||
|
||||
impl OpRecord {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.rc == ADL_OK
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OpRecord {
|
||||
/// The bench line minus the caller's epoch prefix: `op target took_ms ok rc=N(STR) extra`.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let sep = if self.extra.is_empty() { "" } else { " " };
|
||||
write!(
|
||||
f,
|
||||
"{} {} took_ms={} ok={} rc={}({}){sep}{}",
|
||||
self.op,
|
||||
self.target,
|
||||
self.took_ms,
|
||||
self.ok(),
|
||||
self.rc,
|
||||
rc_str(self.rc),
|
||||
self.extra
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a [`run`] ended: the AMD runtime was absent entirely, died at init (nothing was touched),
|
||||
/// or walked the connectors (each op's rc in the records — a NOT_SUPPORTED driver still `Done`s).
|
||||
pub enum RunOutcome {
|
||||
/// `atiadlxx.dll` not loadable (or an export missing) — not an AMD driver install; the
|
||||
/// emulation lever does not exist on this box.
|
||||
NoAdl,
|
||||
/// `ADL2_Main_Control_Create` / adapter enumeration failed — records hold the failing rc.
|
||||
InitFailed(Vec<OpRecord>),
|
||||
/// The connector walk ran; every op's outcome is in the records.
|
||||
Done(Vec<OpRecord>),
|
||||
}
|
||||
|
||||
impl RunOutcome {
|
||||
pub fn records(&self) -> &[OpRecord] {
|
||||
match self {
|
||||
RunOutcome::NoAdl => &[],
|
||||
RunOutcome::InitFailed(r) | RunOutcome::Done(r) => r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn c_str(buf: &[u8]) -> String {
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..len]).into_owned()
|
||||
}
|
||||
|
||||
/// Perform `action` on every AMD adapter's connectors (or only `connector_filter`), returning the
|
||||
/// per-op records. Read-only for [`EmulAction::Probe`]; [`EmulAction::Lock`] pins occupied
|
||||
/// connectors only (unless the filter names one), matching an HPD dummy on the cables that exist.
|
||||
pub fn run(action: EmulAction, connector_filter: Option<i32>) -> RunOutcome {
|
||||
let mut recs: Vec<OpRecord> = Vec::new();
|
||||
let mut rec = |op: &'static str, target: &str, took_ms: u128, rc: i32, extra: String| {
|
||||
recs.push(OpRecord {
|
||||
op,
|
||||
target: target.to_owned(),
|
||||
took_ms,
|
||||
rc,
|
||||
extra,
|
||||
});
|
||||
};
|
||||
|
||||
let Some(adl) = Adl::load() else {
|
||||
return RunOutcome::NoAdl;
|
||||
};
|
||||
|
||||
let mut ctx: AdlContext = std::ptr::null_mut();
|
||||
let t = Instant::now();
|
||||
// SAFETY: documented init call — our allocator callback, iEnumConnectedAdapters=0 (ALL
|
||||
// adapters: an exclusively-isolated streaming host may report no "connected" display on the
|
||||
// physical GPU), and a valid out-slot for the context.
|
||||
let rc = unsafe { (adl.create)(adl_malloc, 0, &mut ctx) };
|
||||
rec(
|
||||
"adl-init",
|
||||
"atiadlxx",
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
if rc != ADL_OK {
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
|
||||
let mut count = 0i32;
|
||||
// SAFETY: live context; valid out-param.
|
||||
let rc = unsafe { (adl.num_adapters)(ctx, &mut count) };
|
||||
if rc != ADL_OK || count <= 0 {
|
||||
rec("adl-num-adapters", "all", 0, rc, format!("count={count}"));
|
||||
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
let mut infos: Vec<AdapterInfo> = (0..count)
|
||||
.map(|_| {
|
||||
// SAFETY: AdapterInfo is plain ints + byte arrays — the all-zero pattern is valid,
|
||||
// and ADL fills the array in place.
|
||||
let mut a: AdapterInfo = unsafe { std::mem::zeroed() };
|
||||
a.iSize = std::mem::size_of::<AdapterInfo>() as i32;
|
||||
a
|
||||
})
|
||||
.collect();
|
||||
let bytes = std::mem::size_of_val(infos.as_slice()) as i32;
|
||||
// SAFETY: caller-allocated array of exactly `count` stamped entries, byte size passed as the
|
||||
// API's iInputSize contract requires.
|
||||
let rc = unsafe { (adl.adapter_info)(ctx, infos.as_mut_ptr(), bytes) };
|
||||
rec("adl-adapters", "all", 0, rc, format!("count={count}"));
|
||||
if rc != ADL_OK {
|
||||
// SAFETY: as above — context teardown, nothing ADL-owned used afterwards.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
|
||||
// One GPU surfaces as many logical adapters — probe each bus once, AMD-present only.
|
||||
let mut seen_buses: Vec<i32> = Vec::new();
|
||||
for info in &infos {
|
||||
if info.iPresent == 0
|
||||
|| info.iVendorID != AMD_VENDOR_ID
|
||||
|| seen_buses.contains(&info.iBusNumber)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
seen_buses.push(info.iBusNumber);
|
||||
let idx = info.iAdapterIndex;
|
||||
let name = c_str(&info.strAdapterName);
|
||||
let target = format!("adapter{idx}[{}]", name.trim());
|
||||
|
||||
let mut supported = 0i32;
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context, adapter index from this enumeration, valid out-param.
|
||||
let rc = unsafe { (adl.edid_caps)(ctx, idx, &mut supported) };
|
||||
rec(
|
||||
"adl-edid-caps",
|
||||
&target,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!("supported={supported}"),
|
||||
);
|
||||
|
||||
let (mut valid, mut n_slots, mut n_conn) = (0i32, 0i32, 0i32);
|
||||
let mut slots: *mut c_void = std::ptr::null_mut();
|
||||
let mut connectors: *mut ADLConnectorInfo = std::ptr::null_mut();
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context + adapter index; out-pointers valid; ADL allocates the two arrays
|
||||
// through `adl_malloc` (deliberately leaked, see there).
|
||||
let rc = unsafe {
|
||||
(adl.board_layout)(
|
||||
ctx,
|
||||
idx,
|
||||
&mut valid,
|
||||
&mut n_slots,
|
||||
&mut slots,
|
||||
&mut n_conn,
|
||||
&mut connectors,
|
||||
)
|
||||
};
|
||||
rec(
|
||||
"adl-board-layout",
|
||||
&target,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!("connectors={n_conn} valid_flags={valid:#x}"),
|
||||
);
|
||||
let connector_list: &[ADLConnectorInfo] =
|
||||
if rc == ADL_OK && !connectors.is_null() && n_conn > 0 {
|
||||
// SAFETY: ADL just filled `connectors` with `n_conn` entries via our allocator; the
|
||||
// (leaked) buffer outlives this borrow.
|
||||
unsafe { std::slice::from_raw_parts(connectors, n_conn as usize) }
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
|
||||
for c in connector_list {
|
||||
if connector_filter.is_some_and(|want| want != c.iConnectorIndex) {
|
||||
continue;
|
||||
}
|
||||
let port = ADLDevicePort::root(c.iConnectorIndex);
|
||||
let ctarget = format!(
|
||||
"adapter{idx}.connector{}[{}]",
|
||||
c.iConnectorIndex,
|
||||
connector_type_str(c.iType)
|
||||
);
|
||||
|
||||
let mut state = ADLConnectionState::default();
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context; port is a by-value POD naming a connector this adapter just
|
||||
// enumerated; valid out-param.
|
||||
let rc = unsafe { (adl.conn_state)(ctx, idx, port, &mut state) };
|
||||
let real = state.iEmulationStatus & ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED != 0;
|
||||
rec(
|
||||
"adl-conn-state",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"status={:#x} real_connected={} emulated_present={} emulated_used={} mode={} display={}",
|
||||
state.iEmulationStatus,
|
||||
real,
|
||||
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT != 0,
|
||||
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_USED != 0,
|
||||
state.iEmulationMode,
|
||||
state.iDisplayIndex,
|
||||
),
|
||||
);
|
||||
if rc != ADL_OK {
|
||||
continue;
|
||||
}
|
||||
|
||||
match action {
|
||||
EmulAction::Probe => {
|
||||
// SAFETY: ADLConnectionData is plain ints + a byte array; all-zero is valid
|
||||
// and ADL overwrites it.
|
||||
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port as above; REAL query fills `data` in place.
|
||||
let rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
|
||||
};
|
||||
rec(
|
||||
"adl-conn-data",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"type={} edid_bytes={}",
|
||||
data.iConnectionType, data.iDataSize
|
||||
),
|
||||
);
|
||||
}
|
||||
EmulAction::Lock => {
|
||||
if !real && connector_filter.is_none() {
|
||||
continue; // nothing to pin — and pinning an EMPTY connector is a different experiment
|
||||
}
|
||||
// SAFETY: as in Probe — zeroed then driver-filled.
|
||||
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; REAL query first — we pin exactly what the
|
||||
// sink reports today, so the emulated display IS the user's monitor.
|
||||
let mut rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
|
||||
};
|
||||
if rc != ADL_OK {
|
||||
// Asleep sinks can refuse a live EDID read — fall back to whatever the
|
||||
// driver already has as emulation data (Radeon-Pro-UI parity).
|
||||
// SAFETY: same contract, emulated-data query.
|
||||
rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_EMULATED_DATA, &mut data)
|
||||
};
|
||||
}
|
||||
rec(
|
||||
"adl-lock-read",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"type={} edid_bytes={}",
|
||||
data.iConnectionType, data.iDataSize
|
||||
),
|
||||
);
|
||||
if rc != ADL_OK || data.iDataSize <= 0 {
|
||||
continue;
|
||||
}
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; `data` passed by value per the ADL signature.
|
||||
let rc = unsafe { (adl.conn_data_set)(ctx, idx, port, data) };
|
||||
rec(
|
||||
"adl-lock-set",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; mode constant from the header.
|
||||
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_ALWAYS) };
|
||||
rec(
|
||||
"adl-lock-mode-always",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
EmulAction::Unlock => {
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; mode constant from the header.
|
||||
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_OFF) };
|
||||
rec(
|
||||
"adl-unlock-mode-off",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; removes emulation data set earlier (harmless
|
||||
// where none exists — the rc says so).
|
||||
let rc = unsafe { (adl.conn_data_remove)(ctx, idx, port) };
|
||||
rec(
|
||||
"adl-unlock-remove",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
RunOutcome::Done(recs)
|
||||
}
|
||||
|
||||
// ---- the `edid_lock` display-policy axis (host-side) ----
|
||||
|
||||
/// The crash-recovery journal: a marker that a lock was applied and not yet unlocked. Pinned
|
||||
/// emulation outlives the process (and can outlive a reboot), so a host that died mid-stream
|
||||
/// must unlock on its next start ([`startup_recover`]).
|
||||
fn journal_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("edid-lock-active.json")
|
||||
}
|
||||
|
||||
fn tracing_log(prefix: &str, outcome: &RunOutcome) {
|
||||
match outcome {
|
||||
RunOutcome::NoAdl => tracing::info!(
|
||||
"{prefix}: atiadlxx.dll not loadable — not an AMD driver install; the ADL \
|
||||
emulation lever does not exist on this box"
|
||||
),
|
||||
RunOutcome::InitFailed(recs) | RunOutcome::Done(recs) => {
|
||||
for r in recs {
|
||||
if r.ok() {
|
||||
tracing::info!("{prefix}: {r}");
|
||||
} else {
|
||||
tracing::warn!("{prefix}: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the `edid_lock` axis at stream bring-up: pin the live EDID + `ADL_EMUL_MODE_ALWAYS` on
|
||||
/// every occupied AMD connector (the software HPD dummy), journaling first so a crash still
|
||||
/// unlocks on the next host start. Returns whether a later [`unlock_after_stream`] is owed —
|
||||
/// true whenever the ADL runtime exists, because even a partially-failed lock may have pinned
|
||||
/// some connectors (each rc is in the log).
|
||||
pub fn lock_for_stream() -> bool {
|
||||
// Journal BEFORE touching the driver: a crash between the first `ConnectionData_Set` and the
|
||||
// journal write would otherwise leave pinned connectors with no startup unlock owed.
|
||||
if let Err(e) = std::fs::write(journal_path(), b"{\"locked\":true}") {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"edid_lock: crash journal write failed — continuing (the feature degrades to \
|
||||
no-crash-journal)"
|
||||
);
|
||||
}
|
||||
let outcome = run(EmulAction::Lock, None);
|
||||
tracing_log("edid_lock", &outcome);
|
||||
if matches!(outcome, RunOutcome::NoAdl) {
|
||||
tracing::info!("edid_lock: enabled but this is not an AMD driver install — axis inert");
|
||||
let _ = std::fs::remove_file(journal_path());
|
||||
return false;
|
||||
}
|
||||
let locked = outcome
|
||||
.records()
|
||||
.iter()
|
||||
.filter(|r| r.op == "adl-lock-mode-always" && r.ok())
|
||||
.count();
|
||||
tracing::info!(
|
||||
connectors = locked,
|
||||
"edid_lock: connector emulation pinned (software HPD dummy) — unlocked at stream teardown"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Undo [`lock_for_stream`] at teardown: `ADL_EMUL_MODE_OFF` + remove the pinned EDID on every
|
||||
/// AMD connector, then clear the crash journal. Idempotent and harmless where nothing is pinned.
|
||||
pub fn unlock_after_stream() {
|
||||
let outcome = run(EmulAction::Unlock, None);
|
||||
tracing_log("edid_lock", &outcome);
|
||||
let _ = std::fs::remove_file(journal_path());
|
||||
}
|
||||
|
||||
/// Host-startup crash recovery: a previous host that died holding the lock left connector
|
||||
/// emulation pinned (it persists past the process — and can persist past a reboot). If the
|
||||
/// journal marker exists, unlock everything and clear it — before any new session touches the
|
||||
/// topology, mirroring `monitor_devnode::startup_recover`.
|
||||
pub fn startup_recover() {
|
||||
if !journal_path().exists() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"edid_lock: a previous host left connector emulation pinned (crash/kill) — unlocking"
|
||||
);
|
||||
unlock_after_stream();
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
// hoist that to the crate root so the smaller modules (`input_desktop`, `monitor_devnode`,
|
||||
// `display_events`) and any future one are covered by default rather than by remembering to opt in.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod adl_emul;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod display_events;
|
||||
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
|
||||
|
||||
@@ -4373,7 +4373,41 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
|
||||
if !gap_out.is_null() {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
|
||||
// written once by value.
|
||||
unsafe { *gap_out = gap };
|
||||
unsafe { *gap_out = gap > 0 };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
|
||||
/// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
|
||||
/// A client with a post-loss display freeze passes the width to
|
||||
/// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
|
||||
/// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
|
||||
/// race — see the gate function's doc).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
|
||||
c: *const PunktfunkConnection,
|
||||
frame_index: u32,
|
||||
gap_width_out: *mut u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let gap = c.inner.note_frame_index(frame_index);
|
||||
if !gap_width_out.is_null() {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
|
||||
// written once by value.
|
||||
unsafe { *gap_width_out = gap };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
@@ -4724,6 +4758,31 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
|
||||
});
|
||||
}
|
||||
|
||||
/// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
|
||||
/// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
|
||||
/// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
|
||||
/// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
|
||||
/// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
|
||||
/// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
|
||||
/// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
|
||||
g: *mut ReanchorGate,
|
||||
expected_drops: u64,
|
||||
) {
|
||||
guard_void(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
|
||||
// not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` here
|
||||
// handles.
|
||||
if let Some(g) = unsafe { g.as_mut() } {
|
||||
g.arm_expecting_drops(std::time::Instant::now(), expected_drops);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
|
||||
/// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
|
||||
/// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
|
||||
|
||||
@@ -881,14 +881,20 @@ impl NativeClient {
|
||||
///
|
||||
/// Call it for EVERY received frame; it is cheap and idempotent, and the
|
||||
/// [`frames_dropped`](Self::frames_dropped)-driven [`request_keyframe`](Self::request_keyframe)
|
||||
/// loop stays the backstop for when the recovery frame itself is lost. Returns `true` when a
|
||||
/// forward gap was detected on this call (whether or not the RFI was throttled), so a client with
|
||||
/// a post-loss display freeze can (re-)arm it on the same signal.
|
||||
/// loop stays the backstop for when the recovery frame itself is lost. Returns the gap WIDTH —
|
||||
/// how many frames this arrival revealed as missing, `0` when none (contiguous or straggler),
|
||||
/// whether or not the RFI was throttled — so a client with a post-loss display freeze can
|
||||
/// (re-)arm it on the same signal AND pre-credit the reassembler's later `frames_dropped` climb
|
||||
/// for the same loss ([`ReanchorGate::arm_expecting_drops`] — without the credit, a fast
|
||||
/// LTR-RFI anchor lifts the freeze before the climb books the loss, and the stale climb then
|
||||
/// re-freezes the healed stream).
|
||||
///
|
||||
/// This centralizes the loss-range detection so every embedder gets identical behavior. (The
|
||||
/// in-process Vulkan session pump keeps its own copy because it gates a display freeze on the same
|
||||
/// signal and shares one throttle across RFI + keyframe requests.)
|
||||
pub fn note_frame_index(&self, frame_index: u32) -> bool {
|
||||
///
|
||||
/// [`ReanchorGate::arm_expecting_drops`]: crate::reanchor::ReanchorGate::arm_expecting_drops
|
||||
pub fn note_frame_index(&self, frame_index: u32) -> u32 {
|
||||
// Decide (and update state) under the lock; fire the request after releasing it.
|
||||
let (gap, ask) = self
|
||||
.rfi
|
||||
|
||||
@@ -32,14 +32,16 @@ pub(crate) enum RecoveryAsk {
|
||||
impl RfiRecovery {
|
||||
/// Pure decision behind [`NativeClient::note_frame_index`]: fold one received `frame_index` (in
|
||||
/// receive order) observed at `now`, advancing the expectation and returning `(gap, ask)`.
|
||||
/// `gap` is whether this frame revealed a forward gap (the embedder arms its post-loss display
|
||||
/// freeze on it); `ask` is the (throttled) recovery request to fire — an RFI naming the exact
|
||||
/// lost span, or a keyframe when the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is
|
||||
/// hopeless there: no encoder holds references that old, and a huge jump is more likely a
|
||||
/// resync — e.g. the first real AU after an old host's speed test — than a real loss). Split
|
||||
/// out from the connection so the wrapping arithmetic + [`RFI_THROTTLE`] are unit-testable
|
||||
/// without a live session (see the tests below).
|
||||
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (bool, RecoveryAsk) {
|
||||
/// `gap` is how many frames this arrival revealed as missing — 0 for contiguous/straggler; the
|
||||
/// embedder arms its post-loss display freeze on a non-zero gap, and the WIDTH lets it
|
||||
/// pre-credit the reassembler's later `frames_dropped` climb for the same loss
|
||||
/// ([`crate::reanchor::ReanchorGate::arm_expecting_drops`] — the double-arm race). `ask` is the
|
||||
/// (throttled) recovery request to fire — an RFI naming the exact lost span, or a keyframe when
|
||||
/// the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is hopeless there: no encoder holds
|
||||
/// references that old, and a huge jump is more likely a resync — e.g. the first real AU after
|
||||
/// an old host's speed test — than a real loss). Split out from the connection so the wrapping
|
||||
/// arithmetic + [`RFI_THROTTLE`] are unit-testable without a live session (see the tests below).
|
||||
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (u32, RecoveryAsk) {
|
||||
match self.next_expected {
|
||||
Some(exp) => {
|
||||
// Wrapping split at the half-space: a small positive delta is a forward gap
|
||||
@@ -47,10 +49,11 @@ impl RfiRecovery {
|
||||
let ahead = frame_index.wrapping_sub(exp);
|
||||
if ahead == 0 {
|
||||
self.next_expected = Some(frame_index.wrapping_add(1)); // contiguous
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
} else if ahead < u32::MAX / 2 {
|
||||
// Forward gap: [exp, frame_index-1] lost. Advance past this frame so the same
|
||||
// gap isn't re-detected, then fire a throttled recovery ask for the lost range.
|
||||
// Forward gap: [exp, frame_index-1] lost (`ahead` frames). Advance past this
|
||||
// frame so the same gap isn't re-detected, then fire a throttled recovery ask
|
||||
// for the lost range.
|
||||
self.next_expected = Some(frame_index.wrapping_add(1));
|
||||
let send = self
|
||||
.last_req
|
||||
@@ -65,15 +68,15 @@ impl RfiRecovery {
|
||||
} else {
|
||||
RecoveryAsk::Rfi(exp, frame_index.wrapping_sub(1))
|
||||
};
|
||||
(true, ask)
|
||||
(ahead, ask)
|
||||
} else {
|
||||
// Straggler behind the delivery point — leave the expectation.
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.next_expected = Some(frame_index.wrapping_add(1));
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +99,7 @@ mod rfi_recovery_tests {
|
||||
fn first_frame_arms_without_a_gap() {
|
||||
let mut r = RfiRecovery::default();
|
||||
// The opening frame only seeds the expectation — there is no prior frame to be missing.
|
||||
assert_eq!(r.observe(100, base()), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(100, base()), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(101));
|
||||
}
|
||||
|
||||
@@ -105,9 +108,9 @@ mod rfi_recovery_tests {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
assert_eq!(r.observe(101, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(102, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(101, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(102, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(104));
|
||||
}
|
||||
|
||||
@@ -117,7 +120,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(100, t); // expecting 101 next
|
||||
// 101..=104 were lost; 105 arrived. The RFI must name exactly the missing span.
|
||||
assert_eq!(r.observe(105, t), (true, RecoveryAsk::Rfi(101, 104)));
|
||||
assert_eq!(r.observe(105, t), (4, RecoveryAsk::Rfi(101, 104)));
|
||||
// The expectation advances past the delivered frame so the same gap can't re-fire.
|
||||
assert_eq!(r.next_expected, Some(106));
|
||||
}
|
||||
@@ -128,7 +131,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
// Exactly one frame (101) lost → range is the single index [101, 101].
|
||||
assert_eq!(r.observe(102, t), (true, RecoveryAsk::Rfi(101, 101)));
|
||||
assert_eq!(r.observe(102, t), (1, RecoveryAsk::Rfi(101, 101)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -137,16 +140,16 @@ mod rfi_recovery_tests {
|
||||
let t0 = base();
|
||||
r.observe(100, t0);
|
||||
// First gap fires the request and stamps the throttle.
|
||||
assert_eq!(r.observe(105, t0), (true, RecoveryAsk::Rfi(101, 104)));
|
||||
assert_eq!(r.observe(105, t0), (4, RecoveryAsk::Rfi(101, 104)));
|
||||
// A second gap 50 ms later is still a gap, but the request is throttled away.
|
||||
assert_eq!(
|
||||
r.observe(110, t0 + Duration::from_millis(50)),
|
||||
(true, RecoveryAsk::None)
|
||||
(4, RecoveryAsk::None)
|
||||
);
|
||||
// Past the window, the request re-opens for the still-accurate lost span.
|
||||
assert_eq!(
|
||||
r.observe(120, t0 + RFI_THROTTLE + Duration::from_millis(1)),
|
||||
(true, RecoveryAsk::Rfi(111, 119))
|
||||
(9, RecoveryAsk::Rfi(111, 119))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +161,7 @@ mod rfi_recovery_tests {
|
||||
r.observe(105, t); // expecting 106 next
|
||||
// A reordered late arrival (103, well behind 106) is neither a gap nor a request, and it
|
||||
// must not rewind the expectation — otherwise the next in-order frame would false-gap.
|
||||
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(106));
|
||||
}
|
||||
|
||||
@@ -167,9 +170,9 @@ mod rfi_recovery_tests {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
|
||||
assert_eq!(r.observe(u32::MAX, t), (false, RecoveryAsk::None)); // contiguous, wraps to 0
|
||||
assert_eq!(r.observe(u32::MAX, t), (0, RecoveryAsk::None)); // contiguous, wraps to 0
|
||||
assert_eq!(r.next_expected, Some(0));
|
||||
assert_eq!(r.observe(0, t), (false, RecoveryAsk::None)); // still contiguous across the wrap
|
||||
assert_eq!(r.observe(0, t), (0, RecoveryAsk::None)); // still contiguous across the wrap
|
||||
assert_eq!(r.next_expected, Some(1));
|
||||
}
|
||||
|
||||
@@ -179,7 +182,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
|
||||
// u32::MAX was lost and 1 arrived → the lost span wraps: [u32::MAX, 0].
|
||||
assert_eq!(r.observe(1, t), (true, RecoveryAsk::Rfi(u32::MAX, 0)));
|
||||
assert_eq!(r.observe(1, t), (2, RecoveryAsk::Rfi(u32::MAX, 0)));
|
||||
assert_eq!(r.next_expected, Some(2));
|
||||
}
|
||||
|
||||
@@ -192,14 +195,14 @@ mod rfi_recovery_tests {
|
||||
// reference exists for an RFI, and the jump may be a phantom (an old host's
|
||||
// speed-test burst consuming video indexes) — ask for the IDR resync instead.
|
||||
let jump = 100 + crate::packet::RFI_MAX_RANGE + 2;
|
||||
assert_eq!(r.observe(jump, t), (true, RecoveryAsk::Keyframe));
|
||||
assert_eq!(r.observe(jump, t), (jump - 101, RecoveryAsk::Keyframe));
|
||||
// The expectation still advances past the delivered frame (no re-fire on the next one).
|
||||
assert_eq!(r.next_expected, Some(jump + 1));
|
||||
assert_eq!(r.observe(jump + 1, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(jump + 1, t), (0, RecoveryAsk::None));
|
||||
// A huge gap consumes the shared throttle too — an immediate follow-up gap stays quiet.
|
||||
assert_eq!(
|
||||
r.observe(jump + 10, t + Duration::from_millis(1)),
|
||||
(true, RecoveryAsk::None)
|
||||
(8, RecoveryAsk::None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,26 @@ pub const REANCHOR_MARKS_TO_LIFT: u32 = 2;
|
||||
/// floor fires, so a real stall still recovers.
|
||||
pub const RECOVERY_MARK_PATIENCE: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// How long a frame-index-gap arm's expected `frames_dropped` climb stays pre-credited in
|
||||
/// [`ReanchorGate::poll`]. One loss arms the gate through TWO signals: the frame-index gap the
|
||||
/// instant the AU after the loss is delivered ([`ReanchorGate::arm_expecting_drops`]), and the
|
||||
/// reassembler's `frames_dropped` climb once the lost frame ages out of its loss window (~120 ms
|
||||
/// later, and only when at least one of its packets arrived). Without the credit, a *fast* recovery
|
||||
/// — an LTR-RFI anchor typically lands within ~60 ms — lifts the freeze between the two signals,
|
||||
/// and the stale climb then re-freezes a stream that is already bit-exact healed; the host swallows
|
||||
/// the resulting keyframe request as an echo of the very RFI that healed it, so the picture stays
|
||||
/// frozen until the [`REANCHOR_FREEZE_MAX`] overdue re-ask extracts a full IDR (the field
|
||||
/// "H265 freezes on every loss, AV1 fine" signature — the slower IDR path usually lands after the
|
||||
/// climb and dodged the race).
|
||||
///
|
||||
/// Sized to cover the reassembler's 120 ms loss window plus delivery jitter with a wide margin,
|
||||
/// while staying short enough that a leftover credit (a straggler that filled the gap late, so no
|
||||
/// climb ever came; or a whole-frame vanish the reassembler never saw a packet of) cannot mask a
|
||||
/// genuinely unrelated future climb for long. A masked climb is also never silent in practice:
|
||||
/// every unrecoverable loss reveals itself as a frame-index gap on the next delivered frame, which
|
||||
/// re-arms (and re-credits) through [`ReanchorGate::arm_expecting_drops`] on its own.
|
||||
pub const DROP_CREDIT_WINDOW: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Frames skipped when `got` arrives while `expected` was the next index, or `None` if `got` is
|
||||
/// contiguous (`== expected`) or a straggler we have already passed. Frame indices are u32 counters
|
||||
/// that wrap, so the "ahead" test is a wrapping subtraction split at the half-space: a small positive
|
||||
@@ -185,6 +205,14 @@ pub struct ReanchorGate {
|
||||
/// a client stamps the decoder's decode-order watermark whenever this counter moves and
|
||||
/// discards the local recovery of anything older. Every other client ignores it.
|
||||
arms: u64,
|
||||
/// `frames_dropped` climb still expected from losses that already armed via a frame-index gap
|
||||
/// ([`Self::arm_expecting_drops`]). [`Self::poll`] consumes climbs against this before treating
|
||||
/// them as a NEW loss, so the reassembler's delayed bookkeeping of a gap-armed (and possibly
|
||||
/// already anchor-healed) loss cannot re-freeze the stream — see [`DROP_CREDIT_WINDOW`].
|
||||
drop_credit: u64,
|
||||
/// When the outstanding [`Self::drop_credit`] lapses ([`DROP_CREDIT_WINDOW`] after the latest
|
||||
/// credited arm). `None` when no credit is outstanding.
|
||||
drop_credit_expiry: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ReanchorGate {
|
||||
@@ -199,6 +227,8 @@ impl ReanchorGate {
|
||||
last_dropped: frames_dropped,
|
||||
local_sei_since_arm: false,
|
||||
arms: 0,
|
||||
drop_credit: 0,
|
||||
drop_credit_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +260,20 @@ impl ReanchorGate {
|
||||
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
}
|
||||
|
||||
/// [`arm`](Self::arm) for a loss detected as a **frame-index gap**, where the caller knows how
|
||||
/// many frames the gap skipped. On top of arming, it pre-credits the reassembler's
|
||||
/// `frames_dropped` climb those same lost frames will produce up to ~120 ms later (its
|
||||
/// loss-window age-out), so [`poll`](Self::poll) does not treat that delayed bookkeeping as a
|
||||
/// SECOND loss. Without the credit a fast LTR-RFI anchor lifts the freeze between the two
|
||||
/// signals and the stale climb re-freezes a healed stream — the double-arm race
|
||||
/// ([`DROP_CREDIT_WINDOW`] tells the whole story). Use plain [`arm`](Self::arm) for every
|
||||
/// non-gap loss signal (decoder wedge/demotion), which has no climb to credit.
|
||||
pub fn arm_expecting_drops(&mut self, now: Instant, expected_drops: u64) {
|
||||
self.arm(now);
|
||||
self.drop_credit = self.drop_credit.saturating_add(expected_drops);
|
||||
self.drop_credit_expiry = Some(now + DROP_CREDIT_WINDOW);
|
||||
}
|
||||
|
||||
/// Fold the client's OWN recovery-point observation for one decoded frame, BEFORE handing that
|
||||
/// frame to [`on_decoded`](Self::on_decoded). Returns `true` when it lifted the freeze.
|
||||
///
|
||||
@@ -333,16 +377,36 @@ impl ReanchorGate {
|
||||
}
|
||||
|
||||
/// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Returns
|
||||
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed (a
|
||||
/// fresh unrecoverable loss — arm the freeze) or the freeze has held a full [`REANCHOR_FREEZE_MAX`]
|
||||
/// window with no re-anchor (re-ask and keep holding — NEVER resume to the concealed picture; a
|
||||
/// genuinely dead stream is the QUIC idle-timeout watchdog's job, not the gate's).
|
||||
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed by
|
||||
/// more than the outstanding gap-arm credit (a fresh unrecoverable loss — arm the freeze) or the
|
||||
/// freeze has held a full [`REANCHOR_FREEZE_MAX`] window with no re-anchor (re-ask and keep
|
||||
/// holding — NEVER resume to the concealed picture; a genuinely dead stream is the QUIC
|
||||
/// idle-timeout watchdog's job, not the gate's).
|
||||
///
|
||||
/// A climb covered by [`arm_expecting_drops`](Self::arm_expecting_drops)' credit is the
|
||||
/// reassembler's delayed bookkeeping of a loss this gate already armed for — it must neither
|
||||
/// re-arm (an LTR-RFI anchor may have healed the stream in the meantime; re-freezing it is the
|
||||
/// double-arm race) nor ask again (the gap already fired the precise RFI, and if THAT recovery
|
||||
/// was lost the overdue backstop still re-asks at the [`REANCHOR_FREEZE_MAX`] deadline the
|
||||
/// gap-arm set — which is also sooner than the deadline a re-arm here would push out to).
|
||||
pub fn poll(&mut self, frames_dropped: u64, now: Instant) -> bool {
|
||||
let mut want_keyframe = false;
|
||||
if frames_dropped > self.last_dropped {
|
||||
let climb = frames_dropped - self.last_dropped;
|
||||
self.last_dropped = frames_dropped;
|
||||
self.arm(now);
|
||||
want_keyframe = true;
|
||||
if self.drop_credit_expiry.is_some_and(|e| now >= e) {
|
||||
self.drop_credit = 0;
|
||||
self.drop_credit_expiry = None;
|
||||
}
|
||||
let credited = climb.min(self.drop_credit);
|
||||
self.drop_credit -= credited;
|
||||
if self.drop_credit == 0 {
|
||||
self.drop_credit_expiry = None;
|
||||
}
|
||||
if climb > credited {
|
||||
self.arm(now);
|
||||
want_keyframe = true;
|
||||
}
|
||||
}
|
||||
if self.awaiting && self.deadline.is_some_and(|d| now >= d) {
|
||||
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
@@ -542,6 +606,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_rfi_anchor_is_not_refrozen_by_the_same_losss_drop_climb() {
|
||||
// The double-arm race (field: "H265 freezes on every loss, AV1 fine"): a loss arms via
|
||||
// the frame-index gap at T+10ms, the LTR-RFI anchor heals at T+60ms, and the reassembler
|
||||
// books the SAME loss into frames_dropped at ~T+130ms. The credited arm must keep that
|
||||
// stale climb from re-freezing the healed stream (and from re-asking — the host would
|
||||
// swallow the ask as an RFI echo and the picture would freeze until a forced IDR).
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t + Duration::from_millis(10), 1); // gap of one lost frame + RFI
|
||||
assert_eq!(
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(60)),
|
||||
GateVerdict::Present,
|
||||
"the anchor lifts"
|
||||
);
|
||||
assert!(
|
||||
!g.poll(1, t + Duration::from_millis(130)),
|
||||
"the credited climb must not ask again"
|
||||
);
|
||||
assert!(!g.is_holding(), "and must not re-freeze the healed stream");
|
||||
assert_eq!(
|
||||
g.on_decoded(0, false, t + Duration::from_millis(141)),
|
||||
GateVerdict::Present,
|
||||
"healthy P-frames keep presenting"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_climb_beyond_the_credit_is_a_fresh_loss_and_arms() {
|
||||
// The credit covers exactly the gap's frames; a bigger climb means MORE loss than the gap
|
||||
// accounted for (an interleaved partial-frame loss) — that part must still arm and ask.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 2);
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50)); // healed the credited loss
|
||||
assert!(
|
||||
g.poll(3, t + Duration::from_millis(130)),
|
||||
"one uncredited drop → ask"
|
||||
);
|
||||
assert!(g.is_holding(), "and re-arm for the uncredited part");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drop_credit_expires_so_a_late_climb_still_arms() {
|
||||
// A straggler can fill the gap late (no climb ever comes) — the leftover credit must not
|
||||
// linger and mask a genuinely NEW loss later. Past DROP_CREDIT_WINDOW the credit is void.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 1);
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50));
|
||||
let late = t + DROP_CREDIT_WINDOW + Duration::from_millis(1);
|
||||
assert!(
|
||||
g.poll(1, late),
|
||||
"an expired credit no longer absorbs climbs"
|
||||
);
|
||||
assert!(g.is_holding());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credited_climb_keeps_the_unhealed_freezes_original_deadline() {
|
||||
// When the recovery never arrives, consuming the climb must not silence the gate: the
|
||||
// overdue backstop still re-asks — at the deadline the GAP arm set, which is sooner than
|
||||
// the deadline a climb re-arm would have pushed out to.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 1); // RFI fired here; assume its anchor is lost in transit
|
||||
assert!(
|
||||
!g.poll(1, t + Duration::from_millis(130)),
|
||||
"credited climb: no early re-ask"
|
||||
);
|
||||
assert!(g.is_holding(), "still frozen — nothing healed it");
|
||||
assert!(
|
||||
g.poll(1, t + REANCHOR_FREEZE_MAX + Duration::from_millis(1)),
|
||||
"the overdue backstop still re-asks on the gap-arm's own deadline"
|
||||
);
|
||||
assert!(g.is_holding(), "and keeps holding, never resuming to gray");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_no_output_streak_trips_at_three() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
|
||||
@@ -27,15 +27,28 @@
|
||||
//! device pulls per-period) whose prime depth the mic pump sets from measured uplink jitter
|
||||
//! ([`VirtualMic::set_target_depth`]), filling silence when the client isn't talking. WASAPI
|
||||
//! objects are `!Send`, so they live entirely on that thread (mirrors `WasapiLoopbackCapturer`).
|
||||
//!
|
||||
//! **Idle stop (host must be able to SLEEP).** A RUNNING WASAPI stream makes the audio stack
|
||||
//! hold a kernel power request ("An audio stream is currently in use", attributed to the
|
||||
//! target device in `powercfg /requests`) that vetoes system sleep — and this pump is
|
||||
//! host-lifetime, so rendering silence 24/7 kept every idle Punktfunk box awake forever
|
||||
//! (field report 2026-08-12: "doesn't go to sleep anymore; powercfg shows the Steam Streaming
|
||||
//! Microphone"). So after [`IDLE_STOP_AFTER`] of silence-only output the render loop stops the
|
||||
//! stream (`IAudioClient::Stop` — the client stays initialized, the mic ENDPOINT keeps
|
||||
//! existing, only the power request is released) and parks on the queue's condvar; the next
|
||||
//! pushed frame resumes it within one device period. During a session the box is kept awake by
|
||||
//! the session's own `DisplayWakeRequest` (pf-frame), never by this silence.
|
||||
//! `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a virtual audio
|
||||
//! driver misbehaves when its render side pauses.
|
||||
|
||||
use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, SyncSender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use wasapi::{Direction, SampleType, StreamMode, WaveFormat};
|
||||
|
||||
const CHANNELS: u32 = 2;
|
||||
@@ -60,9 +73,30 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 120 / 1000) * BLOCK_ALIGN
|
||||
/// Producer-side overflow headroom (~32 ms) over the render loop's prime threshold when the
|
||||
/// adaptive target drives the ring.
|
||||
const CAP_HEADROOM_BYTES: usize = (SAMPLE_RATE as usize * 32 / 1000) * BLOCK_ALIGN;
|
||||
/// Stop the render stream after this long of silence-only output (unprimed, nothing queued), so
|
||||
/// an idle host releases the audio stack's sleep-blocking power request (see the module docs).
|
||||
/// Long enough that mid-conversation pauses never cycle the stream; resume is one condvar
|
||||
/// notify + `IAudioClient::Start`, well under the jitter buffer's prime depth.
|
||||
const IDLE_STOP_AFTER: Duration = Duration::from_secs(10);
|
||||
/// While the stream is idle-stopped the render thread parks on the queue condvar; this timeout
|
||||
/// only bounds how long a host-shutdown `stop` can go unnoticed (same discipline as the pump's
|
||||
/// `drain_sleep`).
|
||||
const IDLE_WAKE_CHECK: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The mic inject ring plus the wake signal for an idle-stopped render thread: `push` notifies
|
||||
/// on the empty→non-empty transition, which is exactly the moment a stopped stream must resume.
|
||||
type MicQueue = (Mutex<VecDeque<u8>>, Condvar);
|
||||
|
||||
/// `PUNKTFUNK_MIC_ALWAYS_ON=1`: never idle-stop the render stream (pre-2026-08 behaviour — the
|
||||
/// host then blocks system sleep for its whole life). Escape hatch in case a virtual audio
|
||||
/// driver's capture side misbehaves while its render side is paused.
|
||||
fn mic_always_on() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("PUNKTFUNK_MIC_ALWAYS_ON").is_some_and(|v| v != "0"))
|
||||
}
|
||||
|
||||
pub struct WasapiVirtualMic {
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
queue: Arc<MicQueue>,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// False once the render thread has exited (device error or stop) — the pump's reopen signal.
|
||||
alive: Arc<AtomicBool>,
|
||||
@@ -94,7 +128,7 @@ impl WasapiVirtualMic {
|
||||
channels == CHANNELS,
|
||||
"virtual mic is stereo-only (got {channels})"
|
||||
);
|
||||
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||
let queue = Arc::new((Mutex::new(VecDeque::<u8>::new()), Condvar::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let ring = Arc::new(RingShared::default());
|
||||
@@ -144,9 +178,11 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut q) = self.queue.lock() else {
|
||||
let (lock, wake) = &*self.queue;
|
||||
let Ok(mut q) = lock.lock() else {
|
||||
return false;
|
||||
};
|
||||
let was_empty = q.is_empty();
|
||||
q.reserve(pcm.len() * 4);
|
||||
for &s in pcm {
|
||||
q.extend(s.to_le_bytes());
|
||||
@@ -171,6 +207,12 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
.overflow
|
||||
.fetch_add((excess / BLOCK_ALIGN) as u64, Ordering::Relaxed);
|
||||
}
|
||||
drop(q);
|
||||
if was_empty {
|
||||
// Empty→non-empty is the resume moment for an idle-stopped stream (the waiter
|
||||
// re-checks the queue under the lock, so this cannot be a lost wakeup).
|
||||
wake.notify_one();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -179,7 +221,7 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
}
|
||||
|
||||
fn discard(&self) {
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
if let Ok(mut q) = self.queue.0.lock() {
|
||||
q.clear();
|
||||
}
|
||||
}
|
||||
@@ -199,7 +241,7 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
if prime == 0 {
|
||||
return None; // render loop hasn't run yet
|
||||
}
|
||||
let q = self.queue.lock().ok()?;
|
||||
let q = self.queue.0.lock().ok()?;
|
||||
Some((q.len() / BLOCK_ALIGN, prime / BLOCK_ALIGN))
|
||||
}
|
||||
|
||||
@@ -387,7 +429,7 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
}
|
||||
|
||||
fn render_thread(
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
queue: Arc<MicQueue>,
|
||||
stop: Arc<AtomicBool>,
|
||||
shared: Arc<RingShared>,
|
||||
ready: SyncSender<Result<String>>,
|
||||
@@ -461,7 +503,40 @@ fn render_thread(
|
||||
// the target).
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut primed = false;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// Idle stop (see the module docs): `running` mirrors the stream's Start/Stop state, and
|
||||
// `idle_mark` is the queue length last seen while unprimed plus when it was first seen at
|
||||
// that length — IDLE_STOP_AFTER of unprimed output at an UNCHANGED length is the idle
|
||||
// verdict. Keying on the length (not just emptiness) covers a sub-prime tail a vanished
|
||||
// client left behind, while any genuinely new audio moves the length (a push appends a
|
||||
// whole frame and the drop-oldest cap sits above the prime threshold, so an unprimed queue
|
||||
// can never coincidentally return to its marked length) and restarts the window.
|
||||
let always_on = mic_always_on();
|
||||
let mut running = true;
|
||||
let mut idle_mark: Option<(usize, Instant)> = None;
|
||||
'render: while !stop.load(Ordering::Relaxed) {
|
||||
if !running {
|
||||
// Idle-stopped: park on the condvar until mic audio arrives (push notifies on the
|
||||
// empty→non-empty edge); the timeout only keeps `stop` responsive.
|
||||
{
|
||||
let (lock, wake) = &*queue;
|
||||
let mut q = lock.lock().unwrap();
|
||||
while q.is_empty() {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break 'render;
|
||||
}
|
||||
let (guard, _timed_out) = wake.wait_timeout(q, IDLE_WAKE_CHECK).unwrap();
|
||||
q = guard;
|
||||
}
|
||||
}
|
||||
// A resume failure means the endpoint died while we slept — propagate, so the
|
||||
// thread exits, `alive` flips, and the pump reopens (fresh plan) as for any death.
|
||||
audio_client
|
||||
.start_stream()
|
||||
.context("resume render stream")?;
|
||||
running = true;
|
||||
idle_mark = None;
|
||||
tracing::debug!("virtual mic stream resumed (client mic audio arrived)");
|
||||
}
|
||||
// The device signals when it wants more data; finite timeout keeps `stop` responsive.
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
@@ -486,7 +561,7 @@ fn render_thread(
|
||||
// Silence base; overwrite with queued mic PCM once the cushion is primed.
|
||||
buf[..need].fill(0);
|
||||
{
|
||||
let mut q = queue.lock().unwrap();
|
||||
let mut q = queue.0.lock().unwrap();
|
||||
if !primed && q.len() >= prime {
|
||||
primed = true;
|
||||
}
|
||||
@@ -500,6 +575,35 @@ fn render_thread(
|
||||
shared.reprimes.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
if primed {
|
||||
idle_mark = None;
|
||||
} else if !always_on {
|
||||
// Unprimed = this period was pure silence. After IDLE_STOP_AFTER of that at an
|
||||
// unchanged queue length, stop the stream so the box can sleep. Decided under
|
||||
// the queue lock, and only when nothing has arrived for the whole window (see
|
||||
// `idle_mark`'s docs) — a burst landing at the boundary moves the length and
|
||||
// resets the window instead of being cleared. What IS cleared is ≥10 s stale
|
||||
// (the pump's stale-gap discard would drop it before the next real frame
|
||||
// anyway), which keeps "queue empty" ⇔ "nothing to play" for the wait above.
|
||||
match idle_mark {
|
||||
Some((len, since)) if len == q.len() => {
|
||||
if since.elapsed() >= IDLE_STOP_AFTER {
|
||||
q.clear();
|
||||
audio_client
|
||||
.stop_stream()
|
||||
.context("idle-stop render stream")?;
|
||||
running = false;
|
||||
idle_mark = None;
|
||||
tracing::debug!(
|
||||
"virtual mic stream idle-stopped (releases the sleep-blocking \
|
||||
audio power request; next mic frame resumes it)"
|
||||
);
|
||||
continue 'render;
|
||||
}
|
||||
}
|
||||
_ => idle_mark = Some((q.len(), Instant::now())),
|
||||
}
|
||||
}
|
||||
}
|
||||
render_client
|
||||
.write_to_device(space, &buf[..need], None)
|
||||
|
||||
@@ -396,6 +396,12 @@ fn real_main() -> Result<()> {
|
||||
// restored (crash/kill/power loss) — before any new session touches the topology.
|
||||
#[cfg(target_os = "windows")]
|
||||
monitor_devnode::startup_recover();
|
||||
// The same recovery for the experimental `edid_lock` axis: unpin AMD connector
|
||||
// emulation a previous host locked and never unlocked — pinned emulation outlives
|
||||
// the process (and can outlive a reboot), so this is the only thing standing between
|
||||
// a crash and a permanently-emulated connector.
|
||||
#[cfg(target_os = "windows")]
|
||||
pf_win_display::adl_emul::startup_recover();
|
||||
// The same recovery for the DEFAULT Exclusive path: a previous host that died holding a
|
||||
// CCD isolate left the operator's panels deactivated with nothing to put them back (the
|
||||
// restore snapshot was process memory). Runs AFTER the devnode leg so re-enabled
|
||||
|
||||
@@ -82,6 +82,10 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
|
||||
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||
"ddc_power_off".into(),
|
||||
"pnp_disable_monitors".into(),
|
||||
// EXPERIMENTAL, Windows + AMD-driver-only in effect (the ADL connector-emulation lever;
|
||||
// inert wherever `atiadlxx.dll` is absent). The console additionally gates the toggle's
|
||||
// VISIBILITY on an AMD GPU being present, so only the hosts it can act on ever see it.
|
||||
"edid_lock".into(),
|
||||
];
|
||||
// `capture_monitor` routes every session to the MIRROR backend, and that backend exists only on
|
||||
// Linux — `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]`, because `pf-capture`
|
||||
@@ -464,6 +468,7 @@ pub(crate) async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutReques
|
||||
store.game_session(),
|
||||
store.ddc_power_off(),
|
||||
store.pnp_disable_monitors(),
|
||||
store.edid_lock(),
|
||||
store.get().capture_monitor,
|
||||
);
|
||||
if let Err(e) = store.set(policy) {
|
||||
|
||||
@@ -1542,9 +1542,11 @@ async fn display_settings_surface() {
|
||||
assert!(enforced.contains(&"mode_conflict"));
|
||||
assert!(enforced.contains(&"identity"));
|
||||
assert!(enforced.contains(&"layout"));
|
||||
// The experimental DDC/CI + PnP-disable axes are acted on (Windows exclusive-isolate path).
|
||||
// The experimental DDC/CI + PnP-disable + EDID-lock axes are acted on (Windows
|
||||
// exclusive-isolate path; edid_lock additionally needs an AMD driver to do anything).
|
||||
assert!(enforced.contains(&"ddc_power_off"));
|
||||
assert!(enforced.contains(&"pnp_disable_monitors"));
|
||||
assert!(enforced.contains(&"edid_lock"));
|
||||
}
|
||||
|
||||
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
||||
|
||||
@@ -3323,6 +3323,21 @@ PunktfunkStatus punktfunk_connection_note_frame_index(const PunktfunkConnection
|
||||
bool *gap_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
|
||||
// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
|
||||
// A client with a post-loss display freeze passes the width to
|
||||
// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
|
||||
// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
|
||||
// race — see the gate function's doc).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
|
||||
PunktfunkStatus punktfunk_connection_note_frame_index_ex(const PunktfunkConnection *c,
|
||||
uint32_t frame_index,
|
||||
uint32_t *gap_width_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
|
||||
@@ -3446,6 +3461,18 @@ void punktfunk_reanchor_gate_free(ReanchorGate *g);
|
||||
// `g` is a valid gate handle.
|
||||
void punktfunk_reanchor_gate_arm(ReanchorGate *g);
|
||||
|
||||
// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
|
||||
// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
|
||||
// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
|
||||
// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
|
||||
// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
|
||||
// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
|
||||
// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
|
||||
//
|
||||
// # Safety
|
||||
// `g` is a valid gate handle.
|
||||
void punktfunk_reanchor_gate_arm_expecting_drops(ReanchorGate *g, uint64_t expected_drops);
|
||||
|
||||
// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
|
||||
// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
|
||||
// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
|
||||
|
||||
@@ -69,13 +69,55 @@ fn hr_success(hr: NTSTATUS) -> bool {
|
||||
hr >= 0
|
||||
}
|
||||
|
||||
/// The `IddCxSetRealtimeGPUPriority` A/B knob: `PFVD_NO_RT_GPU` (any value, MACHINE env — the
|
||||
/// driver runs in WUDFHost as LocalService, so `setx /M PFVD_NO_RT_GPU 1` + a device restart)
|
||||
/// turns the priority raise OFF. Read once per process, the [`crate::log`] `OnceLock` pattern.
|
||||
fn realtime_gpu_priority_enabled() -> bool {
|
||||
/// How (whether) the swap-chain processing device's GPU scheduling is raised — the
|
||||
/// interval-stutter program's A/B ladder, resolved once per WUDFHost process from the MACHINE
|
||||
/// environment (the driver runs as LocalService: `setx /M PFVD_RT_GPU 1` + a device restart
|
||||
/// applies it; the [`crate::log`] `OnceLock` pattern).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RtGpuMode {
|
||||
/// No raise at all — canonical-IDD scheduling, and the DEFAULT since the 2026-08 field
|
||||
/// conviction (see [`rt_gpu_mode`]).
|
||||
Off,
|
||||
/// `PFVD_RT_GPU=thread`: `IDXGIDevice::SetGPUThreadPriority(7)` — the graduated middle rung.
|
||||
/// A per-device GPU *thread* priority inside the band ordinary applications can also reach,
|
||||
/// so it biases the scheduler without the REALTIME rung's unreachable-preemption hazard. Not
|
||||
/// the default because it is unmeasured here — and the host process measured the same call as
|
||||
/// "no help" for its encode-starvation case (`pf-frame/src/dxgi.rs`) — so it exists purely as
|
||||
/// the field-A/B rung between OFF and REALTIME.
|
||||
GpuThread,
|
||||
/// `PFVD_RT_GPU=<anything else>`: the IddCx 1.9 `IddCxSetRealtimeGPUPriority` DDI — the old
|
||||
/// default-ON behavior, "higher priority than any regular application can set".
|
||||
Realtime,
|
||||
}
|
||||
|
||||
/// Resolve the [`RtGpuMode`] ladder. Default **OFF**: no canonical IDD driver raises its
|
||||
/// swap-chain device's GPU priority, and a 2026-08 field A/B on an RX 9070 XT convicted our
|
||||
/// REALTIME raise as the amplifier of a metronomic ~1.8 s capture-stall class — every ~1.8 s
|
||||
/// EVERY process's presents stopped for 150–800 ms while the GPU stayed responsive (a starved
|
||||
/// present path, not a stalled engine); clearing the raise removed the metronome entirely.
|
||||
/// The raise was added as speculative "outranks GPU contention" hardening (branch-2 of the
|
||||
/// disturbance-immunity program) whose CPU half — MMCSS / TIME_CRITICAL on this thread — is the
|
||||
/// part that addressed the observed delivery holes and REMAINS in force; the GPU half never had
|
||||
/// a measured win and now has a measured loss, so it is opt-in on every vendor (NVIDIA is
|
||||
/// untested in either direction, and a vendor-split default would double the support matrix on
|
||||
/// no evidence).
|
||||
///
|
||||
/// Precedence: the old opt-OUT (`PFVD_NO_RT_GPU`, any value) wins over the new opt-IN — a field
|
||||
/// box that carried it through the default-ON era must keep meaning OFF no matter what is set
|
||||
/// beside it. Both directions stay A/B-able without a rebuild.
|
||||
fn rt_gpu_mode() -> RtGpuMode {
|
||||
use std::sync::OnceLock;
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("PFVD_NO_RT_GPU").is_none())
|
||||
static MODE: OnceLock<RtGpuMode> = OnceLock::new();
|
||||
*MODE.get_or_init(|| {
|
||||
if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
|
||||
return RtGpuMode::Off;
|
||||
}
|
||||
match std::env::var_os("PFVD_RT_GPU") {
|
||||
None => RtGpuMode::Off,
|
||||
Some(v) if v.eq_ignore_ascii_case("thread") => RtGpuMode::GpuThread,
|
||||
Some(_) => RtGpuMode::Realtime,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A minimal newtype to move a raw pointer / handle across the thread boundary. The wrapped value is a
|
||||
@@ -252,32 +294,47 @@ impl SwapChainProcessor {
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
// IddCx 1.9 realtime GPU scheduling priority for the processing device (stall-immunity
|
||||
// program, branch-2 hardening): swap-chain buffer processing outruns ordinary GPU
|
||||
// contention — "higher priority than any regular application can set". The slot is
|
||||
// guaranteed populated (`IddMinimumVersionRequired = 10`, lib.rs); the DDI itself may
|
||||
// still decline (e.g. E_NOTIMPL on pre-WDDM-3.0 hardware) — best-effort, never fatal.
|
||||
// Called while our borrowed device reference is still alive; IddCx uses it synchronously.
|
||||
// GPU-scheduling raise for the swap-chain processing device — OPT-IN, default none (see
|
||||
// [`rt_gpu_mode`] for the field conviction that inverted the old default-ON). What used
|
||||
// to be sold as stall immunity ("swap-chain buffer processing outruns ordinary GPU
|
||||
// contention") preempts the game's and DWM's own queues at a level apps can't reach, and
|
||||
// on an AMD field box that manifested as the metronomic content-starving stall class the
|
||||
// stall program spent weeks attributing. The CPU-side half of that hardening (MMCSS /
|
||||
// TIME_CRITICAL, above) is untouched — it addressed the delivery holes actually observed.
|
||||
//
|
||||
// Knobbed (PFVD_NO_RT_GPU, machine env, read in the WUDFHost process): no canonical IDD
|
||||
// driver raises this priority, and it preempts the game's and DWM's own queues at a level
|
||||
// apps can't reach — a candidate aggravator in the interval-stutter program that must
|
||||
// stay A/B-able on a field box without a rebuild. Default ON (today's behavior).
|
||||
if set_ok && realtime_gpu_priority_enabled() {
|
||||
let mut rt = pod_init!(IDARG_IN_SETREALTIMEGPUPRIORITY);
|
||||
rt.pDevice = dxgi_device.as_raw().cast();
|
||||
// SAFETY: driver is loaded; `swap_chain` is the live assigned swap-chain whose device
|
||||
// bind just succeeded; `rt.pDevice` is that same bound DXGI device, alive across the
|
||||
// synchronous call; `rt` points to valid local storage.
|
||||
let hr = unsafe { wdk_iddcx::IddCxSetRealtimeGPUPriority(swap_chain, &rt) };
|
||||
if hr_success(hr) {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: processing device raised to REALTIME GPU priority (target={target_id})"
|
||||
);
|
||||
} else {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: realtime GPU priority declined ({hr:#x}) — normal scheduling (target={target_id})"
|
||||
);
|
||||
// Both raises are best-effort, never fatal, and issued while our borrowed device
|
||||
// reference is still alive (IddCx uses it synchronously; the DXGI call is direct). The
|
||||
// REALTIME slot is guaranteed populated (`IddMinimumVersionRequired = 10`, lib.rs), but
|
||||
// the DDI may still decline (e.g. E_NOTIMPL on pre-WDDM-3.0 hardware).
|
||||
if set_ok {
|
||||
match rt_gpu_mode() {
|
||||
RtGpuMode::Off => {}
|
||||
RtGpuMode::GpuThread => {
|
||||
// SAFETY: `dxgi_device` is the live device just bound to the swap-chain; the
|
||||
// call takes a scalar in the documented −7..=7 band and retains nothing.
|
||||
let res = unsafe { dxgi_device.SetGPUThreadPriority(7) };
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: GPU thread priority +7 (PFVD_RT_GPU=thread) — ok={} (target={target_id})",
|
||||
res.is_ok()
|
||||
);
|
||||
}
|
||||
RtGpuMode::Realtime => {
|
||||
let mut rt = pod_init!(IDARG_IN_SETREALTIMEGPUPRIORITY);
|
||||
rt.pDevice = dxgi_device.as_raw().cast();
|
||||
// SAFETY: driver is loaded; `swap_chain` is the live assigned swap-chain whose
|
||||
// device bind just succeeded; `rt.pDevice` is that same bound DXGI device,
|
||||
// alive across the synchronous call; `rt` points to valid local storage.
|
||||
let hr = unsafe { wdk_iddcx::IddCxSetRealtimeGPUPriority(swap_chain, &rt) };
|
||||
if hr_success(hr) {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: processing device raised to REALTIME GPU priority (PFVD_RT_GPU) (target={target_id})"
|
||||
);
|
||||
} else {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: realtime GPU priority declined ({hr:#x}) — normal scheduling (target={target_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Release our borrowed device reference — IddCx holds its own now, or we gave up. (Explicit drop
|
||||
|
||||
@@ -9,10 +9,15 @@ authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The `adl-emul` subcommand is a printer around `pf_win_display::adl_emul` — one ADL FFI surface
|
||||
# shared with the host's `edid_lock` display-policy axis, so the probe a reporter runs and the
|
||||
# toggle the console flips exercise byte-identical driver calls.
|
||||
pf-win-display = { path = "../../crates/pf-win-display" }
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Devices_Display",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_LibraryLoader",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! `adl-emul` — the AMD ADL EDID-emulation probe (immunity design doc §3's "probe once, log rc",
|
||||
//! promoted to a field A/B after the third RX 9070 XT standby-sink case).
|
||||
//!
|
||||
//! The implementation lives in `pf_win_display::adl_emul` — one FFI surface shared with the
|
||||
//! host's `edid_lock` display-policy axis, so the probe a reporter runs and the toggle the
|
||||
//! console flips exercise byte-identical driver calls. This subcommand is the bench-line
|
||||
//! printer + exit-code contract around it:
|
||||
//!
|
||||
//! * `adl-emul` — read-only: caps, board layout, per-connector state.
|
||||
//! * `adl-emul --lock [--connector N]` — pin the live EDID + `ADL_EMUL_MODE_ALWAYS` (occupied
|
||||
//! connectors only unless `--connector` names one).
|
||||
//! * `adl-emul --unlock [--connector N]` — `ADL_EMUL_MODE_OFF` + remove the pinned EDID.
|
||||
//!
|
||||
//! Every call prints the bench's `epoch_ms op target took_ms ok` line plus `rc=` (decoded): the
|
||||
//! rc IS the deliverable — `ADL2_Adapter_EDIDManagement_Caps` answering `supported=1` with
|
||||
//! `--lock` returning `ADL_OK` on a consumer RX card kills the Pro-gating assumption, and a
|
||||
//! locked connector during a stream with the sink asleep is the direct A/B for the metronomic
|
||||
//! stall. `--unlock` (or a driver reinstall) restores; emulation state can persist across
|
||||
//! reboots, so a `--lock` run must always be paired with a later `--unlock`.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub use pf_win_display::adl_emul::EmulAction;
|
||||
use pf_win_display::adl_emul::{run as adl_run, RunOutcome};
|
||||
|
||||
fn epoch_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn run(action: EmulAction, connector_filter: Option<i32>) -> ! {
|
||||
let outcome = adl_run(action, connector_filter);
|
||||
for r in outcome.records() {
|
||||
println!("{} {r}", epoch_ms());
|
||||
}
|
||||
match outcome {
|
||||
RunOutcome::NoAdl => {
|
||||
eprintln!(
|
||||
"atiadlxx.dll not loadable (or an export is missing) — not an AMD driver \
|
||||
install; the ADL emulation lever does not exist on this box"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
RunOutcome::InitFailed(_) => std::process::exit(1),
|
||||
RunOutcome::Done(_) => std::process::exit(0),
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
//! * `modeset` — Class 1: a same-mode `ChangeDisplaySettingsExW(CDS_RESET)` re-commit — a
|
||||
//! Level-Two modeset-class DDI entry that idles the whole adapter ("the graphics hardware is
|
||||
//! idle") without changing anything Win32-visible.
|
||||
//! * `adl-emul` — not a disturbance but the LEVER probe for the standby-sink class: AMD ADL
|
||||
//! connector-emulation caps/state, and `--lock`/`--unlock` to pin the live EDID +
|
||||
//! `ADL_EMUL_MODE_ALWAYS` (the software HPD-dummy experiment — see `adl.rs`).
|
||||
//!
|
||||
//! Every operation prints `epoch_ms op target duration_ms result` so stalls in a concurrent
|
||||
//! stream's host.log correlate line-for-line. The per-op duration is itself measurement: it is
|
||||
@@ -18,6 +21,7 @@
|
||||
//!
|
||||
//! Usage: `display-disturb ddc [--interval-ms 2000] [--caps] [--vcp 0x10]`
|
||||
//! `display-disturb modeset [--interval-ms 2000]`
|
||||
//! `display-disturb adl-emul [--lock|--unlock] [--connector N]`
|
||||
|
||||
// Unsafe-proof program: every `unsafe {}` in this tool carries a `// SAFETY:` proof.
|
||||
|
||||
@@ -32,6 +36,9 @@ fn main() {
|
||||
win::main()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod adl;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod win {
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
@@ -76,14 +83,17 @@ mod win {
|
||||
interval: Duration,
|
||||
caps: bool,
|
||||
vcp: u8,
|
||||
emul: crate::adl::EmulAction,
|
||||
connector: Option<i32>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Args {
|
||||
let argv: Vec<String> = std::env::args().collect();
|
||||
let mode = argv.get(1).cloned().unwrap_or_default();
|
||||
if !matches!(mode.as_str(), "ddc" | "modeset" | "extend") {
|
||||
if !matches!(mode.as_str(), "ddc" | "modeset" | "extend" | "adl-emul") {
|
||||
eprintln!(
|
||||
"usage: display-disturb <ddc|modeset|extend> [--interval-ms N] [--caps] [--vcp 0xNN]"
|
||||
"usage: display-disturb <ddc|modeset|extend|adl-emul> [--interval-ms N] [--caps] \
|
||||
[--vcp 0xNN] [--lock|--unlock] [--connector N]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -92,6 +102,8 @@ mod win {
|
||||
interval: Duration::from_millis(2000),
|
||||
caps: false,
|
||||
vcp: 0x10, // brightness — universally implemented, read-only harmless
|
||||
emul: crate::adl::EmulAction::Probe,
|
||||
connector: None,
|
||||
};
|
||||
let mut i = 2;
|
||||
while i < argv.len() {
|
||||
@@ -108,6 +120,16 @@ mod win {
|
||||
let s = argv.get(i).map(String::as_str).unwrap_or("0x10");
|
||||
a.vcp = u8::from_str_radix(s.trim_start_matches("0x"), 16).unwrap_or(0x10);
|
||||
}
|
||||
"--lock" => a.emul = crate::adl::EmulAction::Lock,
|
||||
"--unlock" => a.emul = crate::adl::EmulAction::Unlock,
|
||||
"--connector" => {
|
||||
i += 1;
|
||||
a.connector = argv.get(i).and_then(|s| s.parse().ok());
|
||||
if a.connector.is_none() {
|
||||
eprintln!("--connector needs a numeric connector index");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown arg: {other}");
|
||||
std::process::exit(2);
|
||||
@@ -129,6 +151,7 @@ mod win {
|
||||
match args.mode.as_str() {
|
||||
"ddc" => ddc_loop(&args),
|
||||
"extend" => extend_once(),
|
||||
"adl-emul" => crate::adl::run(args.emul, args.connector),
|
||||
_ => modeset_loop(&args),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +217,11 @@
|
||||
"display_pnp_disabled": "Aus",
|
||||
"display_pnp_enabled": "Ein",
|
||||
"display_pnp_badge": "Monitor-Geräte deaktiviert (PnP)",
|
||||
"display_edid": "Monitor-Identität beim Streamen festhalten (EDID)",
|
||||
"display_edid_help": "Nur AMD-Grafikkarten unter Windows, wirkt bei Topologie Exklusiv. Während des Streams weist der Host den AMD-Treiber an, jeden angeschlossenen Monitor mit seiner aktuellen Identität (EDID) weiterhin als vorhanden zu behandeln — auch wenn der Monitor schläft; das Software-Äquivalent eines Dummy-Steckers. Das verhindert, dass der Treiber die Verbindung eines schlafenden Monitors periodisch abfragt, was auf manchen Systemen ein rhythmisches Stottern im Sekundentakt verursacht, solange nur das virtuelle Display aktiv ist. Beim Stream-Ende wird die Fixierung entfernt; stürzt der Host mitten im Stream ab, geschieht das beim nächsten Start. Verhält sich ein Monitor danach seltsam, diese Option ausschalten und den Monitor kurz ab- und wieder anstecken (im schlimmsten Fall den Grafiktreiber neu installieren).",
|
||||
"display_edid_disabled": "Aus",
|
||||
"display_edid_enabled": "Ein",
|
||||
"display_edid_badge": "Monitor-Identität fixiert (EDID)",
|
||||
"display_identity_shared": "Geteilt",
|
||||
"display_identity_per_client": "Pro Client",
|
||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||
|
||||
@@ -217,6 +217,11 @@
|
||||
"display_pnp_disabled": "Off",
|
||||
"display_pnp_enabled": "On",
|
||||
"display_pnp_badge": "Monitor devices disabled (PnP)",
|
||||
"display_edid": "Pin monitor identity while streaming (EDID)",
|
||||
"display_edid_help": "AMD graphics cards on Windows only, takes effect with Exclusive topology. While streaming, the host tells the AMD driver to keep treating each connected monitor as present with its current identity (EDID), even while the monitor sleeps — the software equivalent of a dummy plug. This stops the driver from periodically probing a sleeping monitor's connection, which on some setups causes a rhythmic stutter every couple of seconds while the virtual display is the only active one. The pin is removed when the stream ends; if the host crashes mid-stream, it is removed the next time the host starts. If a monitor misbehaves afterwards, turn this off and unplug/replug the monitor (or reinstall the graphics driver in the worst case).",
|
||||
"display_edid_disabled": "Off",
|
||||
"display_edid_enabled": "On",
|
||||
"display_edid_badge": "Monitor identity pinned (EDID)",
|
||||
"display_identity_shared": "Shared",
|
||||
"display_identity_per_client": "Per client",
|
||||
"display_identity_per_client_mode": "Per client + resolution",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useSetDisplaySettings,
|
||||
useUpdateCustomPreset,
|
||||
} from "@/api/gen/display/display";
|
||||
import { useListGpus } from "@/api/gen/gpu/gpu";
|
||||
import type {
|
||||
ApiDisplayInfo,
|
||||
CustomPreset,
|
||||
@@ -341,6 +342,11 @@ export const DisplayForm: FC<{
|
||||
}) => {
|
||||
const qc = useQueryClient();
|
||||
const { confirm, promptText } = useDialogs();
|
||||
// The EDID-lock toggle is gated on an AMD GPU being present — the axis is the AMD driver's
|
||||
// ADL connector-emulation lever and exists nowhere else. GPUs don't hot-swap; one fetch with
|
||||
// the section's lifetime is plenty (no refetch interval).
|
||||
const gpus = useListGpus();
|
||||
const amdHost = (gpus.data?.gpus ?? []).some((g) => g.vendor === "amd");
|
||||
const createPreset = useCreateCustomPreset();
|
||||
const updatePreset = useUpdateCustomPreset();
|
||||
const deletePreset = useDeleteCustomPreset();
|
||||
@@ -393,6 +399,7 @@ export const DisplayForm: FC<{
|
||||
game_session: draft.game_session ?? "auto",
|
||||
ddc_power_off: draft.ddc_power_off ?? false,
|
||||
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
|
||||
edid_lock: draft.edid_lock ?? false,
|
||||
// Which screen we stream is not a display-behavior axis at all — swapping the
|
||||
// streamed screen out from under the operator because they changed a preset would be
|
||||
// the worst kind of surprise. From the SERVER, not the draft (see serverCaptureMonitor).
|
||||
@@ -415,6 +422,7 @@ export const DisplayForm: FC<{
|
||||
// The experimental axes aren't part of a preset — keep the current settings.
|
||||
ddc_power_off: draft.ddc_power_off ?? false,
|
||||
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
|
||||
edid_lock: draft.edid_lock ?? false,
|
||||
// Nor is the streamed screen: this builds a FRESH policy object rather than spreading
|
||||
// the draft, so anything not named here is silently dropped — which is exactly how
|
||||
// applying a saved preset used to switch a mirroring host back to a virtual display
|
||||
@@ -839,6 +847,21 @@ export const DisplayForm: FC<{
|
||||
busy={busy}
|
||||
onSet={(on) => applyAxis({ pnp_disable_monitors: on })}
|
||||
/>
|
||||
{/* AMD hosts only: the axis is the driver's ADL connector-emulation lever, which
|
||||
exists nowhere else — a toggle NVIDIA/Intel operators could flip but that can
|
||||
never do anything would be the "saved and then did nothing" trap the enforced
|
||||
list exists to prevent. */}
|
||||
{amdHost && (
|
||||
<ExperimentalToggle
|
||||
label={m.display_edid()}
|
||||
help={m.display_edid_help()}
|
||||
value={draft.edid_lock ?? false}
|
||||
offLabel={m.display_edid_disabled()}
|
||||
onLabel={m.display_edid_enabled()}
|
||||
busy={busy}
|
||||
onSet={(on) => applyAxis({ edid_lock: on })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* What's in force right now — read from the API's `effective`, not from the local draft.
|
||||
Deriving it from the draft meant the row restated the operator's unsaved edits back to
|
||||
@@ -874,6 +897,9 @@ export const DisplayForm: FC<{
|
||||
{(draft.pnp_disable_monitors ?? false) && (
|
||||
<Badge variant="outline">{m.display_pnp_badge()}</Badge>
|
||||
)}
|
||||
{(draft.edid_lock ?? false) && (
|
||||
<Badge variant="outline">{m.display_edid_badge()}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="max-w-prose text-xs text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user