mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
Three related fixes. Together they take HDR from unreachable to working
end to end on the XWayland path.
## XWayland was never started
Three lines had been commented out since the initial import: the call
that spawns XWayland, the guard that waits for it, and the `DISPLAY`
handed to the child. Every game therefore launched as a native Wayland
client. Nothing reported it -- the compositor still logged the X display
it was telling clients to point at, which is why it read as working.
That is also why HDR never fired. The colour space is signalled over a
protocol whose Vulkan layer lives in the game process and finds the
compositor through the X11 root window, so the one path able to carry it
was the one path no game was on. `ENABLE_GAMESCOPE_WSI` and `DXVK_HDR`
were already being set, which switched that layer on and then handed it
a
display it could not use.
Restoring the guard also fixes the ordering it was written for: the
launch
now happens after XWayland reports ready rather than ~40ms before it.
## Mesa was dropping every format we advertised alpha-only
Mesa tracks two flags per VkFormat -- one contributed by a format alpha
FourCC, one by its opaque FourCC -- and skips any format carrying only
one:
```c
if (!(disp_fmt->flags & WSI_WL_FMT_ALPHA) ||
!(disp_fmt->flags & WSI_WL_FMT_OPAQUE))
continue;
```
We advertised `ARGB8888` and `XRGB8888`, so `B8G8R8A8` survived and made
the list look like it was working. Everything else was alpha-only and
was
dropped in silence -- `ABGR8888` had been advertised all along while
`R8G8B8A8` never once appeared on a surface. Adding the opaque spellings
takes the surface from 6 formats to 21 and restores the three that carry
HDR.
The comment above that list claimed it was for XWayland DRI3 and that a
game swapchain format was independent of it. It was the opposite: the
list decides what a game can select, and deleting an entry removes that
format from every client.
## Verified against swapchains, not format lists
A client asking for `A2B10G10R10` + `HDR10_ST2084` now gets a swapchain
and the compositor is told colorspace `1000104008`; one asking for
`R16G16B16A16_SFLOAT` + scRGB linear gets `1000104002`. Previously both
were refused at creation -- the WSI layer re-checks the requested format
against the driver own surface list, so the colour space and the pixel
format arrive from two different places and only one was being supplied.
`apps/nescope/scripts/verify-hdr-formats.sh` asks what a client is
offered
from inside a child process, keeping the XCB and Wayland surfaces apart
since a game presents through the XCB one. The default mode guards both
halves of what the compositor controls; `--expect-layer` states the full
target and passes once a WSI layer is present. No new dependencies
(`vulkaninfo` + `python3`).
## Still open
HDR is XWayland-only, documented as a FIXME in `hdr.rs`. A WSI layer
binds
the swapchain factory on its own Wayland connection while a native
client
surface lives on the client one, and object IDs do not cross
connections.
The FIXME records the fix both reference implementations point at, and
the
trap to avoid when we take it: gating format injection on "the
compositor
supports HDR" rather than on being able to signal the surface hands a
client PQ pixels that arrive tagged as SDR, with nothing reporting an
error.
nescope ships no WSI layer of its own; the above was verified with the
stock gamescope one, which drives our protocol unmodified.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR makes HDR-capable native Wayland presentation reachable, adds
the alpha/opaque DMA-BUF FourCC pairs Mesa requires, makes XWayland an
explicit compatibility mode, and adds an HDR surface-format diagnostic.
- Starts XWayland only with `--xwayland`, waits for readiness before
launching the child, and stops cleanly if startup fails or times out.
- Routes Proton through Wayland unconditionally while retaining
`DXVK_HDR` as an HDR-specific setting.
- Advertises paired alpha and opaque FourCC variants with portable
modifiers.
- Separates XCB and Wayland probe results, selects one hardware adapter,
and distinguishes probe failures from format regressions.
- Documents the limitations of the legacy gamescope WSI path and the
external-layer dependency.
<h3>Confidence Score: 5/5</h3>
The PR appears safe to merge; no outstanding correctness, security, or
repository-rule issue remains.
All previous findings are resolved in the current code, including the
XWayland failure lifecycle, removal of unsupported vendor modifiers,
corrected HDR documentation, reliable diagnostic failure handling,
per-GPU format selection, and unconditional Proton Wayland routing. The
changes since the previous review preserve diagnostic output handling
without introducing a new failure.
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| apps/nescope/src/main.rs | Adds opt-in XWayland lifecycle handling,
readiness timeout, conditional DISPLAY propagation, and unconditional
Proton Wayland routing; the previous launch-environment finding is
fixed. |
| apps/nescope/src/state.rs | Stops the event loop on reported XWayland
startup failure and advertises portable paired alpha/opaque DMA-BUF
formats without vendor-specific modifiers. |
| apps/nescope/src/hdr.rs | Documents the working native Wayland HDR
path and accurately distinguishes it from the external, deliberately
disabled gamescope WSI route. |
| apps/nescope/scripts/verify-hdr-formats.sh | Adds a diagnostic that
keeps GPU and surface paths separate and now preserves the intended exit
behavior when filtered Vulkan diagnostics contain no matching lines. |
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Launch[nescope child launch] --> Mode{--xwayland?}
Mode -->|No| Wayland[Native Wayland surface]
Mode -->|Yes| Wait[Start and await XWayland]
Wait -->|Ready| XCB[XCB / XWayland surface]
Wait -->|Error or 10s timeout| Stop[Log failure and stop]
Wayland --> Formats[Paired alpha and opaque FourCCs]
Formats --> HDR[HDR10 and scRGB formats available]
XCB --> SDR[X11 compatibility path without native HDR]
Proton[Proton child] -->|PROTON_ENABLE_WAYLAND=1| Wayland
```
<sub>Reviews (9): Last reviewed commit: ["nescope/scripts: guard the
diagnostic
pi..."](f8bdd68f87)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60368324)</sub>
**Context used:**
- Knowledge Base — [Compositor, display, and input
control](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/compositor-input.md)
- Knowledge Base — [Streaming host
runtime](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/streaming-runtime.md)
<!-- /greptile_comment -->
799 lines
34 KiB
Rust
799 lines
34 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 (`hudless`); 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 [default: 60]
|
||
//! --hdr Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain)
|
||
//! --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 protocols;
|
||
mod screenshot_ipc;
|
||
mod screenshot_wire;
|
||
mod state;
|
||
mod xwm;
|
||
|
||
use crate::input::{decode_wire_event, process_input};
|
||
use state::{CalloopData, ClientState, NescopeState};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CLI
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[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 (fps).
|
||
#[arg(long, default_value = "60", env = "NESCOPE_FPS")]
|
||
fps: u32,
|
||
|
||
/// Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain_factory_v2).
|
||
#[arg(long, env = "NESCOPE_HDR")]
|
||
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")]
|
||
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,
|
||
|
||
/// Path to the hub's screenshot IPC socket (nescope connects as client).
|
||
///
|
||
/// Optional, and absent means the feature is simply off: it exists for
|
||
/// clients that are not games — a Steam login screen has no Vulkan frames
|
||
/// for `nescapture` to take, so its pixels can only come from here.
|
||
#[arg(long, env = "NESCOPE_SCREENSHOT_IPC")]
|
||
screenshot_ipc: Option<String>,
|
||
|
||
/// 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 ──────────────────────────────────────────────
|
||
// Send wl_surface.frame done events at the target fps. This is what
|
||
// drives the game's render loop in the absence of a real scanout.
|
||
let frame_interval = Duration::from_micros(1_000_000 / args.fps.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 gamescope_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", &gamescope_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");
|
||
|
||
// Left set, but deliberately without ENABLE_GAMESCOPE_WSI
|
||
// alongside it, so it is inert unless somebody opts in.
|
||
//
|
||
// That pair activates gamescope's WSI layer, which
|
||
// predates Wayland colour management and works by
|
||
// hiding HDR from the driver and reporting it to the
|
||
// compositor out of band. We do not want it: it needs a
|
||
// layer this image does not ship, it only helps the
|
||
// XWayland path, and capture reads the colour space it
|
||
// hides -- measured, a game asking for HDR10 through it
|
||
// has its PQ samples encoded and tagged BT.709 SDR.
|
||
// Enabling it would trade no HDR for wrong HDR.
|
||
tracing::debug!(
|
||
gamescope_wayland_socket,
|
||
"HDR: Wayland colour management; gamescope WSI not enabled"
|
||
);
|
||
cmd.env("GAMESCOPE_WAYLAND_DISPLAY", &gamescope_wayland_socket);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
}
|
||
}
|
||
}
|