WP6 ran against .21 (CachyOS, RTX 5070 Ti, NVIDIA 610.43.03, GNOME/Wayland, 1080p60 HDMI, VRR provably disabled — `org.gnome.mutter experimental-features` is empty), host and client on the same box, `VK_KHR_present_wait` available. Five defects that unit tests and both CI gates had passed over: 1. The latch learner and the VRR probe observed NOTHING. Both derived spacings with `windows(2)` inside a single batch, but the run loop drains present-wait samples every pass, so a batch is normally ONE stamp. `period_us` read back exactly the mode fallback — correct by luck on a 60 Hz panel, wrong the moment a mode lies, which is the entire reason PanelGrid exists. The tests fed 40-stamp batches, a shape the live loop never produces. Spacings are now measured against the previous stamp across calls. 2. The VRR reference was circular. It compared spacings against the LEARNED period, but the grid cannot be learned from our own presents when the stream runs below panel rate — we only ever observe multiples ≥ our frame interval, so the learner adopts our own cadence and every delta is on-grid by construction. It learned 18-22 ms from a 40-50 fps stream and reported VRR on a display with VRR off. The reference is now the DISPLAY MODE's period, which is the vblank grid presents actually quantize to. 3. The probe is meaningless outside FIFO. MAILBOX deliberately decouples presents from scanout, so its stamps are never grid-quantized: same panel, same minute, FIFO read `no` (correct, period 16.4 ms) and MAILBOX read `yes` (wrong). Outside a FIFO-family mode the honest answer is Unknown, and that is now what it reports. 4. Round evaluation was per-CALL rather than per-sample, so the verdict depended on how the caller batched its stamps. Closed inside the sample loop now, with a test pinning bulk-vs-one-at-a-time equivalence — the same invariant (1) violated, in a second place. 5. `force_latency` was dead code without the `pyrowave` feature: a warning in the `--no-default-features` build CI actually ships (the Windows ARM64 leg). The gate only ever tested default features; it now tests both. DESIGN REVERSAL — the VRR FIFO-first ladder is opt-in (`PUNKTFUNK_VRR_FIFO=1`), no longer default. It shipped default-on for `allow_vrr` + fullscreen, which is the default configuration. Measured A/B, same box, back to back, reproduced across three runs: FIFO+engine `display 28.4 ms (pace 11.8 + latch 16.6)` versus MAILBOX `1.4 ms (0.2 + 1.2)`. Under a compositor the FIFO present's on-glass confirmation arrives a whole refresh later and the presenter serialises behind it. The VRR upside is real in principle but UNMEASURED — no VRR panel was available — and a default that is measurably ~27 ms worse on the hardware we could test, bought against an unproven win on hardware we could not, is the wrong way round. A test pins the default to MAILBOX; flip it back when a VRR panel confirms the win. NOT measured, and not claimed: the FIFO glass gate's own headline. The standing queue only forms when the stream rate approaches the panel rate, and an idle GNOME desktop is damage-driven at 40-50 fps on a 60 Hz panel, so `gated`/`forced` read 0 in every mode and the mechanism never engaged. The 11-13 ms figure is still the code's inherited documentation, not a fresh measurement. It needs its actual target: AMD-on-Windows (no MAILBOX, direct scanout) under load. Rig caveats recorded rather than smoothed over: host and client shared one GPU, so absolute latencies are contended and run-to-run variance was large, and it could not be visually confirmed what the physical screen showed. Mode selection, the fallback ladder, the VRR verdict and the counter plumbing are robust to that; absolute numbers are not. Gates: fmt, clippy -D warnings over the five client crates AND the `--no-default-features` build (added because defect 5 hid there), 160 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
752 lines
31 KiB
Rust
752 lines
31 KiB
Rust
//! 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");
|
||
}
|
||
}
|