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:
@@ -54,6 +54,8 @@ wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
|||||||
# `wayland-backend` is referenced by the generated interface tables.
|
# `wayland-backend` is referenced by the generated interface tables.
|
||||||
wayland-scanner = "0.31"
|
wayland-scanner = "0.31"
|
||||||
wayland-backend = "0.3"
|
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.
|
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||||
xkbcommon = "0.8"
|
xkbcommon = "0.8"
|
||||||
# Opus encode for the GameStream audio stream (links system libopus).
|
# Opus encode for the GameStream audio stream (links system libopus).
|
||||||
|
|||||||
@@ -61,20 +61,21 @@ pub fn run(opts: Options) -> Result<()> {
|
|||||||
capture::open_portal_monitor().context("open portal capturer")?
|
capture::open_portal_monitor().context("open portal capturer")?
|
||||||
}
|
}
|
||||||
Source::KwinVirtual => {
|
Source::KwinVirtual => {
|
||||||
|
let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
width = opts.width,
|
width = opts.width,
|
||||||
height = opts.height,
|
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)
|
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||||
.context("open KWin virtual display")?;
|
|
||||||
let vout = vd
|
let vout = vd
|
||||||
.create(lumen_core::Mode {
|
.create(lumen_core::Mode {
|
||||||
width: opts.width,
|
width: opts.width,
|
||||||
height: opts.height,
|
height: opts.height,
|
||||||
refresh_hz: opts.fps,
|
refresh_hz: opts.fps,
|
||||||
})
|
})
|
||||||
.context("create KWin virtual output")?;
|
.context("create virtual output")?;
|
||||||
capture::capture_virtual_output(vout).context("capture virtual output")?
|
capture::capture_virtual_output(vout).context("capture virtual output")?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ pub enum Compositor {
|
|||||||
Wlroots,
|
Wlroots,
|
||||||
/// Mutter / GNOME — headless backend + Mutter DBus `RecordVirtual`.
|
/// Mutter / GNOME — headless backend + Mutter DBus `RecordVirtual`.
|
||||||
Mutter,
|
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`.
|
/// 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),
|
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
|
||||||
"wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots),
|
"wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots),
|
||||||
"mutter" | "gnome" => Ok(Compositor::Mutter),
|
"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")
|
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||||
@@ -88,6 +93,7 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
|||||||
{
|
{
|
||||||
match compositor {
|
match compositor {
|
||||||
Compositor::Kwin => Ok(Box::new(kwin::KwinDisplay::new()?)),
|
Compositor::Kwin => Ok(Box::new(kwin::KwinDisplay::new()?)),
|
||||||
|
Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)),
|
||||||
Compositor::Wlroots => {
|
Compositor::Wlroots => {
|
||||||
anyhow::bail!("wlroots virtual-output backend not yet implemented")
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod kwin;
|
mod kwin;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user