# Game-mode integration & dedicated game sessions — design > **Status: IMPLEMENTED (2026-07-05), Linux on-glass validation pending.** Parts A (A1–A5) and B > (B0–B2) landed; `cargo build`/`test --workspace`/`clippy -D warnings`/`fmt` green, OpenAPI > regenerated. It reconciles the shipped display-management layer (`design/display-management.md`, > Stages 0–5 merged `95b3496`) with **session-mobile hosts** — Bazzite/SteamOS boxes that flip > between Steam Game Mode (gamescope) and a KDE/GNOME desktop — and adds **dedicated game sessions**: > a `game_session=dedicated` policy that serves every library launch from its own host-spawned > headless gamescope at the client's exact mode, booting straight into the game with no Steam Big > Picture and no game mode. > > ## Implementation status (as-built — read the deviations) > > - **A1 (ownership classes) — DONE.** `DisplayOwnership {Owned, External, SessionManaged}` on > `VirtualOutput`; the registry pools only `Owned` outputs on the default daemon. gamescope > spawn = `Owned`, attach = `External`, managed/SteamOS = `SessionManaged`, KWin/Mutter/Windows = > `Owned`, wlroots = `Owned` but gated out by `remote_fd`. Plus `VirtualDisplay::poolable_now()` > so a managed/attach acquire never reuses a kept bare-spawn. **This alone removes the reported > Bazzite game-mode-reconnect stale-node wedge** (managed sessions are no longer pooled). > - **A2 (validated reuse) — DONE.** Reuse keys on `(backend, mode, launch, epoch)`; > `VirtualDisplay::kept_display_alive()` (gamescope checks its node is still present) tears a dead > kept display down and recreates; `VirtualOutput::reused_gen` + `registry::mark_failed(gen)` on a > reused-display first-frame failure so the retry loop creates fresh instead of re-wedging. > - **A3 (managed restore) — DONE, but DEVIATES from the plan below.** The managed/SteamOS session > is a **single-instance box singleton** (one Steam per uid), so making it a registry-`Owned` pool > entry (as §4-A3 sketches) collides with its single-instance nature on a mode-change relaunch > (two `ManagedSessionHandle` drops fighting over the shared `SESSION_UNIT`/autologin). Since A1 > already fixed the wedge, A3 instead **keeps the managed session's own single-instance lifecycle** > (`MANAGED_SESSION`/`STOPPED_AUTOLOGIN`/`STEAMOS_TOOK_OVER` + the restore worker) and makes only > its **restore policy-driven**: `schedule_restore_tv_session` reads `restore_delay()` = the > `keep_alive` policy (`off`→0s, `duration`→N s, **`forever`→never** = gaming-rig held, unconfigured > →5s bit-for-bit). Plus **crash-restore persistence** (`$XDG_RUNTIME_DIR/punktfunk-session-takeover.json` > → `restore_takeover_on_startup`) and the **SIGKILL-teardown** experiment in `stop_session` > (followups.md #1/#7). *Not done:* `/display/release` freeing a `forever`-held managed session > (managed isn't a registry entry) — a forever managed box returns to gaming mode by manual switch > or host restart (documented; acceptable for a dedicated couch appliance). > - **A4 (session epoch) — DONE.** `session_epoch()`/`bump_session_epoch()` + `ActiveSession.compositor_pid`; > `observe_session_instance()` (called from the per-connect resolve, the mid-stream watcher, the > capture-loss re-detect, and the GameStream acquire) bumps the epoch + `registry::invalidate_backend` > on a compositor-instance change (kind change OR same-kind restart). `take_expired` also reaps > kept dead-epoch entries. > - **A5 (addressed discovery) — PARTIAL.** Per-spawn **log** (`$XDG_RUNTIME_DIR/punktfunk-gamescope-.log`) > + **scoped node discovery** (`find_gamescope_node_scoped` by `application.process.id` / > `descends_from`) — fixes concurrent bare-spawn VIDEO-node ambiguity (the load-bearing correctness > part). **DEFERRED:** the per-instance **EIS input relay** — the injector-coupling rework the map > warned about ("EIS setup timed out"); input for concurrent gamescopes stays on the global relay, > which is exactly `design/gamescope-multiuser.md` scope. Noted there. > - **B0 (dedicated routing) — DONE.** `GameSession {Auto, Dedicated}` — a **top-level** `DisplayPolicy` > field (NOT in `EffectivePolicy`, so presets don't clobber it). `pick_gamescope_mode` gains a leading > `dedicated_launch` that forces `Spawn` (below explicit operator env, above managed-infra/foreign); > `wants_dedicated_game_session()` (launch present ∧ policy dedicated ∧ gamescope available, else > honest `auto` fallback) → `resolve_compositor(pref, dedicated)` forces gamescope; threaded through > `apply_input_env(chosen, dedicated_launch)`. Steam URIs are shaped `steam -silent steam://…` > (`shape_dedicated_command`). GameStream shares the dispatch. > - **B1 (Steam single-instance takeover) — FOCUSED.** A dedicated **Steam** spawn frees the box's > autologin/game-mode Steam first (`is_steam_launch` → `stop_autologin_sessions`, restored on session > end via the A3 machinery). *Not done:* force-releasing a *kept dedicated Steam* session for a > *different* Steam game (two concurrent dedicated Steam sessions) — rare; documented degradation. > - **B2 (game-exit clean end) — DONE.** A dedicated gamescope session whose node vanishes > (`dedicated_game_exited()` — gamescope is single-app, dies with its game) ends the session with the > new `APP_EXITED_CLOSE_CODE` (0x52) so launcher clients return to their library, instead of the 40 s > capture-loss rebuild timeout. > - **B3 (docs/console) — this block + docs-site + CLAUDE.md; the web console toggle is the remaining > surface.** The `game_session` axis is in the API + OpenAPI now. > > **Adversarial review pass (2026-07-05):** a multi-agent review confirmed 11 findings, all fixed: > (1) `registry::acquire` held the pool mutex across the `pw-dump` liveness probe → the probe now runs > OUTSIDE the lock (snapshot candidate under lock → probe → re-lock + re-find by gen); (2–5,7,10) the > session epoch / `invalidate_backend` wrongly tore down **independent gamescope spawns** on any Gaming > pid flap (the CRITICAL "dedicated Steam on a game-mode box self-destructs" — B1 stopping the autologin > flipped the winning PID) → `observe_session_instance` now acts ONLY on **desktop** compositor changes, > and `epoch_matches` exempts gamescope from the epoch (reuse + reap); (3) a dedicated-Steam reconnect > didn't cancel the pending TV restore (reuse skips `create`) → `cancel_pending_tv_restore()` on every > connect; (4,8) B2 game-exit used the UNSCOPED node scan → now scoped to the session's own `node_id` > (threaded through the pipeline); (9) an unknown launch id → blank gamescope → dedicated now gated on > the launch RESOLVING to a command. All green after fixes (`test --workspace` 360). > > **Remaining before this is "shipped":** Linux on-glass validation (`.116` Bazzite-KWin game-mode > reconnect under presets, `.21`/`.116` dedicated launch, gaming-rig-forever held-managed, crash-restore, > SIGKILL-teardown F44 check), and the two documented deferrals (A5 per-instance EIS relay → > gamescope-multiuser; B1 concurrent-dedicated-Steam release). > > The **PLAN as originally written follows** — it still reads as "PLANNED"; the status block above is > the authority on what actually shipped and where it deviates. Companion docs: `design/display-management.md` (the policy/registry layer this reconciles — its §7 capability matrix and §5.1 gamescope semantics are amended here), `design/session-aware-host-followups.md` (open session-switch limitations #1/#7 are resolved by Part A3 below), `design/gamescope-multiuser.md` (per-session input/audio isolation — adjacent; Part B deliberately stops short of it and cross-references instead). ## 1. The two problems **(A) The display-management rewrite does not compose with Bazzite game mode / KDE switching.** The registry (`vdisplay/registry.rs`) assumes it owns every display it pools: it holds the backend keepalive, decides linger/pin/teardown from the policy, and reuses kept displays by `(backend, mode)`. On a session-mobile box that assumption is false twice over — the gamescope managed/attach paths hand it outputs whose lifecycle is owned by *other* machinery (`MANAGED_SESSION` + the debounced TV-restore worker), and the compositor under **every** Linux backend can be killed and replaced at any moment by a Game↔Desktop switch. The result is pool entries that lie: they linger or pin while the session behind them is torn down, then get reused as dead nodes and wedge the whole retry budget. **(B) Library launches deserve a first-class "just the game" mode.** Today a library launch on a Bazzite box rides the managed `gamescope-session-plus` Steam session (Big Picture at the client's mode, launch forwarded into the running Steam) — the game appears, but inside game mode's whole UX, with Steam BPM boot time and game-mode ownership of the box. The bare-spawn path that nests *just* the resolved command in a headless gamescope already exists and works — but the sub-mode ladder makes it unreachable exactly where users want it most (any box with session-plus/SteamOS infra picks managed). The ask: a host option so a library launch **always** gets a dedicated gamescope session at the client's requested mode — game boots directly; non-Steam titles instantly, Steam titles without any UI to navigate. These are one design because both reduce to the same question: **who owns a gamescope session's lifecycle, and how does the registry know?** Part A answers it for what exists; Part B builds the new launch mode on the answer. ## 2. Failure inventory (code-anchored) What actually goes wrong today, in dependency order: 1. **The registry pools externally-owned outputs.** `registry::linux::acquire` pools every output with `remote_fd == None`. The gamescope **managed**, **SteamOS-takeover**, and **attach** paths all return `remote_fd: None` with `keepalive: Box::new(())` (`gamescope.rs::create_managed_session` / `create_managed_session_steamos` / the attach arm) — a unit keepalive that keeps *nothing* alive. The pool entry claims ownership of a display it cannot hold or tear down. 2. **Two lifecycle owners for the managed session → stale-node reuse wedge.** The real managed lifecycle is `MANAGED_SESSION` + `PENDING_RESTORE` + `RESTORE_DEBOUNCE` (hardcoded 5 s) + the restore worker, which stops the session and restarts the TV autologin. With any `keep_alive` policy configured, a disconnect leaves a registry entry Lingering (e.g. 300 s) while the restore worker kills the session at 5 s. A reconnect inside the linger window **reuses the dead `node_id`**; capture fails "no PipeWire frame within 10s"; the capturer drop returns the entry to Lingering; `build_pipeline_with_retry` re-acquires the **same corpse** on every attempt — all 8 retries wedge (~90 s). This is the `.41`-class "game-mode reconnect broken" symptom, now *manufacturable on any Bazzite box by clicking a console preset*. 3. **`gaming-rig` (keep_alive=forever) is a lie on gamescope.** Design §5.1 promised "the policy duration replaces the hardcoded 5 s debounce; forever = the TV session is never auto-restored". Never implemented: the restore worker doesn't read the policy, so `forever` pins an entry whose session the worker restores away regardless. 4. **A session switch leaves zombie entries.** The mid-stream watcher rebuild and the per-connect re-detect drop the old backend's lease → the old entry lingers/pins **for a compositor that no longer exists** (KWin dies on the switch to game mode; the game-mode gamescope dies on the switch to desktop). `/display/state` lies; expiry drops keepalives into dead compositors; a Desktop→Game→Desktop bounce brings up a *new* KWin whose node-id space has no relation to the kept entry's — reuse hands out a number that now means nothing. 5. **Discovery is ambient and ambiguous.** One shared spawn log (`/tmp/punktfunk-gamescope.log`), name-based node discovery (`find_gamescope_node` returns *any* `gamescope` `Video/Source`), and one global EIS relay file (`punktfunk-gamescope-ei`). Correct only while at most one gamescope exists per uid. A kept spawn + live game mode, two concurrent spawns, or (Part B) a dedicated session next to game mode can capture the wrong node and inject into the wrong session. 6. **Stock Bazzite restarts the systemd user manager on every Game↔Desktop switch** (observed on `.41`: user-manager PID churn), killing the host, the compositor, and the pool together. Inherent — keep-alive **cannot** span a switch on a stock setup (the headless-appliance setup — `enable-linger` + `multi-user.target` — keeps the host alive and can). Separately, the takeover bookkeeping (`STOPPED_AUTOLOGIN`, `STEAMOS_TOOK_OVER`, the SteamOS drop-in) is process memory only: a host crash mid-stream strands the box out of game mode with no restore on restart. ## 3. Design principles - **One owner per display.** Every `VirtualOutput` declares who owns its lifecycle; the registry pools only what it owns. This extends the CLAUDE.md invariant ("display lifecycle is owned by the registry; sessions hold leases") with its honest converse: *what the registry does not own, it must not pretend to keep.* - **Keep-alive is an optimization, never a failure mode** (display-management §3, now enforced): reuse validates liveness; a failed reuse invalidates and creates fresh; the retry budget is never spent twice on the same corpse. - **The compositor instance, not the host process, is the unit of session truth.** A session epoch invalidates every display created under a previous compositor instance — kind changes *and* same-kind restarts. - **Addressed, not ambient, discovery.** Every spawned gamescope gets its own log, its own node resolution, its own EIS relay. Global singletons are what break the moment two sessions coexist. - **Launch identity is part of display identity.** A kept display running game A must never be handed to a session asking for game B. ## 4. Part A — ownership & session mobility ### A1. Ownership classes (the structural fix — ship first) `VirtualOutput` gains an ownership declaration: ```rust pub enum DisplayOwnership { /// The registry owns lifecycle: pool, linger, pin, tear down. (KWin, Mutter, gamescope /// SPAWN, Windows manager-delegated.) Owned, /// Someone else's display, mirrored: no keep-alive, no topology, no reuse — codifies the /// display-management §7 "attach = unmanaged pass-through" row the code never implemented. /// (gamescope ATTACH; wlroots stays gated on remote_fd as today.) External, /// A box-level session the gamescope module manages (managed session-plus / SteamOS /// takeover). Pass-through at A1 (capturer owns the lease as pre-Stage-1); A3 converts it /// to Owned by giving the registry the real keepalive + restore duty. SessionManaged, } ``` The registry pools `Owned` only; `External`/`SessionManaged` pass through unchanged (today's pre-registry behavior — teardown-on-capturer-drop is a no-op for their unit keepalives, and the existing gamescope-module machinery keeps doing what it does). `remote_fd == Some` keeps its gate. Effect: failures 1–3 stop being reachable from the console — a preset can no longer create lying entries for game-mode sessions. Smallest possible diff; everything else layers on it. Until A3 lands, keep-alive on gamescope managed/attach honestly reports **unsupported** in `/display/state` capabilities instead of pretending. ### A2. Validated reuse, invalidation, launch key - **Launch key.** `Entry` gains `launch: Option` (the resolved launch command the display was created with; `None` = plain desktop). Reuse requires `(backend, mode, launch)` equality. Without this, a kept spawn running game A is reused for a session that asked to launch game B — latent today (unconfigured Linux lingers Immediate), live the moment keep-alive + launches combine, load-bearing for Part B. - **Liveness probe at reuse.** New trait method, default honest: `fn kept_display_alive(&mut self, node_id: u32) -> bool { true }` — gamescope-spawn checks the child (`try_wait`) *and* the node; KWin/Mutter check the node still exists on the default daemon (one cheap PipeWire registry roundtrip — no capture attach). A dead entry is torn down (its group restore handed off / run) and the acquire falls through to a fresh create. - **Invalidation on reuse failure.** `acquire` marks the returned `VirtualOutput` as reused (a flag on the lease/gen); when `build_pipeline`'s first-frame fails on a **reused** display, it calls `registry::mark_failed(gen)` before returning the error — the entry is torn down, so the retry loop's next `acquire` **creates fresh** instead of re-wedging on the same corpse. This is the direct fix for failure 2's 8×10 s wedge shape, and it holds for every future way a kept display can silently die. ### A3. One owner for the managed session (registry-owned restore) The managed/SteamOS paths become `Owned` by giving the registry the two things it lacks: - **A real keepalive.** `create_managed_session*` returns a `ManagedSessionHandle` whose `Drop` performs today's `do_restore_tv_session` duty: stop the transient unit / remove the SteamOS drop-in, then restart the autologin **iff no desktop session is active** (the existing guard moves in verbatim — a user who switched to KDE mid-linger is never yanked back to game mode). - **The policy as the debounce.** The registry linger *is* the restore delay: unconfigured default = 5 s (bit-for-bit today's `RESTORE_DEBOUNCE`), a configured duration replaces it, `forever` = never auto-restore — `gaming-rig` finally means on a Bazzite couch box what its story says, released via `/display/release` or the tray. A reconnect inside the linger is a registry **reuse** (the same warm-session fast path `PENDING_RESTORE`-cancel gives today, now with A2 validation); a different requested mode tears down + relaunches through the registry (gamescope's honest "reconfigure = recreate"). - **Retire the parallel machinery.** `PENDING_RESTORE`, `RESTORE_DEBOUNCE`, `start_restore_worker`, and the `restore_managed_session()` call sites go away — the registry linger timer is the one timer. `MANAGED_SESSION` survives only as the module's mode/unit cache. `STOPPED_AUTOLOGIN`/`STEAMOS_TOOK_OVER` stay as the *mechanics* the handle's Drop consumes. - **Persist the takeover.** The stopped-unit list + SteamOS-drop-in marker are written to `$XDG_RUNTIME_DIR/punktfunk-session-takeover.json` at takeover, cleared at restore. On host startup, a leftover file (crash / service restart mid-stream) schedules a restore after a short reconnect grace — with the same active-desktop guard. A crashed host no longer strands the TV. - **Teardown signal.** Adopt the parked follow-ups doc #1 experiment here: the handle's stop uses `systemctl --user kill --signal=SIGKILL ` (+ `stop`/`reset-failed` to clear unit state) instead of plain SIGTERM stop, testing the hypothesis that skipping gamescope's crashy SIGTERM teardown avoids the F44 GPU-context leak. A3's validation pass is the natural place to measure it; fall back to SIGTERM if SIGKILL misbehaves. Follow-ups #7 (restore-guard/keep-warm interaction) dissolves: "keep warm" is now just `keep_alive: forever`. ### A4. Session epoch & backend invalidation - **`vdisplay::session_epoch()`** — a host-global counter bumped whenever session detection observes a different compositor **instance**: an `ActiveKind` change *or* a new compositor PID for the same kind (the Desktop→Game→Desktop bounce). Entries stamp their creation epoch; reuse requires an epoch match; the linger timer reaps entries from dead epochs (their keepalive Drops hit dead sockets and fail fast; the registry already drops outside the lock). - **Watcher hook.** A `SessionSwitch` (and a per-connect re-detect that finds the previous backend's compositor gone) additionally calls `registry::invalidate_backend(old)` so `/display/state` is honest immediately rather than at the next expiry poll. - **Stock-Bazzite user-manager restarts** stay inherent: the host dies with the switch, the pool dies with the host, and that is documented ("keep-alive spans a Game↔Desktop switch only on the headless-appliance setup"). The persisted takeover file (A3) is what survives. ### A5. Addressed discovery (Part-B prerequisite) - **Per-spawn log**: `$XDG_RUNTIME_DIR/punktfunk-gamescope-.log`; the spawned instance's node id is parsed from *its* log only. - **Scoped node discovery**: PipeWire node props carry `application.process.id` — a spawn's node must belong to our child's PID tree (`descends_from`, already written); managed/attach discovery conversely **excludes** nodes owned by our spawned children. `find_gamescope_node` grows a scope parameter instead of "first node named gamescope". - **Per-instance EIS relay**: `punktfunk-gamescope-ei-`, path carried on `VirtualOutput` (the gamescope-multiuser doc's item 1), with the injector service resolving the session's own relay for gamescope sessions (a narrow slice of its item 2 — the shared injector stays for the portal backends, where shared input is correct and where per-session churn caused the historic "EIS setup timed out" wedge). ### Part A validation (on-glass, `.116` Bazzite KWin/AMD + Deck `.253`; `.21` for spawn-on-GNOME) 1. Game-mode reconnect: connect → disconnect → reconnect inside game mode, under *every* preset incl. `gaming-rig` (the failure-2/3 repro — must reuse the warm session or cleanly recreate, never wedge the retry budget). 2. Game↔Desktop switch mid-linger both directions; Desktop→Game→Desktop bounce (epoch test); `/display/state` never lists a display whose compositor is dead. 3. `gaming-rig` on Bazzite: TV stays off until `/display/release`; a KDE switch mid-linger is not yanked back to game mode. 4. Kill -9 the host mid-takeover → restart → TV session restored (persisted-takeover test). 5. Two gamescopes coexisting (kept spawn + game mode): capture and input land in the right one. ## 5. Part B — dedicated game sessions for library launches ### 5.1 What it is A session that carries a launch id (native `Hello.launch`, the GTK `--browse`/`--launch` flows, Decky pins, GameStream apps with a library id) can be served by a **dedicated gamescope session**: a host-spawned headless gamescope at exactly the client's WxH@Hz whose nested command is the resolved game. No Steam Big Picture, no game mode, no desktop involvement. Session end returns the client to its launcher (already shipped behavior); the game survives disconnects per `keep_alive` — the Apollo-style detach/reattach the design always wanted, now per-game. ### 5.2 Policy surface One new axis in `display-settings.json` (same store/PUT/console pattern; serde-defaulted so existing files are untouched): ```json5 // How a session that LAUNCHES a game is served: // "auto" – 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) // "dedicated" – a launching session always gets its own headless gamescope at the client's // mode, nesting just the game "game_session": "auto" ``` Sessions **without** a launch id are untouched by this axis — desktop streaming routes exactly as today. `dedicated` degrades honestly: no gamescope binary → log + fall back to `auto`. Console: a toggle on the Virtual displays card (it is a display-lifecycle decision) with one story line. Deliberate non-options for v1: per-entry overrides (schema keys allow a later `"per_entry": {"": …}` overlay) and a client-requested Hello byte ("launch dedicated") — host policy first, protocol growth when a client actually wants to differ per connect. ### 5.3 Routing & command shaping - **Sub-mode ladder.** `pick_gamescope_mode` (pure, unit-tested) gains a leading `dedicated_launch: bool` input that forces `Spawn`, outranking managed-infra/foreign-attach (explicit operator `MANAGED`/`ATTACH`/`NODE` envs still win — they are debug/CI overrides). `resolve_compositor` computes it: launch id present ∧ policy `dedicated` ∧ gamescope available → chosen = `Gamescope`, spawn sub-mode; `launch_is_nested` then routes the command into the spawn as today. - **Non-Steam entries** (custom / lutris / heroic): the resolved command nests directly — truly instant (gamescope up in ~1 s, then the game's own boot). - **Steam entries** (`steam steam://rungameid/`): Steam is single-instance per uid, so: 1. Command shape becomes `steam -silent steam://rungameid/` in dedicated mode — `-silent` suppresses the Steam main window so the game is the gamescope focus. **Empirical validation item** (behavior of `-silent` under a fresh nested Steam); fallback is the plain URI form (a briefly-visible Steam client, still no BPM navigation). 2. If another same-uid Steam is live (game mode autologin, a kept managed session, a kept dedicated Steam session): **take Steam over first** — force-release kept entries whose `launch` is a Steam title, stop the autologin via the A3-owned takeover (persisted state, policy-driven restore). This reuses the exact machinery game mode streaming already needs; `dedicated` adds no new churn class. - **Game exit ends the session.** When the nested command exits (user quits the game), the gamescope child dies; today's capture-loss path would rebuild an empty session. Dedicated sessions instead end cleanly: the rebuild path consults the keepalive child (`try_wait`) and a confirmed child exit becomes a typed session end (host closes QUIC with a new `APP_EXITED_CLOSE_CODE`, sibling of `QUIT_CLOSE_CODE` 0x51, so launchers can distinguish "game ended" from an error) with `force_immediate` release. Clients need no change to *work* (they already return to the launcher on session end); the typed code is polish they can adopt. - **Mid-stream `Reconfigure`** on a dedicated session = teardown + respawn (gamescope cannot live-resize its output; the game restarts) — the same honest §7 caveat as managed, documented. ### 5.4 Lifecycle composition (where Part A pays off) - Dedicated outputs are `Owned` (A1) with the child as a real keepalive → they pool naturally. - Reuse keys on `(backend, mode, launch)` (A2): reconnect to the same game re-attaches to the still-running session instantly; a different game never falsely reuses. `keep_alive` then reads exactly as the presets promise: `off` = game dies with the disconnect; a duration = the detach window; `forever` = the game runs until released (`gaming-rig`, per-game). - Each dedicated session is already its own registry **group** (`group_key` — no topology or restore interaction with the desktop); `max_displays` bounds how many can be kept; Steam titles are additionally bounded to one by the single-instance takeover above. - Input rides the per-instance EIS relay (A5); uinput gamepads are per-session already. Audio and mic stay the host-wide shared services — one *active* dedicated session is the designed case; concurrent independent-audio sessions are exactly `design/gamescope-multiuser.md` scope, not re-solved here. - Admission (`mode_conflict`) applies unchanged across clients; a second client asking for a different dedicated game under `separate` gets its own spawn (non-Steam) or the single-instance takeover rules (Steam) — the honest per-backend gating pattern. ### 5.5 GameStream Same dispatch (the launch path was unified in the 2026-07-01 rebuild): an apps.json/library-id launch under `dedicated` spawns the same way; serverinfo/RTSP negotiate the client's mode as today. Moonlight's quit-app (`h_cancel`) maps to a `force_immediate` release — killing the game on explicit quit — which folds into the already-deferred "GameStream quit-code" follow-up from display-management §5.1. ### 5.6 Client experience & latency honesty No client changes are required: GTK `--browse`/`--launch`, Decky pins, and the Apple/Android library grids just launch, and session end already returns to the launcher. Boot-time expectations, stated plainly in docs: non-Steam titles are gamescope-spawn (~1 s) + game boot; Steam titles pay the Steam client's own cold boot inside the fresh session (~10–25 s class) before the game process starts — "no UI to navigate", not "zero seconds". A pre-warmed parked Steam (`steam -silent` held inside a background headless gamescope at host start) would close that gap but fights game mode over the single instance — explicitly out of scope for v1, noted as the one candidate v2 if Steam cold boot proves to be the complaint. **Stretch** (needs a small mgmt surface, not v1): launchers showing "Resume" for a game the host reports as kept (`/display/state` already exposes the entries; adding `launch` to `DisplayInfo` is the only schema growth). ## 6. Staging & dependencies | Stage | Contents | Depends on | Validation | |---|---|---|---| | **A1** | `DisplayOwnership`, pool `Owned` only, honest capabilities | — | unit + `.116` game-mode reconnect under presets no longer wedges | | **A2** | launch key, `kept_display_alive`, `mark_failed` on reused-display capture failure | A1 | probe-driven: kill a kept display's session → reconnect creates fresh on attempt 2 | | **A3** | `ManagedSessionHandle`, policy-as-debounce, retire restore worker, persisted takeover, SIGKILL-teardown experiment | A1 | `.116`/Deck: gaming-rig semantics, crash-restore, desktop-guard | | **A4** | session epoch, `invalidate_backend` on switch | A1 | `.116`: switch matrix incl. same-kind bounce; `/display/state` honesty | | **A5** | per-spawn log, scoped node discovery, per-instance EIS relay | — (parallel) | two-gamescope coexistence test | | **B0** | `game_session` policy + ladder input + steam `-silent` shaping | A1 (correctness), A5 (if game mode may be live) | `.21`/plain box: non-Steam + Steam dedicated launch on glass | | **B1** | Steam takeover integration (autologin stop / kept-Steam release) | A3 | `.116`/Deck: dedicated launch from game mode, TV restore per policy | | **B2** | reuse-by-launch reattach + game-exit-ends-session (`APP_EXITED` close) | A2 | disconnect → game keeps running → reattach; quit game → launcher | | **B3** | docs (virtual-displays + steamos-host pages), console toggle polish, "Resume" stretch | B0–B2 | — | Every stage lands green (`cargo test/clippy/fmt`, OpenAPI drift) and independently shippable, per the display-management discipline. A1+A2 alone fix the user-visible breakage; A3 makes the presets truthful; B0 is the first user-visible new capability. ## 7. Risks & open questions - **`steam -silent` inside a fresh nested gamescope** — the load-bearing empirical unknown for B0's Steam polish (the launch itself works either way; only the cosmetic Steam-window flash is at stake). Validate first on `.21`/`.116` desktop mode. - **Bare spawn vs session-plus environment** — session-plus wraps Steam in MangoApp/runtime/ controller-config scaffolding a bare `gamescope -- steam` lacks. The historic "nested Steam crashes" finding was Steam-vs-Steam single-instance (both dying), *not* a missing-scaffolding failure — with Steam taken over first, a bare spawn should hold, but this is exactly what B1's on-glass pass must prove per box (Bazzite, Deck). - **PipeWire `application.process.id` availability** across gamescope versions (A5's scoped discovery) — fall back to log-derived ids (per-spawn logs make those unambiguous already). - **Keepalive drops into dead compositors** (A4 reaping): Wayland conns fail fast; Mutter's D-Bus `Stop` can block — the registry already drops outside the lock, keep it that way and bound the damage to one reaper tick. - **Epoch granularity**: detecting "new compositor instance, same kind" needs the compositor PID in `ActiveSession` — cheap (the `/proc` scan already visits it), but the watcher must debounce PID flaps during a switch (its existing 3 s debounce covers it). - **Injector rework caution** (A5): per-session EIS injectors only for gamescope; the shared service stays for portal backends — re-learning the "EIS setup timed out" lesson is the failure mode to guard in review. - **Windows**: entirely untouched — `game_session` is Linux-only for now (`launch_title` on Windows opens via the shell into the one desktop); the policy field documents that honestly rather than pretending.