feat: M2 — gamescope virtual-display backend (spawn headless, capture its PipeWire node)

Third compositor on the VirtualDisplay seam. gamescope's model differs from KWin/Mutter: it's
not a runtime protocol but a micro-compositor we spawn — `gamescope --backend headless -W -H -r
-- <app>` — which composites at the client's size AND refresh natively (so no separate
refresh step), runs the app nested, and exports a built-in PipeWire node named "gamescope".
The backend spawns it, discovers that node via pw-dump, and returns a VirtualOutput whose
keepalive owns the process (drop = kill = teardown). App via LUMEN_GAMESCOPE_APP. Select with
LUMEN_COMPOSITOR=gamescope; m0's virtual source now honors LUMEN_COMPOSITOR so any backend is
testable without a client. Input (gamescope's libei/EIS socket) is a follow-up.

Builds/clippy/fmt clean. Needs gamescope installed to validate; headless capture on the
proprietary NVIDIA driver is plausible-by-architecture but unproven — validate empirically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:23:52 +00:00
parent 22a982a1cb
commit 20bd76ae50
4 changed files with 149 additions and 5 deletions
+2
View File
@@ -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).
+5 -4
View File
@@ -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")?
}
};
+9 -1
View File
@@ -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<Compositor> {
"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<Box<dyn VirtualDisplay>> {
{
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<Box<dyn VirtualDisplay>> {
}
}
#[cfg(target_os = "linux")]
mod gamescope;
#[cfg(target_os = "linux")]
mod kwin;
+133
View File
@@ -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 -- <app>`. 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<Self> {
Ok(GamescopeDisplay)
}
}
impl VirtualDisplay for GamescopeDisplay {
fn name(&self) -> &'static str {
"gamescope"
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
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 -- <app>`. 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<Child> {
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<u32> {
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<u32> {
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();
}
}