diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index fe709187..691a4de2 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -651,6 +651,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings) if touched.has("smooth_buffer") { o.smooth_buffer = Some(values.smooth_buffer); } + if touched.has("vsync") { + o.vsync = Some(values.vsync); + } + if touched.has("allow_vrr") { + o.allow_vrr = Some(values.allow_vrr); + } // Resets are not handled here: they clear the field and re-seed their row the moment the // user asks, so by the time this runs the catalog already reflects them and the row is no // longer marked touched. @@ -1274,6 +1280,22 @@ pub fn show_scoped( buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth"); }); } + let vsync_row = adw::SwitchRow::builder() + .title("V-Sync") + .subtitle( + "Tear-free. Turning it off removes the wait for the screen's refresh — the \ + lowest possible delay, at the cost of visible tearing. Not every driver \ + offers it; the stats overlay names the mode actually in use", + ) + .build(); + let vrr_row = adw::SwitchRow::builder() + .title("Follow variable refresh rate") + .subtitle( + "On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with the \ + stream instead of on a fixed cadence. Applies to fullscreen sessions; \ + harmless on a fixed-refresh screen", + ) + .build(); // ---- Display: Host output ---- let compositor_row = ChoiceRow::new( @@ -1552,6 +1574,8 @@ pub fn show_scoped( buffer_row .widget() .set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth"); + vsync_row.set_active(s.vsync); + vrr_row.set_active(s.allow_vrr); } // ---- Override markers, per-row reset, and the touch that creates an override ---- @@ -1756,6 +1780,8 @@ pub fn show_scoped( o.smooth_buffer.is_some(), index::smooth_buffer ); + toggle!(vsync_row, "vsync", o.vsync.is_some(), vsync); + toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr); toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled); toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444); toggle!( @@ -1863,6 +1889,8 @@ pub fn show_scoped( let presentation_group = group("Presentation", ""); presentation_group.add(present_row.widget()); presentation_group.add(buffer_row.widget()); + presentation_group.add(&vsync_row); + presentation_group.add(&vrr_row); // The one form-level note (deliberately not repeated on every row). let output_group = group( "Host output", @@ -2020,6 +2048,8 @@ pub fn show_scoped( // The index IS the value (0 = Automatic). s.smooth_buffer = (buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1); + s.vsync = vsync_row.is_active(); + s.allow_vrr = vrr_row.is_active(); s.library_enabled = library_row.is_active(); }; diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 4de854df..99d6687b 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -172,6 +172,8 @@ pub fn run(target: Option<&str>) -> u8 { // Presentation-tier like the rows above: latched at console start, a per-host // profile cannot move it in this mode (the documented P4 gap). present_priority: settings_at_start.present_priority(), + vsync: settings_at_start.vsync, + allow_vrr: settings_at_start.allow_vrr, json_status, on_connected: Some(Box::new(move |fingerprint: [u8; 32]| { let fp_hex = trust::hex(&fingerprint); diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index efcc891c..9ac7d0a9 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -618,6 +618,8 @@ mod session_main { invert_scroll: settings.invert_scroll, inhibit_shortcuts: settings.inhibit_shortcuts, present_priority: settings.present_priority(), + vsync: settings.vsync, + allow_vrr: settings.allow_vrr, json_status: true, on_connected: Some(Box::new(|fingerprint: [u8; 32]| { // This host's card carries the accent bar in the desktop client now. diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 79dd6ff1..3815fa54 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -462,6 +462,8 @@ struct OverrideFlags { fullscreen_on_stream: bool, present_priority: bool, smooth_buffer: bool, + vsync: bool, + allow_vrr: bool, } impl OverrideFlags { @@ -492,6 +494,8 @@ impl OverrideFlags { fullscreen_on_stream: o.fullscreen_on_stream.is_some(), present_priority: o.present_priority.is_some(), smooth_buffer: o.smooth_buffer.is_some(), + vsync: o.vsync.is_some(), + allow_vrr: o.allow_vrr.is_some(), } } } @@ -890,6 +894,10 @@ pub(crate) fn settings_page( buffer_i, |s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0, ); + let vsync_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.vsync, |s, on| s.vsync = on); + let vrr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.allow_vrr, |s, on| { + s.allow_vrr = on + }); // --- Input ----------------------------------------------------------------------------- // Controller forwarding: Automatic forwards EVERY real controller, each as its own pad; @@ -1171,6 +1179,29 @@ pub(crate) fn settings_page( Automatic holds two.", )); } + fields.push(described_overridable( + (rev, set_rev), + scope, + "vsync", + "V-Sync", + over.vsync, + vsync_toggle, + "Tear-free. Turning it off removes the wait for the screen\u{2019}s \ + refresh \u{2014} the lowest possible delay, at the cost of visible \ + tearing. Not every driver offers it; the stats overlay names the \ + mode actually in use.", + )); + fields.push(described_overridable( + (rev, set_rev), + scope, + "allow_vrr", + "Follow variable refresh rate", + over.allow_vrr, + vrr_toggle, + "On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with \ + the stream instead of on a fixed cadence. Applies to fullscreen \ + sessions; harmless on a fixed-refresh screen.", + )); fields }, None, @@ -1808,5 +1839,15 @@ mod tests { let f4 = OverrideFlags::of(Some(&p4)); assert!(f4.present_priority); assert!(!f4.smooth_buffer); + + // V-Sync and VRR are independent of each other and of the intent pair. + let mut p5 = StreamProfile::new("t5".to_string()); + p5.overrides = SettingsOverlay { + vsync: Some(false), + ..Default::default() + }; + let f5 = OverrideFlags::of(Some(&p5)); + assert!(f5.vsync); + assert!(!f5.allow_vrr && !f5.present_priority); } } diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 7ce73798..ffbecf2e 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -28,6 +28,8 @@ enum RowId { Chroma444, PresentPriority, SmoothBuffer, + Vsync, + AllowVrr, Audio, Mic, EchoCancel, @@ -48,7 +50,7 @@ enum RowId { // scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo // cancellation all were). Still deliberately smaller than the desktop dialogs — device // pickers (GPU/speaker/mic) and the profile catalog stay desktop-only. -const ROWS: [RowId; 24] = [ +const ROWS: [RowId; 26] = [ RowId::Resolution, RowId::Refresh, RowId::RenderScale, @@ -60,6 +62,8 @@ const ROWS: [RowId; 24] = [ RowId::Chroma444, RowId::PresentPriority, RowId::SmoothBuffer, + RowId::Vsync, + RowId::AllowVrr, RowId::Audio, RowId::Mic, RowId::EchoCancel, @@ -315,6 +319,8 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { .map_or("Automatic", |(_, l)| l) .into(), ), + RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()), + RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()), RowId::Audio => ( Some("Audio"), "Audio channels", @@ -413,6 +419,15 @@ fn detail(id: RowId) -> &'static str { "Frames held back before showing. Each one absorbs about a refresh of network \ hiccup and adds a refresh of delay. Automatic holds two." } + RowId::Vsync => { + "Tear-free. Off removes the wait for the screen's refresh — the lowest \ + possible delay, at the cost of visible tearing. Not every driver offers it; \ + the stats overlay names the mode actually in use." + } + RowId::AllowVrr => { + "On a VRR screen, let the panel refresh in step with the stream instead of on \ + a fixed cadence. Applies to fullscreen sessions; harmless on a fixed screen." + } RowId::Audio => "The speaker layout requested from the host.", RowId::Mic => { "Send this device's microphone to the host's virtual mic. \ @@ -527,6 +542,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { None } } + RowId::Vsync => toggle(&mut s.vsync, delta, wrap), + RowId::AllowVrr => toggle(&mut s.allow_vrr, delta, wrap), RowId::Audio => { let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels); step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0) diff --git a/crates/pf-presenter/src/present_pace.rs b/crates/pf-presenter/src/present_pace.rs index e4602564..38b21b00 100644 --- a/crates/pf-presenter/src/present_pace.rs +++ b/crates/pf-presenter/src/present_pace.rs @@ -145,16 +145,26 @@ impl FrameStore { } /// The panel latch grid: a recent on-glass instant + the latch period, extrapolated -/// forward for slot targeting. Fed per sample batch; the period is the min positive -/// spacing of consecutive stamps (< 1 ms apart = a queued pair, not a grid step), -/// capped by the display mode's refresh — under arrival-paced MAILBOX a stream running -/// below the panel rate spaces its presents at k×period, and the cap keeps a 30 fps -/// stream from claiming a 30 Hz panel grid. Same rule as the host-facing `LatchGrid` -/// fold this clock also feeds, so the phase-lock report and the local scheduler can -/// never disagree about the grid. +/// 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, - period_ns: u64, + grid: punktfunk_core::phase::PanelGrid, fallback_period_ns: u64, } @@ -162,7 +172,7 @@ impl LatchClock { pub(crate) fn new(refresh_hz: u32) -> LatchClock { LatchClock { anchor_ns: 0, - period_ns: 0, + grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32), fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)), } } @@ -178,16 +188,17 @@ impl LatchClock { let min_delta = stamps .windows(2) .map(|w| w[1].saturating_sub(w[0])) - .filter(|&d| d > 1_000_000) + .filter(|&d| d > 1_000_000) // < 1 ms apart = a queued pair, not a grid step .min(); if let Some(d) = min_delta { - self.period_ns = d.min(self.fallback_period_ns); + self.grid.observe(d as i64); } } pub(crate) fn period_ns(&self) -> u64 { - if self.period_ns > 0 { - self.period_ns + let learned = self.grid.period_ns(); + if learned > 0 { + learned as u64 } else { self.fallback_period_ns } @@ -209,6 +220,104 @@ impl LatchClock { } } +/// 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, + verdict: Cadence, +} + +/// Enough deltas to distinguish jitter from a real off-grid cadence. +const CADENCE_MIN_SAMPLES: usize = 24; +/// 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), + 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 { + return; + } + for w in stamps.windows(2) { + let delta = w[1].saturating_sub(w[0]); + if delta == 0 { + continue; + } + 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); + } + 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 { + Cadence::Fixed + } else { + Cadence::Variable + }; + 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.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 @@ -370,11 +479,12 @@ mod tests { 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 — the - // panel grid is still P, so the mode-refresh cap holds the learned period down - // (this is what keeps a 30 fps stream from claiming a 30 Hz panel). + // 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, "capped at the mode refresh"); + 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]); @@ -387,6 +497,86 @@ mod tests { assert_eq!(fast.period_ns(), 8_333_333); } + /// 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, 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]); + } + 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 + + // 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); + 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); + 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 = (0..40) + .map(|i| (1_000_000_000 + i as i64 * P as i64 + jitter[i % jitter.len()]) as u64) + .collect(); + probe.note(&stamps, P); + 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); + 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); + assert_eq!(probe.verdict(), Cadence::Unknown); + + // 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); + assert_eq!(probe.verdict(), Cadence::Unknown); + } + /// 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 fbcfd509..c6a2cdeb 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -18,7 +18,9 @@ use crate::input::{Capture, FingerPhase}; use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase}; -use crate::present_pace::{FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS}; +use crate::present_pace::{ + Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS, +}; use crate::touch::Abs; use crate::vk::{FrameInput, Presenter}; use anyhow::{Context as _, Result}; @@ -70,6 +72,14 @@ pub struct SessionOpts { /// (design/desktop-presentation-rebuild.md). `PUNKTFUNK_PRESENTER=arrival` overrides /// the whole engine back to the legacy drain for field A/B without a rebuild. pub present_priority: PresentPriority, + /// Tear-free presentation ([`Settings::vsync`], default on). Off asks for a tearing + /// present mode for the lowest possible latch — best-effort, and the mode that + /// actually took is named in the stats line. + pub vsync: bool, + /// Let a variable-refresh display follow the stream cadence ([`Settings::allow_vrr`], + /// default on) — prefers the present mode that drives VRR panels directly when the + /// session starts fullscreen. + pub allow_vrr: bool, /// Emit the `{"ready":true}` stdout line after the first presented frame. pub json_status: bool, /// Called once on `Connected` with the host's fingerprint (trust persistence is the @@ -239,6 +249,9 @@ struct StreamState { /// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes /// or without present timing. gate: PresentGate, + /// Is variable refresh actually live? Measured from the same on-glass stamps (no + /// portable query exists) — see [`CadenceProbe`]. + cadence: CadenceProbe, /// The latch slot the last smoothness present served (one present per slot); 0 = /// none yet. last_target_ns: u64, @@ -364,6 +377,7 @@ impl StreamState { store: FrameStore::new(usize::from(priority.fifo_capacity())), clock: LatchClock::new(native_refresh_hz), gate: PresentGate::default(), + cadence: CadenceProbe::new(), last_target_ns: 0, margin_ns: 0, win_misses: 0, @@ -509,7 +523,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result let instance_exts = window .vulkan_instance_extensions() .map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?; - let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?; + let mut presenter = Presenter::new( + &window, + &instance_exts, + crate::vk::PresentPref { + vsync: opts.vsync, + allow_vrr: opts.allow_vrr, + fullscreen: opts.fullscreen, + }, + ) + .context("vulkan presenter")?; // A valid black frame immediately — the window is honest while the connect runs. presenter.present(&window, FrameInput::Redraw, None)?; @@ -704,6 +727,28 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } } } + // Dragged to another monitor (or the mode changed under us): the + // latch grid and the VRR verdict both belong to the OLD panel. The + // refresh rate used to be read once at startup and never revisited, + // so a 60 Hz-seeded clock would keep pacing a 144 Hz panel. + WindowEvent::DisplayChanged(..) => { + let hz = window + .get_display() + .and_then(|d| d.get_mode()) + .map(|m| m.refresh_rate.round().max(0.0) as u32) + .unwrap_or(0); + if let Some(st) = stream.as_mut() { + if hz > 0 { + st.clock = LatchClock::new(hz); + } + st.cadence.reset(); + st.last_target_ns = 0; + tracing::info!( + refresh_hz = hz, + "display changed — relearning the latch grid" + ); + } + } WindowEvent::Exposed => { presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?; } @@ -1406,17 +1451,25 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result .push(s.submitted_ns.saturating_sub(s.decoded_ns) / 1000); st.win_latch_us .push(s.displayed_ns.saturating_sub(s.submitted_ns) / 1000); - // Latch miss (the adaptive margin's error signal): glass more - // than 1.5 latch periods after submit = the intended slot was - // overshot. + // Latch miss (the adaptive margin's error signal): glass later + // than one panel period past submit, PLUS the lead we already + // applied — i.e. the slot we aimed at was missed. Measuring the + // real latch rather than the store's own evictions is the + // Android 0.23.0 correction: policy drops happen whenever the + // stream out-runs the panel and say nothing about the latch, and + // widening on them walked the margin to its ceiling on healthy + // devices, re-imposing the very display latency it had removed. if st.store.is_smoothing() - && s.displayed_ns.saturating_sub(s.submitted_ns) > period + period / 2 + && s.displayed_ns.saturating_sub(s.submitted_ns) > period + st.margin_ns { st.win_misses += 1; } stamps.push(s.displayed_ns); } st.clock.note_batch(&stamps); + // Same stamps answer "is VRR live" — the panel either quantizes them + // to its grid or follows our cadence. + st.cadence.note(&stamps, st.clock.period_ns()); // 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 @@ -1733,6 +1786,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result pace_ms: pace_p50 as f32 / 1000.0, latch_ms: latch_p50 as f32 / 1000.0, mode: presenter.present_mode_name(), + vrr: st.cadence.verdict(), smoothing: st.store.is_smoothing(), q_drop, q_dry, @@ -1763,6 +1817,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result tracing::info!( smoothing = st.presented.smoothing, mode = st.presented.mode, + vrr = st.presented.vrr.label(), replaced, q_drop, q_dry, @@ -2255,6 +2310,8 @@ struct PresentedWindow { /// chosen from what the surface offers, so "why is my latch a refresh long" is /// usually answered by a MAILBOX request having landed on FIFO. mode: &'static str, + /// Whether variable refresh is measurably live (never claimed without evidence). + vrr: Cadence, /// Presenter-engine counters for the window: the smoothing FIFO's overflow drops and /// post-preroll underflows, and the FIFO glass gate's holds/stale force-opens. smoothing: bool, @@ -2390,6 +2447,10 @@ fn stats_text( // they are non-zero, so a healthy latency session shows just the mode. if !p.mode.is_empty() { text.push_str(&format!("\npresent: {}", p.mode)); + // Only once measured — an unproven "vrr no" would be a claim, not a reading. + if p.vrr != Cadence::Unknown { + text.push_str(&format!(" · vrr {}", p.vrr.label())); + } if p.smoothing { text.push_str(" · smoothing"); } diff --git a/crates/pf-presenter/src/vk/mod.rs b/crates/pf-presenter/src/vk/mod.rs index 75499df5..8252af37 100644 --- a/crates/pf-presenter/src/vk/mod.rs +++ b/crates/pf-presenter/src/vk/mod.rs @@ -33,7 +33,7 @@ mod reconfig; mod resources; mod setup; -pub use setup::list_adapters; +pub use setup::{list_adapters, PresentPref}; /// One presenter iteration's video input. pub enum FrameInput<'a> { diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index 29c4e5ec..88c5f704 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -16,7 +16,11 @@ use std::ffi::{c_char, CString}; impl Presenter { /// Bring up instance → surface → device → swapchain over an SDL window. /// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`. - pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result { + pub fn new( + window: &sdl3::video::Window, + instance_extensions: &[String], + pref: PresentPref, + ) -> Result { // SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over // builder structs that are locals outliving the call; the handle it returns is owned by // the value being built here. @@ -450,11 +454,13 @@ impl Presenter { if let Some(v) = video_export.as_mut() { v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some(); } - let present_mode = pick_present_mode(&surface_i, pdev, surface)?; + let present_mode = pick_present_mode(&surface_i, pdev, surface, pref)?; tracing::info!( ?format, ?hdr10_format, ?present_mode, + vsync = pref.vsync, + allow_vrr = pref.allow_vrr, hdr_metadata = has_hdr_metadata, "swapchain config" ); @@ -730,42 +736,153 @@ pub(super) fn pick_formats( Ok((sdr, hdr10)) } -/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE= -/// fifo|mailbox|immediate|fifo_relaxed` overrides). Both defaults are tear-free, but an -/// arrival-paced presenter must not block in FIFO's present queue: when the compositor -/// holds images for a vblank pass (gamescope's composite path) or arrival cadence drifts -/// against refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms -/// added to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the -/// pipeline stays at decode latency and a late frame is replaced, not waited for. +/// What the user asked the presentation to be, resolved into a swapchain present mode by +/// [`present_mode_chain`] (design/desktop-presentation-rebuild.md WP3). +#[derive(Clone, Copy, Debug, Default)] +pub struct PresentPref { + /// Tear-free presentation (the `vsync` setting, default on). + pub vsync: bool, + /// Let a variable-refresh display follow the stream cadence (`allow_vrr`, default on). + pub allow_vrr: bool, + /// The session STARTED fullscreen. The mode is chosen once, at swapchain creation, so + /// this is the starting state and an F11 mid-session does not re-pick — consistent + /// with the shells' "Display changes apply from the next session" footer, and why + /// live present-mode switching is an explicit non-goal. + pub fullscreen: bool, +} + +/// The preference ladder, most to least wanted. The caller takes the first entry the +/// surface actually offers; FIFO ends every chain because the spec guarantees it. +/// +/// * **V-Sync off** — IMMEDIATE (tears, no wait at all), then FIFO_RELAXED (tears only on +/// a late frame), then the tear-free modes. Asking for tearing and silently getting +/// vsync is a lie the stats line now exposes, but the ladder still degrades safely. +/// * **V-Sync on + VRR allowed + fullscreen** — 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. +/// * **Otherwise** — MAILBOX, then FIFO: the shipped default. MAILBOX never queues more +/// than the newest frame, so an arrival-paced presenter doesn't block in the present +/// queue (a measured 11-13 ms standing wait at 60 Hz when the compositor holds images +/// for a vblank pass, or when arrival cadence drifts against refresh). /// /// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO — -/// expected, not a client misconfiguration. FIFO_RELAXED is opt-in only: it tears exactly -/// when a stream frame misses the vblank it was pacing for, which on a drifting arrival -/// cadence is often — a trade the user must choose, never a silent fallback. +/// expected, not a misconfiguration, and now visible in the `present:` stats line. +fn present_mode_chain(pref: PresentPref) -> [vk::PresentModeKHR; 4] { + use vk::PresentModeKHR as M; + if !pref.vsync { + [M::IMMEDIATE, M::FIFO_RELAXED, M::MAILBOX, M::FIFO] + } else if pref.allow_vrr && pref.fullscreen { + [M::FIFO, M::MAILBOX, M::FIFO_RELAXED, M::IMMEDIATE] + } else { + [M::MAILBOX, M::FIFO, M::FIFO_RELAXED, M::IMMEDIATE] + } +} + +/// Resolve the present mode: `PUNKTFUNK_PRESENT_MODE` pins one outright (the debug lever, +/// unchanged), otherwise the first entry of [`present_mode_chain`] the surface offers. fn pick_present_mode( surface_i: &ash::khr::surface::Instance, pdev: vk::PhysicalDevice, surface: vk::SurfaceKHR, + pref: PresentPref, ) -> Result { // SAFETY: per the Vulkan contract above - a read-only query on the live instance/device, // filling locals returned by value. let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?; - let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() { - Some("fifo") => vk::PresentModeKHR::FIFO, - Some("immediate") => vk::PresentModeKHR::IMMEDIATE, - Some("fifo_relaxed") => vk::PresentModeKHR::FIFO_RELAXED, - Some("mailbox") | None => vk::PresentModeKHR::MAILBOX, + let pinned = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() { + Some("fifo") => Some(vk::PresentModeKHR::FIFO), + Some("immediate") => Some(vk::PresentModeKHR::IMMEDIATE), + Some("fifo_relaxed") => Some(vk::PresentModeKHR::FIFO_RELAXED), + Some("mailbox") => Some(vk::PresentModeKHR::MAILBOX), + None => None, Some(other) => { tracing::warn!( value = other, - "unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — using mailbox" + "unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — following the settings" ); - vk::PresentModeKHR::MAILBOX + None } }; - Ok(if modes.contains(&want) { - want - } else { - vk::PresentModeKHR::FIFO // always available per spec - }) + if let Some(want) = pinned { + if modes.contains(&want) { + return Ok(want); + } + tracing::warn!( + ?want, + "PUNKTFUNK_PRESENT_MODE not offered by this surface — falling back" + ); + } + let chain = present_mode_chain(pref); + let chosen = chain + .iter() + .copied() + .find(|m| modes.contains(m)) + .unwrap_or(vk::PresentModeKHR::FIFO); // always available per spec + // The one line that answers "did V-Sync off actually take?" — a request the surface + // can't serve is a fact about the driver, and it must not look like our choice. + if chosen != chain[0] { + tracing::info!( + requested = ?chain[0], + active = ?chosen, + vsync = pref.vsync, + allow_vrr = pref.allow_vrr, + "the surface does not offer the preferred present mode" + ); + } + Ok(chosen) +} + +#[cfg(test)] +mod tests { + use super::*; + use vk::PresentModeKHR as M; + + /// The preference ladders (WP3). Every chain must end at FIFO, which the spec + /// guarantees exists — a chain whose entries a surface all refuses would otherwise + /// have no landing. + #[test] + fn present_mode_chains_rank_by_intent() { + let pref = |vsync, allow_vrr, fullscreen| PresentPref { + vsync, + allow_vrr, + fullscreen, + }; + + // V-Sync off asks to tear, hardest first, and outranks the VRR rule (tearing + // already gives a VRR-like latch, so the two never fight). + assert_eq!(present_mode_chain(pref(false, true, true))[0], M::IMMEDIATE); + assert_eq!( + present_mode_chain(pref(false, false, false))[0], + M::IMMEDIATE + ); + assert_eq!( + present_mode_chain(pref(false, true, true))[1], + M::FIFO_RELAXED, + "tears only on a late frame — the gentler tearing rung" + ); + + // Tear-free + VRR allowed + fullscreen prefers FIFO, so the flip IS the present + // and a variable-refresh panel follows the stream. + assert_eq!(present_mode_chain(pref(true, true, true))[0], M::FIFO); + // Windowed, or VRR declined: the shipped MAILBOX-first default. + assert_eq!(present_mode_chain(pref(true, true, false))[0], M::MAILBOX); + assert_eq!(present_mode_chain(pref(true, false, true))[0], M::MAILBOX); + assert_eq!(present_mode_chain(pref(true, false, false))[0], M::MAILBOX); + + // Every ladder can land: FIFO appears in all of them. + for p in [ + pref(true, true, true), + pref(true, true, false), + pref(true, false, true), + pref(false, true, true), + pref(false, false, false), + ] { + assert!( + present_mode_chain(p).contains(&M::FIFO), + "FIFO is the guaranteed landing" + ); + } + } } diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index e342cd28..50f73ab4 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -97,6 +97,20 @@ many frames are held back before showing. Each frame absorbs roughly one screen hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of jitter. If you never see stutter, you don't need this. +**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame +the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display +can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every +driver or compositor offers a tearing mode, and where none is available the stream stays tear-free. +The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off" +from "off but unavailable". Linux and Windows apps. + +**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel +refresh in step with the stream rather than on a fixed cadence — which removes the wait between a +frame being ready and the screen being willing to show it. Applies to **fullscreen** sessions (a +windowed one is at the compositor's mercy) and is harmless on a fixed-refresh screen. The stats +overlay reports `vrr yes` once it has measured that the panel really is following. Linux and +Windows apps. + **Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual output. Advisory: a host without that backend quietly auto-detects instead. diff --git a/docs-site/content/docs/stats.md b/docs-site/content/docs/stats.md index 6abab1d7..460697b8 100644 --- a/docs-site/content/docs/stats.md +++ b/docs-site/content/docs/stats.md @@ -151,7 +151,9 @@ lost 3 (2.4%) host reports them. Linux/Windows Detailed also carries a **`present:`** line naming how frames are reaching - your screen: the display mode in use (`mailbox`, `fifo`, …) and, when the + your screen: the display mode in use (`mailbox`, `fifo`, …), `vrr yes`/`vrr no` once the + client has *measured* whether your screen is following the stream's cadence (it is + reported only when measured — no guess from what the display claims), and, when the [presentation setting](/docs/client-settings#video) is *Smoothness*, the word `smoothing`. Counters join it only when they're doing something — `qdrop`/`qdry` mean the smoothing buffer overflowed or ran dry (a jittery link), and `gated`/`forced`