# 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-<inst>.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<String>` (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 <unit>` (+ `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-<gen>.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-<gen>`, 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": {"<id>": …}` 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/<id>`): Steam is single-instance per uid, so:
  1. Command shape becomes `steam -silent steam://rungameid/<id>` 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.
