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.
494 lines
19 KiB
Rust
494 lines
19 KiB
Rust
//! 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 ms–1 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 ms–1 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;
|