From 61aa1053e7d48394c31daa73275fc3d90d2a71e7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 28 Jun 2026 11:09:45 +0000 Subject: [PATCH] feat(host/gamescope): headless game mode that follows the box + matches the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Steam game mode work on a display-less streaming host and stream it at the client's resolution: * Ship /etc/gamescope-session-plus/sessions.d/steam (packaging/bazzite/ gamescope-headless-session, installed by the RPM + Arch PKGBUILD): fall back to gamescope's headless backend when no display is connected, so "Switch to Game Mode" boots offscreen instead of crashing on the missing panel (and 5-striking back to desktop). No-op on display-attached boxes; only sets unset values so the host's per-client mode still wins. * Default Bazzite/SteamOS to ATTACH (PUNKTFUNK_GAMESCOPE_ATTACH=1 in host.env): the box owns its session (Desktop<->Game, persistent), the host follows + captures it and never tears it down — so switching is rock-solid and a disconnect leaves the box in its mode (reconnect returns there). * Resize-on-attach (gamescope.rs): on connect, ensure the box's own game-mode session runs at the CLIENT's resolution — reuse it when already matching (fast path, no restart), else reconfigure + restart the box's own autologin gamescope-session-plus@ at the client mode (cooperative: no competing unit, so no autologin-respawn fight). Detect the live gamescope's -W/-H via argv[0] in /proc (its /proc//exe is unreadable for that process). Validated live on a headless bazzite-deck-nvidia box: game mode boots headless + stable (0 strikes); the host attaches + streams video/audio/EIS input; a 5120x1440 client reuses the matching session and streams at 5120x1440. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/vdisplay/linux/gamescope.rs | 157 +++++++++++++++++- packaging/arch/PKGBUILD | 7 + packaging/bazzite/gamescope-headless-session | 22 +++ packaging/bazzite/host.env | 24 ++- packaging/rpm/punktfunk.spec | 10 ++ 5 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 packaging/bazzite/gamescope-headless-session diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index c7da60c..6c76e94 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -15,7 +15,7 @@ //! `inject/libei.rs`) — wired and live-validated. use super::{Mode, VirtualDisplay, VirtualOutput}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; @@ -110,12 +110,11 @@ impl VirtualDisplay for GamescopeDisplay { // PUNKTFUNK_GAMESCOPE_NODE=; "auto" discovers the gamescope `Video/Source` node. if let Ok(id) = std::env::var("PUNKTFUNK_GAMESCOPE_NODE") { let node_id: u32 = if id.trim().eq_ignore_ascii_case("auto") { - find_gamescope_node().ok_or_else(|| { - anyhow!( - "PUNKTFUNK_GAMESCOPE_NODE=auto but no running gamescope Video/Source node \ - was found — is the headless gamescope/Steam session up?" - ) - })? + // Attach to the box-owned game-mode session, but FIRST make it run at the connecting + // client's resolution (the box is headless, so its game-mode mode is ours to set). + // Reuse if it already matches (fast, no restart); otherwise relaunch the box's own + // session at the client mode. Without this the client gets the box's default mode. + ensure_box_gamescope_mode(mode)? } else { id.parse() .context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")? @@ -368,6 +367,150 @@ fn create_managed_session_steamos(mode: Mode) -> Result { }) } +/// ATTACH at the CLIENT's resolution: ensure the box's own game-mode session is running at `mode`'s +/// output size, then return its capture node. Reuses the running session if it already matches (no +/// restart — the rock-solid fast path a stable client always hits); otherwise reconfigures + restarts +/// the box's OWN autologin `gamescope-session-plus@` unit at the client mode. Restarting the +/// box's own unit (rather than spawning a competing one) avoids the autologin-respawn fight the old +/// MANAGED path hit. A headless box has no physical panel, so its game-mode resolution is ours to set; +/// Steam restarts only on an actual resolution CHANGE. +fn ensure_box_gamescope_mode(mode: Mode) -> Result { + let target = (mode.width, mode.height); + // Fast path: already at the client's resolution — just attach to the live node. + if current_gamescope_output_size() == Some(target) { + if let Some(node) = find_gamescope_node() { + tracing::info!( + w = mode.width, + h = mode.height, + node, + "gamescope: box game-mode session already at the client's resolution — reusing" + ); + return Ok(node); + } + } + let Some(unit) = running_autologin_gamescope_unit() else { + // No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to + // whatever node exists, accepting its resolution. + return find_gamescope_node().ok_or_else(|| { + anyhow!( + "no running gamescope Video/Source node — is the headless game mode up? \ + (put the box into Steam Game Mode)" + ) + }); + }; + tracing::info!( + from = ?current_gamescope_output_size(), + to_w = mode.width, + to_h = mode.height, + hz = mode.refresh_hz, + %unit, + "gamescope: relaunching the box game-mode session at the client's resolution" + ); + // The session reads SCREEN_WIDTH/HEIGHT (+ CUSTOM_REFRESH_RATES) from the user-manager + // environment; set them and restart the box's own unit. + systemctl_user(&[ + "set-environment", + &format!("SCREEN_WIDTH={}", mode.width), + &format!("SCREEN_HEIGHT={}", mode.height), + &format!("CUSTOM_REFRESH_RATES={}", mode.refresh_hz.max(1)), + ]); + systemctl_user(&["restart", &unit]); + // Wait for the relaunched session to come up at the new size and publish its capture node. The + // node appears when gamescope is up (well before Steam finishes booting); the caller's + // first-frame retry absorbs Steam's cold start. + let deadline = Instant::now() + Duration::from_secs(45); + loop { + if current_gamescope_output_size() == Some(target) { + if let Some(node) = find_gamescope_node() { + tracing::info!( + node, + w = mode.width, + h = mode.height, + "gamescope: box game-mode session relaunched at the client's resolution" + ); + return Ok(node); + } + } + if Instant::now() >= deadline { + bail!( + "box game-mode session did not come up at {}x{} within 45s after relaunch \ + (Steam may still be booting)", + mode.width, + mode.height + ); + } + std::thread::sleep(Duration::from_millis(500)); + } +} + +/// Output (capture) resolution `-W -H ` of the running `gamescope` binary, parsed from its +/// `/proc//cmdline`. `None` if no gamescope is running or the flags aren't present. +fn current_gamescope_output_size() -> Option<(u32, u32)> { + for entry in std::fs::read_dir("/proc").ok()?.flatten() { + let name = entry.file_name(); + let Some(pid) = name.to_str() else { continue }; + if !pid.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let Ok(raw) = std::fs::read(format!("/proc/{pid}/cmdline")) else { + continue; + }; + let args: Vec = raw + .split(|&b| b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect(); + // Match the gamescope BINARY by argv[0]'s basename — NOT /proc//exe, which is commonly + // unreadable for the gamescope process (returns empty). The session wrapper scripts run as + // bash/sh (argv[0] != gamescope), so they're excluded; the -W/-H presence check below is the + // final filter. + let is_gamescope = args + .first() + .map(|a0| a0.rsplit('/').next().unwrap_or(a0) == "gamescope") + .unwrap_or(false); + if !is_gamescope { + continue; + } + let flag = |names: &[&str]| -> Option { + args.iter().enumerate().find_map(|(i, a)| { + names + .contains(&a.as_str()) + .then(|| args.get(i + 1).and_then(|v| v.parse().ok())) + .flatten() + }) + }; + if let (Some(w), Some(h)) = ( + flag(&["-W", "--output-width"]), + flag(&["-H", "--output-height"]), + ) { + return Some((w, h)); + } + } + None +} + +/// The running autologin gaming-mode unit (`gamescope-session-plus@.service`), if any — the +/// box's own game-mode session, which [`ensure_box_gamescope_mode`] reconfigures + restarts. +fn running_autologin_gamescope_unit() -> Option { + let out = Command::new("systemctl") + .args([ + "--user", + "list-units", + "--type=service", + "--state=running", + "--no-legend", + "--plain", + "gamescope-session-plus@*.service", + ]) + .output() + .ok()?; + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.split_whitespace().next()) + .find(|u| u.starts_with("gamescope-session-plus@") && u.ends_with(".service")) + .map(|u| u.to_string()) +} + /// 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 diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 57ab7a0..ef66db1 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -72,6 +72,8 @@ package_punktfunk-host() { 'xdg-desktop-portal-wlr: portal for the headless Sway session helper' 'punktfunk-web: browser management console (device pairing + status)') install=punktfunk-host.install + # User-editable config: the headless game-mode drop-in (see below) — don't clobber local edits. + backup=('etc/gamescope-session-plus/sessions.d/steam') local R; R="$(_repo)"; local T="$srcdir/target/release" install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host" @@ -100,6 +102,11 @@ package_punktfunk-host() { install -Dm0644 "$R/scripts/host.env.example" "$pkgdir/usr/share/punktfunk/host.env.example" install -Dm0644 "$R/packaging/bazzite/host.env" "$pkgdir/usr/share/punktfunk/host.env.bazzite" install -Dm0644 "$R/packaging/kde/host.env" "$pkgdir/usr/share/punktfunk/host.env.kde" + # Headless GAME-mode fix: gamescope-session-plus drop-in that uses the headless backend when no + # display is connected (so SteamOS/Bazzite "Switch to Game Mode" works on a display-less streaming + # host). No-op on display-attached boxes; sourced as /etc/gamescope-session-plus/sessions.d/steam. + install -Dm0644 "$R/packaging/bazzite/gamescope-headless-session" \ + "$pkgdir/etc/gamescope-session-plus/sessions.d/steam" install -Dm0644 "$R/api/openapi.json" "$pkgdir/usr/share/punktfunk/openapi.json" install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-MIT" install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-APACHE" diff --git a/packaging/bazzite/gamescope-headless-session b/packaging/bazzite/gamescope-headless-session new file mode 100644 index 0000000..ce97c93 --- /dev/null +++ b/packaging/bazzite/gamescope-headless-session @@ -0,0 +1,22 @@ +# punktfunk: headless game-mode fallback for gamescope-session-plus. +# +# Installed as /etc/gamescope-session-plus/sessions.d/steam. The gamescope-session-plus launcher +# SOURCES this (shell, with `set -a` so assignments auto-export) AFTER its /usr/share defaults, so it +# can override the session's gamescope flags. +# +# Why: on a box with NO connected display (a dedicated streaming host), the stock Steam game mode runs +# gamescope's DRM backend against a physical panel (`--prefer-output *,eDP-1`). With nothing to scan +# out, gamescope crashes on launch; after 5 strikes Bazzite/SteamOS force-selects the desktop session +# and "Switch to Game Mode" appears broken. Falling back to gamescope's HEADLESS backend makes game +# mode render entirely offscreen and expose a PipeWire node, which the punktfunk host captures and +# streams — full gamescope game mode (per-game res / FSR / HDR / VRR / frame-limit), no monitor needed. +# +# Safe by construction: +# * NO-OP when any display is connected -> the normal DRM game mode runs unchanged. +# * Only sets values that are still unset (`: "${VAR:=...}"`), so the punktfunk host's per-client +# mode (SCREEN_WIDTH/SCREEN_HEIGHT injected via systemd-run for a managed session) still wins. +if ! grep -qx connected /sys/class/drm/*/status 2>/dev/null; then + : "${BACKEND:=headless}" + : "${SCREEN_WIDTH:=1920}" + : "${SCREEN_HEIGHT:=1080}" +fi diff --git a/packaging/bazzite/host.env b/packaging/bazzite/host.env index 21855c6..44a1994 100644 --- a/packaging/bazzite/host.env +++ b/packaging/bazzite/host.env @@ -20,13 +20,25 @@ PUNKTFUNK_ZEROCOPY=1 # PUNKTFUNK_COMPOSITOR=kwin|mutter|wlroots|gamescope # PUNKTFUNK_INPUT_BACKEND=libei|wlr|gamescope|uinput # -# In Gaming Mode the host MANAGES a gamescope-session-plus at the CLIENT's resolution by default -# (tears the TV's autologin down on connect; restores it on a debounced idle, reused on a quick -# reconnect). To instead ATTACH to the running TV session at its own mode (couch-on-TV — gaming -# stays live on the panel, no Steam restart), set: -# PUNKTFUNK_GAMESCOPE_ATTACH=1 -# PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui # only for an ad-hoc bare-spawn fallback +# GAME MODE = ATTACH (the box owns its session; the host follows). The box decides whether it's in +# Steam Gaming Mode or a Desktop — you switch with the normal Steam UI / "Switch to Desktop". The +# host just ATTACHES to whatever's live and captures it; it never tears the session down or relaunches +# it. So switching Desktop<->Game is rock-solid, and when you disconnect the box STAYS in its current +# mode — reconnecting drops you right back where you were. The streamed resolution in game mode is the +# box's gamescope mode (see SCREEN_WIDTH/HEIGHT in /etc/gamescope-session-plus/sessions.d/steam). +PUNKTFUNK_GAMESCOPE_ATTACH=1 +# +# Opt OUT to the MANAGED model instead (host tears the box's gamescope down on connect and launches +# its OWN at the CLIENT's exact resolution; restores on a debounced idle). Client-mode-following, but +# it does not coexist with a box-owned game-mode session — pick one: +# PUNKTFUNK_GAMESCOPE_MANAGED=1 # (and remove PUNKTFUNK_GAMESCOPE_ATTACH above) # # Follow a Gaming<->Desktop switch MID-STREAM (rebuild the backend in place, no reconnect). This is # ON BY DEFAULT on Bazzite/SteamOS (the host detects the platform); set =0 to disable it: # PUNKTFUNK_SESSION_WATCH=0 +# +# HEADLESS GAME MODE: on a box with no display attached, Bazzite's "Switch to Game Mode" normally +# crashes (gamescope's DRM backend has no panel to drive). The host package ships +# /etc/gamescope-session-plus/sessions.d/steam, which auto-falls-back to gamescope's HEADLESS backend +# when no display is connected — so game mode boots offscreen and streams, with no config here. It's a +# no-op on display-attached boxes. (The host then auto-detects Gaming and streams it.) diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 6c00edc..2d40caf 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -233,6 +233,13 @@ install -Dm0644 packaging/kde/host.env %{buildroot}%{_datadir}/% # screencast/virtual-output grant ships as io.unom.Punktfunk.Host.desktop, installed above). install -d %{buildroot}%{_datadir}/%{name}/bazzite install -Dm0755 packaging/bazzite/kde-desktop-setup.sh %{buildroot}%{_datadir}/%{name}/bazzite/kde-desktop-setup.sh +# Headless GAME-mode fix: a gamescope-session-plus sessions.d drop-in that falls back to gamescope's +# headless backend when no display is connected (so "Switch to Game Mode" works on a display-less +# streaming host instead of crashing + 5-striking back to desktop). No-op on display-attached boxes. +# Sourced by gamescope-session-plus as /etc/gamescope-session-plus/sessions.d/steam (after its +# /usr/share defaults). Harmless on non-gamescope systems (the file is simply never read). +install -Dm0644 packaging/bazzite/gamescope-headless-session \ + %{buildroot}/etc/gamescope-session-plus/sessions.d/steam install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name}/openapi.json %if %{with web} @@ -262,6 +269,9 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt %{_userunitdir}/punktfunk-host.service %{_userunitdir}/punktfunk-kde-session.service %{_datadir}/applications/io.unom.Punktfunk.Host.desktop +%dir /etc/gamescope-session-plus +%dir /etc/gamescope-session-plus/sessions.d +%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam %dir %{_datadir}/%{name} %{_datadir}/%{name}/*