diff --git a/crates/pf-presenter/src/present_pace.rs b/crates/pf-presenter/src/present_pace.rs index 38b21b00..a853551c 100644 --- a/crates/pf-presenter/src/present_pace.rs +++ b/crates/pf-presenter/src/present_pace.rs @@ -123,6 +123,11 @@ impl FrameStore { /// 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; @@ -164,35 +169,65 @@ impl FrameStore { /// 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 a batch of on-glass stamps (ascending submission order). A single stamp - /// re-anchors without touching the learned period — that is also the no-present-wait - /// degradation, where each submit stamp anchors an approximate grid on the mode's - /// refresh period. + /// 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; } - let min_delta = stamps - .windows(2) - .map(|w| w[1].saturating_sub(w[0])) - .filter(|&d| d > 1_000_000) // < 1 ms apart = a queued pair, not a grid step - .min(); - if let Some(d) = min_delta { - self.grid.observe(d as i64); - } } pub(crate) fn period_ns(&self) -> u64 { @@ -260,11 +295,29 @@ impl Cadence { pub(crate) struct CadenceProbe { /// Off-grid distances as a fraction of the period, in thousandths. off_grid_milli: Vec, + /// 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. @@ -274,35 +327,64 @@ 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 one window's on-glass stamps against the learned panel period. - pub(crate) fn note(&mut self, stamps: &[u64], period_ns: u64) { - if period_ns == 0 { + /// 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 w in stamps.windows(2) { - let delta = w[1].saturating_sub(w[0]); - if delta == 0 { + 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]; - self.verdict = if median <= CADENCE_FIXED_MILLI { + 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(); } } @@ -314,6 +396,9 @@ impl CadenceProbe { /// 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; } } @@ -440,6 +525,7 @@ mod tests { } /// 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 = FrameStore::new(3); @@ -497,6 +583,28 @@ mod tests { 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 @@ -507,10 +615,14 @@ mod tests { 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, one window at a time. - for i in 0..8 { - let t = 1_000_000_000 + i * 2 * REAL; - c.note_batch(&[t, t + REAL]); + // 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(), @@ -526,18 +638,21 @@ mod tests { #[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 = (0..40).map(|i| 1_000_000_000 + i * P).collect(); - probe.note(&stamps, P); + let stamps: Vec = (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 = (0..40).map(|i| 1_000_000_000 + i * 2 * P).collect(); - probe.note(&stamps, P); + let stamps: Vec = (0..ROUNDS).map(|i| 1_000_000_000 + i * 2 * P).collect(); + probe.note(&stamps, P, true); assert_eq!( probe.verdict(), Cadence::Fixed, @@ -548,17 +663,19 @@ mod tests { // 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 = (0..40) + let stamps: Vec = (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); + 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 = (0..40).map(|i| 1_000_000_000 + i * 10_000_000).collect(); - probe.note(&stamps, P); + let stamps: Vec = (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. @@ -567,16 +684,54 @@ mod tests { // 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); + 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 = (0..40).map(|i| 1_000_000_000 + i * 10_000_000).collect(); - probe.note(&stamps, 0); + let stamps: Vec = (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 = (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() { diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index c6a2cdeb..09269b94 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -252,6 +252,10 @@ struct StreamState { /// 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, @@ -378,6 +382,7 @@ impl StreamState { 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, @@ -530,6 +535,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result 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")?; @@ -740,6 +748,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result 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; @@ -1468,8 +1477,32 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } st.clock.note_batch(&stamps); // Same stamps answer "is VRR live" — the panel either quantizes them - // to its grid or follows our cadence. - st.cadence.note(&stamps, st.clock.period_ns()); + // 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 diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index 88c5f704..a533a2b0 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -454,6 +454,8 @@ impl Presenter { if let Some(v) = video_export.as_mut() { v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some(); } + 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, @@ -744,6 +746,9 @@ pub struct PresentPref { 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 @@ -757,11 +762,21 @@ pub struct PresentPref { /// * **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** — FIFO first. On a variable-refresh panel -/// with direct scanout the FIFO present IS the flip, so the panel follows the stream's -/// cadence and the latch collapses; MAILBOX would decouple presents from scanout and -/// re-quantize to the compositor's clock. Safe even when VRR turns out not to be live, -/// because the FIFO glass gate bounds the standing queue that used to make FIFO costly. +/// * **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 @@ -773,13 +788,20 @@ 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 { + } 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( @@ -848,6 +870,7 @@ mod tests { 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 @@ -864,8 +887,20 @@ mod tests { ); // Tear-free + VRR allowed + fullscreen prefers FIFO, so the flip IS the present - // and a variable-refresh panel follows the stream. + // 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);