feat(session): console game library — Skia coverflow + SkSL aurora, --browse (phase 4b)

The run loop grows a browse mode: the console library idles between
streams in ONE window (no gamescope handoff), overlay actions launch
sessions via run_browse's callback, session end returns to the library.
The Overlay contract gains menu routing (MenuEvent → haptic pulse),
action draining, and session-phase edges.

pf-console-ui ports the GTK launcher wholesale: the coverflow's springs,
cursor arithmetic and recede/tilt constants move verbatim with their
tests (plus new projection tests — focused-card centering, the inner-
edge-recedes corridor); paint order is draw order (the gtk::Fixed
restack hack is gone); the aurora renders as an SkSL runtime shader at
full rate on every box (the 30 Hz CPU-upscale path and its frozen-on-
Deck fallback are deleted — the generated SkSL is compile-tested);
titles/scenes shape through textlayout (CJK-safe). Poster art streams
in as encoded bytes through the shared model and decodes renderer-side.

The session binary wires --browse host[:port] [--mgmt PORT]: KnownHosts
lookup (unpaired renders the pair-first scene), library + art fetch on
threads, PUNKTFUNK_FAKE_LIBRARY dev hook, and a session_params helper
shared with --connect. The minimal build refuses --browse cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 19:36:21 +02:00
parent 021a2261f6
commit be09f9f345
13 changed files with 1908 additions and 212 deletions
+473
View File
@@ -0,0 +1,473 @@
//! The console library's model and math — everything about the coverflow that isn't
//! Skia: the shared binary↔overlay state (games, phase, incoming art bytes), the
//! spring-driven motion and cursor arithmetic (ported verbatim from the GTK launcher,
//! tests included), and the geometry constants. Rendering lives in `skia_overlay`.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
// --- Geometry (the GTK launcher's constants — Apple coverflow parity) --------------------
/// Poster geometry: 2:3 covers, sized so the focused poster + detail panel + hint bar
/// fit a Deck's 1280×800 with air. Scaled uniformly for other window sizes.
pub const POSTER_W: f64 = 220.0;
pub const POSTER_H: f64 = 330.0;
/// Center of the focused card to the center of its first neighbor.
pub const FOCUS_GAP: f64 = 230.0;
/// Center-to-center distance between successive SIDE cards — much tighter than their
/// projected width, so the side stacks overlap like the classic coverflow shelf.
pub const SIDE_SPACING: f64 = 104.0;
/// Cards farther than this from the eased position aren't drawn at all.
pub const VISIBLE_RANGE: f64 = 5.5;
/// Neighbors recede to this scale…
pub const RECEDE_SCALE: f64 = 0.24;
/// …and swing this many degrees about their own vertical axis under perspective, side
/// cards facing the corridor (their inner edge recedes behind the focus).
pub const ROTATE_DEG: f64 = 38.0;
/// Perspective depth for the tilt, px (CSS `perspective()` semantics).
pub const PERSPECTIVE: f64 = 800.0;
/// The darkening veil's max opacity (side cards stay opaque — they overlap).
pub const RECEDE_DIM: f64 = 0.30;
/// Boundary recoil: a refused move deflects the strip this many px against the push.
pub const BUMP_PX: f64 = 16.0;
/// L1/R1 jump distance.
pub const JUMP: i32 = 5;
// The motion is spring-driven (semi-implicit Euler), not eased — velocity carries across
// retargets, so holding a direction glides and a release settles like a detent.
/// Cursor chase: ζ ≈ 0.85 — settles in ~0.3 s with a whisker of overshoot.
pub const SPRING_K: f64 = 200.0;
pub const SPRING_C: f64 = 24.0;
/// Boundary recoil: stiffer and more underdamped (ζ ≈ 0.55) — one visible wobble.
pub const BUMP_K: f64 = 600.0;
pub const BUMP_C: f64 = 27.0;
/// One semi-implicit-Euler step of a damped spring toward `target`.
fn spring_step(pos: f64, vel: f64, target: f64, k: f64, c: f64, dt: f64) -> (f64, f64) {
let vel = vel + (k * (target - pos) - c * vel) * dt;
(pos + vel * dt, vel)
}
/// Advance a damped spring by a whole frame, integrating in ≤ 8 ms substeps — a stalled
/// frame stays far inside the integrator's stability bound, so the motion feels
/// identical at any frame rate.
pub fn spring_advance(
mut pos: f64,
mut vel: f64,
target: f64,
k: f64,
c: f64,
dt: f64,
) -> (f64, f64) {
let n = (dt / 0.008).ceil().max(1.0) as usize;
let h = dt / n as f64;
for _ in 0..n {
(pos, vel) = spring_step(pos, vel, target, k, c, h);
}
(pos, vel)
}
/// Pure cursor arithmetic for a move/jump: `clamp` lands jumps on the ends, a plain
/// step refuses to leave them.
#[derive(Debug, PartialEq, Eq)]
pub enum StepResult {
Moved(i32),
Boundary,
}
pub fn step_cursor(cursor: i32, len: usize, delta: i32, clamp: bool) -> StepResult {
if len == 0 {
return StepResult::Boundary;
}
let max = len as i32 - 1;
let target = if clamp {
(cursor + delta).clamp(0, max)
} else {
cursor + delta
};
if target == cursor || target < 0 || target > max {
StepResult::Boundary
} else {
StepResult::Moved(target)
}
}
// --- 4×4 matrix (row-major) — the coverflow card transform ------------------------------
/// `T(cx,cy) · P(depth) · Ry(angle) · S(s) · T(-w/2,-h/2)`: card-local (0..w, 0..h) →
/// screen, rotated about the card's own vertical center axis under perspective — the
/// GSK transform chain from the GTK launcher, as one row-major matrix for
/// `Canvas::concat_44`.
#[allow(clippy::too_many_arguments)]
pub fn card_matrix(
cx: f64,
cy: f64,
angle_deg: f64,
scale: f64,
w: f64,
h: f64,
depth: f64,
) -> [f32; 16] {
let t1 = translate(cx, cy);
let p = perspective(depth);
let r = rotate_y(angle_deg.to_radians());
let s = scale_xy(scale);
let t2 = translate(-w / 2.0, -h / 2.0);
let m = mat_mul(&mat_mul(&mat_mul(&mat_mul(&t1, &p), &r), &s), &t2);
core::array::from_fn(|i| m[i] as f32)
}
fn translate(x: f64, y: f64) -> [f64; 16] {
let mut m = identity();
m[3] = x;
m[7] = y;
m
}
fn perspective(d: f64) -> [f64; 16] {
let mut m = identity();
m[14] = -1.0 / d; // row 3, col 2 — w' = 1 z/d (CSS convention)
m
}
fn rotate_y(rad: f64) -> [f64; 16] {
let (s, c) = rad.sin_cos();
let mut m = identity();
m[0] = c;
m[2] = s;
m[8] = -s;
m[10] = c;
m
}
fn scale_xy(s: f64) -> [f64; 16] {
let mut m = identity();
m[0] = s;
m[5] = s;
m
}
fn identity() -> [f64; 16] {
let mut m = [0.0; 16];
m[0] = 1.0;
m[5] = 1.0;
m[10] = 1.0;
m[15] = 1.0;
m
}
fn mat_mul(a: &[f64; 16], b: &[f64; 16]) -> [f64; 16] {
let mut out = [0.0; 16];
for r in 0..4 {
for c in 0..4 {
out[r * 4 + c] = (0..4).map(|k| a[r * 4 + k] * b[k * 4 + c]).sum();
}
}
out
}
// --- Aurora (the Swift `GamepadScreenBackground` blob table, verbatim) --------------------
/// One drifting color blob: base position + drift ellipse in unit coordinates, angular
/// speeds in rad/s, a breathing radius (fraction of the larger dimension), layer opacity.
pub struct Blob {
pub rgb: (f64, f64, f64),
pub center: (f64, f64),
pub drift: (f64, f64),
pub speed: (f64, f64),
pub phase: (f64, f64),
pub radius: f64,
pub breathe: (f64, f64),
pub opacity: f64,
}
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so
/// the field shifts within one temperature instead of strobing through the rainbow.
pub const BLOBS: [Blob; 4] = [
Blob {
rgb: (0.53, 0.47, 0.96),
center: (0.30, 0.24),
drift: (0.16, 0.10),
speed: (0.111, 0.083),
phase: (0.0, 1.9),
radius: 0.52,
breathe: (0.07, 0.061),
opacity: 0.52,
},
Blob {
rgb: (0.24, 0.20, 0.72),
center: (0.78, 0.66),
drift: (0.13, 0.14),
speed: (0.071, 0.096),
phase: (2.4, 0.7),
radius: 0.58,
breathe: (0.08, 0.049),
opacity: 0.55,
},
Blob {
rgb: (0.62, 0.30, 0.80),
center: (0.16, 0.82),
drift: (0.12, 0.09),
speed: (0.089, 0.067),
phase: (4.1, 3.2),
radius: 0.44,
breathe: (0.09, 0.078),
opacity: 0.42,
},
Blob {
rgb: (0.22, 0.38, 0.86),
center: (0.70, 0.12),
drift: (0.10, 0.08),
speed: (0.059, 0.104),
phase: (1.2, 5.0),
radius: 0.40,
breathe: (0.06, 0.055),
opacity: 0.38,
},
];
/// The aurora as SkSL: the blob table baked into the source (only time + resolution are
/// uniforms), each blob an additive radial falloff to `radius/2` — the Cairo gradient's
/// stops — then the legibility scrim's piecewise-linear vertical darkening. Runs on the
/// GPU at full rate; the 30 Hz CPU-upscale path (and its frozen-on-Deck fallback) is the
/// thing this whole port deletes.
pub fn aurora_sksl() -> String {
let mut src = String::from(
"uniform float2 u_res;\nuniform float u_t;\n\
half4 main(float2 xy) {\n\
float side = max(u_res.x, u_res.y);\n\
float3 acc = float3(0.0);\n\
float2 c; float r; float a;\n",
);
for b in &BLOBS {
src.push_str(&format!(
" c = float2(({cx} + {dx} * sin(u_t * {sx} + {px})) * u_res.x, \
({cy} + {dy} * cos(u_t * {sy} + {py})) * u_res.y);\n\
r = side * {radius} * (1.0 + {b0} * sin(u_t * {b1} + {px}));\n\
a = max(0.0, 1.0 - distance(xy, c) / (r * 0.5));\n\
acc += float3({r0}, {g0}, {b0c}) * (a * {op});\n",
cx = b.center.0,
cy = b.center.1,
dx = b.drift.0,
dy = b.drift.1,
sx = b.speed.0,
sy = b.speed.1,
px = b.phase.0,
py = b.phase.1,
radius = b.radius,
b0 = b.breathe.0,
b1 = b.breathe.1,
r0 = b.rgb.0,
g0 = b.rgb.1,
b0c = b.rgb.2,
op = b.opacity,
));
}
// Scrim stops (0, .55) (0.35, .15) (0.65, .20) (1, .60): title (top) and
// detail/hints (bottom) stay on near-black whatever the blobs do behind them.
src.push_str(
" float v = xy.y / u_res.y;\n\
float s = v < 0.35 ? mix(0.55, 0.15, v / 0.35)\n\
: v < 0.65 ? mix(0.15, 0.20, (v - 0.35) / 0.30)\n\
: mix(0.20, 0.60, (v - 0.65) / 0.35);\n\
return half4(half3(acc * (1.0 - s)), 1.0);\n\
}\n",
);
src
}
// --- The shared binary↔overlay model ------------------------------------------------------
#[derive(Clone, PartialEq)]
pub enum LibraryPhase {
Loading,
/// Browse target isn't paired — pairing is the plugin's job, render the advice.
PairFirst,
Error {
title: String,
body: String,
can_retry: bool,
},
Empty,
/// Games are loaded — the carousel.
Ready,
}
#[derive(Clone)]
pub struct LibraryGame {
pub id: String,
pub title: String,
pub store: String,
}
struct Shared {
phase: LibraryPhase,
games: Vec<LibraryGame>,
/// Fetched poster bytes the renderer hasn't decoded yet (id, encoded image).
art_in: VecDeque<(String, Vec<u8>)>,
/// Bumped on phase/games changes so the renderer re-syncs its snapshot.
generation: u64,
}
/// The binary's write handle / the overlay's read handle — fetch threads push into it,
/// the renderer drains per frame. Cheap locks, no rendering data inside.
#[derive(Clone)]
pub struct LibraryShared(Arc<Mutex<Shared>>);
impl Default for LibraryShared {
fn default() -> Self {
LibraryShared(Arc::new(Mutex::new(Shared {
phase: LibraryPhase::Loading,
games: Vec::new(),
art_in: VecDeque::new(),
generation: 0,
})))
}
}
impl LibraryShared {
pub fn set_phase(&self, phase: LibraryPhase) {
let mut s = self.0.lock().unwrap();
s.phase = phase;
s.generation += 1;
}
/// Loaded games → the carousel (empty = the empty scene).
pub fn set_games(&self, games: Vec<LibraryGame>) {
let mut s = self.0.lock().unwrap();
s.phase = if games.is_empty() {
LibraryPhase::Empty
} else {
LibraryPhase::Ready
};
s.games = games;
s.generation += 1;
}
pub fn push_art(&self, id: String, bytes: Vec<u8>) {
self.0.lock().unwrap().art_in.push_back((id, bytes));
}
/// Renderer side: the generation stamp (re-snapshot on change).
pub(crate) fn generation(&self) -> u64 {
self.0.lock().unwrap().generation
}
pub(crate) fn snapshot(&self) -> (LibraryPhase, Vec<LibraryGame>, u64) {
let s = self.0.lock().unwrap();
(s.phase.clone(), s.games.clone(), s.generation)
}
pub(crate) fn drain_art(&self) -> Vec<(String, Vec<u8>)> {
self.0.lock().unwrap().art_in.drain(..).collect()
}
}
/// Store id → display label (the GTK `ui_library` table).
pub fn store_label(store: &str) -> &'static str {
match store {
"steam" => "Steam",
"custom" => "Custom",
"heroic" => "Heroic",
"lutris" => "Lutris",
"epic" => "Epic",
"gog" => "GOG",
"xbox" => "Xbox",
_ => "Game",
}
}
/// Monogram for the placeholder tile: the first letters of the first two words.
pub fn initials(title: &str) -> String {
title
.split_whitespace()
.take(2)
.filter_map(|w| w.chars().next())
.flat_map(char::to_uppercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// The GTK launcher's cursor tests, ported with the math.
#[test]
fn step_refuses_the_ends() {
assert_eq!(step_cursor(0, 5, -1, false), StepResult::Boundary);
assert_eq!(step_cursor(4, 5, 1, false), StepResult::Boundary);
assert_eq!(step_cursor(2, 5, 1, false), StepResult::Moved(3));
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
}
#[test]
fn jump_clamps_onto_the_ends() {
assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0));
assert_eq!(step_cursor(3, 5, JUMP, true), StepResult::Moved(4));
assert_eq!(step_cursor(0, 5, -JUMP, true), StepResult::Boundary);
}
/// Springs converge onto the target and stay finite through a stalled frame.
#[test]
fn springs_converge() {
let (mut pos, mut vel) = (0.0, 0.0);
for _ in 0..120 {
(pos, vel) = spring_advance(pos, vel, 3.0, SPRING_K, SPRING_C, 1.0 / 60.0);
}
assert!((pos - 3.0).abs() < 0.01, "{pos}");
let (p, v) = spring_advance(0.0, 0.0, 1.0, BUMP_K, BUMP_C, 0.05);
assert!(p.is_finite() && v.is_finite() && p > 0.0 && p < 2.0, "{p}/{v}");
}
/// The focused card (angle 0, scale 1) maps its center to (cx, cy) exactly.
#[test]
fn card_matrix_centers_the_focused_card() {
let m = card_matrix(640.0, 400.0, 0.0, 1.0, POSTER_W, POSTER_H, PERSPECTIVE);
// Apply to the card-local center (w/2, h/2, 0, 1).
let (x, y) = (POSTER_W as f32 / 2.0, POSTER_H as f32 / 2.0);
let px = m[0] * x + m[1] * y + m[3];
let py = m[4] * x + m[5] * y + m[7];
let pw = m[12] * x + m[13] * y + m[15];
assert!((px / pw - 640.0).abs() < 0.01, "{}", px / pw);
assert!((py / pw - 400.0).abs() < 0.01, "{}", py / pw);
}
/// A right-side card's INNER (left) edge recedes: its projected x compresses toward
/// the center relative to the flat card — the coverflow corridor.
#[test]
fn side_card_inner_edge_recedes() {
let flat = card_matrix(900.0, 400.0, 0.0, 1.0, POSTER_W, POSTER_H, PERSPECTIVE);
let tilted = card_matrix(900.0, 400.0, -ROTATE_DEG, 1.0, POSTER_W, POSTER_H, PERSPECTIVE);
let project = |m: &[f32; 16], x: f32, y: f32| {
let px = m[0] * x + m[1] * y + m[3];
let pw = m[12] * x + m[13] * y + m[15];
px / pw
};
// The inner edge is x=0 in card space. Perspective divide: receding (w < 1 side)
// pushes it AWAY from the vanishing center — the edge reads as farther.
let flat_left = project(&flat, 0.0, POSTER_H as f32 / 2.0);
let tilt_left = project(&tilted, 0.0, POSTER_H as f32 / 2.0);
let flat_right = project(&flat, POSTER_W as f32, POSTER_H as f32 / 2.0);
let tilt_right = project(&tilted, POSTER_W as f32, POSTER_H as f32 / 2.0);
// Tilt narrows the card's projected width (it turned away from the viewer).
assert!((tilt_right - tilt_left) < (flat_right - flat_left) * 0.95);
}
#[test]
fn initials_take_two_words() {
assert_eq!(initials("Dota 2"), "D2");
assert_eq!(initials("half-life"), "H");
}
/// The generated SkSL parses as far as syntax we control (sanity: balanced braces,
/// all four blobs baked in).
#[test]
fn aurora_sksl_shape() {
let src = aurora_sksl();
assert_eq!(src.matches("acc +=").count(), 4);
assert_eq!(
src.matches('{').count(),
src.matches('}').count(),
);
}
}