WP1 continued — the Linux/Windows session client joins Android on the shared PresentIntervals. Apple is the remaining leg. The metric lives on LatchClock because that is where the on-glass stamps and the learned panel grid it quantises against already meet, so no new plumbing and no second source of truth about the grid. Every stamp is scored, including the sub-millisecond pairs the grid learner deliberately skips: two presents inside one refresh is not a grid step, but it is very much a cadence event. The period is taken seeded-or-learned rather than learned-only, so cadence is scored from the first window instead of waiting for the learner to converge. Judder also becomes a trigger for the 1 Hz presenter line. That line only fired on drops, gate holds or the debug env var — and a cadence defect produces none of those: no drops, no holds, healthy percentiles, visibly broken motion. Without this a desktop stream could judder for an entire session and never emit a line, which is the same blind spot the metric exists to close. One honest asymmetry, recorded in the code: these stamps are CLOCK_REALTIME (this module's domain), so a wall-clock step would forge a hitch that never happened. It lands in the stall/disordered counters rather than the judder ratio, which is part of why that split is worth having. Android feeds the metric a raw monotonic stamp and has no such exposure. Gates: cargo check + clippy --all-targets on pf-presenter clean; punktfunk-core clippy -D warnings clean; fmt clean.
782 lines
33 KiB
Rust
782 lines
33 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;
|
||
|
||
/// Judder (‰ of present intervals off the modal spacing) that on its own justifies a 1 Hz
|
||
/// presenter line. A cadence defect produces no drops, no gate holds and healthy latency
|
||
/// percentiles, so it would otherwise stay silent until someone set the debug env var.
|
||
/// Occasional single-frame slips are normal; a twentieth of a window is not.
|
||
pub(crate) const JUDDER_LOG_PERMILLE: u16 = 50;
|
||
|
||
/// 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,
|
||
/// The cadence (judder) statistic — the only stat we publish that is not a latency,
|
||
/// and the only one that can see a pacing defect. Lives here because this is where
|
||
/// the on-glass stamps and the learned grid it quantises against already meet.
|
||
///
|
||
/// ⚠ These stamps are `CLOCK_REALTIME` (the module's domain), so a wall-clock step
|
||
/// would forge one hitch that never happened. It lands in the stall/disordered
|
||
/// counters rather than the judder ratio, which is why that split is worth having.
|
||
/// Android feeds the metric a raw monotonic stamp and has no such exposure.
|
||
intervals: punktfunk_core::phase::PresentIntervals,
|
||
}
|
||
|
||
/// 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)),
|
||
intervals: punktfunk_core::phase::PresentIntervals::new(),
|
||
}
|
||
}
|
||
|
||
/// Drain the window's cadence summary — the 1 Hz stat boundary, beside the store and
|
||
/// gate counters.
|
||
pub(crate) fn take_cadence(&mut self) -> Option<punktfunk_core::phase::PresentCadence> {
|
||
self.intervals.take()
|
||
}
|
||
|
||
/// 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]) {
|
||
// Seeded-or-learned, so cadence is scored from the first window rather than only
|
||
// once the learner has converged. Held for the batch: a mid-batch period change
|
||
// would requantise a handful of samples for no benefit.
|
||
let period_ns = self.period_ns() as i64;
|
||
for &s in stamps {
|
||
// Cadence sees EVERY stamp, including the sub-millisecond pairs the grid
|
||
// learner skips below: two presents inside one refresh is not a grid step,
|
||
// but it is very much a cadence event (it scores as a zero-refresh interval).
|
||
self.intervals.record(s as i64, period_ns);
|
||
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");
|
||
}
|
||
}
|