diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 0ff19c48..09ad8ef9 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -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), diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index c37e09fe..3ba1fa7e 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -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). diff --git a/crates/pf-client-core/src/orchestrate.rs b/crates/pf-client-core/src/orchestrate.rs index 2bef7dfc..a934bc9a 100644 --- a/crates/pf-client-core/src/orchestrate.rs +++ b/crates/pf-client-core/src/orchestrate.rs @@ -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 } } diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index d361df01..2ac4f33a 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -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, + /// 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, +} + +/// 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 = Vec::new(); + let mut last_applied_phase: Option = 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 = 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); diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index e9c7874b..678f2a8c 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -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, diff --git a/crates/pf-client-core/src/video_d3d11.rs b/crates/pf-client-core/src/video_d3d11.rs index 2a0160c1..f3737ae8 100644 --- a/crates/pf-client-core/src/video_d3d11.rs +++ b/crates/pf-client-core/src/video_d3d11.rs @@ -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 { + 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 = 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 = 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::() 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 { + 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, + }) +} diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index ec19ffa7..7a2320b1 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -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() } diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 7490800c..817499d4 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -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, + /// 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>, /// 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>, @@ -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 .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 { diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index 48742c8c..29440514 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -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))]