mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-27 04:52:25 +03:00
Fixes: #335 Still a work-in-progress. --------- Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
847 lines
36 KiB
Rust
847 lines
36 KiB
Rust
//! nescope — lightweight headless Wayland compositor for game capture.
|
||
//!
|
||
//! # Overview
|
||
//!
|
||
//! nescope creates a virtual Wayland output, starts XWayland, and gives games
|
||
//! a complete compositor environment. Frames are captured externally by a
|
||
//! Vulkan interception library (`nescapture`); nescope itself
|
||
//! never allocates a GBM pool or forwards DMA-BUFs.
|
||
//!
|
||
//! # Usage
|
||
//!
|
||
//! ```text
|
||
//! nescope [OPTIONS] -- <command> [args...]
|
||
//!
|
||
//! Options:
|
||
//! --width <N> Output width [default: 1920]
|
||
//! --height <N> Output height [default: 1080]
|
||
//! --fps <N> Virtual refresh rate, advertised only [default: 60]
|
||
//! --frame-callback-hz <N> wl_surface.frame cadence [default: 1000]
|
||
//! --hdr Enable HDR colour management (wp_color_manager_v1)
|
||
//! --socket <NAME> Wayland socket name [default: nescope-0]
|
||
//! ```
|
||
//!
|
||
//! # Environment variables
|
||
//!
|
||
//! | Variable | Effect |
|
||
//! |----------------|-----------------------------------------------|
|
||
//! | `WAYLAND_DISPLAY` | Set by nescope before spawning the game |
|
||
//! | `DISPLAY` | XWayland display (`:N`), only with `--xwayland` |
|
||
//! | `PROTON_ENABLE_WAYLAND` | Set to `1` always, so Proton renders through Wayland |
|
||
//! | `XCURSOR_THEME` | XCursor theme name for the software cursor |
|
||
//! | `XCURSOR_SIZE` | XCursor size in pixels |
|
||
//! | `RUST_LOG` | Tracing filter (e.g. `nescope=debug`) |
|
||
//!
|
||
//! # Ctrl+C / shutdown
|
||
//!
|
||
//! The first SIGINT/SIGTERM sets an atomic flag; the event loop detects it on
|
||
//! the next idle tick, kills all child process groups, and exits cleanly.
|
||
//! A second signal falls through to the OS default handler (hard kill).
|
||
//!
|
||
//! nescope registers itself as a subreaper (`PR_SET_CHILD_SUBREAPER`) so that
|
||
//! orphaned game descendants (grandchildren, great-grandchildren, …) are
|
||
//! reparented to it instead of PID 1. This prevents zombie accumulation and
|
||
//! ensures `kill_all_children()` can reach every descendant.
|
||
|
||
use std::os::unix::process::CommandExt;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
/// How long to wait for XWayland to report a display before giving up. Startup
|
||
/// is normally tens of milliseconds; this only has to be longer than a slow
|
||
/// machine's worst case, not tuned.
|
||
const XWAYLAND_TIMEOUT_SECS: u64 = 10;
|
||
|
||
use calloop::generic::Generic;
|
||
use calloop::signals::{Signal, Signals};
|
||
use calloop::timer::Timer;
|
||
use calloop::{EventLoop, Interest, Mode, PostAction};
|
||
use clap::Parser;
|
||
use smithay::reexports::wayland_server::Display;
|
||
use smithay::wayland::socket::ListeningSocketSource;
|
||
|
||
mod focus;
|
||
//mod gpu_readback;
|
||
mod handlers;
|
||
mod hdr;
|
||
mod input;
|
||
mod input_ipc;
|
||
mod libinput_backend;
|
||
//mod screenshot_ipc;
|
||
//mod screenshot_wire;
|
||
mod state;
|
||
mod xwm;
|
||
|
||
use crate::input::{decode_wire_event, process_input};
|
||
use state::{CalloopData, ClientState, NescopeState};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CLI
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// A flag that can also arrive as an environment variable.
|
||
///
|
||
/// `--hdr` on its own still means true. The difference is what a value may be:
|
||
/// clap's own bool parser takes `true` and `false` and nothing else, so
|
||
/// `NESCOPE_HDR=1` -- which is how every other environment variable in this
|
||
/// stack is written, and the first thing anyone tries -- was rejected outright.
|
||
fn flag_value(value: &str) -> Result<bool, String> {
|
||
match value.trim().to_ascii_lowercase().as_str() {
|
||
"1" | "true" | "yes" | "on" => Ok(true),
|
||
"0" | "false" | "no" | "off" | "" => Ok(false),
|
||
other => Err(std::format!(
|
||
"expected 1 or 0 (true/false, yes/no and on/off are also taken), got {other:?}"
|
||
)),
|
||
}
|
||
}
|
||
|
||
#[derive(Parser, Debug)]
|
||
#[command(
|
||
name = "nescope",
|
||
about = "Lightweight headless Wayland compositor for game capture",
|
||
after_help = "Everything after '--' is the game command, e.g.:\n nescope --hdr -- %command%"
|
||
)]
|
||
struct Args {
|
||
/// Output width in pixels.
|
||
#[arg(long, default_value = "1920", env = "NESCOPE_WIDTH")]
|
||
width: u32,
|
||
|
||
/// Output height in pixels.
|
||
#[arg(long, default_value = "1080", env = "NESCOPE_HEIGHT")]
|
||
height: u32,
|
||
|
||
/// Virtual output refresh rate, as advertised to clients.
|
||
///
|
||
/// **Advertised only — this does not pace anything.** It is what a game
|
||
/// reads as its monitor's refresh rate, so it should be the rate the
|
||
/// session actually sends at: a game with V-Sync on will lock to it, and
|
||
/// one that reads the mode to build a settings list will offer it.
|
||
///
|
||
/// Pacing is `--frame-callback-hz`, and the two used to be this one value.
|
||
/// That made an honest advertisement and a non-binding cadence mutually
|
||
/// exclusive, which is why the default sat at 60 while sessions asked for
|
||
/// 120.
|
||
#[arg(long, default_value = "60", env = "NESCOPE_FPS")]
|
||
fps: u32,
|
||
|
||
/// How often `wl_surface.frame` callbacks are sent, in hertz.
|
||
///
|
||
/// This is the only rate that can throttle a client, and only a FIFO one:
|
||
/// `IMMEDIATE` and `MAILBOX` swapchains ignore these callbacks entirely.
|
||
/// It is therefore not a frame limiter — it cannot hold a game whose
|
||
/// V-Sync is off, which is every game whose player turned it off. That job
|
||
/// belongs to the capture layer, which sees every present and can hold the
|
||
/// application whatever its swapchain does.
|
||
///
|
||
/// So the default is set high enough never to bind, and the compositor
|
||
/// stops being a second opinion on the frame rate. The cost is the timer
|
||
/// itself: a wakeup per tick, each sending callbacks to the surfaces in
|
||
/// the space. Lower it if that shows up on a small box.
|
||
#[arg(long, default_value = "1000", env = "NESCOPE_FRAME_CALLBACK_HZ")]
|
||
frame_callback_hz: u32,
|
||
|
||
/// Enable HDR colour management (`wp_color_manager_v1`).
|
||
#[arg(
|
||
long,
|
||
env = "NESCOPE_HDR",
|
||
num_args = 0..=1,
|
||
default_value_t = false,
|
||
default_missing_value = "true",
|
||
value_parser = flag_value,
|
||
)]
|
||
hdr: bool,
|
||
|
||
/// Run XWayland, for Linux-native software with no Wayland support.
|
||
///
|
||
/// Off by default, and that is the point. XWayland costs input latency and
|
||
/// a compositing hop, which is the wrong trade for a streaming box. Windows
|
||
/// titles do not need it -- Proton renders through Wayland when told to,
|
||
/// which is what the launch environment does -- and HDR is only offered on
|
||
/// the Wayland surface, so a game routed through XWayland loses it too.
|
||
/// Turn this on for the shrinking set of X11-only native software.
|
||
#[arg(
|
||
long,
|
||
env = "NESCOPE_XWAYLAND",
|
||
num_args = 0..=1,
|
||
default_value_t = false,
|
||
default_missing_value = "true",
|
||
value_parser = flag_value,
|
||
)]
|
||
xwayland: bool,
|
||
|
||
/// Wayland socket name (created in $XDG_RUNTIME_DIR).
|
||
#[arg(long, default_value = "nescope-0", env = "NESCOPE_SOCKET")]
|
||
socket: String,
|
||
|
||
/// Path to the hub's input IPC socket (nescope connects as client).
|
||
#[arg(
|
||
long,
|
||
env = "NESCOPE_INPUT_IPC",
|
||
default_value = "/tmp/nestri-input.sock"
|
||
)]
|
||
input_ipc: String,
|
||
|
||
// There is no `--screenshot-ipc`. The path it named is commented out below,
|
||
// and an option that is accepted and does nothing is worse than one that is
|
||
// refused: a caller passing it gets no error, no capture, and nothing to
|
||
// read that says which. It comes back with the code, or not at all.
|
||
//
|
||
/// GPU render device (e.g. /dev/dri/renderD128). Sets VK_DRIVER_FILES
|
||
/// for the game so it uses the same GPU.
|
||
#[arg(long, env = "NESCOPE_RENDER_DEVICE")]
|
||
render_device: Option<String>,
|
||
|
||
/// X display number for XWayland, so clients can be pointed at it.
|
||
///
|
||
/// Fixed rather than whatever XWayland picks: in compositor mode the
|
||
/// processes that join are started by something else entirely, and a
|
||
/// display number nobody can predict would need a discovery handshake to
|
||
/// communicate something that is free to agree on in advance.
|
||
#[arg(long, env = "NESCOPE_X_DISPLAY", default_value_t = 1)]
|
||
x_display: u32,
|
||
|
||
/// Game command — everything after '--'.
|
||
///
|
||
/// **Optional.** With one, nescope launches it and exits when it and its
|
||
/// windows are gone — a wrapper around a single game. Without one, nescope
|
||
/// is a plain compositor: it comes up, publishes its displays and waits,
|
||
/// and whatever wants to draw connects to it.
|
||
///
|
||
/// The second shape is what a session needs. A Steam client and the game
|
||
/// it authorises have to share a compositor *and* a Wine prefix, and
|
||
/// neither can be the other's parent.
|
||
#[arg(last = true)]
|
||
command: Vec<String>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Entry point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn main() {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||
.init();
|
||
|
||
let args = Args::parse();
|
||
tracing::info!(
|
||
"nescope {}×{}@{}fps hdr={} socket={}",
|
||
args.width,
|
||
args.height,
|
||
args.fps,
|
||
args.hdr,
|
||
args.socket,
|
||
);
|
||
|
||
// ── Become a process subreaper ────────────────────────────────────────
|
||
// Orphaned grandchild processes (Steam launcher → real game client) are
|
||
// reparented to us instead of PID 1. This lets us:
|
||
// • reap all zombie descendants
|
||
// • detect when the entire game tree has exited
|
||
// • kill all children reliably on shutdown
|
||
unsafe {
|
||
if libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0 {
|
||
tracing::warn!("prctl(PR_SET_CHILD_SUBREAPER) failed — orphans may become zombies");
|
||
} else {
|
||
tracing::debug!("Registered as child subreaper");
|
||
}
|
||
}
|
||
|
||
// ── Event loop ────────────────────────────────────────────────────────
|
||
let mut event_loop: EventLoop<CalloopData> =
|
||
EventLoop::try_new().expect("Failed to create event loop");
|
||
let loop_handle = event_loop.handle();
|
||
let loop_signal = event_loop.get_signal();
|
||
|
||
// ── Signal handling ───────────────────────────────────────────────────
|
||
let signals =
|
||
Signals::new(&[Signal::SIGINT, Signal::SIGTERM]).expect("Failed to create signal source");
|
||
|
||
loop_handle
|
||
.insert_source(signals, |event, _, data| {
|
||
tracing::info!("Received signal {:?} — shutting down", event.signal());
|
||
|
||
// Kill game process group
|
||
if let Some(pgid) = data.game_pgid {
|
||
unsafe {
|
||
libc::kill(-pgid, libc::SIGKILL);
|
||
}
|
||
}
|
||
|
||
// Kill everything else
|
||
kill_all_children_sigkill();
|
||
|
||
// Reap
|
||
std::thread::sleep(Duration::from_millis(200));
|
||
reap_zombies(data);
|
||
|
||
data.loop_signal.stop();
|
||
})
|
||
.expect("Failed to register signal source");
|
||
|
||
// ── Wayland display ───────────────────────────────────────────────────
|
||
let mut display: Display<NescopeState> =
|
||
Display::new().expect("Failed to create Wayland display");
|
||
let display_handle = display.handle();
|
||
|
||
// Wake calloop when game clients send requests.
|
||
{
|
||
let fd = display
|
||
.backend()
|
||
.poll_fd()
|
||
.try_clone_to_owned()
|
||
.expect("Failed to clone display fd");
|
||
loop_handle
|
||
.insert_source(
|
||
Generic::new(fd, Interest::READ, Mode::Level),
|
||
|_, _, data| {
|
||
data.display
|
||
.dispatch_clients(&mut data.state)
|
||
.expect("dispatch_clients failed");
|
||
Ok(PostAction::Continue)
|
||
},
|
||
)
|
||
.expect("Failed to register display fd");
|
||
}
|
||
|
||
// ── Wayland socket ────────────────────────────────────────────────────
|
||
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||
|
||
// Remove stale socket + lock files from a previous crash.
|
||
for name in [&args.socket, &format!("{}.lock", args.socket)] {
|
||
let path = std::path::Path::new(&xdg_runtime_dir).join(name);
|
||
if path.exists() {
|
||
tracing::warn!("Removing stale socket file: {}", path.display());
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
}
|
||
|
||
let socket_source = ListeningSocketSource::with_name(&args.socket)
|
||
.unwrap_or_else(|e| panic!("Failed to create Wayland socket '{}': {e}", args.socket));
|
||
let socket_name = socket_source.socket_name().to_os_string();
|
||
|
||
tracing::info!("Wayland socket: {socket_name:?}");
|
||
|
||
{
|
||
let mut dh = display_handle.clone();
|
||
loop_handle
|
||
.insert_source(socket_source, move |stream, _, _| {
|
||
if let Err(e) = dh.insert_client(
|
||
stream,
|
||
Arc::new(ClientState {
|
||
compositor_state: Default::default(),
|
||
}),
|
||
) {
|
||
tracing::error!("Failed to accept Wayland client: {e}");
|
||
}
|
||
})
|
||
.expect("Failed to register socket source");
|
||
}
|
||
|
||
// ── Compositor state ──────────────────────────────────────────────────
|
||
let (mut state, _input_tx) = NescopeState::new(
|
||
display_handle.clone(),
|
||
loop_handle.clone(),
|
||
args.width,
|
||
args.height,
|
||
args.fps,
|
||
args.hdr,
|
||
args.render_device.clone(),
|
||
);
|
||
if args.xwayland {
|
||
state.init_xwayland(&loop_handle, Some(args.x_display));
|
||
}
|
||
|
||
// Said out loud because in compositor mode nothing else can work them out.
|
||
// A process started by the hub rather than by nescope has no inherited
|
||
// environment to read them from.
|
||
if args.command.is_empty() {
|
||
tracing::info!(
|
||
wayland_display = %socket_name.to_string_lossy(),
|
||
display = if args.xwayland {
|
||
format!(":{}", args.x_display)
|
||
} else {
|
||
"(none — XWayland off; pass --xwayland if you need it)".to_string()
|
||
},
|
||
"compositor mode — point clients at these and they will connect"
|
||
);
|
||
}
|
||
|
||
// The GPU to import dmabufs on for screenshots. Same device the game is
|
||
// pointed at, because a buffer the game produced can only be imported on
|
||
// the device that made it.
|
||
//gpu_readback::set_render_device(args.render_device.clone());
|
||
|
||
// ── Screenshot IPC source ────────────────────────────────────────────
|
||
// Same dial-out shape as the input socket below, so the hub is the
|
||
// listener and there is no race against a socket that does not exist yet.
|
||
// Absent means the feature is off, which is the normal case for a game.
|
||
/*if let Some(path) = args.screenshot_ipc.clone() {
|
||
match screenshot_ipc::ScreenshotIpcSource::connect(&path) {
|
||
Ok(source) => match source.try_clone_writer() {
|
||
Ok(mut writer) => {
|
||
tracing::info!("Connected to screenshot IPC socket: {path}");
|
||
loop_handle
|
||
.insert_source(source, move |request, _, data| {
|
||
if request != screenshot_ipc::REQUEST_CAPTURE {
|
||
tracing::warn!("unknown screenshot request {request:#x}");
|
||
return;
|
||
}
|
||
let (status, capture) =
|
||
screenshot_ipc::capture_frontmost(&data.state.space);
|
||
if status != screenshot_wire::Status::Ok {
|
||
// Worth saying: `Unreadable` means the client is
|
||
// rendering on the GPU and this path can never
|
||
// see it -- a configuration problem, not a
|
||
// transient one.
|
||
tracing::debug!("screenshot answered with {status:?}");
|
||
}
|
||
if let Err(e) = screenshot_ipc::write_reply_to(
|
||
&mut writer,
|
||
status,
|
||
capture.as_ref(),
|
||
) {
|
||
tracing::warn!("failed to answer a screenshot request: {e}");
|
||
}
|
||
})
|
||
.expect("Failed to register screenshot IPC source");
|
||
}
|
||
Err(e) => tracing::warn!("Failed to clone screenshot IPC stream: {e}"),
|
||
},
|
||
Err(e) => tracing::warn!("Failed to connect to screenshot IPC socket {path}: {e}"),
|
||
}
|
||
}*/
|
||
|
||
// ── Input IPC source ─────────────────────────────────────────────────
|
||
// Connect to the neshub input socket and feed events into the
|
||
// compositor seat. Reconnection is handled in the idle callback.
|
||
let ipc_path = args.input_ipc.clone();
|
||
match input_ipc::InputIpcSource::connect(&ipc_path) {
|
||
Ok(source) => {
|
||
tracing::info!("Connected to input IPC socket: {ipc_path}");
|
||
match source.try_clone() {
|
||
Ok(write_stream) => {
|
||
state.ipc_write = Some(write_stream);
|
||
state.cursor_image_sent = false; // re-send on reconnect
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("Failed to clone IPC write stream: {e}");
|
||
}
|
||
}
|
||
loop_handle
|
||
.insert_source(source, move |payload, _, data| {
|
||
if let Some(event) = decode_wire_event(&payload) {
|
||
process_input(event, &mut data.state);
|
||
}
|
||
})
|
||
.expect("Failed to register input IPC source");
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("Failed to connect to input IPC socket {ipc_path}: {e}");
|
||
}
|
||
}
|
||
|
||
// ── Frame-callback timer ──────────────────────────────────────────────
|
||
// Sends wl_surface.frame done events, releases the held buffer and posts
|
||
// presentation feedback.
|
||
//
|
||
// Deliberately *not* `--fps`. This cadence only ever throttles a FIFO
|
||
// client, so using it as a frame limiter caps the games that opted into
|
||
// V-Sync and does nothing at all to the ones that did not — which is the
|
||
// wrong way round, and it capped them at 60 while sessions asked for 120.
|
||
// The capture layer holds the game instead, and this runs fast enough to
|
||
// stay out of the way.
|
||
let frame_interval = Duration::from_micros(1_000_000 / args.frame_callback_hz.max(1) as u64);
|
||
loop_handle
|
||
.insert_source(Timer::from_duration(frame_interval), move |_, _, data| {
|
||
if let Some(ref mut li) = data.libinput {
|
||
libinput_backend::dispatch_libinput(li, &mut data.state);
|
||
}
|
||
data.state.on_frame_tick();
|
||
calloop::timer::TimeoutAction::ToDuration(frame_interval)
|
||
})
|
||
.expect("Failed to register frame timer");
|
||
|
||
// ── CalloopData ───────────────────────────────────────────────────────
|
||
let socket_name_for_cleanup = args.socket.clone();
|
||
let command = args.command.clone();
|
||
let wayland_socket = args.socket.clone();
|
||
|
||
// ── libinput backend ─────────────────────────────────────────────────
|
||
let libinput_ctx =
|
||
libinput_backend::create_libinput().expect("Failed to create libinput context");
|
||
|
||
let mut data = CalloopData {
|
||
state,
|
||
display,
|
||
loop_signal,
|
||
libinput: Some(libinput_ctx),
|
||
game_process: None,
|
||
primary_pid: None,
|
||
game_pgid: None,
|
||
};
|
||
|
||
tracing::info!("Entering event loop");
|
||
|
||
// Run with a 1-second timeout so the idle closure fires even when no
|
||
// Wayland events arrive (needed for zombie reaping and auto-exit checks).
|
||
// Deadline for XWayland to come up. The launch below waits on it, so if it
|
||
// never arrives there is nothing to wait for and no game to run.
|
||
let startup = std::time::Instant::now();
|
||
let mut xwayland_timed_out = false;
|
||
|
||
event_loop
|
||
.run(Some(Duration::from_secs(1)), &mut data, move |data| {
|
||
// ── Reap zombie children ──────────────────────────────────
|
||
// As subreaper we own all orphaned descendants. Reap them
|
||
// here on every tick so they don't accumulate.
|
||
reap_zombies(data);
|
||
|
||
// ── Launch game once XWayland is ready ────────────────────
|
||
if !command.is_empty()
|
||
&& data.game_process.is_none()
|
||
&& data.primary_pid.is_none()
|
||
&& !data.state.game_launched
|
||
{
|
||
// Only wait on XWayland when we are the ones providing it.
|
||
// Without --xwayland there is no display coming, so waiting
|
||
// would mean never launching.
|
||
if !args.xwayland || data.state.xdisplay.is_some() {
|
||
data.state.game_launched = true;
|
||
tracing::info!("Launching {:?}", command[0]);
|
||
|
||
let mut cmd = std::process::Command::new(&command[0]);
|
||
|
||
cmd.args(&command[1..])
|
||
.stdin(std::process::Stdio::null())
|
||
.stdout(std::process::Stdio::inherit())
|
||
.stderr(std::process::Stdio::inherit())
|
||
// Put the game in its own process group so we can
|
||
// kill the whole tree at once with kill(-pgid, …).
|
||
.process_group(0)
|
||
.env("WAYLAND_DISPLAY", &wayland_socket);
|
||
|
||
// DISPLAY only if XWayland is actually running. Setting it
|
||
// otherwise points clients at a server that is not there,
|
||
// which is what the compositor used to do.
|
||
if let Some(xdisplay) = data.state.xdisplay {
|
||
cmd.env("DISPLAY", format!(":{xdisplay}"));
|
||
}
|
||
|
||
// Proton renders through XWayland unless this is set, and
|
||
// XWayland is off by default -- so without this a Windows
|
||
// title has no display at all. Unconditional for that
|
||
// reason: it is how the game reaches the compositor, not
|
||
// an HDR switch. It is also what makes HDR reachable, since
|
||
// colour management only exists on the Wayland surface --
|
||
// measured here, that surface offers 21 formats including
|
||
// HDR10 over A2B10G10R10 while the XWayland one offers two,
|
||
// both 8-bit sRGB.
|
||
cmd.env("PROTON_ENABLE_WAYLAND", "1");
|
||
|
||
if args.hdr {
|
||
// DXVK's dxgi.dll gates HDR colour space exposure on
|
||
// this. Without it neither DX11 nor DX12 (vkd3d-proton
|
||
// through DXVK's dxgi) sees HDR as available.
|
||
cmd.env("DXVK_HDR", "1");
|
||
}
|
||
|
||
// Detect GPU vendor from render device and set VK_DRIVER_FILES
|
||
// so the game uses the same GPU as nescope.
|
||
if let Some(ref rd) = args.render_device {
|
||
if let Some(icd_path) = detect_gpu_icd(rd) {
|
||
cmd.env("VK_ICD_FILENAMES", &icd_path);
|
||
cmd.env("VK_DRIVER_FILES", &icd_path); // Mesa fallback
|
||
tracing::info!("GPU ICD → {icd_path}");
|
||
}
|
||
}
|
||
|
||
match cmd.spawn() {
|
||
Ok(child) => {
|
||
let pid = child.id();
|
||
tracing::info!("Game process spawned (pid {pid})");
|
||
data.primary_pid = Some(pid as i32);
|
||
data.game_pgid = Some(pid as i32); // PGID == PID due to .process_group(0)
|
||
data.game_process = Some(child);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!("Failed to launch {:?}: {e}", command[0]);
|
||
data.loop_signal.stop();
|
||
return;
|
||
}
|
||
}
|
||
} else if args.xwayland
|
||
&& !xwayland_timed_out
|
||
&& startup.elapsed() > Duration::from_secs(XWAYLAND_TIMEOUT_SECS)
|
||
{
|
||
// Waiting forever is the outcome to avoid: the auto-exit
|
||
// below only runs once a game has been launched, so a
|
||
// display that never arrives leaves nescope polling with no
|
||
// game and nothing logged. Smithay does not always report a
|
||
// failed XWayland as an error -- an Xwayland that exits
|
||
// immediately simply never becomes ready -- so this is a
|
||
// deadline, not an error handler.
|
||
xwayland_timed_out = true;
|
||
tracing::error!(
|
||
"XWayland did not become ready within {XWAYLAND_TIMEOUT_SECS}s — \
|
||
cannot launch a game without a display"
|
||
);
|
||
kill_all_children();
|
||
data.loop_signal.stop();
|
||
}
|
||
}
|
||
|
||
// ── Poll primary process ──────────────────────────────────
|
||
// The launcher (e.g. Steam's shell wrapper) may exit quickly
|
||
// while the real game client stays alive as a reparented
|
||
// child. We keep the loop running until all mapped windows
|
||
// are gone.
|
||
if let Some(ref mut child) = data.game_process {
|
||
match child.try_wait() {
|
||
Ok(Some(status)) => {
|
||
tracing::info!("Primary game process exited: {status}");
|
||
data.game_process = None;
|
||
}
|
||
Ok(None) => {}
|
||
Err(e) if e.raw_os_error() == Some(libc::ECHILD) => {
|
||
tracing::info!("Primary process already reaped");
|
||
data.game_process = None;
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("try_wait error: {e}");
|
||
data.game_process = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Auto-exit after all windows are gone ──────────────────
|
||
// Wait 5 s after the last mapped window disappears to give
|
||
// any lingering save-game / cleanup processes time to finish.
|
||
if data.state.game_launched && data.game_process.is_none() {
|
||
let has_windows = data.state.space.elements().next().is_some();
|
||
if !has_windows {
|
||
let since = data
|
||
.state
|
||
.no_clients_since
|
||
.get_or_insert_with(std::time::Instant::now);
|
||
if since.elapsed() > Duration::from_secs(5) {
|
||
tracing::info!("No mapped windows for 5 s — exiting.");
|
||
kill_all_children();
|
||
data.loop_signal.stop();
|
||
return;
|
||
}
|
||
} else {
|
||
data.state.no_clients_since = None;
|
||
}
|
||
}
|
||
|
||
// Answer any colour-management information requests that came in
|
||
// this iteration. Deferred to here because the event that ends
|
||
// them destroys the object, and doing that inside the request that
|
||
// created it panics the backend -- see .
|
||
data.state.hdr.flush_information();
|
||
|
||
// ── Flush Wayland clients ─────────────────────────────────
|
||
if let Err(e) = data.display.flush_clients() {
|
||
tracing::warn!("Error flushing Wayland clients: {e}");
|
||
}
|
||
})
|
||
.expect("Event loop error");
|
||
|
||
// ── Final cleanup ─────────────────────────────────────────────────────
|
||
// Kill the game process group directly — SIGKILL, not SIGTERM.
|
||
// This runs regardless of whether the shutdown handler fired.
|
||
if let Some(pgid) = data.game_pgid {
|
||
tracing::debug!("Final cleanup: SIGKILL to game pgid {pgid}");
|
||
unsafe {
|
||
libc::kill(-pgid, libc::SIGKILL);
|
||
}
|
||
}
|
||
kill_all_children_sigkill();
|
||
|
||
// Give kills time to be delivered before we remove sockets
|
||
std::thread::sleep(Duration::from_millis(200));
|
||
reap_zombies(&mut data);
|
||
|
||
// Remove socket files so the next launch doesn't hit stale-lock errors.
|
||
for name in [
|
||
&socket_name_for_cleanup,
|
||
&format!("{}.lock", socket_name_for_cleanup),
|
||
] {
|
||
let path = std::path::Path::new(&xdg_runtime_dir).join(name);
|
||
if path.exists() {
|
||
let _ = std::fs::remove_file(&path);
|
||
tracing::debug!("Cleaned up {}", path.display());
|
||
}
|
||
}
|
||
|
||
tracing::info!("nescope exiting cleanly.");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GPU ICD detection
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Detect the GPU vendor from a render device path and return the
|
||
/// appropriate Vulkan ICD JSON path for VK_ICD_FILENAMES.
|
||
fn detect_gpu_icd(render_device: &str) -> Option<String> {
|
||
// Extract the device number (e.g. "renderD128" → "128")
|
||
let dev_name = std::path::Path::new(render_device)
|
||
.file_name()
|
||
.and_then(|n| n.to_str())?;
|
||
let card_num = dev_name.strip_prefix("renderD")?;
|
||
|
||
let vendor_path = format!("/sys/class/drm/renderD{card_num}/device/vendor");
|
||
let vendor_str = std::fs::read_to_string(&vendor_path).ok()?;
|
||
let vendor = u32::from_str_radix(vendor_str.trim().trim_start_matches("0x"), 16).ok()?;
|
||
|
||
let glob_pattern = match vendor {
|
||
0x1002 | 0x1022 => "radeon_icd*.json",
|
||
0x10de => "nvidia_icd*.json",
|
||
0x8086 => "intel_icd*.json",
|
||
_ => return None,
|
||
};
|
||
|
||
let icd_dirs = &["/usr/share/vulkan/icd.d", "/etc/vulkan/icd.d"];
|
||
for dir in icd_dirs {
|
||
let pat = format!("{dir}/{glob_pattern}");
|
||
if let Ok(entries) = glob::glob(&pat) {
|
||
let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||
// Prefer 64-bit (x86_64) over 32-bit (i686)
|
||
paths.sort_by(|a, b| {
|
||
let a32 = a.to_string_lossy().contains("i686");
|
||
let b32 = b.to_string_lossy().contains("i686");
|
||
a32.cmp(&b32)
|
||
});
|
||
if let Some(path) = paths.first() {
|
||
return Some(path.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Reap all zombie children without blocking.
|
||
///
|
||
/// Called every event loop tick since we are a subreaper.
|
||
fn reap_zombies(data: &mut CalloopData) {
|
||
loop {
|
||
let mut status: i32 = 0;
|
||
let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
|
||
match pid {
|
||
0 => break, // no more zombies right now
|
||
-1 => break, // ECHILD — no children left
|
||
pid => {
|
||
if Some(pid) == data.primary_pid {
|
||
tracing::info!("Primary process reaped (pid {pid})");
|
||
data.game_process = None;
|
||
} else {
|
||
tracing::debug!("Reaped orphaned child (pid {pid})");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Send SIGTERM to all direct children and their process groups.
|
||
///
|
||
/// Because we are a subreaper, any descendant that re-parented itself (e.g.
|
||
/// via double-fork) also ends up under us. We scan `/proc` for direct
|
||
/// children and kill their process groups, which catches the full game tree.
|
||
fn kill_all_children() {
|
||
let our_pid = unsafe { libc::getpid() };
|
||
|
||
let proc = match std::fs::read_dir("/proc") {
|
||
Ok(d) => d,
|
||
Err(_) => return,
|
||
};
|
||
|
||
for entry in proc.flatten() {
|
||
let name = entry.file_name();
|
||
let name_str = name.to_string_lossy();
|
||
if !name_str
|
||
.chars()
|
||
.next()
|
||
.map(|c| c.is_ascii_digit())
|
||
.unwrap_or(false)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
let stat_path = entry.path().join("stat");
|
||
let Ok(contents) = std::fs::read_to_string(&stat_path) else {
|
||
continue;
|
||
};
|
||
|
||
// The `stat` format is: pid (comm) state ppid ...
|
||
// The comm field may contain spaces, so we search backwards from the
|
||
// closing ')' to find the field boundary reliably.
|
||
let Some(after_comm) = contents.rfind(')') else {
|
||
continue;
|
||
};
|
||
let fields: Vec<&str> = contents[after_comm + 1..].split_whitespace().collect();
|
||
let Some(ppid_str) = fields.get(1) else {
|
||
continue;
|
||
};
|
||
let Ok(ppid) = ppid_str.parse::<i32>() else {
|
||
continue;
|
||
};
|
||
|
||
if ppid == our_pid {
|
||
let Ok(child_pid) = name_str.parse::<i32>() else {
|
||
continue;
|
||
};
|
||
tracing::debug!("Killing child pid {child_pid} and its process group");
|
||
unsafe {
|
||
libc::kill(-child_pid, libc::SIGTERM); // kill the process group
|
||
libc::kill(child_pid, libc::SIGTERM); // kill the process itself
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Like kill_all_children() but sends SIGKILL instead of SIGTERM.
|
||
fn kill_all_children_sigkill() {
|
||
let our_pid = unsafe { libc::getpid() };
|
||
let proc = match std::fs::read_dir("/proc") {
|
||
Ok(d) => d,
|
||
Err(_) => return,
|
||
};
|
||
for entry in proc.flatten() {
|
||
let name = entry.file_name();
|
||
let name_str = name.to_string_lossy();
|
||
if !name_str
|
||
.chars()
|
||
.next()
|
||
.map(|c| c.is_ascii_digit())
|
||
.unwrap_or(false)
|
||
{
|
||
continue;
|
||
}
|
||
let stat_path = entry.path().join("stat");
|
||
let Ok(contents) = std::fs::read_to_string(&stat_path) else {
|
||
continue;
|
||
};
|
||
let Some(after_comm) = contents.rfind(')') else {
|
||
continue;
|
||
};
|
||
let fields: Vec<&str> = contents[after_comm + 1..].split_whitespace().collect();
|
||
let Some(ppid_str) = fields.get(1) else {
|
||
continue;
|
||
};
|
||
let Ok(ppid) = ppid_str.parse::<i32>() else {
|
||
continue;
|
||
};
|
||
if ppid == our_pid {
|
||
let Ok(child_pid) = name_str.parse::<i32>() else {
|
||
continue;
|
||
};
|
||
tracing::debug!("SIGKILL to child pid {child_pid}");
|
||
unsafe {
|
||
libc::kill(-child_pid, libc::SIGKILL);
|
||
libc::kill(child_pid, libc::SIGKILL);
|
||
}
|
||
}
|
||
}
|
||
}
|