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() {