Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3058e286 | ||
|
|
cb8c17d948 | ||
|
|
7aad46e54b | ||
|
|
6a9a457f62 | ||
|
|
98e040fd01 | ||
|
|
5174a59832 | ||
|
|
c2a6d30d7b | ||
|
|
20de58a78a | ||
|
|
97b2c01ac1 | ||
|
|
29473d6280 | ||
|
|
b6acbd096e | ||
|
|
d63e913f52 |
@@ -392,7 +392,7 @@ pub(super) fn run_async(
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||
@@ -822,8 +822,21 @@ fn feed_ready(
|
||||
}
|
||||
}
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
|
||||
// here leaked one of the codec's input buffers per occurrence — we forget it and the
|
||||
// codec never frees what it never received, so the pipeline quietly runs out of input
|
||||
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
|
||||
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
|
||||
// keyframe request behind it, unlike every sibling path here.
|
||||
//
|
||||
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
|
||||
// advertised is in no state to be fed the rest of the parked queue this pass, and
|
||||
// retrying the same index against every parked AU would burn the whole backlog. The
|
||||
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
|
||||
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
|
||||
free_inputs.push_front(idx);
|
||||
pending_aus.push_front(frame);
|
||||
break;
|
||||
};
|
||||
let au = &frame.data;
|
||||
if au.len() > dst.len() {
|
||||
|
||||
@@ -115,9 +115,14 @@ pub(crate) struct DecodeOptions {
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
|
||||
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
|
||||
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
|
||||
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
|
||||
///
|
||||
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
|
||||
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
|
||||
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
|
||||
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
|
||||
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
|
||||
//! could be wrong in one direction forever;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
@@ -20,6 +22,7 @@
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -36,9 +39,9 @@ use super::vsync::VsyncShared;
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||
/// signal to widen, not stutter.
|
||||
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
|
||||
/// as a measured latch beyond one panel period (see the adaptation in
|
||||
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||
@@ -71,6 +74,26 @@ fn latch_margin_ns() -> Option<i64> {
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
|
||||
/// SurfaceFlinger more work.
|
||||
///
|
||||
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
|
||||
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
|
||||
/// budget before the display has consumed anything, and the presenter then releases faster than
|
||||
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
|
||||
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
|
||||
/// ground truth about what actually reached glass, so it bounds the prediction.
|
||||
///
|
||||
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
|
||||
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
|
||||
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
|
||||
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
|
||||
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
|
||||
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
|
||||
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
|
||||
/// exactly as before — best-effort, never worse than not having it.
|
||||
const UNDISPLAYED_CAP: i32 = 6;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
@@ -121,6 +144,14 @@ struct InFlight {
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
|
||||
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
|
||||
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
|
||||
/// confirm side on the codec's callback thread, once per frame each.
|
||||
undisplayed: AtomicI32,
|
||||
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
|
||||
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
|
||||
confirms: AtomicBool,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
@@ -147,11 +178,23 @@ impl PresentMeter {
|
||||
codec_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
}),
|
||||
undisplayed: AtomicI32::new(0),
|
||||
confirms: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
///
|
||||
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
|
||||
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
|
||||
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
self.confirms.store(true, Ordering::Relaxed);
|
||||
let _ = self
|
||||
.undisplayed
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
Some((v - 1).max(0))
|
||||
});
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -164,6 +207,26 @@ impl PresentMeter {
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
|
||||
fn note_released(&self) {
|
||||
self.undisplayed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Releases still unconfirmed, and whether confirms happen on this device at all.
|
||||
fn outstanding(&self) -> (i32, bool) {
|
||||
(
|
||||
self.undisplayed.load(Ordering::Relaxed),
|
||||
self.confirms.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
|
||||
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
|
||||
/// them. Never stall the stream on a ledger we cannot audit.
|
||||
fn forgive_outstanding(&self) {
|
||||
self.undisplayed.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
|
||||
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
|
||||
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
|
||||
@@ -239,6 +302,13 @@ pub(super) struct Presenter {
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
/// Pump passes that held a frame back because too many earlier releases were still
|
||||
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
|
||||
/// the signature of a presenter out-running its display.
|
||||
queue_waits: u64,
|
||||
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
|
||||
/// simply stopped coming. `None` while the rail is down.
|
||||
backed_up_since: Option<i64>,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||
@@ -280,6 +350,8 @@ impl Presenter {
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
queue_waits: 0,
|
||||
backed_up_since: None,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
margin_ns,
|
||||
@@ -334,6 +406,7 @@ impl Presenter {
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
meter: &PresentMeter,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
@@ -346,6 +419,10 @@ impl Presenter {
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
|
||||
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
|
||||
// rather than how often a frame happened to be ready.
|
||||
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
@@ -373,9 +450,12 @@ impl Presenter {
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() {
|
||||
if self.inflight.is_some() || backlogged {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
if backlogged {
|
||||
self.queue_waits += 1;
|
||||
}
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
@@ -412,6 +492,7 @@ impl Presenter {
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
meter.note_released();
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
@@ -422,6 +503,33 @@ impl Presenter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
|
||||
///
|
||||
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
|
||||
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
|
||||
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
|
||||
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
|
||||
/// rather than allowed to wedge the stream.
|
||||
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
|
||||
let (outstanding, confirms_live) = meter.outstanding();
|
||||
if !confirms_live || outstanding < UNDISPLAYED_CAP {
|
||||
self.backed_up_since = None;
|
||||
return false;
|
||||
}
|
||||
match self.backed_up_since {
|
||||
Some(t) if now_ns - t > STALE_REOPEN_NS => {
|
||||
meter.forgive_outstanding();
|
||||
self.backed_up_since = None;
|
||||
self.forced += 1;
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.backed_up_since.get_or_insert(now_ns);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
@@ -434,7 +542,9 @@ impl Presenter {
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
|
||||
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
|
||||
/// `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
@@ -462,14 +572,15 @@ impl Presenter {
|
||||
let circ = clock.and_then(|c| {
|
||||
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||
});
|
||||
let latch_samples = latch.len();
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
|
||||
let (outstanding, _) = meter.outstanding();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
qWait={} unconfirmed={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
@@ -480,6 +591,8 @@ impl Presenter {
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
self.queue_waits,
|
||||
outstanding,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
@@ -493,25 +606,48 @@ impl Presenter {
|
||||
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ms,
|
||||
panel_ns as f64 / 1e6,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
|
||||
// late and coalesces the next frame into `paced`) mean this device's SF does need
|
||||
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
|
||||
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
|
||||
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
|
||||
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
|
||||
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
//
|
||||
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
|
||||
// evictions — a second frame decoding while one is held — which happen whenever the
|
||||
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
|
||||
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
|
||||
// of pure display latency the P2e sweep had just measured away.
|
||||
let latch_p50_ns = (latch_p50 * 1e6) as i64;
|
||||
if !self.margin_pinned
|
||||
&& self.margin_ns < LATCH_MARGIN_NS
|
||||
&& panel_ns > 0
|
||||
&& latch_samples >= 8
|
||||
&& latch_p50_ns > panel_ns + self.margin_ns
|
||||
{
|
||||
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||
log::warn!(
|
||||
"presenter: {} latch misses in 1s — margin widened to {}us",
|
||||
self.paced_drops,
|
||||
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
|
||||
latch_p50,
|
||||
panel_ns as f64 / 1e6,
|
||||
self.margin_ns / 1_000
|
||||
);
|
||||
}
|
||||
if self.queue_waits > 0 {
|
||||
log::warn!(
|
||||
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
|
||||
OnFrameRendered (the display is not keeping up with the release rate)",
|
||||
self.queue_waits,
|
||||
outstanding
|
||||
);
|
||||
}
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
self.queue_waits = 0;
|
||||
circ
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ pub(super) struct VsyncShared {
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
|
||||
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
|
||||
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
|
||||
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
@@ -231,6 +233,11 @@ struct CallbackCtx {
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
|
||||
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
|
||||
/// doc) — and its streak state is nobody else's business; only the settled period is
|
||||
/// published, to `shared.panel_period_ns`.
|
||||
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
@@ -240,22 +247,25 @@ impl CallbackCtx {
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
|
||||
// only honest witness to what the panel is doing — the configured mode is not (under a
|
||||
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
|
||||
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
|
||||
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
|
||||
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
let mut grid = self.panel.get();
|
||||
if grid.observe(spacing) {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(grid.period_ns(), Ordering::Relaxed);
|
||||
log::info!(
|
||||
"vsync: panel grid now {:.2}ms",
|
||||
grid.period_ns() as f64 / 1e6
|
||||
);
|
||||
}
|
||||
self.panel.set(grid);
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
@@ -372,8 +382,9 @@ pub(super) struct VsyncClock {
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) — the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
|
||||
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
@@ -383,11 +394,9 @@ impl VsyncClock {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
panel_period_ns: AtomicI64::new(
|
||||
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
|
||||
),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -408,6 +417,7 @@ impl VsyncClock {
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
|
||||
@@ -175,6 +175,46 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
// MARK: Escape-drop re-lock
|
||||
//
|
||||
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape — the platform's
|
||||
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
|
||||
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
|
||||
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
|
||||
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
|
||||
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
|
||||
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
|
||||
// (⌘⎋, ⌃⌥⇧Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
|
||||
// is already false when their drop is observed and none of them are fought here.
|
||||
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
|
||||
/// — never having been granted one means the scene doesn't qualify, not that Esc took it.
|
||||
/// Cleared when capture ends, so each capture starts from a clean slate.
|
||||
private var pointerLockWasEngaged = false
|
||||
/// Attempts spent in the current re-lock burst, and when the burst began.
|
||||
private var pointerRelockAttempt = 0
|
||||
private var pointerRelockBurstStart: CFTimeInterval = 0
|
||||
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
|
||||
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
|
||||
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
|
||||
/// teleporting the host's to the pointer's absolute position.
|
||||
private var pointerRelockPending = false
|
||||
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
|
||||
/// presents the system with a genuine false→true transition instead of re-asserting a value it
|
||||
/// already holds. See `requestPointerRelock()`.
|
||||
private var pointerLockForcedOff = false
|
||||
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
|
||||
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
|
||||
/// to today's click-to-recapture, rather than retrying forever.
|
||||
private static let pointerRelockAttemptLimit = 3
|
||||
private static let pointerRelockBurstWindow: CFTimeInterval = 2
|
||||
/// Gap between attempts in a burst — long enough for the system to answer the previous
|
||||
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
|
||||
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
|
||||
/// next attempt evaluates.
|
||||
private static let pointerRelockRetryDelay: TimeInterval = 0.2
|
||||
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
|
||||
/// so the system observes a real transition instead of coalescing the flip away.
|
||||
private static let pointerLockForcedOffHold: TimeInterval = 0.05
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -260,7 +300,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock }
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
|
||||
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||
|
||||
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
|
||||
@@ -383,6 +423,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// is the exact mirror of the GCMouse handlers, which fire only while locked.
|
||||
streamView.onPointerMoveAbs = { [weak self] p in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
|
||||
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
|
||||
// resume. Motion only — BUTTONS still forward (they carry no position, so a click during
|
||||
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
|
||||
guard !self.pointerRelockPending else { return }
|
||||
self.inputCapture?.sendMouseAbs(
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
@@ -693,6 +738,24 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// change and capture toggle. Main queue.
|
||||
private func syncPointerLock() {
|
||||
let locked = pointerLockEngaged() == true
|
||||
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
|
||||
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
|
||||
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
|
||||
// duration to win a lock that isn't coming. A first grant is already driven by the chain
|
||||
// engage in setCaptured/viewDidAppear.
|
||||
if locked {
|
||||
pointerLockWasEngaged = true
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
} else if wantsPointerLock, pointerLockWasEngaged {
|
||||
requestPointerRelock()
|
||||
} else {
|
||||
// Capture is gone (or the lock was never ours) — settle, and let the next capture
|
||||
// start from a clean "never held" slate.
|
||||
if !wantsPointerLock { pointerLockWasEngaged = false }
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
let useGCMouse = captured && locked
|
||||
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
|
||||
// gcMouseForwarding flips false its release handler is gated off, so flush any held
|
||||
@@ -704,7 +767,83 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
|
||||
if iosInputDebug {
|
||||
iosInputLog.debug(
|
||||
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
|
||||
"""
|
||||
pointer lock isLocked=\(locked, privacy: .public) \
|
||||
captured=\(self.captured, privacy: .public) \
|
||||
relockPending=\(self.pointerRelockPending, privacy: .public) \
|
||||
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
|
||||
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
|
||||
private func requestPointerRelock() {
|
||||
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
|
||||
// saying we don't qualify, not the Esc key — re-asking would be noise, and the qualifying
|
||||
// states (foreground, appearance, reparent) each re-resolve on their own already.
|
||||
guard view.window?.windowScene?.activationState == .foregroundActive else {
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
let now = CACurrentMediaTime()
|
||||
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
|
||||
// is the backstop for the pathological case where a grant is immediately revoked again and
|
||||
// re-arms us. Even then this stays timer-driven at a few Hz — never a spin.
|
||||
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
|
||||
pointerRelockBurstStart = now
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
|
||||
// Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back
|
||||
// and a click into the video re-captures. The caller invalidates the interaction, so
|
||||
// the cursor can never stay hidden on a lock the system won't grant.
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
pointerRelockAttempt += 1
|
||||
pointerRelockPending = true
|
||||
let escalate = pointerRelockAttempt > 1
|
||||
// Deferred a turn so a ⌘⎋ whose GC keystroke lands after the system's unlock notification
|
||||
// has already cleared `captured` — then the guard below drops this attempt instead of
|
||||
// fighting the user's own release.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
|
||||
// The grant landed, or the capture went away under us (⌘⎋ / ⌃⌥⇧Q / resign).
|
||||
// Settle through the one decision point rather than returning with `pending` still
|
||||
// set — that flag hides the cursor, so it must never outlive the burst.
|
||||
self.syncPointerLock()
|
||||
return
|
||||
}
|
||||
if escalate {
|
||||
// Re-asserting a value the system already holds didn't take. Present a real
|
||||
// false→true transition instead — the documented way to change your mind about the
|
||||
// lock — and re-anchor the chain in case a reparent broke the downward walk to us.
|
||||
// Held for a beat rather than cleared on the next turn: the system resolves the
|
||||
// property asynchronously, and a same-turn flip back to true can be coalesced into
|
||||
// no transition at all. We are already unlocked, so the false pass costs nothing.
|
||||
self.pointerLockForcedOff = true
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.updatePointerLockChain()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
|
||||
[weak self] in
|
||||
guard let self else { return }
|
||||
self.pointerLockForcedOff = false
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
} else {
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
// A GRANT arrives as a didChange → syncPointerLock, which settles the burst and makes
|
||||
// this retry a no-op. Routed back through syncPointerLock (not straight into another
|
||||
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
|
||||
// place that does it.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
|
||||
[weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
self.syncPointerLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -724,7 +863,11 @@ extension StreamViewController: UIPointerInteractionDelegate {
|
||||
// host renders its own cursor from GCMouse deltas and a visible local one would just
|
||||
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
|
||||
// pointer is forwarded as an absolute position, both cursors tracking together.
|
||||
captured && pointerLockEngaged() == true ? .hidden() : nil
|
||||
// …except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
|
||||
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
|
||||
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
|
||||
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
|
||||
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -156,6 +156,20 @@ mod index {
|
||||
pub fn gamepad(s: &Settings) -> u32 {
|
||||
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn present_priority(s: &Settings) -> u32 {
|
||||
// Unknown values (a newer client's intent) read as the default, exactly as
|
||||
// `PresentPriority::resolve` treats them.
|
||||
PRESENT_PRIORITIES
|
||||
.iter()
|
||||
.position(|&p| p == s.present_priority)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn smooth_buffer(s: &Settings) -> u32 {
|
||||
// The index IS the stored value: 0 = Automatic, 1..3 = frames.
|
||||
u32::from(s.smooth_buffer).min(SMOOTH_BUFFER_LABELS.len() as u32 - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
|
||||
@@ -631,6 +645,18 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("fullscreen_on_stream") {
|
||||
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
|
||||
}
|
||||
if touched.has("present_priority") {
|
||||
o.present_priority = Some(values.present_priority.clone());
|
||||
}
|
||||
if touched.has("smooth_buffer") {
|
||||
o.smooth_buffer = Some(values.smooth_buffer);
|
||||
}
|
||||
if touched.has("vsync") {
|
||||
o.vsync = Some(values.vsync);
|
||||
}
|
||||
if touched.has("allow_vrr") {
|
||||
o.allow_vrr = Some(values.allow_vrr);
|
||||
}
|
||||
// Resets are not handled here: they clear the field and re-seed their row the moment the
|
||||
// user asks, so by the time this runs the catalog already reflects them and the row is no
|
||||
// longer marked touched.
|
||||
@@ -684,6 +710,20 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
||||
"The cursor jumps to your finger — a tap clicks there",
|
||||
"Real multi-touch reaches the host — for touch-native apps",
|
||||
];
|
||||
/// Presentation-intent values (persisted under the `present_priority` key the Apple and
|
||||
/// Android clients share) + labels + dynamic captions. Captions stay ONE line, like the
|
||||
/// touch/mouse rows.
|
||||
const PRESENT_PRIORITIES: &[&str] = &["latency", "smooth"];
|
||||
const PRESENT_PRIORITY_LABELS: &[&str] = &["Lowest latency", "Smoothness"];
|
||||
const PRESENT_PRIORITY_CAPTIONS: &[&str] = &[
|
||||
"Each frame shows the moment the display can take it",
|
||||
"Buffers a little to even out network hiccups",
|
||||
];
|
||||
/// Smoothness buffer depth, in frames — the index IS the stored `smooth_buffer` value
|
||||
/// (0 = Automatic, which resolves to 2). No millisecond hints: the cost is one refresh
|
||||
/// per frame, and the session's refresh isn't known here when the mode is Native.
|
||||
const SMOOTH_BUFFER_LABELS: &[&str] = &["Automatic", "1 frame", "2 frames", "3 frames"];
|
||||
|
||||
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
|
||||
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
|
||||
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
|
||||
@@ -1213,6 +1253,50 @@ pub fn show_scoped(
|
||||
row
|
||||
});
|
||||
|
||||
// ---- Display: Presentation ----
|
||||
// The intent pair the Apple and Android clients already carry. The buffer row only
|
||||
// means anything under Smoothness, so it hides itself the rest of the time rather
|
||||
// than sitting there inert.
|
||||
let present_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Prioritize",
|
||||
PRESENT_PRIORITY_CAPTIONS[0],
|
||||
PRESENT_PRIORITY_LABELS,
|
||||
);
|
||||
let buffer_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Smoothness buffer",
|
||||
"Each frame held absorbs one refresh of hiccup and adds one of delay",
|
||||
SMOOTH_BUFFER_LABELS,
|
||||
);
|
||||
{
|
||||
let w = present_row.widget().clone();
|
||||
let buffer = buffer_row.widget().clone();
|
||||
present_row.connect_changed(move |i| {
|
||||
let i = (i as usize).min(PRESENT_PRIORITY_CAPTIONS.len() - 1);
|
||||
set_row_subtitle(&w, PRESENT_PRIORITY_CAPTIONS[i]);
|
||||
buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth");
|
||||
});
|
||||
}
|
||||
let vsync_row = adw::SwitchRow::builder()
|
||||
.title("V-Sync")
|
||||
.subtitle(
|
||||
"Tear-free. Turning it off removes the wait for the screen's refresh — the \
|
||||
lowest possible delay, at the cost of visible tearing. Not every driver \
|
||||
offers it; the stats overlay names the mode actually in use",
|
||||
)
|
||||
.build();
|
||||
let vrr_row = adw::SwitchRow::builder()
|
||||
.title("Follow variable refresh rate")
|
||||
.subtitle(
|
||||
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with the \
|
||||
stream instead of on a fixed cadence. Applies to fullscreen sessions; \
|
||||
harmless on a fixed-refresh screen",
|
||||
)
|
||||
.build();
|
||||
|
||||
// ---- Display: Host output ----
|
||||
let compositor_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
@@ -1479,6 +1563,19 @@ pub fn show_scoped(
|
||||
let codec_i = index::codec(s);
|
||||
codec_row.set_selected(codec_i);
|
||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i));
|
||||
let present_i = index::present_priority(s);
|
||||
present_row.set_selected(present_i);
|
||||
set_row_subtitle(
|
||||
present_row.widget(),
|
||||
PRESENT_PRIORITY_CAPTIONS[present_i as usize],
|
||||
);
|
||||
buffer_row.set_selected(index::smooth_buffer(s));
|
||||
// `set_selected` never fires the changed hook, so mirror its visibility rule here.
|
||||
buffer_row
|
||||
.widget()
|
||||
.set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth");
|
||||
vsync_row.set_active(s.vsync);
|
||||
vrr_row.set_active(s.allow_vrr);
|
||||
}
|
||||
|
||||
// ---- Override markers, per-row reset, and the touch that creates an override ----
|
||||
@@ -1671,6 +1768,20 @@ pub fn show_scoped(
|
||||
index::surround
|
||||
);
|
||||
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
|
||||
choice!(
|
||||
present_row,
|
||||
"present_priority",
|
||||
o.present_priority.is_some(),
|
||||
index::present_priority
|
||||
);
|
||||
choice!(
|
||||
buffer_row,
|
||||
"smooth_buffer",
|
||||
o.smooth_buffer.is_some(),
|
||||
index::smooth_buffer
|
||||
);
|
||||
toggle!(vsync_row, "vsync", o.vsync.is_some(), vsync);
|
||||
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
|
||||
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
|
||||
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
|
||||
toggle!(
|
||||
@@ -1775,6 +1886,11 @@ pub fn show_scoped(
|
||||
if let (Some(r), false) = (&gpu_row, profile_mode) {
|
||||
quality_group.add(r.widget());
|
||||
}
|
||||
let presentation_group = group("Presentation", "");
|
||||
presentation_group.add(present_row.widget());
|
||||
presentation_group.add(buffer_row.widget());
|
||||
presentation_group.add(&vsync_row);
|
||||
presentation_group.add(&vrr_row);
|
||||
// The one form-level note (deliberately not repeated on every row).
|
||||
let output_group = group(
|
||||
"Host output",
|
||||
@@ -1783,6 +1899,7 @@ pub fn show_scoped(
|
||||
output_group.add(compositor_row.widget());
|
||||
display.add(&resolution_group);
|
||||
display.add(&quality_group);
|
||||
display.add(&presentation_group);
|
||||
display.add(&output_group);
|
||||
|
||||
let input = page("Input", "input-keyboard-symbolic");
|
||||
@@ -1925,6 +2042,14 @@ pub fn show_scoped(
|
||||
_ => 2,
|
||||
};
|
||||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||||
s.present_priority = PRESENT_PRIORITIES
|
||||
[(present_row.selected() as usize).min(PRESENT_PRIORITIES.len() - 1)]
|
||||
.to_string();
|
||||
// The index IS the value (0 = Automatic).
|
||||
s.smooth_buffer =
|
||||
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
|
||||
s.vsync = vsync_row.is_active();
|
||||
s.allow_vrr = vrr_row.is_active();
|
||||
s.library_enabled = library_row.is_active();
|
||||
};
|
||||
|
||||
|
||||
@@ -169,6 +169,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
|
||||
// Presentation-tier like the rows above: latched at console start, a per-host
|
||||
// profile cannot move it in this mode (the documented P4 gap).
|
||||
present_priority: settings_at_start.present_priority(),
|
||||
vsync: settings_at_start.vsync,
|
||||
allow_vrr: settings_at_start.allow_vrr,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
|
||||
@@ -617,6 +617,9 @@ mod session_main {
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
inhibit_shortcuts: settings.inhibit_shortcuts,
|
||||
present_priority: settings.present_priority(),
|
||||
vsync: settings.vsync,
|
||||
allow_vrr: settings.allow_vrr,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
|
||||
@@ -101,6 +101,19 @@ const MOUSE_MODES: &[(&str, &str)] = &[
|
||||
("capture", "Capture (games)"),
|
||||
("desktop", "Desktop (absolute)"),
|
||||
];
|
||||
/// Presentation intent: `(stored value, display label)` — the `present_priority` key the
|
||||
/// Apple and Android clients share, so one profile means the same thing everywhere.
|
||||
const PRESENT_PRIORITIES: &[(&str, &str)] =
|
||||
&[("latency", "Lowest latency"), ("smooth", "Smoothness")];
|
||||
/// Smoothness buffer depth in frames: `(stored value, display label)`. `0` = Automatic,
|
||||
/// which resolves to 2 (`PresentPriority::resolve`). No millisecond hints — the cost is
|
||||
/// one refresh per frame, and the refresh isn't known here when the mode is Native.
|
||||
const SMOOTH_BUFFERS: &[(u8, &str)] = &[
|
||||
(0, "Automatic"),
|
||||
(1, "1 frame"),
|
||||
(2, "2 frames"),
|
||||
(3, "3 frames"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -447,6 +460,10 @@ struct OverrideFlags {
|
||||
gamepad: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
present_priority: bool,
|
||||
smooth_buffer: bool,
|
||||
vsync: bool,
|
||||
allow_vrr: bool,
|
||||
}
|
||||
|
||||
impl OverrideFlags {
|
||||
@@ -475,6 +492,10 @@ impl OverrideFlags {
|
||||
gamepad: o.gamepad.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
present_priority: o.present_priority.is_some(),
|
||||
smooth_buffer: o.smooth_buffer.is_some(),
|
||||
vsync: o.vsync.is_some(),
|
||||
allow_vrr: o.allow_vrr.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +872,32 @@ pub(crate) fn settings_page(
|
||||
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
|
||||
s.enable_444 = on
|
||||
});
|
||||
// Presentation intent (design/desktop-presentation-rebuild.md). The buffer row is
|
||||
// rendered only under Smoothness — `commit` bumps the revision, so flipping the
|
||||
// intent re-renders the section and the row appears/disappears with it.
|
||||
let (present_names, present_i) = presets(PRESENT_PRIORITIES, |v| *v == s.present_priority);
|
||||
let present_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
present_names,
|
||||
present_i,
|
||||
|s, i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string(),
|
||||
);
|
||||
let smoothing = s.present_priority == "smooth";
|
||||
let (buffer_names, buffer_i) = presets(SMOOTH_BUFFERS, |v| *v == s.smooth_buffer);
|
||||
let buffer_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
buffer_names,
|
||||
buffer_i,
|
||||
|s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0,
|
||||
);
|
||||
let vsync_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.vsync, |s, on| s.vsync = on);
|
||||
let vrr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.allow_vrr, |s, on| {
|
||||
s.allow_vrr = on
|
||||
});
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
@@ -1105,6 +1152,60 @@ pub(crate) fn settings_page(
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Presentation"),
|
||||
{
|
||||
let mut fields = vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"present_priority",
|
||||
"Prioritize",
|
||||
over.present_priority,
|
||||
present_combo,
|
||||
"Lowest latency shows each frame the moment the display can take \
|
||||
it \u{2014} a network hiccup becomes an occasional repeated or \
|
||||
skipped frame. Smoothness buffers a little to even those out.",
|
||||
)];
|
||||
if smoothing {
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"smooth_buffer",
|
||||
"Smoothness buffer",
|
||||
over.smooth_buffer,
|
||||
buffer_combo,
|
||||
"Frames held back before showing. Each one absorbs about a \
|
||||
refresh of network hiccup and adds a refresh of delay. \
|
||||
Automatic holds two.",
|
||||
));
|
||||
}
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"vsync",
|
||||
"V-Sync",
|
||||
over.vsync,
|
||||
vsync_toggle,
|
||||
"Tear-free. Turning it off removes the wait for the screen\u{2019}s \
|
||||
refresh \u{2014} the lowest possible delay, at the cost of visible \
|
||||
tearing. Not every driver offers it; the stats overlay names the \
|
||||
mode actually in use.",
|
||||
));
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"allow_vrr",
|
||||
"Follow variable refresh rate",
|
||||
over.allow_vrr,
|
||||
vrr_toggle,
|
||||
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with \
|
||||
the stream instead of on a fixed cadence. Applies to fullscreen \
|
||||
sessions; harmless on a fixed-refresh screen.",
|
||||
));
|
||||
fields
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Host output"),
|
||||
vec![described_overridable(
|
||||
@@ -1727,5 +1828,26 @@ mod tests {
|
||||
let f3 = OverrideFlags::of(Some(&p3));
|
||||
assert!(f3.echo_cancel);
|
||||
assert!(!f3.mic_enabled);
|
||||
|
||||
// The presentation pair, likewise independent: pinning the intent doesn't claim
|
||||
// the buffer (a "Smoothness, whatever the global buffer is" profile is valid).
|
||||
let mut p4 = StreamProfile::new("t4".to_string());
|
||||
p4.overrides = SettingsOverlay {
|
||||
present_priority: Some("smooth".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f4 = OverrideFlags::of(Some(&p4));
|
||||
assert!(f4.present_priority);
|
||||
assert!(!f4.smooth_buffer);
|
||||
|
||||
// V-Sync and VRR are independent of each other and of the intent pair.
|
||||
let mut p5 = StreamProfile::new("t5".to_string());
|
||||
p5.overrides = SettingsOverlay {
|
||||
vsync: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let f5 = OverrideFlags::of(Some(&p5));
|
||||
assert!(f5.vsync);
|
||||
assert!(!f5.allow_vrr && !f5.present_priority);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,7 +612,10 @@ pub fn open_portal_monitor(
|
||||
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
|
||||
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
|
||||
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards.
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
|
||||
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
|
||||
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
|
||||
/// "pointer hidden" the composited/forwarded cursor must honor.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_virtual_output(
|
||||
@@ -625,6 +628,7 @@ pub fn open_virtual_output(
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::from_virtual_output(
|
||||
remote_fd,
|
||||
@@ -636,6 +640,7 @@ pub fn open_virtual_output(
|
||||
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ struct CaptureOpts {
|
||||
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
|
||||
/// gamescope fixates its own — gating those would starve legitimate first frames).
|
||||
expect_exact_dims: bool,
|
||||
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
|
||||
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
|
||||
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
|
||||
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
|
||||
cursor_id0_hides: bool,
|
||||
}
|
||||
|
||||
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
|
||||
@@ -301,6 +306,10 @@ impl PortalCapturer {
|
||||
want_444: false,
|
||||
want_hdr,
|
||||
expect_exact_dims: false,
|
||||
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
|
||||
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
|
||||
// one through here yet; the virtual-output path below carries the real flag.
|
||||
cursor_id0_hides: false,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
@@ -316,7 +325,8 @@ impl PortalCapturer {
|
||||
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
||||
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
||||
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it.
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
|
||||
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_virtual_output(
|
||||
remote_fd: Option<OwnedFd>,
|
||||
@@ -328,6 +338,7 @@ impl PortalCapturer {
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<PortalCapturer> {
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -335,6 +346,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
|
||||
@@ -350,6 +362,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
|
||||
@@ -811,6 +811,7 @@ pub fn pipewire_thread(
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
..
|
||||
} = opts;
|
||||
crate::pwinit::ensure_init();
|
||||
@@ -985,7 +986,7 @@ pub fn pipewire_thread(
|
||||
yuv444: want_444,
|
||||
linear_nv12_failed: false,
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::default(),
|
||||
cursor: CursorState::new(cursor_id0_hides),
|
||||
expect_dims: if expect_exact_dims {
|
||||
preferred.map(|(w, h, _)| (w, h))
|
||||
} else {
|
||||
|
||||
@@ -39,9 +39,23 @@ pub(super) struct CursorState {
|
||||
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
|
||||
/// process-wide latch made the second session's triage read as "no meta".
|
||||
seen_meta: bool,
|
||||
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
|
||||
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
|
||||
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
|
||||
/// [`note_cursor_id`].
|
||||
id0_hides: bool,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// The per-stream state, declaring which `id == 0` contract the producer follows
|
||||
/// ([`Self::id0_hides`]).
|
||||
pub(super) fn new(id0_hides: bool) -> CursorState {
|
||||
CursorState {
|
||||
id0_hides,
|
||||
..CursorState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
@@ -79,6 +93,31 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
|
||||
/// meta region (position, bitmap) is worth parsing.
|
||||
///
|
||||
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
|
||||
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
|
||||
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
|
||||
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
|
||||
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
|
||||
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
|
||||
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
|
||||
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
|
||||
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
|
||||
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
|
||||
/// Windows has its own CURSOR_SUPPRESSED source).
|
||||
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
|
||||
if id == 0 {
|
||||
if cursor.id0_hides {
|
||||
cursor.visible = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cursor.visible = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
@@ -121,16 +160,9 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
if !note_cursor_id(cursor, id) {
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
@@ -367,9 +399,44 @@ mod tests {
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
seen_meta: true,
|
||||
id0_hides: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn id_zero_hides_only_on_a_rewriting_producer() {
|
||||
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
|
||||
// a game or Big Picture hiding the pointer must reach the stream.
|
||||
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
kwin.id0_hides = true;
|
||||
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
|
||||
let o = kwin.overlay().expect("bitmap stays cached across a hide");
|
||||
assert!(!o.visible, "KWin id 0 must hide the overlay");
|
||||
// The pointer coming back re-shows the SAME cached bitmap.
|
||||
assert!(note_cursor_id(&mut kwin, 1));
|
||||
assert!(kwin.overlay().expect("still cached").visible);
|
||||
|
||||
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
|
||||
// (honoring them flickered the cursor off between hovers, on-glass round 5).
|
||||
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
assert!(!note_cursor_id(&mut mutter, 0));
|
||||
assert!(
|
||||
mutter.overlay().expect("cached").visible,
|
||||
"a stale-meta producer's id 0 must NOT hide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_zero_before_any_bitmap_yields_no_overlay() {
|
||||
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
|
||||
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
|
||||
let mut c = CursorState::new(true);
|
||||
assert!(!note_cursor_id(&mut c, 0));
|
||||
assert!(c.overlay().is_none());
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -982,6 +982,10 @@ mod tests {
|
||||
height: 1440,
|
||||
bitrate_kbps: 55000,
|
||||
codec: "av1".into(),
|
||||
present_priority: "smooth".into(),
|
||||
smooth_buffer: 2,
|
||||
vsync: false,
|
||||
allow_vrr: false,
|
||||
..Default::default()
|
||||
},
|
||||
clipboard: true,
|
||||
|
||||
@@ -77,6 +77,18 @@ pub struct SettingsOverlay {
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
/// The presentation cluster — the keys the Apple client already writes into this
|
||||
/// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`;
|
||||
/// Android carries the first two). First-class here so a profile authored on any
|
||||
/// client applies on all of them instead of riding `extra` unapplied.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub present_priority: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub smooth_buffer: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vsync: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_vrr: Option<bool>,
|
||||
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
|
||||
/// load→save round-trip untouched.
|
||||
#[serde(flatten)]
|
||||
@@ -150,6 +162,18 @@ impl SettingsOverlay {
|
||||
if let Some(v) = self.fullscreen_on_stream {
|
||||
s.fullscreen_on_stream = v;
|
||||
}
|
||||
if let Some(v) = &self.present_priority {
|
||||
s.present_priority = v.clone();
|
||||
}
|
||||
if let Some(v) = self.smooth_buffer {
|
||||
s.smooth_buffer = v;
|
||||
}
|
||||
if let Some(v) = self.vsync {
|
||||
s.vsync = v;
|
||||
}
|
||||
if let Some(v) = self.allow_vrr {
|
||||
s.allow_vrr = v;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -226,6 +250,18 @@ impl SettingsOverlay {
|
||||
if after.fullscreen_on_stream != before.fullscreen_on_stream {
|
||||
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
|
||||
}
|
||||
if after.present_priority != before.present_priority {
|
||||
self.present_priority = Some(after.present_priority.clone());
|
||||
}
|
||||
if after.smooth_buffer != before.smooth_buffer {
|
||||
self.smooth_buffer = Some(after.smooth_buffer);
|
||||
}
|
||||
if after.vsync != before.vsync {
|
||||
self.vsync = Some(after.vsync);
|
||||
}
|
||||
if after.allow_vrr != before.allow_vrr {
|
||||
self.allow_vrr = Some(after.allow_vrr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one override by its overlay field name, putting the row back to inheriting. The
|
||||
@@ -259,6 +295,10 @@ impl SettingsOverlay {
|
||||
"gamepad" => self.gamepad = None,
|
||||
"stats_verbosity" => self.stats_verbosity = None,
|
||||
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||
"present_priority" => self.present_priority = None,
|
||||
"smooth_buffer" => self.smooth_buffer = None,
|
||||
"vsync" => self.vsync = None,
|
||||
"allow_vrr" => self.allow_vrr = None,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
@@ -455,6 +495,10 @@ mod tests {
|
||||
match_window: Some(true),
|
||||
fullscreen_on_stream: Some(false),
|
||||
stats_verbosity: Some(StatsVerbosity::Detailed),
|
||||
present_priority: Some("smooth".into()),
|
||||
smooth_buffer: Some(3),
|
||||
vsync: Some(false),
|
||||
allow_vrr: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!overlay.is_empty());
|
||||
@@ -476,6 +520,10 @@ mod tests {
|
||||
assert!(out.match_window);
|
||||
assert!(!out.fullscreen_on_stream);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
assert_eq!(out.present_priority, "smooth");
|
||||
assert_eq!(out.smooth_buffer, 3);
|
||||
assert!(!out.vsync);
|
||||
assert!(!out.allow_vrr);
|
||||
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
|
||||
// stays coherent with it.
|
||||
assert!(out.show_stats);
|
||||
@@ -573,6 +621,59 @@ mod tests {
|
||||
assert!(o.is_empty());
|
||||
}
|
||||
|
||||
/// The presentation cluster is first-class, not `extra` passengers: it applies,
|
||||
/// absorbs, clears, and serialises under the exact keys the Apple client already
|
||||
/// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog
|
||||
/// has to round-trip through every platform, and a mismatched key would be carried
|
||||
/// but never applied.
|
||||
#[test]
|
||||
fn presentation_cluster_is_first_class() {
|
||||
let base = Settings::default();
|
||||
let mut o = SettingsOverlay::default();
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.present_priority = "smooth".into();
|
||||
o.absorb(&before, &after);
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.smooth_buffer = 1;
|
||||
o.absorb(&before, &after);
|
||||
assert_eq!(o.present_priority.as_deref(), Some("smooth"));
|
||||
assert_eq!(o.smooth_buffer, Some(1));
|
||||
assert!(
|
||||
o.extra.is_empty(),
|
||||
"modelled fields must never land in the passthrough"
|
||||
);
|
||||
let out = o.apply(&base);
|
||||
assert_eq!(
|
||||
out.present_priority(),
|
||||
crate::trust::PresentPriority::Smooth { buffer: 1 }
|
||||
);
|
||||
|
||||
// Serialised under the shared keys, and read back from a foreign client's file.
|
||||
let text = serde_json::to_string(&o).unwrap();
|
||||
assert!(text.contains("\"present_priority\":\"smooth\""), "{text}");
|
||||
assert!(text.contains("\"smooth_buffer\":1"), "{text}");
|
||||
let from_apple: SettingsOverlay = serde_json::from_str(
|
||||
r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(from_apple.present_priority.as_deref(), Some("latency"));
|
||||
assert_eq!(from_apple.smooth_buffer, Some(2));
|
||||
assert_eq!(from_apple.vsync, Some(true));
|
||||
assert_eq!(from_apple.allow_vrr, Some(false));
|
||||
assert!(from_apple.extra.is_empty());
|
||||
|
||||
assert!(o.clear("present_priority"));
|
||||
assert!(o.clear("smooth_buffer"));
|
||||
assert_eq!(o.present_priority, None);
|
||||
assert!(o.is_empty());
|
||||
let mut vrr = from_apple;
|
||||
assert!(vrr.clear("vsync"));
|
||||
assert!(vrr.clear("allow_vrr"));
|
||||
assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None));
|
||||
}
|
||||
|
||||
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
|
||||
#[test]
|
||||
fn clear_drops_one_override() {
|
||||
|
||||
@@ -787,6 +787,45 @@ impl MouseMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Presentation intent — what the presenter optimizes for
|
||||
/// (design/desktop-presentation-rebuild.md; the Apple/Android clients' shared
|
||||
/// `present_priority`/`smooth_buffer` pair). Stored stringly in
|
||||
/// [`Settings::present_priority`] + [`Settings::smooth_buffer`]; resolved with
|
||||
/// [`PresentPriority::resolve`], whose rules match the Android reference
|
||||
/// (`decode/presenter.rs`): anything but an explicit `"smooth"` is latency, and a
|
||||
/// smooth buffer outside 1..=3 (including 0 = Automatic) becomes 2.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum PresentPriority {
|
||||
/// Every frame presents the moment the display can take it; a network hiccup is an
|
||||
/// occasional repeated or skipped frame. The default.
|
||||
Latency,
|
||||
/// A small frame buffer (1–3 frames) evens out network/decode jitter, at the
|
||||
/// buffer's worth of added display latency.
|
||||
Smooth { buffer: u8 },
|
||||
}
|
||||
|
||||
impl PresentPriority {
|
||||
/// The shared cross-client resolution rule — pure, so every embedder agrees on what
|
||||
/// a foreign profile's values mean.
|
||||
pub fn resolve(name: &str, buffer: u8) -> PresentPriority {
|
||||
if name == "smooth" {
|
||||
PresentPriority::Smooth {
|
||||
buffer: if (1..=3).contains(&buffer) { buffer } else { 2 },
|
||||
}
|
||||
} else {
|
||||
PresentPriority::Latency
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames the smoothing store holds; `0` = newest-wins (the latency intent).
|
||||
pub fn fifo_capacity(self) -> u8 {
|
||||
match self {
|
||||
PresentPriority::Latency => 0,
|
||||
PresentPriority::Smooth { buffer } => buffer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -874,6 +913,32 @@ pub struct Settings {
|
||||
/// `default = true`: the Linux stores never carried this and always advertised.
|
||||
#[serde(default = "default_true")]
|
||||
pub hdr_enabled: bool,
|
||||
/// Presentation intent: `"latency"` (default) or `"smooth"` — the Apple/Android
|
||||
/// clients' shared `present_priority` profile key, resolved with
|
||||
/// [`PresentPriority::resolve`] (via [`Settings::present_priority`]). Anything
|
||||
/// unknown reads as latency, so a newer client's future value degrades safely.
|
||||
#[serde(default = "default_present_priority")]
|
||||
pub present_priority: String,
|
||||
/// Smoothness buffer size in frames: `0` = Automatic (resolves to 2), else 1–3.
|
||||
/// Only meaningful under `present_priority = "smooth"` (the shared `smooth_buffer`
|
||||
/// key). Each buffered frame absorbs about one refresh of jitter and adds one
|
||||
/// refresh of display latency.
|
||||
#[serde(default)]
|
||||
pub smooth_buffer: u8,
|
||||
/// Tear-free presentation (default ON = today's behavior: MAILBOX, FIFO fallback).
|
||||
/// Off asks for a tearing present mode (IMMEDIATE) for the lowest possible latch
|
||||
/// latency — best-effort: platforms/drivers without tearing silently stay tear-free
|
||||
/// and the active mode is visible in the detailed stats. The shared `vsync` profile
|
||||
/// key; the desktop default differs from macOS's (`false` there) deliberately —
|
||||
/// sync-off means something different on each platform, the key is the contract.
|
||||
#[serde(default = "default_true")]
|
||||
pub vsync: bool,
|
||||
/// Let a variable-refresh display follow the stream cadence: prefers the present
|
||||
/// mode that drives VRR panels directly when fullscreen. Inert on fixed-refresh
|
||||
/// displays (detection is measured from on-glass timestamps, not queried). The
|
||||
/// shared `allow_vrr` profile key. Default ON, like the Apple client.
|
||||
#[serde(default = "default_true")]
|
||||
pub allow_vrr: bool,
|
||||
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
|
||||
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
|
||||
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
|
||||
@@ -939,6 +1004,10 @@ fn default_mouse_mode() -> String {
|
||||
"capture".into()
|
||||
}
|
||||
|
||||
fn default_present_priority() -> String {
|
||||
"latency".into()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -970,6 +1039,12 @@ impl Settings {
|
||||
MouseMode::from_name(&self.mouse_mode)
|
||||
}
|
||||
|
||||
/// The presentation intent for this session (the resolved
|
||||
/// `present_priority` × `smooth_buffer` pair).
|
||||
pub fn present_priority(&self) -> PresentPriority {
|
||||
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -1007,6 +1082,10 @@ impl Default for Settings {
|
||||
adapter: String::new(),
|
||||
enable_444: false,
|
||||
hdr_enabled: true,
|
||||
present_priority: "latency".into(),
|
||||
smooth_buffer: 0,
|
||||
vsync: true,
|
||||
allow_vrr: true,
|
||||
show_stats: true,
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
@@ -1144,6 +1223,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings file predating the presentation cluster loads with the shipped
|
||||
/// defaults (latency intent, Automatic buffer, tear-free, VRR allowed), and the
|
||||
/// resolution rules match the Apple/Android reference: anything but an explicit
|
||||
/// `"smooth"` is latency, and a smooth buffer outside 1..=3 becomes 2.
|
||||
#[test]
|
||||
fn settings_presentation_defaults_and_resolution() {
|
||||
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
|
||||
let s: Settings = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(s.present_priority, "latency");
|
||||
assert_eq!(s.smooth_buffer, 0);
|
||||
assert!(s.vsync);
|
||||
assert!(s.allow_vrr);
|
||||
assert_eq!(s.present_priority(), PresentPriority::Latency);
|
||||
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 0),
|
||||
PresentPriority::Smooth { buffer: 2 },
|
||||
"Automatic resolves to 2"
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 3),
|
||||
PresentPriority::Smooth { buffer: 3 }
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 9),
|
||||
PresentPriority::Smooth { buffer: 2 },
|
||||
"out-of-range pins to the Automatic resolution"
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("balanced-from-the-future", 2),
|
||||
PresentPriority::Latency,
|
||||
"unknown intents degrade to latency"
|
||||
);
|
||||
assert_eq!(PresentPriority::Latency.fifo_capacity(), 0);
|
||||
assert_eq!(PresentPriority::Smooth { buffer: 3 }.fifo_capacity(), 3);
|
||||
}
|
||||
|
||||
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
|
||||
#[test]
|
||||
fn settings_forward_pad_defaults_empty() {
|
||||
|
||||
@@ -26,6 +26,10 @@ enum RowId {
|
||||
Decoder,
|
||||
Hdr,
|
||||
Chroma444,
|
||||
PresentPriority,
|
||||
SmoothBuffer,
|
||||
Vsync,
|
||||
AllowVrr,
|
||||
Audio,
|
||||
Mic,
|
||||
EchoCancel,
|
||||
@@ -46,7 +50,7 @@ enum RowId {
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
|
||||
const ROWS: [RowId; 22] = [
|
||||
const ROWS: [RowId; 26] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
@@ -56,6 +60,10 @@ const ROWS: [RowId; 22] = [
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Chroma444,
|
||||
RowId::PresentPriority,
|
||||
RowId::SmoothBuffer,
|
||||
RowId::Vsync,
|
||||
RowId::AllowVrr,
|
||||
RowId::Audio,
|
||||
RowId::Mic,
|
||||
RowId::EchoCancel,
|
||||
@@ -117,6 +125,17 @@ const DECODERS: [(&str, &str); 4] = [
|
||||
("software", "Software"),
|
||||
];
|
||||
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
|
||||
/// Presentation intent — the `present_priority` key shared with the Apple and Android
|
||||
/// clients, so one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [(&str, &str); 2] =
|
||||
[("latency", "Lowest latency"), ("smooth", "Smoothness")];
|
||||
/// Smoothness buffer depth in frames; `0` = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [(u8, &str); 4] = [
|
||||
(0, "Automatic"),
|
||||
(1, "1 frame"),
|
||||
(2, "2 frames"),
|
||||
(3, "3 frames"),
|
||||
];
|
||||
const PAD_TYPES: [(&str, &str); 6] = [
|
||||
("auto", "Automatic"),
|
||||
("xbox360", "Xbox 360"),
|
||||
@@ -222,9 +241,16 @@ impl SettingsScreen {
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
let s = &ctx.settings;
|
||||
// Echo cancellation only means anything while the mic streams — dimmed and inert while it
|
||||
// doesn't, the same relationship the desktop shells draw with a greyed-out row.
|
||||
let enabled = !matches!(id, RowId::EchoCancel) || s.mic_enabled;
|
||||
// Two rows follow another: echo cancellation only means anything while the mic
|
||||
// streams, and the smoothness buffer only while that intent is chosen. Both go dim
|
||||
// and inert otherwise — the same relationship the desktop shells draw by greying a
|
||||
// row out (they hide the buffer row entirely; a fixed row list can't, and a row that
|
||||
// vanished mid-list would move everything under the cursor).
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
RowId::Resolution => (
|
||||
Some("Stream"),
|
||||
@@ -279,6 +305,22 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
|
||||
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
|
||||
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
|
||||
RowId::PresentPriority => (
|
||||
Some("Presentation"),
|
||||
"Prioritize",
|
||||
label_for(&PRESENT_PRIORITIES, &s.present_priority).into(),
|
||||
),
|
||||
RowId::SmoothBuffer => (
|
||||
None,
|
||||
"Smoothness buffer",
|
||||
SMOOTH_BUFFERS
|
||||
.iter()
|
||||
.find(|(v, _)| *v == s.smooth_buffer)
|
||||
.map_or("Automatic", |(_, l)| l)
|
||||
.into(),
|
||||
),
|
||||
RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()),
|
||||
RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()),
|
||||
RowId::Audio => (
|
||||
Some("Audio"),
|
||||
"Audio channels",
|
||||
@@ -368,6 +410,24 @@ fn detail(id: RowId) -> &'static str {
|
||||
Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders \
|
||||
stream 4:2:0 and the session falls back silently."
|
||||
}
|
||||
RowId::PresentPriority => {
|
||||
"Lowest latency shows each frame the moment the display can take it — a \
|
||||
network hiccup becomes an occasional repeated or skipped frame. Smoothness \
|
||||
buffers a little to even those out."
|
||||
}
|
||||
RowId::SmoothBuffer => {
|
||||
"Frames held back before showing. Each one absorbs about a refresh of network \
|
||||
hiccup and adds a refresh of delay. Automatic holds two."
|
||||
}
|
||||
RowId::Vsync => {
|
||||
"Tear-free. Off removes the wait for the screen's refresh — the lowest \
|
||||
possible delay, at the cost of visible tearing. Not every driver offers it; \
|
||||
the stats overlay names the mode actually in use."
|
||||
}
|
||||
RowId::AllowVrr => {
|
||||
"On a VRR screen, let the panel refresh in step with the stream instead of on \
|
||||
a fixed cadence. Applies to fullscreen sessions; harmless on a fixed screen."
|
||||
}
|
||||
RowId::Audio => "The speaker layout requested from the host.",
|
||||
RowId::Mic => {
|
||||
"Send this device's microphone to the host's virtual mic. \
|
||||
@@ -463,6 +523,27 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
|
||||
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
|
||||
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
|
||||
RowId::PresentPriority => {
|
||||
let cur = PRESENT_PRIORITIES
|
||||
.iter()
|
||||
.position(|(v, _)| *v == s.present_priority);
|
||||
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
|
||||
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
|
||||
}
|
||||
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
|
||||
RowId::SmoothBuffer => {
|
||||
if s.present_priority == "smooth" {
|
||||
let cur = SMOOTH_BUFFERS
|
||||
.iter()
|
||||
.position(|(v, _)| *v == s.smooth_buffer);
|
||||
step_option(cur, SMOOTH_BUFFERS.len(), delta, wrap)
|
||||
.map(|i| s.smooth_buffer = SMOOTH_BUFFERS[i].0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
RowId::Vsync => toggle(&mut s.vsync, delta, wrap),
|
||||
RowId::AllowVrr => toggle(&mut s.allow_vrr, delta, wrap),
|
||||
RowId::Audio => {
|
||||
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
|
||||
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
|
||||
@@ -648,6 +729,45 @@ mod tests {
|
||||
assert!(ctx.settings.echo_cancel);
|
||||
}
|
||||
|
||||
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
|
||||
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
|
||||
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
|
||||
/// row list dims it, because a row vanishing mid-list would shift everything under the
|
||||
/// cursor.
|
||||
#[test]
|
||||
fn smoothness_buffer_follows_the_intent() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.present_priority, "latency", "the shipped default");
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||
"latency intent = thud"
|
||||
);
|
||||
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
|
||||
|
||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||
|
||||
// The intent wraps back and the row goes inert again.
|
||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "latency");
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_mode_steps_and_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
|
||||
@@ -52,6 +52,8 @@ pub mod keymap_sdl;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod overlay;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod present_pace;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod run;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod touch;
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
//! The presentation intent engine (design/desktop-presentation-rebuild.md WP2): the
|
||||
//! store, clock, and gate the run loop composes into the two intents.
|
||||
//!
|
||||
//! * [`FrameStore`] — newest-wins slot (latency) or smoothing FIFO with preroll
|
||||
//! (smoothness), ported from the Apple `FrameStore` / Android `presenter.rs` so all
|
||||
//! three clients agree on what the intents mean.
|
||||
//! * [`LatchClock`] — the panel latch grid, learned from `VK_KHR_present_wait` on-glass
|
||||
//! stamps (measured, never queried — the Android refresh-rate lie and VRR both punish
|
||||
//! trusting a reported rate). Without present-wait it degrades to a grid rooted at the
|
||||
//! last submit on the mode's refresh period.
|
||||
//! * [`PresentGate`] — the FIFO glass budget: one undisplayed present in flight, so the
|
||||
//! swapchain's own queue can never become a standing queue (+1 refresh per slot,
|
||||
//! forever — the law every bounded-FIFO pacing rediscovered on Apple). MAILBOX cannot
|
||||
//! queue and never needs it.
|
||||
//!
|
||||
//! Everything here is pure state + arithmetic on `CLOCK_REALTIME` ns (the
|
||||
//! `pf_client_core::session::now_ns` domain the on-glass stamps live in); the run loop
|
||||
//! owns all clocks and Vulkan calls, which is what keeps this testable.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Stale-present force-open: an undisplayed present older than this is presumed lost
|
||||
/// (occluded window, wedged compositor) and the gate opens anyway, counted as `forced`
|
||||
/// — reads 0 on healthy systems. The Apple/Android presenters use the same 100 ms.
|
||||
const STALE_REOPEN_NS: u64 = 100_000_000;
|
||||
|
||||
/// The adaptive slot-pick margin's ceiling and step (Android's measured values: start
|
||||
/// at 0 — a fixed lead was pure display tax on the reference device — and widen only
|
||||
/// when measured misses demand it).
|
||||
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
|
||||
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
|
||||
|
||||
/// The decoded-frame store between the wake channel and the present call.
|
||||
///
|
||||
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
|
||||
/// `capacity 1..=3` = smoothing FIFO: preroll-to-capacity, drop-oldest on overflow,
|
||||
/// an underflow after preroll re-arms the preroll (the previous frame persists on
|
||||
/// glass — a repeat by omission) while headroom rebuilds.
|
||||
pub(crate) struct FrameStore<T> {
|
||||
capacity: usize,
|
||||
frames: VecDeque<T>,
|
||||
prerolled: bool,
|
||||
/// Newest-wins displacements (normal operation under latency, not a fault signal).
|
||||
replaced: u32,
|
||||
/// FIFO drop-oldest evictions — the Apple debug line's `qDrop`.
|
||||
overflow_drops: u32,
|
||||
/// FIFO dry-after-preroll events — `qDry`.
|
||||
underflows: u32,
|
||||
}
|
||||
|
||||
impl<T> FrameStore<T> {
|
||||
pub(crate) fn new(capacity: usize) -> FrameStore<T> {
|
||||
FrameStore {
|
||||
capacity,
|
||||
frames: VecDeque::with_capacity(capacity.max(1) + 1),
|
||||
prerolled: false,
|
||||
replaced: 0,
|
||||
overflow_drops: 0,
|
||||
underflows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_smoothing(&self) -> bool {
|
||||
self.capacity > 0
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.frames.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn submit(&mut self, f: T) {
|
||||
if self.capacity == 0 {
|
||||
if self.frames.pop_front().is_some() {
|
||||
self.replaced += 1;
|
||||
}
|
||||
self.frames.push_back(f);
|
||||
} else {
|
||||
self.frames.push_back(f);
|
||||
// Drop the OLDEST past capacity: bounded added latency, the newest keeps
|
||||
// flowing. Also trims a transient capacity+1 a put_back left behind.
|
||||
while self.frames.len() > self.capacity {
|
||||
self.frames.pop_front();
|
||||
self.overflow_drops += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take(&mut self) -> Option<T> {
|
||||
if self.capacity == 0 {
|
||||
return self.frames.pop_front();
|
||||
}
|
||||
if !self.prerolled {
|
||||
// Preroll gate: without it a steady stream drains every frame on arrival
|
||||
// and jitter headroom never builds (the Apple store's lesson).
|
||||
if self.frames.len() < self.capacity {
|
||||
return None;
|
||||
}
|
||||
self.prerolled = true;
|
||||
}
|
||||
match self.frames.pop_front() {
|
||||
Some(f) => Some(f),
|
||||
None => {
|
||||
self.underflows += 1;
|
||||
self.prerolled = false;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame taken but not presented (gate closed, present failed before consuming
|
||||
/// it). Newest-wins reinserts only into an empty slot — a fresher decode wins;
|
||||
/// FIFO puts it back at the front (it is the oldest).
|
||||
pub(crate) fn put_back(&mut self, f: T) {
|
||||
if self.capacity == 0 {
|
||||
if self.frames.is_empty() {
|
||||
self.frames.push_back(f);
|
||||
}
|
||||
} else {
|
||||
self.frames.push_front(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse to newest-wins for the rest of the stream (PyroWave: its plane-ring
|
||||
/// retirement accounting assumes the depth-2 newest-wins hand-off, and its all-intra
|
||||
/// frames make buffering pointless anyway).
|
||||
///
|
||||
/// Gated with its only caller: the power-user build (`--no-default-features`, which
|
||||
/// the Windows ARM64 leg ships) has no PyroWave decode path, and an ungated helper
|
||||
/// is dead code there.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
pub(crate) fn force_latency(&mut self) {
|
||||
if self.capacity == 0 {
|
||||
return;
|
||||
}
|
||||
self.capacity = 0;
|
||||
self.prerolled = false;
|
||||
while self.frames.len() > 1 {
|
||||
self.frames.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the window's counters: `(replaced, overflow_drops, underflows)`.
|
||||
pub(crate) fn take_counters(&mut self) -> (u32, u32, u32) {
|
||||
let c = (self.replaced, self.overflow_drops, self.underflows);
|
||||
self.replaced = 0;
|
||||
self.overflow_drops = 0;
|
||||
self.underflows = 0;
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel latch grid: a recent on-glass instant + the latch period, extrapolated
|
||||
/// forward for slot targeting.
|
||||
///
|
||||
/// The period learner is the SHARED [`punktfunk_core::phase::PanelGrid`], not a local
|
||||
/// rule. An earlier version of this clock capped the learned period at the display
|
||||
/// mode's refresh, on the reasoning that a stream running below panel rate spaces its
|
||||
/// presents at k×period and the cap stops a 30 fps stream claiming a 30 Hz panel. That
|
||||
/// cap is the same defect the Android presenter shipped in 0.23.0: the seed is only what
|
||||
/// the *mode* claims, and when the real panel is slower (a refused mode switch, a
|
||||
/// compositor running its own rate) a downward-only learner pins a grid that never
|
||||
/// arrives, for the whole session, with no way back. `PanelGrid` moves both ways —
|
||||
/// narrowing at once, widening only after eight consecutive agreeing observations and
|
||||
/// then to the narrowest of them.
|
||||
///
|
||||
/// What is fed to it is still the window's MIN spacing: within one window that resists
|
||||
/// the k×period inflation the old cap was aimed at, while the streak requirement means a
|
||||
/// genuinely slower panel is still discovered. Same grid the host-facing `LatchGrid`
|
||||
/// publish reads, so the phase-lock report and the local scheduler cannot disagree.
|
||||
pub(crate) struct LatchClock {
|
||||
anchor_ns: u64,
|
||||
/// The previous stamp, kept ACROSS calls. The run loop drains present-wait samples
|
||||
/// every pass, so a "batch" is very often a single stamp — computing spacings only
|
||||
/// within a batch (`windows(2)`) observed nothing at all on glass, and the learner
|
||||
/// silently ran on its seed forever.
|
||||
last_ns: u64,
|
||||
/// Narrowest spacing seen since the last handoff to the grid, and how many have
|
||||
/// accumulated. The grid is fed the MIN of a run rather than every spacing: our
|
||||
/// observations are the spacing of OUR presents, which is k×period whenever the
|
||||
/// stream runs below panel rate, and the min over a run is the best available
|
||||
/// estimate of the true grid step.
|
||||
pending_min_ns: u64,
|
||||
pending_count: u32,
|
||||
grid: punktfunk_core::phase::PanelGrid,
|
||||
fallback_period_ns: u64,
|
||||
}
|
||||
|
||||
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
|
||||
/// mode change is picked up in well under a second at any sane frame rate.
|
||||
const GRID_OBSERVE_EVERY: u32 = 16;
|
||||
|
||||
impl LatchClock {
|
||||
pub(crate) fn new(refresh_hz: u32) -> LatchClock {
|
||||
LatchClock {
|
||||
anchor_ns: 0,
|
||||
last_ns: 0,
|
||||
pending_min_ns: 0,
|
||||
pending_count: 0,
|
||||
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
|
||||
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
|
||||
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
|
||||
/// the learner.
|
||||
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
|
||||
for &s in stamps {
|
||||
if self.last_ns != 0 && s > self.last_ns {
|
||||
let d = s - self.last_ns;
|
||||
// < 1 ms apart = a queued pair, not a grid step.
|
||||
if d > 1_000_000 {
|
||||
self.pending_min_ns = if self.pending_min_ns == 0 {
|
||||
d
|
||||
} else {
|
||||
self.pending_min_ns.min(d)
|
||||
};
|
||||
self.pending_count += 1;
|
||||
if self.pending_count >= GRID_OBSERVE_EVERY {
|
||||
self.grid.observe(self.pending_min_ns as i64);
|
||||
self.pending_min_ns = 0;
|
||||
self.pending_count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_ns = s;
|
||||
}
|
||||
if let Some(&last) = stamps.last() {
|
||||
self.anchor_ns = last;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn period_ns(&self) -> u64 {
|
||||
let learned = self.grid.period_ns();
|
||||
if learned > 0 {
|
||||
learned as u64
|
||||
} else {
|
||||
self.fallback_period_ns
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn anchor_ns(&self) -> u64 {
|
||||
self.anchor_ns
|
||||
}
|
||||
|
||||
/// The first predicted latch strictly after `after_ns` (`anchor + k·period`). With
|
||||
/// no anchor yet: one period out — callers get a usable, if unanchored, deadline.
|
||||
pub(crate) fn next_slot_after(&self, after_ns: u64) -> u64 {
|
||||
let p = self.period_ns();
|
||||
if self.anchor_ns == 0 || after_ns < self.anchor_ns {
|
||||
return after_ns.saturating_add(p);
|
||||
}
|
||||
let k = (after_ns - self.anchor_ns) / p + 1;
|
||||
self.anchor_ns + k * p
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the panel is refreshing on a fixed grid or following our cadence.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub(crate) enum Cadence {
|
||||
/// Not enough evidence yet — say nothing rather than guess.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// On-glass instants land on multiples of the panel period: a fixed-refresh panel.
|
||||
Fixed,
|
||||
/// On-glass instants track our present spacing instead: variable refresh is live.
|
||||
Variable,
|
||||
}
|
||||
|
||||
impl Cadence {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Cadence::Unknown => "",
|
||||
Cadence::Fixed => "no",
|
||||
Cadence::Variable => "yes",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is variable refresh actually live? **Measured, never queried** — no portable query
|
||||
/// exists (SDL exposes none, Wayland does not report adaptive-sync state, and Windows
|
||||
/// surfaces nothing through Vulkan), and the platforms that *do* answer have been caught
|
||||
/// lying before (Android reports a game-uid's down-rated refresh as the panel's).
|
||||
///
|
||||
/// The discriminator is quantization. On a fixed-refresh panel every on-glass instant
|
||||
/// lands on the vblank grid, so the spacing between consecutive presents is always
|
||||
/// ~k×period for whole k — even when the stream runs slower than the panel, where it just
|
||||
/// picks a larger k. Under real VRR the panel refreshes *when we present*, so the spacing
|
||||
/// follows our own cadence and sits wherever it likes relative to the grid.
|
||||
///
|
||||
/// So: fold each delta to its distance from the nearest multiple of the period. Tight
|
||||
/// against the grid ⇒ Fixed; consistently off it ⇒ Variable. A stream running exactly at
|
||||
/// panel rate is indistinguishable either way (both give delta ≈ period), which is
|
||||
/// harmless — at that rate VRR has nothing to do.
|
||||
pub(crate) struct CadenceProbe {
|
||||
/// Off-grid distances as a fraction of the period, in thousandths.
|
||||
off_grid_milli: Vec<u32>,
|
||||
/// Previous stamp, kept across calls for the same reason [`LatchClock`] does: the
|
||||
/// live drain hands over one sample at a time.
|
||||
last_ns: u64,
|
||||
/// The last round's raw reading and how many rounds have agreed — a verdict is only
|
||||
/// published once [`CADENCE_STABLE_ROUNDS`] agree.
|
||||
candidate: Cadence,
|
||||
agree_rounds: u8,
|
||||
verdict: Cadence,
|
||||
}
|
||||
|
||||
/// Enough deltas to distinguish jitter from a real off-grid cadence.
|
||||
const CADENCE_MIN_SAMPLES: usize = 24;
|
||||
/// Consecutive agreeing rounds before a verdict is published.
|
||||
///
|
||||
/// ⭐ On glass (GNOME/Wayland, .21, 2026-08-02) the raw per-round verdict FLAPPED between
|
||||
/// runs with VRR provably disabled. The cause is structural, not a tuning miss: under a
|
||||
/// compositor our on-glass stamp is the compositor's release, so anything that perturbs
|
||||
/// delivery — an occluded or unfocused surface being throttled, a distressed pipeline
|
||||
/// missing vblanks — smears the spacings exactly the way real VRR does. This probe can
|
||||
/// therefore only ever say "presents are not landing on the grid", so it demands
|
||||
/// agreement across rounds and refuses evidence from a distressed window (see
|
||||
/// [`CadenceProbe::note`]'s `healthy` flag) before claiming anything.
|
||||
const CADENCE_STABLE_ROUNDS: u8 = 2;
|
||||
/// Median off-grid distance under this fraction of a period reads as grid-locked. Present
|
||||
/// stamps carry real measurement jitter (the wait returns, then we read the clock), so
|
||||
/// this is deliberately loose — the two regimes differ by far more than this in practice.
|
||||
const CADENCE_FIXED_MILLI: u32 = 150;
|
||||
|
||||
impl CadenceProbe {
|
||||
pub(crate) fn new() -> CadenceProbe {
|
||||
CadenceProbe {
|
||||
off_grid_milli: Vec::with_capacity(64),
|
||||
last_ns: 0,
|
||||
candidate: Cadence::Unknown,
|
||||
agree_rounds: 0,
|
||||
verdict: Cadence::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold on-glass stamps against the learned panel period. Spacings are measured
|
||||
/// against the previous stamp whatever the batching.
|
||||
///
|
||||
/// `healthy` is the caller's statement that this window's presents were flowing
|
||||
/// normally (no stale force-opens). A distressed pipeline smears spacings for reasons
|
||||
/// that have nothing to do with the panel, so its evidence is dropped — the timeline
|
||||
/// continuity is still advanced, it simply does not count as a sample.
|
||||
pub(crate) fn note(&mut self, stamps: &[u64], period_ns: u64, healthy: bool) {
|
||||
if period_ns == 0 || !healthy {
|
||||
self.last_ns = stamps.last().copied().unwrap_or(self.last_ns);
|
||||
return;
|
||||
}
|
||||
for &s in stamps {
|
||||
let prev = std::mem::replace(&mut self.last_ns, s);
|
||||
if prev == 0 || s <= prev {
|
||||
continue;
|
||||
}
|
||||
let delta = s - prev;
|
||||
let rem = delta % period_ns;
|
||||
// Distance to the NEAREST multiple, so a delta just under k×period reads as
|
||||
// close to the grid rather than a whole period away from k-1.
|
||||
let off = rem.min(period_ns - rem);
|
||||
self.off_grid_milli
|
||||
.push((off.saturating_mul(1000) / period_ns) as u32);
|
||||
// A round closes on the SAMPLE count, inside the loop — not once per call.
|
||||
// Evaluating per call would make the verdict depend on how the caller happens
|
||||
// to batch its stamps (one big batch = one round, forever short of the
|
||||
// agreement requirement), and the live drain and the tests batch differently.
|
||||
self.close_round_if_ready();
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a verdict once a round's worth of spacings agree with the previous round.
|
||||
fn close_round_if_ready(&mut self) {
|
||||
if self.off_grid_milli.len() >= CADENCE_MIN_SAMPLES {
|
||||
self.off_grid_milli.sort_unstable();
|
||||
let median = self.off_grid_milli[self.off_grid_milli.len() / 2];
|
||||
let round = if median <= CADENCE_FIXED_MILLI {
|
||||
Cadence::Fixed
|
||||
} else {
|
||||
Cadence::Variable
|
||||
};
|
||||
if round == self.candidate {
|
||||
self.agree_rounds = self.agree_rounds.saturating_add(1);
|
||||
} else {
|
||||
self.candidate = round;
|
||||
self.agree_rounds = 1;
|
||||
}
|
||||
if self.agree_rounds >= CADENCE_STABLE_ROUNDS {
|
||||
self.verdict = round;
|
||||
}
|
||||
self.off_grid_milli.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn verdict(&self) -> Cadence {
|
||||
self.verdict
|
||||
}
|
||||
|
||||
/// A mode switch / display change invalidates the evidence.
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.off_grid_milli.clear();
|
||||
self.last_ns = 0;
|
||||
self.candidate = Cadence::Unknown;
|
||||
self.agree_rounds = 0;
|
||||
self.verdict = Cadence::Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// The FIFO glass budget: at most one undisplayed present in flight, measured by the
|
||||
/// present-wait waiter's outstanding count. Never consulted under MAILBOX/IMMEDIATE
|
||||
/// (they cannot queue) or without present-wait (nothing to count with — behavior is
|
||||
/// then exactly the shipped arrival pacing).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PresentGate {
|
||||
/// Submit stamp of the newest tracked present; 0 = none yet.
|
||||
last_present_ns: u64,
|
||||
gated: u32,
|
||||
forced: u32,
|
||||
}
|
||||
|
||||
impl PresentGate {
|
||||
/// May a new present go out? Open when nothing undisplayed is in flight; a stale
|
||||
/// in-flight present (occlusion, wedged compositor) force-opens after 100 ms so the
|
||||
/// stream survives, counted as `forced`.
|
||||
pub(crate) fn open(&mut self, outstanding: usize, now_ns: u64) -> bool {
|
||||
if outstanding == 0 {
|
||||
return true;
|
||||
}
|
||||
if self.last_present_ns != 0
|
||||
&& now_ns.saturating_sub(self.last_present_ns) > STALE_REOPEN_NS
|
||||
{
|
||||
self.forced += 1;
|
||||
return true;
|
||||
}
|
||||
self.gated += 1;
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn note_present(&mut self, now_ns: u64) {
|
||||
self.last_present_ns = now_ns;
|
||||
}
|
||||
|
||||
/// Drain the window's counters: `(gated, forced)`.
|
||||
pub(crate) fn take_counters(&mut self) -> (u32, u32) {
|
||||
let c = (self.gated, self.forced);
|
||||
self.gated = 0;
|
||||
self.forced = 0;
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Newest-wins: submit replaces, take clears, put_back only fills an empty slot.
|
||||
#[test]
|
||||
fn newest_wins_replaces_and_putback_never_clobbers() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(0);
|
||||
assert!(!s.is_smoothing());
|
||||
assert_eq!(s.take(), None);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
s.submit(3);
|
||||
assert_eq!(s.take(), Some(3), "only the newest survives");
|
||||
assert_eq!(s.take(), None);
|
||||
// A taken-but-unpresented frame returns — unless a fresher one arrived.
|
||||
s.submit(4);
|
||||
let f = s.take().unwrap();
|
||||
s.put_back(f);
|
||||
assert_eq!(s.take(), Some(4));
|
||||
let f = s.take();
|
||||
assert_eq!(f, None);
|
||||
s.submit(5);
|
||||
let f = s.take().unwrap();
|
||||
s.submit(6);
|
||||
s.put_back(f); // 6 arrived while 5 was out — 6 wins
|
||||
assert_eq!(s.take(), Some(6));
|
||||
assert_eq!(
|
||||
s.take_counters(),
|
||||
(2, 0, 0),
|
||||
"two displacements, no fifo counters"
|
||||
);
|
||||
}
|
||||
|
||||
/// FIFO: preroll to capacity, drop-oldest overflow, underflow re-arms the preroll.
|
||||
#[test]
|
||||
fn fifo_prerolls_overflows_oldest_and_rearms_on_dry() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(2);
|
||||
assert!(s.is_smoothing());
|
||||
s.submit(1);
|
||||
assert_eq!(s.take(), None, "prerolling: below capacity, nothing vends");
|
||||
s.submit(2);
|
||||
assert_eq!(s.take(), Some(1), "preroll reached — FIFO order");
|
||||
assert_eq!(
|
||||
s.take(),
|
||||
Some(2),
|
||||
"once prerolled the buffer drains normally"
|
||||
);
|
||||
// Dry after preroll = one underflow, preroll re-arms.
|
||||
assert_eq!(s.take(), None);
|
||||
s.submit(3);
|
||||
assert_eq!(s.take(), None, "re-armed preroll holds again");
|
||||
s.submit(4);
|
||||
assert_eq!(s.take(), Some(3));
|
||||
// Overflow drops the OLDEST: [4] → [4,5] → 6 evicts 4 → 7 evicts 5.
|
||||
s.submit(5);
|
||||
s.submit(6);
|
||||
s.submit(7);
|
||||
assert_eq!(s.take(), Some(6));
|
||||
assert_eq!(s.take(), Some(7));
|
||||
let (replaced, drops, dry) = s.take_counters();
|
||||
assert_eq!(replaced, 0);
|
||||
assert_eq!(drops, 2, "6 evicted 4, 7 evicted 5");
|
||||
assert_eq!(dry, 1);
|
||||
}
|
||||
|
||||
/// put_back under FIFO goes to the FRONT (it is the oldest), and the transient
|
||||
/// capacity+1 is trimmed by the next submit.
|
||||
#[test]
|
||||
fn fifo_putback_restores_order() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(2);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
let f = s.take().unwrap();
|
||||
s.put_back(f);
|
||||
assert_eq!(s.take(), Some(1), "the put-back frame is still first");
|
||||
}
|
||||
|
||||
/// force_latency collapses a smoothing store to a newest-wins slot mid-stream.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
#[test]
|
||||
fn force_latency_collapses_to_one_slot() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(3);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
s.submit(3);
|
||||
s.force_latency();
|
||||
assert!(!s.is_smoothing());
|
||||
assert_eq!(s.take(), Some(3), "only the newest survives the collapse");
|
||||
s.submit(4);
|
||||
s.submit(5);
|
||||
assert_eq!(s.take(), Some(5));
|
||||
}
|
||||
|
||||
/// The clock learns the min positive spacing (capped at the mode refresh), anchors
|
||||
/// on the newest stamp, and extrapolates the next slot; sub-ms pairs (a queued
|
||||
/// double-present) never become the period.
|
||||
#[test]
|
||||
fn latch_clock_learns_and_extrapolates() {
|
||||
const P: u64 = 16_666_666; // 60 Hz
|
||||
let mut c = LatchClock::new(60);
|
||||
assert_eq!(c.period_ns(), P, "fallback = the mode refresh");
|
||||
// No anchor: a usable deadline one period out.
|
||||
assert_eq!(c.next_slot_after(1_000), 1_000 + P);
|
||||
|
||||
c.note_batch(&[1_000_000_000, 1_000_000_000 + P, 1_000_000_000 + 2 * P]);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
assert_eq!(c.anchor_ns(), 1_000_000_000 + 2 * P);
|
||||
let next = c.next_slot_after(c.anchor_ns());
|
||||
assert_eq!(next, 1_000_000_000 + 3 * P);
|
||||
// Mid-slot query lands on the same boundary; a later one steps whole periods.
|
||||
assert_eq!(c.next_slot_after(next - 1), next);
|
||||
assert_eq!(c.next_slot_after(next), next + P);
|
||||
|
||||
// A queued pair (< 1 ms apart) must not poison the period.
|
||||
c.note_batch(&[2_000_000_000, 2_000_000_500]);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
assert_eq!(c.anchor_ns(), 2_000_000_500, "the anchor still advances");
|
||||
|
||||
// A stream presenting every OTHER refresh spaces its glass stamps at 2×P. One
|
||||
// such window must NOT move the grid — the shared learner needs a streak before
|
||||
// it will widen, which is what keeps a briefly-slow stream from claiming a slow
|
||||
// panel while still allowing a genuinely slower display to be discovered.
|
||||
c.note_batch(&[3_000_000_000, 3_000_000_000 + 2 * P]);
|
||||
assert_eq!(c.period_ns(), P, "one wide window is not a slower panel");
|
||||
|
||||
// A single stamp re-anchors without touching the period.
|
||||
c.note_batch(&[5_000_000_000]);
|
||||
assert_eq!(c.anchor_ns(), 5_000_000_000);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
|
||||
// A faster panel learns its own finer grid.
|
||||
let mut fast = LatchClock::new(120);
|
||||
fast.note_batch(&[1_000_000_000, 1_008_333_333]);
|
||||
assert_eq!(fast.period_ns(), 8_333_333);
|
||||
}
|
||||
|
||||
/// ⭐ The live loop drains present-wait samples EVERY pass, so stamps arrive one at a
|
||||
/// time. Measuring spacings only within a batch meant the learner observed nothing on
|
||||
/// glass and silently ran on its seed (found on .21, 2026-08-02: `period_us` read back
|
||||
/// exactly the 60 Hz fallback while the panel really was 60 Hz — correct by luck, and
|
||||
/// wrong the moment the mode lies).
|
||||
#[test]
|
||||
fn latch_clock_learns_from_one_sample_at_a_time() {
|
||||
const REAL: u64 = 16_666_666;
|
||||
let mut c = LatchClock::new(120); // seeded too fast, as a refused mode switch would
|
||||
let mut t = 1_000_000_000u64;
|
||||
for _ in 0..(GRID_OBSERVE_EVERY * 8 + 8) {
|
||||
t += REAL;
|
||||
c.note_batch(&[t]); // ONE stamp per call — the live shape
|
||||
}
|
||||
assert_eq!(
|
||||
c.period_ns(),
|
||||
REAL,
|
||||
"single-stamp batches must still feed the grid learner"
|
||||
);
|
||||
assert_eq!(c.anchor_ns(), t);
|
||||
}
|
||||
|
||||
/// The mode's refresh is a CLAIM, not a measurement — a refused mode switch or a
|
||||
/// compositor running its own rate leaves the seed too fast. The old downward-only
|
||||
/// cap pinned that wrong grid for the session (the Android 0.23.0 defect); the
|
||||
/// shared learner climbs back out once the evidence is consistent.
|
||||
#[test]
|
||||
fn latch_clock_recovers_from_a_seed_faster_than_the_real_panel() {
|
||||
const REAL: u64 = 16_666_666; // the panel is really 60 Hz…
|
||||
let mut c = LatchClock::new(120); // …but the mode claimed 120
|
||||
assert_eq!(c.period_ns(), 8_333_333, "seeded from the claim");
|
||||
|
||||
// Consistent 60 Hz evidence. The grid is fed the MIN of every
|
||||
// GRID_OBSERVE_EVERY spacings, and PanelGrid widens only after 8 agreeing
|
||||
// observations, so a real widen needs 8 × GRID_OBSERVE_EVERY spacings — the
|
||||
// deliberate cost of not letting one slow patch redefine the panel.
|
||||
let mut t = 1_000_000_000u64;
|
||||
for _ in 0..(GRID_OBSERVE_EVERY * 8 + GRID_OBSERVE_EVERY) {
|
||||
t += REAL;
|
||||
c.note_batch(&[t]);
|
||||
}
|
||||
assert_eq!(
|
||||
c.period_ns(),
|
||||
REAL,
|
||||
"a sustained slower grid is adopted instead of aimed past forever"
|
||||
);
|
||||
}
|
||||
|
||||
/// The VRR discriminator: presents landing on the vblank grid read Fixed, presents
|
||||
/// landing wherever our own cadence puts them read Variable — including the case that
|
||||
/// matters most, a stream SLOWER than the panel, where a fixed panel still quantizes
|
||||
/// to a larger whole multiple.
|
||||
#[test]
|
||||
fn cadence_probe_separates_grid_locked_from_variable() {
|
||||
const P: u64 = 8_333_333; // 120 Hz
|
||||
// Enough spacings for CADENCE_STABLE_ROUNDS full rounds: a verdict is published
|
||||
// only once consecutive rounds agree (on glass a single round FLAPPED).
|
||||
const ROUNDS: u64 = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
|
||||
|
||||
// Fixed panel, stream at panel rate: every delta is exactly one period.
|
||||
let mut probe = CadenceProbe::new();
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown, "no evidence yet");
|
||||
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * P).collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Fixed);
|
||||
|
||||
// Fixed panel, stream at HALF panel rate: deltas are 2×P — still grid-locked.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * 2 * P).collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(
|
||||
probe.verdict(),
|
||||
Cadence::Fixed,
|
||||
"a slower stream on a fixed panel picks a larger k, it does not leave the grid"
|
||||
);
|
||||
|
||||
// Fixed panel with realistic measurement jitter (±0.5 ms on an 8.3 ms period)
|
||||
// must not read as variable.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let jitter = [0i64, 300_000, -250_000, 120_000, -400_000, 80_000];
|
||||
let stamps: Vec<u64> = (0..ROUNDS as usize)
|
||||
.map(|i| (1_000_000_000 + i as i64 * P as i64 + jitter[i % jitter.len()]) as u64)
|
||||
.collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Fixed, "jitter is not VRR");
|
||||
|
||||
// VRR live: a 100 fps stream on a 120 Hz-max panel. 10 ms is not a multiple of
|
||||
// 8.33 ms, so every present sits off the grid.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS)
|
||||
.map(|i| 1_000_000_000 + i * 10_000_000)
|
||||
.collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Variable);
|
||||
|
||||
// A display change throws the evidence away rather than carrying a stale verdict.
|
||||
probe.reset();
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
|
||||
// Below the sample floor nothing is claimed.
|
||||
let mut probe = CadenceProbe::new();
|
||||
probe.note(&[1_000_000_000, 1_010_000_000, 1_020_000_000], P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
|
||||
// ⭐ THE SHAPE THE LIVE LOOP ACTUALLY PRODUCES: the run loop drains present-wait
|
||||
// samples every pass, so stamps arrive ONE AT A TIME. Measuring spacings only
|
||||
// within a batch observed nothing at all on glass — `vrr` stayed Unknown and the
|
||||
// latch clock ran on its seed forever. Found on .21, 2026-08-02.
|
||||
let mut probe = CadenceProbe::new();
|
||||
for i in 0..ROUNDS {
|
||||
probe.note(&[1_000_000_000 + i * 10_000_000], P, true); // 100 fps, off a 120 Hz grid
|
||||
}
|
||||
assert_eq!(
|
||||
probe.verdict(),
|
||||
Cadence::Variable,
|
||||
"one-sample batches must still yield spacings"
|
||||
);
|
||||
|
||||
// A period we never learned can't discriminate anything.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS)
|
||||
.map(|i| 1_000_000_000 + i * 10_000_000)
|
||||
.collect();
|
||||
probe.note(&stamps, 0, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
}
|
||||
|
||||
/// ⭐ Batching must not change the verdict. The same spacings delivered as one big
|
||||
/// batch, or one stamp at a time, must reach the same conclusion — the live loop
|
||||
/// drains one at a time while tests hand over vectors, and an evaluation keyed to
|
||||
/// call boundaries silently made the two disagree.
|
||||
#[test]
|
||||
fn cadence_verdict_is_independent_of_batching() {
|
||||
const P: u64 = 8_333_333;
|
||||
let n = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
|
||||
|
||||
let stamps: Vec<u64> = (0..n).map(|i| 1_000_000_000 + i * P).collect();
|
||||
let mut bulk = CadenceProbe::new();
|
||||
bulk.note(&stamps, P, true);
|
||||
|
||||
let mut drip = CadenceProbe::new();
|
||||
for s in &stamps {
|
||||
drip.note(&[*s], P, true);
|
||||
}
|
||||
|
||||
assert_eq!(bulk.verdict(), Cadence::Fixed);
|
||||
assert_eq!(drip.verdict(), bulk.verdict(), "batching must not matter");
|
||||
}
|
||||
|
||||
/// Gate: open at zero outstanding, closed at one, force-open past the stale bound.
|
||||
#[test]
|
||||
fn gate_budgets_one_undisplayed_present() {
|
||||
let mut g = PresentGate::default();
|
||||
let t0 = 1_000_000_000u64;
|
||||
assert!(g.open(0, t0));
|
||||
g.note_present(t0);
|
||||
assert!(!g.open(1, t0 + 8_000_000), "one in flight — hold");
|
||||
assert!(
|
||||
g.open(1, t0 + STALE_REOPEN_NS + 1),
|
||||
"stale in-flight present force-opens"
|
||||
);
|
||||
let (gated, forced) = g.take_counters();
|
||||
assert_eq!((gated, forced), (1, 1));
|
||||
assert_eq!(g.take_counters(), (0, 0), "counters drain");
|
||||
}
|
||||
}
|
||||
+490
-45
@@ -18,12 +18,15 @@
|
||||
|
||||
use crate::input::{Capture, FingerPhase};
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::present_pace::{
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
|
||||
};
|
||||
use crate::touch::Abs;
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
use anyhow::{Context as _, Result};
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use pf_client_core::trust::{MouseMode, PresentPriority, StatsVerbosity, TouchMode};
|
||||
use pf_client_core::video::VulkanDecodeDevice;
|
||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -63,6 +66,20 @@ pub struct SessionOpts {
|
||||
/// work profile that streams on a second screen and still Alt-Tabs here. Never applies
|
||||
/// under the `desktop` mouse model, which is something you Alt-Tab *away* from.
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Presentation intent ([`Settings::present_priority`] resolved): `Latency` keeps the
|
||||
/// shipped arrival pacing (newest-wins, present the moment a frame can go out);
|
||||
/// `Smooth { buffer }` runs the smoothing FIFO drained one frame per latch slot
|
||||
/// (design/desktop-presentation-rebuild.md). `PUNKTFUNK_PRESENTER=arrival` overrides
|
||||
/// the whole engine back to the legacy drain for field A/B without a rebuild.
|
||||
pub present_priority: PresentPriority,
|
||||
/// Tear-free presentation ([`Settings::vsync`], default on). Off asks for a tearing
|
||||
/// present mode for the lowest possible latch — best-effort, and the mode that
|
||||
/// actually took is named in the stats line.
|
||||
pub vsync: bool,
|
||||
/// Let a variable-refresh display follow the stream cadence ([`Settings::allow_vrr`],
|
||||
/// default on) — prefers the present mode that drives VRR panels directly when the
|
||||
/// session starts fullscreen.
|
||||
pub allow_vrr: bool,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
@@ -213,8 +230,47 @@ struct StreamState {
|
||||
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
||||
win_e2e_us: Vec<u64>,
|
||||
win_disp_us: Vec<u64>,
|
||||
/// The display stage's two halves (present-timing sessions only): decoded→submit and
|
||||
/// submit→on-glass. See [`PresentedWindow::pace_ms`].
|
||||
win_pace_us: Vec<u64>,
|
||||
win_latch_us: Vec<u64>,
|
||||
win_start: Instant,
|
||||
presented: PresentedWindow,
|
||||
/// The intent engine (design/desktop-presentation-rebuild.md WP2): the decoded-frame
|
||||
/// store between the wake channel and the present call — a newest-wins slot under
|
||||
/// the latency intent (behaviorally the shipped drain), the smoothing FIFO under
|
||||
/// smoothness. NOTE: a smoothing store holds decoder-pool frames (Vulkan-Video
|
||||
/// AVFrames) up to `buffer` deep on top of the depth-2 wake channels — within pool
|
||||
/// headroom for 1..=3, but any deeper store must revisit pool sizing.
|
||||
store: FrameStore<DecodedFrame>,
|
||||
/// The panel latch grid (present-wait glass stamps; submit-anchored fallback) — the
|
||||
/// smoothness slot clock, and the values published to the host-facing `latch_grid`.
|
||||
clock: LatchClock,
|
||||
/// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes
|
||||
/// or without present timing.
|
||||
gate: PresentGate,
|
||||
/// Is variable refresh actually live? Measured from the same on-glass stamps (no
|
||||
/// portable query exists) — see [`CadenceProbe`].
|
||||
cadence: CadenceProbe,
|
||||
/// The DISPLAY MODE's refresh period — the vblank grid presents quantize to when
|
||||
/// VRR is off, and so the cadence probe's reference. Deliberately not the learned
|
||||
/// period (see the probe's call site).
|
||||
mode_period_ns: u64,
|
||||
/// The latch slot the last smoothness present served (one present per slot); 0 =
|
||||
/// none yet.
|
||||
last_target_ns: u64,
|
||||
/// Smoothness slot-pick margin: starts 0 (a fixed lead is pure display tax —
|
||||
/// measured on Android), widens +500 µs per >2-miss window toward 2.5 ms.
|
||||
margin_ns: u64,
|
||||
/// This window's latch misses (a present that reached glass > 1.5 latch periods
|
||||
/// after submit) — the adaptive margin's error signal.
|
||||
win_misses: u32,
|
||||
/// This window's peak undisplayed-presents-in-flight (present timing only).
|
||||
win_out_max: usize,
|
||||
/// One-shot log latch: smoothness was requested but a PyroWave stream collapsed the
|
||||
/// store to latency (its plane-ring retirement assumes the newest-wins hand-off).
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_latency_forced: bool,
|
||||
// Hardware-path health: a failure streak (or a device with no import support at
|
||||
// all) demotes the decoder to software via the shared flag — once per session.
|
||||
dmabuf_demoted: bool,
|
||||
@@ -279,6 +335,8 @@ impl StreamState {
|
||||
params: SessionParams,
|
||||
force_software: Arc<AtomicBool>,
|
||||
wake: sdl3::event::EventSender,
|
||||
priority: PresentPriority,
|
||||
native_refresh_hz: u32,
|
||||
) -> StreamState {
|
||||
let profile = params.profile.clone();
|
||||
// The presenter's half of phase-locked capture: it writes the latch grid the
|
||||
@@ -316,8 +374,21 @@ impl StreamState {
|
||||
hdr_untonemapped: false,
|
||||
win_e2e_us: Vec::with_capacity(256),
|
||||
win_disp_us: Vec::with_capacity(256),
|
||||
win_pace_us: Vec::with_capacity(256),
|
||||
win_latch_us: Vec::with_capacity(256),
|
||||
win_start: Instant::now(),
|
||||
presented: PresentedWindow::default(),
|
||||
store: FrameStore::new(usize::from(priority.fifo_capacity())),
|
||||
clock: LatchClock::new(native_refresh_hz),
|
||||
gate: PresentGate::default(),
|
||||
cadence: CadenceProbe::new(),
|
||||
mode_period_ns: 1_000_000_000 / u64::from(native_refresh_hz.max(1)),
|
||||
last_target_ns: 0,
|
||||
margin_ns: 0,
|
||||
win_misses: 0,
|
||||
win_out_max: 0,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_latency_forced: false,
|
||||
dmabuf_demoted: false,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_present_warned: false,
|
||||
@@ -356,6 +427,25 @@ impl StreamState {
|
||||
}
|
||||
self.handle.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// The event-loop wait bound: a smoothness stream with buffered frames sleeps only
|
||||
/// to its next latch-slot deadline; everything else keeps the 15 ms housekeeping
|
||||
/// tick (frames, input, and present completions all wake the loop early anyway).
|
||||
fn wake_timeout(&self) -> Duration {
|
||||
const TICK: Duration = Duration::from_millis(15);
|
||||
if !self.store.is_smoothing() || self.store.is_empty() {
|
||||
return TICK;
|
||||
}
|
||||
let now = session::now_ns();
|
||||
let mut target = self
|
||||
.clock
|
||||
.next_slot_after(now.saturating_add(self.margin_ns));
|
||||
if target == self.last_target_ns {
|
||||
// This slot is already served — the next boundary is the deadline.
|
||||
target += self.clock.period_ns();
|
||||
}
|
||||
Duration::from_nanos(target.saturating_sub(now)).clamp(Duration::from_millis(1), TICK)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a present error is `VK_ERROR_DEVICE_LOST` anywhere in its chain. A lost
|
||||
@@ -438,9 +528,41 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
let instance_exts = window
|
||||
.vulkan_instance_extensions()
|
||||
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
|
||||
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
|
||||
let mut presenter = Presenter::new(
|
||||
&window,
|
||||
&instance_exts,
|
||||
crate::vk::PresentPref {
|
||||
vsync: opts.vsync,
|
||||
allow_vrr: opts.allow_vrr,
|
||||
fullscreen: opts.fullscreen,
|
||||
// Resolved from the env inside `Presenter::new` — the swapchain owns that
|
||||
// decision so every caller gets the same (opt-in) default.
|
||||
vrr_fifo_opt_in: false,
|
||||
},
|
||||
)
|
||||
.context("vulkan presenter")?;
|
||||
// A valid black frame immediately — the window is honest while the connect runs.
|
||||
presenter.present(&window, FrameInput::Redraw, None)?;
|
||||
|
||||
// `PUNKTFUNK_PRESENTER=arrival` — the legacy drain, the intent engine's field-A/B
|
||||
// kill switch (the Android sysprop pattern: no rebuild to bisect a pacing suspicion).
|
||||
let arrival_override = std::env::var("PUNKTFUNK_PRESENTER").ok().as_deref() == Some("arrival");
|
||||
let present_priority = if arrival_override {
|
||||
tracing::info!("PUNKTFUNK_PRESENTER=arrival — presentation pacing disabled");
|
||||
PresentPriority::Latency
|
||||
} else {
|
||||
opts.present_priority
|
||||
};
|
||||
let pacing_active = !arrival_override;
|
||||
let present_debug = std::env::var_os("PUNKTFUNK_PRESENT_DEBUG").is_some();
|
||||
// Present completions wake the loop exactly like decoded frames: a glass-gate
|
||||
// reopen or a smoothness slot must not wait out the event timeout.
|
||||
{
|
||||
let sender = events.event_sender();
|
||||
presenter.set_present_wake(Box::new(move || {
|
||||
let _ = sender.push_custom_event(FrameWake);
|
||||
}));
|
||||
}
|
||||
// Browse mode is "ready" the moment the library window presents — there may never be
|
||||
// a stream. (Single mode announces on the first VIDEO frame instead, further down, so
|
||||
// a shell only yields to a window that actually shows the stream.)
|
||||
@@ -517,6 +639,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
present_priority,
|
||||
native.refresh_hz,
|
||||
))
|
||||
}
|
||||
ModeCtl::Browse(_) => None,
|
||||
@@ -544,8 +668,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// forwarder's FrameWake) all land in this one queue, so the loop wakes exactly
|
||||
// when there is work — a short-timeout poll here burned a full core (measured;
|
||||
// the timeout only bounds stop-flag/pump-tick latency now). In browse-idle the
|
||||
// per-iteration FIFO present vsync-throttles the loop anyway.
|
||||
let timeout = Duration::from_millis(15);
|
||||
// per-iteration FIFO present vsync-throttles the loop anyway. A smoothness
|
||||
// stream tightens the bound to its next latch-slot deadline.
|
||||
let timeout = stream
|
||||
.as_ref()
|
||||
.map_or(Duration::from_millis(15), |st| st.wake_timeout());
|
||||
let first = event_pump.wait_event_timeout(timeout);
|
||||
let mut queued: Vec<Event> = Vec::new();
|
||||
if let Some(e) = first {
|
||||
@@ -608,6 +735,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dragged to another monitor (or the mode changed under us): the
|
||||
// latch grid and the VRR verdict both belong to the OLD panel. The
|
||||
// refresh rate used to be read once at startup and never revisited,
|
||||
// so a 60 Hz-seeded clock would keep pacing a 144 Hz panel.
|
||||
WindowEvent::DisplayChanged(..) => {
|
||||
let hz = window
|
||||
.get_display()
|
||||
.and_then(|d| d.get_mode())
|
||||
.map(|m| m.refresh_rate.round().max(0.0) as u32)
|
||||
.unwrap_or(0);
|
||||
if let Some(st) = stream.as_mut() {
|
||||
if hz > 0 {
|
||||
st.clock = LatchClock::new(hz);
|
||||
st.mode_period_ns = 1_000_000_000 / u64::from(hz);
|
||||
}
|
||||
st.cadence.reset();
|
||||
st.last_target_ns = 0;
|
||||
tracing::info!(
|
||||
refresh_hz = hz,
|
||||
"display changed — relearning the latch grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
WindowEvent::Exposed => {
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
}
|
||||
@@ -1032,6 +1182,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
*params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
present_priority,
|
||||
native.refresh_hz,
|
||||
));
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Connecting);
|
||||
@@ -1279,11 +1431,148 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
presenter.set_hdr_metadata(m);
|
||||
}
|
||||
}
|
||||
let mut newest: Option<DecodedFrame> = None;
|
||||
while let Ok(f) = st.frames.try_recv() {
|
||||
newest = Some(f);
|
||||
// Present-wait completions drive the latch clock, the glass gate, and the
|
||||
// host-facing grid — drained every pass (a 1 Hz batch would starve all
|
||||
// three; the waiter's SDL wake pairs with this so completions never wait
|
||||
// out the event timeout).
|
||||
if presenter.present_timing_active() {
|
||||
let samples = presenter.take_presented_samples();
|
||||
if !samples.is_empty() {
|
||||
let clock_offset_ns = st
|
||||
.clock_offset
|
||||
.as_ref()
|
||||
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||
let period = st.clock.period_ns();
|
||||
let mut stamps = Vec::with_capacity(samples.len());
|
||||
for s in &samples {
|
||||
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128
|
||||
- s.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
st.win_e2e_us.push(e2e / 1000);
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
// The display split (WP4): our pipeline vs the vsync latch. Only
|
||||
// meaningful with true glass stamps, which is exactly when this
|
||||
// branch runs.
|
||||
st.win_pace_us
|
||||
.push(s.submitted_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
st.win_latch_us
|
||||
.push(s.displayed_ns.saturating_sub(s.submitted_ns) / 1000);
|
||||
// Latch miss (the adaptive margin's error signal): glass later
|
||||
// than one panel period past submit, PLUS the lead we already
|
||||
// applied — i.e. the slot we aimed at was missed. Measuring the
|
||||
// real latch rather than the store's own evictions is the
|
||||
// Android 0.23.0 correction: policy drops happen whenever the
|
||||
// stream out-runs the panel and say nothing about the latch, and
|
||||
// widening on them walked the margin to its ceiling on healthy
|
||||
// devices, re-imposing the very display latency it had removed.
|
||||
if st.store.is_smoothing()
|
||||
&& s.displayed_ns.saturating_sub(s.submitted_ns) > period + st.margin_ns
|
||||
{
|
||||
st.win_misses += 1;
|
||||
}
|
||||
stamps.push(s.displayed_ns);
|
||||
}
|
||||
st.clock.note_batch(&stamps);
|
||||
// Same stamps answer "is VRR live" — the panel either quantizes them
|
||||
// to its grid or follows our cadence. Evidence only counts from a
|
||||
// window whose presents were flowing normally: a distressed pipeline
|
||||
// (stale force-opens) smears spacings for reasons that have nothing
|
||||
// to do with the panel, and on glass that flapped the verdict.
|
||||
//
|
||||
// ⚠ The reference is the DISPLAY MODE's period, NOT the learned one.
|
||||
// The learned grid comes from our own present spacings, and a stream
|
||||
// running below panel rate only ever produces multiples ≥ its frame
|
||||
// interval — so the learner adopts our cadence as "the grid" and every
|
||||
// delta then looks on-grid by construction. Measured on .21
|
||||
// (2026-08-02): a 40-50 fps stream on a 60 Hz panel learned 18-22 ms
|
||||
// and the probe reported VRR on a display with VRR provably disabled.
|
||||
// The vblank grid is the mode's refresh; that is what presents
|
||||
// quantize to when VRR is off.
|
||||
//
|
||||
// ⚠⚠ And it is only asked under a FIFO-family mode. The whole test
|
||||
// rests on "with VRR off, a present waits for vblank" — MAILBOX and
|
||||
// IMMEDIATE deliberately break that, so their stamps are never
|
||||
// grid-quantized and the probe would call every mailbox session VRR.
|
||||
// Measured on .21: same panel, same second — fifo read `no`
|
||||
// (correct, period 16.56 ms), mailbox read `yes` (wrong). Outside
|
||||
// FIFO the honest answer is "cannot tell", i.e. Unknown.
|
||||
let healthy = st.presented.forced == 0;
|
||||
if presenter.fifo_present_mode() {
|
||||
st.cadence.note(&stamps, st.mode_period_ns, healthy);
|
||||
}
|
||||
// Phase-locked capture, the presenter's half: publish the grid the
|
||||
// local clock just learned — a recent TRUE on-glass instant plus
|
||||
// the latch period — for the pump's ~1 Hz PhaseReport. One learner
|
||||
// feeds both, so the report and the scheduler cannot disagree.
|
||||
if let Some(grid) = &st.latch_grid {
|
||||
grid.period_ns
|
||||
.store(st.clock.period_ns(), Ordering::Relaxed);
|
||||
grid.anchor_ns
|
||||
.store(st.clock.anchor_ns(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = newest {
|
||||
|
||||
// Intake into the intent store: a newest-wins slot under latency (the
|
||||
// shipped drain, now with displacement counters), the smoothing FIFO under
|
||||
// smoothness. PyroWave collapses smoothness to latency for the stream: its
|
||||
// plane-ring retirement accounting assumes the newest-wins hand-off
|
||||
// (`video_pyrowave::RETIRE_HANDOVERS`), and all-intra frames make
|
||||
// buffering moot anyway.
|
||||
while let Ok(f) = st.frames.try_recv() {
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if st.store.is_smoothing() && matches!(f.image, DecodedImage::PyroWave(_)) {
|
||||
st.store.force_latency();
|
||||
if !st.pyro_latency_forced {
|
||||
st.pyro_latency_forced = true;
|
||||
tracing::info!(
|
||||
"PyroWave stream — smoothness buffering does not apply \
|
||||
(latency pacing)"
|
||||
);
|
||||
}
|
||||
}
|
||||
st.store.submit(f);
|
||||
}
|
||||
|
||||
// One frame out, by intent: latency takes the newest whenever the glass
|
||||
// gate allows; smoothness serves at most one frame per latch slot (the
|
||||
// preroll/underflow behavior lives in the store).
|
||||
let now_ns = session::now_ns();
|
||||
let mut slot_target = 0u64;
|
||||
let mut to_present = if st.store.is_smoothing() {
|
||||
let target = st
|
||||
.clock
|
||||
.next_slot_after(now_ns.saturating_add(st.margin_ns));
|
||||
if target != st.last_target_ns {
|
||||
slot_target = target;
|
||||
st.store.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
st.store.take()
|
||||
};
|
||||
// The FIFO glass budget: one undisplayed present in flight, so the
|
||||
// swapchain's own FIFO can never become a standing queue (a measured
|
||||
// 11-13 ms at 60 Hz on MAILBOX-less drivers). Only FIFO modes queue and
|
||||
// only present timing can count, so everywhere else this stays inert and
|
||||
// behavior is the shipped arrival pacing.
|
||||
if pacing_active && presenter.fifo_present_mode() && presenter.present_timing_active() {
|
||||
if let Some(f) = to_present.take() {
|
||||
if st.gate.open(presenter.presents_outstanding(), now_ns) {
|
||||
to_present = Some(f);
|
||||
} else {
|
||||
// Parked: a newest-wins store replaces it if a fresher frame
|
||||
// lands; the waiter's wake (or the 100 ms stale force-open)
|
||||
// retries.
|
||||
st.store.put_back(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = to_present {
|
||||
// Resize END: a frame at the steered target size means the sharp new-mode
|
||||
// picture is here — lift the scrim. A no-op unless a switch is in flight.
|
||||
let (fw, fh) = f.image.dimensions();
|
||||
@@ -1472,6 +1761,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
};
|
||||
if did_present {
|
||||
presented_video = true;
|
||||
// Smoothness: this latch slot is served — one present per slot.
|
||||
// (Set only on success: a gated or failed present leaves the slot
|
||||
// open for the retry.)
|
||||
if slot_target != 0 {
|
||||
st.last_target_ns = slot_target;
|
||||
}
|
||||
if opts.json_status && !st.ready_announced {
|
||||
st.ready_announced = true;
|
||||
println!("{{\"ready\":true}}");
|
||||
@@ -1481,6 +1776,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// e2e/display samples arrive via `take_presented_samples` with a
|
||||
// TRUE on-glass stamp instead of the submit-time one below.
|
||||
presenter.note_presented(pts_ns, decoded_ns);
|
||||
st.gate.note_present(now_ns);
|
||||
st.win_out_max = st.win_out_max.max(presenter.presents_outstanding());
|
||||
} else {
|
||||
let displayed_ns = session::now_ns();
|
||||
// The `displayed` stamp (same clamp rules as the pump's windows).
|
||||
@@ -1495,59 +1792,81 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(displayed_ns.saturating_sub(decoded_ns) / 1000);
|
||||
// No glass stamps on this stack: the submit instant anchors an
|
||||
// approximate grid on the mode's refresh period, so smoothness
|
||||
// still drains one frame per (approximate) slot.
|
||||
st.clock.note_batch(&[displayed_ns]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fold the presenter window into the shared stats line once per second.
|
||||
// (The on-glass samples themselves are drained every pass above — they
|
||||
// drive the latch clock and glass gate, not just this fold.)
|
||||
if st.win_start.elapsed() >= Duration::from_secs(1) {
|
||||
// On-glass samples the present-wait waiter completed this window (empty
|
||||
// when timing is inactive — the legacy submit-time pushes fill in then).
|
||||
let clock_offset_ns = st
|
||||
.clock_offset
|
||||
.as_ref()
|
||||
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||
let samples = presenter.take_presented_samples();
|
||||
// Phase-locked capture, the presenter's half: publish this window's latch
|
||||
// grid — a recent TRUE on-glass instant plus the panel period — for the
|
||||
// pump's ~1 Hz PhaseReport. The period is the min positive spacing of
|
||||
// consecutive on-glass stamps (Apple's method: honest under VRR), capped
|
||||
// by the display mode's refresh — under arrival-paced MAILBOX a stream
|
||||
// running below the panel rate spaces its presents at k×period, and the
|
||||
// cap keeps a 30 fps stream from claiming a 30 Hz panel grid.
|
||||
if let Some(grid) = &st.latch_grid {
|
||||
if let Some(last) = samples.last() {
|
||||
let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1));
|
||||
let min_delta = samples
|
||||
.windows(2)
|
||||
.map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns))
|
||||
.filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step
|
||||
.min()
|
||||
.unwrap_or(refresh_period);
|
||||
grid.period_ns
|
||||
.store(min_delta.min(refresh_period), Ordering::Relaxed);
|
||||
grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
for s in samples {
|
||||
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
st.win_e2e_us.push(e2e / 1000);
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
}
|
||||
let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us);
|
||||
let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us);
|
||||
let (pace_p50, _) = session::window_percentiles(&mut st.win_pace_us);
|
||||
let (latch_p50, _) = session::window_percentiles(&mut st.win_latch_us);
|
||||
// Drained ONCE per window and shared by the HUD and the log line below —
|
||||
// a second `take_counters` would read zeros.
|
||||
let (replaced, q_drop, q_dry) = st.store.take_counters();
|
||||
let (gated, forced) = st.gate.take_counters();
|
||||
st.presented = PresentedWindow {
|
||||
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
|
||||
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
|
||||
display_ms: disp_p50 as f32 / 1000.0,
|
||||
pace_ms: pace_p50 as f32 / 1000.0,
|
||||
latch_ms: latch_p50 as f32 / 1000.0,
|
||||
mode: presenter.present_mode_name(),
|
||||
vrr: st.cadence.verdict(),
|
||||
smoothing: st.store.is_smoothing(),
|
||||
q_drop,
|
||||
q_dry,
|
||||
gated,
|
||||
forced,
|
||||
};
|
||||
st.win_e2e_us.clear();
|
||||
st.win_disp_us.clear();
|
||||
st.win_pace_us.clear();
|
||||
st.win_latch_us.clear();
|
||||
st.win_start = Instant::now();
|
||||
// Adaptive slot margin (the Android presenter's measured recipe):
|
||||
// start at 0 — a fixed lead is pure display tax — and widen one step
|
||||
// per window whose measured latch misses demand it. One-way per
|
||||
// stream; the next stream restarts at 0.
|
||||
if st.store.is_smoothing() && st.win_misses > 2 && st.margin_ns < MARGIN_MAX_NS {
|
||||
st.margin_ns = (st.margin_ns + MARGIN_STEP_NS).min(MARGIN_MAX_NS);
|
||||
tracing::info!(
|
||||
margin_us = st.margin_ns / 1000,
|
||||
misses = st.win_misses,
|
||||
"smoothness slot margin widened (measured latch misses)"
|
||||
);
|
||||
}
|
||||
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
|
||||
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
|
||||
// the field-triage instrument for the intent engine.
|
||||
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
|
||||
tracing::info!(
|
||||
smoothing = st.presented.smoothing,
|
||||
mode = st.presented.mode,
|
||||
vrr = st.presented.vrr.label(),
|
||||
replaced,
|
||||
q_drop,
|
||||
q_dry,
|
||||
gated,
|
||||
forced,
|
||||
misses = st.win_misses,
|
||||
out_max = st.win_out_max,
|
||||
pace_ms = st.presented.pace_ms,
|
||||
latch_ms = st.presented.latch_ms,
|
||||
period_us = st.clock.period_ns() / 1000,
|
||||
margin_us = st.margin_ns / 1000,
|
||||
"presenter window"
|
||||
);
|
||||
}
|
||||
st.win_misses = 0;
|
||||
st.win_out_max = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2007,6 +2326,32 @@ struct PresentedWindow {
|
||||
e2e_p50_ms: f32,
|
||||
e2e_p95_ms: f32,
|
||||
display_ms: f32,
|
||||
/// The display stage split (design/desktop-presentation-rebuild.md WP4):
|
||||
/// `pace` = decoded → present-submit (our own pipeline), `latch` = submit → on-glass
|
||||
/// (the presentation engine's queue + the vblank wait). Both `0` without
|
||||
/// `VK_KHR_present_wait`, where the two are not separable — the HUD then shows the
|
||||
/// unsplit figure rather than inventing a zero latch.
|
||||
///
|
||||
/// This split is what makes a high `display` self-diagnosing: latch dominating means
|
||||
/// the vsync/queue floor (or a standing queue), pace dominating means us.
|
||||
/// `pace` is also the honest cross-platform twin of the Apple client's shaved
|
||||
/// number — Apple subtracts its measured OS present floor, and the latch IS our
|
||||
/// floor, so `pace` is what remains on both sides of that comparison.
|
||||
pace_ms: f32,
|
||||
latch_ms: f32,
|
||||
/// The live swapchain present mode (`mailbox`/`fifo`/…). Shown because a mode is
|
||||
/// chosen from what the surface offers, so "why is my latch a refresh long" is
|
||||
/// usually answered by a MAILBOX request having landed on FIFO.
|
||||
mode: &'static str,
|
||||
/// Whether variable refresh is measurably live (never claimed without evidence).
|
||||
vrr: Cadence,
|
||||
/// Presenter-engine counters for the window: the smoothing FIFO's overflow drops and
|
||||
/// post-preroll underflows, and the FIFO glass gate's holds/stale force-opens.
|
||||
smoothing: bool,
|
||||
q_drop: u32,
|
||||
q_dry: u32,
|
||||
gated: u32,
|
||||
forced: u32,
|
||||
}
|
||||
|
||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||
@@ -2112,6 +2457,15 @@ fn stats_text(
|
||||
" · decode {:.1} · display {:.1} ms",
|
||||
s.decode_ms, p.display_ms
|
||||
));
|
||||
// The display split (WP4). Only with true on-glass stamps — without them the
|
||||
// two halves are not separable and the unsplit figure stands alone rather than
|
||||
// implying a zero latch.
|
||||
if p.latch_ms > 0.0 || p.pace_ms > 0.0 {
|
||||
text.push_str(&format!(
|
||||
" (pace {:.1} + latch {:.1})",
|
||||
p.pace_ms, p.latch_ms
|
||||
));
|
||||
}
|
||||
// Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution
|
||||
// (queue → encode → seal/xfer → pace) reads as the host pipeline in order.
|
||||
if s.staged {
|
||||
@@ -2120,6 +2474,32 @@ fn stats_text(
|
||||
s.host_queue_ms, s.host_encode_ms, s.host_xfer_ms, s.host_pace_ms
|
||||
));
|
||||
}
|
||||
// The presenter line: the swapchain mode that is actually live, the chosen
|
||||
// intent, and the engine's own counters. Present-mode alone answers most
|
||||
// "why is my latch a whole refresh" questions; the counters only render when
|
||||
// they are non-zero, so a healthy latency session shows just the mode.
|
||||
if !p.mode.is_empty() {
|
||||
text.push_str(&format!("\npresent: {}", p.mode));
|
||||
// Only once measured — an unproven "vrr no" would be a claim, not a reading.
|
||||
if p.vrr != Cadence::Unknown {
|
||||
text.push_str(&format!(" · vrr {}", p.vrr.label()));
|
||||
}
|
||||
if p.smoothing {
|
||||
text.push_str(" · smoothing");
|
||||
}
|
||||
if p.q_drop > 0 {
|
||||
text.push_str(&format!(" · qdrop {}", p.q_drop));
|
||||
}
|
||||
if p.q_dry > 0 {
|
||||
text.push_str(&format!(" · qdry {}", p.q_dry));
|
||||
}
|
||||
if p.gated > 0 {
|
||||
text.push_str(&format!(" · gated {}", p.gated));
|
||||
}
|
||||
if p.forced > 0 {
|
||||
text.push_str(&format!(" · forced {}", p.forced));
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.lost > 0 {
|
||||
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
|
||||
@@ -2393,6 +2773,7 @@ mod tests {
|
||||
e2e_p50_ms: 6.4,
|
||||
e2e_p95_ms: 9.1,
|
||||
display_ms: 1.1,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2430,6 +2811,70 @@ mod tests {
|
||||
!normal.contains("queue"),
|
||||
"host-stage split is Detailed-only"
|
||||
);
|
||||
assert!(
|
||||
!detailed.contains("pace 1.1"),
|
||||
"no glass stamps in this sample — the display stage stays unsplit"
|
||||
);
|
||||
}
|
||||
|
||||
/// WP4: with true on-glass stamps the display stage reads as its two halves, the
|
||||
/// live present mode is named, and the engine counters render only when non-zero —
|
||||
/// so a healthy latency session shows the mode and nothing else. Without glass
|
||||
/// stamps (no `VK_KHR_present_wait`) the split is absent rather than a zero latch.
|
||||
#[test]
|
||||
fn detailed_splits_display_into_pace_and_latch() {
|
||||
let (s, mut p) = sample();
|
||||
p.display_ms = 12.4;
|
||||
p.pace_ms = 1.1;
|
||||
p.latch_ms = 11.3;
|
||||
p.mode = "fifo";
|
||||
let split = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(split.contains("display 12.4 ms (pace 1.1 + latch 11.3)"));
|
||||
assert!(split.contains("\npresent: fifo"));
|
||||
assert!(
|
||||
!split.contains("qdrop") && !split.contains("gated") && !split.contains("smoothing"),
|
||||
"quiet counters stay off the HUD: {split}"
|
||||
);
|
||||
|
||||
// The smoothing FIFO and the glass gate surface once they actually do something.
|
||||
p.smoothing = true;
|
||||
p.q_drop = 2;
|
||||
p.q_dry = 1;
|
||||
p.gated = 7;
|
||||
p.forced = 1;
|
||||
let busy = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(busy.contains("present: fifo · smoothing · qdrop 2 · qdry 1 · gated 7 · forced 1"));
|
||||
|
||||
// A tier below Detailed never carries any of it.
|
||||
let normal = stats_text(
|
||||
StatsVerbosity::Normal,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(!normal.contains("present:") && !normal.contains("pace"));
|
||||
}
|
||||
|
||||
/// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT
|
||||
|
||||
@@ -33,7 +33,7 @@ mod reconfig;
|
||||
mod resources;
|
||||
mod setup;
|
||||
|
||||
pub use setup::list_adapters;
|
||||
pub use setup::{list_adapters, PresentPref};
|
||||
|
||||
/// One presenter iteration's video input.
|
||||
pub enum FrameInput<'a> {
|
||||
@@ -247,10 +247,56 @@ impl Presenter {
|
||||
/// (the presenter itself never sees them). No-op when timing is inactive.
|
||||
pub(crate) fn note_presented(&mut self, pts_ns: u64, decoded_ns: u64) {
|
||||
if let (Some(t), Some((sc, id))) = (&self.present_timer, self.last_presented.take()) {
|
||||
t.enqueue(sc, id, pts_ns, decoded_ns);
|
||||
// The submit stamp: `present()` already returned, so "now" is within the
|
||||
// present-call tail — the pace/latch split point.
|
||||
t.enqueue(
|
||||
sc,
|
||||
id,
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
pf_client_core::session::now_ns(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undisplayed id-carrying presents in flight (0 when timing is inactive) — the
|
||||
/// FIFO glass gate's budget count.
|
||||
pub(crate) fn presents_outstanding(&self) -> usize {
|
||||
self.present_timer.as_ref().map_or(0, |t| t.outstanding())
|
||||
}
|
||||
|
||||
/// Install the run loop's wake for present completions (an SDL event push). No-op
|
||||
/// without present timing — there is nothing to wake on then.
|
||||
pub(crate) fn set_present_wake(&self, cb: Box<dyn Fn() + Send>) {
|
||||
if let Some(t) = &self.present_timer {
|
||||
t.set_wake(cb);
|
||||
}
|
||||
}
|
||||
|
||||
/// The live swapchain present mode, for the stats overlay: a mode is picked from
|
||||
/// what the surface actually offers, so the requested one and this can differ (a
|
||||
/// MAILBOX request lands on FIFO wherever the driver has no mailbox — AMD's Windows
|
||||
/// driver, notably). Showing it is what makes that visible instead of puzzling.
|
||||
pub(crate) fn present_mode_name(&self) -> &'static str {
|
||||
match self.present_mode {
|
||||
vk::PresentModeKHR::MAILBOX => "mailbox",
|
||||
vk::PresentModeKHR::FIFO => "fifo",
|
||||
vk::PresentModeKHR::FIFO_RELAXED => "fifo-relaxed",
|
||||
vk::PresentModeKHR::IMMEDIATE => "immediate",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// The active present mode queues presents (FIFO family): the only modes where the
|
||||
/// swapchain itself can become a standing queue, and so the only ones the glass
|
||||
/// gate governs. MAILBOX/IMMEDIATE replace/flip and never queue.
|
||||
pub(crate) fn fifo_present_mode(&self) -> bool {
|
||||
matches!(
|
||||
self.present_mode,
|
||||
vk::PresentModeKHR::FIFO | vk::PresentModeKHR::FIFO_RELAXED
|
||||
)
|
||||
}
|
||||
|
||||
/// Take the window's completed on-glass samples (empty when timing is inactive).
|
||||
pub(crate) fn take_presented_samples(&self) -> Vec<present_timing::PresentedSample> {
|
||||
self.present_timer
|
||||
|
||||
@@ -26,6 +26,9 @@ pub(crate) struct PresentedSample {
|
||||
pub pts_ns: u64,
|
||||
/// Decode-complete stamp (client clock) — the display-stage anchor.
|
||||
pub decoded_ns: u64,
|
||||
/// `vkQueuePresentKHR`-return stamp (client clock) — the pace/latch split point:
|
||||
/// `submitted − decoded` is our pipeline, `displayed − submitted` the vsync latch.
|
||||
pub submitted_ns: u64,
|
||||
/// `vkWaitForPresentKHR` completion = the image is visible (client clock).
|
||||
pub displayed_ns: u64,
|
||||
}
|
||||
@@ -35,15 +38,24 @@ struct Job {
|
||||
present_id: u64,
|
||||
pts_ns: u64,
|
||||
decoded_ns: u64,
|
||||
submitted_ns: u64,
|
||||
}
|
||||
|
||||
/// The run loop's wake callback (an SDL event push), shared with the waiter thread.
|
||||
type WakeSlot = Arc<Mutex<Option<Box<dyn Fn() + Send>>>>;
|
||||
|
||||
/// The waiter: a channel-fed thread turning (swapchain, present-id) pairs into
|
||||
/// [`PresentedSample`]s. One frame in flight upstream keeps the queue depth ~1.
|
||||
pub(crate) struct PresentTimer {
|
||||
tx: Option<mpsc::Sender<Job>>,
|
||||
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown.
|
||||
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown,
|
||||
/// and the glass gate's "undisplayed presents in flight" count.
|
||||
pending: Arc<AtomicUsize>,
|
||||
results: Arc<Mutex<Vec<PresentedSample>>>,
|
||||
/// Called by the waiter after each completed wait (sample or not) — the run loop
|
||||
/// installs an SDL wake here so a gate reopen / smoothness slot never waits out the
|
||||
/// event-loop timeout.
|
||||
wake: WakeSlot,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -52,7 +64,8 @@ impl PresentTimer {
|
||||
let (tx, rx) = mpsc::channel::<Job>();
|
||||
let pending = Arc::new(AtomicUsize::new(0));
|
||||
let results = Arc::new(Mutex::new(Vec::with_capacity(256)));
|
||||
let (pending_t, results_t) = (pending.clone(), results.clone());
|
||||
let wake: WakeSlot = Arc::new(Mutex::new(None));
|
||||
let (pending_t, results_t, wake_t) = (pending.clone(), results.clone(), wake.clone());
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-present-wait".into())
|
||||
.spawn(move || {
|
||||
@@ -69,12 +82,20 @@ impl PresentTimer {
|
||||
results_t.lock().unwrap().push(PresentedSample {
|
||||
pts_ns: job.pts_ns,
|
||||
decoded_ns: job.decoded_ns,
|
||||
submitted_ns: job.submitted_ns,
|
||||
displayed_ns,
|
||||
});
|
||||
}
|
||||
// SUBOPTIMAL/TIMEOUT/DEVICE_LOST: no sample; the frame still showed
|
||||
// (or the loop is about to find out) — never poison the window.
|
||||
pending_t.fetch_sub(1, Ordering::AcqRel);
|
||||
// Wake the run loop AFTER the count dropped: what it observes on
|
||||
// wake is the post-completion state (the gate may now be open).
|
||||
// Called under the slot lock — the callback is a bare SDL event
|
||||
// push and never reenters this type.
|
||||
if let Some(cb) = wake_t.lock().unwrap().as_ref() {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn pf-present-wait");
|
||||
@@ -82,10 +103,23 @@ impl PresentTimer {
|
||||
tx: Some(tx),
|
||||
pending,
|
||||
results,
|
||||
wake,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the run loop's wake callback (an SDL event push — thread-safe by design).
|
||||
pub(crate) fn set_wake(&self, cb: Box<dyn Fn() + Send>) {
|
||||
*self.wake.lock().unwrap() = Some(cb);
|
||||
}
|
||||
|
||||
/// Presents handed to the waiter and not yet resolved to glass — the glass gate's
|
||||
/// budget count. (Also counts a wait that will end SUBOPTIMAL/TIMEOUT; those resolve
|
||||
/// within the 250 ms cap, far past the gate's own 100 ms stale force-open.)
|
||||
pub(crate) fn outstanding(&self) -> usize {
|
||||
self.pending.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Hand a successfully submitted present to the waiter.
|
||||
pub(crate) fn enqueue(
|
||||
&self,
|
||||
@@ -93,6 +127,7 @@ impl PresentTimer {
|
||||
present_id: u64,
|
||||
pts_ns: u64,
|
||||
decoded_ns: u64,
|
||||
submitted_ns: u64,
|
||||
) {
|
||||
if let Some(tx) = &self.tx {
|
||||
self.pending.fetch_add(1, Ordering::AcqRel);
|
||||
@@ -102,6 +137,7 @@ impl PresentTimer {
|
||||
present_id,
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
submitted_ns,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
@@ -16,7 +16,11 @@ use std::ffi::{c_char, CString};
|
||||
impl Presenter {
|
||||
/// Bring up instance → surface → device → swapchain over an SDL window.
|
||||
/// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`.
|
||||
pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result<Presenter> {
|
||||
pub fn new(
|
||||
window: &sdl3::video::Window,
|
||||
instance_extensions: &[String],
|
||||
pref: PresentPref,
|
||||
) -> Result<Presenter> {
|
||||
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
|
||||
// builder structs that are locals outliving the call; the handle it returns is owned by
|
||||
// the value being built here.
|
||||
@@ -450,11 +454,15 @@ impl Presenter {
|
||||
if let Some(v) = video_export.as_mut() {
|
||||
v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some();
|
||||
}
|
||||
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
|
||||
let mut pref = pref;
|
||||
pref.vrr_fifo_opt_in = vrr_fifo_opt_in();
|
||||
let present_mode = pick_present_mode(&surface_i, pdev, surface, pref)?;
|
||||
tracing::info!(
|
||||
?format,
|
||||
?hdr10_format,
|
||||
?present_mode,
|
||||
vsync = pref.vsync,
|
||||
allow_vrr = pref.allow_vrr,
|
||||
hdr_metadata = has_hdr_metadata,
|
||||
"swapchain config"
|
||||
);
|
||||
@@ -730,42 +738,186 @@ pub(super) fn pick_formats(
|
||||
Ok((sdr, hdr10))
|
||||
}
|
||||
|
||||
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
|
||||
/// fifo|mailbox|immediate|fifo_relaxed` overrides). Both defaults are tear-free, but an
|
||||
/// arrival-paced presenter must not block in FIFO's present queue: when the compositor
|
||||
/// holds images for a vblank pass (gamescope's composite path) or arrival cadence drifts
|
||||
/// against refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms
|
||||
/// added to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
|
||||
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
|
||||
/// What the user asked the presentation to be, resolved into a swapchain present mode by
|
||||
/// [`present_mode_chain`] (design/desktop-presentation-rebuild.md WP3).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PresentPref {
|
||||
/// Tear-free presentation (the `vsync` setting, default on).
|
||||
pub vsync: bool,
|
||||
/// Let a variable-refresh display follow the stream cadence (`allow_vrr`, default on).
|
||||
pub allow_vrr: bool,
|
||||
/// Opt-in for the VRR FIFO-first ladder (`PUNKTFUNK_VRR_FIFO=1`). Off by default on
|
||||
/// measured evidence — see [`present_mode_chain`].
|
||||
pub vrr_fifo_opt_in: bool,
|
||||
/// The session STARTED fullscreen. The mode is chosen once, at swapchain creation, so
|
||||
/// this is the starting state and an F11 mid-session does not re-pick — consistent
|
||||
/// with the shells' "Display changes apply from the next session" footer, and why
|
||||
/// live present-mode switching is an explicit non-goal.
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
/// The preference ladder, most to least wanted. The caller takes the first entry the
|
||||
/// surface actually offers; FIFO ends every chain because the spec guarantees it.
|
||||
///
|
||||
/// * **V-Sync off** — IMMEDIATE (tears, no wait at all), then FIFO_RELAXED (tears only on
|
||||
/// a late frame), then the tear-free modes. Asking for tearing and silently getting
|
||||
/// vsync is a lie the stats line now exposes, but the ladder still degrades safely.
|
||||
/// * **V-Sync on + VRR allowed + fullscreen + `PUNKTFUNK_VRR_FIFO=1`** — FIFO first. On a
|
||||
/// variable-refresh panel with direct scanout the FIFO present IS the flip, so the panel
|
||||
/// follows the stream's cadence; MAILBOX would decouple presents from scanout and
|
||||
/// re-quantize to the compositor's clock.
|
||||
///
|
||||
/// ⚠ **Opt-in, not default, on measured evidence.** It was default-on until an on-glass
|
||||
/// A/B (.21, GNOME/Wayland, NVIDIA, *non*-VRR 60 Hz panel, 2026-08-02) showed this
|
||||
/// route costs ~27 ms of display stage versus MAILBOX on the same box, reproducibly:
|
||||
/// `display 28.4 ms (pace 11.8 + latch 16.6)` on FIFO against `1.4 ms (0.2 + 1.2)` on
|
||||
/// MAILBOX. Under a compositor the FIFO present's on-glass confirmation arrives a whole
|
||||
/// refresh later, and the presenter serialises behind it. The upside on a genuine VRR
|
||||
/// panel is real but UNMEASURED — no VRR display was available — and a default that is
|
||||
/// measurably worse on the hardware we could test, in exchange for an unproven win on
|
||||
/// hardware we could not, is the wrong way round. Flip the default once a VRR panel
|
||||
/// confirms the win (WP6 open item).
|
||||
/// * **Otherwise** — MAILBOX, then FIFO: the shipped default. MAILBOX never queues more
|
||||
/// than the newest frame, so an arrival-paced presenter doesn't block in the present
|
||||
/// queue (a measured 11-13 ms standing wait at 60 Hz when the compositor holds images
|
||||
/// for a vblank pass, or when arrival cadence drifts against refresh).
|
||||
///
|
||||
/// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO —
|
||||
/// expected, not a client misconfiguration. FIFO_RELAXED is opt-in only: it tears exactly
|
||||
/// when a stream frame misses the vblank it was pacing for, which on a drifting arrival
|
||||
/// cadence is often — a trade the user must choose, never a silent fallback.
|
||||
/// expected, not a misconfiguration, and now visible in the `present:` stats line.
|
||||
fn present_mode_chain(pref: PresentPref) -> [vk::PresentModeKHR; 4] {
|
||||
use vk::PresentModeKHR as M;
|
||||
if !pref.vsync {
|
||||
[M::IMMEDIATE, M::FIFO_RELAXED, M::MAILBOX, M::FIFO]
|
||||
} else if pref.allow_vrr && pref.fullscreen && pref.vrr_fifo_opt_in {
|
||||
[M::FIFO, M::MAILBOX, M::FIFO_RELAXED, M::IMMEDIATE]
|
||||
} else {
|
||||
[M::MAILBOX, M::FIFO, M::FIFO_RELAXED, M::IMMEDIATE]
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VRR_FIFO=1` — opt into the FIFO-first ladder for variable-refresh panels.
|
||||
/// See [`present_mode_chain`] for the measurement that made this opt-in rather than
|
||||
/// default.
|
||||
fn vrr_fifo_opt_in() -> bool {
|
||||
std::env::var("PUNKTFUNK_VRR_FIFO").is_ok_and(|v| v != "0")
|
||||
}
|
||||
|
||||
/// Resolve the present mode: `PUNKTFUNK_PRESENT_MODE` pins one outright (the debug lever,
|
||||
/// unchanged), otherwise the first entry of [`present_mode_chain`] the surface offers.
|
||||
fn pick_present_mode(
|
||||
surface_i: &ash::khr::surface::Instance,
|
||||
pdev: vk::PhysicalDevice,
|
||||
surface: vk::SurfaceKHR,
|
||||
pref: PresentPref,
|
||||
) -> Result<vk::PresentModeKHR> {
|
||||
// SAFETY: per the Vulkan contract above - a read-only query on the live instance/device,
|
||||
// filling locals returned by value.
|
||||
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
|
||||
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
|
||||
Some("fifo") => vk::PresentModeKHR::FIFO,
|
||||
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
|
||||
Some("fifo_relaxed") => vk::PresentModeKHR::FIFO_RELAXED,
|
||||
Some("mailbox") | None => vk::PresentModeKHR::MAILBOX,
|
||||
let pinned = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
|
||||
Some("fifo") => Some(vk::PresentModeKHR::FIFO),
|
||||
Some("immediate") => Some(vk::PresentModeKHR::IMMEDIATE),
|
||||
Some("fifo_relaxed") => Some(vk::PresentModeKHR::FIFO_RELAXED),
|
||||
Some("mailbox") => Some(vk::PresentModeKHR::MAILBOX),
|
||||
None => None,
|
||||
Some(other) => {
|
||||
tracing::warn!(
|
||||
value = other,
|
||||
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — using mailbox"
|
||||
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — following the settings"
|
||||
);
|
||||
vk::PresentModeKHR::MAILBOX
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(if modes.contains(&want) {
|
||||
want
|
||||
} else {
|
||||
vk::PresentModeKHR::FIFO // always available per spec
|
||||
})
|
||||
if let Some(want) = pinned {
|
||||
if modes.contains(&want) {
|
||||
return Ok(want);
|
||||
}
|
||||
tracing::warn!(
|
||||
?want,
|
||||
"PUNKTFUNK_PRESENT_MODE not offered by this surface — falling back"
|
||||
);
|
||||
}
|
||||
let chain = present_mode_chain(pref);
|
||||
let chosen = chain
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|m| modes.contains(m))
|
||||
.unwrap_or(vk::PresentModeKHR::FIFO); // always available per spec
|
||||
// The one line that answers "did V-Sync off actually take?" — a request the surface
|
||||
// can't serve is a fact about the driver, and it must not look like our choice.
|
||||
if chosen != chain[0] {
|
||||
tracing::info!(
|
||||
requested = ?chain[0],
|
||||
active = ?chosen,
|
||||
vsync = pref.vsync,
|
||||
allow_vrr = pref.allow_vrr,
|
||||
"the surface does not offer the preferred present mode"
|
||||
);
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vk::PresentModeKHR as M;
|
||||
|
||||
/// The preference ladders (WP3). Every chain must end at FIFO, which the spec
|
||||
/// guarantees exists — a chain whose entries a surface all refuses would otherwise
|
||||
/// have no landing.
|
||||
#[test]
|
||||
fn present_mode_chains_rank_by_intent() {
|
||||
let pref = |vsync, allow_vrr, fullscreen| PresentPref {
|
||||
vsync,
|
||||
allow_vrr,
|
||||
fullscreen,
|
||||
vrr_fifo_opt_in: true, // the ladder under test; the DEFAULT is off (see below)
|
||||
};
|
||||
|
||||
// V-Sync off asks to tear, hardest first, and outranks the VRR rule (tearing
|
||||
// already gives a VRR-like latch, so the two never fight).
|
||||
assert_eq!(present_mode_chain(pref(false, true, true))[0], M::IMMEDIATE);
|
||||
assert_eq!(
|
||||
present_mode_chain(pref(false, false, false))[0],
|
||||
M::IMMEDIATE
|
||||
);
|
||||
assert_eq!(
|
||||
present_mode_chain(pref(false, true, true))[1],
|
||||
M::FIFO_RELAXED,
|
||||
"tears only on a late frame — the gentler tearing rung"
|
||||
);
|
||||
|
||||
// Tear-free + VRR allowed + fullscreen prefers FIFO, so the flip IS the present
|
||||
// and a variable-refresh panel follows the stream — but ONLY when opted in.
|
||||
assert_eq!(present_mode_chain(pref(true, true, true))[0], M::FIFO);
|
||||
// Without the opt-in the shipped MAILBOX-first default stands: measured on glass
|
||||
// to be ~27 ms of display stage better on a non-VRR panel.
|
||||
assert_eq!(
|
||||
present_mode_chain(PresentPref {
|
||||
vsync: true,
|
||||
allow_vrr: true,
|
||||
fullscreen: true,
|
||||
vrr_fifo_opt_in: false,
|
||||
})[0],
|
||||
M::MAILBOX,
|
||||
"the VRR ladder is opt-in until a VRR panel confirms the win"
|
||||
);
|
||||
// Windowed, or VRR declined: the shipped MAILBOX-first default.
|
||||
assert_eq!(present_mode_chain(pref(true, true, false))[0], M::MAILBOX);
|
||||
assert_eq!(present_mode_chain(pref(true, false, true))[0], M::MAILBOX);
|
||||
assert_eq!(present_mode_chain(pref(true, false, false))[0], M::MAILBOX);
|
||||
|
||||
// Every ladder can land: FIFO appears in all of them.
|
||||
for p in [
|
||||
pref(true, true, true),
|
||||
pref(true, true, false),
|
||||
pref(true, false, true),
|
||||
pref(false, true, true),
|
||||
pref(false, false, false),
|
||||
] {
|
||||
assert!(
|
||||
present_mode_chain(p).contains(&M::FIFO),
|
||||
"FIFO is the guaranteed landing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,13 +407,21 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
|
||||
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
|
||||
let _ = compositor;
|
||||
// `ensure_available` self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
|
||||
// devnode present, interface gone): one device cycle + re-probe before giving up.
|
||||
anyhow::ensure!(
|
||||
driver::ensure_available(),
|
||||
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
|
||||
not loaded (the host installer bundles it; reinstall or check the driver state)"
|
||||
);
|
||||
// `ensure_available` waits out a devnode that is merely coming up (the wake-from-sleep case:
|
||||
// the adapter re-enters D0 and re-registers its interface while a reconnecting client is
|
||||
// already knocking) and self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
|
||||
// devnode present, interface gone) by reloading the adapter.
|
||||
//
|
||||
// `context`, not a replacement message: it reports WHY — how long it waited, whether a reload
|
||||
// ran, how many interface instances were seen and in what state. A flat "the driver is not
|
||||
// installed" is what a field report carried from a box whose driver was installed, started,
|
||||
// and simply mid-resume, and it pointed every reader at the wrong problem.
|
||||
use anyhow::Context as _;
|
||||
driver::ensure_available().context(
|
||||
"pf-vdisplay driver interface not available — the pf-vdisplay IddCx driver is not \
|
||||
installed, not loaded, or did not finish coming back up (the host installer bundles \
|
||||
it; reinstall or check the driver state)",
|
||||
)?;
|
||||
Ok(Box::new(driver::PfVdisplayDisplay::new()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -425,6 +425,20 @@ pub fn control_device_handle() -> Option<HANDLE> {
|
||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||
}
|
||||
|
||||
/// Retire the cached control handle from OUTSIDE the manager, for a caller that KNOWS the device
|
||||
/// died — the adapter-reload recovery in [`crate::driver`], which tears the driver stack down and
|
||||
/// back up. Without it the stale handle survives into the next session's `IOCTL_ADD` and is only
|
||||
/// recovered by the gone-classified retry one failed IOCTL later.
|
||||
///
|
||||
/// Takes the `device` mutex, so it must NOT be called from inside it (notably not from
|
||||
/// `VdisplayDriver::open`, which `ensure_device` invokes while holding it). No-op before any backend
|
||||
/// opened the device.
|
||||
pub(crate) fn invalidate_cached_device(why: &str) {
|
||||
if let Some(m) = VDM.get() {
|
||||
m.invalidate_device(&anyhow::anyhow!("{why}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-commit the CURRENT display config under the manager `state` lock (the sole-topology-mutator
|
||||
/// contract of [`force_mode_reenumeration`]). The secure-desktop guard's actuator: the OS only
|
||||
/// reverts a path to its software-cursor default ON a mode commit, so standing the hardware-cursor
|
||||
|
||||
@@ -21,6 +21,7 @@ use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
@@ -143,31 +144,70 @@ fn reap_ghost_monitors() -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the pf-vdisplay ADAPTER device (disable → enable) — the in-process equivalent of
|
||||
/// `reset-pf-vdisplay.ps1` step 3. A crashed/killed WUDFHost can leave the devnode "started" yet
|
||||
/// HOSTLESS (PnP Status OK, no WUDFHost process, zero device-interface instances) — a zombie no
|
||||
/// session can open until the stack reloads; on-glass, only a device cycle recovered it. Called by
|
||||
/// [`VdisplayDriver::open`] when `open_device` finds no openable interface; the caller retries the
|
||||
/// open afterwards. Best-effort + bounded (~7 s inside the script). Returns whether a punktfunk
|
||||
/// adapter devnode was found (and therefore cycled) — `false` means the driver genuinely is not
|
||||
/// installed and a retry is pointless.
|
||||
fn restart_vdisplay_device() -> bool {
|
||||
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
|
||||
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
|
||||
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
|
||||
/// woken host logged `cycled … status=OK` and then failed the session for a missing interface).
|
||||
enum AdapterCycle {
|
||||
/// The driver stack was genuinely reloaded. `how` names the lever that worked.
|
||||
Reloaded { how: &'static str, status: String },
|
||||
/// No punktfunk adapter devnode exists at all — the driver is not installed and retrying is
|
||||
/// pointless.
|
||||
NotInstalled,
|
||||
/// A devnode exists but could not be reloaded; carries the reason (already whitespace-collapsed).
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
|
||||
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
|
||||
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
|
||||
/// reloads; on-glass, only a device reload recovered it.
|
||||
///
|
||||
/// Two levers, in order. `Disable-PnpDevice` + `Enable-PnpDevice` is the one `reset-pf-vdisplay.ps1`
|
||||
/// uses — but that script stops the host service FIRST, precisely because the host holds the driver's
|
||||
/// control device open (its step 1), and a disable can be refused for a device in use. This runs
|
||||
/// INSIDE the host, so it structurally cannot take that step: the retired-but-never-closed handles in
|
||||
/// [`DeviceSlot`](super::manager) are still open on the very device being disabled. So a refusal is
|
||||
/// the expected case here, not the exotic one, and `pnputil /restart-device` — which reloads a device
|
||||
/// that is in use — is the fallback. Whichever runs, the failure paths re-enable, so a half-completed
|
||||
/// cycle can never leave the adapter DISABLED.
|
||||
///
|
||||
/// Best-effort + bounded (~6 s inside the script).
|
||||
fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// Mirrors reset-pf-vdisplay.ps1's Get-PfAdapter selector ('punktfunk Virtual Display' is the INF
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above.
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
|
||||
// reported tokens are ours, so parsing them is locale-invariant too.
|
||||
//
|
||||
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
|
||||
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
|
||||
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
|
||||
// reading `OK`, so the host logged a successful recovery it had never performed.
|
||||
//
|
||||
// `$LASTEXITCODE = 1` before the pnputil call for the same reason: no native command runs before
|
||||
// it, so an unlaunchable pnputil would otherwise leave the variable holding whatever it held and
|
||||
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
|
||||
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
|
||||
// System32.
|
||||
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
||||
if ($ad) { \
|
||||
Disable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
|
||||
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status; \
|
||||
if ($st -ne 'OK') { Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 2; \
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status }; \
|
||||
Write-Output $st \
|
||||
} else { Write-Output 'ABSENT' }";
|
||||
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
|
||||
$id = $ad.InstanceId; $err = ''; \
|
||||
try { \
|
||||
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
|
||||
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
|
||||
catch { Start-Sleep -Seconds 2; Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop }; \
|
||||
Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED cycle ' + (Get-PnpDevice -InstanceId $id).Status); exit \
|
||||
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
|
||||
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
match std::process::Command::new(&ps)
|
||||
let out = match std::process::Command::new(&ps)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -178,22 +218,65 @@ fn restart_vdisplay_device() -> bool {
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if status == "ABSENT" {
|
||||
tracing::warn!("pf-vdisplay: no adapter devnode to cycle — driver not installed");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%status,
|
||||
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
|
||||
);
|
||||
}
|
||||
status != "ABSENT"
|
||||
}
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: adapter cycle could not spawn powershell");
|
||||
false
|
||||
tracing::warn!(error = %e, "pf-vdisplay: adapter reload could not spawn powershell");
|
||||
return AdapterCycle::Refused(format!("could not spawn powershell: {e}"));
|
||||
}
|
||||
};
|
||||
let outcome = classify_reload_output(&out);
|
||||
match &outcome {
|
||||
AdapterCycle::NotInstalled => {
|
||||
tracing::warn!("pf-vdisplay: no adapter devnode to reload — driver not installed");
|
||||
}
|
||||
AdapterCycle::Reloaded { how, status } => tracing::warn!(
|
||||
how,
|
||||
%status,
|
||||
"pf-vdisplay: reloaded the adapter device (hostless-zombie recovery)"
|
||||
),
|
||||
AdapterCycle::Refused(why) => tracing::warn!(
|
||||
reason = %why,
|
||||
"pf-vdisplay: the adapter devnode exists but could NOT be reloaded — a session cannot \
|
||||
recover from this without a host-service restart or a reboot"
|
||||
),
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Parse [`reload_vdisplay_adapter`]'s script output. Split out to be testable without a box: the
|
||||
/// bug this whole change answers was a recovery that MISreported its own outcome, so the decoding of
|
||||
/// that outcome is worth pinning down.
|
||||
fn classify_reload_output(out: &str) -> AdapterCycle {
|
||||
let out = out.trim();
|
||||
let (verb, rest) = out.split_once(char::is_whitespace).unwrap_or((out, ""));
|
||||
match verb {
|
||||
"ABSENT" => AdapterCycle::NotInstalled,
|
||||
"RELOADED" => {
|
||||
let (how, status) = rest
|
||||
.trim()
|
||||
.split_once(char::is_whitespace)
|
||||
.unwrap_or((rest.trim(), ""));
|
||||
// Held as `&'static str` so the two levers stay distinguishable in a field report:
|
||||
// `restart` means the disable was refused, i.e. something still holds the device open —
|
||||
// worth knowing when a reload does not fix the box.
|
||||
let how: &'static str = if how == "restart" {
|
||||
"pnputil /restart-device"
|
||||
} else {
|
||||
"disable+enable"
|
||||
};
|
||||
AdapterCycle::Reloaded {
|
||||
how,
|
||||
status: status.trim().to_string(),
|
||||
}
|
||||
}
|
||||
// Covers `REFUSED <reason>` and anything unrecognised, including an empty stdout (powershell
|
||||
// died before writing). All of them mean an un-reloaded devnode, which is the only thing
|
||||
// callers act on; the text rides along for the log.
|
||||
_ => AdapterCycle::Refused(if rest.trim().is_empty() {
|
||||
format!("unexpected adapter-reload output: {out:?}")
|
||||
} else {
|
||||
rest.trim().to_string()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +408,55 @@ impl Drop for DevInfoList {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a device-interface enumeration found. The counts are what let [`ensure_available`] tell a
|
||||
/// devnode that is MID-TRANSITION (present, interface registered, not started yet — resuming from
|
||||
/// sleep, restarting, reloading) apart from one that is genuinely gone. Only the second is worth
|
||||
/// answering with device surgery; cycling the first only lengthens the outage it is waiting out.
|
||||
struct Probe {
|
||||
/// The control handle, if any interface instance opened.
|
||||
handle: Option<OwnedHandle>,
|
||||
/// Instances seen with `SPINT_ACTIVE` set — the owning device is started.
|
||||
active: u32,
|
||||
/// Instances seen with `SPINT_ACTIVE` clear — registered, but the owning device is not started.
|
||||
inactive: u32,
|
||||
/// The last enumeration/open failure, kept for the diagnostic.
|
||||
last_err: Option<anyhow::Error>,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
/// No interface instance of ANY kind. With an adapter devnode present this is the hostless-zombie
|
||||
/// state a WUDFHost crash leaves; with none, the driver is not installed. Either way, waiting
|
||||
/// alone will not fix it.
|
||||
fn is_absent(&self) -> bool {
|
||||
self.handle.is_none() && self.active == 0 && self.inactive == 0
|
||||
}
|
||||
|
||||
/// Why no handle came back, NAMING what was seen — "0 interfaces" and "1 inactive interface" are
|
||||
/// completely different diagnoses (not installed vs. still coming up), and the old message
|
||||
/// collapsed both into "is the driver installed?". Call only on a miss; a hit reports as much.
|
||||
fn into_error(self) -> anyhow::Error {
|
||||
let seen = format!("{} active, {} inactive", self.active, self.inactive);
|
||||
if self.handle.is_some() {
|
||||
return anyhow::anyhow!("pf-vdisplay device interface opened ({seen})");
|
||||
}
|
||||
match self.last_err {
|
||||
Some(e) => e.context(format!("no openable pf-vdisplay device interface ({seen})")),
|
||||
None => anyhow::anyhow!(
|
||||
"no pf-vdisplay device interface found ({seen}) — is the pf-vdisplay driver \
|
||||
installed and its device started?"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume into the [`open_device`] result.
|
||||
fn into_result(mut self) -> Result<OwnedHandle> {
|
||||
match self.handle.take() {
|
||||
Some(h) => Ok(h),
|
||||
None => Err(self.into_error()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the pf-vdisplay control device.
|
||||
///
|
||||
/// SAFE, and owning. It has no caller obligation — it takes no arguments and every precondition is
|
||||
@@ -333,26 +465,40 @@ impl Drop for DevInfoList {
|
||||
/// this file has already leaked from once (see the wrap-IMMEDIATELY comment in `open`). Returning an
|
||||
/// `OwnedHandle` makes the close a `Drop`, so there is exactly one way to get it wrong: not at all.
|
||||
fn open_device() -> Result<OwnedHandle> {
|
||||
probe_device().into_result()
|
||||
}
|
||||
|
||||
/// [`open_device`], reporting WHAT it found rather than only whether it succeeded.
|
||||
fn probe_device() -> Probe {
|
||||
let mut probe = Probe {
|
||||
handle: None,
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
last_err: None,
|
||||
};
|
||||
// SAFETY: plain SetupAPI enumeration call; the returned list is solely owned by the RAII wrapper.
|
||||
let hdev = DevInfoList(
|
||||
unsafe {
|
||||
SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
let hdev = match unsafe {
|
||||
SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
}
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")
|
||||
{
|
||||
Ok(h) => DevInfoList(h),
|
||||
Err(e) => {
|
||||
probe.last_err = Some(e);
|
||||
return probe;
|
||||
}
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?,
|
||||
);
|
||||
};
|
||||
|
||||
// Enumerate EVERY interface instance, not just index 0: after a driver upgrade a present-but-
|
||||
// failed devnode (Code 10) can hold index 0 while the LIVE node's interface sits at a later
|
||||
// index — the old single-index read then failed every session with "driver not installed"
|
||||
// even though a working interface existed. `SPINT_ACTIVE` filters dead interfaces (an interface
|
||||
// is active only while its owning device is started); the first active + openable one wins.
|
||||
let mut inactive = 0u32;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for index in 0..64u32 {
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
@@ -367,9 +513,10 @@ fn open_device() -> Result<OwnedHandle> {
|
||||
break; // ERROR_NO_MORE_ITEMS — no further candidates
|
||||
}
|
||||
if idata.Flags & SPINT_ACTIVE == 0 {
|
||||
inactive += 1;
|
||||
probe.inactive += 1;
|
||||
continue;
|
||||
}
|
||||
probe.active += 1;
|
||||
let mut required = 0u32;
|
||||
// SAFETY: sizing call — null buffer plus a valid `required` out-param; the expected
|
||||
// ERROR_INSUFFICIENT_BUFFER "failure" is ignored and only `required` is consumed.
|
||||
@@ -409,20 +556,18 @@ fn open_device() -> Result<OwnedHandle> {
|
||||
})
|
||||
};
|
||||
match opened {
|
||||
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing else
|
||||
// holds it, so transferring it into the `OwnedHandle` gives it a single owner that
|
||||
// closes it exactly once on drop.
|
||||
Ok(h) => return Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) }),
|
||||
Ok(h) => {
|
||||
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing
|
||||
// else holds it, so transferring it into the `OwnedHandle` gives it a single owner
|
||||
// that closes it exactly once on drop.
|
||||
probe.handle = Some(unsafe { OwnedHandle::from_raw_handle(h.0 as _) });
|
||||
return probe;
|
||||
}
|
||||
// A raced-away or wedged device — remember the error, try the next interface.
|
||||
Err(e) => last_err = Some(e),
|
||||
Err(e) => probe.last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no ACTIVE pf-vdisplay device interface found ({inactive} inactive) — is the \
|
||||
pf-vdisplay driver installed and its device started?"
|
||||
)
|
||||
}))
|
||||
probe
|
||||
}
|
||||
|
||||
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
|
||||
@@ -435,29 +580,14 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
}
|
||||
|
||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
|
||||
let device = match open_device() {
|
||||
Ok(d) => d,
|
||||
Err(first) => {
|
||||
// No openable interface. If a WUDFHost crash left the devnode a hostless zombie
|
||||
// (validated on-glass: PnP Status OK, zero interface instances), a device cycle
|
||||
// reloads the stack — kick it once and retry the open over a short arrival window.
|
||||
if !restart_vdisplay_device() {
|
||||
return Err(first); // no adapter devnode at all — genuinely not installed
|
||||
}
|
||||
let mut reopened = Err(first);
|
||||
for _ in 0..8 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
match open_device() {
|
||||
Ok(d) => {
|
||||
reopened = Ok(d);
|
||||
break;
|
||||
}
|
||||
Err(e) => reopened = Err(e),
|
||||
}
|
||||
}
|
||||
reopened.context("pf-vdisplay interface still absent after an adapter cycle")?
|
||||
}
|
||||
};
|
||||
// A short re-probe, and deliberately NO adapter reload — this replaces the second, impatient
|
||||
// copy of the recovery that used to live here. Session bring-up already ran the full
|
||||
// `ensure_available` before constructing the backend, so anything left for this open to
|
||||
// absorb is a race, not a wedge. `hw_cursor_capable` also lands here, mid client handshake,
|
||||
// where a reload's tens of seconds would be entirely the wrong trade for one capability bool
|
||||
// — and where reloading would deadlock besides, since `ensure_device` calls us holding the
|
||||
// manager's `device` mutex (see the `RECOVERY` ordering contract).
|
||||
let device = wait_for_interface(BRIEF_RETRY, false).0?;
|
||||
// `open_device` hands back an `OwnedHandle`, so every `?` below closes the device exactly
|
||||
// once by construction — the shape this used to reach by wrapping the raw handle here, and
|
||||
// which leaked whenever GET_INFO itself failed before that wrap was moved up.
|
||||
@@ -879,25 +1009,159 @@ pub fn is_available() -> bool {
|
||||
open_device().is_ok()
|
||||
}
|
||||
|
||||
/// [`is_available`], with self-heal: an interface-less driver whose adapter devnode EXISTS is the
|
||||
/// hostless-zombie state a WUDFHost crash leaves behind (validated on-glass — PnP reports Status OK
|
||||
/// with no WUDFHost process and zero interface instances, and every session fails at this gate until
|
||||
/// the device reloads). Cycle the adapter once and re-probe over a short arrival window. A genuinely
|
||||
/// uninstalled driver (no adapter devnode) fails fast without the wait.
|
||||
pub fn ensure_available() -> bool {
|
||||
if is_available() {
|
||||
return true;
|
||||
/// How often the interface is re-probed while waiting.
|
||||
const PROBE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long a devnode whose interface exists but is NOT-READY (no active instance, or `CreateFileW`
|
||||
/// refused) is given to come up on its own before the adapter is reloaded.
|
||||
///
|
||||
/// This is the wake-from-sleep window. Resuming re-enters D0 and re-registers the interface while
|
||||
/// the rest of the resume storm is still running, and a client reconnecting a second after wake
|
||||
/// arrives inside that gap — which the old code, probing exactly ONCE, answered by disabling and
|
||||
/// re-enabling a display adapter that was seconds from being ready anyway.
|
||||
const NOT_READY_GRACE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long a fully ABSENT interface is given before the adapter is reloaded. Short — a hostless
|
||||
/// devnode does not heal itself, and that is the case this recovery exists for — but non-zero, so a
|
||||
/// resume that briefly de-registers the interface is not met with device surgery either.
|
||||
const ABSENT_SETTLE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long the interface is given to ARRIVE after a reload.
|
||||
///
|
||||
/// Was 4 s, which a quiet box meets and a box still finishing a resume does not: PnP is contended
|
||||
/// right after wake. Field report 2026-08-02 — a woken host logged a successful adapter cycle and
|
||||
/// then failed the session 4 s later for a missing interface, and the client could not connect.
|
||||
const ARRIVAL_AFTER_RELOAD: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Hard ceiling on the whole wait, so display prep can never block for an unbounded sum of the
|
||||
/// windows above. Without it a devnode wedged NOT-READY costs the full grace, then the reload, then
|
||||
/// the full arrival window before failing — the pathological case paying nearly a minute per session.
|
||||
/// Patience for a device that is coming back is the point; patience for one that never will is not.
|
||||
const TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The budget a caller that must NOT stall gives the interface: no adapter reload, just a short
|
||||
/// re-probe to ride out a race. [`VdisplayDriver::open`] uses it — by the time the manager opens,
|
||||
/// session bring-up has already run the full [`ensure_available`] above, and the OTHER path that
|
||||
/// reaches it (`manager::hw_cursor_capable`, a best-effort capability answer during the client
|
||||
/// handshake) must never hold the Welcome for tens of seconds to decide one bool.
|
||||
const BRIEF_RETRY: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Serializes the recovery so N sessions racing in after a wake perform ONE adapter reload between
|
||||
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
|
||||
/// on. The second caller through typically finds the interface already up and returns at once.
|
||||
///
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
|
||||
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
|
||||
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
|
||||
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
|
||||
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
|
||||
/// wake from sleep.
|
||||
///
|
||||
/// Returns the reason on failure instead of a bare `false`: the caller used to replace it with a
|
||||
/// flat "the driver is not installed", which is what a field report showed on a box whose driver was
|
||||
/// installed, started, and merely mid-resume.
|
||||
pub fn ensure_available() -> Result<()> {
|
||||
// Poisoning carries no meaning here — the guard protects a `()`, not state a panic could leave
|
||||
// inconsistent — so a previous panic must not wedge every later session out of recovery.
|
||||
let (result, reloaded) = {
|
||||
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
wait_for_interface(NOT_READY_GRACE, true)
|
||||
};
|
||||
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
|
||||
// stack down and back up, so any control handle a previous session cached is dead by
|
||||
// construction — retire it while we know that for certain, rather than leaving the next session
|
||||
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
|
||||
if reloaded {
|
||||
super::manager::invalidate_cached_device(
|
||||
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
|
||||
);
|
||||
}
|
||||
if !restart_vdisplay_device() {
|
||||
return false;
|
||||
}
|
||||
for _ in 0..8 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
if is_available() {
|
||||
return true;
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
/// Wait for an openable control interface, reloading the adapter if `reload` and the devnode looks
|
||||
/// genuinely hostless. Returns the handle (so the manager's own open can keep it) alongside whether
|
||||
/// a reload ran.
|
||||
///
|
||||
/// Two distinguishable states hide behind "cannot open the interface", and they want opposite
|
||||
/// treatment:
|
||||
///
|
||||
/// * **Not ready** — instances are registered but none is active (or the open is refused). The
|
||||
/// devnode is THERE and coming up: resuming from sleep, restarting, reloading. It heals itself;
|
||||
/// reloading the adapter underneath it only lengthens the outage.
|
||||
/// * **Absent** — no instance at all. With an adapter devnode present this is the hostless-zombie
|
||||
/// state a WUDFHost crash leaves (validated on-glass: PnP Status OK, no WUDFHost process, zero
|
||||
/// interface instances). Only a reload clears it.
|
||||
///
|
||||
/// So: probe, wait out a not-ready device, reload an absent one after a short settle, and give the
|
||||
/// interface a real arrival window afterwards. A reload is still attempted once at the end of
|
||||
/// `not_ready_grace`, so a devnode wedged not-ready (a failed start) recovers exactly as it did
|
||||
/// before. A genuinely uninstalled driver — no adapter devnode — still fails FAST, with no wait.
|
||||
fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedHandle>, bool) {
|
||||
let started = Instant::now();
|
||||
let mut deadline = started + not_ready_grace;
|
||||
let mut absent_since: Option<Instant> = None;
|
||||
let mut reloaded = false;
|
||||
loop {
|
||||
let mut probe = probe_device();
|
||||
if let Some(h) = probe.handle.take() {
|
||||
if reloaded || started.elapsed() > PROBE_INTERVAL {
|
||||
tracing::info!(
|
||||
waited_ms = started.elapsed().as_millis() as u64,
|
||||
reloaded,
|
||||
"pf-vdisplay: control interface available"
|
||||
);
|
||||
}
|
||||
return (Ok(h), reloaded);
|
||||
}
|
||||
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
|
||||
// between absent and not-ready is treated as the transition it is.
|
||||
if probe.is_absent() {
|
||||
absent_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
absent_since = None;
|
||||
}
|
||||
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
|
||||
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
|
||||
match reload_vdisplay_adapter() {
|
||||
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
|
||||
// burning the arrival window on a box that simply does not have it installed.
|
||||
AdapterCycle::NotInstalled => {
|
||||
let e = Err(probe.into_error()).context(
|
||||
"no punktfunk virtual-display adapter devnode exists — the driver is not \
|
||||
installed",
|
||||
);
|
||||
return (e, reloaded);
|
||||
}
|
||||
AdapterCycle::Refused(why) => {
|
||||
let e = Err(probe.into_error()).context(format!(
|
||||
"the pf-vdisplay adapter devnode could not be reloaded ({why})"
|
||||
));
|
||||
return (e, reloaded);
|
||||
}
|
||||
AdapterCycle::Reloaded { .. } => {
|
||||
reloaded = true;
|
||||
absent_since = None;
|
||||
deadline = (Instant::now() + ARRIVAL_AFTER_RELOAD).min(started + TOTAL_BUDGET);
|
||||
}
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let e = Err(probe.into_error()).context(format!(
|
||||
"the pf-vdisplay control interface did not appear within {:?}{}",
|
||||
started.elapsed(),
|
||||
if reloaded {
|
||||
" (including an adapter reload)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
return (e, reloaded);
|
||||
}
|
||||
std::thread::sleep(PROBE_INTERVAL);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -906,6 +1170,96 @@ mod tests {
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The recovery must not be able to claim success it did not achieve. This is the whole bug:
|
||||
/// the old script ran the cycle under `SilentlyContinue` and reported `(Get-PnpDevice).Status`,
|
||||
/// so a device whose disable had been REFUSED — untouched, still started — reported `OK`, and
|
||||
/// the host logged `cycled the adapter device … status=OK` while nothing had been cycled at all
|
||||
/// (field report 2026-08-02). A refusal must decode as a refusal, carrying its reason.
|
||||
#[test]
|
||||
fn a_refused_reload_is_not_reported_as_a_reload() {
|
||||
let refused =
|
||||
classify_reload_output("REFUSED This device cannot be disabled because it is in use.");
|
||||
match refused {
|
||||
AdapterCycle::Refused(why) => {
|
||||
assert!(why.contains("in use"), "the reason must survive: {why:?}")
|
||||
}
|
||||
other => panic!("a refused reload decoded as {}", variant(&other)),
|
||||
}
|
||||
// A bare device status — what the OLD script emitted on every path — must NEVER decode as a
|
||||
// successful reload now, however healthy it looks.
|
||||
for stale in ["OK", "Error", "Unknown"] {
|
||||
assert!(
|
||||
matches!(classify_reload_output(stale), AdapterCycle::Refused(_)),
|
||||
"{stale:?} is a device status, not a reload outcome"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
|
||||
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
|
||||
/// disable was refused and something still holds the device open).
|
||||
#[test]
|
||||
fn reload_outcomes_decode() {
|
||||
assert!(matches!(
|
||||
classify_reload_output("ABSENT"),
|
||||
AdapterCycle::NotInstalled
|
||||
));
|
||||
match classify_reload_output("RELOADED cycle OK") {
|
||||
AdapterCycle::Reloaded { how, status } => {
|
||||
assert_eq!(how, "disable+enable");
|
||||
assert_eq!(status, "OK");
|
||||
}
|
||||
other => panic!("expected Reloaded, got {}", variant(&other)),
|
||||
}
|
||||
match classify_reload_output("RELOADED restart OK\r\n") {
|
||||
AdapterCycle::Reloaded { how, status } => {
|
||||
assert_eq!(how, "pnputil /restart-device");
|
||||
assert_eq!(status, "OK");
|
||||
}
|
||||
other => panic!("expected Reloaded, got {}", variant(&other)),
|
||||
}
|
||||
// powershell died before writing anything — an un-reloaded devnode, so `Refused`, not a
|
||||
// silent success.
|
||||
assert!(matches!(
|
||||
classify_reload_output(" "),
|
||||
AdapterCycle::Refused(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
|
||||
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
|
||||
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
|
||||
/// the outage it is already recovering from.
|
||||
#[test]
|
||||
fn only_a_total_absence_counts_as_absent() {
|
||||
let probe = |active, inactive| Probe {
|
||||
handle: None,
|
||||
active,
|
||||
inactive,
|
||||
last_err: None,
|
||||
};
|
||||
assert!(probe(0, 0).is_absent(), "no instances at all = absent");
|
||||
assert!(
|
||||
!probe(0, 1).is_absent(),
|
||||
"a registered-but-inactive instance is a device coming up, not a missing one"
|
||||
);
|
||||
assert!(
|
||||
!probe(1, 0).is_absent(),
|
||||
"an active instance we merely failed to open is not a missing device"
|
||||
);
|
||||
// And the diagnostic names what was seen — the old message collapsed every one of these
|
||||
// into "is the driver installed?", which sent a field report down the wrong path.
|
||||
assert!(probe(0, 2).into_error().to_string().contains("2 inactive"));
|
||||
}
|
||||
|
||||
fn variant(c: &AdapterCycle) -> &'static str {
|
||||
match c {
|
||||
AdapterCycle::Reloaded { .. } => "Reloaded",
|
||||
AdapterCycle::NotInstalled => "NotInstalled",
|
||||
AdapterCycle::Refused(_) => "Refused",
|
||||
}
|
||||
}
|
||||
|
||||
/// Live hardware round trip — `#[ignore]`d (needs the pf-vdisplay driver installed); run with
|
||||
/// `cargo test -p pf-vdisplay -- --ignored live_create_drop`. Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
|
||||
@@ -73,9 +73,9 @@ pub struct StreamedAu {
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
/// Bytes not yet sealed into a block: the sub-shard remainder plus anything below the
|
||||
/// slice-flush threshold. The final block always has ≥ 1 byte (flushes emit only whole
|
||||
/// shards and never drain to empty on a slice that ends the AU — `finish_streamed` seals
|
||||
/// whatever remains).
|
||||
/// slice-flush threshold. The final block always has ≥ 1 byte — flushes emit only whole
|
||||
/// shards, and a flush that WOULD empty this keeps one shard back (see `push_streamed`),
|
||||
/// so `finish_streamed` always has something real to seal.
|
||||
pending: Vec<u8>,
|
||||
/// Sentinel blocks already emitted.
|
||||
blocks_out: u16,
|
||||
@@ -418,7 +418,18 @@ impl Packetizer {
|
||||
"streamed AU exceeds the negotiated max_frame_bytes",
|
||||
));
|
||||
}
|
||||
let k = whole.min(self.fec.max_data_per_block as usize);
|
||||
// Never drain `pending` to EMPTY. [`finish_streamed`] must have bytes left to seal,
|
||||
// or the final block degenerates to a single zero-padded filler shard whose derived
|
||||
// base (`total_data − 1`) overlaps the block flushed just now — which the receiver's
|
||||
// retro-validation correctly reads as a lying header and kills the whole AU. It bites
|
||||
// exactly when the AU's length is a multiple of `shard_payload` (~1 in 1408 frames on
|
||||
// a 1500-MTU link), and only on the slice arm: the legacy `must_flush` is a strict
|
||||
// `>`, so its remainder is never empty. Keeping one whole shard back costs nothing —
|
||||
// it rides out in the final block, which has to exist regardless.
|
||||
let mut k = whole.min(self.fec.max_data_per_block as usize);
|
||||
if k > 1 && k == whole && au.pending.len() == whole * payload {
|
||||
k -= 1;
|
||||
}
|
||||
let sof = !au.opened;
|
||||
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
|
||||
let fi = au.frame_index;
|
||||
|
||||
@@ -467,14 +467,33 @@ impl Reassembler {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
|
||||
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
|
||||
// the limits' maximum — its real size doesn't exist yet.
|
||||
let buf_len = if sentinel {
|
||||
total_data_max * shard_bytes
|
||||
// How many shards of frame buffer THIS packet proves the frame needs. A sentinel carries
|
||||
// no total, but it does pin its own block's extent — a slice sentinel by its wire base,
|
||||
// a legacy one by its full-K position — and that is what the buffer must cover to place
|
||||
// the shard. The frame grows as later blocks reveal more, and the final (non-sentinel)
|
||||
// block's totals settle it.
|
||||
//
|
||||
// ⚠ NOT `total_data_max` (= the negotiated `max_frame_bytes`, 8-64 MiB): that shape
|
||||
// shipped in 0.23.0 and was survivable only while sentinels were rare — the streamed
|
||||
// path emitted one solely for an AU exceeding a whole FEC block (~281 KB). The slice
|
||||
// wire flushes at `MIN_STREAM_BLOCK_SHARDS`, so EVERY ordinary AU became sentinel-opened
|
||||
// and every one of them committed the full ceiling: a multi-megabyte zeroed allocation
|
||||
// per access unit, and an in-flight budget (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`)
|
||||
// exhausted after ~3 concurrent frames — beyond which every packet of every further
|
||||
// frame was dropped outright. On a jittery link that is a permanent loss storm.
|
||||
let need_shards = if sentinel && slice_stream {
|
||||
frame_bytes / shard_bytes + data_shards
|
||||
} else if sentinel {
|
||||
// Legacy sentinels are full-K uniform blocks (firewall-enforced), so the block's
|
||||
// index alone gives its end.
|
||||
(block_idx + 1).saturating_mul(lim.max_data_shards)
|
||||
} else {
|
||||
total_data * shard_bytes
|
||||
};
|
||||
total_data
|
||||
}
|
||||
.min(total_data_max);
|
||||
// First packet of a frame allocates its (zeroed) buffer, budget-gated; later packets must
|
||||
// agree with its geometry.
|
||||
let buf_len = need_shards * shard_bytes;
|
||||
let frame = match win.frames.entry(hdr.frame_index) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
@@ -602,6 +621,21 @@ impl Reassembler {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// Grow to this packet's proven extent. A streamed frame opens at whichever block arrived
|
||||
// first and learns its real size from the final block's totals (or a later, higher
|
||||
// sentinel base) — reorder means either can come first, so the buffer is sized by
|
||||
// whatever the frame has proven so far. Never shrinks: the totals only settle the frame's
|
||||
// END, and completion truncates to `frame_bytes` anyway. The budget is re-checked here
|
||||
// for exactly the reason it is checked at open — growth commits memory too.
|
||||
if buf_len > frame.buf.len() {
|
||||
let delta = buf_len - frame.buf.len();
|
||||
if *in_flight_bytes + delta > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
*in_flight_bytes += delta;
|
||||
frame.buf.resize(buf_len, 0);
|
||||
}
|
||||
let FrameBuf {
|
||||
buf,
|
||||
blocks,
|
||||
|
||||
@@ -941,8 +941,9 @@ fn slice_config() -> Config {
|
||||
|
||||
/// Slice chunks chosen to exercise every packetizer path: an exact-shard slice, a slice with
|
||||
/// a sub-shard remainder, a slice below [`MIN_STREAM_BLOCK_SHARDS`] that must accumulate,
|
||||
/// and a finish tail. 1023 B total → blocks (K, base-shard): (20, 0), (25, 20), (18, 45),
|
||||
/// final (1, 63) with block_count 4.
|
||||
/// and a finish tail. 1023 B total → blocks (K, base-shard): (19, 0), (26, 19), (18, 45),
|
||||
/// final (1, 63) with block_count 4. Chunk 0 is an exact 20-shard multiple and flushes 19:
|
||||
/// a flush never drains `pending` to empty, so `finish_streamed` always seals real bytes.
|
||||
fn slice_chunks() -> Vec<Vec<u8>> {
|
||||
[320usize, 403, 100, 200]
|
||||
.iter()
|
||||
@@ -1007,7 +1008,8 @@ fn slice_streamed_wire_shape_and_roundtrip() {
|
||||
assert_eq!(src.len(), 1023);
|
||||
// (block_index, K, base bytes) — chunk 2 (100 B) accumulated instead of flushing (6
|
||||
// whole shards < MIN_STREAM_BLOCK_SHARDS) and rode into block 2 with chunk 3's bytes.
|
||||
let expect = [(0u16, 20u16, 0u32), (1, 25, 320), (2, 18, 720)];
|
||||
// Block 0 keeps one shard back (chunk 0 is an exact multiple), which rides into block 1.
|
||||
let expect = [(0u16, 19u16, 0u32), (1, 26, 304), (2, 18, 720)];
|
||||
for p in &pkts {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
assert_ne!(
|
||||
@@ -1478,15 +1480,24 @@ fn parts_flow_for_legacy_streamed_frames() {
|
||||
assert!(got.last().unwrap().complete);
|
||||
}
|
||||
|
||||
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
|
||||
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
|
||||
/// A one-datagram open commits only the buffer its OWN header proves it needs, and the
|
||||
/// in-flight budget still bounds the ones that claim a lot.
|
||||
///
|
||||
/// Both halves matter. A sentinel that claims little must cost little: sizing every
|
||||
/// sentinel-opened frame at `max_frame_bytes` (the 0.23.0 shape) was survivable only while
|
||||
/// sentinels were rare, and the slice wire made every ordinary AU one — after which the budget
|
||||
/// was spent on ~3 frames and everything else on the link was dropped. A sentinel that claims a
|
||||
/// lot must still be bounded: its wire base can point near the frame ceiling, which is the
|
||||
/// amplification this budget exists for.
|
||||
#[test]
|
||||
fn streamed_open_amplification_is_budget_bounded() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
fn streamed_open_commits_its_own_extent_and_stays_bounded() {
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
// limits(): shard 16 B, max_data_shards 8, max_frame_bytes 4096 → budget = 4 × 4096.
|
||||
// Modest legacy sentinels (block 0, full K = 8 → 128 B each): far more than
|
||||
// IN_FLIGHT_BUF_FACTOR of them must fit, because none of them claims the ceiling.
|
||||
let mut r = Reassembler::new(limits());
|
||||
let stats = StatsCounters::default();
|
||||
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
|
||||
for fi in 0..5u32 {
|
||||
for fi in 0..32u32 {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
@@ -1498,10 +1509,35 @@ fn streamed_open_amplification_is_budget_bounded() {
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
0,
|
||||
"ordinary one-datagram opens must not exhaust the in-flight budget"
|
||||
);
|
||||
|
||||
// A SLICE sentinel whose wire base sits just under the ceiling really does commit a
|
||||
// max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B) — four fit the budget, the
|
||||
// fifth must be refused.
|
||||
let mut r = Reassembler::new(limits());
|
||||
let stats = StatsCounters::default();
|
||||
for fi in 0..5u32 {
|
||||
let mut h = base_header();
|
||||
h.user_flags = USER_FLAG_SLICE_STREAM;
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 4096 - 8 * 16;
|
||||
h.block_index = 1;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
h.frame_index = fi;
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
1,
|
||||
"the fifth max-sized open must be refused by the in-flight budget"
|
||||
"the fifth ceiling-claiming open must be refused by the in-flight budget"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1612,3 +1648,124 @@ fn streamed_second_final_with_different_totals_is_rejected() {
|
||||
.expect("frame completes under the first pinned totals");
|
||||
assert_eq!(got.data.len(), 160);
|
||||
}
|
||||
|
||||
/// Production-shaped slice geometry: a 1500-MTU shard payload and the smallest frame ceiling
|
||||
/// the QUIC handshake ever negotiates (`max_frame_bytes` is clamped to ≥ 8 MiB there).
|
||||
fn prod_slice_config() -> Config {
|
||||
use crate::config::{FecConfig, ProtocolPhase, Role};
|
||||
Config {
|
||||
role: Role::Host,
|
||||
phase: ProtocolPhase::P2Punktfunk,
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 20,
|
||||
max_data_per_block: 200,
|
||||
},
|
||||
shard_payload: crate::config::mtu1500_shard_payload(),
|
||||
max_frame_bytes: 8 << 20,
|
||||
encrypt: false,
|
||||
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Packetize one streamed AU of `chunks`, each chunk an encoder slice boundary.
|
||||
fn streamed_packets_with(
|
||||
cfg: &Config,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
slice: bool,
|
||||
chunks: &[usize],
|
||||
) -> (Vec<Vec<u8>>, Vec<u8>) {
|
||||
let coder = coder_for(cfg.fec.scheme);
|
||||
let mut pk = Packetizer::new(cfg);
|
||||
let uf = if slice { USER_FLAG_SLICE_STREAM } else { 0 };
|
||||
let mut au = pk.begin_streamed(pts_ns, uf, Some(frame_index));
|
||||
let (mut pkts, mut src) = (Vec::new(), Vec::new());
|
||||
let sink = |pkts: &mut Vec<Vec<u8>>, h: &PacketHeader, b: &[u8]| {
|
||||
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
|
||||
p.extend_from_slice(h.as_bytes());
|
||||
p.extend_from_slice(b);
|
||||
pkts.push(p);
|
||||
};
|
||||
for (c, &n) in chunks.iter().enumerate() {
|
||||
let data: Vec<u8> = (0..n).map(|i| (c * 57 + i * 131 + 7) as u8).collect();
|
||||
src.extend_from_slice(&data);
|
||||
pk.push_streamed(&mut au, &data, true, coder.as_ref(), |h, b| {
|
||||
sink(&mut pkts, h, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
pk.finish_streamed(au, coder.as_ref(), |h, b| {
|
||||
sink(&mut pkts, h, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(pkts, src)
|
||||
}
|
||||
|
||||
/// An AU whose length is an exact multiple of the shard payload must still reassemble.
|
||||
///
|
||||
/// Regression: the slice flush drained `pending` to empty, so `finish_streamed` sealed a final
|
||||
/// block of one zero-padded FILLER shard. Its derived base (`total_data − 1`) overlapped the
|
||||
/// sentinel block flushed a moment earlier, the receiver's retro-validation read that as a lying
|
||||
/// header, and the whole AU was destroyed — one frame in every `shard_payload` (~12 s at 120 fps),
|
||||
/// each costing a re-anchor freeze and a recovery keyframe.
|
||||
#[test]
|
||||
fn slice_streamed_exact_shard_multiple_completes() {
|
||||
let cfg = prod_slice_config();
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let payload = cfg.shard_payload;
|
||||
for shards in [16usize, 29, 30, 64] {
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[shards * payload]);
|
||||
// Whatever the block split, the final block must carry real bytes — never a lone
|
||||
// zero-pad shard sitting on top of the previous block's range.
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("{shards}-shard AU (exact multiple) must complete"));
|
||||
assert_eq!(f.data, src, "{shards}-shard AU must be byte-identical");
|
||||
}
|
||||
// ...and the sweep around one of them, so an off-by-one in the keep-back can't hide.
|
||||
for extra in 0..3usize {
|
||||
let n = 30 * payload + extra;
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &[n]);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("{n}-byte AU must complete"));
|
||||
assert_eq!(f.data, src);
|
||||
}
|
||||
}
|
||||
|
||||
/// A slice-streamed frame must cost the reassembler its OWN size, not the negotiated ceiling.
|
||||
///
|
||||
/// Regression: sentinel-opened frames allocated `max_frame_bytes` (8-64 MiB) each. Since the
|
||||
/// slice wire makes every ordinary AU sentinel-opened, the in-flight budget
|
||||
/// (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`) was spent after ~3 concurrent frames and every
|
||||
/// packet of every further frame was dropped outright — a permanent loss storm on any link with
|
||||
/// normal reorder, plus a multi-megabyte zeroing per access unit.
|
||||
#[test]
|
||||
fn slice_streamed_in_flight_budget_matches_legacy() {
|
||||
let cfg = prod_slice_config();
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
// A normal 40 KB access unit, opened but not completed — the shape a link with reorder
|
||||
// holds several of at once.
|
||||
for slice in [false, true] {
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
for i in 0..12u32 {
|
||||
let (pkts, _) = streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
|
||||
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
stats
|
||||
.packets_dropped
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
0,
|
||||
"slice={slice}: 12 ordinary AUs in flight must fit the in-flight budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,105 @@
|
||||
//! Circular (directional) statistics for phase-locked capture (design/phase-locked-capture.md):
|
||||
//! the client-side half of the controller's v2 error signal. Pure math, no features — shared so
|
||||
//! every vsync-aware presenter (Android today, iOS next) computes the SAME statistic the host
|
||||
//! the client-side half of the controller's v2 error signal, plus the panel-grid learner every
|
||||
//! vsync-aware presenter paces against. Pure math, no features — shared so every presenter
|
||||
//! (Android today, iOS and the desktop session client next) computes the SAME statistic the host
|
||||
//! controller was tuned against, and so the controller's simulation tests can generate their
|
||||
//! synthetic reports through the identical code path.
|
||||
|
||||
/// Plausible panel periods: ~24 Hz to ~500 Hz. A spacing outside this is a clock glitch, not a
|
||||
/// display mode, and must never reach the estimate.
|
||||
const PANEL_PERIOD_RANGE_NS: std::ops::RangeInclusive<i64> = 2_000_000..=42_000_000;
|
||||
|
||||
/// Spacings within this of the estimate are the same grid — absorbs ordinary timeline jitter.
|
||||
const PANEL_GRID_TOLERANCE_NS: i64 = 200_000;
|
||||
|
||||
/// Consecutive WIDER observations required before the estimate grows. One stray wide sample is a
|
||||
/// scheduling hiccup; eight in a row (~66 ms at 120 Hz) is a display that really did slow down.
|
||||
const PANEL_WIDEN_STREAK: u8 = 8;
|
||||
|
||||
/// The panel's true refresh period, learned from observed vsync/frame-timeline spacing.
|
||||
///
|
||||
/// A presenter subdivides its release targets onto this grid, so an estimate FINER than the panel
|
||||
/// makes it aim at instants that never arrive and release faster than the display consumes —
|
||||
/// which is why the estimate has to be able to move both ways.
|
||||
///
|
||||
/// Seeding is the reason this is not simply "believe the last sample". The platform's *configured*
|
||||
/// mode is not the panel: under a per-uid frame-rate override a 120 Hz panel reports 60
|
||||
/// (`Display.getRefreshRate` returns the override — observed on-glass, A024), and the app's own
|
||||
/// choreographer callbacks arrive at the down-rated rate while the panel scans at its own. The
|
||||
/// mode TABLE is honest about what the panel *can* do, so it is the seed; the timeline spacing is
|
||||
/// honest about what it is *doing*, so it is the correction.
|
||||
///
|
||||
/// The asymmetry is deliberate. **Narrowing is immediate**: a finer real grid is always safe to
|
||||
/// subdivide onto, and it is the down-rate case the seed most often gets wrong. **Widening needs
|
||||
/// [`PANEL_WIDEN_STREAK`] consecutive agreeing observations** and then adopts the *narrowest* of
|
||||
/// them, because a wide sample is far more likely to be a missed callback than a mode change.
|
||||
///
|
||||
/// ⚠ 0.23.0 shipped this learner as narrow-only, seeded from the display mode the app *requests*
|
||||
/// (`preferredDisplayModeId` is a hint the system may refuse). A refused 120 Hz switch therefore
|
||||
/// left the presenter pacing a 60 Hz panel on an 8.33 ms grid with no way back — permanently.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PanelGrid {
|
||||
period_ns: i64,
|
||||
widen_streak: u8,
|
||||
/// Narrowest wider-than-estimate spacing seen during the current streak.
|
||||
widen_candidate: i64,
|
||||
}
|
||||
|
||||
impl PanelGrid {
|
||||
/// Seed from the display mode's refresh rate (`0` = unknown — the first plausible observation
|
||||
/// then sets the estimate outright).
|
||||
pub fn seeded(hz: i32) -> PanelGrid {
|
||||
PanelGrid {
|
||||
period_ns: if hz > 0 { 1_000_000_000 / hz as i64 } else { 0 },
|
||||
widen_streak: 0,
|
||||
widen_candidate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The learned period, or `0` while unknown.
|
||||
pub fn period_ns(&self) -> i64 {
|
||||
self.period_ns
|
||||
}
|
||||
|
||||
/// Fold one observed grid spacing. Returns `true` when [`period_ns`](Self::period_ns) changed.
|
||||
pub fn observe(&mut self, spacing_ns: i64) -> bool {
|
||||
if !PANEL_PERIOD_RANGE_NS.contains(&spacing_ns) {
|
||||
return false; // implausible — a clock glitch, not a display mode
|
||||
}
|
||||
if self.period_ns == 0 {
|
||||
self.reset_streak();
|
||||
self.period_ns = spacing_ns;
|
||||
return true;
|
||||
}
|
||||
if spacing_ns < self.period_ns - PANEL_GRID_TOLERANCE_NS {
|
||||
self.reset_streak();
|
||||
self.period_ns = spacing_ns;
|
||||
return true;
|
||||
}
|
||||
if spacing_ns > self.period_ns + PANEL_GRID_TOLERANCE_NS {
|
||||
self.widen_streak = self.widen_streak.saturating_add(1);
|
||||
self.widen_candidate = if self.widen_candidate == 0 {
|
||||
spacing_ns
|
||||
} else {
|
||||
self.widen_candidate.min(spacing_ns)
|
||||
};
|
||||
if self.widen_streak >= PANEL_WIDEN_STREAK {
|
||||
self.period_ns = self.widen_candidate;
|
||||
self.reset_streak();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.reset_streak(); // this sample agreed — the run of wider ones is broken
|
||||
false
|
||||
}
|
||||
|
||||
fn reset_streak(&mut self) {
|
||||
self.widen_streak = 0;
|
||||
self.widen_candidate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular (vector-mean) statistics of latch samples against a display period: the mean latch
|
||||
/// mod the period (ns) and the coherence (‰).
|
||||
///
|
||||
@@ -90,3 +186,111 @@ mod tests {
|
||||
assert!(circular_latch(&[1_000; 16], 0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_grid_tests {
|
||||
use super::*;
|
||||
|
||||
const P120: i64 = 8_333_333;
|
||||
const P60: i64 = 16_666_666;
|
||||
|
||||
#[test]
|
||||
fn seeds_from_the_mode_and_reports_unknown_without_one() {
|
||||
assert_eq!(PanelGrid::seeded(120).period_ns(), 8_333_333);
|
||||
assert_eq!(PanelGrid::seeded(0).period_ns(), 0);
|
||||
let mut g = PanelGrid::seeded(0);
|
||||
assert!(
|
||||
g.observe(P120),
|
||||
"the first plausible sample sets an unseeded grid"
|
||||
);
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrows_immediately_when_the_panel_is_faster_than_the_mode_said() {
|
||||
// The down-rate case: the mode table read 60, the timelines run at 120.
|
||||
let mut g = PanelGrid::seeded(60);
|
||||
assert!(g.observe(P120));
|
||||
assert_eq!(g.period_ns(), P120, "a finer real grid is adopted at once");
|
||||
}
|
||||
|
||||
/// The 0.23.0 bug: `preferredDisplayModeId` is a request, so a refused 120 Hz switch seeds a
|
||||
/// 120 Hz grid on a panel that is really running 60. The narrow-only learner could never
|
||||
/// climb back, and the presenter aimed at instants the panel never reached.
|
||||
#[test]
|
||||
fn widens_back_out_when_the_requested_mode_was_refused() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for i in 0..PANEL_WIDEN_STREAK - 1 {
|
||||
assert!(!g.observe(P60), "sample {i} must not widen on its own");
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
assert!(
|
||||
g.observe(P60),
|
||||
"a sustained run of wider spacings widens the grid"
|
||||
);
|
||||
assert_eq!(g.period_ns(), P60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_stray_wide_sample_never_widens() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for _ in 0..40 {
|
||||
assert!(!g.observe(P60));
|
||||
assert!(!g.observe(P120)); // an agreeing sample breaks the run
|
||||
}
|
||||
assert_eq!(
|
||||
g.period_ns(),
|
||||
P120,
|
||||
"alternating samples must not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widening_adopts_the_narrowest_of_the_run() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
// A run of wide spacings that includes some very wide outliers.
|
||||
let run = [
|
||||
P60,
|
||||
33_000_000,
|
||||
P60 + 400_000,
|
||||
41_000_000,
|
||||
P60,
|
||||
P60,
|
||||
P60,
|
||||
P60,
|
||||
];
|
||||
for s in run {
|
||||
g.observe(s);
|
||||
}
|
||||
assert_eq!(
|
||||
g.period_ns(),
|
||||
P60,
|
||||
"the estimate takes the narrowest of the run, never an outlier"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implausible_spacings_are_ignored_entirely() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for _ in 0..100 {
|
||||
assert!(!g.observe(0));
|
||||
assert!(!g.observe(-1));
|
||||
assert!(!g.observe(1_000_000)); // 1000 Hz — below the range floor
|
||||
assert!(!g.observe(100_000_000)); // 10 Hz — above the ceiling
|
||||
}
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transient_narrow_glitch_self_heals() {
|
||||
// Narrowing is immediate, so a glitch DOES poison the estimate — the point is that it is
|
||||
// no longer permanent (0.23.0's learner had no way back).
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
assert!(g.observe(2_100_000), "a glitch narrows the estimate");
|
||||
assert_eq!(g.period_ns(), 2_100_000);
|
||||
for _ in 0..PANEL_WIDEN_STREAK {
|
||||
g.observe(P120);
|
||||
}
|
||||
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
// The output's compositor rewrites `SPA_META_Cursor` on every buffer (KWin), so an id-0 meta
|
||||
// is an authoritative "pointer hidden" — the caller derives it from the backend that created
|
||||
// `vout` (which also covers registry-pooled reuse: a kept display only ever matches its own
|
||||
// backend). See `pf_capture`'s `cursor_id0_hides` contract.
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The portal negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the
|
||||
// capture backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch)
|
||||
@@ -132,6 +137,7 @@ pub fn capture_virtual_output(
|
||||
want.hdr,
|
||||
zero_copy_policy(want.pyrowave, want.nv12_native),
|
||||
vout.expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -171,6 +177,9 @@ pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
// Linux-only fact (the PipeWire cursor-meta contract); the IDD-push path has no
|
||||
// `SPA_META_Cursor` and its own CURSOR_SUPPRESSED hide source.
|
||||
_cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
@@ -285,6 +294,7 @@ pub fn capture_virtual_output(
|
||||
_vout: crate::vdisplay::VirtualOutput,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
_cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||
}
|
||||
|
||||
@@ -559,6 +559,7 @@ pub fn mirror_test(args: &[String]) -> Result<()> {
|
||||
vout,
|
||||
fmt,
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")?;
|
||||
cap.set_active(true);
|
||||
|
||||
@@ -570,6 +570,7 @@ fn open_gs_mirror_source(
|
||||
vout,
|
||||
pf_frame::OutputFormat::resolve(cfg.hdr, crate::zerocopy::enabled()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")
|
||||
}
|
||||
@@ -782,6 +783,7 @@ fn open_gs_virtual_source(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
|
||||
@@ -1815,6 +1815,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// on purpose — it survives every in-loop rebuild path (session switch, mode/stall rebuilds,
|
||||
// encoder backoff), so a mid-stream rebuild keeps the acquired lock.
|
||||
let mut phase_ctl = PhaseController::new();
|
||||
// Frame-driven wire-rate cap (see [`PaceBudget`]): a loop local like `phase_ctl`, and for the
|
||||
// same reason — it must survive every in-loop rebuild path so a mid-stream rebuild can't
|
||||
// reopen the overshoot. Bounded burst (CAP) is all a rebuild gap can buy.
|
||||
let mut pace = PaceBudget::new(std::time::Instant::now());
|
||||
// The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this
|
||||
// loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`).
|
||||
// A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs
|
||||
@@ -3117,8 +3121,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled.
|
||||
// Frame-driven mode (T1.1) re-anchors to the ACTUAL submit — arrivals are the clock, and a
|
||||
// fixed `+= interval` grid would drift against them and squeeze the pacing budget; the
|
||||
// legacy tick keeps its fixed grid (with the catch-up reset in the tail).
|
||||
// legacy tick keeps its fixed grid (with the catch-up reset in the tail). The rate-cap
|
||||
// charge lives under the same guard as the tail's gate: the legacy tick paces by its grid
|
||||
// alone, and charging it without ever accruing would bank unbounded debt that stalls the
|
||||
// loop if a rebuild later flips the capturer to arrival-wait.
|
||||
next = if frame_driven_enabled() && capturer.supports_arrival_wait() {
|
||||
pace.charge();
|
||||
std::time::Instant::now() + interval
|
||||
} else {
|
||||
next + interval
|
||||
@@ -3477,9 +3485,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// T1.1 frame-driven trigger: instead of sleeping out the whole tick and then
|
||||
// SAMPLING (which holds a frame that arrived just after the previous sample for up
|
||||
// to a full interval — ~half on average), sleep only to the rate floor and then
|
||||
// wake on the capture's actual arrival. The 0.9×interval floor caps the encode
|
||||
// rate at ~1.11× target when the source runs faster (compositor Hz > session fps);
|
||||
// the +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
|
||||
// wake on the capture's actual arrival. The 0.9×interval floor leaves per-gap jitter
|
||||
// headroom; the `pace` budget pins the long-run AVERAGE at the pacing rate — the
|
||||
// floor alone let a source that always has a frame pending (an HZ_MULT-overdriven
|
||||
// display under uncapped content) settle at 0.9-interval spacing, 1.11× the
|
||||
// negotiated rate on the wire, frames the client's panel can only drop. The
|
||||
// +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
|
||||
// client liveness) at 1.5×interval cadence and bounds control-servicing latency.
|
||||
//
|
||||
// Anchor the floor to THIS frame's arrival (`t_cap`), not to `next` — `next` is
|
||||
@@ -3489,9 +3500,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// period becomes interval + encode (≈158 fps off a 240 Hz source; 360 Hz → ~200).
|
||||
// An async encoder (NVENC) returns from submit in ≈0, so t_cap ≈ post-submit and this
|
||||
// is a no-op for it — which is why H.26x already holds full rate. Arrival-anchoring
|
||||
// lets the synchronous encode overlap the interval; the ≥0.9×interval spacing from
|
||||
// the last grab still caps the rate at ~1.11× target.
|
||||
let earliest = t_cap + interval.mul_f32(0.9);
|
||||
// lets the synchronous encode overlap the interval; the budget, not the floor, is
|
||||
// what bounds the sustained rate.
|
||||
let earliest = std::cmp::max(
|
||||
t_cap + interval.mul_f32(0.9),
|
||||
pace.earliest(std::time::Instant::now(), interval),
|
||||
);
|
||||
if let Some(d) = earliest.checked_duration_since(std::time::Instant::now()) {
|
||||
std::thread::sleep(d);
|
||||
}
|
||||
@@ -4001,6 +4015,59 @@ fn pacing_hz(session_hz: u32, achieved_hz: u32) -> u32 {
|
||||
achieved_hz.min(session_hz).max(1)
|
||||
}
|
||||
|
||||
/// Long-run rate limiter for the frame-driven trigger (T1.1): pins the AVERAGE encode rate at the
|
||||
/// pacing rate while the 0.9×interval arrival floor keeps its jitter headroom.
|
||||
///
|
||||
/// The floor alone bounds only each gap, so a source that always has a frame pending — a display
|
||||
/// overdriven by `PUNKTFUNK_VDISPLAY_HZ_MULT`, uncapped content — settles at 0.9-interval spacing:
|
||||
/// 1.11× the negotiated rate on the wire (field report: 132 fps on a 120 fps session, frames the
|
||||
/// client's 120 Hz panel can only drop). Credit accrues at one frame per interval of real elapsed
|
||||
/// time (capped at [`Self::CAP`]) and every submitted frame spends one; a grab may run early only
|
||||
/// against banked credit, so per-gap jitter still passes while the average cannot exceed the
|
||||
/// pacing rate. A source at or below the pacing rate banks credit faster than it spends and is
|
||||
/// never delayed.
|
||||
struct PaceBudget {
|
||||
/// Banked frames, in `[-1.0, CAP]`. Transiently dips below 0 when a grab spent credit it had
|
||||
/// only partly banked; the owed fraction is repaid before the next grab.
|
||||
credit: f32,
|
||||
/// When credit last accrued (the previous [`Self::earliest`] call).
|
||||
last: std::time::Instant,
|
||||
}
|
||||
|
||||
impl PaceBudget {
|
||||
/// Burst allowance: at most this many frames may follow a stall back-to-back before the
|
||||
/// bucket re-gates. One frame of instant catch-up plus the floor's own headroom.
|
||||
const CAP: f32 = 1.25;
|
||||
|
||||
fn new(now: std::time::Instant) -> PaceBudget {
|
||||
PaceBudget {
|
||||
credit: Self::CAP,
|
||||
last: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Accrue the elapsed credit and return the earliest instant the next grab may run: `now`
|
||||
/// once a full frame is banked, else the missing fraction of an interval out.
|
||||
fn earliest(
|
||||
&mut self,
|
||||
now: std::time::Instant,
|
||||
interval: std::time::Duration,
|
||||
) -> std::time::Instant {
|
||||
let secs = interval.as_secs_f32();
|
||||
if secs > 0.0 {
|
||||
let accrued = now.duration_since(self.last).as_secs_f32() / secs;
|
||||
self.credit = (self.credit + accrued).min(Self::CAP);
|
||||
}
|
||||
self.last = now;
|
||||
now + interval.mul_f32((1.0 - self.credit).max(0.0))
|
||||
}
|
||||
|
||||
/// One frame submitted — spend its credit.
|
||||
fn charge(&mut self) {
|
||||
self.credit -= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt the rate a freshly built pipeline's encoder was actually opened at.
|
||||
///
|
||||
/// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's
|
||||
@@ -4122,9 +4189,19 @@ fn build_pipeline(
|
||||
// VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2;
|
||||
// otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends,
|
||||
// which auto-detect HDR from the monitor state.)
|
||||
let mut capturer =
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
//
|
||||
// KWin rewrites `SPA_META_Cursor` on every buffer, so its id-0 metas are an authoritative
|
||||
// "pointer hidden" the cursor blend/forward must honor — without this, the composited arrow
|
||||
// outlives every in-game/Big Picture hide (0.22.0 field report). Derived from the backend
|
||||
// (correct for pooled reuse too — a kept display only matches its own backend).
|
||||
let cursor_id0_hides = vd.name() == pf_vdisplay::Compositor::Kwin.id();
|
||||
let mut capturer = crate::capture::capture_virtual_output(
|
||||
vout,
|
||||
plan.output_format(),
|
||||
plan.capture,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
|
||||
// reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
|
||||
// QueryPointer position) and feeds `cursor()`, which the encode loop composites.
|
||||
@@ -4270,6 +4347,105 @@ mod tests {
|
||||
assert_eq!(pacing_hz(60, 0), 1);
|
||||
}
|
||||
|
||||
/// Drive [`PaceBudget`] against a source that ALWAYS has a frame pending (the overdriven
|
||||
/// display + uncapped content case): each cycle grabs the instant the gate opens, the next
|
||||
/// `earliest` call runs right after (encode folded into the wait, like the loop). Returns the
|
||||
/// grab instants.
|
||||
fn grab_saturated(
|
||||
b: &mut PaceBudget,
|
||||
start: std::time::Instant,
|
||||
interval: std::time::Duration,
|
||||
n: usize,
|
||||
) -> Vec<std::time::Instant> {
|
||||
let mut now = start;
|
||||
let mut grabs = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let gate = b.earliest(now, interval);
|
||||
let grab = gate.max(now);
|
||||
b.charge();
|
||||
grabs.push(grab);
|
||||
now = grab;
|
||||
}
|
||||
grabs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_pins_a_saturated_source_at_the_interval() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
let grabs = grab_saturated(&mut b, t0, interval, 120);
|
||||
// Whatever the initial credit bought, the total may exceed the on-rate schedule by at
|
||||
// most the burst cap — 120 grabs span no less than (120 - 1 - CAP) intervals.
|
||||
let span = grabs[119].duration_since(grabs[0]);
|
||||
assert!(
|
||||
span >= interval.mul_f32(120.0 - 1.0 - PaceBudget::CAP),
|
||||
"span {span:?} admits more than CAP frames of overshoot"
|
||||
);
|
||||
// And the steady state is EXACTLY the interval: past the warmup, consecutive grabs are
|
||||
// one interval apart (not 0.9 — the 132-fps bug).
|
||||
for w in grabs[20..].windows(2) {
|
||||
let gap = w[1].duration_since(w[0]);
|
||||
assert!(
|
||||
gap >= interval.mul_f32(0.999) && gap <= interval.mul_f32(1.001),
|
||||
"steady-state gap {gap:?} != interval {interval:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_never_delays_an_on_rate_or_slow_source() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
// A source at half the pacing rate (a 60 fps game on a 120 fps session): every arrival
|
||||
// banks two frames of credit and spends one — the gate is always already open.
|
||||
let mut now = t0;
|
||||
for _ in 0..50 {
|
||||
now += interval * 2;
|
||||
assert_eq!(
|
||||
b.earliest(now, interval),
|
||||
now,
|
||||
"slow source must not be gated"
|
||||
);
|
||||
b.charge();
|
||||
}
|
||||
// Exactly on-rate: still never gated (credit hovers at the cap, never below 1).
|
||||
let mut b = PaceBudget::new(t0);
|
||||
let mut now = t0;
|
||||
for _ in 0..50 {
|
||||
now += interval;
|
||||
assert_eq!(
|
||||
b.earliest(now, interval),
|
||||
now,
|
||||
"on-rate source must not be gated"
|
||||
);
|
||||
b.charge();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_burst_after_a_stall_is_capped() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
// Settle into the gated steady state, then stall the source for 10 intervals.
|
||||
let grabs = grab_saturated(&mut b, t0, interval, 20);
|
||||
let stall_end = grabs[19] + interval * 10;
|
||||
// However long the stall, the recovery may run ahead of the on-rate schedule by at most
|
||||
// CAP frames: the second post-stall grab is already re-gated.
|
||||
let after = grab_saturated(&mut b, stall_end, interval, 3);
|
||||
assert_eq!(after[0], stall_end, "first post-stall grab is immediate");
|
||||
assert!(
|
||||
after[1].duration_since(after[0]) >= interval.mul_f32(2.0 - PaceBudget::CAP),
|
||||
"second post-stall grab spent more than the burst cap"
|
||||
);
|
||||
assert!(
|
||||
after[2].duration_since(after[1]) >= interval.mul_f32(0.999),
|
||||
"third post-stall grab must be back on the interval grid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_mode_multiplier_scales_only_the_refresh() {
|
||||
// Default (no env set in the test process) is 1× — the identity, which is what every
|
||||
|
||||
@@ -118,6 +118,7 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("capture virtual output")?
|
||||
}
|
||||
|
||||
@@ -82,9 +82,34 @@ Full detail: [HDR](/docs/hdr).
|
||||
**Full chroma (4:4:4)** — *default: off.* Crisp small text and thin lines, at more bandwidth. It
|
||||
needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full
|
||||
chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is
|
||||
built. **Today only the Apple app actually advertises 4:4:4**, and only when its hardware decode
|
||||
probe passes — the Linux and Windows apps store the toggle but their session doesn't advertise the
|
||||
capability yet, so it has no effect there. Android, Decky and the console home don't offer it.
|
||||
built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware
|
||||
decode probe to pass). Android, Decky and the console home don't offer it.
|
||||
|
||||
**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is
|
||||
ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup
|
||||
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens
|
||||
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps; the Apple and
|
||||
Android apps have carried the same setting for a while, and it is stored under the same name, so a
|
||||
[profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
|
||||
many frames are held back before showing. Each frame absorbs roughly one screen refresh of network
|
||||
hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra
|
||||
delay bought against 17 ms of jitter. If you never see stutter, you don't need this.
|
||||
|
||||
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
|
||||
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
|
||||
can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every
|
||||
driver or compositor offers a tearing mode, and where none is available the stream stays tear-free.
|
||||
The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off"
|
||||
from "off but unavailable". Linux and Windows apps.
|
||||
|
||||
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel
|
||||
refresh in step with the stream rather than on a fixed cadence — which removes the wait between a
|
||||
frame being ready and the screen being willing to show it. Applies to **fullscreen** sessions (a
|
||||
windowed one is at the compositor's mercy) and is harmless on a fixed-refresh screen. The stats
|
||||
overlay reports `vrr yes` once it has measured that the panel really is following. Linux and
|
||||
Windows apps.
|
||||
|
||||
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual
|
||||
output. Advisory: a host without that backend quietly auto-detects instead.
|
||||
|
||||
@@ -242,6 +242,8 @@ A few knobs are read by the native **clients**, not the host:
|
||||
| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. |
|
||||
| `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. |
|
||||
| `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. |
|
||||
| `PUNKTFUNK_PRESENTER` | `arrival` | Turn the frame-pacing engine off for this run: frames present the instant they decode, exactly as they did before the **Prioritize** setting existed. A diagnostic — if a pacing change is suspected of causing judder or added delay, this switches it off without reinstalling anything. Linux and Windows clients. |
|
||||
| `PUNKTFUNK_PRESENT_DEBUG` | `1` | Log the presenter's own 1-second summary (display mode, buffer drops, pacing counters) every second, even when nothing is going wrong. Without it the line appears only when there is something to report. |
|
||||
| `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `900000` | The startup link-capacity probe's burst target (default 2 Gbps — deliberately above any plausible link so the burst measures the link, not itself). Lower it on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. |
|
||||
| `PUNKTFUNK_ABR_PROBE` | `0` | Skip the startup link-capacity probe entirely. The adaptive-bitrate climb ceiling then stays at the negotiated starting rate — a blunt instrument; prefer `PUNKTFUNK_ABR_MAX_MBPS`. |
|
||||
| `PUNKTFUNK_ABR_MAX_MBPS` | Mbps, e.g. `300` | Hard cap on the adaptive bitrate's climb ceiling, whatever the startup probe measured. The escape hatch when adaptive sessions keep climbing past what your client's **decoder** can sustain (periodic hitch + "receive backlog stopped draining" in the client log). An explicit bitrate setting still bypasses ABR entirely. |
|
||||
|
||||
@@ -62,8 +62,9 @@ differently. Linux · Windows · Steam Deck:
|
||||
|
||||
```
|
||||
1920×1080@120 · 120 fps · 24.3 Mb/s · target 30 Mb/s (auto) · vulkan · HDR
|
||||
e2e 14.2/19.8 ms (p50/p95) · host 3.1 · net 6.7 · decode 2.1 · display 2.3 ms
|
||||
e2e 14.2/19.8 ms (p50/p95) · host 3.1 · net 6.7 · decode 2.1 · display 2.3 ms (pace 0.6 + latch 1.7)
|
||||
host: queue 0.6 · encode 1.8 · xfer 0.2 · pace 0.5 ms
|
||||
present: mailbox
|
||||
lost 3 (2.4%)
|
||||
```
|
||||
|
||||
@@ -109,10 +110,11 @@ lost 3 (2.4%)
|
||||
which otherwise reads as inexplicable judder plus a refresh of extra latency.
|
||||
- **Line 2 — the headline.** `end-to-end` (`e2e` on Linux/Windows) is the *directly
|
||||
measured* time from host capture to the endpoint named at the end of the line —
|
||||
`capture→on-glass` or `capture→displayed`. Linux/Windows don't spell the endpoint out,
|
||||
because their presenter always measures to the present instant. `p50` = the typical
|
||||
frame (median), `p95` = the slow outliers. This is the one number that summarizes your
|
||||
stream.
|
||||
`capture→on-glass` or `capture→displayed`. On Linux/Windows the endpoint is the moment
|
||||
the frame is genuinely **visible** wherever the GPU driver can report it (most can);
|
||||
where it can't, the measurement stops at the instant the frame is handed to the display
|
||||
and so reads slightly optimistic. `p50` = the typical frame (median), `p95` = the slow
|
||||
outliers. This is the one number that summarizes your stream.
|
||||
- **Line 3 — where the time goes.** The first four stages **tile the end-to-end interval** —
|
||||
each starts where the previous one ends, so they add up to the headline. The two extra
|
||||
terms under them are not extra time: one is excluded from the total, the other sits inside a
|
||||
@@ -123,7 +125,12 @@ lost 3 (2.4%)
|
||||
reassembly on your device.
|
||||
- `decode` — received → decoded, on your device.
|
||||
- `display` — decoded → displayed: waiting for the right screen refresh, rendering,
|
||||
and vsync.
|
||||
and vsync. On Linux/Windows it splits into `(pace + latch)` when your driver reports
|
||||
true on-glass timing: **pace** is Punktfunk's own work — getting the decoded frame
|
||||
submitted — and **latch** is the wait for the display to take it. A large `latch` is
|
||||
the screen's refresh cycle, not the stream; a large `pace` is us. (`pace` is also the
|
||||
fair number to compare against an iPhone or iPad, whose figure already has its
|
||||
equivalent of `latch` removed.)
|
||||
- `os present` *(iOS and tvOS)* — the fixed depth of the OS present pipeline, which is
|
||||
excluded from both the headline and `display` and printed here so you can add it
|
||||
back.
|
||||
@@ -143,6 +150,15 @@ lost 3 (2.4%)
|
||||
encode … · xfer … · pace …` — splitting the host's own share into its stages, when the
|
||||
host reports them.
|
||||
|
||||
Linux/Windows Detailed also carries a **`present:`** line naming how frames are reaching
|
||||
your screen: the display mode in use (`mailbox`, `fifo`, …), `vrr yes`/`vrr no` once the
|
||||
client has *measured* whether your screen is following the stream's cadence (it is
|
||||
reported only when measured — no guess from what the display claims), and, when the
|
||||
[presentation setting](/docs/client-settings#video) is *Smoothness*, the word
|
||||
`smoothing`. Counters join it only when they're doing something — `qdrop`/`qdry` mean
|
||||
the smoothing buffer overflowed or ran dry (a jittery link), and `gated`/`forced`
|
||||
belong to the pacing that keeps frames from stacking up behind the display.
|
||||
|
||||
(Stage values are per-stage medians, so they sum only *approximately* to the
|
||||
headline median — percentiles aren't perfectly additive. The headline is measured
|
||||
directly, never computed as a sum.)
|
||||
|
||||
@@ -88,6 +88,7 @@ mod linux {
|
||||
false,
|
||||
policy,
|
||||
vout.expect_exact_dims,
|
||||
compositor == pf_vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach the PipeWire capturer")?;
|
||||
cap.set_active(true);
|
||||
|
||||
Reference in New Issue
Block a user