feat(client): desktop phase-locked capture, a real display volume on Windows, and the console's missing rows

The cross-platform half of the gap sweep:

* Phase-locked capture reaches the DESKTOP clients (it shipped on
  Apple/Android only, though the desktop presenter has the best latch
  signal of all — true on-glass stamps via VK_KHR_present_wait). The
  presenter's 1 Hz fold publishes a latch grid (anchor = last on-glass
  instant; period = min positive present spacing, capped by the display
  mode's refresh so an arrival-paced sub-panel-rate stream can't claim a
  slower grid); the session pump folds every AU's arrival stamp against
  it with the SHARED `phase::circular_latch` statistic and sends the
  ~1 Hz PhaseReport (1 ms uncertainty — reference-client parity). The
  cap is advertised only when present timing is real
  (`VulkanDecodeDevice::present_timing` gates `SessionParams::phase_lock`),
  and the host's applied grid offset from the 0xCF tail is logged so an
  on-glass run can watch the controller engage.
* `Hello::display_hdr` stops being hardcoded `None`: Windows reads the
  panel's colour volume from DXGI (`IDXGIOutput6::GetDesc1`, the
  `--window-pos` output else the primary, advanced-color outputs only,
  gated on the HDR setting) so the host's virtual-display EDID matches
  the real glass. Linux keeps the EDID defaults — no portable
  Wayland/X11 query exists — and the comment now says exactly that.
* The console settings screen (the ONLY editor in Gaming Mode) learns
  the rows it was missing: render scale, full chroma 4:4:4, invert
  scroll, capture system shortcuts, fullscreen-on-stream, auto-wake and
  the game-library toggle.
* A spec-run session's device picks (GPU adapter, speaker, microphone)
  now come from the `--resolved-spec` instead of a raw Settings load —
  the last store read the spec path still owed (§5), and what would
  make those fields profileable.
* `session_args()` documents why the GTK/CLI spawners pass no
  `--window-pos` (Wayland exposes no global coordinates to read and SDL
  can't apply them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 22:05:24 +02:00
co-authored by Claude Fable 5
parent fd75e66041
commit 2ce0bea830
9 changed files with 337 additions and 11 deletions
+36 -7
View File
@@ -218,6 +218,8 @@ mod session_main {
height: sh,
..mode
};
// Before the struct literal — `vulkan` moves into it below.
let phase_lock = vulkan.as_ref().is_some_and(|v| v.present_timing);
SessionParams {
host: addr,
port,
@@ -267,9 +269,19 @@ mod session_main {
} else {
0
},
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
// pump) pins one manually.
// This panel's HDR colour volume → the host's virtual-display EDID, so host
// apps tone-map to the real glass. Windows reads it from DXGI (the
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
// setting, since with 10-bit/HDR unadvertised above the volume is noise. No
// portable Wayland/X11 query exists yet, so Linux keeps the host's EDID
// defaults; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session pump) pins one
// manually on either OS and wins over both.
#[cfg(windows)]
display_hdr: settings
.hdr_enabled
.then(|| pf_client_core::video_d3d11::display_hdr_volume(window_pos()))
.flatten(),
#[cfg(not(windows))]
display_hdr: None,
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
// channel); capture-mode sessions keep the composited cursor, so only advertise
@@ -289,6 +301,13 @@ mod session_main {
connect_timeout: connect_timeout(),
force_software,
profile,
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
// advertised only when the presenter has real on-glass latch stamps
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
// grid itself is written by the presenter (run_session clones the Arc out of
// these params) and folded into ~1 Hz PhaseReports by the session pump.
phase_lock,
latch_grid: std::sync::Arc::new(pf_client_core::session::LatchGrid::default()),
}
}
@@ -443,11 +462,21 @@ mod session_main {
// The Settings device picks → env, unless the user already forced one by hand:
// the GPU (the shells' pickers store the adapter's marketing name) for the
// presenter's device selection, and the audio endpoints (PipeWire node names)
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
// the RADV knob (covers --connect and --browse).
// presenter's device selection, and the audio endpoints (PipeWire node names /
// WASAPI endpoint ids) for the playback/mic streams. Before any Vulkan call,
// like the RADV knob (covers --connect and --browse).
//
// Spec mode takes them from the SPEC's settings — the spawner's resolve — which
// keeps the §5 zero-store-reads invariant and lets a profile overlay reach these
// fields if they ever become profileable. Parsed leniently here (the `--connect`
// flow re-reads the spec authoritatively and errors there); the compat path and
// `--browse` (which never carries a spec) still load the store.
{
let s = trust::Settings::load();
let s = arg_value("--resolved-spec")
.and_then(|p| {
pf_client_core::orchestrate::ResolvedSpec::read(std::path::Path::new(&p)).ok()
})
.map_or_else(trust::Settings::load, |spec| spec.settings);
for (var, value) in [
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
+2
View File
@@ -68,6 +68,8 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
"d3dcommon",
"dxgi",
"handleapi",
# RECT/HMONITOR for DXGI_OUTPUT_DESC1 (the display-HDR volume query).
"windef",
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES.
"minwinbase",
# The GlobalAlloc block the clipboard takes ownership of (clipboard.rs).
+6
View File
@@ -230,6 +230,12 @@ impl ConnectPlan {
if self.settings.fullscreen_on_stream {
args.push("--fullscreen".into());
}
// Deliberately NO `--window-pos` here. The Windows shell appends its own (its
// window's desktop coordinates place the session on the same monitor), but on
// Wayland neither GTK can read global window coordinates nor can SDL apply
// them — the compositor owns placement — so from the GTK/CLI spawners the flag
// would be a silent no-op everywhere it matters. X11 could carry it, but a
// Linux-only special case that most Linux sessions ignore isn't worth the drift.
args
}
}
+90 -2
View File
@@ -78,6 +78,30 @@ pub struct SessionParams {
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
/// re-reading any store (design/client-settings-profiles.md §5.2).
pub profile: Option<String>,
/// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass
/// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid),
/// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to
/// (design/phase-locked-capture.md — previously Apple/Android only). Never set without
/// present timing: the host arms on report receipt, but the Hello should say what the
/// client actually does.
pub phase_lock: bool,
/// The presenter-written latch grid the pump's reports are computed from.
pub latch_grid: Arc<LatchGrid>,
}
/// The presenter's display-latch grid, shared presenter → pump (the `force_software`
/// pattern in the other direction): the presenter's 1 Hz present-timing fold writes a
/// recent on-glass latch instant plus the panel period; the pump's stats window folds its
/// per-AU arrival stamps against them into the ~1 Hz `PhaseReport`. All zeros until the
/// first fold — and forever when present timing isn't available — so the pump simply
/// stays quiet then.
#[derive(Default)]
pub struct LatchGrid {
/// A recent on-glass latch instant (client `CLOCK_REALTIME` ns — the same domain as
/// the AU arrival stamps). Any grid point works; the report extrapolates forward.
pub anchor_ns: std::sync::atomic::AtomicU64,
/// The panel's latch period (ns). `0` = no grid yet.
pub period_ns: std::sync::atomic::AtomicU64,
}
/// The session pump's share of the unified stats window (design/stats-unification.md):
@@ -282,11 +306,17 @@ fn pump(
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
if params.cursor_forward {
// CURSOR: this embedder renders the host cursor locally in desktop mouse mode.
// PHASE_LOCK: the presenter has real latch stamps and the pump reports them below.
(if params.cursor_forward {
punktfunk_core::quic::CLIENT_CAP_CURSOR
} else {
0
},
}) | (if params.phase_lock {
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
} else {
0
}),
// Slice-progressive delivery: off — this presenter feeds FFmpeg whole AUs; a partial
// avcodec feed path can flip it later.
false,
@@ -412,6 +442,13 @@ fn pump(
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
let clock_offset_live = connector.clock_offset_shared();
// Phase-lock (advertised above): every received AU's arrival stamp, folded per stats
// window against the presenter's latch grid into the ~1 Hz PhaseReport. Desktop
// sessions receive whole AUs only (no frame parts), so every arrival counts — the
// reference reporters (Apple/Android) sample the same signal. 256 ≈ 2 s at 120 Hz.
let latch_grid = params.latch_grid.clone();
let mut phase_arrivals: Vec<u64> = Vec::new();
let mut last_applied_phase: Option<i32> = None;
// PUNKTFUNK_DEBUG_RECONFIGURE=WxH@HZ:SECS — lab lever: request ONE mid-stream mode
// switch N seconds in, so a headless session (no window manager to drag a window in)
// can exercise the resize path deterministically — host pipeline rebuild, decoder
@@ -501,6 +538,9 @@ fn pump(
} else {
now_ns()
};
if params.phase_lock && phase_arrivals.len() < 256 {
phase_arrivals.push(received_ns);
}
// fps / goodput count every received AU (spec), decoded or not.
frames_n += 1;
bytes_n += frame.data.len() as u64;
@@ -754,6 +794,19 @@ fn pump(
// host never emits any — the deque fills to its cap and the OSD keeps the
// combined `host+network` stage.
while let Ok(t) = connector.next_host_timing(Duration::ZERO) {
// Phase-lock closed loop: the host's applied grid offset rides the 0xCF tail.
// Log transitions so an on-glass run can watch the controller engage/settle
// (the Android reporter's parity log).
if params.phase_lock
&& t.applied_phase_ns.is_some()
&& t.applied_phase_ns != last_applied_phase
{
last_applied_phase = t.applied_phase_ns;
tracing::info!(
applied_phase_ns = t.applied_phase_ns.unwrap_or(0),
"host phase-lock: applied capture-grid offset"
);
}
if let Some(i) = pending_split.iter().position(|(p, _)| *p == t.pts_ns) {
let (_, hn_us) = pending_split.remove(i).unwrap();
host_us_win.push(t.host_us as u64);
@@ -796,6 +849,41 @@ fn pump(
}
if window_start.elapsed() >= Duration::from_secs(1) {
// Phase-lock report (~1 Hz, riding the stats window — the reference reporters'
// cadence): this window's arrival leads before the presenter's latch grid,
// folded with the SHARED circular statistic (the host controller was tuned
// against it). Quiet until the presenter has a grid (period 0 — no
// present-timing samples yet) or the window is thin (< 8 arrivals —
// `circular_latch` declines). 1 ms uncertainty = Apple/Android parity.
if params.phase_lock {
let period = latch_grid.period_ns.load(Ordering::Relaxed);
let anchor = latch_grid.anchor_ns.load(Ordering::Relaxed);
if period > 0 && anchor > 0 {
let leads_us: Vec<u64> = phase_arrivals
.iter()
.map(|a| {
((anchor as i128 - *a as i128).rem_euclid(period as i128) / 1000) as u64
})
.collect();
if let Some((lead_ns, coherence)) =
punktfunk_core::phase::circular_latch(&leads_us, period as i64)
{
// Extrapolate the (possibly ~1 s old) anchor to the next latch at
// or after now, then express it on the host clock.
let (now, p, a) = (now_ns() as i128, period as i128, anchor as i128);
let k = ((now - a).max(0) + p - 1) / p;
let offset = clock_offset_live.load(Ordering::Relaxed) as i128;
connector.report_phase(
(a + k * p + offset).max(0) as u64,
period.min(u32::MAX as u64) as u32,
1_000_000,
lead_ns.min(u32::MAX as u64) as u32,
coherence,
);
}
}
phase_arrivals.clear();
}
let secs = window_start.elapsed().as_secs_f32();
let (hn_p50, _) = window_percentiles(&mut hostnet_us);
let (dec_p50, _) = window_percentiles(&mut decode_us);
+5
View File
@@ -876,6 +876,10 @@ pub struct VulkanDecodeDevice {
/// features). The bundle now exists even without it — Windows D3D11 interop rides the
/// same struct — so consumers gate the FFmpeg-Vulkan decoder on THIS, not on `Some`.
pub video_decode: bool,
/// The presenter has REAL on-glass present timing (`VK_KHR_present_wait` — its
/// `PresentTimer` runs). Gates the `CLIENT_CAP_PHASE_LOCK` advertisement: without a
/// true latch stamp the desktop has no latch grid and must not claim the cap.
pub present_timing: bool,
/// PyroWave decode (the wired-LAN wavelet codec) is usable: Vulkan 1.3 + the compute
/// features its kernels need were present AND enabled at device creation
/// (`shaderInt16`, `storageBuffer8BitAccess`, subgroup size control). Gates the
@@ -987,6 +991,7 @@ mod tests {
queue_families: Vec::new(),
pyrowave_decode: false,
video_decode: true,
present_timing: false,
d3d11_import: false,
d3d11_hdr10: false,
adapter_luid: None,
+84
View File
@@ -898,3 +898,87 @@ fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32,
);
}
}
/// This desktop's HDR colour volume (`IDXGIOutput6::GetDesc1`) → the Hello's
/// `display_hdr`, so the host's virtual-display EDID matches THIS panel instead of its
/// generic defaults (host apps then tone-map to the real glass). `pos` picks the output
/// containing that desktop point — the `--window-pos` monitor, where the stream window
/// will open; no `pos` or no match falls back to the output holding the desktop origin
/// (the primary). Returns `None` when that output's advanced color is off (an SDR
/// colorspace): claiming an HDR volume for a desktop that won't present HDR would steer
/// host tone mapping wrong, and the host's EDID defaults are the honest answer there.
/// (`PUNKTFUNK_CLIENT_PEAK_NITS` still overrides whatever this reports — see
/// `punktfunk_core::client::display_hdr_env_override`.)
pub fn display_hdr_volume(pos: Option<(i32, i32)>) -> Option<punktfunk_core::quic::HdrMeta> {
use windows::Win32::dxgi::{IDXGIOutput6, DXGI_OUTPUT_DESC1};
// SAFETY: plain DXGI factory creation — no arguments to get wrong; the returned
// interface is owned by this scope and dropped with it.
let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?;
let mut fallback: Option<DXGI_OUTPUT_DESC1> = None;
for a in 0.. {
// SAFETY: read-only enumeration on the live factory; the returned adapter is
// owned by this scope.
let Ok(adapter) = (unsafe { factory.EnumAdapters1(a) }) else {
break;
};
for o in 0.. {
// Out-pointer convention in this windows-rs rev (no retval annotation).
let mut output: Option<windows::Win32::dxgi::IDXGIOutput> = None;
// SAFETY: read-only enumeration on the live adapter, writing a local
// out-pointer that outlives the call.
if unsafe { adapter.EnumOutputs(o, &mut output) }.ok().is_err() {
break;
}
let Some(output) = output else {
break;
};
let Ok(out6) = output.cast::<IDXGIOutput6>() else {
continue; // pre-1809 DXGI — no advanced-color facts to read
};
let mut desc = DXGI_OUTPUT_DESC1::default();
// SAFETY: fills a local, correctly-sized DXGI_OUTPUT_DESC1 that outlives
// the call; the interface is live (owned just above).
if unsafe { out6.GetDesc1(&mut desc) }.ok().is_err() {
continue;
}
let r = desc.DesktopCoordinates;
let contains =
|x: i32, y: i32| x >= r.left && x < r.right && y >= r.top && y < r.bottom;
if let Some((x, y)) = pos {
if contains(x, y) {
return hdr_meta_from_output(&desc);
}
}
if fallback.is_none() || contains(0, 0) {
fallback = Some(desc);
}
}
}
hdr_meta_from_output(&fallback?)
}
/// The ST.2086 shape of one output's colour facts; `None` for an SDR colorspace.
fn hdr_meta_from_output(
d: &windows::Win32::dxgi::DXGI_OUTPUT_DESC1,
) -> Option<punktfunk_core::quic::HdrMeta> {
use windows::Win32::dxgi::DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020;
if d.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
return None;
}
// Chromaticity → 1/50000 units; luminance → 0.0001 cd/m² units (the HdrMeta contract).
let c = |v: [f32; 2]| {
[
(v[0] * 50_000.0).round().clamp(0.0, 65_535.0) as u16,
(v[1] * 50_000.0).round().clamp(0.0, 65_535.0) as u16,
]
};
Some(punktfunk_core::quic::HdrMeta {
// ST.2086 primary order is G, B, R (see the HdrMeta docs); DXGI reports R/G/B.
display_primaries: [c(d.GreenPrimary), c(d.BluePrimary), c(d.RedPrimary)],
white_point: c(d.WhitePoint),
max_display_mastering_luminance: (f64::from(d.MaxLuminance) * 10_000.0) as u32,
min_display_mastering_luminance: (f64::from(d.MinLuminance) * 10_000.0) as u32,
max_cll: d.MaxLuminance.round().clamp(0.0, 65_535.0) as u16,
max_fall: d.MaxFullFrameLuminance.round().clamp(0.0, 65_535.0) as u16,
})
}
+79 -1
View File
@@ -19,35 +19,54 @@ use skia_safe::{Canvas, Rect};
enum RowId {
Resolution,
Refresh,
RenderScale,
Bitrate,
Compositor,
Codec,
Decoder,
Hdr,
Chroma444,
Audio,
Mic,
Pad,
PadType,
Touch,
Mouse,
InvertScroll,
Shortcuts,
Stats,
Fullscreen,
AutoWake,
Library,
}
const ROWS: [RowId; 14] = [
// The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle all
// were). Still deliberately smaller than the desktop dialogs — device pickers
// (GPU/speaker/mic) and the profile catalog stay desktop-only.
const ROWS: [RowId; 21] = [
RowId::Resolution,
RowId::Refresh,
RowId::RenderScale,
RowId::Bitrate,
RowId::Compositor,
RowId::Codec,
RowId::Decoder,
RowId::Hdr,
RowId::Chroma444,
RowId::Audio,
RowId::Mic,
RowId::Pad,
RowId::PadType,
RowId::Touch,
RowId::Mouse,
RowId::InvertScroll,
RowId::Shortcuts,
RowId::Stats,
RowId::Fullscreen,
RowId::AutoWake,
RowId::Library,
];
const RESOLUTIONS: [(u32, u32); 6] = [
@@ -59,6 +78,8 @@ const RESOLUTIONS: [(u32, u32); 6] = [
(3840, 2160),
];
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
/// Mirrors [`punktfunk_core::render_scale::PRESETS`] (and the desktop pickers).
const RENDER_SCALES: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
const COMPOSITORS: [(&str, &str); 5] = [
("auto", "Automatic"),
@@ -220,6 +241,17 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
format!("{} Hz", s.refresh_hz)
},
),
RowId::RenderScale => (
None,
"Render scale",
if s.render_scale == 1.0 {
"Native".into()
} else if s.render_scale > 1.0 {
format!("{}× (supersample)", s.render_scale)
} else {
format!("{}×", s.render_scale)
},
),
RowId::Bitrate => (
None,
"Bitrate",
@@ -241,6 +273,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
),
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
RowId::Audio => (
Some("Audio"),
"Audio channels",
@@ -274,11 +307,24 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
s.touch_mode().label().into(),
),
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()),
RowId::Shortcuts => (
None,
"Capture system shortcuts",
on_off(s.inhibit_shortcuts).into(),
),
RowId::Stats => (
Some("Interface"),
"Statistics overlay",
s.stats_verbosity().label().into(),
),
RowId::Fullscreen => (
None,
"Start streams fullscreen",
on_off(s.fullscreen_on_stream).into(),
),
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
};
RowSpec {
header,
@@ -298,6 +344,10 @@ fn detail(id: RowId) -> &'static str {
Match window follows this window, including mid-stream resizes."
}
RowId::Refresh => "Native follows the display this window is on.",
RowId::RenderScale => {
"The host renders larger or smaller than the stream mode and this window \
resamples above 1× supersamples, below saves bandwidth."
}
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
RowId::Compositor => {
"Which compositor drives the virtual output — honored only if available on the host."
@@ -307,6 +357,10 @@ fn detail(id: RowId) -> &'static str {
RowId::Hdr => {
"HDR10 — engages when the host sends HDR content and this display supports it."
}
RowId::Chroma444 => {
"Full-colour video: crisp small text and thin lines, at more bandwidth. \
HEVC only, and only where the host can encode it."
}
RowId::Audio => "The speaker layout requested from the host.",
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
@@ -320,10 +374,21 @@ fn detail(id: RowId) -> &'static str {
for games), Desktop leaves it free and sends absolute positions. \
Ctrl+Alt+Shift+M switches live while streaming."
}
RowId::InvertScroll => "Reverses the wheel and trackpad scroll direction sent to the host.",
RowId::Shortcuts => {
"Alt+Tab, Super and friends reach the host while input is captured. \
Off, they act on this device instead."
}
RowId::Stats => {
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
Ctrl+Alt+Shift+S cycles it live while streaming."
}
RowId::Fullscreen => "Streams open fullscreen instead of windowed.",
RowId::AutoWake => {
"Send Wake-on-LAN to a sleeping host before connecting. Turn off for hosts \
reached over a VPN, where the wake wait only adds delay."
}
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
}
}
@@ -368,6 +433,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
let cur = REFRESH.iter().position(|r| *r == s.refresh_hz);
step_option(cur, REFRESH.len(), delta, wrap).map(|i| s.refresh_hz = REFRESH[i])
}
RowId::RenderScale => {
// Exact float compare is fine: every writer (here and the desktop pickers)
// stores one of these literals; a hand-edited oddball snaps to the first step.
let cur = RENDER_SCALES.iter().position(|v| *v == s.render_scale);
step_option(cur, RENDER_SCALES.len(), delta, wrap)
.map(|i| s.render_scale = RENDER_SCALES[i])
}
RowId::Bitrate => {
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
@@ -376,6 +448,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
RowId::Audio => {
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
@@ -400,6 +473,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, MouseMode::ALL.len(), delta, wrap)
.map(|i| s.mouse_mode = MouseMode::ALL[i].as_name().to_string())
}
RowId::InvertScroll => toggle(&mut s.invert_scroll, delta, wrap),
RowId::Shortcuts => toggle(&mut s.inhibit_shortcuts, delta, wrap),
RowId::Stats => {
let cur = StatsVerbosity::ALL
.iter()
@@ -407,6 +482,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
}
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
}
.is_some()
}
+32 -1
View File
@@ -193,6 +193,10 @@ struct StreamState {
/// The settings profile this session resolved with, for the stats overlay's first line
/// ("which profile am I on?"). `None` = the global defaults, and nothing is shown.
profile: Option<String>,
/// The latch grid the pump's PhaseReports read (see `session::LatchGrid`), written by
/// the 1 Hz present-timing fold. `None` = the session didn't advertise phase lock
/// (no present-wait, or an embedder that opted out).
latch_grid: Option<Arc<session::LatchGrid>>,
/// Live host↔client clock offset handle (None until Connected): loaded per present so
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
@@ -269,6 +273,10 @@ impl StreamState {
wake: sdl3::event::EventSender,
) -> StreamState {
let profile = params.profile.clone();
// The presenter's half of phase-locked capture: it writes the latch grid the
// pump reads (see `LatchGrid`), so keep the Arc before the params move. `None`
// when the session didn't advertise the cap — the 1 Hz fold then skips the work.
let latch_grid = params.phase_lock.then(|| params.latch_grid.clone());
let handle = session::start(params);
let (wake_tx, wake_rx) = async_channel::bounded(2);
let pump_rx = handle.frames.clone();
@@ -294,6 +302,7 @@ impl StreamState {
ready_announced: false,
mode_line: String::new(),
profile,
latch_grid,
clock_offset: None,
hdr: false,
win_e2e_us: Vec::with_capacity(256),
@@ -1458,7 +1467,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
for s in presenter.take_presented_samples() {
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 {
+3
View File
@@ -424,6 +424,9 @@ impl Presenter {
queue_families: queue_info.iter().map(|q| q.queue_family_index).collect(),
pyrowave_decode: pyrowave_ok,
video_decode: video_ok,
// The phase-lock gate: real on-glass latch stamps exist only when the
// present-wait timer runs (see `PresentTimer`).
present_timing: present_timer.is_some(),
#[cfg(windows)]
d3d11_import: win_capable,
#[cfg(not(windows))]