feat(host/gamescope): custom-resolution Game-Mode streaming on the Steam Deck
The Steam Deck (SteamOS) ships its OWN gaming session — `gamescope-session.target` driven by `/usr/lib/steamos/gamescope-session`, not Bazzite's `gamescope-session-plus`. That script `exec gamescope`s with HARDCODED physical-panel args (`-w 1280 -h 800 -O '*',eDP-1`) and launches Steam via a SEPARATE `steam-launcher.service`, so the existing managed-session path (which assumes session-plus) couldn't honor the client's mode — an attach captured the panel's native 1280x800 instead. Add a SteamOS branch to the managed-session path: detect it, write a `gamescope` PATH-shim that rewrites the hardcoded args to `--backend headless -W <client> -H <client> -r <hz>`, drop a transient user `gamescope-session.service.d` override pointing PATH at the shim + the mode, then RESTART the whole target so `steam-launcher.service` brings Steam up IN the headless gamescope at the client's resolution. Attach to the one fresh node (the restart kills any prior gamescope, so no stale-node attach). Restore-on-disconnect removes the override + restarts the target back to the physical panel (debounced; skipped if the user switched to a desktop session). All user-level (`systemctl --user`) — no root. Also widen `build_pipeline_with_retry` to 8 attempts (~90s): a host-managed gamescope session cold-starting Steam Big Picture takes 30-60s to first frame, and a first-connect timeout would tear down the warm session (forcing another cold start on reconnect). Permanent failures still fail fast via `is_permanent_build_error`. Validated live on a Steam Deck: Game Mode auto-detected, host takes over headless at the client's mode (720p / 1080p), Steam Big Picture streamed glass-to-glass to the Mac at the requested resolution. Single-tenant (concurrent clients at different modes still thrash — a follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2794,7 +2794,12 @@ fn build_pipeline_with_retry(
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
) -> Result<Pipeline> {
|
||||
const MAX_ATTEMPTS: u32 = 4;
|
||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
||||
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
||||
// 30-60s to produce its first frame, and a first-connect timeout would tear down the warm
|
||||
// session (forcing another cold start on reconnect). A genuinely permanent failure still fails
|
||||
// fast via `is_permanent_build_error`; only transient "no frame yet" retries consume the budget.
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
let mut backoff = std::time::Duration::from_millis(500);
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth) {
|
||||
|
||||
@@ -62,6 +62,21 @@ const SESSION_UNIT: &str = "punktfunk-gamescope";
|
||||
/// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts).
|
||||
const SESSION_PLUS_BIN: &str = "/usr/share/gamescope-session-plus/gamescope-session-plus";
|
||||
|
||||
/// The ACTUAL Steam Deck (SteamOS) ships its OWN session — NOT Bazzite's session-plus. It's the
|
||||
/// systemd-user `gamescope-session.target`, whose `gamescope-session.service` runs this script, which
|
||||
/// `exec gamescope`s with HARDCODED physical-panel args (`-w 1280 -h 800 -O '*',eDP-1`) and launches
|
||||
/// Steam via a SEPARATE `steam-launcher.service`. To honor the client's mode we (a) drop a `gamescope`
|
||||
/// PATH-shim that rewrites those args to `--backend headless -W <client> …`, and (b) write a transient
|
||||
/// user drop-in pointing the service's PATH at the shim + the mode, then restart the whole target —
|
||||
/// so `steam-launcher.service` brings Steam up IN the headless gamescope at the client's resolution.
|
||||
const STEAMOS_SESSION_BIN: &str = "/usr/lib/steamos/gamescope-session";
|
||||
const STEAMOS_SESSION_TARGET: &str = "gamescope-session.target";
|
||||
|
||||
/// Set once we've reconfigured SteamOS's `gamescope-session.target` headless for a stream — the
|
||||
/// SteamOS analogue of [`STOPPED_AUTOLOGIN`], so the restore path knows to remove the drop-in and
|
||||
/// restart the physical session.
|
||||
static STEAMOS_TOOK_OVER: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
impl GamescopeDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(GamescopeDisplay)
|
||||
@@ -140,6 +155,11 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
// A (re)connect cancels any pending debounced TV-restore: we're about to (re)use the managed
|
||||
// session, so the autologin must stay stopped and the warm session stays up (no stop/relaunch).
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
// SteamOS (the real Steam Deck) has no session-plus: take over its `gamescope-session.target`
|
||||
// headless at the client's mode instead of launching a separate managed session.
|
||||
if steamos_session_present() {
|
||||
return create_managed_session_steamos(mode);
|
||||
}
|
||||
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the
|
||||
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||
@@ -193,6 +213,147 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
})
|
||||
}
|
||||
|
||||
/// SteamOS detection: its session launcher is present and Bazzite's session-plus is NOT (so the
|
||||
/// drop-in / PATH-shim takeover applies rather than launching a separate session-plus unit).
|
||||
fn steamos_session_present() -> bool {
|
||||
std::path::Path::new(STEAMOS_SESSION_BIN).exists()
|
||||
&& !std::path::Path::new(SESSION_PLUS_BIN).exists()
|
||||
}
|
||||
|
||||
/// Run a `systemctl --user` subcommand best-effort — a failure just means the session won't change,
|
||||
/// which the caller's node-wait surfaces.
|
||||
fn systemctl_user(args: &[&str]) {
|
||||
let _ = Command::new("systemctl").arg("--user").args(args).status();
|
||||
}
|
||||
|
||||
/// Directory holding the per-user `gamescope` PATH-shim (tmpfs under `XDG_RUNTIME_DIR`).
|
||||
fn headless_shim_dir() -> std::path::PathBuf {
|
||||
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||
std::path::Path::new(&base).join("punktfunk-gsbin")
|
||||
}
|
||||
|
||||
/// The gamescope arg-rewriting shim. SteamOS hardcodes physical-panel args, so we intercept the
|
||||
/// session's `exec gamescope` (via PATH) and rewrite to a headless output at the client's mode (read
|
||||
/// from `PF_W`/`PF_H`/`PF_HZ`), dropping the physical flags. Idempotent; returns the shim's directory.
|
||||
fn write_headless_shim() -> Result<std::path::PathBuf> {
|
||||
const SHIM_BODY: &str = r#"#!/bin/bash
|
||||
W="${PF_W:-1920}"; H="${PF_H:-1080}"; HZ="${PF_HZ:-60}"
|
||||
keep=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--generate-drm-mode|-w|-h|-W|-H|-O|--prefer-output) shift 2;;
|
||||
*) keep+=("$1"); shift;;
|
||||
esac
|
||||
done
|
||||
exec /usr/bin/gamescope --backend headless -W "$W" -H "$H" -w "$W" -h "$H" -r "$HZ" "${keep[@]}"
|
||||
"#;
|
||||
let dir = headless_shim_dir();
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
|
||||
let shim = dir.join("gamescope");
|
||||
std::fs::write(&shim, SHIM_BODY).with_context(|| format!("write shim {}", shim.display()))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755))
|
||||
.with_context(|| format!("chmod shim {}", shim.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Path of the transient user drop-in that points `gamescope-session.service` at the shim + mode.
|
||||
/// `zz-` so it sorts last (overrides any distro drop-in).
|
||||
fn steamos_dropin_path() -> std::path::PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/deck".to_string());
|
||||
std::path::Path::new(&home)
|
||||
.join(".config/systemd/user/gamescope-session.service.d/zz-punktfunk-headless.conf")
|
||||
}
|
||||
|
||||
/// Write the drop-in: prepend the shim dir to the service's PATH + pass the client's mode via `PF_*`.
|
||||
/// A subsequent `daemon-reload` + target restart applies it.
|
||||
fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode) -> Result<()> {
|
||||
let path = steamos_dropin_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
|
||||
}
|
||||
let body = format!(
|
||||
"[Service]\n\
|
||||
Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\
|
||||
Environment=PF_W={w}\n\
|
||||
Environment=PF_H={h}\n\
|
||||
Environment=PF_HZ={hz}\n",
|
||||
shim = shim_dir.display(),
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz.max(1),
|
||||
);
|
||||
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))
|
||||
}
|
||||
|
||||
/// Remove the headless drop-in (restore-on-disconnect). Best-effort.
|
||||
fn remove_steamos_dropin() {
|
||||
let _ = std::fs::remove_file(steamos_dropin_path());
|
||||
}
|
||||
|
||||
/// Take over SteamOS's `gamescope-session.target` headless at the CLIENT's mode: write the shim + a
|
||||
/// drop-in carrying the mode, `daemon-reload`, then RESTART the target so `steam-launcher.service`
|
||||
/// brings Steam up in the fresh headless gamescope — and attach to its node. A same-mode reconnect
|
||||
/// reuses the running session (no Steam restart); a different mode rewrites the drop-in + restarts.
|
||||
/// The restart kills any prior gamescope, so there's exactly one node to discover (no stale attach).
|
||||
fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
||||
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let same_mode = guard.as_ref().is_some_and(|s| {
|
||||
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
||||
});
|
||||
if same_mode {
|
||||
if let Some(node_id) = find_gamescope_node() {
|
||||
point_injector_at_eis();
|
||||
tracing::info!(
|
||||
node_id,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope (SteamOS): reusing the headless session (same mode — no Steam restart)"
|
||||
);
|
||||
return Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
});
|
||||
}
|
||||
*guard = None; // tracked session lost its node — fall through to a clean restart
|
||||
}
|
||||
let shim_dir = write_headless_shim()?;
|
||||
write_steamos_dropin(&shim_dir, mode)?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
systemctl_user(&["restart", STEAMOS_SESSION_TARGET]);
|
||||
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
// gamescope's node appears within a few seconds of the restart; Steam's first FRAME is slower
|
||||
// (Big Picture cold start) and is awaited by the caller's first-frame retry loop.
|
||||
let node_id = wait_for_node(Duration::from_secs(30)).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"SteamOS headless gamescope node did not appear within 30s after restarting \
|
||||
{STEAMOS_SESSION_TARGET} — check `journalctl --user -u gamescope-session.service`"
|
||||
)
|
||||
})?;
|
||||
point_injector_at_eis();
|
||||
*guard = Some(SessionState {
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh_hz: mode.refresh_hz,
|
||||
});
|
||||
tracing::info!(
|
||||
node_id,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope (SteamOS): took over gamescope-session.target headless at the client's mode"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||
@@ -240,12 +401,13 @@ fn stop_autologin_sessions() {
|
||||
/// per-connect churn is what leaked GPU context on F44. No-op when nothing was stolen (non-Bazzite /
|
||||
/// headless box). Idempotent / safe to call on every session end.
|
||||
pub fn schedule_restore_tv_session() {
|
||||
if STOPPED_AUTOLOGIN
|
||||
let nothing_to_restore = STOPPED_AUTOLOGIN
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
{
|
||||
return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
|
||||
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if nothing_to_restore {
|
||||
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
||||
}
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(Instant::now() + RESTORE_DEBOUNCE);
|
||||
@@ -260,6 +422,34 @@ pub fn schedule_restore_tv_session() {
|
||||
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
||||
/// cancelled+reconnected window keeps the list for a later real restore.
|
||||
fn do_restore_tv_session() {
|
||||
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
|
||||
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
|
||||
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
|
||||
{
|
||||
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *took {
|
||||
*took = false;
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
remove_steamos_dropin();
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
use super::ActiveKind;
|
||||
if matches!(
|
||||
super::detect_active_session().kind,
|
||||
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots
|
||||
) {
|
||||
tracing::info!(
|
||||
"gamescope (SteamOS): a desktop session is active — removed the headless \
|
||||
override, not restarting the gaming session"
|
||||
);
|
||||
return;
|
||||
}
|
||||
systemctl_user(&["restart", STEAMOS_SESSION_TARGET]);
|
||||
tracing::info!(
|
||||
"gamescope (SteamOS): restored the physical gaming session (removed headless override)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
if units.is_empty() {
|
||||
return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
|
||||
|
||||
Reference in New Issue
Block a user