From 0323158a3ef7bb30cbe700bddbc81d84d252ee2c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 00:28:52 +0200 Subject: [PATCH] fix(vdisplay/gamescope): a fresh dedicated launch streams from the first second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly spawned headless gamescope never delivered a single PipeWire buffer when a game was launched: gamescope only composites (and only on a composite pushes a capture buffer) when a client paints, and a nested Steam bootstrap paints nothing until its UI's first frame — far longer than the 10 s first-frame budget. The native plane's retry ladder then killed the half-booted Steam on every attempt and started from zero; the GameStream plane died on its single wait. Reused live displays worked, which pointed away from the real cause (root-caused on .41 with a raw pw_stream probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately). Every bare spawn now backgrounds the host's own splash client (`gamescope-splash`, hidden subcommand) beside the nested app: a dark window with a subtle breathing bar painted at ~2.5 Hz — damage from the first second, so capture gets its first frame within the budget on both planes. In `--steam` mode gamescope composites only windows whose appid is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a painting vkcube gets zero composites without it), so the splash declares itself as the Steam UI (STEAM_GAME=769) and seeds the baselayer iff unset; Steam's own rewrite at game launch hands composite focus to the game with no action on our side. PUNKTFUNK_GAMESCOPE_SPLASH=0 is the escape hatch. The dedicated Steam launch is also shaped with `-gamepadui` instead of `-silent`: the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built around — so the boot shows the gamepad UI instead of the desktop client window flashing through the stream, and gamescope's focus rules (game outranks the Steam UI appid) cover what `-silent` was working around. On-glass on .41 (Bazzite, RTX 5070 Ti, gamescope c31743d): cold connect + Balatro launch delivers frames on the first attempt on both a cold and a torn-down-then-fresh spawn; Big Picture boot → game handover confirmed. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/pf-host-config/src/lib.rs | 11 ++ crates/pf-vdisplay/Cargo.toml | 3 + crates/pf-vdisplay/src/lib.rs | 10 ++ .../src/vdisplay/linux/gamescope.rs | 134 ++++++++++----- .../src/vdisplay/linux/gamescope/splash.rs | 159 ++++++++++++++++++ crates/punktfunk-host/src/main.rs | 5 + 7 files changed, 285 insertions(+), 38 deletions(-) create mode 100644 crates/pf-vdisplay/src/vdisplay/linux/gamescope/splash.rs diff --git a/Cargo.lock b/Cargo.lock index b8b5d23a..70da3b6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3048,6 +3048,7 @@ dependencies = [ "wayland-client", "wayland-scanner", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "x11rb", ] [[package]] diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index 41eb74f3..fda96efe 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -115,6 +115,14 @@ pub struct HostConfig { /// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles /// and menus, so it's opt-in per host until validated on-glass. pub gamescope_grab_cursor: bool, + /// `PUNKTFUNK_GAMESCOPE_SPLASH` — run the host's built-in splash client inside every bare + /// headless gamescope spawn. gamescope only composites (and only then pushes a PipeWire capture + /// buffer) when a client paints, and a dedicated Steam launch paints NOTHING + /// for the whole Steam bootstrap — so without the splash a fresh spawn's capture starves: format + /// negotiated, zero buffers, first-frame timeout, and every retry kills the booting Steam and + /// starts over (the "fresh gamescope output never delivers frames" field failure). Default ON; + /// explicit-off grammar (`=0` disables, the on-glass A/B + emergency escape hatch). + pub gamescope_splash: bool, /// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO /// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell /// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up @@ -177,6 +185,9 @@ impl HostConfig { "1" | "true" | "yes" | "on" ) }), + // Default ON, explicit-off grammar: the splash is what makes a fresh bare spawn deliver + // its first frames at all; `=0` is the A/B + escape hatch. + gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true), recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD") .filter(|s| !s.trim().is_empty()), on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()), diff --git a/crates/pf-vdisplay/Cargo.toml b/crates/pf-vdisplay/Cargo.toml index 1df4b04d..6090c64d 100644 --- a/crates/pf-vdisplay/Cargo.toml +++ b/crates/pf-vdisplay/Cargo.toml @@ -48,6 +48,9 @@ wayland-backend = "0.3" # wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums # (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs). bitflags = "2" +# The gamescope bare-spawn splash client (gamescope/splash.rs): pure-Rust X11 core protocol (the +# same no-libxcb-link stance as pf-capture's XFixes cursor source), no extension features needed. +x11rb = { version = "0.13", default-features = false } [target.'cfg(target_os = "windows")'.dependencies] # The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs). diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index d925a7d6..8498c776 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -425,6 +425,16 @@ pub fn effective_topology() -> policy::Topology { #[path = "vdisplay/linux/gamescope.rs"] mod gamescope; +/// Entry point for the hidden `gamescope-splash` host subcommand: the tiny X11 client every bare +/// gamescope spawn backgrounds beside its nested app, so the fresh compositor composites — and its +/// PipeWire node delivers frames — from the first second instead of starving the first-frame wait +/// while the nested Steam bootstrap paints nothing (see `vdisplay/linux/gamescope/splash.rs`). +/// Blocks for the session's lifetime; gamescope's reaper tears it down with the session. +#[cfg(target_os = "linux")] +pub fn gamescope_splash_client() -> anyhow::Result<()> { + gamescope::splash_run() +} + // Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID + // ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows // consumes it in non-test code today — the KWin wiring is the next Stage-3 step. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index fbd46791..071f97de 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -21,6 +21,8 @@ use std::time::{Duration, Instant}; #[path = "gamescope/discovery.rs"] mod discovery; +#[path = "gamescope/splash.rs"] +mod splash; use discovery::{ check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_node_present, poll_managed_node, wait_for_node, @@ -29,6 +31,7 @@ pub(crate) use discovery::{ game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit, SteamGameWatch, }; +pub(crate) use splash::run as splash_run; /// The gamescope virtual-display driver. Three modes by env, in precedence order: /// * `PUNKTFUNK_GAMESCOPE_SESSION=` — host-MANAGE a `gamescope-session-plus` session @@ -1963,11 +1966,6 @@ pub fn ei_socket_file() -> std::path::PathBuf { crate::with_env_lock(pf_paths::gamescope_ei_socket_file) } -/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch -/// (`steam steam://rungameid/`, produced by `library::command_for`) gets `-silent` inserted so -/// the game is the gamescope focus with no Steam client window to navigate -/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam -/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested. /// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's /// single instance free before a dedicated spawn (B1). Pure + unit-tested. fn is_steam_launch(cmd: &str) -> bool { @@ -1975,12 +1973,21 @@ fn is_steam_launch(cmd: &str) -> bool { it.next() == Some("steam") && cmd.contains("steam://") } +/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch +/// (`steam steam://rungameid/`, produced by `library::command_for`) gets `-gamepadui` inserted +/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built +/// around (it's what SteamOS/Bazzite game mode runs): the boot shows the gamepad UI instead of the +/// desktop Steam client window (field report 2026-07-27: the desktop UI flashing through the +/// stream "looks bad"), and gamescope's focus rules already prefer the game window over the Steam +/// UI appid, which is what the previous `-silent` shaping was working around. Operator-typed +/// custom commands and non-Steam launches are returned unchanged. Idempotent (never +/// double-inserts). Pure + unit-tested. fn shape_dedicated_command(app: &str) -> String { let mut it = app.split_whitespace(); if it.next() == Some("steam") { let rest: Vec<&str> = it.collect(); - if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) { - return format!("steam -silent {}", rest.join(" ")); + if !rest.contains(&"-gamepadui") && rest.iter().any(|t| t.starts_with("steam://")) { + return format!("steam -gamepadui {}", rest.join(" ")); } } app.to_string() @@ -2016,7 +2023,11 @@ fn add_bare_gamescope_args( /// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session). /// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny /// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`] -/// so the input injector can connect to gamescope's EIS server from outside. +/// so the input injector can connect to gamescope's EIS server from outside — and (unless +/// `PUNKTFUNK_GAMESCOPE_SPLASH=0`) backgrounds the host's splash client first, so the fresh +/// compositor has a painting window from the first second: gamescope pushes capture buffers only +/// when it composites, and a nested Steam bootstrap paints nothing until the gamepad UI's first +/// frame — far longer than any first-frame budget (see `gamescope/splash.rs`). fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result { // A non-empty per-session command (set via `set_launch_command`) wins; else the // `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps @@ -2033,8 +2044,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R // cursor-grab flag below. let game_launch = app.is_some(); let app = app.unwrap_or_else(|| "sleep infinity".to_string()); - // Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the - // gamescope focus with no Steam client window to navigate. + // Dedicated-launch command shaping (Part B): a Steam URI runs with `-gamepadui` so the nested + // Steam is Big Picture — the identity gamescope's `--steam` mode is built around — instead of + // the desktop client window. let app = shape_dedicated_command(&app); let relay = ei_socket_file(); let _ = std::fs::remove_file(&relay); // stale socket path from a previous session @@ -2047,29 +2059,37 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R // cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook. // Default OFF (it forces relative mode, which would break absolute-pointer games/menus). let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor; + // The splash client (see `gamescope/splash.rs`): without a painting client gamescope pushes NO + // capture buffers, and a nested Steam bootstrap paints nothing for far longer than any + // first-frame budget — so every bare spawn backgrounds the host's own splash beside the nested + // app. Skipped only via the PUNKTFUNK_GAMESCOPE_SPLASH=0 escape hatch (or if the host can't + // name its own executable, where the old starve-prone behaviour is still better than no spawn). + let splash_exe = pf_host_config::config() + .gamescope_splash + .then(std::env::current_exe) + .and_then(|r| { + r.map_err(|e| tracing::warn!(error = %e, "gamescope: current_exe failed — no splash")) + .ok() + }); let mut cmd = Command::new("gamescope"); add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor); - cmd.args([ - "sh", - "-c", - &format!( - "printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"", - relay.display() - ), - "sh", - ]) - .args(app.split_whitespace()) - // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). - .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") - // A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after - // a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale - // WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session) - // makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before - // its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the - // bug because it predates any login's env import). gamescope exports its own DISPLAY / - // GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing. - .env_remove("DISPLAY") - .env_remove("WAYLAND_DISPLAY"); + let script = nested_wrapper_script(&relay, splash_exe.is_some()); + cmd.args(["sh", "-c", &script, "sh"]); + if let Some(exe) = &splash_exe { + cmd.arg(exe); + } + cmd.args(app.split_whitespace()) + // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). + .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") + // A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after + // a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale + // WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session) + // makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before + // its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the + // bug because it predates any login's env import). gamescope exports its own DISPLAY / + // GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY"); if let Ok(logf) = std::fs::File::create(log) { if let Ok(log2) = logf.try_clone() { cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2)); @@ -2077,11 +2097,34 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R } else { cmd.stdout(Stdio::null()).stderr(Stdio::null()); } - tracing::info!(w, h, hz, steam_mode, %app, log = %log.display(), "spawning gamescope (headless)"); + tracing::info!( + w, h, hz, steam_mode, + splash = splash_exe.is_some(), + %app, + log = %log.display(), + "spawning gamescope (headless)" + ); cmd.spawn() .context("spawn gamescope (is it installed? `apt install gamescope`)") } +/// The nested-command wrapper script for a bare spawn: relay gamescope's `LIBEI_SOCKET` to the +/// injector's file, optionally background the splash client (`"$1"` is the host executable — passed +/// as an argv so its path never needs shell-escaping), then exec the real app. Pure + unit-tested. +fn nested_wrapper_script(relay: &std::path::Path, with_splash: bool) -> String { + if with_splash { + format!( + "printf %s \"$LIBEI_SOCKET\" > '{}'; \"$1\" gamescope-splash & shift; exec \"$@\"", + relay.display() + ) + } else { + format!( + "printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"", + relay.display() + ) + } +} + /// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual /// output down. struct GamescopeProc { @@ -2105,9 +2148,24 @@ impl Drop for GamescopeProc { mod tests { use super::{ cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under, - dm_survives_masked_unit, is_steam_launch, shape_dedicated_command, + dm_survives_masked_unit, is_steam_launch, nested_wrapper_script, shape_dedicated_command, }; + #[test] + fn nested_wrapper_script_shapes() { + let relay = std::path::Path::new("/run/user/1000/pf-ei"); + // Plain: relay + exec, no splash machinery. + let plain = nested_wrapper_script(relay, false); + assert!(plain.contains("/run/user/1000/pf-ei")); + assert!(plain.ends_with("exec \"$@\"")); + assert!(!plain.contains("gamescope-splash")); + // Splash: `"$1"` is the host exe (an argv, never shell-interpolated), backgrounded and + // shifted away so `exec "$@"` still runs the untouched app tokens. + let splash = nested_wrapper_script(relay, true); + assert!(splash.contains("\"$1\" gamescope-splash &")); + assert!(splash.contains("shift; exec \"$@\"")); + } + #[test] fn display_manager_flavor_detection() { let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id())); @@ -2169,15 +2227,15 @@ mod tests { #[test] fn dedicated_command_shaping() { - // Steam URI → -silent inserted so the game is the gamescope focus. + // Steam URI → -gamepadui inserted so the nested Steam is Big Picture (not the desktop UI). assert_eq!( shape_dedicated_command("steam steam://rungameid/570"), - "steam -silent steam://rungameid/570" + "steam -gamepadui steam://rungameid/570" ); - // Idempotent: an already-silent command is left alone. + // Idempotent: an already-gamepadui command is left alone. assert_eq!( - shape_dedicated_command("steam -silent steam://rungameid/570"), - "steam -silent steam://rungameid/570" + shape_dedicated_command("steam -gamepadui steam://rungameid/570"), + "steam -gamepadui steam://rungameid/570" ); // Non-Steam launches and operator custom commands are untouched. assert_eq!(shape_dedicated_command("vkcube"), "vkcube"); diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/splash.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/splash.rs new file mode 100644 index 00000000..d99fe239 --- /dev/null +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/splash.rs @@ -0,0 +1,159 @@ +//! The bare-spawn **splash client** — the reason a fresh headless gamescope delivers frames at all. +//! +//! gamescope only composites (and only on a composite pushes a buffer to its PipeWire capture +//! node) when a client paints. A dedicated Steam launch paints NOTHING for the whole Steam +//! bootstrap (no window maps until the gamepad UI's first frame) — so a fresh +//! spawn's capture negotiated a format, reached `Streaming`, and then starved: zero buffers, the +//! 10 s first-frame timeout, teardown — and every native-plane retry killed the half-booted Steam +//! and started from zero, while the GameStream plane died on its single wait (the "fresh gamescope +//! output never delivers frames" field failure; root-caused on-box 2026-07-27 with a raw pw_stream +//! probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately). +//! +//! This client runs INSIDE the spawned gamescope (the nested-command wrapper backgrounds it before +//! exec'ing the real app) and guarantees damage from the first second: a dark window with a subtle +//! breathing bar painted at ~2.5 Hz. Two gamescope focus facts, both live-proven on c31743d: +//! +//! * In `--steam` mode gamescope composites ONLY windows whose Steam appid is listed in the root +//! window's `GAMESCOPECTRL_BASELAYER_APPID` property (a plain mapped+painting window — even +//! vkcube — gets zero composites). So the splash declares itself as the Steam client UI +//! (`STEAM_GAME=769`) and seeds the baselayer with that appid iff nobody has written it yet. +//! When Steam finishes booting it rewrites the baselayer as part of launching the game, which +//! hands composite focus to the real game window with no action on our side (verified: 2.5 Hz +//! splash cadence → 60 Hz game cadence on the same capture stream, no gap). +//! * In plain (non-`--steam`) mode any mapped painting window composites; the atoms are inert. +//! +//! The splash never exits on its own while the session lives: after the game takes focus it keeps +//! painting unfocused (damage on an unfocused window schedules no composite — harmless), so if the +//! game's window briefly goes away (Steam updater closed, level-load window swap) and gamescope +//! re-focuses the splash, liveness returns instead of a frozen last frame. It dies with the session: +//! gamescope's reaper kills it at teardown, and any X error (the connection dropping) exits cleanly. + +use anyhow::{Context, Result}; +use std::time::Duration; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ + AtomEnum, ChangeGCAux, ConnectionExt, CreateGCAux, CreateWindowAux, PropMode, Rectangle, + WindowClass, +}; +use x11rb::rust_connection::RustConnection; +use x11rb::wrapper::ConnectionExt as _; + +/// The Steam client UI's appid — the identity the splash borrows so `--steam`-mode gamescope +/// treats it as focusable before (and beneath) any real game. +const STEAM_UI_APPID: u32 = 769; + +/// Window/bar palette: near-black background, greys the bar breathes through. +const BG: u32 = 0x0d0d0d; + +/// How often the splash repaints (each paint is the damage that makes gamescope push a capture +/// buffer). 400 ms ≈ 2.5 fps — far inside every first-frame budget, invisible in CPU terms. +const TICK: Duration = Duration::from_millis(400); + +/// Entry point for the hidden `gamescope-splash` host subcommand. Blocks for the session's +/// lifetime; returns (or errors) only when the X connection is gone or never came up. +pub(crate) fn run() -> Result<()> { + // gamescope execs its nested command only once Xwayland is ready and DISPLAY is set, so the + // first attempt normally lands — the retry covers a slow Xwayland under driver-init load. + let (conn, screen_num) = connect_with_retry()?; + let screen = &conn.setup().roots[screen_num]; + let (w, h) = (screen.width_in_pixels, screen.height_in_pixels); + let win = conn.generate_id()?; + conn.create_window( + x11rb::COPY_DEPTH_FROM_PARENT, + win, + screen.root, + 0, + 0, + w, + h, + 0, + WindowClass::INPUT_OUTPUT, + screen.root_visual, + &CreateWindowAux::new().background_pixel(BG), + )?; + conn.change_property8( + PropMode::REPLACE, + win, + AtomEnum::WM_NAME, + AtomEnum::STRING, + b"Punktfunk", + )?; + // STEAM_GAME on the window + GAMESCOPECTRL_BASELAYER_APPID on the root — the pair that makes a + // `--steam` gamescope composite us. Seed the baselayer only while unset: once Steam is up it + // owns that property (its rewrite at game launch is exactly the handover we want). + let steam_game = conn.intern_atom(false, b"STEAM_GAME")?.reply()?.atom; + conn.change_property32( + PropMode::REPLACE, + win, + steam_game, + AtomEnum::CARDINAL, + &[STEAM_UI_APPID], + )?; + let baselayer = conn + .intern_atom(false, b"GAMESCOPECTRL_BASELAYER_APPID")? + .reply()? + .atom; + let existing = conn + .get_property(false, screen.root, baselayer, AtomEnum::CARDINAL, 0, 4)? + .reply()?; + if existing.value_len == 0 { + conn.change_property32( + PropMode::REPLACE, + screen.root, + baselayer, + AtomEnum::CARDINAL, + &[STEAM_UI_APPID], + )?; + } + conn.map_window(win)?; + let gc = conn.generate_id()?; + conn.create_gc(gc, win, &CreateGCAux::new().foreground(BG))?; + conn.flush()?; + tracing::info!( + w, + h, + seeded_baselayer = existing.value_len == 0, + "gamescope splash: mapped" + ); + + // The breathing bar. Server-side fills generate the damage; the background pixel handles + // exposures, so no event loop is needed. Any request failing = the session (or its Xwayland) + // is gone = a clean exit. + let bar_w = 120i16; + let bar_h = 8i16; + let bar = Rectangle { + x: (w as i16) / 2 - bar_w / 2, + y: (h as i16) / 2 - bar_h / 2, + width: bar_w as u16, + height: bar_h as u16, + }; + let mut t: u32 = 0; + loop { + // 8-step triangle pulse through dark greys (0x1a…0x40 per channel). + let phase = (t % 8) as i32; + let tri = if phase < 4 { phase } else { 8 - phase }; + let grey = 0x1a + 0x0c * tri as u32; + let colour = (grey << 16) | (grey << 8) | grey; + conn.change_gc(gc, &ChangeGCAux::new().foreground(colour))?; + conn.poly_fill_rectangle(win, gc, &[bar])?; + conn.flush() + .context("gamescope splash: X connection lost")?; + t = t.wrapping_add(1); + std::thread::sleep(TICK); + } +} + +/// Connect to the session's `DISPLAY`, retrying briefly — gamescope sets the variable before +/// exec'ing the nested command, but a slow Xwayland under cold driver init gets a grace window. +fn connect_with_retry() -> Result<(RustConnection, usize)> { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + match x11rb::connect(None) { + Ok(ok) => return Ok(ok), + Err(e) if std::time::Instant::now() >= deadline => { + return Err(e).context("gamescope splash: could not connect to the session DISPLAY") + } + Err(_) => std::thread::sleep(Duration::from_millis(200)), + } + } +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index bd59611c..4f442269 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -333,6 +333,11 @@ fn real_main() -> Result<()> { // names the inherited socketpair end. #[cfg(target_os = "linux")] Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]), + // Hidden: the splash client every bare gamescope spawn backgrounds beside its nested app, + // so a fresh headless gamescope composites (and its PipeWire node delivers frames) from + // the first second — never run by hand; it needs a gamescope session's DISPLAY. + #[cfg(target_os = "linux")] + Some("gamescope-splash") => vdisplay::gamescope_splash_client(), // NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12 // on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A // `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.