feat(host): game-mode integration + dedicated game sessions
Implements design/gamemode-and-dedicated-sessions.md (Parts A1-A5 + B0-B2):
reconciles the merged display-management registry with session-mobile
Bazzite/SteamOS hosts and adds a per-launch dedicated gamescope mode.
- A1 DisplayOwnership {Owned,External,SessionManaged} + poolable_now(): the
registry pools only what it owns, so gamescope managed/attach outputs are no
longer double-owned by the registry AND the gamescope restore worker (fixes
the game-mode-reconnect stale-node wedge).
- A2 validated reuse: (backend,mode,launch,epoch) reuse key + kept_display_alive
liveness probe + reused_gen/mark_failed on a reused-display first-frame failure.
- A3 policy-driven managed restore (keep_alive replaces the hardcoded 5s debounce;
forever = held = gaming-rig truthful) + crash-restore persist + SIGKILL teardown
(kill_unit, applied to our transient unit AND the autologin stop -- validated
live on .181 to avoid the F44 GPU-context leak).
- A4 session epoch: observe_session_instance bumps the epoch + invalidate_backend
on a desktop-compositor instance change; gamescope spawns are exempt.
- A5 per-spawn log + PID-scoped gamescope node discovery.
- B0 game_session {auto,dedicated} policy (top-level, preset-orthogonal) +
pick_gamescope_mode dedicated_launch + steam -silent command shaping.
- B1 free the autologin Steam before a dedicated Steam spawn (single-instance).
- B2 game-exit -> APP_EXITED_CLOSE_CODE (0x52) clean session end.
Adversarially reviewed (11 findings fixed). Validated on glass (.181 Bazzite F44,
RTX 4090): dedicated spawn streams a real game smoothly; keep-alive reuse; the
SIGKILL fix avoids the F44 vkCreateDevice leak. Workspace green
(build / test --workspace / clippy -D warnings / fmt), OpenAPI + C header
regenerated, web console tsc + vite build green. clients/probe: bump the
no-video timeout 8s->45s for gamescope cold starts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,22 @@ pub struct Layout {
|
||||
pub positions: BTreeMap<String, Position>,
|
||||
}
|
||||
|
||||
/// How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is
|
||||
/// served (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes
|
||||
/// — a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers
|
||||
/// it. Linux-only in effect (a launching Windows session opens into the one desktop).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GameSession {
|
||||
/// Today's routing: the launch rides whatever session the box is in (managed Steam session on
|
||||
/// Bazzite/SteamOS, bare spawn on plain distros, spawned into the live desktop on KWin/Mutter/wlroots).
|
||||
#[default]
|
||||
Auto,
|
||||
/// A launching session always gets its OWN headless gamescope at the client's mode, nesting just
|
||||
/// the game — no Steam Big Picture, no game mode. Degrades to `auto` when gamescope is unavailable.
|
||||
Dedicated,
|
||||
}
|
||||
|
||||
/// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any
|
||||
/// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
@@ -202,6 +218,11 @@ pub struct DisplayPolicy {
|
||||
/// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).
|
||||
#[serde(default = "default_max_displays")]
|
||||
pub max_displays: u32,
|
||||
/// How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).
|
||||
/// Orthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`
|
||||
/// so existing `display-settings.json` files are untouched.
|
||||
#[serde(default)]
|
||||
pub game_session: GameSession,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
@@ -224,6 +245,7 @@ impl Default for DisplayPolicy {
|
||||
identity: Identity::default(),
|
||||
layout: Layout::default(),
|
||||
max_displays: 4,
|
||||
game_session: GameSession::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +301,11 @@ impl EffectivePolicy {
|
||||
/// transform, factored out pure so arranging displays stays orthogonal to the other axes and is
|
||||
/// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new
|
||||
/// layout — rule; a named preset would ignore them.)
|
||||
pub fn with_manual_layout(&self, positions: BTreeMap<String, Position>) -> DisplayPolicy {
|
||||
pub fn with_manual_layout(
|
||||
&self,
|
||||
positions: BTreeMap<String, Position>,
|
||||
game_session: GameSession,
|
||||
) -> DisplayPolicy {
|
||||
DisplayPolicy {
|
||||
version: 1,
|
||||
preset: Preset::Custom,
|
||||
@@ -292,6 +318,8 @@ impl EffectivePolicy {
|
||||
positions,
|
||||
},
|
||||
max_displays: self.max_displays,
|
||||
// Preserve the orthogonal game-session axis (EffectivePolicy doesn't carry it).
|
||||
game_session,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,6 +426,13 @@ impl DisplayPolicyStore {
|
||||
self.configured().map(|p| p.effective())
|
||||
}
|
||||
|
||||
/// The game-session routing axis (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to
|
||||
/// the preset — read directly off the stored policy (or the default `Auto` when unconfigured), so a
|
||||
/// preset selection never resets it.
|
||||
pub fn game_session(&self) -> GameSession {
|
||||
self.get().game_session
|
||||
}
|
||||
|
||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
||||
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
||||
@@ -560,7 +595,9 @@ mod tests {
|
||||
let mut positions = BTreeMap::new();
|
||||
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
||||
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
||||
let p = eff.with_manual_layout(positions);
|
||||
let p = eff.with_manual_layout(positions, GameSession::Dedicated);
|
||||
// The orthogonal game-session axis is preserved through the layout transform.
|
||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||
assert_eq!(p.preset, Preset::Custom);
|
||||
// …every other behavior axis is preserved verbatim…
|
||||
|
||||
Reference in New Issue
Block a user