A stats tier picked between streams reached nothing until the app was restarted #178

Merged
enricobuehler merged 1 commits from worktree-console-stats-tier-relatch into main 2026-08-12 15:38:05 +00:00
4 changed files with 101 additions and 17 deletions
+15 -11
View File
@@ -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,
+60 -6
View File
@@ -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))]
+13
View File
@@ -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<String>,
/// 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
+13
View File
@@ -1280,6 +1280,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
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