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:
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))]
|
||||
|
||||
Reference in New Issue
Block a user