From d839f4c2b6c8636f8cb7f3d8db30648be3054517 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 23:40:15 +0200 Subject: [PATCH 1/7] fix(client/windows): settings stop going stale behind your back, and the log has a door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field reporter's codec setting "changed by itself" between sessions. Nothing writes the negotiated codec back — what they saw was a stale snapshot. `AppCtx.settings` is loaded ONCE at process start and the page renders from it, but this process is not the file's only writer (the spawned session persists its match-window size, the console UI and Decky save too), so the page showed values another process had already replaced — until a row was touched and `commit`'s rebase pulled the file in, at which point the value visibly jumped. The 2026-07-31 rebase fix covered the whole-file writers and missed two spots: nothing re-based on page ENTRY, and the profile-scope commit arm cloned the snapshot without reloading, so overlay absorption diffed against stale globals. Both now re-base on the file. Two more ways a setting could vanish or cost time: * An older binary's whole-file save DROPPED a newer client's keys — `Settings` had no unknown-key passthrough, unlike `SettingsOverlay`, whose `extra` map already gives profiles exactly that contract. Extended to the globals: additive, empty on every existing store, and an empty map serializes to nothing so no file churns. (`save()` was already temp+rename, so the torn-file → silent-Default reset was closed.) * "Check the client log" never said WHERE. Settings ▸ About grows an Open log folder row (%LOCALAPPDATA%\punktfunk\logs, folder not file so the rotated .old generation is in reach), and the failed-spawn banner now names the path. The 4:4:4 caption said "HEVC only, and only where the host can encode it", which sends people hunting: the host gate is PyroWave or an NVENC backend. It says so now. Co-Authored-By: Claude Fable 5 --- clients/windows/src/app/hosts.rs | 10 ++++-- clients/windows/src/app/licenses.rs | 14 +++++--- clients/windows/src/app/mod.rs | 6 +++- clients/windows/src/app/settings.rs | 51 ++++++++++++++++++++++++++--- clients/windows/src/logfile.rs | 5 +-- clients/windows/src/spawn.rs | 9 ++++- crates/pf-client-core/src/trust.rs | 32 ++++++++++++++++++ 7 files changed, 112 insertions(+), 15 deletions(-) diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index 5312d5e6..a27245f4 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -623,8 +623,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { actions.push( icon_btn("Settings", Symbol::Setting) .on_click({ - let ss = set_screen.clone(); - move || ss.call(Screen::Settings) + let (c, ss) = (ctx.clone(), set_screen.clone()); + move || { + // Re-base the settings snapshot on the file before the page + // renders — this process is not its only writer (see + // settings::refresh_snapshot). + super::settings::refresh_snapshot(&c); + ss.call(Screen::Settings) + } }) .into(), ); diff --git a/clients/windows/src/app/licenses.rs b/clients/windows/src/app/licenses.rs index cbe8127b..03df1cd6 100644 --- a/clients/windows/src/app/licenses.rs +++ b/clients/windows/src/app/licenses.rs @@ -2,7 +2,8 @@ //! Settings). use super::style::*; -use super::Screen; +use super::{AppCtx, Screen}; +use std::sync::Arc; use windows_reactor::*; /// punktfunk's own license (MIT OR Apache-2.0). @@ -15,10 +16,15 @@ const APP_LICENSE: &str = concat!( /// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/). const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt"); -pub(crate) fn licenses_page(set_screen: &AsyncSetState) -> Element { +pub(crate) fn licenses_page(ctx: &Arc, set_screen: &AsyncSetState) -> Element { let back_btn = button("Back").accent().icon(Symbol::Back).on_click({ - let ss = set_screen.clone(); - move || ss.call(Screen::Settings) + let (c, ss) = (ctx.clone(), set_screen.clone()); + move || { + // Back RE-ENTERS the settings page — re-base its snapshot on the file, same + // as the hosts page's Settings button (see settings::refresh_snapshot). + super::settings::refresh_snapshot(&c); + ss.call(Screen::Settings) + } }); let app_card = card( diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index a218882f..df1826ef 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -172,6 +172,10 @@ pub(crate) struct Shared { pub struct AppCtx { pub(crate) identity: (String, String), + /// The settings snapshot the UI renders from. Loaded once at startup, and RE-BASED on + /// the file when the settings page is (re)entered (`settings::refresh_snapshot`) and + /// inside every `commit` — this process is not the file's only writer (session resize, + /// console UI, Decky), so a plain process-lifetime snapshot goes stale on screen. pub(crate) settings: Mutex, pub(crate) gamepad: GamepadService, pub(crate) shared: Arc, @@ -688,7 +692,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { &set_settings_rev, nav_progress, ), - Screen::Licenses => licenses::licenses_page(&set_screen), + Screen::Licenses => licenses::licenses_page(ctx, &set_screen), Screen::Help => help::help_page(&set_screen), Screen::Pair => component(pair::pair_page, svc), Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }), diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 60cbab53..1551834b 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -411,7 +411,16 @@ fn commit( return; } let mut catalog = ProfilesFile::load(); - let base = ctx.settings.lock().unwrap().clone(); + // The same rebase as the global arm above: `base` is what `absorb`'s before/after + // effective settings derive from, and the snapshot is not the file — another process + // (session resize, console UI, Decky) may have moved a global under us. The historical + // rebase fix ("settings saves stop reverting each other") covered the whole-file + // writers but missed this arm. + let base = { + let mut s = ctx.settings.lock().unwrap(); + *s = Settings::load(); + s.clone() + }; let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else { return; // deleted from under us; the next render falls back to the defaults scope }; @@ -425,6 +434,17 @@ fn commit( rev.1.call(rev.0 + 1); } +/// Re-base the process-lifetime settings snapshot on the file — called from the navigation +/// handlers that (re)enter this page, NOT per render pass. `ctx.settings` is loaded once at +/// process start and this process is not the file's only writer (a spawned session persists +/// its match-window size, the console UI and Decky save too — profiles.rs documents the +/// family), so without this the page opens showing values another process already replaced, +/// which then visibly "jump" the moment a row is touched and `commit`'s rebase pulls the +/// file in. The field report this fixes: a codec setting that "changed by itself". +pub(crate) fn refresh_snapshot(ctx: &Arc) { + *ctx.settings.lock().unwrap() = Settings::load(); +} + /// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the /// call sites read as `over.codec` — the row and its flag stay visibly paired. #[derive(Default)] @@ -978,6 +998,16 @@ pub(crate) fn settings_page( let ss = set_screen.clone(); button("Third-party licenses").on_click(move || ss.call(Screen::Licenses)) }; + // The client log's home (%LOCALAPPDATA%\punktfunk\logs) — the file every "check the + // client log" message means, which until this row had no way in from the UI at all. + // The folder rather than the file so the rotated `.old` generation is in reach too. + // Best-effort, like the log itself: a missing dir or a failed spawn stays silent. + let logs_button = button("Open log folder").on_click(|| { + if let Some(dir) = crate::logfile::log_dir() { + let _ = std::fs::create_dir_all(&dir); + let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn(); + } + }); let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| { s.library_enabled = on }); @@ -1071,8 +1101,9 @@ pub(crate) fn settings_page( "HDR10, when the host has HDR content and this display supports it. \ HEVC only; otherwise the stream stays SDR.", ), - // Wording shared with the GTK client (its chroma_row) — same setting, - // same constraints. + // First sentence shared with the GTK client (its chroma_row); the + // constraint sentence names the real gate (host: PyroWave || NVENC) — + // "where the host can encode it" cost field users the discovery time. described_overridable( (rev, set_rev), scope, @@ -1081,7 +1112,8 @@ pub(crate) fn settings_page( over.enable_444, chroma_toggle, "Full-colour video: crisp small text and thin lines, at more \ - bandwidth. HEVC only, and only where the host can encode it.", + bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \ + codec \u{2014} other encoders stream 4:2:0.", ), ], None, @@ -1348,7 +1380,16 @@ pub(crate) fn settings_page( "About", group( None, - vec![about_identity.into(), licenses_button.into()], + vec![ + about_identity.into(), + described_labeled( + "Diagnostics", + logs_button, + "The client log (client.log, plus the session\u{2019}s whole \ + receive/decode/present trail) \u{2014} attach it to a bug report.", + ), + licenses_button.into(), + ], None, ), ), diff --git a/clients/windows/src/logfile.rs b/clients/windows/src/logfile.rs index bfc66135..4e6efddb 100644 --- a/clients/windows/src/logfile.rs +++ b/clients/windows/src/logfile.rs @@ -21,11 +21,12 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024; static SINK: OnceLock>>> = OnceLock::new(); -fn log_dir() -> Option { +/// The log directory — Settings ▸ About's "Open log folder" opens it in Explorer. +pub(crate) fn log_dir() -> Option { Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs")) } -/// The log file's path, for the "logs land here" startup line (and any future UI affordance). +/// The log file's path, for the "logs land here" startup line and the failed-spawn banner. pub(crate) fn path() -> Option { Some(log_dir()?.join("client.log")) } diff --git a/clients/windows/src/spawn.rs b/clients/windows/src/spawn.rs index 536fd618..ab907c74 100644 --- a/clients/windows/src/spawn.rs +++ b/clients/windows/src/spawn.rs @@ -105,7 +105,14 @@ fn parse_line(line: &str) -> Option { /// connect that silently drops back to the host list. pub(crate) fn silent_exit_banner(code: i32) -> Option { (code != 0 && code != -1).then(|| { - format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.") + // Name the log's actual location — "check the client log" without a path is a + // scavenger hunt (Settings ▸ About's "Open log folder" reaches it too). + let log = crate::logfile::path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "the client log".into()); + format!( + "The session didn't start (punktfunk-session exited with code {code}). Check {log}." + ) }) } diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 5f4f7702..fdfee63b 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -14,6 +14,7 @@ use anyhow::{anyhow, Context, Result}; use punktfunk_core::client::NativeClient; use punktfunk_core::quic::endpoint; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub fn config_dir() -> Result { @@ -940,6 +941,14 @@ pub struct Settings { /// the user will be looking at. `0` = never stored → the 1280×720 default. pub last_window_w: u32, pub last_window_h: u32, + /// Settings keys this build doesn't model (a newer client's field), carried through a + /// load→save round-trip untouched — [`crate::profiles::SettingsOverlay`]'s `extra` + /// pattern extended to the globals. Without it, every whole-file writer of this store + /// (two shells, the console settings screen, the session's resize callback, Decky) + /// running as an OLDER binary silently drops what a newer one persisted. Empty on + /// every existing store, and an empty map serializes to nothing, so files don't churn. + #[serde(flatten)] + pub extra: BTreeMap, } fn default_codec() -> String { @@ -1034,6 +1043,7 @@ impl Default for Settings { match_window: false, last_window_w: 0, last_window_h: 0, + extra: BTreeMap::new(), } } } @@ -1208,6 +1218,28 @@ mod tests { assert!(s.echo_cancel); } + /// A key this build doesn't model (a newer client's setting) survives a load→save + /// round trip instead of being dropped by the next whole-file write — the same + /// contract `SettingsOverlay.extra` gives profiles. And when there are no unknown + /// keys, the flatten map adds nothing, so existing files don't churn. + #[test] + fn settings_unknown_keys_survive_round_trip() { + let newer = r#"{"width":1920,"height":1080,"frob_mode":"fancy","frob_level":3}"#; + let s: Settings = serde_json::from_str(newer).unwrap(); + assert_eq!((s.width, s.height), (1920, 1080)); + assert_eq!( + s.extra.get("frob_mode").and_then(|v| v.as_str()), + Some("fancy") + ); + let out = serde_json::to_string(&s).unwrap(); + assert!(out.contains(r#""frob_mode":"fancy""#), "{out}"); + assert!(out.contains(r#""frob_level":3"#), "{out}"); + // No unknown keys → no artifact of the passthrough field in the file. + let plain = serde_json::to_string(&Settings::default()).unwrap(); + assert!(!plain.contains("extra"), "{plain}"); + assert!(!plain.contains("frob"), "{plain}"); + } + /// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off, /// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy /// bool in sync so pre-tier binaries reading the same file agree on off vs on. From 5f55fa874a68a532d6d26356fbaae8ae981a5341 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 18:57:29 +0200 Subject: [PATCH 2/7] feat(client/present): the desktop presenter gains the Apple/Android intent model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows session client presented arrival-paced with no pacing layer at all: two depth-2 newest-wins hops into a drain-to-newest and an immediate present. That IS the lowest-latency intent, but it was unnamed, unselectable, and had no alternative — and on a surface without MAILBOX (AMD's Windows driver offers none, and any compositor holding images does the same) the swapchain's own FIFO becomes a standing queue worth a measured 11-13 ms at 60 Hz. WP1 — the settings cluster, under the keys the Apple client already writes into the shared profile catalog (present_priority / smooth_buffer / vsync / allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but never applied. PresentPriority::resolve mirrors the Android reference exactly (anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes 2), so a profile authored on any client means the same thing on all of them. Only the first two are consumed here; vsync/allow_vrr land in WP3. WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests): - FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity, drop-oldest overflow, and an underflow that re-arms the preroll (repeat by omission) — the Apple/Android semantics, with qDrop/qDry counters. - LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps, min positive spacing capped by the mode refresh (measured, never queried — VRR and Android's per-uid refresh lie both punish trusting a reported rate). It now also publishes the host-facing LatchGrid, so the phase-lock report and the local scheduler cannot disagree about the grid. - PresentGate: one undisplayed present in flight on FIFO surfaces, with the 100 ms stale force-open. This is the standing-queue killer, and it is inert on MAILBOX/IMMEDIATE and without present timing — where behaviour stays byte-for-byte the shipped arrival pacing. Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the event timeout; smoothness serves one frame per latch slot and tightens the loop's wait to that deadline; the adaptive slot margin starts at 0 and widens +500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field A/B without a rebuild. PyroWave collapses smoothness to latency for the stream: its plane-ring retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra frames make buffering moot anyway. Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target cannot print a vacuous Finished): clippy -D warnings across pf-client-core, pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- clients/session/src/console.rs | 3 + clients/session/src/main.rs | 1 + crates/pf-client-core/src/orchestrate.rs | 4 + crates/pf-client-core/src/profiles.rs | 101 +++++ crates/pf-client-core/src/trust.rs | 116 ++++++ crates/pf-presenter/src/lib.rs | 2 + crates/pf-presenter/src/present_pace.rs | 406 +++++++++++++++++++ crates/pf-presenter/src/run.rs | 291 +++++++++++-- crates/pf-presenter/src/vk/mod.rs | 34 +- crates/pf-presenter/src/vk/present_timing.rs | 40 +- 10 files changed, 951 insertions(+), 47 deletions(-) create mode 100644 crates/pf-presenter/src/present_pace.rs diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 6df6d8dc..4de854df 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -169,6 +169,9 @@ pub fn run(target: Option<&str>) -> u8 { mouse_mode: settings_at_start.mouse_mode(), invert_scroll: settings_at_start.invert_scroll, inhibit_shortcuts: settings_at_start.inhibit_shortcuts, + // 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(), 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 4d2add15..8986e042 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -623,6 +623,7 @@ mod session_main { mouse_mode: settings.mouse_mode(), invert_scroll: settings.invert_scroll, inhibit_shortcuts: settings.inhibit_shortcuts, + present_priority: settings.present_priority(), 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/crates/pf-client-core/src/orchestrate.rs b/crates/pf-client-core/src/orchestrate.rs index 98b7416e..7eb07676 100644 --- a/crates/pf-client-core/src/orchestrate.rs +++ b/crates/pf-client-core/src/orchestrate.rs @@ -982,6 +982,10 @@ mod tests { height: 1440, bitrate_kbps: 55000, codec: "av1".into(), + present_priority: "smooth".into(), + smooth_buffer: 2, + vsync: false, + allow_vrr: false, ..Default::default() }, clipboard: true, diff --git a/crates/pf-client-core/src/profiles.rs b/crates/pf-client-core/src/profiles.rs index 0d8e1ad4..0d3cf6ae 100644 --- a/crates/pf-client-core/src/profiles.rs +++ b/crates/pf-client-core/src/profiles.rs @@ -79,6 +79,18 @@ pub struct SettingsOverlay { pub stats_verbosity: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fullscreen_on_stream: Option, + /// The presentation cluster — the keys the Apple client already writes into this + /// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`; + /// Android carries the first two). First-class here so a profile authored on any + /// client applies on all of them instead of riding `extra` unapplied. + #[serde(skip_serializing_if = "Option::is_none")] + pub present_priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub smooth_buffer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub vsync: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_vrr: Option, /// Overlay keys a newer client wrote and this one doesn't model — carried through a /// load→save round-trip untouched. #[serde(flatten)] @@ -155,6 +167,18 @@ impl SettingsOverlay { if let Some(v) = self.fullscreen_on_stream { s.fullscreen_on_stream = v; } + if let Some(v) = &self.present_priority { + s.present_priority = v.clone(); + } + if let Some(v) = self.smooth_buffer { + s.smooth_buffer = v; + } + if let Some(v) = self.vsync { + s.vsync = v; + } + if let Some(v) = self.allow_vrr { + s.allow_vrr = v; + } s } @@ -234,6 +258,18 @@ impl SettingsOverlay { if after.fullscreen_on_stream != before.fullscreen_on_stream { self.fullscreen_on_stream = Some(after.fullscreen_on_stream); } + if after.present_priority != before.present_priority { + self.present_priority = Some(after.present_priority.clone()); + } + if after.smooth_buffer != before.smooth_buffer { + self.smooth_buffer = Some(after.smooth_buffer); + } + if after.vsync != before.vsync { + self.vsync = Some(after.vsync); + } + if after.allow_vrr != before.allow_vrr { + self.allow_vrr = Some(after.allow_vrr); + } } /// Drop one override by its overlay field name, putting the row back to inheriting. The @@ -268,6 +304,10 @@ impl SettingsOverlay { "gamepad_forwarding" => self.gamepad_forwarding = None, "stats_verbosity" => self.stats_verbosity = None, "fullscreen_on_stream" => self.fullscreen_on_stream = None, + "present_priority" => self.present_priority = None, + "smooth_buffer" => self.smooth_buffer = None, + "vsync" => self.vsync = None, + "allow_vrr" => self.allow_vrr = None, _ => return false, } true @@ -469,6 +509,10 @@ mod tests { match_window: Some(true), fullscreen_on_stream: Some(false), stats_verbosity: Some(StatsVerbosity::Detailed), + present_priority: Some("smooth".into()), + smooth_buffer: Some(3), + vsync: Some(false), + allow_vrr: Some(false), ..Default::default() }; assert!(!overlay.is_empty()); @@ -491,6 +535,10 @@ mod tests { assert!(out.match_window); assert!(!out.fullscreen_on_stream); assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed); + assert_eq!(out.present_priority, "smooth"); + assert_eq!(out.smooth_buffer, 3); + assert!(!out.vsync); + assert!(!out.allow_vrr); // The tier goes through the setter, so the legacy bool a pre-tier binary reads // stays coherent with it. assert!(out.show_stats); @@ -588,6 +636,59 @@ mod tests { assert!(o.is_empty()); } + /// The presentation cluster is first-class, not `extra` passengers: it applies, + /// absorbs, clears, and serialises under the exact keys the Apple client already + /// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog + /// has to round-trip through every platform, and a mismatched key would be carried + /// but never applied. + #[test] + fn presentation_cluster_is_first_class() { + let base = Settings::default(); + let mut o = SettingsOverlay::default(); + let before = o.apply(&base); + let mut after = before.clone(); + after.present_priority = "smooth".into(); + o.absorb(&before, &after); + let before = o.apply(&base); + let mut after = before.clone(); + after.smooth_buffer = 1; + o.absorb(&before, &after); + assert_eq!(o.present_priority.as_deref(), Some("smooth")); + assert_eq!(o.smooth_buffer, Some(1)); + assert!( + o.extra.is_empty(), + "modelled fields must never land in the passthrough" + ); + let out = o.apply(&base); + assert_eq!( + out.present_priority(), + crate::trust::PresentPriority::Smooth { buffer: 1 } + ); + + // Serialised under the shared keys, and read back from a foreign client's file. + let text = serde_json::to_string(&o).unwrap(); + assert!(text.contains("\"present_priority\":\"smooth\""), "{text}"); + assert!(text.contains("\"smooth_buffer\":1"), "{text}"); + let from_apple: SettingsOverlay = serde_json::from_str( + r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#, + ) + .unwrap(); + assert_eq!(from_apple.present_priority.as_deref(), Some("latency")); + assert_eq!(from_apple.smooth_buffer, Some(2)); + assert_eq!(from_apple.vsync, Some(true)); + assert_eq!(from_apple.allow_vrr, Some(false)); + assert!(from_apple.extra.is_empty()); + + assert!(o.clear("present_priority")); + assert!(o.clear("smooth_buffer")); + assert_eq!(o.present_priority, None); + assert!(o.is_empty()); + let mut vrr = from_apple; + assert!(vrr.clear("vsync")); + assert!(vrr.clear("allow_vrr")); + assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None)); + } + /// `clear` is the explicit way back to inheriting, including the resolution tri-state. #[test] fn clear_drops_one_override() { diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index fdfee63b..929e245c 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -788,6 +788,45 @@ impl MouseMode { } } +/// Presentation intent — what the presenter optimizes for +/// (design/desktop-presentation-rebuild.md; the Apple/Android clients' shared +/// `present_priority`/`smooth_buffer` pair). Stored stringly in +/// [`Settings::present_priority`] + [`Settings::smooth_buffer`]; resolved with +/// [`PresentPriority::resolve`], whose rules match the Android reference +/// (`decode/presenter.rs`): anything but an explicit `"smooth"` is latency, and a +/// smooth buffer outside 1..=3 (including 0 = Automatic) becomes 2. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PresentPriority { + /// Every frame presents the moment the display can take it; a network hiccup is an + /// occasional repeated or skipped frame. The default. + Latency, + /// A small frame buffer (1–3 frames) evens out network/decode jitter, at the + /// buffer's worth of added display latency. + Smooth { buffer: u8 }, +} + +impl PresentPriority { + /// The shared cross-client resolution rule — pure, so every embedder agrees on what + /// a foreign profile's values mean. + pub fn resolve(name: &str, buffer: u8) -> PresentPriority { + if name == "smooth" { + PresentPriority::Smooth { + buffer: if (1..=3).contains(&buffer) { buffer } else { 2 }, + } + } else { + PresentPriority::Latency + } + } + + /// Frames the smoothing store holds; `0` = newest-wins (the latency intent). + pub fn fifo_capacity(self) -> u8 { + match self { + PresentPriority::Latency => 0, + PresentPriority::Smooth { buffer } => buffer, + } + } +} + /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// stays readable; parsed with `*Pref::from_name` at connect time. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -890,6 +929,32 @@ pub struct Settings { /// `default = true`: the Linux stores never carried this and always advertised. #[serde(default = "default_true")] pub hdr_enabled: bool, + /// Presentation intent: `"latency"` (default) or `"smooth"` — the Apple/Android + /// clients' shared `present_priority` profile key, resolved with + /// [`PresentPriority::resolve`] (via [`Settings::present_priority`]). Anything + /// unknown reads as latency, so a newer client's future value degrades safely. + #[serde(default = "default_present_priority")] + pub present_priority: String, + /// Smoothness buffer size in frames: `0` = Automatic (resolves to 2), else 1–3. + /// Only meaningful under `present_priority = "smooth"` (the shared `smooth_buffer` + /// key). Each buffered frame absorbs about one refresh of jitter and adds one + /// refresh of display latency. + #[serde(default)] + pub smooth_buffer: u8, + /// Tear-free presentation (default ON = today's behavior: MAILBOX, FIFO fallback). + /// Off asks for a tearing present mode (IMMEDIATE) for the lowest possible latch + /// latency — best-effort: platforms/drivers without tearing silently stay tear-free + /// and the active mode is visible in the detailed stats. The shared `vsync` profile + /// key; the desktop default differs from macOS's (`false` there) deliberately — + /// sync-off means something different on each platform, the key is the contract. + #[serde(default = "default_true")] + pub vsync: bool, + /// Let a variable-refresh display follow the stream cadence: prefers the present + /// mode that drives VRR panels directly when fullscreen. Inert on fixed-refresh + /// displays (detection is measured from on-glass timestamps, not queried). The + /// shared `allow_vrr` profile key. Default ON, like the Apple client. + #[serde(default = "default_true")] + pub allow_vrr: bool, /// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept /// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same /// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted @@ -963,6 +1028,10 @@ fn default_mouse_mode() -> String { "capture".into() } +fn default_present_priority() -> String { + "latency".into() +} + fn default_true() -> bool { true } @@ -994,6 +1063,12 @@ impl Settings { MouseMode::from_name(&self.mouse_mode) } + /// The presentation intent for this session (the resolved + /// `present_priority` × `smooth_buffer` pair). + pub fn present_priority(&self) -> PresentPriority { + PresentPriority::resolve(&self.present_priority, self.smooth_buffer) + } + /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). pub fn preferred_codec(&self) -> u8 { match self.codec.as_str() { @@ -1032,6 +1107,10 @@ impl Default for Settings { adapter: String::new(), enable_444: false, hdr_enabled: true, + present_priority: "latency".into(), + smooth_buffer: 0, + vsync: true, + allow_vrr: true, show_stats: true, stats_verbosity: None, fullscreen_on_stream: true, @@ -1170,6 +1249,43 @@ mod tests { } } + /// A settings file predating the presentation cluster loads with the shipped + /// defaults (latency intent, Automatic buffer, tear-free, VRR allowed), and the + /// resolution rules match the Apple/Android reference: anything but an explicit + /// `"smooth"` is latency, and a smooth buffer outside 1..=3 becomes 2. + #[test] + fn settings_presentation_defaults_and_resolution() { + let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#; + let s: Settings = serde_json::from_str(old).unwrap(); + assert_eq!(s.present_priority, "latency"); + assert_eq!(s.smooth_buffer, 0); + assert!(s.vsync); + assert!(s.allow_vrr); + assert_eq!(s.present_priority(), PresentPriority::Latency); + + assert_eq!( + PresentPriority::resolve("smooth", 0), + PresentPriority::Smooth { buffer: 2 }, + "Automatic resolves to 2" + ); + assert_eq!( + PresentPriority::resolve("smooth", 3), + PresentPriority::Smooth { buffer: 3 } + ); + assert_eq!( + PresentPriority::resolve("smooth", 9), + PresentPriority::Smooth { buffer: 2 }, + "out-of-range pins to the Automatic resolution" + ); + assert_eq!( + PresentPriority::resolve("balanced-from-the-future", 2), + PresentPriority::Latency, + "unknown intents degrade to latency" + ); + assert_eq!(PresentPriority::Latency.fifo_capacity(), 0); + assert_eq!(PresentPriority::Smooth { buffer: 3 }.fifo_capacity(), 3); + } + /// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic. #[test] fn settings_forward_pad_defaults_empty() { diff --git a/crates/pf-presenter/src/lib.rs b/crates/pf-presenter/src/lib.rs index 158510fa..db34f32f 100644 --- a/crates/pf-presenter/src/lib.rs +++ b/crates/pf-presenter/src/lib.rs @@ -52,6 +52,8 @@ pub mod keymap_sdl; #[cfg(any(target_os = "linux", windows))] pub mod overlay; #[cfg(any(target_os = "linux", windows))] +mod present_pace; +#[cfg(any(target_os = "linux", windows))] mod run; #[cfg(any(target_os = "linux", windows))] pub mod touch; diff --git a/crates/pf-presenter/src/present_pace.rs b/crates/pf-presenter/src/present_pace.rs new file mode 100644 index 00000000..e4602564 --- /dev/null +++ b/crates/pf-presenter/src/present_pace.rs @@ -0,0 +1,406 @@ +//! The presentation intent engine (design/desktop-presentation-rebuild.md WP2): the +//! store, clock, and gate the run loop composes into the two intents. +//! +//! * [`FrameStore`] — newest-wins slot (latency) or smoothing FIFO with preroll +//! (smoothness), ported from the Apple `FrameStore` / Android `presenter.rs` so all +//! three clients agree on what the intents mean. +//! * [`LatchClock`] — the panel latch grid, learned from `VK_KHR_present_wait` on-glass +//! stamps (measured, never queried — the Android refresh-rate lie and VRR both punish +//! trusting a reported rate). Without present-wait it degrades to a grid rooted at the +//! last submit on the mode's refresh period. +//! * [`PresentGate`] — the FIFO glass budget: one undisplayed present in flight, so the +//! swapchain's own queue can never become a standing queue (+1 refresh per slot, +//! forever — the law every bounded-FIFO pacing rediscovered on Apple). MAILBOX cannot +//! queue and never needs it. +//! +//! Everything here is pure state + arithmetic on `CLOCK_REALTIME` ns (the +//! `pf_client_core::session::now_ns` domain the on-glass stamps live in); the run loop +//! owns all clocks and Vulkan calls, which is what keeps this testable. + +use std::collections::VecDeque; + +/// Stale-present force-open: an undisplayed present older than this is presumed lost +/// (occluded window, wedged compositor) and the gate opens anyway, counted as `forced` +/// — reads 0 on healthy systems. The Apple/Android presenters use the same 100 ms. +const STALE_REOPEN_NS: u64 = 100_000_000; + +/// The adaptive slot-pick margin's ceiling and step (Android's measured values: start +/// at 0 — a fixed lead was pure display tax on the reference device — and widen only +/// when measured misses demand it). +pub(crate) const MARGIN_STEP_NS: u64 = 500_000; +pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000; + +/// The decoded-frame store between the wake channel and the present call. +/// +/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears. +/// `capacity 1..=3` = smoothing FIFO: preroll-to-capacity, drop-oldest on overflow, +/// an underflow after preroll re-arms the preroll (the previous frame persists on +/// glass — a repeat by omission) while headroom rebuilds. +pub(crate) struct FrameStore { + capacity: usize, + frames: VecDeque, + prerolled: bool, + /// Newest-wins displacements (normal operation under latency, not a fault signal). + replaced: u32, + /// FIFO drop-oldest evictions — the Apple debug line's `qDrop`. + overflow_drops: u32, + /// FIFO dry-after-preroll events — `qDry`. + underflows: u32, +} + +impl FrameStore { + pub(crate) fn new(capacity: usize) -> FrameStore { + FrameStore { + capacity, + frames: VecDeque::with_capacity(capacity.max(1) + 1), + prerolled: false, + replaced: 0, + overflow_drops: 0, + underflows: 0, + } + } + + pub(crate) fn is_smoothing(&self) -> bool { + self.capacity > 0 + } + + pub(crate) fn is_empty(&self) -> bool { + self.frames.is_empty() + } + + pub(crate) fn submit(&mut self, f: T) { + if self.capacity == 0 { + if self.frames.pop_front().is_some() { + self.replaced += 1; + } + self.frames.push_back(f); + } else { + self.frames.push_back(f); + // Drop the OLDEST past capacity: bounded added latency, the newest keeps + // flowing. Also trims a transient capacity+1 a put_back left behind. + while self.frames.len() > self.capacity { + self.frames.pop_front(); + self.overflow_drops += 1; + } + } + } + + pub(crate) fn take(&mut self) -> Option { + if self.capacity == 0 { + return self.frames.pop_front(); + } + if !self.prerolled { + // Preroll gate: without it a steady stream drains every frame on arrival + // and jitter headroom never builds (the Apple store's lesson). + if self.frames.len() < self.capacity { + return None; + } + self.prerolled = true; + } + match self.frames.pop_front() { + Some(f) => Some(f), + None => { + self.underflows += 1; + self.prerolled = false; + None + } + } + } + + /// A frame taken but not presented (gate closed, present failed before consuming + /// it). Newest-wins reinserts only into an empty slot — a fresher decode wins; + /// FIFO puts it back at the front (it is the oldest). + pub(crate) fn put_back(&mut self, f: T) { + if self.capacity == 0 { + if self.frames.is_empty() { + self.frames.push_back(f); + } + } else { + self.frames.push_front(f); + } + } + + /// Collapse to newest-wins for the rest of the stream (PyroWave: its plane-ring + /// retirement accounting assumes the depth-2 newest-wins hand-off, and its all-intra + /// frames make buffering pointless anyway). + pub(crate) fn force_latency(&mut self) { + if self.capacity == 0 { + return; + } + self.capacity = 0; + self.prerolled = false; + while self.frames.len() > 1 { + self.frames.pop_front(); + } + } + + /// Drain the window's counters: `(replaced, overflow_drops, underflows)`. + pub(crate) fn take_counters(&mut self) -> (u32, u32, u32) { + let c = (self.replaced, self.overflow_drops, self.underflows); + self.replaced = 0; + self.overflow_drops = 0; + self.underflows = 0; + c + } +} + +/// The panel latch grid: a recent on-glass instant + the latch period, extrapolated +/// forward for slot targeting. 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. +pub(crate) struct LatchClock { + anchor_ns: u64, + period_ns: u64, + fallback_period_ns: u64, +} + +impl LatchClock { + pub(crate) fn new(refresh_hz: u32) -> LatchClock { + LatchClock { + anchor_ns: 0, + period_ns: 0, + 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. + pub(crate) fn note_batch(&mut self, stamps: &[u64]) { + 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) + .min(); + if let Some(d) = min_delta { + self.period_ns = d.min(self.fallback_period_ns); + } + } + + pub(crate) fn period_ns(&self) -> u64 { + if self.period_ns > 0 { + self.period_ns + } else { + self.fallback_period_ns + } + } + + pub(crate) fn anchor_ns(&self) -> u64 { + self.anchor_ns + } + + /// The first predicted latch strictly after `after_ns` (`anchor + k·period`). With + /// no anchor yet: one period out — callers get a usable, if unanchored, deadline. + pub(crate) fn next_slot_after(&self, after_ns: u64) -> u64 { + let p = self.period_ns(); + if self.anchor_ns == 0 || after_ns < self.anchor_ns { + return after_ns.saturating_add(p); + } + let k = (after_ns - self.anchor_ns) / p + 1; + self.anchor_ns + k * p + } +} + +/// The FIFO glass budget: at most one undisplayed present in flight, measured by the +/// present-wait waiter's outstanding count. Never consulted under MAILBOX/IMMEDIATE +/// (they cannot queue) or without present-wait (nothing to count with — behavior is +/// then exactly the shipped arrival pacing). +#[derive(Default)] +pub(crate) struct PresentGate { + /// Submit stamp of the newest tracked present; 0 = none yet. + last_present_ns: u64, + gated: u32, + forced: u32, +} + +impl PresentGate { + /// May a new present go out? Open when nothing undisplayed is in flight; a stale + /// in-flight present (occlusion, wedged compositor) force-opens after 100 ms so the + /// stream survives, counted as `forced`. + pub(crate) fn open(&mut self, outstanding: usize, now_ns: u64) -> bool { + if outstanding == 0 { + return true; + } + if self.last_present_ns != 0 + && now_ns.saturating_sub(self.last_present_ns) > STALE_REOPEN_NS + { + self.forced += 1; + return true; + } + self.gated += 1; + false + } + + pub(crate) fn note_present(&mut self, now_ns: u64) { + self.last_present_ns = now_ns; + } + + /// Drain the window's counters: `(gated, forced)`. + pub(crate) fn take_counters(&mut self) -> (u32, u32) { + let c = (self.gated, self.forced); + self.gated = 0; + self.forced = 0; + c + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Newest-wins: submit replaces, take clears, put_back only fills an empty slot. + #[test] + fn newest_wins_replaces_and_putback_never_clobbers() { + let mut s: FrameStore = FrameStore::new(0); + assert!(!s.is_smoothing()); + assert_eq!(s.take(), None); + s.submit(1); + s.submit(2); + s.submit(3); + assert_eq!(s.take(), Some(3), "only the newest survives"); + assert_eq!(s.take(), None); + // A taken-but-unpresented frame returns — unless a fresher one arrived. + s.submit(4); + let f = s.take().unwrap(); + s.put_back(f); + assert_eq!(s.take(), Some(4)); + let f = s.take(); + assert_eq!(f, None); + s.submit(5); + let f = s.take().unwrap(); + s.submit(6); + s.put_back(f); // 6 arrived while 5 was out — 6 wins + assert_eq!(s.take(), Some(6)); + assert_eq!( + s.take_counters(), + (2, 0, 0), + "two displacements, no fifo counters" + ); + } + + /// FIFO: preroll to capacity, drop-oldest overflow, underflow re-arms the preroll. + #[test] + fn fifo_prerolls_overflows_oldest_and_rearms_on_dry() { + let mut s: FrameStore = FrameStore::new(2); + assert!(s.is_smoothing()); + s.submit(1); + assert_eq!(s.take(), None, "prerolling: below capacity, nothing vends"); + s.submit(2); + assert_eq!(s.take(), Some(1), "preroll reached — FIFO order"); + assert_eq!( + s.take(), + Some(2), + "once prerolled the buffer drains normally" + ); + // Dry after preroll = one underflow, preroll re-arms. + assert_eq!(s.take(), None); + s.submit(3); + assert_eq!(s.take(), None, "re-armed preroll holds again"); + s.submit(4); + assert_eq!(s.take(), Some(3)); + // Overflow drops the OLDEST: [4] → [4,5] → 6 evicts 4 → 7 evicts 5. + s.submit(5); + s.submit(6); + s.submit(7); + assert_eq!(s.take(), Some(6)); + assert_eq!(s.take(), Some(7)); + let (replaced, drops, dry) = s.take_counters(); + assert_eq!(replaced, 0); + assert_eq!(drops, 2, "6 evicted 4, 7 evicted 5"); + assert_eq!(dry, 1); + } + + /// put_back under FIFO goes to the FRONT (it is the oldest), and the transient + /// capacity+1 is trimmed by the next submit. + #[test] + fn fifo_putback_restores_order() { + let mut s: FrameStore = FrameStore::new(2); + s.submit(1); + s.submit(2); + let f = s.take().unwrap(); + s.put_back(f); + assert_eq!(s.take(), Some(1), "the put-back frame is still first"); + } + + /// force_latency collapses a smoothing store to a newest-wins slot mid-stream. + #[test] + fn force_latency_collapses_to_one_slot() { + let mut s: FrameStore = FrameStore::new(3); + s.submit(1); + s.submit(2); + s.submit(3); + s.force_latency(); + assert!(!s.is_smoothing()); + assert_eq!(s.take(), Some(3), "only the newest survives the collapse"); + s.submit(4); + s.submit(5); + assert_eq!(s.take(), Some(5)); + } + + /// The clock learns the min positive spacing (capped at the mode refresh), anchors + /// on the newest stamp, and extrapolates the next slot; sub-ms pairs (a queued + /// double-present) never become the period. + #[test] + fn latch_clock_learns_and_extrapolates() { + const P: u64 = 16_666_666; // 60 Hz + let mut c = LatchClock::new(60); + assert_eq!(c.period_ns(), P, "fallback = the mode refresh"); + // No anchor: a usable deadline one period out. + assert_eq!(c.next_slot_after(1_000), 1_000 + P); + + c.note_batch(&[1_000_000_000, 1_000_000_000 + P, 1_000_000_000 + 2 * P]); + assert_eq!(c.period_ns(), P); + assert_eq!(c.anchor_ns(), 1_000_000_000 + 2 * P); + let next = c.next_slot_after(c.anchor_ns()); + assert_eq!(next, 1_000_000_000 + 3 * P); + // Mid-slot query lands on the same boundary; a later one steps whole periods. + assert_eq!(c.next_slot_after(next - 1), next); + assert_eq!(c.next_slot_after(next), next + P); + + // A queued pair (< 1 ms apart) must not poison the period. + c.note_batch(&[2_000_000_000, 2_000_000_500]); + assert_eq!(c.period_ns(), P); + assert_eq!(c.anchor_ns(), 2_000_000_500, "the anchor still advances"); + + // A stream presenting every OTHER refresh spaces its glass stamps at 2×P — 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). + c.note_batch(&[3_000_000_000, 3_000_000_000 + 2 * P]); + assert_eq!(c.period_ns(), P, "capped at the mode refresh"); + + // A single stamp re-anchors without touching the period. + c.note_batch(&[5_000_000_000]); + assert_eq!(c.anchor_ns(), 5_000_000_000); + assert_eq!(c.period_ns(), P); + + // A faster panel learns its own finer grid. + let mut fast = LatchClock::new(120); + fast.note_batch(&[1_000_000_000, 1_008_333_333]); + assert_eq!(fast.period_ns(), 8_333_333); + } + + /// Gate: open at zero outstanding, closed at one, force-open past the stale bound. + #[test] + fn gate_budgets_one_undisplayed_present() { + let mut g = PresentGate::default(); + let t0 = 1_000_000_000u64; + assert!(g.open(0, t0)); + g.note_present(t0); + assert!(!g.open(1, t0 + 8_000_000), "one in flight — hold"); + assert!( + g.open(1, t0 + STALE_REOPEN_NS + 1), + "stale in-flight present force-opens" + ); + let (gated, forced) = g.take_counters(); + assert_eq!((gated, forced), (1, 1)); + assert_eq!(g.take_counters(), (0, 0), "counters drain"); + } +} diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index b84e9c5c..a613b15d 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -18,12 +18,13 @@ 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::touch::Abs; use crate::vk::{FrameInput, Presenter}; use anyhow::{Context as _, Result}; use pf_client_core::gamepad::GamepadService; use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats}; -use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode}; +use pf_client_core::trust::{MouseMode, PresentPriority, StatsVerbosity, TouchMode}; use pf_client_core::video::VulkanDecodeDevice; use pf_client_core::video::{DecodedFrame, DecodedImage}; use punktfunk_core::client::NativeClient; @@ -63,6 +64,12 @@ pub struct SessionOpts { /// work profile that streams on a second screen and still Alt-Tabs here. Never applies /// under the `desktop` mouse model, which is something you Alt-Tab *away* from. pub inhibit_shortcuts: bool, + /// Presentation intent ([`Settings::present_priority`] resolved): `Latency` keeps the + /// shipped arrival pacing (newest-wins, present the moment a frame can go out); + /// `Smooth { buffer }` runs the smoothing FIFO drained one frame per latch slot + /// (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, /// 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 @@ -215,6 +222,34 @@ struct StreamState { win_disp_us: Vec, win_start: Instant, presented: PresentedWindow, + /// The intent engine (design/desktop-presentation-rebuild.md WP2): the decoded-frame + /// store between the wake channel and the present call — a newest-wins slot under + /// the latency intent (behaviorally the shipped drain), the smoothing FIFO under + /// smoothness. NOTE: a smoothing store holds decoder-pool frames (Vulkan-Video + /// AVFrames) up to `buffer` deep on top of the depth-2 wake channels — within pool + /// headroom for 1..=3, but any deeper store must revisit pool sizing. + store: FrameStore, + /// The panel latch grid (present-wait glass stamps; submit-anchored fallback) — the + /// smoothness slot clock, and the values published to the host-facing `latch_grid`. + clock: LatchClock, + /// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes + /// or without present timing. + gate: PresentGate, + /// The latch slot the last smoothness present served (one present per slot); 0 = + /// none yet. + last_target_ns: u64, + /// Smoothness slot-pick margin: starts 0 (a fixed lead is pure display tax — + /// measured on Android), widens +500 µs per >2-miss window toward 2.5 ms. + margin_ns: u64, + /// This window's latch misses (a present that reached glass > 1.5 latch periods + /// after submit) — the adaptive margin's error signal. + win_misses: u32, + /// This window's peak undisplayed-presents-in-flight (present timing only). + win_out_max: usize, + /// One-shot log latch: smoothness was requested but a PyroWave stream collapsed the + /// store to latency (its plane-ring retirement assumes the newest-wins hand-off). + #[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))] + pyro_latency_forced: bool, // Hardware-path health: a failure streak (or a device with no import support at // all) demotes the decoder to software via the shared flag — once per session. dmabuf_demoted: bool, @@ -279,6 +314,8 @@ impl StreamState { params: SessionParams, force_software: Arc, wake: sdl3::event::EventSender, + priority: PresentPriority, + native_refresh_hz: u32, ) -> StreamState { let profile = params.profile.clone(); // The presenter's half of phase-locked capture: it writes the latch grid the @@ -318,6 +355,15 @@ impl StreamState { win_disp_us: Vec::with_capacity(256), win_start: Instant::now(), presented: PresentedWindow::default(), + store: FrameStore::new(usize::from(priority.fifo_capacity())), + clock: LatchClock::new(native_refresh_hz), + gate: PresentGate::default(), + last_target_ns: 0, + margin_ns: 0, + win_misses: 0, + win_out_max: 0, + #[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))] + pyro_latency_forced: false, dmabuf_demoted: false, #[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))] pyro_present_warned: false, @@ -356,6 +402,25 @@ impl StreamState { } self.handle.stop.store(true, Ordering::SeqCst); } + + /// The event-loop wait bound: a smoothness stream with buffered frames sleeps only + /// to its next latch-slot deadline; everything else keeps the 15 ms housekeeping + /// tick (frames, input, and present completions all wake the loop early anyway). + fn wake_timeout(&self) -> Duration { + const TICK: Duration = Duration::from_millis(15); + if !self.store.is_smoothing() || self.store.is_empty() { + return TICK; + } + let now = session::now_ns(); + let mut target = self + .clock + .next_slot_after(now.saturating_add(self.margin_ns)); + if target == self.last_target_ns { + // This slot is already served — the next boundary is the deadline. + target += self.clock.period_ns(); + } + Duration::from_nanos(target.saturating_sub(now)).clamp(Duration::from_millis(1), TICK) + } } /// Whether a present error is `VK_ERROR_DEVICE_LOST` anywhere in its chain. A lost @@ -441,6 +506,26 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?; // A valid black frame immediately — the window is honest while the connect runs. presenter.present(&window, FrameInput::Redraw, None)?; + + // `PUNKTFUNK_PRESENTER=arrival` — the legacy drain, the intent engine's field-A/B + // kill switch (the Android sysprop pattern: no rebuild to bisect a pacing suspicion). + let arrival_override = std::env::var("PUNKTFUNK_PRESENTER").ok().as_deref() == Some("arrival"); + let present_priority = if arrival_override { + tracing::info!("PUNKTFUNK_PRESENTER=arrival — presentation pacing disabled"); + PresentPriority::Latency + } else { + opts.present_priority + }; + let pacing_active = !arrival_override; + let present_debug = std::env::var_os("PUNKTFUNK_PRESENT_DEBUG").is_some(); + // Present completions wake the loop exactly like decoded frames: a glass-gate + // reopen or a smoothness slot must not wait out the event timeout. + { + let sender = events.event_sender(); + presenter.set_present_wake(Box::new(move || { + let _ = sender.push_custom_event(FrameWake); + })); + } // Browse mode is "ready" the moment the library window presents — there may never be // a stream. (Single mode announces on the first VIDEO frame instead, further down, so // a shell only yields to a window that actually shows the stream.) @@ -517,6 +602,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result params, force_software, events.event_sender(), + present_priority, + native.refresh_hz, )) } ModeCtl::Browse(_) => None, @@ -544,8 +631,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // forwarder's FrameWake) all land in this one queue, so the loop wakes exactly // when there is work — a short-timeout poll here burned a full core (measured; // the timeout only bounds stop-flag/pump-tick latency now). In browse-idle the - // per-iteration FIFO present vsync-throttles the loop anyway. - let timeout = Duration::from_millis(15); + // per-iteration FIFO present vsync-throttles the loop anyway. A smoothness + // stream tightens the bound to its next latch-slot deadline. + let timeout = stream + .as_ref() + .map_or(Duration::from_millis(15), |st| st.wake_timeout()); let first = event_pump.wait_event_timeout(timeout); let mut queued: Vec = Vec::new(); if let Some(e) = first { @@ -1032,6 +1122,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result *params, force_software, events.event_sender(), + present_priority, + native.refresh_hz, )); if let Some(o) = overlay.as_mut() { o.session_phase(SessionPhase::Connecting); @@ -1279,11 +1371,109 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result presenter.set_hdr_metadata(m); } } - let mut newest: Option = None; - while let Ok(f) = st.frames.try_recv() { - newest = Some(f); + // Present-wait completions drive the latch clock, the glass gate, and the + // host-facing grid — drained every pass (a 1 Hz batch would starve all + // three; the waiter's SDL wake pairs with this so completions never wait + // out the event timeout). + if presenter.present_timing_active() { + let samples = presenter.take_presented_samples(); + if !samples.is_empty() { + let clock_offset_ns = st + .clock_offset + .as_ref() + .map_or(0, |o| o.load(Ordering::Relaxed)); + let period = st.clock.period_ns(); + let mut stamps = Vec::with_capacity(samples.len()); + for s in &samples { + let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 + - s.pts_ns as i128) + .max(0) as u64; + if e2e > 0 && e2e < 10_000_000_000 { + st.win_e2e_us.push(e2e / 1000); + } + st.win_disp_us + .push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000); + // Latch miss (the adaptive margin's error signal): glass more + // than 1.5 latch periods after submit = the intended slot was + // overshot. + if st.store.is_smoothing() + && s.displayed_ns.saturating_sub(s.submitted_ns) > period + period / 2 + { + st.win_misses += 1; + } + stamps.push(s.displayed_ns); + } + st.clock.note_batch(&stamps); + // 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 + // feeds both, so the report and the scheduler cannot disagree. + if let Some(grid) = &st.latch_grid { + grid.period_ns + .store(st.clock.period_ns(), Ordering::Relaxed); + grid.anchor_ns + .store(st.clock.anchor_ns(), Ordering::Relaxed); + } + } } - if let Some(f) = newest { + + // Intake into the intent store: a newest-wins slot under latency (the + // shipped drain, now with displacement counters), the smoothing FIFO under + // smoothness. PyroWave collapses smoothness to latency for the stream: its + // plane-ring retirement accounting assumes the newest-wins hand-off + // (`video_pyrowave::RETIRE_HANDOVERS`), and all-intra frames make + // buffering moot anyway. + while let Ok(f) = st.frames.try_recv() { + #[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))] + if st.store.is_smoothing() && matches!(f.image, DecodedImage::PyroWave(_)) { + st.store.force_latency(); + if !st.pyro_latency_forced { + st.pyro_latency_forced = true; + tracing::info!( + "PyroWave stream — smoothness buffering does not apply \ + (latency pacing)" + ); + } + } + st.store.submit(f); + } + + // One frame out, by intent: latency takes the newest whenever the glass + // gate allows; smoothness serves at most one frame per latch slot (the + // preroll/underflow behavior lives in the store). + let now_ns = session::now_ns(); + let mut slot_target = 0u64; + let mut to_present = if st.store.is_smoothing() { + let target = st + .clock + .next_slot_after(now_ns.saturating_add(st.margin_ns)); + if target != st.last_target_ns { + slot_target = target; + st.store.take() + } else { + None + } + } else { + st.store.take() + }; + // The FIFO glass budget: one undisplayed present in flight, so the + // swapchain's own FIFO can never become a standing queue (a measured + // 11-13 ms at 60 Hz on MAILBOX-less drivers). Only FIFO modes queue and + // only present timing can count, so everywhere else this stays inert and + // behavior is the shipped arrival pacing. + if pacing_active && presenter.fifo_present_mode() && presenter.present_timing_active() { + if let Some(f) = to_present.take() { + if st.gate.open(presenter.presents_outstanding(), now_ns) { + to_present = Some(f); + } else { + // Parked: a newest-wins store replaces it if a fresher frame + // lands; the waiter's wake (or the 100 ms stale force-open) + // retries. + st.store.put_back(f); + } + } + } + if let Some(f) = to_present { // Resize END: a frame at the steered target size means the sharp new-mode // picture is here — lift the scrim. A no-op unless a switch is in flight. let (fw, fh) = f.image.dimensions(); @@ -1472,6 +1662,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result }; if did_present { presented_video = true; + // Smoothness: this latch slot is served — one present per slot. + // (Set only on success: a gated or failed present leaves the slot + // open for the retry.) + if slot_target != 0 { + st.last_target_ns = slot_target; + } if opts.json_status && !st.ready_announced { st.ready_announced = true; println!("{{\"ready\":true}}"); @@ -1481,6 +1677,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // e2e/display samples arrive via `take_presented_samples` with a // TRUE on-glass stamp instead of the submit-time one below. presenter.note_presented(pts_ns, decoded_ns); + st.gate.note_present(now_ns); + st.win_out_max = st.win_out_max.max(presenter.presents_outstanding()); } else { let displayed_ns = session::now_ns(); // The `displayed` stamp (same clamp rules as the pump's windows). @@ -1495,49 +1693,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } st.win_disp_us .push(displayed_ns.saturating_sub(decoded_ns) / 1000); + // No glass stamps on this stack: the submit instant anchors an + // approximate grid on the mode's refresh period, so smoothness + // still drains one frame per (approximate) slot. + st.clock.note_batch(&[displayed_ns]); } } } // Fold the presenter window into the shared stats line once per second. + // (The on-glass samples themselves are drained every pass above — they + // drive the latch clock and glass gate, not just this fold.) if st.win_start.elapsed() >= Duration::from_secs(1) { - // On-glass samples the present-wait waiter completed this window (empty - // when timing is inactive — the legacy submit-time pushes fill in then). - let clock_offset_ns = st - .clock_offset - .as_ref() - .map_or(0, |o| o.load(Ordering::Relaxed)); - let samples = presenter.take_presented_samples(); - // Phase-locked capture, the presenter's half: publish this window's latch - // grid — a recent TRUE on-glass instant plus the panel period — for the - // pump's ~1 Hz PhaseReport. The period is the min positive spacing of - // consecutive on-glass stamps (Apple's method: honest under VRR), 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. - if let Some(grid) = &st.latch_grid { - if let Some(last) = samples.last() { - let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1)); - let min_delta = samples - .windows(2) - .map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns)) - .filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step - .min() - .unwrap_or(refresh_period); - grid.period_ns - .store(min_delta.min(refresh_period), Ordering::Relaxed); - grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed); - } - } - for s in samples { - let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128) - .max(0) as u64; - if e2e > 0 && e2e < 10_000_000_000 { - st.win_e2e_us.push(e2e / 1000); - } - st.win_disp_us - .push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000); - } let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us); let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us); st.presented = PresentedWindow { @@ -1548,6 +1715,42 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result st.win_e2e_us.clear(); st.win_disp_us.clear(); st.win_start = Instant::now(); + // Adaptive slot margin (the Android presenter's measured recipe): + // start at 0 — a fixed lead is pure display tax — and widen one step + // per window whose measured latch misses demand it. One-way per + // stream; the next stream restarts at 0. + if st.store.is_smoothing() && st.win_misses > 2 && st.margin_ns < MARGIN_MAX_NS { + st.margin_ns = (st.margin_ns + MARGIN_STEP_NS).min(MARGIN_MAX_NS); + tracing::info!( + margin_us = st.margin_ns / 1000, + misses = st.win_misses, + "smoothness slot margin widened (measured latch misses)" + ); + } + // The 1 Hz presenter line (the Apple `pf-present` analogue): emitted + // when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 — + // the field-triage instrument for the intent engine. + let (replaced, q_drop, q_dry) = st.store.take_counters(); + let (gated, forced) = st.gate.take_counters(); + if pacing_active + && (present_debug || replaced + q_drop + q_dry + gated + forced > 0) + { + tracing::info!( + smoothing = st.store.is_smoothing(), + replaced, + q_drop, + q_dry, + gated, + forced, + misses = st.win_misses, + out_max = st.win_out_max, + period_us = st.clock.period_ns() / 1000, + margin_us = st.margin_ns / 1000, + "presenter window" + ); + } + st.win_misses = 0; + st.win_out_max = 0; } } diff --git a/crates/pf-presenter/src/vk/mod.rs b/crates/pf-presenter/src/vk/mod.rs index 24d78339..32adf40a 100644 --- a/crates/pf-presenter/src/vk/mod.rs +++ b/crates/pf-presenter/src/vk/mod.rs @@ -247,10 +247,42 @@ impl Presenter { /// (the presenter itself never sees them). No-op when timing is inactive. pub(crate) fn note_presented(&mut self, pts_ns: u64, decoded_ns: u64) { if let (Some(t), Some((sc, id))) = (&self.present_timer, self.last_presented.take()) { - t.enqueue(sc, id, pts_ns, decoded_ns); + // The submit stamp: `present()` already returned, so "now" is within the + // present-call tail — the pace/latch split point. + t.enqueue( + sc, + id, + pts_ns, + decoded_ns, + pf_client_core::session::now_ns(), + ); } } + /// Undisplayed id-carrying presents in flight (0 when timing is inactive) — the + /// FIFO glass gate's budget count. + pub(crate) fn presents_outstanding(&self) -> usize { + self.present_timer.as_ref().map_or(0, |t| t.outstanding()) + } + + /// Install the run loop's wake for present completions (an SDL event push). No-op + /// without present timing — there is nothing to wake on then. + pub(crate) fn set_present_wake(&self, cb: Box) { + if let Some(t) = &self.present_timer { + t.set_wake(cb); + } + } + + /// The active present mode queues presents (FIFO family): the only modes where the + /// swapchain itself can become a standing queue, and so the only ones the glass + /// gate governs. MAILBOX/IMMEDIATE replace/flip and never queue. + pub(crate) fn fifo_present_mode(&self) -> bool { + matches!( + self.present_mode, + vk::PresentModeKHR::FIFO | vk::PresentModeKHR::FIFO_RELAXED + ) + } + /// Take the window's completed on-glass samples (empty when timing is inactive). pub(crate) fn take_presented_samples(&self) -> Vec { self.present_timer diff --git a/crates/pf-presenter/src/vk/present_timing.rs b/crates/pf-presenter/src/vk/present_timing.rs index d44dcdac..b10b7880 100644 --- a/crates/pf-presenter/src/vk/present_timing.rs +++ b/crates/pf-presenter/src/vk/present_timing.rs @@ -26,6 +26,9 @@ pub(crate) struct PresentedSample { pub pts_ns: u64, /// Decode-complete stamp (client clock) — the display-stage anchor. pub decoded_ns: u64, + /// `vkQueuePresentKHR`-return stamp (client clock) — the pace/latch split point: + /// `submitted − decoded` is our pipeline, `displayed − submitted` the vsync latch. + pub submitted_ns: u64, /// `vkWaitForPresentKHR` completion = the image is visible (client clock). pub displayed_ns: u64, } @@ -35,15 +38,24 @@ struct Job { present_id: u64, pts_ns: u64, decoded_ns: u64, + submitted_ns: u64, } +/// The run loop's wake callback (an SDL event push), shared with the waiter thread. +type WakeSlot = Arc>>>; + /// The waiter: a channel-fed thread turning (swapchain, present-id) pairs into /// [`PresentedSample`]s. One frame in flight upstream keeps the queue depth ~1. pub(crate) struct PresentTimer { tx: Option>, - /// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown. + /// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown, + /// and the glass gate's "undisplayed presents in flight" count. pending: Arc, results: Arc>>, + /// Called by the waiter after each completed wait (sample or not) — the run loop + /// installs an SDL wake here so a gate reopen / smoothness slot never waits out the + /// event-loop timeout. + wake: WakeSlot, join: Option>, } @@ -52,7 +64,8 @@ impl PresentTimer { let (tx, rx) = mpsc::channel::(); let pending = Arc::new(AtomicUsize::new(0)); let results = Arc::new(Mutex::new(Vec::with_capacity(256))); - let (pending_t, results_t) = (pending.clone(), results.clone()); + let wake: WakeSlot = Arc::new(Mutex::new(None)); + let (pending_t, results_t, wake_t) = (pending.clone(), results.clone(), wake.clone()); let join = std::thread::Builder::new() .name("pf-present-wait".into()) .spawn(move || { @@ -69,12 +82,20 @@ impl PresentTimer { results_t.lock().unwrap().push(PresentedSample { pts_ns: job.pts_ns, decoded_ns: job.decoded_ns, + submitted_ns: job.submitted_ns, displayed_ns, }); } // SUBOPTIMAL/TIMEOUT/DEVICE_LOST: no sample; the frame still showed // (or the loop is about to find out) — never poison the window. pending_t.fetch_sub(1, Ordering::AcqRel); + // Wake the run loop AFTER the count dropped: what it observes on + // wake is the post-completion state (the gate may now be open). + // Called under the slot lock — the callback is a bare SDL event + // push and never reenters this type. + if let Some(cb) = wake_t.lock().unwrap().as_ref() { + cb(); + } } }) .expect("spawn pf-present-wait"); @@ -82,10 +103,23 @@ impl PresentTimer { tx: Some(tx), pending, results, + wake, join: Some(join), } } + /// Install the run loop's wake callback (an SDL event push — thread-safe by design). + pub(crate) fn set_wake(&self, cb: Box) { + *self.wake.lock().unwrap() = Some(cb); + } + + /// Presents handed to the waiter and not yet resolved to glass — the glass gate's + /// budget count. (Also counts a wait that will end SUBOPTIMAL/TIMEOUT; those resolve + /// within the 250 ms cap, far past the gate's own 100 ms stale force-open.) + pub(crate) fn outstanding(&self) -> usize { + self.pending.load(Ordering::Acquire) + } + /// Hand a successfully submitted present to the waiter. pub(crate) fn enqueue( &self, @@ -93,6 +127,7 @@ impl PresentTimer { present_id: u64, pts_ns: u64, decoded_ns: u64, + submitted_ns: u64, ) { if let Some(tx) = &self.tx { self.pending.fetch_add(1, Ordering::AcqRel); @@ -102,6 +137,7 @@ impl PresentTimer { present_id, pts_ns, decoded_ns, + submitted_ns, }) .is_err() { From b1ac4d02de390de170d4591cee00e99987d6b189 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 19:56:34 +0200 Subject: [PATCH 3/7] feat(client/present): the display stat splits, and the intent reaches the settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP4 + WP5 of design/desktop-presentation-rebuild.md, on top of the WP1/WP2 engine. The engine shipped with no way to choose it and no way to see what it cost; this closes both. WP4 — the display stage splits into `pace` (decoded → present-submit, our own pipeline) + `latch` (submit → on-glass, the presentation queue and the vblank wait), off the `submitted_ns` stamp WP2 already carried. That split is what makes a high `display` self-diagnosing: latch dominating is the vsync floor or a standing queue, pace dominating is us. A `present:` line joins the Detailed tier naming the live swapchain mode — the answer to most "why is my latch a whole refresh" questions, since a MAILBOX request silently lands on FIFO wherever the driver has no mailbox — plus the engine's counters, rendered only when they are non-zero so a healthy latency session shows just the mode. Deviation from the plan: the planned `display_adj` twin is NOT here. It was specified as `display − latch_p50` for parity with the Apple HUD's shaved figure, but with a real per-sample `pace` percentile that twin is the same quantity derived worse (subtracting percentiles). `pace` IS the Apple-comparable number — Apple subtracts its OS present floor, the latch is ours — and the user docs now say exactly that. WP5 — Prioritize + Smoothness buffer on all three surfaces: the GTK dialog (a new Presentation group on the Display page), the WinUI settings page, and the console settings screen, which is the ONLY editor reachable in Gaming Mode and so the one that decides whether Deck users can reach this at all. The buffer control follows the intent the way echo cancellation follows the mic: hidden on the desktop shells, dimmed and inert on the console, where a row that vanished mid-list would shift everything under the cursor. The V-Sync and VRR rows are deliberately NOT here. Their settings exist and are profile-routed, but the swapchain does not honour them until WP3, and a toggle that does nothing is exactly how "Full chroma (4:4:4)" shipped inert on desktop for three releases after being announced. Buffer labels carry no millisecond hints (Apple/Android derive them from the session refresh): under a Native mode the shells do not know the refresh at settings time, so the captions state the cost as one refresh per frame rather than a confident wrong number. Docs: the stats page documents the split and the `present:` line, and stops claiming Linux/Windows measure to the present instant (untrue since present_wait); client-settings documents both new rows and drops the stale claim that the desktop 4:4:4 toggle has no effect (it was wired to VIDEO_CAP_444); configuration documents PUNKTFUNK_PRESENTER and PUNKTFUNK_PRESENT_DEBUG. 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, 158 tests. The WinUI leg cannot be reached by any Linux or macOS check, so it was compiled on the Windows runner .133: clippy -D warnings and tests both exit 0, against a tree proven by content to contain the edit. ⚠ The first run there reported a false pass — the script printed its done-marker while the log carried a test failure (a STATUS_DLL_NOT_FOUND launch failure, ffmpeg's DLLs missing from PATH); the harness now echoes each phase's exit code so the verdict is a fact in the log rather than an inference from a marker. Co-Authored-By: Claude Opus 5 (1M context) --- clients/linux/src/ui_settings.rs | 95 +++++++++++ clients/windows/src/app/settings.rs | 81 ++++++++++ crates/pf-console-ui/src/screens/settings.rs | 108 ++++++++++++- crates/pf-presenter/src/run.rs | 160 ++++++++++++++++++- crates/pf-presenter/src/vk/mod.rs | 14 ++ docs-site/content/docs/client-settings.md | 17 +- docs-site/content/docs/configuration.md | 2 + docs-site/content/docs/stats.md | 26 ++- 8 files changed, 484 insertions(+), 19 deletions(-) diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index 5cd87e87..d2bef1fe 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -156,6 +156,20 @@ mod index { pub fn gamepad(s: &Settings) -> u32 { GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32 } + + pub fn present_priority(s: &Settings) -> u32 { + // Unknown values (a newer client's intent) read as the default, exactly as + // `PresentPriority::resolve` treats them. + PRESENT_PRIORITIES + .iter() + .position(|&p| p == s.present_priority) + .unwrap_or(0) as u32 + } + + pub fn smooth_buffer(s: &Settings) -> u32 { + // The index IS the stored value: 0 = Automatic, 1..3 = frames. + u32::from(s.smooth_buffer).min(SMOOTH_BUFFER_LABELS.len() as u32 - 1) + } } /// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a @@ -634,6 +648,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings) if touched.has("fullscreen_on_stream") { o.fullscreen_on_stream = Some(values.fullscreen_on_stream); } + if touched.has("present_priority") { + o.present_priority = Some(values.present_priority.clone()); + } + if touched.has("smooth_buffer") { + o.smooth_buffer = Some(values.smooth_buffer); + } // 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. @@ -687,6 +707,20 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[ "The cursor jumps to your finger — a tap clicks there", "Real multi-touch reaches the host — for touch-native apps", ]; +/// Presentation-intent values (persisted under the `present_priority` key the Apple and +/// Android clients share) + labels + dynamic captions. Captions stay ONE line, like the +/// touch/mouse rows. +const PRESENT_PRIORITIES: &[&str] = &["latency", "smooth"]; +const PRESENT_PRIORITY_LABELS: &[&str] = &["Lowest latency", "Smoothness"]; +const PRESENT_PRIORITY_CAPTIONS: &[&str] = &[ + "Each frame shows the moment the display can take it", + "Buffers a little to even out network hiccups", +]; +/// Smoothness buffer depth, in frames — the index IS the stored `smooth_buffer` value +/// (0 = Automatic, which resolves to 2). No millisecond hints: the cost is one refresh +/// per frame, and the session's refresh isn't known here when the mode is Native. +const SMOOTH_BUFFER_LABELS: &[&str] = &["Automatic", "1 frame", "2 frames", "3 frames"]; + /// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as /// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream. const MOUSE_MODES: &[&str] = &["capture", "desktop"]; @@ -1216,6 +1250,34 @@ pub fn show_scoped( row }); + // ---- Display: Presentation ---- + // The intent pair the Apple and Android clients already carry. The buffer row only + // means anything under Smoothness, so it hides itself the rest of the time rather + // than sitting there inert. + let present_row = ChoiceRow::new( + &dialog, + inline, + "Prioritize", + PRESENT_PRIORITY_CAPTIONS[0], + PRESENT_PRIORITY_LABELS, + ); + let buffer_row = ChoiceRow::new( + &dialog, + inline, + "Smoothness buffer", + "Each frame held absorbs one refresh of hiccup and adds one of delay", + SMOOTH_BUFFER_LABELS, + ); + { + let w = present_row.widget().clone(); + let buffer = buffer_row.widget().clone(); + present_row.connect_changed(move |i| { + let i = (i as usize).min(PRESENT_PRIORITY_CAPTIONS.len() - 1); + set_row_subtitle(&w, PRESENT_PRIORITY_CAPTIONS[i]); + buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth"); + }); + } + // ---- Display: Host output ---- let compositor_row = ChoiceRow::new( &dialog, @@ -1506,6 +1568,17 @@ pub fn show_scoped( let codec_i = index::codec(s); codec_row.set_selected(codec_i); set_row_subtitle(codec_row.widget(), codec_caption(codec_i)); + let present_i = index::present_priority(s); + present_row.set_selected(present_i); + set_row_subtitle( + present_row.widget(), + PRESENT_PRIORITY_CAPTIONS[present_i as usize], + ); + buffer_row.set_selected(index::smooth_buffer(s)); + // `set_selected` never fires the changed hook, so mirror its visibility rule here. + buffer_row + .widget() + .set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth"); } // ---- Override markers, per-row reset, and the touch that creates an override ---- @@ -1704,6 +1777,18 @@ pub fn show_scoped( o.gamepad_forwarding.is_some(), gamepad_forwarding ); + choice!( + present_row, + "present_priority", + o.present_priority.is_some(), + index::present_priority + ); + choice!( + buffer_row, + "smooth_buffer", + o.smooth_buffer.is_some(), + index::smooth_buffer + ); 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!( @@ -1808,6 +1893,9 @@ pub fn show_scoped( if let (Some(r), false) = (&gpu_row, profile_mode) { quality_group.add(r.widget()); } + let presentation_group = group("Presentation", ""); + presentation_group.add(present_row.widget()); + presentation_group.add(buffer_row.widget()); // The one form-level note (deliberately not repeated on every row). let output_group = group( "Host output", @@ -1816,6 +1904,7 @@ pub fn show_scoped( output_group.add(compositor_row.widget()); display.add(&resolution_group); display.add(&quality_group); + display.add(&presentation_group); display.add(&output_group); let input = page("Input", "input-keyboard-symbolic"); @@ -1963,6 +2052,12 @@ pub fn show_scoped( _ => 2, }; s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string(); + s.present_priority = PRESENT_PRIORITIES + [(present_row.selected() as usize).min(PRESENT_PRIORITIES.len() - 1)] + .to_string(); + // The index IS the value (0 = Automatic). + s.smooth_buffer = + (buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1); s.library_enabled = library_row.is_active(); }; diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 1551834b..035f8922 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -101,6 +101,19 @@ const MOUSE_MODES: &[(&str, &str)] = &[ ("capture", "Capture (games)"), ("desktop", "Desktop (absolute)"), ]; +/// Presentation intent: `(stored value, display label)` — the `present_priority` key the +/// Apple and Android clients share, so one profile means the same thing everywhere. +const PRESENT_PRIORITIES: &[(&str, &str)] = + &[("latency", "Lowest latency"), ("smooth", "Smoothness")]; +/// Smoothness buffer depth in frames: `(stored value, display label)`. `0` = Automatic, +/// which resolves to 2 (`PresentPriority::resolve`). No millisecond hints — the cost is +/// one refresh per frame, and the refresh isn't known here when the mode is Native. +const SMOOTH_BUFFERS: &[(u8, &str)] = &[ + (0, "Automatic"), + (1, "1 frame"), + (2, "2 frames"), + (3, "3 frames"), +]; /// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to /// auto-detect when the choice is unavailable. Only meaningful against a Linux host. const COMPOSITORS: &[(&str, &str)] = &[ @@ -468,6 +481,8 @@ struct OverrideFlags { gamepad_forwarding: bool, stats_verbosity: bool, fullscreen_on_stream: bool, + present_priority: bool, + smooth_buffer: bool, } impl OverrideFlags { @@ -497,6 +512,8 @@ impl OverrideFlags { gamepad_forwarding: o.gamepad_forwarding.is_some(), stats_verbosity: o.stats_verbosity.is_some(), fullscreen_on_stream: o.fullscreen_on_stream.is_some(), + present_priority: o.present_priority.is_some(), + smooth_buffer: o.smooth_buffer.is_some(), } } } @@ -873,6 +890,28 @@ pub(crate) fn settings_page( let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| { s.enable_444 = on }); + // Presentation intent (design/desktop-presentation-rebuild.md). The buffer row is + // rendered only under Smoothness — `commit` bumps the revision, so flipping the + // intent re-renders the section and the row appears/disappears with it. + let (present_names, present_i) = presets(PRESENT_PRIORITIES, |v| *v == s.present_priority); + let present_combo = setting_combo( + ctx, + scope, + (rev, set_rev), + present_names, + present_i, + |s, i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string(), + ); + let smoothing = s.present_priority == "smooth"; + let (buffer_names, buffer_i) = presets(SMOOTH_BUFFERS, |v| *v == s.smooth_buffer); + let buffer_combo = setting_combo( + ctx, + scope, + (rev, set_rev), + buffer_names, + buffer_i, + |s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0, + ); // --- Input ----------------------------------------------------------------------------- // Controller forwarding: Automatic forwards EVERY real controller, each as its own pad; @@ -1143,6 +1182,37 @@ pub(crate) fn settings_page( }, None, )); + out.extend(group( + Some("Presentation"), + { + let mut fields = vec![described_overridable( + (rev, set_rev), + scope, + "present_priority", + "Prioritize", + over.present_priority, + present_combo, + "Lowest latency shows each frame the moment the display can take \ + it \u{2014} a network hiccup becomes an occasional repeated or \ + skipped frame. Smoothness buffers a little to even those out.", + )]; + if smoothing { + fields.push(described_overridable( + (rev, set_rev), + scope, + "smooth_buffer", + "Smoothness buffer", + over.smooth_buffer, + buffer_combo, + "Frames held back before showing. Each one absorbs about a \ + refresh of network hiccup and adds a refresh of delay. \ + Automatic holds two.", + )); + } + fields + }, + None, + )); out.extend(group( Some("Host output"), vec![described_overridable( @@ -1791,5 +1861,16 @@ mod tests { let f3 = OverrideFlags::of(Some(&p3)); assert!(f3.echo_cancel); assert!(!f3.mic_enabled); + + // The presentation pair, likewise independent: pinning the intent doesn't claim + // the buffer (a "Smoothness, whatever the global buffer is" profile is valid). + let mut p4 = StreamProfile::new("t4".to_string()); + p4.overrides = SettingsOverlay { + present_priority: Some("smooth".into()), + ..Default::default() + }; + let f4 = OverrideFlags::of(Some(&p4)); + assert!(f4.present_priority); + assert!(!f4.smooth_buffer); } } diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index a30cede6..1b7b44da 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -26,6 +26,8 @@ enum RowId { Decoder, Hdr, Chroma444, + PresentPriority, + SmoothBuffer, Audio, Mic, EchoCancel, @@ -47,7 +49,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; 23] = [ +const ROWS: [RowId; 25] = [ RowId::Resolution, RowId::Refresh, RowId::RenderScale, @@ -57,6 +59,8 @@ const ROWS: [RowId; 23] = [ RowId::Decoder, RowId::Hdr, RowId::Chroma444, + RowId::PresentPriority, + RowId::SmoothBuffer, RowId::Audio, RowId::Mic, RowId::EchoCancel, @@ -119,6 +123,17 @@ const DECODERS: [(&str, &str); 4] = [ ("software", "Software"), ]; const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")]; +/// Presentation intent — the `present_priority` key shared with the Apple and Android +/// clients, so one profile reads the same on every device. +const PRESENT_PRIORITIES: [(&str, &str); 2] = + [("latency", "Lowest latency"), ("smooth", "Smoothness")]; +/// Smoothness buffer depth in frames; `0` = Automatic (resolves to 2). +const SMOOTH_BUFFERS: [(u8, &str); 4] = [ + (0, "Automatic"), + (1, "1 frame"), + (2, "2 frames"), + (3, "3 frames"), +]; const PAD_TYPES: [(&str, &str); 6] = [ ("auto", "Automatic"), ("xbox360", "Xbox 360"), @@ -224,12 +239,16 @@ impl SettingsScreen { fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { let s = &ctx.settings; - // Echo cancellation only means anything while the mic streams, and which controller to - // forward as which virtual pad only while any controller is forwarded at all — dimmed and - // inert otherwise, the same relationship the desktop shells draw with a greyed-out row. + // Several rows follow another: echo cancellation only means anything while the mic + // streams, the pad rows only while any controller is forwarded at all, and the + // smoothness buffer only while that intent is chosen. All go dim and inert otherwise + // — the same relationship the desktop shells draw by greying a row out (they hide the + // buffer row entirely; a fixed row list can't, and a row that vanished mid-list would + // move everything under the cursor). let enabled = match id { RowId::EchoCancel => s.mic_enabled, RowId::Pad | RowId::PadType => s.gamepad_forwarding, + RowId::SmoothBuffer => s.present_priority == "smooth", _ => true, }; let (header, label, value): (Option<&'static str>, &str, String) = match id { @@ -286,6 +305,20 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()), RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()), RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()), + RowId::PresentPriority => ( + Some("Presentation"), + "Prioritize", + label_for(&PRESENT_PRIORITIES, &s.present_priority).into(), + ), + RowId::SmoothBuffer => ( + None, + "Smoothness buffer", + SMOOTH_BUFFERS + .iter() + .find(|(v, _)| *v == s.smooth_buffer) + .map_or("Automatic", |(_, l)| l) + .into(), + ), RowId::Audio => ( Some("Audio"), "Audio channels", @@ -380,6 +413,15 @@ fn detail(id: RowId) -> &'static str { Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders \ stream 4:2:0 and the session falls back silently." } + RowId::PresentPriority => { + "Lowest latency shows each frame the moment the display can take it — a \ + network hiccup becomes an occasional repeated or skipped frame. Smoothness \ + buffers a little to even those out." + } + RowId::SmoothBuffer => { + "Frames held back before showing. Each one absorbs about a refresh of network \ + hiccup and adds a refresh of delay. Automatic holds two." + } RowId::Audio => "The speaker layout requested from the host.", RowId::Mic => { "Send this device's microphone to the host's virtual mic. \ @@ -480,6 +522,25 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap), RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap), RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap), + RowId::PresentPriority => { + let cur = PRESENT_PRIORITIES + .iter() + .position(|(v, _)| *v == s.present_priority); + step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap) + .map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string()) + } + // Inert unless smoothness is chosen — a boundary thud, matching the dimmed row. + RowId::SmoothBuffer => { + if s.present_priority == "smooth" { + let cur = SMOOTH_BUFFERS + .iter() + .position(|(v, _)| *v == s.smooth_buffer); + step_option(cur, SMOOTH_BUFFERS.len(), delta, wrap) + .map(|i| s.smooth_buffer = SMOOTH_BUFFERS[i].0) + } else { + None + } + } 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) @@ -674,6 +735,45 @@ mod tests { assert!(ctx.settings.echo_cancel); } + /// The smoothness buffer follows the presentation intent, exactly as echo cancellation + /// follows the mic: dimmed and inert under Lowest latency (where holding frames means + /// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed + /// row list dims it, because a row vanishing mid-list would shift everything under the + /// cursor. + #[test] + fn smoothness_buffer_follows_the_intent() { + let (mut settings, pads) = ctx_parts(); + assert_eq!(settings.present_priority, "latency", "the shipped default"); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled); + assert!( + !adjust(RowId::SmoothBuffer, 1, false, &mut ctx), + "latency intent = thud" + ); + assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written"); + + // Stepping the intent to Smoothness brings the buffer row to life. + assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx)); + assert_eq!(ctx.settings.present_priority, "smooth"); + assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled); + assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx)); + assert_eq!(ctx.settings.smooth_buffer, 1); + + // The intent wraps back and the row goes inert again. + assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx)); + assert_eq!(ctx.settings.present_priority, "latency"); + assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled); + } + #[test] fn touch_mode_steps_and_wraps() { let (mut settings, pads) = ctx_parts(); diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index a613b15d..fbcfd509 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -220,6 +220,10 @@ struct StreamState { // capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50. win_e2e_us: Vec, win_disp_us: Vec, + /// The display stage's two halves (present-timing sessions only): decoded→submit and + /// submit→on-glass. See [`PresentedWindow::pace_ms`]. + win_pace_us: Vec, + win_latch_us: Vec, win_start: Instant, presented: PresentedWindow, /// The intent engine (design/desktop-presentation-rebuild.md WP2): the decoded-frame @@ -353,6 +357,8 @@ impl StreamState { hdr_untonemapped: false, win_e2e_us: Vec::with_capacity(256), win_disp_us: Vec::with_capacity(256), + win_pace_us: Vec::with_capacity(256), + win_latch_us: Vec::with_capacity(256), win_start: Instant::now(), presented: PresentedWindow::default(), store: FrameStore::new(usize::from(priority.fifo_capacity())), @@ -1393,6 +1399,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } st.win_disp_us .push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000); + // The display split (WP4): our pipeline vs the vsync latch. Only + // meaningful with true glass stamps, which is exactly when this + // branch runs. + st.win_pace_us + .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. @@ -1707,13 +1720,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if st.win_start.elapsed() >= Duration::from_secs(1) { let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us); let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us); + let (pace_p50, _) = session::window_percentiles(&mut st.win_pace_us); + let (latch_p50, _) = session::window_percentiles(&mut st.win_latch_us); + // Drained ONCE per window and shared by the HUD and the log line below — + // a second `take_counters` would read zeros. + let (replaced, q_drop, q_dry) = st.store.take_counters(); + let (gated, forced) = st.gate.take_counters(); st.presented = PresentedWindow { e2e_p50_ms: e2e_p50 as f32 / 1000.0, e2e_p95_ms: e2e_p95 as f32 / 1000.0, display_ms: disp_p50 as f32 / 1000.0, + pace_ms: pace_p50 as f32 / 1000.0, + latch_ms: latch_p50 as f32 / 1000.0, + mode: presenter.present_mode_name(), + smoothing: st.store.is_smoothing(), + q_drop, + q_dry, + gated, + forced, }; st.win_e2e_us.clear(); st.win_disp_us.clear(); + st.win_pace_us.clear(); + st.win_latch_us.clear(); st.win_start = Instant::now(); // Adaptive slot margin (the Android presenter's measured recipe): // start at 0 — a fixed lead is pure display tax — and widen one step @@ -1730,13 +1759,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // The 1 Hz presenter line (the Apple `pf-present` analogue): emitted // when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 — // the field-triage instrument for the intent engine. - let (replaced, q_drop, q_dry) = st.store.take_counters(); - let (gated, forced) = st.gate.take_counters(); - if pacing_active - && (present_debug || replaced + q_drop + q_dry + gated + forced > 0) - { + if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) { tracing::info!( - smoothing = st.store.is_smoothing(), + smoothing = st.presented.smoothing, + mode = st.presented.mode, replaced, q_drop, q_dry, @@ -1744,6 +1770,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result forced, misses = st.win_misses, out_max = st.win_out_max, + pace_ms = st.presented.pace_ms, + latch_ms = st.presented.latch_ms, period_us = st.clock.period_ns() / 1000, margin_us = st.margin_ns / 1000, "presenter window" @@ -2210,6 +2238,30 @@ struct PresentedWindow { e2e_p50_ms: f32, e2e_p95_ms: f32, display_ms: f32, + /// The display stage split (design/desktop-presentation-rebuild.md WP4): + /// `pace` = decoded → present-submit (our own pipeline), `latch` = submit → on-glass + /// (the presentation engine's queue + the vblank wait). Both `0` without + /// `VK_KHR_present_wait`, where the two are not separable — the HUD then shows the + /// unsplit figure rather than inventing a zero latch. + /// + /// This split is what makes a high `display` self-diagnosing: latch dominating means + /// the vsync/queue floor (or a standing queue), pace dominating means us. + /// `pace` is also the honest cross-platform twin of the Apple client's shaved + /// number — Apple subtracts its measured OS present floor, and the latch IS our + /// floor, so `pace` is what remains on both sides of that comparison. + pace_ms: f32, + latch_ms: f32, + /// The live swapchain present mode (`mailbox`/`fifo`/…). Shown because a mode is + /// 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, + /// 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, + q_drop: u32, + q_dry: u32, + gated: u32, + forced: u32, } /// The capture hints (`ui_stream` parity — the words the user reads while released). @@ -2315,6 +2367,15 @@ fn stats_text( " · decode {:.1} · display {:.1} ms", s.decode_ms, p.display_ms )); + // The display split (WP4). Only with true on-glass stamps — without them the + // two halves are not separable and the unsplit figure stands alone rather than + // implying a zero latch. + if p.latch_ms > 0.0 || p.pace_ms > 0.0 { + text.push_str(&format!( + " (pace {:.1} + latch {:.1})", + p.pace_ms, p.latch_ms + )); + } // Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution // (queue → encode → seal/xfer → pace) reads as the host pipeline in order. if s.staged { @@ -2323,6 +2384,28 @@ fn stats_text( s.host_queue_ms, s.host_encode_ms, s.host_xfer_ms, s.host_pace_ms )); } + // The presenter line: the swapchain mode that is actually live, the chosen + // intent, and the engine's own counters. Present-mode alone answers most + // "why is my latch a whole refresh" questions; the counters only render when + // 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)); + if p.smoothing { + text.push_str(" · smoothing"); + } + if p.q_drop > 0 { + text.push_str(&format!(" · qdrop {}", p.q_drop)); + } + if p.q_dry > 0 { + text.push_str(&format!(" · qdry {}", p.q_dry)); + } + if p.gated > 0 { + text.push_str(&format!(" · gated {}", p.gated)); + } + if p.forced > 0 { + text.push_str(&format!(" · forced {}", p.forced)); + } + } } if s.lost > 0 { text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct)); @@ -2596,6 +2679,7 @@ mod tests { e2e_p50_ms: 6.4, e2e_p95_ms: 9.1, display_ms: 1.1, + ..Default::default() }, ) } @@ -2633,6 +2717,70 @@ mod tests { !normal.contains("queue"), "host-stage split is Detailed-only" ); + assert!( + !detailed.contains("pace 1.1"), + "no glass stamps in this sample — the display stage stays unsplit" + ); + } + + /// WP4: with true on-glass stamps the display stage reads as its two halves, the + /// live present mode is named, and the engine counters render only when non-zero — + /// so a healthy latency session shows the mode and nothing else. Without glass + /// stamps (no `VK_KHR_present_wait`) the split is absent rather than a zero latch. + #[test] + fn detailed_splits_display_into_pace_and_latch() { + let (s, mut p) = sample(); + p.display_ms = 12.4; + p.pace_ms = 1.1; + p.latch_ms = 11.3; + p.mode = "fifo"; + let split = stats_text( + StatsVerbosity::Detailed, + "m", + &s, + &p, + false, + false, + false, + None, + ); + assert!(split.contains("display 12.4 ms (pace 1.1 + latch 11.3)")); + assert!(split.contains("\npresent: fifo")); + assert!( + !split.contains("qdrop") && !split.contains("gated") && !split.contains("smoothing"), + "quiet counters stay off the HUD: {split}" + ); + + // The smoothing FIFO and the glass gate surface once they actually do something. + p.smoothing = true; + p.q_drop = 2; + p.q_dry = 1; + p.gated = 7; + p.forced = 1; + let busy = stats_text( + StatsVerbosity::Detailed, + "m", + &s, + &p, + false, + false, + false, + None, + ); + assert!(busy.contains("present: fifo · smoothing · qdrop 2 · qdry 1 · gated 7 · forced 1")); + + // A tier below Detailed never carries any of it. + let normal = stats_text( + StatsVerbosity::Normal, + "m", + &s, + &p, + false, + false, + false, + None, + ); + assert!(!normal.contains("present:") && !normal.contains("pace")); } /// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT diff --git a/crates/pf-presenter/src/vk/mod.rs b/crates/pf-presenter/src/vk/mod.rs index 32adf40a..75499df5 100644 --- a/crates/pf-presenter/src/vk/mod.rs +++ b/crates/pf-presenter/src/vk/mod.rs @@ -273,6 +273,20 @@ impl Presenter { } } + /// The live swapchain present mode, for the stats overlay: a mode is picked from + /// what the surface actually offers, so the requested one and this can differ (a + /// MAILBOX request lands on FIFO wherever the driver has no mailbox — AMD's Windows + /// driver, notably). Showing it is what makes that visible instead of puzzling. + pub(crate) fn present_mode_name(&self) -> &'static str { + match self.present_mode { + vk::PresentModeKHR::MAILBOX => "mailbox", + vk::PresentModeKHR::FIFO => "fifo", + vk::PresentModeKHR::FIFO_RELAXED => "fifo-relaxed", + vk::PresentModeKHR::IMMEDIATE => "immediate", + _ => "other", + } + } + /// The active present mode queues presents (FIFO family): the only modes where the /// swapchain itself can become a standing queue, and so the only ones the glass /// gate governs. MAILBOX/IMMEDIATE replace/flip and never queue. diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 1b3401fd..bb9e96e6 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -82,9 +82,20 @@ Full detail: [HDR](/docs/hdr). **Full chroma (4:4:4)** — *default: off.* Crisp small text and thin lines, at more bandwidth. It needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is -built. **Today only the Apple app actually advertises 4:4:4**, and only when its hardware decode -probe passes — the Linux and Windows apps store the toggle but their session doesn't advertise the -capability yet, so it has no effect there. Android, Decky and the console home don't offer it. +built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware +decode probe to pass). Android, Decky and the console home don't offer it. + +**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is +ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup +becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens +those hiccups out, at that buffer's worth of added delay. Linux and Windows apps; the Apple and +Android apps have carried the same setting for a while, and it is stored under the same name, so a +[profile](/docs/profiles-and-links) means the same thing on every device. + +**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How +many frames are held back before showing. Each frame absorbs roughly one screen refresh of network +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. **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/configuration.md b/docs-site/content/docs/configuration.md index 721b9047..b629c400 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -242,6 +242,8 @@ A few knobs are read by the native **clients**, not the host: | `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. | | `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. | | `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. | +| `PUNKTFUNK_PRESENTER` | `arrival` | Turn the frame-pacing engine off for this run: frames present the instant they decode, exactly as they did before the **Prioritize** setting existed. A diagnostic — if a pacing change is suspected of causing judder or added delay, this switches it off without reinstalling anything. Linux and Windows clients. | +| `PUNKTFUNK_PRESENT_DEBUG` | `1` | Log the presenter's own 1-second summary (display mode, buffer drops, pacing counters) every second, even when nothing is going wrong. Without it the line appears only when there is something to report. | | `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `900000` | The startup link-capacity probe's burst target (default 2 Gbps — deliberately above any plausible link so the burst measures the link, not itself). Lower it on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. | | `PUNKTFUNK_ABR_PROBE` | `0` | Skip the startup link-capacity probe entirely. The adaptive-bitrate climb ceiling then stays at the negotiated starting rate — a blunt instrument; prefer `PUNKTFUNK_ABR_MAX_MBPS`. | | `PUNKTFUNK_ABR_MAX_MBPS` | Mbps, e.g. `300` | Hard cap on the adaptive bitrate's climb ceiling, whatever the startup probe measured. The escape hatch when adaptive sessions keep climbing past what your client's **decoder** can sustain (periodic hitch + "receive backlog stopped draining" in the client log). An explicit bitrate setting still bypasses ABR entirely. | diff --git a/docs-site/content/docs/stats.md b/docs-site/content/docs/stats.md index 4c1840a0..6abab1d7 100644 --- a/docs-site/content/docs/stats.md +++ b/docs-site/content/docs/stats.md @@ -62,8 +62,9 @@ differently. Linux · Windows · Steam Deck: ``` 1920×1080@120 · 120 fps · 24.3 Mb/s · target 30 Mb/s (auto) · vulkan · HDR -e2e 14.2/19.8 ms (p50/p95) · host 3.1 · net 6.7 · decode 2.1 · display 2.3 ms +e2e 14.2/19.8 ms (p50/p95) · host 3.1 · net 6.7 · decode 2.1 · display 2.3 ms (pace 0.6 + latch 1.7) host: queue 0.6 · encode 1.8 · xfer 0.2 · pace 0.5 ms +present: mailbox lost 3 (2.4%) ``` @@ -109,10 +110,11 @@ lost 3 (2.4%) which otherwise reads as inexplicable judder plus a refresh of extra latency. - **Line 2 — the headline.** `end-to-end` (`e2e` on Linux/Windows) is the *directly measured* time from host capture to the endpoint named at the end of the line — - `capture→on-glass` or `capture→displayed`. Linux/Windows don't spell the endpoint out, - because their presenter always measures to the present instant. `p50` = the typical - frame (median), `p95` = the slow outliers. This is the one number that summarizes your - stream. + `capture→on-glass` or `capture→displayed`. On Linux/Windows the endpoint is the moment + the frame is genuinely **visible** wherever the GPU driver can report it (most can); + where it can't, the measurement stops at the instant the frame is handed to the display + and so reads slightly optimistic. `p50` = the typical frame (median), `p95` = the slow + outliers. This is the one number that summarizes your stream. - **Line 3 — where the time goes.** The first four stages **tile the end-to-end interval** — each starts where the previous one ends, so they add up to the headline. The two extra terms under them are not extra time: one is excluded from the total, the other sits inside a @@ -123,7 +125,12 @@ lost 3 (2.4%) reassembly on your device. - `decode` — received → decoded, on your device. - `display` — decoded → displayed: waiting for the right screen refresh, rendering, - and vsync. + and vsync. On Linux/Windows it splits into `(pace + latch)` when your driver reports + true on-glass timing: **pace** is Punktfunk's own work — getting the decoded frame + submitted — and **latch** is the wait for the display to take it. A large `latch` is + the screen's refresh cycle, not the stream; a large `pace` is us. (`pace` is also the + fair number to compare against an iPhone or iPad, whose figure already has its + equivalent of `latch` removed.) - `os present` *(iOS and tvOS)* — the fixed depth of the OS present pipeline, which is excluded from both the headline and `display` and printed here so you can add it back. @@ -143,6 +150,13 @@ lost 3 (2.4%) encode … · xfer … · pace …` — splitting the host's own share into its stages, when the 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 + [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` + belong to the pacing that keeps frames from stacking up behind the display. + (Stage values are per-stage medians, so they sum only *approximately* to the headline median — percentiles aren't perfectly additive. The headline is measured directly, never computed as a sum.) From e38e3c44c9e74a2fb931253042512654f4e36ce3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 21:56:15 +0200 Subject: [PATCH 4/7] feat(client/present): V-Sync and VRR become real settings, and VRR is measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/linux/src/ui_settings.rs | 30 +++ clients/session/src/console.rs | 2 + clients/session/src/main.rs | 2 + clients/windows/src/app/settings.rs | 41 ++++ crates/pf-console-ui/src/screens/settings.rs | 19 +- crates/pf-presenter/src/present_pace.rs | 224 +++++++++++++++++-- crates/pf-presenter/src/run.rs | 73 +++++- crates/pf-presenter/src/vk/mod.rs | 2 +- crates/pf-presenter/src/vk/setup.rs | 165 ++++++++++++-- docs-site/content/docs/client-settings.md | 14 ++ docs-site/content/docs/stats.md | 4 +- 11 files changed, 526 insertions(+), 50 deletions(-) diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index d2bef1fe..58463ffd 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -654,6 +654,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. @@ -1277,6 +1283,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( @@ -1579,6 +1601,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 ---- @@ -1789,6 +1813,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!( @@ -1896,6 +1922,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", @@ -2058,6 +2086,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 8986e042..be6478a6 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -624,6 +624,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 035f8922..9e970787 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -483,6 +483,8 @@ struct OverrideFlags { fullscreen_on_stream: bool, present_priority: bool, smooth_buffer: bool, + vsync: bool, + allow_vrr: bool, } impl OverrideFlags { @@ -514,6 +516,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(), } } } @@ -912,6 +916,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; @@ -1209,6 +1217,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, @@ -1872,5 +1903,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 1b7b44da..35995361 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, @@ -49,7 +51,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; 25] = [ +const ROWS: [RowId; 27] = [ RowId::Resolution, RowId::Refresh, RowId::RenderScale, @@ -61,6 +63,8 @@ const ROWS: [RowId; 25] = [ RowId::Chroma444, RowId::PresentPriority, RowId::SmoothBuffer, + RowId::Vsync, + RowId::AllowVrr, RowId::Audio, RowId::Mic, RowId::EchoCancel, @@ -319,6 +323,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", @@ -422,6 +428,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. \ @@ -541,6 +556,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 bb9e96e6..e24992d2 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` From f422ae3e381a37119439be77c745a8fd6323f530 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 22:47:21 +0200 Subject: [PATCH 5/7] fix(client/present): what the first on-glass session found, including a reversed default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP6 ran against .21 (CachyOS, RTX 5070 Ti, NVIDIA 610.43.03, GNOME/Wayland, 1080p60 HDMI, VRR provably disabled — `org.gnome.mutter experimental-features` is empty), host and client on the same box, `VK_KHR_present_wait` available. Five defects that unit tests and both CI gates had passed over: 1. The latch learner and the VRR probe observed NOTHING. Both derived spacings with `windows(2)` inside a single batch, but the run loop drains present-wait samples every pass, so a batch is normally ONE stamp. `period_us` read back exactly the mode fallback — correct by luck on a 60 Hz panel, wrong the moment a mode lies, which is the entire reason PanelGrid exists. The tests fed 40-stamp batches, a shape the live loop never produces. Spacings are now measured against the previous stamp across calls. 2. The VRR reference was circular. It compared spacings against the LEARNED period, but the grid cannot be learned from our own presents when the stream runs below panel rate — we only ever observe multiples ≥ our frame interval, so the learner adopts our own cadence and every delta is on-grid by construction. It learned 18-22 ms from a 40-50 fps stream and reported VRR on a display with VRR off. The reference is now the DISPLAY MODE's period, which is the vblank grid presents actually quantize to. 3. The probe is meaningless outside FIFO. MAILBOX deliberately decouples presents from scanout, so its stamps are never grid-quantized: same panel, same minute, FIFO read `no` (correct, period 16.4 ms) and MAILBOX read `yes` (wrong). Outside a FIFO-family mode the honest answer is Unknown, and that is now what it reports. 4. Round evaluation was per-CALL rather than per-sample, so the verdict depended on how the caller batched its stamps. Closed inside the sample loop now, with a test pinning bulk-vs-one-at-a-time equivalence — the same invariant (1) violated, in a second place. 5. `force_latency` was dead code without the `pyrowave` feature: a warning in the `--no-default-features` build CI actually ships (the Windows ARM64 leg). The gate only ever tested default features; it now tests both. DESIGN REVERSAL — the VRR FIFO-first ladder is opt-in (`PUNKTFUNK_VRR_FIFO=1`), no longer default. It shipped default-on for `allow_vrr` + fullscreen, which is the default configuration. Measured A/B, same box, back to back, reproduced across three runs: FIFO+engine `display 28.4 ms (pace 11.8 + latch 16.6)` versus MAILBOX `1.4 ms (0.2 + 1.2)`. Under a compositor the FIFO present's on-glass confirmation arrives a whole refresh later and the presenter serialises behind it. The VRR upside is real in principle but UNMEASURED — no VRR panel was available — and a default that is measurably ~27 ms worse on the hardware we could test, bought against an unproven win on hardware we could not, is the wrong way round. A test pins the default to MAILBOX; flip it back when a VRR panel confirms the win. NOT measured, and not claimed: the FIFO glass gate's own headline. The standing queue only forms when the stream rate approaches the panel rate, and an idle GNOME desktop is damage-driven at 40-50 fps on a 60 Hz panel, so `gated`/`forced` read 0 in every mode and the mechanism never engaged. The 11-13 ms figure is still the code's inherited documentation, not a fresh measurement. It needs its actual target: AMD-on-Windows (no MAILBOX, direct scanout) under load. Rig caveats recorded rather than smoothed over: host and client shared one GPU, so absolute latencies are contended and run-to-run variance was large, and it could not be visually confirmed what the physical screen showed. Mode selection, the fallback ladder, the VRR verdict and the counter plumbing are robust to that; absolute numbers are not. Gates: fmt, clippy -D warnings over the five client crates AND the `--no-default-features` build (added because defect 5 hid there), 160 tests. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-presenter/src/present_pace.rs | 223 ++++++++++++++++++++---- crates/pf-presenter/src/run.rs | 37 +++- crates/pf-presenter/src/vk/setup.rs | 49 +++++- 3 files changed, 266 insertions(+), 43 deletions(-) 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); From e08474d96dbe1c86b914474a4a11586e3eaa7cbb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 23:45:08 +0200 Subject: [PATCH 6/7] fix(client/present): log the surface's actual present modes, and document the VRR opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "AMD's Windows driver offers no MAILBOX" is the premise the FIFO glass gate is built on, and it has been carried in a code comment rather than measured. Present modes are a property of the (surface, device) pair — they vary by platform surface, driver version and fullscreen state — so the only way to settle it is to read them back from real machines. One unconditional log line makes every field log answer the question. First reading, .21 (NVIDIA 610.43.03, GNOME/Wayland): surface present modes available=[MAILBOX, 1000361000, FIFO] Two things fall out. No IMMEDIATE and no FIFO_RELAXED on this surface, which is why a PUNKTFUNK_PRESENT_MODE=immediate run reported mode=fifo — the pin was not offered and the ladder fell through; previously that looked like a puzzling result and is now evidence. And 1000361000 is VK_PRESENT_MODE_FIFO_LATEST_READY_EXT: FIFO's tear-free vblank pacing that presents the LATEST READY image instead of draining a queue — the driver-native version of what the glass gate emulates in software, and a candidate to replace it wherever the driver exposes it (needs VK_EXT_present_mode_fifo_latest_ready enabled at device creation, so a work package rather than a tweak). Also documents PUNKTFUNK_VRR_FIFO, which the previous commit introduced without a docs entry. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-presenter/src/vk/setup.rs | 10 ++++++++++ docs-site/content/docs/configuration.md | 1 + 2 files changed, 11 insertions(+) diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index a533a2b0..55a226a1 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -836,6 +836,16 @@ fn pick_present_mode( "PUNKTFUNK_PRESENT_MODE not offered by this surface — falling back" ); } + // What the surface ACTUALLY offers, logged unconditionally. "AMD's Windows driver + // has no MAILBOX" is the premise the FIFO glass gate is built on, and it has been + // carried in comments rather than measured — present modes are a property of the + // (surface, device) pair, so they vary by platform surface, driver version and + // fullscreen state, and the only way to settle it is to read it back from real + // machines. One line here makes every field log answer the question. + tracing::info!( + available = ?modes, + "surface present modes" + ); let chain = present_mode_chain(pref); let chosen = chain .iter() diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index b629c400..a03b77d4 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -243,6 +243,7 @@ A few knobs are read by the native **clients**, not the host: | `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. | | `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. | | `PUNKTFUNK_PRESENTER` | `arrival` | Turn the frame-pacing engine off for this run: frames present the instant they decode, exactly as they did before the **Prioritize** setting existed. A diagnostic — if a pacing change is suspected of causing judder or added delay, this switches it off without reinstalling anything. Linux and Windows clients. | +| `PUNKTFUNK_VRR_FIFO` | `1` | Opt into the presentation mode intended for **variable-refresh (VRR / FreeSync / G-Sync) displays**, where each frame is shown as the panel is ready for it rather than on a fixed cadence. Off by default: on a *fixed*-refresh display this mode measured substantially worse (about 27 ms more display latency than the default), and its benefit on a real VRR panel has not yet been confirmed. Try it if you have a VRR display and run fullscreen; check the Detailed [stats overlay](/docs/stats) — `vrr yes` means the panel really is following the stream. Linux and Windows clients. | | `PUNKTFUNK_PRESENT_DEBUG` | `1` | Log the presenter's own 1-second summary (display mode, buffer drops, pacing counters) every second, even when nothing is going wrong. Without it the line appears only when there is something to report. | | `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `900000` | The startup link-capacity probe's burst target (default 2 Gbps — deliberately above any plausible link so the burst measures the link, not itself). Lower it on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. | | `PUNKTFUNK_ABR_PROBE` | `0` | Skip the startup link-capacity probe entirely. The adaptive-bitrate climb ceiling then stays at the negotiated starting rate — a blunt instrument; prefer `PUNKTFUNK_ABR_MAX_MBPS`. | From 6b3c582eb18dc2fbfd29ff64d014b2b02d4a7e49 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 00:00:48 +0200 Subject: [PATCH 7/7] feat(client/present): use the driver's queue-free vblank mode where it exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT` is FIFO's tear-free vblank pacing that presents the LATEST READY image at each refresh and retires the older ones, instead of draining a queue. That is precisely what the software glass gate emulates — so where the driver offers it, the driver does the job, and it does it exactly where the gate matters most: a surface with no MAILBOX gets newest-wins behaviour back without the app holding frames. Found by asking the surface what it actually offers rather than trusting a comment: the previous commit's `surface present modes` line read back `[MAILBOX, 1000361000, FIFO]` on NVIDIA/Wayland, and 1000361000 is this mode. The extension postdates the Vulkan headers ash 0.38 is generated from (1.3.281), so there is no binding — hence the bare number in the log. It is hand-declared here: mode value, extension name, and `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` spliced into the device pNext chain. One trap worth naming: the SURFACE advertises the mode even with the extension disabled, and using it on that basis is undefined — so the ladder only offers it when the device feature actually came back true and we enabled it. The gate/probe predicate had to split in two, and the distinction is the point: * `needs_glass_gate()` — FIFO and FIFO_RELAXED only. NOT this mode: gating on top of a driver that already retires stale images would hold frames back to emulate something the presentation engine is doing, paying the serialisation twice, which is the ~27 ms the last commit measured. * `vblank_locked()` — the whole FIFO family INCLUDING this mode, because it still presents on the refresh boundary, so the VRR cadence probe's premise ("with VRR off, a present waits for vblank") still holds. Ranking: MAILBOX first (measured good at 1.4 ms), then LATEST_READY, then plain FIFO — so a MAILBOX-less surface reaches newest-wins in the driver rather than in our gate. MEASURED ON GLASS (.21, NVIDIA 610.43.03, GNOME/Wayland): the extension probe, feature enable and swapchain creation all succeed with a mode ash has no binding for. Default ladder selects MAILBOX with `fifo_latest_ready=true`; the VRR ladder selects `present_mode=1000361000` and measures `display 2.6 ms (pace 0.6 + latch 2.0)` — against 13-28 ms for plain FIFO + gate on the same box. The vblank-locked path is now MAILBOX-class. That changes the previous commit's reversal. The VRR ladder was reverted to opt-in because it led with plain FIFO and cost ~27 ms; led with LATEST_READY it costs 0.6 ms over MAILBOX. So `allow_vrr` is automatic again WHERE THE DEVICE OFFERS THE MODE, and stays behind `PUNKTFUNK_VRR_FIFO=1` where it does not — on those drivers the ladder would fall back to plain FIFO and the regression returns. Both branches are pinned by tests. This also retires a dead switch: the "Follow variable refresh rate" row did nothing at all after the reversal, and now does something real on any driver with the extension. ⚠ Still unverified off this box: whether Windows and Intel drivers expose the mode at all. Nothing measured here carries over — Windows Vulkan WSI goes through DXGI, so exposing the enum and mapping it usefully onto flip-model semantics are separate questions, and Intel is a different vendor stack again. Both facts are logged unconditionally now (`surface present modes` + `fifo_latest_ready=`), so one run on any box settles it. The code is safe either way: the mode is only requested where the device feature enabled, and `allow_vrr` only goes automatic there — everywhere else the shipped MAILBOX-first behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-presenter/src/run.rs | 12 +- crates/pf-presenter/src/vk/mod.rs | 27 ++- crates/pf-presenter/src/vk/setup.rs | 195 +++++++++++++++++++--- docs-site/content/docs/client-settings.md | 8 +- docs-site/content/docs/configuration.md | 2 +- docs-site/content/docs/stats.md | 2 +- 6 files changed, 209 insertions(+), 37 deletions(-) diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 09269b94..1ef0f997 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -535,9 +535,11 @@ 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, + // `vrr_fifo_opt_in` (env) and `fifo_latest_ready` (device capability) are + // both resolved inside `Presenter::new` — the swapchain owns those, so every + // caller gets the same answer. `..Default` keeps this site from breaking each + // time the struct learns another one. + ..Default::default() }, ) .context("vulkan presenter")?; @@ -1500,7 +1502,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // (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() { + if presenter.vblank_locked() { st.cadence.note(&stamps, st.mode_period_ns, healthy); } // Phase-locked capture, the presenter's half: publish the grid the @@ -1560,7 +1562,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // 11-13 ms at 60 Hz on MAILBOX-less drivers). Only FIFO modes queue and // only present timing can count, so everywhere else this stays inert and // behavior is the shipped arrival pacing. - if pacing_active && presenter.fifo_present_mode() && presenter.present_timing_active() { + if pacing_active && presenter.needs_glass_gate() && presenter.present_timing_active() { if let Some(f) = to_present.take() { if st.gate.open(presenter.presents_outstanding(), now_ns) { to_present = Some(f); diff --git a/crates/pf-presenter/src/vk/mod.rs b/crates/pf-presenter/src/vk/mod.rs index 8252af37..31379352 100644 --- a/crates/pf-presenter/src/vk/mod.rs +++ b/crates/pf-presenter/src/vk/mod.rs @@ -283,20 +283,39 @@ impl Presenter { vk::PresentModeKHR::FIFO => "fifo", vk::PresentModeKHR::FIFO_RELAXED => "fifo-relaxed", vk::PresentModeKHR::IMMEDIATE => "immediate", + setup::fifo_latest_ready::MODE => "fifo-latest-ready", _ => "other", } } - /// The active present mode queues presents (FIFO family): the only modes where the - /// swapchain itself can become a standing queue, and so the only ones the glass - /// gate governs. MAILBOX/IMMEDIATE replace/flip and never queue. - pub(crate) fn fifo_present_mode(&self) -> bool { + /// The active present mode QUEUES presents — the only modes where the swapchain + /// itself can become a standing queue, and so the only ones the glass gate governs. + /// + /// MAILBOX and IMMEDIATE replace/flip and never queue. Nor does + /// `FIFO_LATEST_READY`, which retires stale images in the driver: gating on top of it + /// would hold frames back to emulate something the presentation engine is already + /// doing, paying the serialisation twice. + pub(crate) fn needs_glass_gate(&self) -> bool { matches!( self.present_mode, vk::PresentModeKHR::FIFO | vk::PresentModeKHR::FIFO_RELAXED ) } + /// The active present mode shows images ON THE VBLANK GRID — the premise the VRR + /// cadence probe rests on ("with VRR off, a present waits for vblank"). The whole + /// FIFO family qualifies, `FIFO_LATEST_READY` included: it drops stale images but + /// still presents on the refresh boundary. MAILBOX/IMMEDIATE do not, and under them + /// the probe reports Unknown rather than calling every session VRR. + pub(crate) fn vblank_locked(&self) -> bool { + matches!( + self.present_mode, + vk::PresentModeKHR::FIFO + | vk::PresentModeKHR::FIFO_RELAXED + | setup::fifo_latest_ready::MODE + ) + } + /// Take the window's completed on-glass samples (empty when timing is inactive). pub(crate) fn take_presented_samples(&self) -> Vec { self.present_timer diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index 55a226a1..5ded5716 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -13,6 +13,47 @@ use ash::vk; use ash::vk::Handle as _; use std::ffi::{c_char, CString}; +/// `VK_EXT_present_mode_fifo_latest_ready`, hand-declared: it postdates the Vulkan headers +/// ash 0.38 is generated from (1.3.281), so there is no binding for it — which is also why +/// an unenabled driver reports the mode back as the bare number `1000361000`. +/// +/// The mode is FIFO's tear-free vblank pacing that presents the **latest ready** image at +/// each refresh and retires the older ones, instead of draining a queue. That is precisely +/// what [`super::super::present_pace::PresentGate`] emulates in software, done by the +/// driver — and it matters most exactly where the gate does: on a surface that offers no +/// MAILBOX, this restores newest-wins behaviour without the app holding frames back. +pub(crate) mod fifo_latest_ready { + use ash::vk; + + /// `VK_EXT_present_mode_fifo_latest_ready` (extension 361). + pub(super) const NAME: &std::ffi::CStr = c"VK_EXT_present_mode_fifo_latest_ready"; + /// `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`. + pub(crate) const MODE: vk::PresentModeKHR = vk::PresentModeKHR::from_raw(1000361000); + /// `VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_MODE_FIFO_LATEST_READY_FEATURES_EXT`. + const S_TYPE: vk::StructureType = vk::StructureType::from_raw(1000361000); + + /// `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT`. The mode is usable only + /// when this feature is enabled at device creation, so the surface advertising the + /// mode is NOT on its own permission to request it. + #[repr(C)] + #[derive(Clone, Copy)] + pub(super) struct Features { + pub s_type: vk::StructureType, + pub p_next: *mut std::ffi::c_void, + pub present_mode_fifo_latest_ready: vk::Bool32, + } + + impl Default for Features { + fn default() -> Features { + Features { + s_type: S_TYPE, + p_next: std::ptr::null_mut(), + present_mode_fifo_latest_ready: vk::FALSE, + } + } + } +} + impl Presenter { /// Bring up instance → surface → device → swapchain over an SDL window. /// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`. @@ -180,6 +221,21 @@ impl Presenter { // structs through its pNext chain, so any later use of it would pin those borrows — // every read of a chained struct below must come after this, have_f2's last use. let have_shader_int16 = have_f2.features.shader_int16; + // FIFO_LATEST_READY: the surface may list the mode even with the extension + // disabled, so the device feature is the real gate on using it. + let flr_ok = if has(fifo_latest_ready::NAME) { + let mut feat = fifo_latest_ready::Features::default(); + let mut probe = vk::PhysicalDeviceFeatures2 { + p_next: (&mut feat) as *mut _ as *mut std::ffi::c_void, + ..Default::default() + }; + // SAFETY: per the Vulkan contract above - a read-only query on the live + // instance/device, filling locals returned by value; `feat` outlives the call. + unsafe { instance.get_physical_device_features2(pdev, &mut probe) }; + feat.present_mode_fifo_latest_ready == vk::TRUE + } else { + false + }; let present_wait_ok = present_wait_exts && have_pid.present_id == vk::TRUE && have_pwait.present_wait == vk::TRUE; @@ -277,6 +333,13 @@ impl Presenter { dev_exts.push(ash::khr::present_id::NAME.as_ptr()); dev_exts.push(ash::khr::present_wait::NAME.as_ptr()); } + if flr_ok { + dev_exts.push(fifo_latest_ready::NAME.as_ptr()); + } + let mut en_flr = fifo_latest_ready::Features { + present_mode_fifo_latest_ready: vk::TRUE, + ..Default::default() + }; let mut en_pid = vk::PhysicalDevicePresentIdFeaturesKHR::default().present_id(true); let mut en_pwait = vk::PhysicalDevicePresentWaitFeaturesKHR::default().present_wait(true); @@ -299,6 +362,11 @@ impl Presenter { if present_wait_ok { en_f2 = en_f2.push_next(&mut en_pid).push_next(&mut en_pwait); } + if flr_ok { + // Hand-rolled struct, so chain it by hand: splice into the pNext list head. + en_flr.p_next = en_f2.p_next; + en_f2.p_next = (&mut en_flr) as *mut _ as *mut std::ffi::c_void; + } en_f2.features.shader_int16 = if pyrowave_ok { vk::TRUE } else { vk::FALSE }; let priorities = [1.0f32]; @@ -456,6 +524,7 @@ impl Presenter { } let mut pref = pref; pref.vrr_fifo_opt_in = vrr_fifo_opt_in(); + pref.fifo_latest_ready = flr_ok; let present_mode = pick_present_mode(&surface_i, pdev, surface, pref)?; tracing::info!( ?format, @@ -463,6 +532,7 @@ impl Presenter { ?present_mode, vsync = pref.vsync, allow_vrr = pref.allow_vrr, + fifo_latest_ready = flr_ok, hdr_metadata = has_hdr_metadata, "swapchain config" ); @@ -749,6 +819,9 @@ pub struct PresentPref { /// 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, + /// `VK_EXT_present_mode_fifo_latest_ready` is enabled on the device, so the mode may + /// be requested. Resolved during device creation; never set by callers. + pub fifo_latest_ready: 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 @@ -767,16 +840,20 @@ pub struct PresentPref { /// 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). +/// **Automatic where a queue-free vblank mode exists, opt-in otherwise.** The history is +/// worth keeping: this was default-on, then measured on glass (.21, GNOME/Wayland, +/// NVIDIA, *non*-VRR 60 Hz panel, 2026-08-02) to cost ~27 ms of display stage against +/// MAILBOX — `28.4 ms (pace 11.8 + latch 16.6)` versus `1.4 ms (0.2 + 1.2)` — because a +/// plain-FIFO present's on-glass confirmation lands a whole refresh later and the +/// presenter serialises behind it. It became opt-in on that evidence. +/// +/// `FIFO_LATEST_READY` removes the cause rather than working around it: the driver +/// retires stale images, so the vblank-locked path measured **2.6 ms** on the same box — +/// 0.6 ms over MAILBOX instead of 27. So where the device offers it, following the panel +/// is cheap enough to be the default again; where it does not, the ladder would fall +/// back to plain FIFO and the regression returns, so it stays behind +/// `PUNKTFUNK_VRR_FIFO=1` there. The win on a genuine VRR panel is still UNMEASURED — +/// no VRR display was available — but the cost of trying is now small and bounded. /// * **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 @@ -784,15 +861,36 @@ pub struct PresentPref { /// /// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO — /// expected, not a misconfiguration, and now visible in the `present:` stats line. -fn present_mode_chain(pref: PresentPref) -> [vk::PresentModeKHR; 4] { +fn present_mode_chain(pref: PresentPref) -> Vec { use vk::PresentModeKHR as M; - if !pref.vsync { - [M::IMMEDIATE, M::FIFO_RELAXED, M::MAILBOX, M::FIFO] - } else if pref.allow_vrr && pref.fullscreen && pref.vrr_fifo_opt_in { - [M::FIFO, M::MAILBOX, M::FIFO_RELAXED, M::IMMEDIATE] + let flr = pref.fifo_latest_ready.then_some(fifo_latest_ready::MODE); + let mut chain: Vec = if !pref.vsync { + vec![M::IMMEDIATE, M::FIFO_RELAXED, M::MAILBOX] + } else if pref.allow_vrr && pref.fullscreen && (pref.fifo_latest_ready || pref.vrr_fifo_opt_in) + { + // The VRR ladder wants the vblank-locked family; LATEST_READY is that with the + // queue removed, so it outranks plain FIFO here too. + vec![] + .into_iter() + .chain(flr) + .chain([M::FIFO, M::MAILBOX, M::FIFO_RELAXED, M::IMMEDIATE]) + .collect() } else { - [M::MAILBOX, M::FIFO, M::FIFO_RELAXED, M::IMMEDIATE] + // MAILBOX first (measured good), then LATEST_READY — which is what gives a + // MAILBOX-less surface the same newest-wins behaviour, in the driver instead of + // in our glass gate. + vec![M::MAILBOX] + .into_iter() + .chain(flr) + .chain([M::FIFO_RELAXED, M::IMMEDIATE]) + .collect() + }; + if !pref.vsync { + chain.extend(flr); } + // FIFO ends every chain: the spec guarantees it exists, so there is always a landing. + chain.push(M::FIFO); + chain } /// `PUNKTFUNK_VRR_FIFO=1` — opt into the FIFO-first ladder for variable-refresh panels. @@ -881,7 +979,9 @@ mod tests { allow_vrr, fullscreen, vrr_fifo_opt_in: true, // the ladder under test; the DEFAULT is off (see below) + fifo_latest_ready: false, }; + let flr = fifo_latest_ready::MODE; // 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). @@ -896,8 +996,8 @@ mod tests { "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 — but ONLY when opted in. + // Tear-free + VRR allowed + fullscreen prefers the vblank-locked family — 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. @@ -907,14 +1007,62 @@ mod tests { allow_vrr: true, fullscreen: true, vrr_fifo_opt_in: false, + fifo_latest_ready: false, })[0], M::MAILBOX, - "the VRR ladder is opt-in until a VRR panel confirms the win" + "without a queue-free vblank mode the VRR ladder would lead with plain FIFO, \ + which measured ~27 ms worse — so it stays opt-in there" + ); + assert_eq!( + present_mode_chain(PresentPref { + vsync: true, + allow_vrr: true, + fullscreen: true, + vrr_fifo_opt_in: false, + fifo_latest_ready: true, + })[0], + fifo_latest_ready::MODE, + "with LATEST_READY available, following the panel costs 0.6 ms over MAILBOX \ + instead of 27 — cheap enough to be automatic" + ); + + // FIFO_LATEST_READY only appears where the device enabled it, and it outranks + // plain FIFO everywhere: it is FIFO's vblank pacing WITHOUT the queue, which is + // what a MAILBOX-less surface otherwise needs the software glass gate for. + let with_flr = |vsync, allow_vrr, fullscreen| PresentPref { + vsync, + allow_vrr, + fullscreen, + vrr_fifo_opt_in: true, + fifo_latest_ready: true, + }; + for p in [ + pref(true, false, false), + pref(true, true, true), + pref(false, true, true), + ] { + assert!( + !present_mode_chain(p).contains(&flr), + "never requested unless the device enabled the extension" + ); + } + let default_flr = present_mode_chain(with_flr(true, false, false)); + assert_eq!( + default_flr[0], + M::MAILBOX, + "MAILBOX still leads by measurement" + ); + assert_eq!(default_flr[1], flr, "then the driver-native newest-wins"); + assert!( + default_flr.iter().position(|m| *m == flr) + < default_flr.iter().position(|m| *m == M::FIFO), + "LATEST_READY must outrank plain FIFO — it is FIFO minus the standing queue" + ); + assert_eq!( + present_mode_chain(with_flr(true, true, true))[0], + flr, + "the VRR ladder takes the queue-free vblank mode first" ); - // 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 [ @@ -923,6 +1071,7 @@ mod tests { pref(true, false, true), pref(false, true, true), pref(false, false, false), + with_flr(true, false, false), ] { assert!( present_mode_chain(p).contains(&M::FIFO), diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index e24992d2..6445c987 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -107,9 +107,11 @@ 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. +windowed one is at the compositor's mercy) and is harmless on a fixed-refresh screen. It needs a +graphics driver that offers the modern queue-free display mode; on an older driver it does nothing +unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the +older way of following a panel costs noticeable latency 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/configuration.md b/docs-site/content/docs/configuration.md index a03b77d4..429d05ff 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -243,7 +243,7 @@ A few knobs are read by the native **clients**, not the host: | `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. | | `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. | | `PUNKTFUNK_PRESENTER` | `arrival` | Turn the frame-pacing engine off for this run: frames present the instant they decode, exactly as they did before the **Prioritize** setting existed. A diagnostic — if a pacing change is suspected of causing judder or added delay, this switches it off without reinstalling anything. Linux and Windows clients. | -| `PUNKTFUNK_VRR_FIFO` | `1` | Opt into the presentation mode intended for **variable-refresh (VRR / FreeSync / G-Sync) displays**, where each frame is shown as the panel is ready for it rather than on a fixed cadence. Off by default: on a *fixed*-refresh display this mode measured substantially worse (about 27 ms more display latency than the default), and its benefit on a real VRR panel has not yet been confirmed. Try it if you have a VRR display and run fullscreen; check the Detailed [stats overlay](/docs/stats) — `vrr yes` means the panel really is following the stream. Linux and Windows clients. | +| `PUNKTFUNK_VRR_FIFO` | `1` | Force the display mode used to follow a **variable-refresh (VRR / FreeSync / G-Sync)** screen, on graphics drivers too old to offer the modern one. You almost certainly don't need this: where the driver supports the modern mode — which is what **Follow variable refresh rate** in [client settings](/docs/client-settings#video) uses — following the panel is already automatic and costs almost nothing. On an older driver the only way to follow the panel is a mode that measured roughly 27 ms *worse* on a fixed-refresh screen, so it stays off unless you ask for it, and it's only worth asking if you genuinely have a VRR screen and play fullscreen. Check the Detailed [stats overlay](/docs/stats): `vrr yes` means the panel really is following the stream. Linux and Windows clients. | | `PUNKTFUNK_PRESENT_DEBUG` | `1` | Log the presenter's own 1-second summary (display mode, buffer drops, pacing counters) every second, even when nothing is going wrong. Without it the line appears only when there is something to report. | | `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `900000` | The startup link-capacity probe's burst target (default 2 Gbps — deliberately above any plausible link so the burst measures the link, not itself). Lower it on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. | | `PUNKTFUNK_ABR_PROBE` | `0` | Skip the startup link-capacity probe entirely. The adaptive-bitrate climb ceiling then stays at the negotiated starting rate — a blunt instrument; prefer `PUNKTFUNK_ABR_MAX_MBPS`. | diff --git a/docs-site/content/docs/stats.md b/docs-site/content/docs/stats.md index 460697b8..2a4b4952 100644 --- a/docs-site/content/docs/stats.md +++ b/docs-site/content/docs/stats.md @@ -151,7 +151,7 @@ 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`, …), `vrr yes`/`vrr no` once the + your screen: the display mode in use (`mailbox`, `fifo`, `fifo-latest-ready`, …), `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