Files
punktfunk/crates/pf-console-ui/src/shell.rs
T
enricobuehler d5e23146c0 feat(client): M8 — the software rung is openh264 and rav1d, and swscale is gone
The ladder's last rung no longer runs FFmpeg. H.264 decodes through openh264,
AV1 through rav1d, and HEVC is refused outright: no permissively licensed
software HEVC decoder exists, so an HEVC session that exhausts its hardware
rungs now tears down and re-dials advertising HEVC-less caps, and the host
picks H.264. The plan calls that a first-class path; it is one.

swscale is deleted, and with it the BT.601 default that its correction code
existed to undo. Colour on the H.264 lane now comes from the same
pf-bitstream planner every hardware rung submits from — openh264 reports no
VUI at all — and AV1's comes per-picture from the sequence header. One colour
source, one CSC: the old default is unrepresentable rather than merely fixed.
Frames reach the presenter as three tightly-packed planes through the planar
CSC pass, which had to be un-gated from the pyrowave feature and its device
probe, since the last rung must exist on devices that failed that probe.

rav1d rather than the dav1d crate, deliberately and against the plan's
literal wording: dav1d-sys is system-deps-only, so it would add a system
library and a .pc file to every client package — in the milestone family
whose excision checklist exists to delete exactly that. rav1d is the same
decoder, same licence, statically linked. The cost is honest: no-asm builds
on both decoders, and software throughput is still unmeasured.

The colour test is the milestone's exit criterion, so it is built to fail.
Three fixtures, and a mutation check: hardcoding the swscale default turns the
red bar to [255,24,0], and swapping Cb/Cr turns red to blue — a silent error
no metadata assertion could catch. Review then disproved the range half of it
numerically: with eight saturated bars, decoding the full-range fixture with
the wrong range gives max error ZERO, because a mismatch only pushes values
outside [0,1] where the shader clamps. A mid-tone was added; the wrong range
now costs 11, well past the tolerance. The exit criterion I set was
satisfiable by a test that proved nothing.

Two blocking defects, both emergent rather than local.

Software AV1 on a 10-bit stream never reached its typed refusal: rav1d is
built 8-bit-only and returns ENOPROTOOPT, which the send loop turned into a
generic error, so the pump's typed downcast missed and every AU failed
identically — a permanent freeze on precisely the shipping case, since AV1 is
advertised only where hardware AV1 exists and hardware AV1 plus HDR is Main
10. The shape is now read from the sequence header before any byte reaches
the decoder, exactly as the H.264 leg reads the active SPS.

And the new Reconnecting phase was the first state that is not streaming, not
connecting, and still holding a live stream — which opened all three guards
that had made a second launch impossible. Pressing A assigned over `stream`
where every other site shuts down first, and StreamState has no Drop, so the
old pump was detached: a second live session still submitting to a Vulkan
device that gets destroyed underneath it. Nothing about the reconnect was
wrong in isolation; the defect lived between a new state and three guards
nobody re-examined. Start is now defensive and the retry raises the
connecting modal, so the UI matches the state and B can cancel.

Also closed: retry_caps was computed, tested and never applied, so a shape
refusal could end a session reporting no codec available while a working
retry existed; the retry inherited force_software sticky-true, landing an
HEVC→H.264 fallback on software H.264 with working hardware H.264; it
re-dialled with a stale mode; the CPU present arm had no survivable-failure
handling where the pyrowave arm — same pass — has it; HEVC is no longer
advertised when the decoder is pinned to software; and the software rung now
feeds the recovery-point SEI it already had in hand to the re-anchor gate.

⚠ Two host-side gaps found while tracing, neither in scope here: Hello::launch
is NOT idempotent (gog:/custom: targets spawn a second copy on a retry; the
field is kept verbatim because dropping it orphans the gamescope display whose
reuse key includes the command), and a reconnected session can never adopt a
game predating its own launch stamp, so it has no game-exit detection.

⚠ OWED: the on-glass software run. ~200 lines of new Vulkan on a path that
only runs because the GPU already failed, and no driver has seen it. The
review's minimum check is sync validation enabled, a non-multiple-of-16 mode,
a mid-session resize and demotion, and both colour matrices.

Gates: container clippy -D warnings over four crates, 236 tests, workspace
check. pf-vkdecode and pf-bitstream are byte-for-byte untouched, so the
hardware rungs' 250/250 stands.
2026-08-07 06:18:23 +02:00

494 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The console shell: the screen stack, the push/pop entrance/exit choreography, the
//! chrome every screen shares (pinned title, controller chip, hint bar), and the modal
//! overlays (connecting, waking, toasts). Screens draw CONTENT; the shell makes them
//! read — and move — as one coherent console.
//!
//! Transitions: a push slides the incoming screen up out of a fade while the outgoing
//! one recedes; a pop mirrors it. One eased 0→1 progress drives both layers (0.26 s,
//! ease-out cubic — the WinUI shell's entrance feel), each composited through a
//! `save_layer_alpha` so a screen fades as a unit, never element by element. The
//! backdrop crossfades in parallel when the screens disagree (aurora ↔ form).
use crate::anim::Progress;
use crate::glyphs::GlyphStyle;
use crate::library::{mesh_sksl, LibraryShared};
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
use anyhow::{anyhow, Result};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
use pf_client_core::trust;
use pf_presenter::overlay::OverlayAction;
use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
use std::collections::VecDeque;
use std::time::Instant;
mod overlays;
mod render;
const TRANSITION_S: f64 = 0.26;
/// Chrome bands (design units): the pinned title above, hints below.
const TOP_BAND: f64 = 64.0;
const BOTTOM_BAND: f64 = 86.0;
enum Motion {
None,
Push(Progress),
Pop { leaving: Box<Screen>, t: Progress },
}
struct Toast {
text: String,
at: f64,
}
struct Connecting {
title: String,
canceling: bool,
appear: f64,
/// A request-access wait (parked on the host until the operator approves) — the
/// takeover reads "Waiting for approval" rather than "Connecting".
request_access: bool,
}
/// What the session binary hands the shell at construction.
pub struct ConsoleOptions {
/// The machine's hostname — the default device name pairing registers.
pub device_name: String,
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
pub deck: bool,
}
pub(crate) struct Shell {
stack: Vec<Screen>,
motion: Motion,
console: ConsoleShared,
library: LibraryShared,
bus: ConsoleBus,
actions: VecDeque<OverlayAction>,
settings: trust::Settings,
hosts: Vec<HostRow>,
hosts_gen: u64,
device_name: String,
deck: bool,
pub(crate) in_stream: bool,
connecting: Option<Connecting>,
/// The last host title a connect was raised for, kept past the connect itself so
/// [`Self::session_reconnecting`] can name the host it is re-dialing — that flow
/// raises no `Launch` of its own and therefore never passes a title through.
last_connect_title: Option<String>,
wake: Option<WakeStatus>,
/// True while `wake` is the shell's own optimistic placeholder — raised the instant a
/// screen queues `ConsoleCmd::Wake` (see [`Self::apply`]), before the service thread has
/// round-tripped its first real `WakeStatus` (~100 ms1 s). `sync` must not clear the
/// placeholder in that window, or navigation would race the wake ungated (the "pressed A,
/// cursor drifted to Add Host, then got thrust into the stream" bug).
wake_optimistic: bool,
toast: Option<Toast>,
mesh: RuntimeEffect,
/// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition.
bg_mix: f64,
glyphs: GlyphStyle,
chip: Option<String>,
pads: Vec<PadInfo>,
t0: Instant,
last_frame: Option<Instant>,
}
impl Shell {
pub(crate) fn new(
console: ConsoleShared,
library: LibraryShared,
bus: ConsoleBus,
opts: ConsoleOptions,
stack: Vec<Screen>,
) -> Result<Shell> {
anyhow::ensure!(!stack.is_empty(), "the console needs a root screen");
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
let bg_mix = match stack.last().expect("non-empty").background() {
Bg::Aurora => 0.0,
Bg::Form => 1.0,
};
Ok(Shell {
stack,
motion: Motion::None,
console,
library,
bus,
actions: VecDeque::new(),
settings: trust::Settings::load(),
hosts: Vec::new(),
hosts_gen: u64::MAX,
device_name: opts.device_name,
deck: opts.deck,
in_stream: false,
connecting: None,
last_connect_title: None,
wake: None,
wake_optimistic: false,
toast: None,
mesh,
bg_mix,
glyphs: GlyphStyle::Keyboard,
chip: None,
pads: Vec::new(),
t0: Instant::now(),
last_frame: None,
})
}
fn t(&self) -> f64 {
self.t0.elapsed().as_secs_f64()
}
pub(crate) fn editing(&self) -> bool {
!self.in_stream
&& self.connecting.is_none()
&& self.stack.last().is_some_and(Screen::editing)
}
pub(crate) fn take_action(&mut self) -> Option<OverlayAction> {
self.actions.pop_front()
}
// --- Session lifecycle edges (from the overlay's `session_phase`) --------------------
pub(crate) fn set_connecting(&mut self, title: Option<String>) {
match title {
Some(title) => {
self.last_connect_title = Some(title.clone());
self.connecting = Some(Connecting {
title,
canceling: false,
appear: 0.0,
request_access: false,
})
}
None => self.connecting = None,
}
}
pub(crate) fn session_failed(&mut self, msg: &str) {
self.connecting = None;
self.in_stream = false;
self.show_toast(format!("Couldn't connect — {msg}"));
}
pub(crate) fn session_streaming(&mut self) {
self.connecting = None;
self.in_stream = true;
}
pub(crate) fn session_ended(&mut self, reason: Option<&str>) {
self.connecting = None;
self.in_stream = false;
if let Some(reason) = reason {
self.show_toast(format!("Session ended — {reason}"));
}
}
/// The stream stopped and the client is dialing again on its own (M8's codec
/// fallback). Says what changed — the picture is about to come back as a different
/// codec and silence would read as a glitch — and raises the connecting modal.
///
/// The modal is not cosmetic. Nothing raises a `Launch` for this retry (the run loop
/// starts the pump itself), so without it the shell would be in a state no other flow
/// produces: not streaming, not connecting, and a live pump behind the console. All
/// three gates would open at once — menu events flowing, the console drawn
/// full-screen over a frozen picture, and no modal interlock — and pressing A would
/// launch a SECOND session on top of the running one. This is also what gives B
/// somewhere to go: the modal's Back raises `CancelConnect`, which the run loop
/// applies to the retry's pump exactly as it does to a first dial.
///
/// `appear = 1.0`: the takeover is already the thing on screen (the retry follows a
/// live stream), so fading it in would read as a flash rather than a transition.
pub(crate) fn session_reconnecting(&mut self, msg: &str) {
self.in_stream = false;
self.connecting = Some(Connecting {
// The host this session was dialed to. `None` only if the shell never raised
// the connect itself (a `--connect` run has no console at all, so it never
// reaches here) — name the codec change instead of an empty string.
title: self
.last_connect_title
.clone()
.unwrap_or_else(|| "the host".to_string()),
canceling: false,
appear: 1.0,
request_access: false,
});
self.show_toast(msg.to_string());
}
fn show_toast(&mut self, text: String) {
self.toast = Some(Toast { text, at: self.t() });
}
// --- Model sync (hosts, pairing, wake) — before input and before render --------------
fn sync(&mut self) {
if self.console.hosts_gen() != self.hosts_gen {
(self.hosts, self.hosts_gen) = self.console.hosts_snapshot();
}
let pair = self.console.pair();
match &pair {
PairPhase::Idle => {}
PairPhase::Paired { key } => {
let name = self
.hosts
.iter()
.find(|h| &h.key == key)
.map_or_else(|| "the host".to_string(), |h| h.name.clone());
self.show_toast(format!("Paired with {name}"));
self.console.set_pair(PairPhase::Idle);
if matches!(self.stack.last(), Some(Screen::Pair(_))) {
self.apply_nav(Nav::Pop);
}
}
phase => {
if let Some(Screen::Pair(p)) = self.stack.last_mut() {
p.apply_phase(phase);
}
if matches!(phase, PairPhase::Failed(_)) {
self.console.set_pair(PairPhase::Idle);
}
}
}
match self.console.wake() {
Some(w) => {
self.wake_optimistic = false;
self.wake = Some(w);
}
// No service status yet: keep an optimistic placeholder alive — clearing it here
// would reopen the ungated window it exists to close.
None if !self.wake_optimistic => self.wake = None,
None => {}
}
if let Some(w) = &self.wake {
if w.online {
// Awake: stop the wake loop, and connect if that's what A meant.
let intent = w.then_connect.then(|| {
self.hosts
.iter()
.find(|h| h.key == w.key)
.map(|h| ConnectIntent {
addr: h.addr.clone(),
port: h.port,
fp_hex: h.fp_hex.clone(),
launch: None,
// A wake started from a pinned card carries its profile
// through to the connect (the row's key found it again).
title: match &h.pin {
Some(p) => format!("{} · {}", h.name, p.name),
None => h.name.clone(),
},
request_access: false,
profile: h.pin.as_ref().map(|p| p.id.clone()),
})
});
self.bus.send(ConsoleCmd::CancelWake);
self.wake = None;
if let Some(Some(intent)) = intent {
self.start_connect(intent);
// The wake takeover was already full-screen; skip the connect fade-in so the
// Waking → Connecting handoff is seamless (no flash of the home behind).
if let Some(c) = &mut self.connecting {
c.appear = 1.0;
}
}
}
}
}
fn start_connect(&mut self, intent: ConnectIntent) {
self.set_connecting(Some(intent.title.clone()));
if let Some(c) = &mut self.connecting {
c.request_access = intent.request_access;
}
self.actions.push_back(OverlayAction::Launch {
addr: intent.addr,
port: intent.port,
fp_hex: intent.fp_hex,
launch: intent.launch,
title: intent.title,
request_access: intent.request_access,
profile: intent.profile,
});
}
// --- Input ---------------------------------------------------------------------------
pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
self.sync();
// Modal precedence: the connect card, then the wake card, then the screens.
if let Some(c) = &mut self.connecting {
if ev == MenuEvent::Back && !c.canceling {
c.canceling = true;
self.actions.push_back(OverlayAction::CancelConnect);
return Some(MenuPulse::Confirm);
}
return None;
}
if let Some(w) = &self.wake {
match ev {
MenuEvent::Back => {
self.bus.send(ConsoleCmd::CancelWake);
self.wake = None;
self.wake_optimistic = false;
return Some(MenuPulse::Confirm);
}
MenuEvent::Confirm if w.timed_out => {
self.bus.send(ConsoleCmd::Wake {
key: w.key.clone(),
then_connect: w.then_connect,
});
return Some(MenuPulse::Confirm);
}
_ => return None,
}
}
// Mid-transition input is dropped — 0.26 s, and it keeps a double-tapped A
// from pushing two screens.
if !matches!(self.motion, Motion::None) {
return None;
}
let mut fx = Outbox::default();
let pulse = {
let mut ctx = Ctx {
hosts: &self.hosts,
library: &self.library,
settings: &mut self.settings,
pads: &self.pads,
deck: self.deck,
device_name: &self.device_name,
t: self.t0.elapsed().as_secs_f64(),
};
self.stack
.last_mut()
.expect("non-empty stack")
.menu(ev, &mut ctx, &mut fx)
};
self.apply(fx);
pulse
}
/// The keyboard fallback — the console is fully drivable with no pad. Arrows and
/// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary
/// (suppressed while editing, where letters are text).
pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool {
use sdl3::keyboard::Scancode as S;
if self.editing() {
if let Some(top) = self.stack.last_mut() {
if top.edit_key(sc) {
return true;
}
}
// Arrows etc. still drive the OSK grid below.
}
let editing = self.stack.last().is_some_and(Screen::editing);
let ev = match sc {
S::Left => MenuEvent::Move(MenuDir::Left),
S::Right => MenuEvent::Move(MenuDir::Right),
S::Up => MenuEvent::Move(MenuDir::Up),
S::Down => MenuEvent::Move(MenuDir::Down),
S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm,
S::Escape | S::Backspace if !repeat => MenuEvent::Back,
S::PageUp if !repeat => MenuEvent::JumpBack,
S::PageDown if !repeat => MenuEvent::JumpForward,
S::Y if !repeat && !editing => MenuEvent::Secondary,
S::X if !repeat && !editing => MenuEvent::Tertiary,
_ => return false,
};
self.handle_menu(ev); // no pad to pulse
true
}
pub(crate) fn text_input(&mut self, text: &str) {
if let Some(top) = self.stack.last_mut() {
top.text_input(text);
}
}
fn apply(&mut self, fx: Outbox) {
for cmd in fx.cmds {
// An input-initiated wake must gate input in the SAME call, exactly like
// `start_connect` gates via `connecting`: the service's first WakeStatus is
// ~100 ms1 s away, and until it lands the screen would keep navigating —
// then the arriving status freezes the UI wherever the cursor drifted, with
// the "Waking…" card never shown for a fast wake. Raise it optimistically;
// `sync` lets the service's real status supersede this placeholder.
if let ConsoleCmd::Wake { key, then_connect } = &cmd {
let name = self
.hosts
.iter()
.find(|h| &h.key == key)
.map(|h| h.name.clone())
.unwrap_or_default();
self.wake = Some(WakeStatus {
key: key.clone(),
name,
seconds: 0,
timed_out: false,
online: false,
then_connect: *then_connect,
});
self.wake_optimistic = true;
}
self.bus.send(cmd);
}
if let Some(text) = fx.toast {
self.show_toast(text);
}
if let Some(intent) = fx.connect {
self.start_connect(intent);
}
if let Some(nav) = fx.nav {
self.apply_nav(nav);
}
}
fn apply_nav(&mut self, nav: Nav) {
match nav {
Nav::Push(screen) => {
self.stack.push(*screen);
self.motion = Motion::Push(Progress::new(TRANSITION_S));
}
Nav::Pop => {
if self.stack.len() > 1 {
let leaving = self.stack.pop().expect("len > 1");
self.motion = Motion::Pop {
leaving: Box::new(leaving),
t: Progress::new(TRANSITION_S),
};
} else {
// Popping the root quits the console (B at home).
self.actions.push_back(OverlayAction::Quit);
}
}
}
}
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
// SAFETY: `uniforms` is a local `[f32; 3]` — exactly 12 bytes — and `f32` has no padding or
// invalid bit patterns, so reading it as bytes is sound; the slice is copied by
// `Data::new_copy` before `uniforms` goes out of scope.
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
Some(shader) => {
let mut paint = Paint::default();
paint.set_shader(shader);
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
}
None => {
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
}
}
}
}
#[cfg(test)]
mod tests;