feat(session): Skia console UI — overlay contract + stats OSD/capture HUD (phase 4a)

The §6.1 presenter↔console-UI contract lands: pf-presenter exposes its
device (SharedDevice) and composites at most one premultiplied-alpha
quad per frame (new overlay.frag + LOAD render pass over the swapchain;
zero cost while the overlay returns None). pf-console-ui implements it
with skia-safe on the shared VkDevice: DirectContext via the ash
dispatch chain, a ring of two offscreen render targets (one-frame-in-
flight safe), damage-driven redraws — the OSD re-renders at 1 Hz, the
hint on capture toggles, nothing per-frame. Skia never touches the
swapchain. The session binary carries it behind the default ui feature:
4.9 MB stripped without, 10 MB with (measured).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 18:43:24 +02:00
parent 433a23da7a
commit 021a2261f6
15 changed files with 1031 additions and 42 deletions
+86 -21
View File
@@ -7,6 +7,7 @@
//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so).
use crate::input::Capture;
use crate::overlay::{FrameCtx, Overlay, OverlayFrame};
use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService;
@@ -31,6 +32,10 @@ pub struct SessionOpts {
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
/// binary's business — this loop stays store-agnostic).
pub on_connected: Option<Box<dyn FnMut([u8; 32])>>,
/// The console-UI overlay (§6.1) — `None` is the Skia-free power-user build (stats
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
/// warning rather than killing the session.
pub overlay: Option<Box<dyn Overlay>>,
}
pub enum Outcome {
@@ -68,7 +73,16 @@ where
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
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)?;
presenter.present(&window, FrameInput::Redraw, None)?;
let mut overlay = opts.overlay.take();
if let Some(o) = overlay.as_mut() {
if let Err(e) = o.init(&presenter.shared_device()) {
tracing::warn!(error = %format!("{e:#}"),
"console-UI overlay init failed — continuing without it");
overlay = None;
}
}
let gamepad_subsystem = sdl.gamepad().context("SDL gamepad")?;
let (gamepad, mut pump) = GamepadService::pumped(gamepad_subsystem);
@@ -119,6 +133,11 @@ where
// demotes the decoder to software via the shared flag — once per session.
let mut dmabuf_demoted = false;
let mut hw_fails = 0u32;
// The OSD's text (multi-line; rebuilt each Stats window) and the console-UI frame
// currently composited. The frame persists across presents within an iteration —
// the overlay only re-renders when `frame()` runs again (ring of two, see §6.1).
let mut osd_text = String::new();
let mut overlay_frame: Option<OverlayFrame> = None;
let outcome = 'main: loop {
// --- SDL events (input, window, gamepads) ---------------------------------------
@@ -134,6 +153,13 @@ where
queued.push(e);
}
for event in queued {
// The console UI sees input first: a consumed event (its menu is up) never
// reaches capture/forwarding. The OSD/HUD milestone consumes nothing.
if let Some(o) = overlay.as_mut() {
if o.handle_event(&event) {
continue;
}
}
match event {
Event::Quit { .. } => {
// Window close / SIGINT: deliberate user exit — QUIT_CLOSE_CODE so
@@ -155,10 +181,10 @@ where
}
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
presenter.recreate_swapchain(&window)?;
presenter.present(&window, FrameInput::Redraw)?;
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
WindowEvent::Exposed => {
presenter.present(&window, FrameInput::Redraw)?;
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
_ => {}
},
@@ -302,8 +328,9 @@ where
}
}
SessionEvent::Stats(s) => {
osd_text = stats_text(&mode_line, &s, &presented, hdr);
if print_stats {
print_stats_line(&mode_line, &s, &presented, hdr);
println!("stats: {}", osd_text.replace('\n', " | "));
}
}
SessionEvent::Failed { msg, trust_rejected } => {
@@ -320,6 +347,35 @@ where
}
}
// --- Console UI: damage-driven overlay re-render for this iteration --------------
if let Some(o) = overlay.as_mut() {
let (pw, ph) = window.size_in_pixels();
let hint = match &capture {
Some(cap) if !cap.captured() => Some(if gamepad.active().is_some() {
HINT_WITH_PAD
} else {
HINT_KEYBOARD
}),
_ => None,
};
let ctx = FrameCtx {
width: pw,
height: ph,
stats: (print_stats && connector.is_some() && !osd_text.is_empty())
.then_some(osd_text.as_str()),
hint,
};
match o.frame(&ctx) {
Ok(f) => overlay_frame = f,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"),
"overlay frame failed — disabling the console UI");
overlay = None;
overlay_frame = None;
}
}
}
// --- Frames: drain to the newest, upload + present -------------------------------
let mut newest: Option<DecodedFrame> = None;
while let Ok(f) = handle.frames.try_recv() {
@@ -334,11 +390,11 @@ where
let did_present = match image {
DecodedImage::Cpu(c) => {
hdr = c.color.is_pq();
presenter.present(&window, FrameInput::Cpu(&c))?
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
}
DecodedImage::Dmabuf(d) if presenter.supports_dmabuf() && !dmabuf_demoted => {
hdr = d.color.is_pq();
match presenter.present(&window, FrameInput::Dmabuf(d)) {
match presenter.present(&window, FrameInput::Dmabuf(d), overlay_frame.as_ref()) {
Ok(p) => {
hw_fails = 0;
p
@@ -406,6 +462,10 @@ where
};
handle.stop.store(true, Ordering::SeqCst);
// Overlay resources live on the presenter's device: quiesce the queue first, drop
// the overlay (its Drop destroys the Skia surfaces), THEN the presenter tears down.
presenter.wait_idle();
drop(overlay);
Ok(outcome)
}
@@ -417,32 +477,37 @@ struct PresentedWindow {
display_ms: f32,
}
/// One `stats:` line per 1 s window — the OSD's numbers (design/stats-unification.md) in
/// terminal form: headline end-to-end capture→displayed, then the per-stage p50s.
fn print_stats_line(mode_line: &str, s: &Stats, p: &PresentedWindow, hdr: bool) {
let mut line = format!(
"stats: {mode_line} · {:.0} fps · {:.1} Mb/s · {}{} · e2e {:.1}/{:.1} ms (p50/p95)",
/// The capture hints (`ui_stream` parity — the words the user reads while released).
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
fn stats_text(mode_line: &str, s: &Stats, p: &PresentedWindow, hdr: bool) -> String {
let mut text = format!(
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
s.fps,
s.mbps,
if s.decoder.is_empty() { "-" } else { s.decoder },
if hdr { " · HDR" } else { "" },
p.e2e_p50_ms,
p.e2e_p95_ms,
);
text.push_str(&format!(
"\ne2e {:.1}/{:.1} ms (p50/p95)",
p.e2e_p50_ms, p.e2e_p95_ms
));
if s.split {
line.push_str(&format!(
" · host {:.1} · net {:.1}",
s.host_ms, s.net_ms
));
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
} else {
line.push_str(&format!(" · host+net {:.1}", s.host_net_ms));
text.push_str(&format!(" · host+net {:.1}", s.host_net_ms));
}
line.push_str(&format!(
text.push_str(&format!(
" · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms
));
if s.lost > 0 {
line.push_str(&format!(" · lost {} ({:.1}%)", s.lost, s.lost_pct));
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
}
println!("{line}");
text
}