feat(client/present): the desktop presenter gains the Apple/Android intent model
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -617,6 +617,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -77,6 +77,18 @@ pub struct SettingsOverlay {
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
/// 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub smooth_buffer: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vsync: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_vrr: Option<bool>,
|
||||
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
|
||||
/// load→save round-trip untouched.
|
||||
#[serde(flatten)]
|
||||
@@ -150,6 +162,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
|
||||
}
|
||||
|
||||
@@ -226,6 +250,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
|
||||
@@ -259,6 +295,10 @@ impl SettingsOverlay {
|
||||
"gamepad" => self.gamepad = 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
|
||||
@@ -455,6 +495,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());
|
||||
@@ -476,6 +520,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);
|
||||
@@ -573,6 +621,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() {
|
||||
|
||||
@@ -787,6 +787,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)]
|
||||
@@ -874,6 +913,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
|
||||
@@ -939,6 +1004,10 @@ fn default_mouse_mode() -> String {
|
||||
"capture".into()
|
||||
}
|
||||
|
||||
fn default_present_priority() -> String {
|
||||
"latency".into()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -970,6 +1039,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() {
|
||||
@@ -1007,6 +1082,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,
|
||||
@@ -1144,6 +1223,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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T> {
|
||||
capacity: usize,
|
||||
frames: VecDeque<T>,
|
||||
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<T> FrameStore<T> {
|
||||
pub(crate) fn new(capacity: usize) -> FrameStore<T> {
|
||||
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<T> {
|
||||
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<u32> = 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<u32> = 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<u32> = 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<u32> = 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");
|
||||
}
|
||||
}
|
||||
+247
-44
@@ -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<u64>,
|
||||
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<DecodedFrame>,
|
||||
/// 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<AtomicBool>,
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
// 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<Event> = Vec::new();
|
||||
if let Some(e) = first {
|
||||
@@ -1032,6 +1122,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
*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<Option<Outcome>
|
||||
presenter.set_hdr_metadata(m);
|
||||
}
|
||||
}
|
||||
let mut newest: Option<DecodedFrame> = 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<Option<Outcome>
|
||||
};
|
||||
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<Option<Outcome>
|
||||
// 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<Option<Outcome>
|
||||
}
|
||||
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<Option<Outcome>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<dyn Fn() + Send>) {
|
||||
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<present_timing::PresentedSample> {
|
||||
self.present_timer
|
||||
|
||||
@@ -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<Mutex<Option<Box<dyn Fn() + Send>>>>;
|
||||
|
||||
/// 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<mpsc::Sender<Job>>,
|
||||
/// 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<AtomicUsize>,
|
||||
results: Arc<Mutex<Vec<PresentedSample>>>,
|
||||
/// 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<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -52,7 +64,8 @@ impl PresentTimer {
|
||||
let (tx, rx) = mpsc::channel::<Job>();
|
||||
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<dyn Fn() + Send>) {
|
||||
*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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user