feat(client/present): V-Sync and VRR become real settings, and VRR is measured
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 2m50s
android / android (pull_request) Successful in 2m49s
ci / web (pull_request) Successful in 2m35s
ci / docs-site (pull_request) Successful in 3m17s
ci / rust-arm64 (pull_request) Successful in 3m51s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m54s
ci / rust (pull_request) Successful in 9m23s

WP3 of design/desktop-presentation-rebuild.md. The `vsync` and `allow_vrr`
settings have existed since WP1 but nothing consumed them — the swapchain picked
MAILBOX-or-FIFO once, from an env var, and froze. This makes them mean
something, which is also what unblocks their settings rows (deliberately
withheld from WP5 rather than shipped as dead switches).

Present-mode selection is now a preference ladder, not a constant:

* V-Sync off — IMMEDIATE, then FIFO_RELAXED, then the tear-free modes. Asking
  to tear and silently getting vsync is a lie, so the mode that actually took is
  named in the stats line and a refused preference is logged requested-vs-active.
* 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 instead of a fixed grid; MAILBOX would decouple presents from
  scanout and re-quantize to the compositor's clock. This is only safe because
  WP2's glass gate bounds the standing queue that historically made FIFO costly.
* Otherwise — MAILBOX then FIFO, the shipped default, unchanged.

`PUNKTFUNK_PRESENT_MODE` still pins a mode outright and now falls back to the
settings (rather than to mailbox) when the name is unknown.

VRR detection is MEASURED, never queried. No portable query exists — SDL exposes
none, Wayland does not report adaptive-sync state, Windows surfaces nothing
through Vulkan — and the platforms that do answer have been caught lying (see
the Android per-uid refresh-rate finding). The discriminator is quantization: on
a fixed-refresh panel every on-glass instant lands on the vblank grid, so the
spacing between presents is ~k×period for whole k even when the stream runs
slower than the panel (it just picks a larger k); under real VRR the panel
refreshes when we present, so the spacing follows our own cadence and sits off
the grid. `CadenceProbe` folds each delta to its distance from the nearest
multiple of the learned period and takes the median. Tri-state: it stays Unknown
below 24 deltas and after a display change, so `vrr` is reported only when it
has been measured — never inferred from what the display claims.

Also fixes the read-once refresh rate: `native.refresh_hz` was sampled at
startup and never revisited, so dragging the window to another monitor left a
60 Hz-seeded clock pacing a 144 Hz panel. `WindowEvent::DisplayChanged` now
relearns the latch grid, resets the cadence verdict, and clears the served-slot
latch.

Settings rows for both, on all three surfaces (GTK, WinUI, console). The
console's V-Sync row is reachable in Gaming Mode, which is the only editor a
Deck user has.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 160 tests (the two new ones cover every ladder and both cadence
regimes, including the case that matters most: a stream slower than a FIXED
panel must still read as fixed). WinUI leg on the Windows runner .133:
clippy=0 tests=0, against a tree proven by content to contain the edit.

⚠ On-glass validation is still owed and is NOT claimed here: every box with a
real display was powered off when this landed, so the VRR ladder and the
detector have been exercised only against synthetic stamps in unit tests.

Rebase follow-up: `20de58a7` landed the same "panel grid can be wrong in both
directions" defect fix on Android and extracted the corrected learner into
`punktfunk_core::phase::PanelGrid` for the iOS and desktop presenters to share.
This clock had the identical bug — it capped the learned period at the display
mode's refresh, and the mode is only a CLAIM, so a display really running slower
than it advertises pinned a grid whose instants never arrive, for the session,
with no way back. Adopted the shared learner rather than carrying a second,
buggier copy; still fed the window's MIN spacing, which preserves the k×period
resistance the cap was actually aimed at while the streak requirement lets a
genuinely slower panel be discovered. New test: seed 120 Hz, real panel 60 Hz,
the clock must climb back out.

Took the same commit's third lesson too: the adaptive margin widened on a
latch over 1.5×period (a number picked here), and now widens on the latch
exceeding one period plus the lead already applied — the slot actually aimed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 22:02:54 +02:00
co-authored by Claude Opus 5
parent f821bb059b
commit e35981d70f
11 changed files with 526 additions and 50 deletions
+30
View File
@@ -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();
};
+2
View File
@@ -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);
+2
View File
@@ -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.
+41
View File
@@ -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);
}
}
+18 -1
View File
@@ -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)
+207 -17
View File
@@ -145,16 +145,26 @@ impl<T> FrameStore<T> {
}
/// 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<u32>,
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<u64> = (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<u64> = (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<u64> = (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<u64> = (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<u64> = (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() {
+67 -6
View File
@@ -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<Option<Outcome>
let instance_exts = window
.vulkan_instance_extensions()
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
let mut presenter = Presenter::new(
&window,
&instance_exts,
crate::vk::PresentPref {
vsync: opts.vsync,
allow_vrr: opts.allow_vrr,
fullscreen: opts.fullscreen,
},
)
.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<Option<Outcome>
}
}
}
// Dragged to another monitor (or the mode changed under us): the
// latch grid and the VRR verdict both belong to the OLD panel. The
// refresh rate used to be read once at startup and never revisited,
// so a 60 Hz-seeded clock would keep pacing a 144 Hz panel.
WindowEvent::DisplayChanged(..) => {
let hz = window
.get_display()
.and_then(|d| d.get_mode())
.map(|m| m.refresh_rate.round().max(0.0) as u32)
.unwrap_or(0);
if let Some(st) = stream.as_mut() {
if hz > 0 {
st.clock = LatchClock::new(hz);
}
st.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<Option<Outcome>
.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<Option<Outcome>
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<Option<Outcome>
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");
}
+1 -1
View File
@@ -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> {
+141 -24
View File
@@ -16,7 +16,11 @@ use std::ffi::{c_char, CString};
impl Presenter {
/// Bring up instance → surface → device → swapchain over an SDL window.
/// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`.
pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result<Presenter> {
pub fn new(
window: &sdl3::video::Window,
instance_extensions: &[String],
pref: PresentPref,
) -> Result<Presenter> {
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by
// the value being built here.
@@ -450,11 +454,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<vk::PresentModeKHR> {
// SAFETY: per the Vulkan contract above - a read-only query on the live instance/device,
// filling locals returned by value.
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("fifo") => vk::PresentModeKHR::FIFO,
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
Some("fifo_relaxed") => vk::PresentModeKHR::FIFO_RELAXED,
Some("mailbox") | None => vk::PresentModeKHR::MAILBOX,
let pinned = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("fifo") => Some(vk::PresentModeKHR::FIFO),
Some("immediate") => Some(vk::PresentModeKHR::IMMEDIATE),
Some("fifo_relaxed") => Some(vk::PresentModeKHR::FIFO_RELAXED),
Some("mailbox") => Some(vk::PresentModeKHR::MAILBOX),
None => None,
Some(other) => {
tracing::warn!(
value = other,
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — using mailbox"
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — following the settings"
);
vk::PresentModeKHR::MAILBOX
None
}
};
Ok(if modes.contains(&want) {
want
} else {
vk::PresentModeKHR::FIFO // always available per spec
})
if let Some(want) = pinned {
if modes.contains(&want) {
return Ok(want);
}
tracing::warn!(
?want,
"PUNKTFUNK_PRESENT_MODE not offered by this surface — falling back"
);
}
let chain = present_mode_chain(pref);
let chosen = chain
.iter()
.copied()
.find(|m| modes.contains(m))
.unwrap_or(vk::PresentModeKHR::FIFO); // always available per spec
// The one line that answers "did V-Sync off actually take?" — a request the surface
// can't serve is a fact about the driver, and it must not look like our choice.
if chosen != chain[0] {
tracing::info!(
requested = ?chain[0],
active = ?chosen,
vsync = pref.vsync,
allow_vrr = pref.allow_vrr,
"the surface does not offer the preferred present mode"
);
}
Ok(chosen)
}
#[cfg(test)]
mod tests {
use super::*;
use vk::PresentModeKHR as M;
/// The preference ladders (WP3). Every chain must end at FIFO, which the spec
/// guarantees exists — a chain whose entries a surface all refuses would otherwise
/// have no landing.
#[test]
fn present_mode_chains_rank_by_intent() {
let pref = |vsync, allow_vrr, fullscreen| PresentPref {
vsync,
allow_vrr,
fullscreen,
};
// 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"
);
}
}
}
+14
View File
@@ -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.
+3 -1
View File
@@ -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`