feat(client): Vulkan session binary — SDL3 + ash presenter MVP (phase 1)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Ctl>,
|
||||
}
|
||||
|
||||
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<Vec<PadInfo>>,
|
||||
active_out: &'a Mutex<Option<PadInfo>>,
|
||||
pads_out: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active_out: Arc<Mutex<Option<PadInfo>>>,
|
||||
/// 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<MenuEvent>,
|
||||
}
|
||||
|
||||
impl Worker<'_> {
|
||||
impl Worker {
|
||||
fn active_id(&self) -> Option<u32> {
|
||||
// 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<Mutex<Vec<PadInfo>>>,
|
||||
active_out: Arc<Mutex<Option<PadInfo>>>,
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
disconnect_tx: async_channel::Sender<()>,
|
||||
menu_tx: async_channel::Sender<MenuEvent>,
|
||||
) -> 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<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
pads_out: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active_out: Arc<Mutex<Option<PadInfo>>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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<NativeClient>,
|
||||
captured: bool,
|
||||
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||
held_keys: HashSet<u8>,
|
||||
held_buttons: HashSet<u32>,
|
||||
/// 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<NativeClient>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<u32> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<Box<dyn FnMut([u8; 32])>>,
|
||||
}
|
||||
|
||||
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<String>),
|
||||
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<F>(mut opts: SessionOpts, build_params: F) -> Result<Outcome>
|
||||
where
|
||||
F: FnOnce(&GamepadService, Mode, Arc<AtomicBool>) -> 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<Arc<NativeClient>> = None;
|
||||
let mut capture: Option<Capture> = 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<u64> = Vec::with_capacity(256);
|
||||
let mut win_disp_us: Vec<u64> = 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<Event> = 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<DecodedFrame> = 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}");
|
||||
}
|
||||
@@ -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<vk::Image>,
|
||||
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<vk::Semaphore>,
|
||||
acquire_sem: vk::Semaphore,
|
||||
fence: vk::Fence,
|
||||
cmd_pool: vk::CommandPool,
|
||||
cmd_buf: vk::CommandBuffer,
|
||||
staging: Option<Staging>,
|
||||
video: Option<VideoImage>,
|
||||
/// 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<Presenter> {
|
||||
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<CString> = 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<bool> {
|
||||
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<vk::DeviceMemory> {
|
||||
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=<index>` 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<usize> = std::env::var("PUNKTFUNK_VK_DEVICE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok());
|
||||
let candidates: Vec<vk::PhysicalDevice> = 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<vk::SurfaceFormatKHR> {
|
||||
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<vk::PresentModeKHR> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user