From c894c6f897f288cc5251f9a7dfa06bc8c04416bb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 11 Jun 2026 16:14:10 +0000 Subject: [PATCH] feat(host): host-managed gamescope session at the client's mode (dynamic res + refresh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nested games on the Bazzite host saw the wrong display: refresh capped at 60 Hz, the box's connected TV's EDID modes leaking in (DOOM landed on 2560×1440@60), and the resolution fixed at whatever the always-on session was launched at — the client's requested mode never reached the game. Root causes: the session-plus gamescope command has no --nested-refresh (Xwayland advertises 59.96 Hz for every mode), --prefer-output HDMI-A-1 makes gamescope read the TV EDID, and the ATTACH model launches one fixed-resolution session. New vdisplay path: PUNKTFUNK_GAMESCOPE_SESSION= — the host LAUNCHES gamescope-session-plus headless AT THE CLIENT'S mode and relaunches it when the mode changes. Injected via a host-written GAMESCOPE_BIN wrapper (--nested-refresh $PF_HZ, the flag session-plus doesn't expose) + DRM_MODE=cvt (gamescope generates clean CVT modes at that refresh instead of the TV's EDID). The session runs as a transient `systemd-run --user` unit (clean cgroup teardown of the Steam tree); state lives in a host-lifetime static (MANAGED_SESSION), NOT in GamescopeDisplay (which is per-client-session) — so a same-mode reconnect REUSES the running session instantly (no Steam restart) while a different mode RELAUNCHES it (games can't change output mode live; a game/Steam restart on a mode change is unavoidable and acceptable). Reuses the existing node + EIS auto-discovery (find_gamescope_node / find_gamescope_eis_socket, factored into point_injector_at_eis) and the existing mid-stream Reconfigure → vd.create(mode) machinery — no protocol or m3 control-flow change. Validated live on bazzite (RTX 4090): games' Xwayland now advertises 5120×1440 @ 239.90 Hz as the preferred mode (was 59.96), the TV's 3840×2160/4096×2160@60 modes are gone, frames stream; reconnect at 1920×1080@120 relaunches and games see that; same-mode reconnect reuses with no restart and frames flow instantly. scripts: host.env.example documents PUNKTFUNK_GAMESCOPE_SESSION (mutually exclusive with the legacy NODE=auto attach); punktfunk-steam-session.service marked deprecated (superseded — must not run alongside the host-managed path). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/vdisplay/gamescope.rs | 221 +++++++++++++++--- scripts/host.env.example | 13 +- scripts/punktfunk-steam-session.service | 12 +- 3 files changed, 213 insertions(+), 33 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay/gamescope.rs b/crates/punktfunk-host/src/vdisplay/gamescope.rs index ef49338..f08c013 100644 --- a/crates/punktfunk-host/src/vdisplay/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/gamescope.rs @@ -18,10 +18,33 @@ use anyhow::{anyhow, Context, Result}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; -/// The gamescope virtual-display driver. Each [`create`](VirtualDisplay::create) spawns one -/// headless gamescope process sized to the requested mode. +/// The gamescope virtual-display driver. Three modes by env, in precedence order: +/// * `PUNKTFUNK_GAMESCOPE_SESSION=` — host-MANAGE a `gamescope-session-plus` session +/// (full Steam-Deck-UI polish) headless at the CLIENT's mode; relaunch it when the mode changes. +/// * `PUNKTFUNK_GAMESCOPE_NODE=` — ATTACH to an already-running gamescope (capture + +/// inject, no lifecycle ownership). +/// * else — SPAWN a bare headless gamescope sized to the mode, running `PUNKTFUNK_GAMESCOPE_APP`. pub struct GamescopeDisplay; +/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at. +struct SessionState { + width: u32, + height: u32, + refresh_hz: u32, +} + +/// The host-managed `gamescope-session-plus` session, tracked at **host lifetime** (NOT per +/// `GamescopeDisplay`, which is recreated per client session and would otherwise cold-start Steam on +/// every reconnect). A same-mode reconnect reuses the running session (no Steam restart); a +/// different mode relaunches it. Cleared/relaunched by `launch_session`; survives across client +/// connections; on host restart the next launch stops the leftover unit by name and starts fresh. +static MANAGED_SESSION: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// systemd --user transient unit name for the host-managed gamescope-session-plus session. +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"; + impl GamescopeDisplay { pub fn new() -> Result { Ok(GamescopeDisplay) @@ -34,12 +57,16 @@ impl VirtualDisplay for GamescopeDisplay { } fn create(&mut self, mode: Mode) -> Result { - // Attach to an already-running gamescope (e.g. a headless `gamescope-session-plus` Steam - // session, or a debug/Steam-launched one) instead of spawning our own: capture its node - // AND inject into its EIS socket. PUNKTFUNK_GAMESCOPE_NODE=; "auto" discovers the - // gamescope `Video/Source` node so nothing has to be hand-wired. This is the Bazzite path: - // a persistent headless Steam-Deck-UI session (full gamescope-session-plus polish, at the - // client's resolution) that punktfunk streams + drives, instead of nesting a second Steam. + // Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the + // full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE + // them (via the injected --nested-refresh + generated CVT modes, not the box's TV EDID) — + // and relaunch it when the client's mode changes. Reuses the node + EIS discovery below. + if let Ok(client) = std::env::var("PUNKTFUNK_GAMESCOPE_SESSION") { + return create_managed_session(&client, mode); + } + // Attach to an already-running gamescope (a foreign / externally-launched session) instead + // of spawning our own: capture its node AND inject into its EIS socket. + // 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(|| { @@ -52,25 +79,7 @@ impl VirtualDisplay for GamescopeDisplay { id.parse() .context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")? }; - // Point the libei injector at the running gamescope's EIS socket: it reads the relay - // file [`EI_SOCKET_FILE`], so write the live socket's name there. Best-effort — video - // still works without it (input just won't reach the attached session). - match find_gamescope_eis_socket() { - Some(sock) => match std::fs::write(EI_SOCKET_FILE, &sock) { - Ok(()) => tracing::info!( - socket = %sock, - "gamescope attach: pointed injector at the running session's EIS socket" - ), - Err(e) => tracing::warn!( - error = %e, - "gamescope attach: could not write the EIS relay file — input may not reach the session" - ), - }, - None => tracing::warn!( - "gamescope attach: no connectable gamescope EIS socket found — input injection \ - will not reach the attached session" - ), - } + point_injector_at_eis(); tracing::info!(node_id, "gamescope: attaching to existing PipeWire node"); return Ok(VirtualOutput { node_id, @@ -105,6 +114,164 @@ impl VirtualDisplay for GamescopeDisplay { } } +/// Host-managed `gamescope-session-plus` at the client's mode (state in [`MANAGED_SESSION`], so it +/// persists across client connections — a reconnect at the same mode reuses it instantly). REUSE +/// the running session if the mode is unchanged and its node is still live (no Steam restart); +/// otherwise stop the old transient unit and RELAUNCH at the new mode (gamescope can't change output +/// mode live). Then discover the node + point the injector, exactly as the attach path does. +fn create_managed_session(client: &str, mode: Mode) -> Result { + 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 session: reusing the running 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(()), + }); + } + tracing::warn!("gamescope session: tracked session has no live node — relaunching"); + *guard = None; + } + // (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is + // exactly one gamescope `Video/Source` node for discovery. + let node_id = launch_session(client, SESSION_UNIT, mode)?; + 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 session: launched gamescope-session-plus 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(()), + }) +} + +/// Point the libei injector at the running gamescope's EIS socket (it reads the relay file +/// [`EI_SOCKET_FILE`]). Best-effort — video still works without it (input just won't reach the +/// session). Shared by the attach and host-managed-session paths. +fn point_injector_at_eis() { + match find_gamescope_eis_socket() { + Some(sock) => match std::fs::write(EI_SOCKET_FILE, &sock) { + Ok(()) => { + tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket") + } + Err(e) => tracing::warn!( + error = %e, + "gamescope: could not write the EIS relay file — input may not reach the session" + ), + }, + None => tracing::warn!( + "gamescope: no connectable gamescope EIS socket found — input won't reach the session" + ), + } +} + +/// Path of the host-written `GAMESCOPE_BIN` wrapper (per-user, in tmpfs). +fn gamescope_bin_wrapper_path() -> std::path::PathBuf { + let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + std::path::Path::new(&base).join("punktfunk-gamescope-bin") +} + +/// Write the `GAMESCOPE_BIN` wrapper that injects `--nested-refresh $PF_HZ` — the flag +/// gamescope-session-plus does NOT expose, and the one that makes games see the client's refresh +/// instead of ~60 Hz. The body is constant (the rate comes from the `PF_HZ` env per launch), so the +/// write is idempotent. Returns its path. +fn write_gamescope_bin_wrapper() -> Result { + let path = gamescope_bin_wrapper_path(); + std::fs::write( + &path, + "#!/bin/sh\nexec /usr/bin/gamescope --nested-refresh \"${PF_HZ:-60}\" \"$@\"\n", + ) + .with_context(|| format!("write GAMESCOPE_BIN wrapper {}", path.display()))?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod the GAMESCOPE_BIN wrapper {}", path.display()))?; + Ok(path) +} + +/// Launch `gamescope-session-plus ` headless at `mode` as a transient `systemd --user` +/// unit (clean cgroup teardown of the whole Steam tree on stop). Injects `--nested-refresh` (via +/// the wrapper) + `--generate-drm-mode cvt` so games see exactly `mode` (resolution + refresh) and +/// not the box's physical-display EDID. Blocks until the gamescope `Video/Source` node appears +/// (Steam Big Picture cold-start is slow), returning its id; on timeout it stops the unit and errors. +fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result { + if !std::path::Path::new(SESSION_PLUS_BIN).exists() { + anyhow::bail!( + "PUNKTFUNK_GAMESCOPE_SESSION is set but {SESSION_PLUS_BIN} is missing — the host-managed \ + session needs gamescope-session-plus (a Bazzite / SteamOS-like host)" + ); + } + let wrapper = write_gamescope_bin_wrapper()?; + stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean + let hz = mode.refresh_hz.max(1); + let status = Command::new("systemd-run") + .args(["--user", "--collect", &format!("--unit={unit_name}")]) + .arg("--setenv=BACKEND=headless") + .arg(format!("--setenv=SCREEN_WIDTH={}", mode.width)) + .arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height)) + .arg(format!("--setenv=PF_HZ={hz}")) + .arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display())) + .arg("--setenv=DRM_MODE=cvt") + .arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}")) + .arg("--") + .arg(SESSION_PLUS_BIN) + .arg(client) + .status() + .context( + "launch gamescope-session-plus via `systemd-run --user` (is the user systemd manager \ + up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)", + )?; + if !status.success() { + anyhow::bail!("`systemd-run --user` failed to start the gamescope session (exit {status})"); + } + // Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s. + let deadline = Instant::now() + Duration::from_secs(45); + loop { + if let Some(id) = find_gamescope_node() { + return Ok(id); + } + if Instant::now() >= deadline { + stop_session(unit_name); + anyhow::bail!( + "gamescope-session-plus '{client}' did not publish a Video/Source node within 45s \ + (Steam failed to start? — `journalctl --user -u {unit_name}`)" + ); + } + std::thread::sleep(Duration::from_millis(500)); + } +} + +/// Stop the host-managed session's transient unit (best-effort) and clear the EIS relay so a dead +/// session's socket name can't be reconnected. +fn stop_session(unit_name: &str) { + let _ = Command::new("systemctl") + .args(["--user", "stop", unit_name]) + .status(); + let _ = std::fs::remove_file(EI_SOCKET_FILE); +} + /// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket), /// read by the libei injector to drive input into the nested app. See [`crate::inject`]. pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei"; diff --git a/scripts/host.env.example b/scripts/host.env.example index 5a0adf3..e725a9b 100644 --- a/scripts/host.env.example +++ b/scripts/host.env.example @@ -13,9 +13,20 @@ PUNKTFUNK_VIDEO_SOURCE=virtual # GPU zero-copy capture (EGL/Vulkan → CUDA → NVENC). Falls back to CPU automatically. PUNKTFUNK_ZEROCOPY=1 +# --- Bazzite / SteamOS-like host: host-managed Steam-Deck-UI session ----------------------- +# The host LAUNCHES gamescope-session-plus headless AT THE CLIENT'S mode (so games see the +# client's exact resolution + refresh, not the box's TV), and relaunches it when the mode +# changes. Requires the headless-appliance prereqs (linger + multi-user.target — see +# punktfunk-steam-session.service header) and NO physical gaming session running. +#PUNKTFUNK_COMPOSITOR=gamescope +#PUNKTFUNK_GAMESCOPE_SESSION=steam # host owns a gamescope-session-plus session at the client mode +#PUNKTFUNK_INPUT_BACKEND=gamescope +# Mutually exclusive with the above: ATTACH to a gamescope session something ELSE owns (fixed mode): +#PUNKTFUNK_GAMESCOPE_NODE=auto # discover + capture a running gamescope (do NOT combine with SESSION) + # Optional overrides (apps.json is the primary mechanism for per-app settings): #PUNKTFUNK_COMPOSITOR=kwin # kwin | mutter | gamescope | wlroots -#PUNKTFUNK_GAMESCOPE_APP=vkcube # nested command for ad-hoc gamescope sessions +#PUNKTFUNK_GAMESCOPE_APP=vkcube # nested command for ad-hoc bare-gamescope sessions #PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput #PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent #PUNKTFUNK_PERF=1 # per-stage timing logs diff --git a/scripts/punktfunk-steam-session.service b/scripts/punktfunk-steam-session.service index 8d66ac3..69a5716 100644 --- a/scripts/punktfunk-steam-session.service +++ b/scripts/punktfunk-steam-session.service @@ -1,10 +1,12 @@ # punktfunk headless Steam session — systemd USER unit (Bazzite / SteamOS-like hosts). # -# Runs the FULL gamescope-session-plus Steam Deck UI (MangoApp/VRR/controller config — all the -# polish) headless, at your streaming client's resolution, with no physical display. punktfunk's -# host then ATTACHES to it (capture its PipeWire node + inject into its libei socket) instead of -# nesting a second, conflicting Steam — set the host's PUNKTFUNK_GAMESCOPE_NODE=auto + -# PUNKTFUNK_INPUT_BACKEND=gamescope (see scripts/host.env.example). +# DEPRECATED — superseded by the host-managed session (`PUNKTFUNK_GAMESCOPE_SESSION=steam` in +# host.env). The host now LAUNCHES gamescope-session-plus on demand AT THE CLIENT'S mode (so games +# see the client's exact resolution + refresh, dynamically per connection) and relaunches it on a +# mode change. Do NOT enable this fixed-resolution unit alongside the host-managed path — two +# gamescope sessions publish two Video/Source nodes and break node discovery. Kept only for the +# legacy fixed-mode ATTACH setup (`PUNKTFUNK_GAMESCOPE_NODE=auto`); it caps every game at this +# unit's resolution. The PREREQS below still apply to the host-managed path too. # # Prereq — free Steam from the local gaming session (so this owns it), on a headless box: # sudo loginctl enable-linger $USER # user services run without a graphical login