diff --git a/crates/lumen-host/Cargo.toml b/crates/lumen-host/Cargo.toml index 711dee0..beb0954 100644 --- a/crates/lumen-host/Cargo.toml +++ b/crates/lumen-host/Cargo.toml @@ -54,6 +54,8 @@ wayland-protocols-misc = { version = "0.3", features = ["client"] } # `wayland-backend` is referenced by the generated interface tables. wayland-scanner = "0.31" wayland-backend = "0.3" +# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend). +serde_json = "1" # Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state. xkbcommon = "0.8" # Opus encode for the GameStream audio stream (links system libopus). diff --git a/crates/lumen-host/src/m0.rs b/crates/lumen-host/src/m0.rs index fe40a97..6682b0a 100644 --- a/crates/lumen-host/src/m0.rs +++ b/crates/lumen-host/src/m0.rs @@ -61,20 +61,21 @@ pub fn run(opts: Options) -> Result<()> { capture::open_portal_monitor().context("open portal capturer")? } Source::KwinVirtual => { + let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin); tracing::info!( width = opts.width, height = opts.height, - "M0 source: KWin virtual output (zkde_screencast)" + ?compositor, + "M0 source: virtual output (LUMEN_COMPOSITOR)" ); - let mut vd = crate::vdisplay::open(crate::vdisplay::Compositor::Kwin) - .context("open KWin virtual display")?; + let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; let vout = vd .create(lumen_core::Mode { width: opts.width, height: opts.height, refresh_hz: opts.fps, }) - .context("create KWin virtual output")?; + .context("create virtual output")?; capture::capture_virtual_output(vout).context("capture virtual output")? } }; diff --git a/crates/lumen-host/src/vdisplay.rs b/crates/lumen-host/src/vdisplay.rs index bca44ac..5342a53 100644 --- a/crates/lumen-host/src/vdisplay.rs +++ b/crates/lumen-host/src/vdisplay.rs @@ -51,6 +51,8 @@ pub enum Compositor { Wlroots, /// Mutter / GNOME — headless backend + Mutter DBus `RecordVirtual`. Mutter, + /// gamescope — spawned headless at the client's size/refresh; capture its PipeWire node. + Gamescope, } /// Detect the compositor to drive: `LUMEN_COMPOSITOR` override, else `XDG_CURRENT_DESKTOP`. @@ -60,7 +62,10 @@ pub fn detect() -> Result { "kwin" | "kde" | "plasma" => Ok(Compositor::Kwin), "wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots), "mutter" | "gnome" => Ok(Compositor::Mutter), - other => anyhow::bail!("unknown LUMEN_COMPOSITOR '{other}' (kwin|wlroots|mutter)"), + "gamescope" => Ok(Compositor::Gamescope), + other => { + anyhow::bail!("unknown LUMEN_COMPOSITOR '{other}' (kwin|wlroots|mutter|gamescope)") + } }; } let desktop = std::env::var("XDG_CURRENT_DESKTOP") @@ -88,6 +93,7 @@ pub fn open(compositor: Compositor) -> Result> { { match compositor { Compositor::Kwin => Ok(Box::new(kwin::KwinDisplay::new()?)), + Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)), Compositor::Wlroots => { anyhow::bail!("wlroots virtual-output backend not yet implemented") } @@ -103,5 +109,7 @@ pub fn open(compositor: Compositor) -> Result> { } } +#[cfg(target_os = "linux")] +mod gamescope; #[cfg(target_os = "linux")] mod kwin; diff --git a/crates/lumen-host/src/vdisplay/gamescope.rs b/crates/lumen-host/src/vdisplay/gamescope.rs new file mode 100644 index 0000000..2719e18 --- /dev/null +++ b/crates/lumen-host/src/vdisplay/gamescope.rs @@ -0,0 +1,133 @@ +//! gamescope virtual-display backend. +//! +//! Unlike KWin/Mutter (which create a virtual output at runtime via a protocol), gamescope is a +//! micro-compositor we *spawn*: `gamescope --backend headless -W w -H h -r hz -- `. It runs +//! the app nested, composites at the requested size/refresh (so the source rate is the client's +//! rate natively — no separate refresh step), and exports a built-in PipeWire node named +//! `gamescope` (media.class `Video/Source`, BGRx/NV12, dmabuf or shm) on the user's PipeWire +//! daemon. We discover that node and capture it like any other; the gamescope *process* is the +//! keepalive — dropping the [`VirtualOutput`] kills it (tearing the output down). +//! +//! Requirements: gamescope built with PipeWire + libei input emulation (distro packages are); +//! a usable Vulkan device (the NVIDIA render node). Headless capture on the proprietary NVIDIA +//! driver is plausible-by-architecture but not a well-trodden path — validate empirically. +//! Input is a gamescope-specific libei/EIS socket (`LIBEI_SOCKET`), wired separately (TODO). + +use super::{Mode, VirtualDisplay, VirtualOutput}; +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. +pub struct GamescopeDisplay; + +impl GamescopeDisplay { + pub fn new() -> Result { + Ok(GamescopeDisplay) + } +} + +impl VirtualDisplay for GamescopeDisplay { + fn name(&self) -> &'static str { + "gamescope" + } + + fn create(&mut self, mode: Mode) -> Result { + let proc = GamescopeProc(spawn(mode.width, mode.height, mode.refresh_hz.max(1))?); + // gamescope creates its PipeWire node a moment after start; poll for it (the proc is held + // alive meanwhile, and killed if we give up). + let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| { + anyhow!( + "gamescope PipeWire node did not appear within 15s — gamescope may have failed to \ + start or headless capture is unsupported on this GPU/driver (see /tmp/lumen-gamescope.log)" + ) + })?; + tracing::info!( + node_id, + w = mode.width, + h = mode.height, + hz = mode.refresh_hz, + "gamescope virtual output ready" + ); + Ok(VirtualOutput { + node_id, + remote_fd: None, + keepalive: Box::new(proc), + }) + } +} + +/// Spawn `gamescope --backend headless -W w -H h -r hz -- `. The app comes from +/// `LUMEN_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real +/// game/GL app for actual content). stdout/stderr go to `/tmp/lumen-gamescope.log`. +fn spawn(w: u32, h: u32, hz: u32) -> Result { + let app = std::env::var("LUMEN_GAMESCOPE_APP").unwrap_or_else(|_| "sleep infinity".to_string()); + let mut cmd = Command::new("gamescope"); + cmd.args(["--backend", "headless"]) + .args(["-W", &w.to_string()]) + .args(["-H", &h.to_string()]) + .args(["-r", &hz.to_string()]) + .args(["--xwayland-count", "1", "--"]) + .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"); + if let Ok(log) = std::fs::File::create("/tmp/lumen-gamescope.log") { + if let Ok(log2) = log.try_clone() { + cmd.stdout(Stdio::from(log)).stderr(Stdio::from(log2)); + } + } else { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + tracing::info!(w, h, hz, %app, "spawning gamescope (headless)"); + cmd.spawn() + .context("spawn gamescope (is it installed? `apt install gamescope`)") +} + +/// Poll `pw-dump` for gamescope's PipeWire node until it appears or `timeout` elapses. +fn wait_for_node(timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Some(id) = find_gamescope_node() { + return Some(id); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(300)); + } +} + +/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon. +fn find_gamescope_node() -> Option { + let out = Command::new("pw-dump").output().ok()?; + let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + for obj in dump.as_array()? { + if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") { + continue; + } + let props = obj.get("info").and_then(|i| i.get("props")); + let name = props + .and_then(|p| p.get("node.name")) + .and_then(|n| n.as_str()) + .unwrap_or(""); + let class = props + .and_then(|p| p.get("media.class")) + .and_then(|n| n.as_str()) + .unwrap_or(""); + if name == "gamescope" || (class == "Video/Source" && name.contains("gamescope")) { + return obj.get("id").and_then(|i| i.as_u64()).map(|x| x as u32); + } + } + None +} + +/// Owns the spawned gamescope process; killing it tears the virtual output down. +struct GamescopeProc(Child); + +impl Drop for GamescopeProc { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +}