From 89d45f2a55f8f41d0df6352d6e5605c128cd6fbb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 16:55:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20Vulkan=20session=20binary=20?= =?UTF-8?q?=E2=80=94=20SDL3=20+=20ash=20presenter=20MVP=20(phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit punktfunk-session streams one --connect session in an SDL3 window: ash swapchain with a transfer-only letterboxed blit of the software-decode path (no graphics pipeline until the phase-2 dmabuf/CSC pass), the ui_stream input-capture state machine on SDL events (scancode→VK table cross-checked against the evdev one), gamepads via a new caller-pumped GamepadService mode (SDL video owns the main thread here), and the shell↔session stdout contract: {"ready":true}, per-window stats: lines, JSON error + exit codes 0/2/3/4. Strict trust — no pin, no connect. Design: punktfunk-planning linux-client-rearchitecture.md. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 26 + Cargo.toml | 2 + clients/linux-session/Cargo.toml | 22 + clients/linux-session/README.md | 28 + clients/linux-session/src/main.rs | 231 ++++++++ crates/pf-client-core/src/gamepad.rs | 141 ++++- crates/pf-presenter/Cargo.toml | 24 + crates/pf-presenter/src/input.rs | 183 +++++++ crates/pf-presenter/src/keymap_sdl.rs | 201 +++++++ crates/pf-presenter/src/lib.rs | 21 + crates/pf-presenter/src/run.rs | 415 ++++++++++++++ crates/pf-presenter/src/vk.rs | 747 ++++++++++++++++++++++++++ 12 files changed, 2014 insertions(+), 27 deletions(-) create mode 100644 clients/linux-session/Cargo.toml create mode 100644 clients/linux-session/README.md create mode 100644 clients/linux-session/src/main.rs create mode 100644 crates/pf-presenter/Cargo.toml create mode 100644 crates/pf-presenter/src/input.rs create mode 100644 crates/pf-presenter/src/keymap_sdl.rs create mode 100644 crates/pf-presenter/src/lib.rs create mode 100644 crates/pf-presenter/src/run.rs create mode 100644 crates/pf-presenter/src/vk.rs diff --git a/Cargo.lock b/Cargo.lock index f3b3be26..ebf0a88b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,19 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "pf-presenter" +version = "0.8.2" +dependencies = [ + "anyhow", + "ash", + "async-channel", + "pf-client-core", + "punktfunk-core", + "sdl3", + "tracing", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2956,6 +2969,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "punktfunk-client-session" +version = "0.8.2" +dependencies = [ + "anyhow", + "pf-client-core", + "pf-presenter", + "punktfunk-core", + "tracing", + "tracing-subscriber", +] + [[package]] name = "punktfunk-client-windows" version = "0.8.2" @@ -3703,6 +3728,7 @@ version = "0.6.6+SDL-3.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7" dependencies = [ + "ash", "cc", "cmake", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 1971c5e4..7ba058b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +6,11 @@ members = [ "crates/punktfunk-host/vendor/usbip-sim", "crates/punktfunk-tray", "crates/pf-client-core", + "crates/pf-presenter", "crates/pf-driver-proto", "clients/probe", "clients/linux", + "clients/linux-session", "clients/windows", "clients/android/native", "tools/latency-probe", diff --git a/clients/linux-session/Cargo.toml b/clients/linux-session/Cargo.toml new file mode 100644 index 00000000..63d524db --- /dev/null +++ b/clients/linux-session/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "punktfunk-client-session" +description = "punktfunk/1 Vulkan session binary — SDL3 window, ash presenter, terminal stats; the power-user / gamescope stream client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[[bin]] +name = "punktfunk-session" +path = "src/main.rs" + +# Same Linux gating as the rest of the client stack; elsewhere this is a stub binary. +[target.'cfg(target_os = "linux")'.dependencies] +pf-presenter = { path = "../../crates/pf-presenter" } +pf-client-core = { path = "../../crates/pf-client-core" } +punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] } +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/clients/linux-session/README.md b/clients/linux-session/README.md new file mode 100644 index 00000000..3b1d5194 --- /dev/null +++ b/clients/linux-session/README.md @@ -0,0 +1,28 @@ +# punktfunk-session + +The Vulkan session binary: one stream per invocation in an SDL3 window — no UI toolkit, +no widgets, terminal stats. The power-user / gamescope stream client, and the stage-2 +presenter of the Linux client re-architecture (punktfunk-planning: +`linux-client-rearchitecture.md`). + +``` +punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats] +``` + +Reads the same identity / known-hosts / settings stores as the desktop client +(`punktfunk-client`) — pair there (or via its headless `--pair`) first; this binary never +connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store). + +Stdout is the machine interface: `{"ready":true}` after the first presented frame, +`stats: …` once per second (Ctrl+Alt+Shift+S toggles, `--stats` forces on), one +`{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0` +clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter +init failed. + +In-stream keys match the desktop client: click captures input (Ctrl+Alt+Shift+Q +releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller escape +chord (L1+R1+Start+Select, hold to disconnect) works the same. + +Current phase (1): software decode presented via a transfer-only blit — request path +`--connect` only. The VAAPI dmabuf → Vulkan zero-copy import, HDR/P010, and the Skia +console UI (`--browse`) are later phases of the plan. diff --git a/clients/linux-session/src/main.rs b/clients/linux-session/src/main.rs new file mode 100644 index 00000000..750042eb --- /dev/null +++ b/clients/linux-session/src/main.rs @@ -0,0 +1,231 @@ +//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning +//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS +//! the power-user CLI build). +//! +//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`, +//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity +//! / known-hosts / settings stores as the GTK client (`punktfunk-client`), so pairing +//! there (or via its headless `--pair`) makes this binary connect silently. +//! +//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after +//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` / +//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end, +//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed. + +#[cfg(target_os = "linux")] +mod session_main { + use pf_client_core::session::SessionParams; + use pf_client_core::trust; + use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; + use std::time::Duration; + + pub const EXIT_CONNECT_FAILED: u8 = 2; + pub const EXIT_TRUST_REJECTED: u8 = 3; + pub const EXIT_PRESENTER_FAILED: u8 = 4; + + /// The value following `flag` in argv, if present (`--flag value`). + fn arg_value(flag: &str) -> Option { + std::env::args() + .skip_while(|a| a != flag) + .nth(1) + .filter(|v| !v.starts_with("--")) + } + + fn arg_flag(flag: &str) -> bool { + std::env::args().any(|a| a == flag) + } + + /// `host[:port]`, port defaulting to the native 9777. + fn parse_host_port(target: &str) -> (String, u16) { + match target.rsplit_once(':') { + Some((a, p)) => match p.parse() { + Ok(port) => (a.to_string(), port), + Err(_) => { + eprintln!("--connect: unparsable port in '{target}', using default 9777"); + (a.to_string(), 9777) + } + }, + None => (target.to_string(), 9777), + } + } + + /// One JSON status line on stdout (the shell parses these; strings hand-escaped via + /// the minimal rules a reason string can need). + fn json_line(key: &str, msg: &str, trust_rejected: Option) { + let escaped: String = msg + .chars() + .flat_map(|c| match c { + '"' => vec!['\\', '"'], + '\\' => vec!['\\', '\\'], + '\n' => vec!['\\', 'n'], + c if (c as u32) < 0x20 => vec![' '], + c => vec![c], + }) + .collect(); + match trust_rejected { + Some(t) => println!("{{\"{key}\":\"{escaped}\",\"trust_rejected\":{t}}}"), + None => println!("{{\"{key}\":\"{escaped}\"}}"), + } + } + + pub fn run() -> u8 { + // Logs to STDERR — stdout is the machine interface (ready/stats/error lines). + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + // Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming + // every pad Steam Input has virtualized; capturing the Deck's real built-in + // controller needs it cleared (same rationale as the GTK client's `app::run`). + for var in [ + "SDL_GAMECONTROLLER_IGNORE_DEVICES", + "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT", + ] { + if let Ok(v) = std::env::var(var) { + tracing::info!(var, value = %v, "clearing Steam's SDL device filter"); + std::env::remove_var(var); + } + } + + let Some(target) = arg_value("--connect") else { + eprintln!( + "usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\ + \n\ + Streams from a paired punktfunk host in a Vulkan window. Pair first via the\n\ + desktop client or `punktfunk-client --pair --connect host[:port]` —\n\ + this binary never connects to a host it has no pinned fingerprint for." + ); + return EXIT_CONNECT_FAILED; + }; + let (addr, port) = parse_host_port(&target); + + let identity = match trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + json_line("error", &format!("client identity: {e:#}"), None); + return EXIT_CONNECT_FAILED; + } + }; + let settings = trust::Settings::load(); + + // Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin + // connects silently; an unknown host is REFUSED — there is no dialog here, and a + // silent TOFU would defeat the pinning model. Pair via the desktop client. + let known = trust::KnownHosts::load(); + let known_host = known.hosts.iter().find(|h| h.addr == addr && h.port == port); + let pin = arg_value("--fp") + .as_deref() + .and_then(trust::parse_hex32) + .or_else(|| known_host.and_then(|h| trust::parse_hex32(&h.fp_hex))); + let Some(pin) = pin else { + json_line( + "error", + &format!( + "no pinned fingerprint for {addr}:{port} — pair first \ + (punktfunk-client --pair --connect {addr}:{port}) or pass --fp HEX" + ), + Some(true), + ); + return EXIT_TRUST_REJECTED; + }; + + let host_label = known_host.map_or_else(|| addr.clone(), |h| h.name.clone()); + let launch = arg_value("--launch"); + let title = launch + .clone() + .map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}")); + + let fullscreen = arg_flag("--fullscreen") + || std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some(); + + let opts = pf_presenter::SessionOpts { + window_title: format!("Punktfunk · {title}"), + fullscreen, + print_stats: settings.show_stats || arg_flag("--stats"), + json_status: true, + on_connected: Some(Box::new(|fingerprint: [u8; 32]| { + // This host's card carries the accent bar in the desktop client now. + trust::touch_last_used(&trust::hex(&fingerprint)); + })), + }; + + let outcome = pf_presenter::run_session(opts, move |gamepad, native, force_software| { + // Explicit settings, `0` fields resolved to the window's display (the GTK + // client reads the monitor under its window — same contract). + let mode = Mode { + width: if settings.width == 0 { native.width } else { settings.width }, + height: if settings.width == 0 { native.height } else { settings.height }, + refresh_hz: if settings.refresh_hz == 0 { + native.refresh_hz.max(30) + } else { + settings.refresh_hz + }, + }; + SessionParams { + host: addr, + port, + mode, + compositor: CompositorPref::from_name(&settings.compositor) + .unwrap_or(CompositorPref::Auto), + gamepad: match GamepadPref::from_name(&settings.gamepad) { + Some(GamepadPref::Auto) | None => gamepad.auto_pref(), + Some(explicit) => explicit, + }, + bitrate_kbps: settings.bitrate_kbps, + audio_channels: settings.audio_channels, + preferred_codec: settings.preferred_codec(), + mic_enabled: settings.mic_enabled, + // Phase 1 presents the software path only (the Vulkan dmabuf import is + // Phase 2) — request it outright instead of demoting after one frame. + // PUNKTFUNK_DECODER still overrides inside the decoder for bisects. + decoder: "software".into(), + launch, + pin: Some(pin), + identity, + connect_timeout: Duration::from_secs(15), + force_software, + } + }); + + match outcome { + Ok(pf_presenter::Outcome::Ended(None)) => 0, + Ok(pf_presenter::Outcome::Ended(Some(reason))) => { + // The host ending the session (game quit, host shutdown) is a normal end + // for a one-shot stream binary — report the reason, exit clean. + json_line("ended", &reason, None); + 0 + } + Ok(pf_presenter::Outcome::ConnectFailed { msg, trust_rejected }) => { + json_line("error", &msg, Some(trust_rejected)); + if trust_rejected { + EXIT_TRUST_REJECTED + } else { + EXIT_CONNECT_FAILED + } + } + Err(e) => { + json_line("error", &format!("presenter: {e:#}"), None); + EXIT_PRESENTER_FAILED + } + } + } + +} + +#[cfg(target_os = "linux")] +fn main() -> std::process::ExitCode { + std::process::ExitCode::from(session_main::run()) +} + +/// Vulkan/SDL3/PipeWire are Linux turf; this stub keeps `cargo build --workspace` green +/// elsewhere (the Mac client lives in clients/apple). +#[cfg(not(target_os = "linux"))] +fn main() { + eprintln!("punktfunk-session is Linux-only — the macOS client lives in clients/apple"); + std::process::exit(2); +} diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 930221e7..986f1ad6 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -339,7 +339,7 @@ impl GamepadService { if let Err(e) = std::thread::Builder::new() .name("punktfunk-gamepad".into()) .spawn(move || { - if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) { + if let Err(e) = run(p, a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) { tracing::warn!(error = %e, "gamepad service ended — pads disabled"); } }) @@ -356,6 +356,45 @@ impl GamepadService { } } + /// The caller-pumped variant for the session binary: SDL video+events live on ITS + /// main thread, and SDL only ever grants one thread the event queue — a second + /// `start()`-style worker thread could never see gamepad events there. The caller + /// owns the SDL context, feeds every polled event to [`GamepadPump::handle_event`], + /// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's + /// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback). + /// + /// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their + /// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled + /// for the duration of an attached session only. + pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) { + set_valve_hidapi(false); + let pads = Arc::new(Mutex::new(Vec::new())); + let active = Arc::new(Mutex::new(None)); + let (ctl, ctl_rx) = std::sync::mpsc::channel(); + let (escape_tx, escape_rx) = async_channel::unbounded(); + let (disconnect_tx, disconnect_rx) = async_channel::unbounded(); + let (menu_tx, menu_rx) = async_channel::unbounded(); + let worker = Worker::new( + subsystem, + pads.clone(), + active.clone(), + escape_tx, + disconnect_tx, + menu_tx, + ); + ( + GamepadService { + pads, + active, + ctl, + escape_rx, + disconnect_rx, + menu_rx, + }, + GamepadPump { worker, ctl_rx }, + ) + } + /// A receiver that yields one `()` each time the controller escape chord is pressed. /// A fresh clone per call (shared mpmc channel); the stream page spawns a future on it. pub fn escape_events(&self) -> async_channel::Receiver<()> { @@ -431,6 +470,32 @@ impl GamepadService { } } +/// The caller-pumped worker half of [`GamepadService::pumped`]: the session binary owns +/// SDL and its event loop; this just needs the events and a periodic tick. +pub struct GamepadPump { + worker: Worker, + ctl_rx: Receiver, +} + +impl GamepadPump { + /// Feed one polled SDL event. Non-gamepad events (window, keyboard, mouse) are + /// ignored, so the caller can forward everything unfiltered. + pub fn handle_event(&mut self, event: sdl3::event::Event) { + self.worker.handle_event(event); + } + + /// The per-wakeup polled work the threaded worker runs after each event wait: ctl + /// drain (attach/detach/pin/menu), the escape-chord hold check, menu repeat timing, + /// and rumble/HID feedback. Call once per loop iteration (≲30 ms cadence keeps + /// chord-hold and haptics inside the threaded worker's tolerances). + pub fn tick(&mut self) { + let _ = self.worker.drain_ctl(&self.ctl_rx); + self.worker.maybe_fire_disconnect(); + self.worker.menu_poll(); + self.worker.render_feedback(); + } +} + fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) { let _ = connector.send_input(&InputEvent { kind, @@ -528,11 +593,11 @@ impl Ds5Feedback { } } -struct Worker<'a> { +struct Worker { subsystem: sdl3::GamepadSubsystem, /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. - pads_out: &'a Mutex>, - active_out: &'a Mutex>, + pads_out: Arc>>, + active_out: Arc>>, /// The ONE device held open — the active pad while a session is attached, `None` /// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the /// hidraw device away from the system), so idle keeps this empty; see the module doc. @@ -578,7 +643,7 @@ struct Worker<'a> { menu_tx: async_channel::Sender, } -impl Worker<'_> { +impl Worker { fn active_id(&self) -> Option { // The pin matches by stable key (most recently connected wins if two identical pads // share one); an unmatched pin falls through to automatic without being cleared. @@ -1217,9 +1282,46 @@ impl Worker<'_> { } } +impl Worker { + /// The blank worker over an SDL gamepad subsystem — shared by the threaded service + /// (`run`) and the caller-pumped variant (`GamepadService::pumped`). + fn new( + subsystem: sdl3::GamepadSubsystem, + pads_out: Arc>>, + active_out: Arc>>, + escape_tx: async_channel::Sender<()>, + disconnect_tx: async_channel::Sender<()>, + menu_tx: async_channel::Sender, + ) -> Worker { + Worker { + subsystem, + pads_out, + active_out, + open: None, + order: Vec::new(), + pinned: None, + attached: None, + last_axis: [i32::MIN; 6], + held_buttons: Vec::new(), + held_touches: std::collections::HashSet::new(), + surface_last: [(0, 0, false); 2], + held_clicks: [false; 2], + last_accel: [0; 3], + escape_tx, + disconnect_tx, + chord_armed: false, + chord_since: None, + disconnect_fired: false, + menu_mode: false, + menu_nav: MenuNav::new(), + menu_tx, + } + } +} + fn run( - pads_out: &Mutex>, - active_out: &Mutex>, + pads_out: Arc>>, + active_out: Arc>>, ctl: &Receiver, escape_tx: &async_channel::Sender<()>, disconnect_tx: &async_channel::Sender<()>, @@ -1237,29 +1339,14 @@ fn run( let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; - let mut w = Worker { + let mut w = Worker::new( subsystem, pads_out, active_out, - open: None, - order: Vec::new(), - pinned: None, - attached: None, - last_axis: [i32::MIN; 6], - held_buttons: Vec::new(), - held_touches: std::collections::HashSet::new(), - surface_last: [(0, 0, false); 2], - held_clicks: [false; 2], - last_accel: [0; 3], - escape_tx: escape_tx.clone(), - disconnect_tx: disconnect_tx.clone(), - chord_armed: false, - chord_since: None, - disconnect_fired: false, - menu_mode: false, - menu_nav: MenuNav::new(), - menu_tx: menu_tx.clone(), - }; + escape_tx.clone(), + disconnect_tx.clone(), + menu_tx.clone(), + ); loop { // Control plane from the UI thread. diff --git a/crates/pf-presenter/Cargo.toml b/crates/pf-presenter/Cargo.toml new file mode 100644 index 00000000..a6fcebf6 --- /dev/null +++ b/crates/pf-presenter/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pf-presenter" +description = "The Vulkan session presenter — SDL3 window, ash swapchain, frame present, input capture; the stage-2 presenter of punktfunk-planning linux-client-rearchitecture.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +# Same Linux gating as the rest of the client stack. +[target.'cfg(target_os = "linux")'.dependencies] +pf-client-core = { path = "../pf-client-core" } +punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } + +# `loaded` dlopens libvulkan at runtime (no link-time dependency — GPU-less boxes still +# start and fail into a clean error). `ash` on sdl3 types SDL_Vulkan_CreateSurface with +# ash 0.38 handles so the surface hands over without transmutes. +ash = { version = "0.38", features = ["loaded"] } +sdl3 = { version = "0.18", features = ["hidapi", "ash"] } + +async-channel = "2" +anyhow = "1" +tracing = "0.1" diff --git a/crates/pf-presenter/src/input.rs b/crates/pf-presenter/src/input.rs new file mode 100644 index 00000000..6fce0fe5 --- /dev/null +++ b/crates/pf-presenter/src/input.rs @@ -0,0 +1,183 @@ +//! Input capture — the `ui_stream` state machine on SDL events. +//! +//! Capture is a deliberate, reversible STATE (Moonlight-style): engaged when the stream +//! starts and when the user clicks into the video (that click is suppressed toward the +//! host); released by Ctrl+Alt+Shift+Q (toggles) or focus loss — held keys/buttons are +//! flushed host-side on release so nothing sticks down. While captured the local cursor +//! is hidden (the host renders its own); while released nothing is forwarded. +//! +//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Mouse is absolute +//! (`MouseMoveAbs` scaled into the negotiated mode through the letterbox transform, +//! window size packed in `flags` — the GTK client's exact contract); motion is coalesced +//! to one datagram per loop iteration. Pointer-lock relative capture is the follow-up +//! (§5.3 of the plan) — absolute first for GTK parity. + +use crate::keymap_sdl; +use punktfunk_core::client::NativeClient; +use punktfunk_core::input::{InputEvent, InputKind}; +use std::collections::HashSet; +use std::sync::Arc; + +pub struct Capture { + connector: Arc, + captured: bool, + /// VKs / GameStream button ids currently held — flushed up on release. + held_keys: HashSet, + held_buttons: HashSet, + /// Newest absolute pointer position (window coordinates) not yet on the wire — + /// motion events only store here; `flush_motion` sends at most one `MouseMoveAbs` + /// per loop iteration (a 1000 Hz mouse would otherwise send a datagram per event). + pending_abs: Option<(f32, f32)>, + /// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space — + /// precision surfaces deliver sub-unit deltas; truncating each event drops the tail. + scroll_acc: (f64, f64), +} + +fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) { + let _ = connector.send_input(&InputEvent { + kind, + _pad: [0; 3], + code, + x, + y, + flags, + }); +} + +impl Capture { + pub fn new(connector: Arc) -> Capture { + Capture { + connector, + captured: false, + held_keys: HashSet::new(), + held_buttons: HashSet::new(), + pending_abs: None, + scroll_acc: (0.0, 0.0), + } + } + + pub fn captured(&self) -> bool { + self.captured + } + + /// Engage capture. The caller hides the cursor (SDL state lives with the loop). + pub fn engage(&mut self) -> bool { + !std::mem::replace(&mut self.captured, true) + } + + /// Release capture, flushing everything held so nothing sticks down on the host. + /// The caller restores the cursor. Returns false if it wasn't engaged. + pub fn release(&mut self) -> bool { + if !std::mem::replace(&mut self.captured, false) { + return false; + } + self.pending_abs = None; // never flush motion gathered while captured + for vk in self.held_keys.drain() { + send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); + } + for b in self.held_buttons.drain() { + send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0); + } + true + } + + /// Forward the coalesced pointer position, if any — one datagram per loop iteration. + /// `win` is the window's logical size (SDL mouse coordinates' space). + pub fn flush_motion(&mut self, win: (u32, u32)) { + if let Some((x, y)) = self.pending_abs.take() { + self.send_abs(x, y, win); + } + } + + /// Absolute position: window coordinates → video pixels through the Contain-fit + /// letterbox (the blit uses the same math in pixel space — the ratios are identical). + /// `flags` packs the coordinate-space size (`(w << 16) | h`) — the host normalizes + /// against it before mapping into the EIS region; without it the event is dropped. + fn send_abs(&self, x: f32, y: f32, win: (u32, u32)) { + let mode = self.connector.mode(); + let (ww, wh) = (win.0.max(1) as f64, win.1.max(1) as f64); + let (vw, vh) = (mode.width.max(1) as f64, mode.height.max(1) as f64); + let scale = (ww / vw).min(wh / vh); + let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0); + let px = ((f64::from(x) - ox) / scale).round().clamp(0.0, vw - 1.0) as i32; + let py = ((f64::from(y) - oy) / scale).round().clamp(0.0, vh - 1.0) as i32; + let flags = (mode.width << 16) | (mode.height & 0xffff); + send(&self.connector, InputKind::MouseMoveAbs, 0, px, py, flags); + } + + pub fn on_motion(&mut self, x: f32, y: f32) { + if self.captured { + self.pending_abs = Some((x, y)); + } + } + + pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode, win: (u32, u32)) { + if !self.captured { + return; + } + if let Some(vk) = keymap_sdl::scancode_to_vk(sc) { + // Keep the wire ordered: the host must see the cursor where the user does + // when the key lands (e.g. "press E at the crosshair"). + self.flush_motion(win); + self.held_keys.insert(vk); + send(&self.connector, InputKind::KeyDown, vk as u32, 0, 0, 0); + } + } + + pub fn on_key_up(&mut self, sc: sdl3::keyboard::Scancode) { + if let Some(vk) = keymap_sdl::scancode_to_vk(sc) { + // Flush-on-release may have beaten us to it — only forward if still held. + if self.held_keys.remove(&vk) { + send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); + } + } + } + + /// A button press while captured. The engaging click is the caller's business (it + /// never reaches here). The click's own coordinates supersede pending motion so the + /// button-down lands exactly where the cursor last was. + pub fn on_button_down(&mut self, b: sdl3::mouse::MouseButton, x: f32, y: f32, win: (u32, u32)) { + if !self.captured { + return; + } + self.pending_abs = Some((x, y)); + self.flush_motion(win); + if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) { + self.held_buttons.insert(gs); + send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0); + } + } + + pub fn on_button_up(&mut self, b: sdl3::mouse::MouseButton, win: (u32, u32)) { + self.flush_motion(win); // the release must not beat the motion before it + if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) { + if self.held_buttons.remove(&gs) { + send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0); + } + } + } + + /// Wheel — the wire carries WHEEL_DELTA(120) units, positive = up / right. SDL3's y + /// is positive = up and x positive = right already (no negation — GTK's dy was the + /// inverted one). Fractional remainders accumulate per axis. + pub fn on_wheel(&mut self, dx: f32, dy: f32, win: (u32, u32)) { + if !self.captured { + return; + } + self.flush_motion(win); // scroll happens at the latest cursor position + let (mut ax, mut ay) = self.scroll_acc; + ay += f64::from(dy) * 120.0; + ax += f64::from(dx) * 120.0; + let vy = ay.trunc() as i32; + if vy != 0 { + ay -= f64::from(vy); + send(&self.connector, InputKind::MouseScroll, 0, vy, 0, 0); + } + let vx = ax.trunc() as i32; + if vx != 0 { + ax -= f64::from(vx); + send(&self.connector, InputKind::MouseScroll, 1, vx, 0, 0); + } + self.scroll_acc = (ax, ay); + } +} diff --git a/crates/pf-presenter/src/keymap_sdl.rs b/crates/pf-presenter/src/keymap_sdl.rs new file mode 100644 index 00000000..89ef43be --- /dev/null +++ b/crates/pf-presenter/src/keymap_sdl.rs @@ -0,0 +1,201 @@ +//! SDL scancodes / mouse buttons → the punktfunk input wire contract. +//! +//! Same VK codes as `pf_client_core::keymap` (the wire carries Windows Virtual-Keys, the +//! GameStream convention), keyed on SDL scancodes instead of evdev codes: SDL normalizes +//! the platform keycode to its USB-HID-derived scancode table, so this stays +//! layout-independent exactly like the evdev table. Coverage mirrors `keymap::evdev_to_vk` +//! one-to-one — a key the wire contract doesn't cover (media keys etc.) returns `None` +//! and is dropped rather than guessed. + +use sdl3::keyboard::Scancode; + +/// Map an SDL scancode to the Windows VK code the host expects. +pub fn scancode_to_vk(sc: Scancode) -> Option { + use Scancode as S; + Some(match sc { + // --- Navigation / editing / whitespace --- + S::Backspace => 0x08, + S::Tab => 0x09, + S::Return => 0x0D, + S::Pause => 0x13, + S::CapsLock => 0x14, + S::Escape => 0x1B, + S::Space => 0x20, + S::PageUp => 0x21, + S::PageDown => 0x22, + S::End => 0x23, + S::Home => 0x24, + S::Left => 0x25, + S::Up => 0x26, + S::Right => 0x27, + S::Down => 0x28, + S::PrintScreen => 0x2C, + S::Insert => 0x2D, + S::Delete => 0x2E, + + // --- Digit row --- + S::_0 => 0x30, + S::_1 => 0x31, + S::_2 => 0x32, + S::_3 => 0x33, + S::_4 => 0x34, + S::_5 => 0x35, + S::_6 => 0x36, + S::_7 => 0x37, + S::_8 => 0x38, + S::_9 => 0x39, + + // --- Letters --- + S::A => 0x41, + S::B => 0x42, + S::C => 0x43, + S::D => 0x44, + S::E => 0x45, + S::F => 0x46, + S::G => 0x47, + S::H => 0x48, + S::I => 0x49, + S::J => 0x4A, + S::K => 0x4B, + S::L => 0x4C, + S::M => 0x4D, + S::N => 0x4E, + S::O => 0x4F, + S::P => 0x50, + S::Q => 0x51, + S::R => 0x52, + S::S => 0x53, + S::T => 0x54, + S::U => 0x55, + S::V => 0x56, + S::W => 0x57, + S::X => 0x58, + S::Y => 0x59, + S::Z => 0x5A, + + // --- Meta / context-menu --- + S::LGui => 0x5B, + S::RGui => 0x5C, + S::Application => 0x5D, + + // --- Numpad --- + S::Kp0 => 0x60, + S::Kp1 => 0x61, + S::Kp2 => 0x62, + S::Kp3 => 0x63, + S::Kp4 => 0x64, + S::Kp5 => 0x65, + S::Kp6 => 0x66, + S::Kp7 => 0x67, + S::Kp8 => 0x68, + S::Kp9 => 0x69, + S::KpMultiply => 0x6A, + S::KpPlus => 0x6B, + // KP-Enter → VK_SEPARATOR mirrors the evdev table (KEY_KPENTER → 0x6C). + S::KpEnter => 0x6C, + S::KpMinus => 0x6D, + S::KpPeriod => 0x6E, + S::KpDivide => 0x6F, + + // --- Function keys --- + S::F1 => 0x70, + S::F2 => 0x71, + S::F3 => 0x72, + S::F4 => 0x73, + S::F5 => 0x74, + S::F6 => 0x75, + S::F7 => 0x76, + S::F8 => 0x77, + S::F9 => 0x78, + S::F10 => 0x79, + S::F11 => 0x7A, + S::F12 => 0x7B, + + // --- Locks --- + S::NumLockClear => 0x90, + S::ScrollLock => 0x91, + + // --- Left/right modifiers --- + S::LShift => 0xA0, + S::RShift => 0xA1, + S::LCtrl => 0xA2, + S::RCtrl => 0xA3, + S::LAlt => 0xA4, + S::RAlt => 0xA5, + + // --- OEM punctuation (US-layout positions) --- + S::Semicolon => 0xBA, + S::Equals => 0xBB, + S::Comma => 0xBC, + S::Minus => 0xBD, + S::Period => 0xBE, + S::Slash => 0xBF, + S::Grave => 0xC0, + S::LeftBracket => 0xDB, + S::Backslash => 0xDC, + S::RightBracket => 0xDD, + S::Apostrophe => 0xDE, + S::NonUsBackslash => 0xE2, + + _ => return None, + }) +} + +/// SDL mouse button → the GameStream button id the wire expects (1=left, 2=middle, +/// 3=right, 4=X1, 5=X2) — the SDL twin of `keymap::gdk_button_to_gs`. +pub fn mouse_button_to_gs(b: sdl3::mouse::MouseButton) -> Option { + use sdl3::mouse::MouseButton as B; + Some(match b { + B::Left => 1, + B::Middle => 2, + B::Right => 3, + B::X1 => 4, + B::X2 => 5, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pf_client_core::keymap::evdev_to_vk; + + /// Both tables feed the same wire: for every key the evdev table knows, the SDL + /// scancode of the same physical key must map to the same VK. + #[test] + fn agrees_with_the_evdev_table() { + use Scancode as S; + let same_key: &[(Scancode, u16)] = &[ + (S::Backspace, 14), + (S::Tab, 15), + (S::Return, 28), + (S::Escape, 1), + (S::Space, 57), + (S::PageUp, 104), + (S::Left, 105), + (S::_0, 11), + (S::_1, 2), + (S::_9, 10), + (S::A, 30), + (S::Q, 16), + (S::Z, 44), + (S::LGui, 125), + (S::Kp0, 82), + (S::Kp9, 73), + (S::KpEnter, 96), + (S::F1, 59), + (S::F11, 87), + (S::F12, 88), + (S::NumLockClear, 69), + (S::LShift, 42), + (S::RAlt, 100), + (S::Semicolon, 39), + (S::Grave, 41), + (S::NonUsBackslash, 86), + ]; + for &(sc, ev) in same_key { + assert_eq!(scancode_to_vk(sc), evdev_to_vk(ev), "scancode {sc:?}"); + } + assert_eq!(scancode_to_vk(Scancode::Mute), None); // not in the wire contract + } +} diff --git a/crates/pf-presenter/src/lib.rs b/crates/pf-presenter/src/lib.rs new file mode 100644 index 00000000..16fac865 --- /dev/null +++ b/crates/pf-presenter/src/lib.rs @@ -0,0 +1,21 @@ +//! The Vulkan session presenter (punktfunk-planning `linux-client-rearchitecture.md`, +//! Phase 1): an SDL3 window + ash swapchain that presents the shared session pump's +//! decoded frames, captures input on the `ui_stream` state-machine contract, and reports +//! the unified stats window on stdout. No UI toolkit anywhere in the dependency tree. +//! +//! Phase 1 is the software path: `CpuFrame` RGBA uploads + a transfer-only letterbox +//! blit (no graphics pipeline, no shaders — those arrive with the Phase 2 dmabuf/CSC +//! pass). A hardware (dmabuf) frame slipping through demotes the decoder to software via +//! the session pump's `force_software` contract, same as the GTK presenter. + +#[cfg(target_os = "linux")] +pub mod input; +#[cfg(target_os = "linux")] +pub mod keymap_sdl; +#[cfg(target_os = "linux")] +mod run; +#[cfg(target_os = "linux")] +pub mod vk; + +#[cfg(target_os = "linux")] +pub use run::{run_session, Outcome, SessionOpts}; diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs new file mode 100644 index 00000000..8fd81152 --- /dev/null +++ b/crates/pf-presenter/src/run.rs @@ -0,0 +1,415 @@ +//! The session lifecycle loop: one SDL context on the caller's main thread driving the +//! window, the Vulkan presenter, input capture, the pumped gamepad service, and the +//! shared session pump's event/frame channels. +//! +//! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}` +//! line after the first presented frame, `stats: …` lines once per window while enabled +//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so). + +use crate::input::Capture; +use crate::vk::Presenter; +use anyhow::{Context as _, Result}; +use pf_client_core::gamepad::GamepadService; +use pf_client_core::session::{self, SessionEvent, SessionParams, Stats}; +use pf_client_core::video::{DecodedFrame, DecodedImage}; +use punktfunk_core::client::NativeClient; +use punktfunk_core::config::Mode; +use sdl3::event::{Event, WindowEvent}; +use sdl3::keyboard::Mod; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +pub struct SessionOpts { + pub window_title: String, + /// Start fullscreen (gamescope / `--fullscreen`). + pub fullscreen: bool, + /// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live). + pub print_stats: bool, + /// Emit the `{"ready":true}` stdout line after the first presented frame. + pub json_status: bool, + /// Called once on `Connected` with the host's fingerprint (trust persistence is the + /// binary's business — this loop stays store-agnostic). + pub on_connected: Option>, +} + +pub enum Outcome { + /// The session ran and ended: `None` = deliberate exit (user quit), `Some` = the + /// reason the pump reported (host ended, transport error…). + Ended(Option), + ConnectFailed { + msg: String, + trust_rejected: bool, + }, +} + +/// Everything SDL on the main thread, per the plan (§5.1): window + events + gamepads in +/// ONE event loop — the shell-era worker-thread arrangement can't coexist with an SDL +/// video window. `build_params` receives the gamepad service (for `auto_pref`), the +/// window's native display mode (the `0 = native` resolution fallback), and the shared +/// `force_software` flag, and returns the pump parameters. +pub fn run_session(mut opts: SessionOpts, build_params: F) -> Result +where + F: FnOnce(&GamepadService, Mode, Arc) -> SessionParams, +{ + sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); + let sdl = sdl3::init().context("SDL init")?; + let video = sdl.video().context("SDL video")?; + let mut window = { + let mut b = video.window(&opts.window_title, 1280, 720); + b.position_centered().resizable().vulkan(); + if opts.fullscreen { + b.fullscreen(); + } + b.build().context("SDL window")? + }; + let instance_exts = window + .vulkan_instance_extensions() + .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, None)?; + + let gamepad_subsystem = sdl.gamepad().context("SDL gamepad")?; + let (gamepad, mut pump) = GamepadService::pumped(gamepad_subsystem); + let escape_rx = gamepad.escape_events(); + let disconnect_rx = gamepad.disconnect_events(); + + // The native display mode — the `0 = native` fallback for the requested stream mode + // (the GTK client reads the monitor under its window; same idea). + let native = window + .get_display() + .and_then(|d| d.get_mode()) + .map(|m| Mode { + width: m.w.max(0) as u32, + height: m.h.max(0) as u32, + refresh_hz: m.refresh_rate.round().max(0.0) as u32, + }) + .unwrap_or(Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }); + + let force_software = Arc::new(AtomicBool::new(false)); + let params = build_params(&gamepad, native, force_software.clone()); + let handle = session::start(params); + + let mut event_pump = sdl + .event_pump() + .map_err(|e| anyhow::anyhow!("SDL event pump: {e}"))?; + let mouse = sdl.mouse(); + + // --- Loop state -------------------------------------------------------------------- + let mut connector: Option> = None; + let mut capture: Option = None; + let mut fullscreen = opts.fullscreen; + let mut ready_announced = false; + let mut print_stats = opts.print_stats; + let mut mode_line = String::new(); + let mut clock_offset_ns: i64 = 0; + let mut hdr = false; + // Presenter-side 1 s window (design/stats-unification.md): end-to-end + // capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50. + let mut win_e2e_us: Vec = Vec::with_capacity(256); + let mut win_disp_us: Vec = Vec::with_capacity(256); + let mut win_start = Instant::now(); + let mut presented = PresentedWindow::default(); + // Retained newest frame for expose/resize re-blits (the presenter keeps the GPU + // image; this keeps the pending upload if one arrives while minimized). + let mut dmabuf_demoted = false; + + let outcome = 'main: loop { + // --- SDL events (input, window, gamepads) --------------------------------------- + // Block briefly in SDL's own wait so idle costs nothing; while streaming, frames + // arrive on the channel below and 1 ms bounds the added present latency. + let timeout = Duration::from_millis(if connector.is_some() { 1 } else { 10 }); + let first = event_pump.wait_event_timeout(timeout); + let mut queued: Vec = Vec::new(); + if let Some(e) = first { + queued.push(e); + } + while let Some(e) = event_pump.poll_event() { + queued.push(e); + } + for event in queued { + match event { + Event::Quit { .. } => { + // Window close / SIGINT: deliberate user exit — QUIT_CLOSE_CODE so + // the host tears down instead of lingering for a reconnect. + if let Some(c) = &connector { + c.disconnect_quit(); + } + handle.stop.store(true, Ordering::SeqCst); + break 'main Outcome::Ended(None); + } + Event::Window { win_event, .. } => match win_event { + WindowEvent::FocusLost => { + if let Some(cap) = &mut capture { + if cap.release() { + mouse.show_cursor(true); + tracing::info!("focus lost — input released"); + } + } + } + WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => { + presenter.recreate_swapchain(&window)?; + presenter.present(&window, None)?; + } + WindowEvent::Exposed => { + presenter.present(&window, None)?; + } + _ => {} + }, + Event::KeyDown { + scancode: Some(sc), + keymod, + repeat: false, + .. + } => { + let chord = keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD) + && keymod.intersects(Mod::LALTMOD | Mod::RALTMOD) + && keymod.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD); + use sdl3::keyboard::Scancode; + if chord && sc == Scancode::Q { + if let Some(cap) = &mut capture { + if cap.captured() { + cap.release(); + mouse.show_cursor(true); + } else { + cap.engage(); + mouse.show_cursor(false); + } + tracing::info!(captured = cap.captured(), "chord: release/engage"); + } + continue; + } + if chord && sc == Scancode::D { + tracing::info!("chord: disconnect"); + if let Some(cap) = &mut capture { + cap.release(); + } + if let Some(c) = &connector { + c.disconnect_quit(); + } + handle.stop.store(true, Ordering::SeqCst); + break 'main Outcome::Ended(None); + } + if chord && sc == Scancode::S { + print_stats = !print_stats; + continue; + } + if sc == Scancode::F11 { + fullscreen = !fullscreen; + if let Err(e) = window.set_fullscreen(fullscreen) { + tracing::warn!(error = %e, "fullscreen toggle"); + } + continue; + } + if let Some(cap) = &mut capture { + cap.on_key_down(sc, window.size()); + } + } + Event::KeyUp { + scancode: Some(sc), .. + } => { + if let Some(cap) = &mut capture { + cap.on_key_up(sc); + } + } + Event::MouseMotion { x, y, .. } => { + if let Some(cap) = &mut capture { + cap.on_motion(x, y); + } + } + Event::MouseButtonDown { + mouse_btn, x, y, .. + } => { + if let Some(cap) = &mut capture { + if !cap.captured() { + // The engaging click is suppressed toward the host. + cap.engage(); + mouse.show_cursor(false); + } else { + cap.on_button_down(mouse_btn, x, y, window.size()); + } + } + } + Event::MouseButtonUp { mouse_btn, .. } => { + if let Some(cap) = &mut capture { + cap.on_button_up(mouse_btn, window.size()); + } + } + Event::MouseWheel { x, y, .. } => { + if let Some(cap) = &mut capture { + cap.on_wheel(x, y, window.size()); + } + } + // Everything else (gamepad add/remove/button/axis/touchpad/sensor…) is + // the pumped gamepad worker's — it ignores what it doesn't know. + other => pump.handle_event(other), + } + } + pump.tick(); + + // Controller escape chord: release capture (+ leave fullscreen on desktop — under + // a `--fullscreen` gamescope launch there is nothing to release into). + while escape_rx.try_recv().is_ok() { + if let Some(cap) = &mut capture { + if cap.release() { + mouse.show_cursor(true); + } + } + if fullscreen && !opts.fullscreen { + fullscreen = false; + let _ = window.set_fullscreen(false); + } + } + // Escape chord held past the threshold: the controller's Ctrl+Alt+Shift+D. + if disconnect_rx.try_recv().is_ok() { + tracing::info!("controller chord: disconnect"); + if let Some(cap) = &mut capture { + cap.release(); + } + if let Some(c) = &connector { + c.disconnect_quit(); + } + handle.stop.store(true, Ordering::SeqCst); + break Outcome::Ended(None); + } + + // --- Session events -------------------------------------------------------------- + while let Ok(ev) = handle.events.try_recv() { + match ev { + SessionEvent::Connected { + connector: c, + mode, + fingerprint, + } => { + mode_line = format!("{}×{}@{}", mode.width, mode.height, mode.refresh_hz); + tracing::info!(mode = %mode_line, "connected"); + window.set_title(&format!("{} · {}", opts.window_title, mode_line)).ok(); + gamepad.attach(c.clone()); + clock_offset_ns = c.clock_offset_ns; + let mut cap = Capture::new(c.clone()); + cap.engage(); // capture engages when the stream starts (ui_stream parity) + mouse.show_cursor(false); + capture = Some(cap); + connector = Some(c); + if let Some(f) = opts.on_connected.as_mut() { + f(fingerprint); + } + } + SessionEvent::Stats(s) => { + if print_stats { + print_stats_line(&mode_line, &s, &presented, hdr); + } + } + SessionEvent::Failed { msg, trust_rejected } => { + break 'main Outcome::ConnectFailed { msg, trust_rejected }; + } + SessionEvent::Ended(reason) => { + gamepad.detach(); + if let Some(cap) = &mut capture { + cap.release(); + mouse.show_cursor(true); + } + break 'main Outcome::Ended(reason); + } + } + } + + // --- Frames: drain to the newest, upload + present ------------------------------- + let mut newest: Option = None; + while let Ok(f) = handle.frames.try_recv() { + newest = Some(f); + } + if let Some(f) = newest { + match f.image { + DecodedImage::Cpu(c) => { + hdr = c.color.is_pq(); + presenter.present(&window, Some(&c))?; + let displayed_ns = session::now_ns(); + if opts.json_status && !ready_announced { + ready_announced = true; + println!("{{\"ready\":true}}"); + } + // The `displayed` stamp (same clamp rules as the pump's windows). + let e2e = (displayed_ns as i128 + clock_offset_ns as i128 - f.pts_ns as i128) + .max(0) as u64; + if e2e > 0 && e2e < 10_000_000_000 { + win_e2e_us.push(e2e / 1000); + } + win_disp_us.push(displayed_ns.saturating_sub(f.decoded_ns) / 1000); + } + DecodedImage::Dmabuf(_) => { + // Phase 1 has no hardware present path — demote the decoder to + // software through the same contract the GTK presenter uses. Once. + if !dmabuf_demoted { + dmabuf_demoted = true; + tracing::warn!( + "hardware (dmabuf) frame reached the phase-1 presenter — \ + demoting the decoder to software" + ); + force_software.store(true, Ordering::Relaxed); + } + } + } + } + + // Fold the presenter window into the shared stats line once per second. + if win_start.elapsed() >= Duration::from_secs(1) { + let (e2e_p50, e2e_p95) = session::window_percentiles(&mut win_e2e_us); + let (disp_p50, _) = session::window_percentiles(&mut win_disp_us); + presented = PresentedWindow { + e2e_p50_ms: e2e_p50 as f32 / 1000.0, + e2e_p95_ms: e2e_p95 as f32 / 1000.0, + display_ms: disp_p50 as f32 / 1000.0, + }; + win_e2e_us.clear(); + win_disp_us.clear(); + win_start = Instant::now(); + } + }; + + handle.stop.store(true, Ordering::SeqCst); + Ok(outcome) +} + +/// The presenter's share of the unified stats window — folded into each printed line. +#[derive(Default)] +struct PresentedWindow { + e2e_p50_ms: f32, + e2e_p95_ms: f32, + 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)", + s.fps, + s.mbps, + if s.decoder.is_empty() { "-" } else { s.decoder }, + if hdr { " · HDR" } else { "" }, + 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 + )); + } else { + line.push_str(&format!(" · host+net {:.1}", s.host_net_ms)); + } + line.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)); + } + println!("{line}"); +} diff --git a/crates/pf-presenter/src/vk.rs b/crates/pf-presenter/src/vk.rs new file mode 100644 index 00000000..94166eba --- /dev/null +++ b/crates/pf-presenter/src/vk.rs @@ -0,0 +1,747 @@ +//! The transfer-only Vulkan presenter: swapchain + staging upload + letterboxed blit. +//! +//! Phase 1 deliberately has NO graphics pipeline: software-decoded RGBA uploads to a +//! device-local image (`copy_buffer_to_image`, the row stride handled by +//! `buffer_row_length`), and each present clears the swapchain image and +//! `vkCmdBlitImage`s the video into the Contain-fit letterbox rect (linear filter, +//! format conversion by the blit). No render pass, no framebuffers, no image views, no +//! SPIR-V toolchain — all of that arrives with the Phase 2 dmabuf/CSC pass, which is the +//! first thing that actually needs a shader. +//! +//! Pacing: one frame in flight (the submit fence is waited before each record), FIFO by +//! default (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate` if available). Present is +//! arrival-paced by the caller: `present(Some(frame))` on each decoded frame, +//! `present(None)` re-blits the retained video image (expose/resize redraws). + +use anyhow::{anyhow, bail, Context as _, Result}; +use ash::vk; +use pf_client_core::video::CpuFrame; +use std::ffi::CString; + +/// The one video image (device-local RGBA the size of the decoded stream) + its staging. +struct VideoImage { + image: vk::Image, + memory: vk::DeviceMemory, + width: u32, + height: u32, +} + +struct Staging { + buffer: vk::Buffer, + memory: vk::DeviceMemory, + ptr: *mut u8, + capacity: usize, +} + +pub struct Presenter { + // Field order = drop order documentation only; teardown is explicit in `Drop`. + entry: ash::Entry, + instance: ash::Instance, + surface_i: ash::khr::surface::Instance, + surface: vk::SurfaceKHR, + pdev: vk::PhysicalDevice, + mem_props: vk::PhysicalDeviceMemoryProperties, + device: ash::Device, + swap_d: ash::khr::swapchain::Device, + queue: vk::Queue, + format: vk::SurfaceFormatKHR, + present_mode: vk::PresentModeKHR, + swapchain: vk::SwapchainKHR, + images: Vec, + extent: vk::Extent2D, + /// Per-swapchain-image render-finished semaphores (present consumes them on the + /// image's schedule — one shared semaphore could be re-submitted while a previous + /// present still holds it). + render_sems: Vec, + acquire_sem: vk::Semaphore, + fence: vk::Fence, + cmd_pool: vk::CommandPool, + cmd_buf: vk::CommandBuffer, + staging: Option, + video: Option, + /// The submit fence has a submission pending (wait before recording again — also + /// what makes the single staging buffer safe to overwrite). + submitted: bool, +} + +impl Presenter { + /// Bring up instance → surface → device → swapchain over an SDL window. + /// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`. + pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result { + let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?; + + let app_name = CString::new("punktfunk-session").unwrap(); + let app_info = vk::ApplicationInfo::default() + .application_name(&app_name) + .api_version(vk::API_VERSION_1_2); + let ext_cstrings: Vec = instance_extensions + .iter() + .map(|e| CString::new(e.as_str()).unwrap()) + .collect(); + let ext_ptrs: Vec<*const i8> = ext_cstrings.iter().map(|e| e.as_ptr()).collect(); + let instance = unsafe { + entry.create_instance( + &vk::InstanceCreateInfo::default() + .application_info(&app_info) + .enabled_extension_names(&ext_ptrs), + None, + ) + } + .context("vkCreateInstance")?; + let surface_i = ash::khr::surface::Instance::new(&entry, &instance); + + let surface = unsafe { window.vulkan_create_surface(instance.handle()) } + .map_err(|e| anyhow!("SDL_Vulkan_CreateSurface: {e}"))?; + + let (pdev, qfi) = pick_device(&instance, &surface_i, surface)?; + let mem_props = unsafe { instance.get_physical_device_memory_properties(pdev) }; + { + let props = unsafe { instance.get_physical_device_properties(pdev) }; + let name = props + .device_name_as_c_str() + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_default(); + tracing::info!(device = %name, queue_family = qfi, "vulkan device"); + } + + let queue_info = [vk::DeviceQueueCreateInfo::default() + .queue_family_index(qfi) + .queue_priorities(&[1.0])]; + // Phase 2 (dmabuf import) adds VK_EXT_external_memory_dma_buf + + // VK_KHR_external_memory_fd + VK_EXT_image_drm_format_modifier here. + let dev_exts = [ash::khr::swapchain::NAME.as_ptr()]; + let device = unsafe { + instance.create_device( + pdev, + &vk::DeviceCreateInfo::default() + .queue_create_infos(&queue_info) + .enabled_extension_names(&dev_exts), + None, + ) + } + .context("vkCreateDevice")?; + let swap_d = ash::khr::swapchain::Device::new(&instance, &device); + let queue = unsafe { device.get_device_queue(qfi, 0) }; + + let format = pick_format(&surface_i, pdev, surface)?; + let present_mode = pick_present_mode(&surface_i, pdev, surface)?; + tracing::info!(?format, ?present_mode, "swapchain config"); + + let cmd_pool = unsafe { + device.create_command_pool( + &vk::CommandPoolCreateInfo::default() + .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER) + .queue_family_index(qfi), + None, + ) + }?; + let cmd_buf = unsafe { + device.allocate_command_buffers( + &vk::CommandBufferAllocateInfo::default() + .command_pool(cmd_pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1), + ) + }?[0]; + let acquire_sem = + unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) }?; + let fence = unsafe { + device.create_fence( + &vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED), + None, + ) + }?; + + let mut p = Presenter { + entry, + instance, + surface_i, + surface, + pdev, + mem_props, + device, + swap_d, + queue, + format, + present_mode, + swapchain: vk::SwapchainKHR::null(), + images: Vec::new(), + extent: vk::Extent2D::default(), + render_sems: Vec::new(), + acquire_sem, + fence, + cmd_pool, + cmd_buf, + staging: None, + video: None, + submitted: false, + }; + p.recreate_swapchain(window)?; + Ok(p) + } + + /// (Re)build the swapchain for the window's current pixel size. Also the resize path. + pub fn recreate_swapchain(&mut self, window: &sdl3::video::Window) -> Result<()> { + unsafe { self.device.device_wait_idle() }.ok(); + self.submitted = false; + + let caps = unsafe { + self.surface_i + .get_physical_device_surface_capabilities(self.pdev, self.surface) + }?; + let (pw, ph) = window.size_in_pixels(); + let extent = if caps.current_extent.width != u32::MAX { + caps.current_extent + } else { + vk::Extent2D { + width: pw.clamp(caps.min_image_extent.width, caps.max_image_extent.width), + height: ph.clamp(caps.min_image_extent.height, caps.max_image_extent.height), + } + }; + if extent.width == 0 || extent.height == 0 { + // Minimized — keep the old swapchain; presents will report OUT_OF_DATE and + // land back here once the window has a size again. + return Ok(()); + } + let mut min_images = caps.min_image_count + 1; + if caps.max_image_count > 0 { + min_images = min_images.min(caps.max_image_count); + } + + let old = self.swapchain; + let info = vk::SwapchainCreateInfoKHR::default() + .surface(self.surface) + .min_image_count(min_images) + .image_format(self.format.format) + .image_color_space(self.format.color_space) + .image_extent(extent) + .image_array_layers(1) + // TRANSFER_DST is the whole phase-1 pipeline (clear + blit); COLOR_ATTACHMENT + // keeps the phase-2 render pass from forcing a swapchain rebuild contract change. + .image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_DST) + .image_sharing_mode(vk::SharingMode::EXCLUSIVE) + .pre_transform(caps.current_transform) + .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) + .present_mode(self.present_mode) + .clipped(true) + .old_swapchain(old); + let swapchain = unsafe { self.swap_d.create_swapchain(&info, None) } + .context("vkCreateSwapchainKHR")?; + if old != vk::SwapchainKHR::null() { + unsafe { self.swap_d.destroy_swapchain(old, None) }; + } + self.swapchain = swapchain; + self.images = unsafe { self.swap_d.get_swapchain_images(swapchain) }?; + self.extent = extent; + + for s in self.render_sems.drain(..) { + unsafe { self.device.destroy_semaphore(s, None) }; + } + for _ in 0..self.images.len() { + self.render_sems.push(unsafe { + self.device + .create_semaphore(&vk::SemaphoreCreateInfo::default(), None) + }?); + } + tracing::debug!( + width = extent.width, + height = extent.height, + images = self.images.len(), + "swapchain (re)created" + ); + Ok(()) + } + + /// Present one frame: upload `frame` if given (else re-blit the retained video + /// image), clear, letterbox-blit, present. Returns false when the swapchain was out + /// of date — the caller recreates (with current window state) and may retry. + pub fn present(&mut self, window: &sdl3::video::Window, frame: Option<&CpuFrame>) -> Result { + if self.extent.width == 0 || self.extent.height == 0 { + return Ok(true); // minimized — nothing to do + } + // One frame in flight: the fence covers the command buffer AND the staging + // buffer, so waiting here makes both safe to reuse. + unsafe { + if self.submitted { + self.device.wait_for_fences(&[self.fence], true, u64::MAX)?; + self.submitted = false; + } + self.device.reset_fences(&[self.fence])?; + } + + if let Some(f) = frame { + self.stage_frame(f)?; + } + + let (index, _suboptimal) = match unsafe { + self.swap_d.acquire_next_image( + self.swapchain, + u64::MAX, + self.acquire_sem, + vk::Fence::null(), + ) + } { + Ok(r) => r, + Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swapchain(window)?; + return Ok(false); + } + Err(e) => return Err(e).context("vkAcquireNextImageKHR"), + }; + let swap_image = self.images[index as usize]; + + unsafe { + self.device.begin_command_buffer( + self.cmd_buf, + &vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), + )?; + + // New frame: staging → video image (stride carried by buffer_row_length). + if let (Some(f), Some(v), Some(s)) = (frame, &self.video, &self.staging) { + barrier( + &self.device, + self.cmd_buf, + v.image, + vk::ImageLayout::UNDEFINED, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + ); + let region = vk::BufferImageCopy::default() + .buffer_row_length((f.stride / 4) as u32) + .image_subresource(subresource_layers()) + .image_extent(vk::Extent3D { + width: v.width, + height: v.height, + depth: 1, + }); + self.device.cmd_copy_buffer_to_image( + self.cmd_buf, + s.buffer, + v.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[region], + ); + barrier( + &self.device, + self.cmd_buf, + v.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + ); + } + + // Swapchain image: discard old content, clear to black (the letterbox bars), + // blit the video in, hand to present. + barrier( + &self.device, + self.cmd_buf, + swap_image, + vk::ImageLayout::UNDEFINED, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + ); + self.device.cmd_clear_color_image( + self.cmd_buf, + swap_image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &vk::ClearColorValue { + float32: [0.0, 0.0, 0.0, 1.0], + }, + &[subresource_range()], + ); + if let Some(v) = &self.video { + let (dst0, dst1) = letterbox(self.extent, v.width, v.height); + let blit = vk::ImageBlit::default() + .src_subresource(subresource_layers()) + .src_offsets([ + vk::Offset3D { x: 0, y: 0, z: 0 }, + vk::Offset3D { + x: v.width as i32, + y: v.height as i32, + z: 1, + }, + ]) + .dst_subresource(subresource_layers()) + .dst_offsets([dst0, dst1]); + self.device.cmd_blit_image( + self.cmd_buf, + v.image, + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + swap_image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[blit], + vk::Filter::LINEAR, + ); + } + barrier( + &self.device, + self.cmd_buf, + swap_image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + vk::ImageLayout::PRESENT_SRC_KHR, + ); + self.device.end_command_buffer(self.cmd_buf)?; + + let render_sem = self.render_sems[index as usize]; + let wait_sems = [self.acquire_sem]; + let wait_stages = [vk::PipelineStageFlags::TRANSFER]; + let cmd_bufs = [self.cmd_buf]; + let signal_sems = [render_sem]; + self.device.queue_submit( + self.queue, + &[vk::SubmitInfo::default() + .wait_semaphores(&wait_sems) + .wait_dst_stage_mask(&wait_stages) + .command_buffers(&cmd_bufs) + .signal_semaphores(&signal_sems)], + self.fence, + )?; + self.submitted = true; + + let swapchains = [self.swapchain]; + let indices = [index]; + let present_sems = [render_sem]; + match self.swap_d.queue_present( + self.queue, + &vk::PresentInfoKHR::default() + .wait_semaphores(&present_sems) + .swapchains(&swapchains) + .image_indices(&indices), + ) { + Ok(_) => Ok(true), + Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swapchain(window)?; + Ok(false) + } + Err(e) => Err(e).context("vkQueuePresentKHR"), + } + } + } + + /// Copy the frame's RGBA into the staging buffer and (re)build the video image on a + /// stream-size change. Rows keep their stride — `buffer_row_length` unpacks it. + fn stage_frame(&mut self, f: &CpuFrame) -> Result<()> { + anyhow::ensure!( + f.stride % 4 == 0 && f.stride >= f.width as usize * 4, + "unexpected RGBA stride {} for width {}", + f.stride, + f.width + ); + if self + .video + .as_ref() + .is_none_or(|v| v.width != f.width || v.height != f.height) + { + self.rebuild_video_image(f.width, f.height)?; + tracing::info!(width = f.width, height = f.height, "video image (re)built"); + } + let needed = f.stride * f.height as usize; + if self.staging.as_ref().is_none_or(|s| s.capacity < needed) { + self.rebuild_staging(needed)?; + } + let s = self.staging.as_ref().unwrap(); + let n = f.rgba.len().min(needed); + unsafe { std::ptr::copy_nonoverlapping(f.rgba.as_ptr(), s.ptr, n) }; + Ok(()) + } + + fn rebuild_video_image(&mut self, width: u32, height: u32) -> Result<()> { + unsafe { self.device.device_wait_idle() }.ok(); + self.submitted = false; + if let Some(v) = self.video.take() { + unsafe { + self.device.destroy_image(v.image, None); + self.device.free_memory(v.memory, None); + } + } + let image = unsafe { + self.device.create_image( + &vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(vk::Format::R8G8B8A8_UNORM) + .extent(vk::Extent3D { + width, + height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::TRANSFER_SRC) + .initial_layout(vk::ImageLayout::UNDEFINED), + None, + ) + }?; + let reqs = unsafe { self.device.get_image_memory_requirements(image) }; + let memory = self.allocate(reqs, vk::MemoryPropertyFlags::DEVICE_LOCAL)?; + unsafe { self.device.bind_image_memory(image, memory, 0) }?; + self.video = Some(VideoImage { + image, + memory, + width, + height, + }); + Ok(()) + } + + fn rebuild_staging(&mut self, capacity: usize) -> Result<()> { + unsafe { self.device.device_wait_idle() }.ok(); + self.submitted = false; + if let Some(s) = self.staging.take() { + unsafe { + self.device.unmap_memory(s.memory); + self.device.destroy_buffer(s.buffer, None); + self.device.free_memory(s.memory, None); + } + } + let buffer = unsafe { + self.device.create_buffer( + &vk::BufferCreateInfo::default() + .size(capacity as u64) + .usage(vk::BufferUsageFlags::TRANSFER_SRC) + .sharing_mode(vk::SharingMode::EXCLUSIVE), + None, + ) + }?; + let reqs = unsafe { self.device.get_buffer_memory_requirements(buffer) }; + let memory = self.allocate( + reqs, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + )?; + unsafe { self.device.bind_buffer_memory(buffer, memory, 0) }?; + let ptr = unsafe { + self.device + .map_memory(memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty()) + }? as *mut u8; + self.staging = Some(Staging { + buffer, + memory, + ptr, + capacity, + }); + Ok(()) + } + + fn allocate( + &self, + reqs: vk::MemoryRequirements, + flags: vk::MemoryPropertyFlags, + ) -> Result { + let type_index = (0..self.mem_props.memory_type_count) + .find(|&i| { + reqs.memory_type_bits & (1 << i) != 0 + && self.mem_props.memory_types[i as usize] + .property_flags + .contains(flags) + }) + .with_context(|| format!("no memory type for {flags:?}"))?; + unsafe { + self.device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .allocation_size(reqs.size) + .memory_type_index(type_index), + None, + ) + } + .context("vkAllocateMemory") + } +} + +impl Drop for Presenter { + fn drop(&mut self) { + unsafe { + self.device.device_wait_idle().ok(); + if let Some(s) = self.staging.take() { + self.device.unmap_memory(s.memory); + self.device.destroy_buffer(s.buffer, None); + self.device.free_memory(s.memory, None); + } + if let Some(v) = self.video.take() { + self.device.destroy_image(v.image, None); + self.device.free_memory(v.memory, None); + } + for s in self.render_sems.drain(..) { + self.device.destroy_semaphore(s, None); + } + self.device.destroy_semaphore(self.acquire_sem, None); + self.device.destroy_fence(self.fence, None); + self.device.destroy_command_pool(self.cmd_pool, None); + if self.swapchain != vk::SwapchainKHR::null() { + self.swap_d.destroy_swapchain(self.swapchain, None); + } + self.device.destroy_device(None); + self.surface_i.destroy_surface(self.surface, None); + self.instance.destroy_instance(None); + } + // `entry` (the libvulkan handle) drops last, after every vk call is done. + let _ = &self.entry; + } +} + +/// First physical device with a queue family that does graphics + present here; +/// `PUNKTFUNK_VK_DEVICE=` overrides on multi-GPU boxes. +fn pick_device( + instance: &ash::Instance, + surface_i: &ash::khr::surface::Instance, + surface: vk::SurfaceKHR, +) -> Result<(vk::PhysicalDevice, u32)> { + let devices = unsafe { instance.enumerate_physical_devices() }?; + let forced: Option = std::env::var("PUNKTFUNK_VK_DEVICE") + .ok() + .and_then(|v| v.parse().ok()); + let candidates: Vec = match forced { + Some(i) => devices.get(i).copied().into_iter().collect(), + None => devices, + }; + for pdev in candidates { + let families = unsafe { instance.get_physical_device_queue_family_properties(pdev) }; + for (i, f) in families.iter().enumerate() { + let graphics = f.queue_flags.contains(vk::QueueFlags::GRAPHICS); + let present = unsafe { + surface_i.get_physical_device_surface_support(pdev, i as u32, surface) + } + .unwrap_or(false); + if graphics && present { + return Ok((pdev, i as u32)); + } + } + } + bail!("no Vulkan device with a graphics+present queue family") +} + +/// Prefer BGRA8 UNORM (the near-universal presentable format); RGBA8 second; else +/// whatever the surface offers first. UNORM (not SRGB) — the decoded RGBA is already +/// display-referred, the blit must not re-encode it. +fn pick_format( + surface_i: &ash::khr::surface::Instance, + pdev: vk::PhysicalDevice, + surface: vk::SurfaceKHR, +) -> Result { + let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?; + for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] { + if let Some(f) = formats.iter().find(|f| f.format == want) { + return Ok(*f); + } + } + formats + .first() + .copied() + .ok_or_else(|| anyhow!("surface offers no formats")) +} + +/// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available — +/// a streaming client defaults to tear-free. +fn pick_present_mode( + surface_i: &ash::khr::surface::Instance, + pdev: vk::PhysicalDevice, + surface: vk::SurfaceKHR, +) -> Result { + let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?; + let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() { + Some("mailbox") => vk::PresentModeKHR::MAILBOX, + Some("immediate") => vk::PresentModeKHR::IMMEDIATE, + _ => vk::PresentModeKHR::FIFO, + }; + Ok(if modes.contains(&want) { + want + } else { + vk::PresentModeKHR::FIFO // always available per spec + }) +} + +/// The Contain-fit letterbox: video (vw×vh) into the swapchain extent, centered. +fn letterbox(extent: vk::Extent2D, vw: u32, vh: u32) -> (vk::Offset3D, vk::Offset3D) { + let (ew, eh) = (f64::from(extent.width), f64::from(extent.height)); + let scale = (ew / f64::from(vw.max(1))).min(eh / f64::from(vh.max(1))); + let dw = (f64::from(vw) * scale).round(); + let dh = (f64::from(vh) * scale).round(); + let ox = ((ew - dw) / 2.0).floor() as i32; + let oy = ((eh - dh) / 2.0).floor() as i32; + ( + vk::Offset3D { x: ox, y: oy, z: 0 }, + vk::Offset3D { + x: (ox + dw as i32).min(extent.width as i32), + y: (oy + dh as i32).min(extent.height as i32), + z: 1, + }, + ) +} + +fn subresource_layers() -> vk::ImageSubresourceLayers { + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .layer_count(1) +} + +fn subresource_range() -> vk::ImageSubresourceRange { + vk::ImageSubresourceRange::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .level_count(1) + .layer_count(1) +} + +/// A full-subresource layout transition with the conservative ALL_COMMANDS/TRANSFER +/// scopes this transfer-only pipeline needs (per-frame granularity, not per-stage). +fn barrier( + device: &ash::Device, + cmd: vk::CommandBuffer, + image: vk::Image, + from: vk::ImageLayout, + to: vk::ImageLayout, +) { + let b = vk::ImageMemoryBarrier::default() + .src_access_mask(vk::AccessFlags::MEMORY_WRITE) + .dst_access_mask(vk::AccessFlags::MEMORY_READ | vk::AccessFlags::MEMORY_WRITE) + .old_layout(from) + .new_layout(to) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(image) + .subresource_range(subresource_range()); + unsafe { + device.cmd_pipeline_barrier( + cmd, + vk::PipelineStageFlags::ALL_COMMANDS, + vk::PipelineStageFlags::ALL_COMMANDS, + vk::DependencyFlags::empty(), + &[], + &[], + &[b], + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letterbox_pillarboxes_a_wide_window() { + // 16:10 video in a 21:9-ish window: full height, centered horizontally. + let (a, b) = letterbox( + vk::Extent2D { + width: 3440, + height: 1440, + }, + 1280, + 800, + ); + assert_eq!((a.y, b.y), (0, 1440)); + assert_eq!(b.x - a.x, 2304); // 1280 * (1440/800) + assert_eq!(a.x, (3440 - 2304) / 2); + } + + #[test] + fn letterbox_matches_exact_fit() { + let (a, b) = letterbox( + vk::Extent2D { + width: 1280, + height: 800, + }, + 1280, + 800, + ); + assert_eq!((a.x, a.y), (0, 0)); + assert_eq!((b.x, b.y), (1280, 800)); + } +}