diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 1ba24b6b..8e60d50b 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -13,7 +13,7 @@ //! (portrait paths starting with `/` load from disk), the GPU-only dev path. use crate::session_main::{ - arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos, + arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, stats_tier, window_pos, }; use pf_client_core::gamepad::is_steam_deck; use pf_client_core::{discovery, library, trust, wol}; @@ -141,11 +141,18 @@ pub fn run(target: Option<&str>) -> u8 { let json_status = arg_flag("--json-status"); let settings_at_start = trust::Settings::load(); // The console's window and its input models are built ONCE, from the global defaults, and - // live across every launch — so the presentation-tier fields below (stats tier, touch and - // mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host - // profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec, - // audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means - // rebuilding the presenter's models per launch — profiles P4 territory, not P0. + // live across every launch — so the presentation-tier fields below (touch and mouse model, + // shortcut inhibit, match-window, render scale) are latched here and a per-host profile + // cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio, + // pad) is re-resolved per launch and does honor the binding. Closing the rest of that gap + // means rebuilding the presenter's models per launch — profiles P4 territory, not P0. + // + // ⚠ The STATS TIER used to be latched here too, and that was a bug people hit: the console's + // own settings screen writes the tier to the file and redraws its row, so the choice looked + // taken while every stream kept the tier the process started on — "no matter what I select + // the overlay is stuck on Detailed", cured only by restarting the app. It now rides + // `SessionParams` per launch (`stats_verbosity`), so the value below only seeds the loop + // until the first stream. Anything else moved off this snapshot has to travel the same way. let latched_mouse = settings_at_start.mouse_mode(); // Request-access hand-off: the launch handler stamps this when it starts a delegated-approval @@ -162,11 +169,8 @@ pub fn run(target: Option<&str>) -> u8 { ), fullscreen: fullscreen_mode(), window_pos: window_pos(), - // `--stats` forces the overlay visible without demoting a richer chosen tier. - stats_verbosity: match settings_at_start.stats_verbosity() { - trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal, - v => v, - }, + // Seeds the loop only — every launch carries its own freshly resolved tier. + stats_verbosity: stats_tier(&settings_at_start), touch_mode: settings_at_start.touch_mode(), mouse_mode: settings_at_start.mouse_mode(), invert_scroll: settings_at_start.invert_scroll, diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 0c3e804f..b82e55d3 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -116,6 +116,28 @@ mod session_main { std::env::args().any(|a| a == flag) } + /// The stats-overlay tier a session starts on: the resolved setting, except that + /// `--stats` (tooling/debug runs) forces the overlay VISIBLE without demoting an + /// explicitly chosen richer tier. + /// + /// One helper because three callers need the identical rule — both run modes' presenter + /// options and the per-launch [`session_params`] — and a fourth reading of it would be + /// the bug this is here to prevent. + pub(crate) fn stats_tier(settings: &trust::Settings) -> trust::StatsVerbosity { + stats_tier_with(settings.stats_verbosity(), arg_flag("--stats")) + } + + /// [`stats_tier`]'s rule, with argv lifted out so it is testable. + pub(crate) fn stats_tier_with( + chosen: trust::StatsVerbosity, + stats_flag: bool, + ) -> trust::StatsVerbosity { + match chosen { + trust::StatsVerbosity::Off if stats_flag => trust::StatsVerbosity::Normal, + v => v, + } + } + /// Running under Gaming Mode (a Deck, or any gamescope session): the environment /// where the local Steam UI owns the physical Steam/QAM buttons — the system-button /// "auto" policy keys off this. @@ -420,6 +442,12 @@ mod session_main { connect_timeout: connect_timeout(), force_software, profile, + // Presentation-tier, carried per launch rather than read once by the run loop: + // the console streams many sessions through ONE loop, so this is the only way a + // tier the user picked between streams (or one a host's profile carries) reaches + // the overlay before the app is restarted. Single mode passes the same value its + // presenter options already hold, so it changes nothing there. + stats_verbosity: stats_tier(settings), // Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity): // advertised only when the presenter has real on-glass latch stamps // (VK_KHR_present_wait) — without them there is no latch grid to report. The @@ -926,12 +954,7 @@ mod session_main { window_title: format!("Punktfunk · {title}"), fullscreen, window_pos: window_pos(), - // `--stats` forces the overlay visible (tooling/debug runs) without - // demoting an explicitly chosen richer tier. - stats_verbosity: match settings.stats_verbosity() { - trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal, - v => v, - }, + stats_verbosity: stats_tier(&settings), touch_mode: settings.touch_mode(), mouse_mode: settings.mouse_mode(), invert_scroll: settings.invert_scroll, @@ -1000,6 +1023,37 @@ mod session_main { } } } + + #[cfg(test)] + mod tests { + use super::*; + use trust::StatsVerbosity as V; + + /// `--stats` is a floor, never a ceiling: it lifts Off to Normal and leaves every + /// richer chosen tier alone. Both run modes' presenter options AND the per-launch + /// params read this one rule, which is the point of having it. + #[test] + fn the_stats_flag_lifts_off_and_demotes_nothing() { + assert_eq!(stats_tier_with(V::Off, true), V::Normal); + assert_eq!(stats_tier_with(V::Off, false), V::Off); + for chosen in [V::Compact, V::Normal, V::Detailed] { + assert_eq!(stats_tier_with(chosen, true), chosen); + assert_eq!(stats_tier_with(chosen, false), chosen); + } + } + + /// The console reads the file ONCE for its window, so a tier changed between streams + /// can only reach the overlay by riding the launch. Guards the wiring the field exists + /// for: whatever settings a launch resolved is what the params carry. + #[test] + fn a_launch_carries_the_tier_its_settings_resolved() { + let mut s = trust::Settings::default(); + for chosen in [V::Off, V::Compact, V::Normal, V::Detailed] { + s.set_stats_verbosity(chosen); + assert_eq!(stats_tier_with(s.stats_verbosity(), false), chosen); + } + } + } } #[cfg(any(target_os = "linux", windows))] diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index f881ea9d..bcffb7fb 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -104,6 +104,19 @@ pub struct SessionParams { /// above; it rides along so the stats overlay can answer "which profile am I on?" without /// re-reading any store (design/client-settings-profiles.md §5.2). pub profile: Option, + /// The stats-overlay tier THIS launch resolved to — the globals, or the profile bound to + /// this host. Presentation-tier, like [`profile`](Self::profile): the session controller + /// never reads it, it rides along so the presenter can adopt it when a browse-mode launch + /// starts. + /// + /// That adoption is the whole point. The console (Gaming Mode / Decky) builds its window + /// and its run loop ONCE and streams many sessions through them, so a tier taken only from + /// the loop's start-of-process options could never change again — a user picking a tier in + /// the console's settings screen saw the row move, the file updated, and every stream keep + /// the old overlay until the app was restarted. Carrying it per launch is what lets the + /// choice land on the next stream, and it makes a profile's `stats_verbosity` reach the + /// console too. The in-stream cycle chord still wins for the rest of the stream it moved. + pub stats_verbosity: crate::trust::StatsVerbosity, /// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass /// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid), /// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index fb8bbb80..f15809ea 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -1280,6 +1280,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result opts.render_scale_max_dim, ); } + // Adopt the tier this launch RESOLVED (globals or the host's + // profile) instead of keeping the one the process started on. + // `opts.stats_verbosity` only ever seeds the loop: the console + // outlives every stream, so without this a settings change + // reached the file and the settings row and nothing else until + // the app was restarted. + // + // Deliberately HERE and not in `StreamState::new`: the + // codec-fallback retry rebuilds the state from a clone of these + // params mid-stream, and doing it there would snap the overlay + // back every time a session fell down the codec ladder, undoing + // a cycle the user had just made with the chord. + stats_verbosity = params.stats_verbosity; // A live pump here would be DETACHED by the assignment // below — `StreamState` has no `Drop`, so its thread // would keep decoding onto the shared Vulkan device that