fix(vdisplay/gamescope): a fresh dedicated launch streams from the first second
audit / bun-audit (push) Failing after 20s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
ci / bench (push) Successful in 6m35s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 7m50s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
arch / build-publish (push) Canceled after 7m36s
deb / build-publish (push) Canceled after 4m26s
ci / web (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Canceled after 6m54s
deb / build-publish-client-arm64 (push) Canceled after 6m48s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 40s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
apple / swift (push) Successful in 5m32s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
audit / cargo-audit (push) Successful in 2m54s
ci / docs-site (push) Successful in 1m25s
release / apple (push) Canceled after 1m57s
decky / build-publish (push) Successful in 45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 39s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 6m34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
audit / bun-audit (push) Failing after 20s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
ci / bench (push) Successful in 6m35s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 7m50s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
arch / build-publish (push) Canceled after 7m36s
deb / build-publish (push) Canceled after 4m26s
ci / web (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Canceled after 6m54s
deb / build-publish-client-arm64 (push) Canceled after 6m48s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 40s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
apple / swift (push) Successful in 5m32s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
audit / cargo-audit (push) Successful in 2m54s
ci / docs-site (push) Successful in 1m25s
release / apple (push) Canceled after 1m57s
decky / build-publish (push) Successful in 45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 39s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 6m34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
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 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -3048,6 +3048,7 @@ dependencies = [
|
|||||||
"wayland-client",
|
"wayland-client",
|
||||||
"wayland-scanner",
|
"wayland-scanner",
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ pub struct HostConfig {
|
|||||||
/// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles
|
/// 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.
|
/// and menus, so it's opt-in per host until validated on-glass.
|
||||||
pub gamescope_grab_cursor: bool,
|
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
|
/// `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
|
/// 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
|
/// 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"
|
"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")
|
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||||
.filter(|s| !s.trim().is_empty()),
|
.filter(|s| !s.trim().is_empty()),
|
||||||
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ wayland-backend = "0.3"
|
|||||||
# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
|
# 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).
|
# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
|
||||||
bitflags = "2"
|
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]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
|
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
|
||||||
|
|||||||
@@ -425,6 +425,16 @@ pub fn effective_topology() -> policy::Topology {
|
|||||||
#[path = "vdisplay/linux/gamescope.rs"]
|
#[path = "vdisplay/linux/gamescope.rs"]
|
||||||
mod gamescope;
|
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 +
|
// 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
|
// 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.
|
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
#[path = "gamescope/discovery.rs"]
|
#[path = "gamescope/discovery.rs"]
|
||||||
mod discovery;
|
mod discovery;
|
||||||
|
#[path = "gamescope/splash.rs"]
|
||||||
|
mod splash;
|
||||||
use discovery::{
|
use discovery::{
|
||||||
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
|
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
|
||||||
gamescope_node_present, poll_managed_node, wait_for_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,
|
game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit,
|
||||||
SteamGameWatch,
|
SteamGameWatch,
|
||||||
};
|
};
|
||||||
|
pub(crate) use splash::run as splash_run;
|
||||||
|
|
||||||
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
||||||
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
|
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — 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)
|
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/<id>`, 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
|
/// 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.
|
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
|
||||||
fn is_steam_launch(cmd: &str) -> bool {
|
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://")
|
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/<id>`, 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 {
|
fn shape_dedicated_command(app: &str) -> String {
|
||||||
let mut it = app.split_whitespace();
|
let mut it = app.split_whitespace();
|
||||||
if it.next() == Some("steam") {
|
if it.next() == Some("steam") {
|
||||||
let rest: Vec<&str> = it.collect();
|
let rest: Vec<&str> = it.collect();
|
||||||
if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) {
|
if !rest.contains(&"-gamepadui") && rest.iter().any(|t| t.starts_with("steam://")) {
|
||||||
return format!("steam -silent {}", rest.join(" "));
|
return format!("steam -gamepadui {}", rest.join(" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.to_string()
|
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).
|
/// 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
|
/// 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`]
|
/// 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<Child> {
|
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
|
||||||
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
// 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
|
// `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.
|
// cursor-grab flag below.
|
||||||
let game_launch = app.is_some();
|
let game_launch = app.is_some();
|
||||||
let app = app.unwrap_or_else(|| "sleep infinity".to_string());
|
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
|
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-gamepadui` so the nested
|
||||||
// gamescope focus with no Steam client window to navigate.
|
// 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 app = shape_dedicated_command(&app);
|
||||||
let relay = ei_socket_file();
|
let relay = ei_socket_file();
|
||||||
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
||||||
@@ -2047,18 +2059,26 @@ 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.
|
// 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).
|
// 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;
|
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");
|
let mut cmd = Command::new("gamescope");
|
||||||
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
|
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
|
||||||
cmd.args([
|
let script = nested_wrapper_script(&relay, splash_exe.is_some());
|
||||||
"sh",
|
cmd.args(["sh", "-c", &script, "sh"]);
|
||||||
"-c",
|
if let Some(exe) = &splash_exe {
|
||||||
&format!(
|
cmd.arg(exe);
|
||||||
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
|
}
|
||||||
relay.display()
|
cmd.args(app.split_whitespace())
|
||||||
),
|
|
||||||
"sh",
|
|
||||||
])
|
|
||||||
.args(app.split_whitespace())
|
|
||||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
||||||
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
|
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
|
||||||
@@ -2077,11 +2097,34 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
|
|||||||
} else {
|
} else {
|
||||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
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()
|
cmd.spawn()
|
||||||
.context("spawn gamescope (is it installed? `apt install gamescope`)")
|
.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
|
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
|
||||||
/// output down.
|
/// output down.
|
||||||
struct GamescopeProc {
|
struct GamescopeProc {
|
||||||
@@ -2105,9 +2148,24 @@ impl Drop for GamescopeProc {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
|
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]
|
#[test]
|
||||||
fn display_manager_flavor_detection() {
|
fn display_manager_flavor_detection() {
|
||||||
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
|
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
|
||||||
@@ -2169,15 +2227,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dedicated_command_shaping() {
|
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!(
|
assert_eq!(
|
||||||
shape_dedicated_command("steam steam://rungameid/570"),
|
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!(
|
assert_eq!(
|
||||||
shape_dedicated_command("steam -silent steam://rungameid/570"),
|
shape_dedicated_command("steam -gamepadui steam://rungameid/570"),
|
||||||
"steam -silent steam://rungameid/570"
|
"steam -gamepadui steam://rungameid/570"
|
||||||
);
|
);
|
||||||
// Non-Steam launches and operator custom commands are untouched.
|
// Non-Steam launches and operator custom commands are untouched.
|
||||||
assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
|
assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,6 +333,11 @@ fn real_main() -> Result<()> {
|
|||||||
// names the inherited socketpair end.
|
// names the inherited socketpair end.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
|
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
|
// 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
|
// 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.
|
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
|
||||||
|
|||||||
Reference in New Issue
Block a user