feat(nescope): open the compositor

A headless Wayland compositor for a single fullscreen client, and the second
component into this repo. Imported as a tree from `nestrilabs/nescope` for the
same reason as the last one: the upstream repo is private, its history has never
been reviewed for publication, and a squash is what keeps that history from
becoming permanent here.

Wired to the workspace — versions from the root, `nesprotocol` by path instead
of a sibling directory. 8 tests pass.

It knows a lot about Steam, and all of it stays. `steam_app_*` window classes,
a launcher that exits before the game it started, a client that shows a login
screen with no Vulkan frames in it: that is third-party behaviour a compositor
for games has to handle, and describing it reveals nothing about how we are put
together. The rule is about topology, not vocabulary.

Two comments still name the transport by its old name and are left for the
commit that renames it, so that rename reads as one change rather than as
noise spread across four imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wanjohi
2026-08-26 18:01:41 +03:00
parent 938f5e6544
commit 37dd985810
21 changed files with 6744 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
//! `nescope-shot` — ask a running nescope for a picture of what it is showing.
//!
//! nescope has no display and no renderer, so "is anything actually there?" is
//! otherwise unanswerable: a black stream, a window that never mapped and a
//! client rendering on the GPU all look identical from outside.
//!
//! This is the listener side of the screenshot socket. nescope dials *out*, so
//! a shell one-liner cannot stand in for it — something has to be listening
//! before nescope starts, and the reply is a length-prefixed binary frame
//! rather than text.
//!
//! ```text
//! nescope-shot --socket /tmp/nestri-screenshot.sock --watch --out shot.ppm
//! nescope --screenshot-ipc /tmp/nestri-screenshot.sock -- <program>
//! ```
//!
//! Start this first. It waits for nescope to connect, then asks — once, or on
//! an interval with `--watch`.
//!
//! # Watching, and why it is the useful mode
//!
//! A client can take a long time to put anything on screen. Steam unpacks,
//! self-updates, verifies and starts a browser process before it shows a login
//! window at all, so a single capture almost always lands on nothing and says
//! so. Watching turns that into a story: nothing, then a window with no buffer,
//! then pixels.
//!
//! # Reading the answer
//!
//! The status is the whole diagnosis:
//!
//! - **ok** — pixels arrived; whatever is running presents an shm surface
//! - **no-surface** — no window is mapped at all
//! - **no-buffer** — windows exist but none has drawn anything readable yet.
//! Normal while something is starting; suspicious if it persists
//! - **unreadable** — a surface exists but could not be read by any route:
//! not as shm, and importing it from the GPU failed as well. This is a bug
//! rather than a limitation, and nescope's own log carries the reason
//!
//! PPM is written rather than PNG so this tool needs no image dependency. Any
//! viewer opens it, and `magick shot.ppm shot.png` converts it.
// Included by path rather than duplicated, so the tool and the compositor
// cannot drift apart on the wire format. Most of the module is the
// compositor's half and unused here, which is expected rather than a problem.
#[path = "../screenshot_wire.rs"]
#[allow(dead_code)]
mod screenshot_wire;
use std::io::{Read, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::time::{Duration, Instant};
use screenshot_wire::{REQUEST_CAPTURE, Status};
fn status_from(byte: u8) -> Option<Status> {
match byte {
0 => Some(Status::Ok),
1 => Some(Status::NoSurface),
2 => Some(Status::Unreadable),
3 => Some(Status::NoBuffer),
_ => None,
}
}
/// What to tell somebody staring at the result.
fn explain(status: Status) -> &'static str {
match status {
Status::Ok => "a surface was read",
Status::NoSurface => "no window is mapped at all",
Status::NoBuffer => {
"windows exist but none has drawn anything readable yet — normal while something \
is starting, suspicious if it persists"
}
Status::Unreadable => {
"a surface exists but could not be read by any route — not as shm, and importing \
it from the GPU failed too. nescope's own log says why"
}
}
}
struct Args {
socket: String,
out: Option<String>,
watch: bool,
interval: Duration,
keep: usize,
}
fn parse_args() -> Args {
let mut args = Args {
socket: "/tmp/nestri-screenshot.sock".to_string(),
out: None,
watch: false,
interval: Duration::from_millis(1000),
keep: 5,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--socket" | "-s" => args.socket = it.next().unwrap_or(args.socket),
"--out" | "-o" => args.out = it.next(),
"--watch" | "-w" => args.watch = true,
"--interval" => {
args.interval = it
.next()
.and_then(|v| v.parse().ok())
.map(Duration::from_millis)
.unwrap_or(args.interval)
}
"--keep" => args.keep = it.next().and_then(|v| v.parse().ok()).unwrap_or(args.keep),
"--help" | "-h" => {
println!("nescope-shot [OPTIONS]");
println!();
println!(" -s, --socket PATH socket to listen on");
println!(" -o, --out FILE.ppm write the capture here");
println!(" -w, --watch keep capturing until Ctrl-C");
println!(" --interval MS how often to capture when watching (1000)");
println!(" --keep N rolling files to keep when watching (5)");
println!();
println!("Start this before nescope; nescope connects to it.");
std::process::exit(0);
}
other => {
eprintln!("unknown argument {other:?}; try --help");
std::process::exit(2);
}
}
}
args
}
/// `shot.ppm` + 2 -> `shot-2.ppm`, so the files sort next to each other.
fn numbered(path: &str, n: usize) -> String {
match path.rsplit_once('.') {
Some((stem, ext)) => format!("{stem}-{n}.{ext}"),
None => format!("{path}-{n}"),
}
}
fn write_ppm(path: &str, width: u32, height: u32, rgba: &[u8]) -> std::io::Result<()> {
let mut ppm = Vec::with_capacity(rgba.len() / 4 * 3 + 32);
ppm.extend_from_slice(format!("P6\n{width} {height}\n255\n").as_bytes());
for px in rgba.chunks_exact(4) {
ppm.extend_from_slice(&px[..3]);
}
std::fs::write(path, ppm)
}
/// One request and its reply. `None` means the connection ended.
fn capture(stream: &mut UnixStream) -> std::io::Result<Option<(Status, u32, u32, Vec<u8>)>> {
if stream.write_all(&[REQUEST_CAPTURE]).is_err() {
return Ok(None);
}
let mut header = [0u8; 9];
if stream.read_exact(&mut header).is_err() {
return Ok(None);
}
let Some(status) = status_from(header[0]) else {
eprintln!("unknown status byte {:#x} — version mismatch?", header[0]);
std::process::exit(1);
};
let width = u32::from_le_bytes(header[1..5].try_into().unwrap());
let height = u32::from_le_bytes(header[5..9].try_into().unwrap());
let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
if !rgba.is_empty() {
stream.read_exact(&mut rgba)?;
}
Ok(Some((status, width, height, rgba)))
}
fn main() -> std::io::Result<()> {
let args = parse_args();
// A stale socket file from a previous run makes bind fail with EADDRINUSE,
// which reads as "something is already listening" when nothing is.
let _ = std::fs::remove_file(&args.socket);
let listener = UnixListener::bind(&args.socket)?;
eprintln!(
"listening on {} — start nescope with --screenshot-ipc {}",
args.socket, args.socket
);
let (mut stream, _) = listener.accept()?;
eprintln!("nescope connected; requesting a capture");
let started = Instant::now();
let mut frame = 0usize;
let mut last_report: Option<(Status, u32, u32)> = None;
// Something unchanging still has to say it is alive. Watching a client
// that never maps a window otherwise prints one line and then looks hung,
// which is indistinguishable from the tool having died -- and sends you
// looking at the wrong thing.
let mut last_line = Instant::now();
let heartbeat = Duration::from_secs(15);
loop {
let Some((status, width, height, rgba)) = capture(&mut stream)? else {
eprintln!("nescope disconnected");
return Ok(());
};
// In watch mode only changes are worth a line — a status printed once a
// second for two minutes buries the moment it changed, which is the
// only thing being watched for.
let now = (status, width, height);
let changed = last_report != Some(now);
let due = last_line.elapsed() >= heartbeat;
if changed || due || !args.watch {
let at = started.elapsed().as_secs_f32();
let still = if changed { "" } else { " (still)" };
println!("[{at:6.1}s] {status:?}{still}{}", explain(status));
if status == Status::Ok {
println!(" size: {width}x{height}");
}
last_report = Some(now);
last_line = Instant::now();
}
if status == Status::Ok && !rgba.is_empty() {
if let Some(out) = &args.out {
let path = if args.watch {
numbered(out, frame % args.keep.max(1))
} else {
out.clone()
};
write_ppm(&path, width, height, &rgba)?;
if changed || !args.watch {
println!(" wrote {path}");
}
} else if changed || !args.watch {
// A fully uniform image is the signature of "mapped but never
// drew anything", which looks identical to a working capture
// until somebody checks.
let first = &rgba[..4.min(rgba.len())];
let uniform = rgba.chunks_exact(4).all(|px| px == first);
println!(
" first pixel RGBA: {first:02x?}{}",
if uniform {
" (every pixel identical — the window is blank)"
} else {
""
}
);
}
frame += 1;
}
if !args.watch {
// Non-zero for anything but a real capture, so a script can ask
// whether the path works at all.
std::process::exit(if status == Status::Ok { 0 } else { 1 });
}
std::thread::sleep(args.interval);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rolling_names_sort_beside_the_original() {
assert_eq!(numbered("shot.ppm", 3), "shot-3.ppm");
assert_eq!(numbered("/tmp/a/shot.ppm", 0), "/tmp/a/shot-0.ppm");
// No extension is not an error; the number still has to land somewhere.
assert_eq!(numbered("shot", 2), "shot-2");
}
#[test]
fn every_status_byte_maps_back() {
// The tool and the compositor share this file, so a status added on one
// side without the other would otherwise surface as "version mismatch"
// against a peer of exactly the same version.
for (byte, expected) in [
(0, Status::Ok),
(1, Status::NoSurface),
(2, Status::Unreadable),
(3, Status::NoBuffer),
] {
assert_eq!(status_from(byte), Some(expected), "byte {byte}");
assert_eq!(expected as u8, byte, "{expected:?} encodes as {byte}");
}
assert_eq!(status_from(9), None);
}
}

298
apps/nescope/src/focus.rs Normal file
View File

@@ -0,0 +1,298 @@
//! Keyboard and pointer focus target types for the compositor.
//!
//! Smithay's `SeatHandler::KeyboardFocus` / `PointerFocus` types determine how
//! keyboard and pointer events are dispatched. For X11 windows (via XWayland)
//! focus must go through the `X11Surface` implementation — this calls
//! `XSetInputFocus` (keyboard) or translates Wayland pointer events back to
//! X11 pointer events, which X11 clients require.
//!
//! The `ProxiedX11` variant handles the narrow window between when a game's
//! X11 window is mapped and when its `wl_surface` becomes available. We
//! route events through any other XWayland surface so that `wl_keyboard` /
//! `wl_pointer` delivery still reaches the XWayland process; XWayland then
//! forwards the events to whichever X11 window holds X11 focus.
use std::borrow::Cow;
use smithay::backend::input::KeyState;
use smithay::desktop::{Window, WindowSurface};
use smithay::input::Seat;
use smithay::input::keyboard::{KeyboardTarget, KeysymHandle, ModifiersState};
use smithay::input::pointer::{
AxisFrame, ButtonEvent, MotionEvent, PointerTarget, RelativeMotionEvent,
};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{IsAlive, Serial};
use smithay::wayland::seat::WaylandFocus;
use crate::state::NescopeState;
/// Focus target for keyboard and pointer input.
///
/// Wraps a [`Window`] and dispatches keyboard/pointer events to the correct
/// underlying surface type (Wayland toplevel or X11 surface).
#[derive(Debug, Clone, PartialEq)]
pub enum KeyboardFocusTarget {
/// A normal Wayland or X11 window with a live `wl_surface`.
Window(Window),
/// An X11 window whose `wl_surface` is not yet available (e.g. the
/// gamescope WSI bypass creates a raw VkSurface before XWayland gets a
/// chance to create a `wl_surface`). Events are proxied through
/// a different XWayland surface so that delivery still reaches the
/// XWayland client.
ProxiedX11 {
window: Window,
proxy_surface: WlSurface,
},
}
impl IsAlive for KeyboardFocusTarget {
#[inline]
fn alive(&self) -> bool {
match self {
Self::Window(w) => w.alive(),
Self::ProxiedX11 { window, .. } => window.alive(),
}
}
}
impl WaylandFocus for KeyboardFocusTarget {
#[inline]
fn wl_surface(&self) -> Option<Cow<'_, WlSurface>> {
match self {
Self::Window(w) => w.wl_surface(),
Self::ProxiedX11 { proxy_surface, .. } => Some(Cow::Borrowed(proxy_surface)),
}
}
}
impl From<Window> for KeyboardFocusTarget {
#[inline]
fn from(w: Window) -> Self {
Self::Window(w)
}
}
impl KeyboardTarget<NescopeState> for KeyboardFocusTarget {
fn enter(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
keys: Vec<KeysymHandle<'_>>,
serial: Serial,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
KeyboardTarget::enter(w.wl_surface(), seat, data, keys, serial)
}
WindowSurface::X11(s) => KeyboardTarget::enter(s, seat, data, keys, serial),
},
Self::ProxiedX11 { proxy_surface, .. } => {
KeyboardTarget::enter(proxy_surface, seat, data, keys, serial)
}
}
}
fn leave(&self, seat: &Seat<NescopeState>, data: &mut NescopeState, serial: Serial) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
KeyboardTarget::leave(w.wl_surface(), seat, data, serial)
}
WindowSurface::X11(s) => KeyboardTarget::leave(s, seat, data, serial),
},
Self::ProxiedX11 { proxy_surface, .. } => {
KeyboardTarget::leave(proxy_surface, seat, data, serial)
}
}
}
fn key(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
key: KeysymHandle<'_>,
state: KeyState,
serial: Serial,
time: u32,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
KeyboardTarget::key(w.wl_surface(), seat, data, key, state, serial, time)
}
WindowSurface::X11(s) => {
KeyboardTarget::key(s, seat, data, key, state, serial, time)
}
},
Self::ProxiedX11 { proxy_surface, .. } => {
KeyboardTarget::key(proxy_surface, seat, data, key, state, serial, time)
}
}
}
fn modifiers(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
modifiers: ModifiersState,
serial: Serial,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
KeyboardTarget::modifiers(w.wl_surface(), seat, data, modifiers, serial)
}
WindowSurface::X11(s) => {
KeyboardTarget::modifiers(s, seat, data, modifiers, serial)
}
},
Self::ProxiedX11 { proxy_surface, .. } => {
KeyboardTarget::modifiers(proxy_surface, seat, data, modifiers, serial)
}
}
}
}
impl PointerTarget<NescopeState> for KeyboardFocusTarget {
fn enter(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
event: &MotionEvent,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
PointerTarget::enter(w.wl_surface(), seat, data, event)
}
WindowSurface::X11(s) => PointerTarget::enter(s, seat, data, event),
},
Self::ProxiedX11 { proxy_surface, .. } => {
PointerTarget::enter(proxy_surface, seat, data, event)
}
}
}
fn motion(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
event: &MotionEvent,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
PointerTarget::motion(w.wl_surface(), seat, data, event)
}
WindowSurface::X11(s) => PointerTarget::motion(s, seat, data, event),
},
Self::ProxiedX11 { proxy_surface, .. } => {
PointerTarget::motion(proxy_surface, seat, data, event)
}
}
}
fn relative_motion(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
event: &RelativeMotionEvent,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
PointerTarget::relative_motion(w.wl_surface(), seat, data, event)
}
WindowSurface::X11(s) => {
PointerTarget::relative_motion(s, seat, data, event)
}
},
Self::ProxiedX11 { proxy_surface, .. } => {
PointerTarget::relative_motion(proxy_surface, seat, data, event)
}
}
}
fn button(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
event: &ButtonEvent,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
PointerTarget::button(w.wl_surface(), seat, data, event)
}
WindowSurface::X11(s) => PointerTarget::button(s, seat, data, event),
},
Self::ProxiedX11 { proxy_surface, .. } => {
PointerTarget::button(proxy_surface, seat, data, event)
}
}
}
fn axis(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
frame: AxisFrame,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
PointerTarget::axis(w.wl_surface(), seat, data, frame)
}
WindowSurface::X11(s) => PointerTarget::axis(s, seat, data, frame),
},
Self::ProxiedX11 { proxy_surface, .. } => {
PointerTarget::axis(proxy_surface, seat, data, frame)
}
}
}
fn leave(
&self,
seat: &Seat<NescopeState>,
data: &mut NescopeState,
serial: Serial,
time: u32,
) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
PointerTarget::leave(w.wl_surface(), seat, data, serial, time)
}
WindowSurface::X11(s) => PointerTarget::leave(s, seat, data, serial, time),
},
Self::ProxiedX11 { proxy_surface, .. } => {
PointerTarget::leave(proxy_surface, seat, data, serial, time)
}
}
}
fn gesture_swipe_begin(&self, _seat: &Seat<NescopeState>, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureSwipeBeginEvent) {}
fn gesture_swipe_update(&self, _seat: &Seat<NescopeState>, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureSwipeUpdateEvent) {}
fn gesture_swipe_end(&self, _seat: &Seat<NescopeState>, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureSwipeEndEvent) {}
fn gesture_pinch_begin(&self, _seat: &Seat<NescopeState>, _data: &mut NescopeState, _event: &smithay::input::pointer::GesturePinchBeginEvent) {}
fn gesture_pinch_update(&self, _seat: &Seat<NescopeState>, _data: &mut NescopeState, _event: &smithay::input::pointer::GesturePinchUpdateEvent) {}
fn gesture_pinch_end(&self, _seat: &Seat<NescopeState>, _data: &mut NescopeState, _event: &smithay::input::pointer::GesturePinchEndEvent) {}
fn gesture_hold_begin(&self, _seat: &Seat<NescopeState>, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureHoldBeginEvent) {}
fn gesture_hold_end(&self, _seat: &Seat<NescopeState>, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureHoldEndEvent) {}
fn frame(&self, seat: &Seat<NescopeState>, data: &mut NescopeState) {
match self {
Self::Window(w) => match w.underlying_surface() {
WindowSurface::Wayland(w) => {
PointerTarget::frame(w.wl_surface(), seat, data)
}
WindowSurface::X11(s) => PointerTarget::frame(s, seat, data),
},
Self::ProxiedX11 { proxy_surface, .. } => {
PointerTarget::frame(proxy_surface, seat, data)
}
}
}
}

View File

@@ -0,0 +1,182 @@
//! Copying a GPU buffer back to the CPU, so a screenshot is not limited to
//! clients that render in software.
//!
//! nescope hands dmabuf straight through to `nescapture` and never looks at it,
//! which is right for a game and useless for everything else. XWayland with
//! glamor — which is every XWayland smithay spawns, since it passes no
//! `-noglamor` — always presents dmabuf, so a Steam client under XWayland was
//! unreadable by construction.
//!
//! This imports such a buffer as a texture and copies it back. It is the only
//! GPU work nescope does, and it is deliberately not compositing: one buffer
//! in, one image out, no output, no swapchain, no presentation.
//!
//! # Why the renderer is created lazily and kept
//!
//! Building an EGL context costs enough to notice, and a login screen is polled
//! every second or so. Building one per capture would spend most of the poll
//! interval on setup. It is created on the first capture that needs it, so a
//! box that only ever runs games never pays for it at all.
//!
//! Thread-local rather than in `NescopeState`: the compositor is single
//! threaded, `GlesRenderer` is not `Send`, and this keeps a debugging aid out
//! of the state every other part of the compositor passes around.
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::egl::{EGLContext, EGLDisplay};
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::backend::allocator::Buffer;
use smithay::backend::renderer::{ExportMem, ImportDma};
use smithay::utils::{Point, Rectangle, Size};
thread_local! {
/// `None` until the first attempt; the inner `None` means the attempt
/// failed and should not be retried on every poll.
static RENDERER: RefCell<Option<Option<GlesRenderer>>> = const { RefCell::new(None) };
/// Whether the reason for a failed read has been said out loud yet.
static WARNED: Cell<bool> = const { Cell::new(false) };
/// The render device to import on, from `--render-device`.
static RENDER_DEVICE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
/// Tell the readback which GPU to use, once, at startup.
///
/// The device cannot be taken from the buffer: a dmabuf handed over by a
/// client carries no `DrmNode` — smithay only fills that in when the
/// compositor put it there, and nescope never does. The operator already names
/// the GPU for the game's sake, so the same answer serves here.
pub fn set_render_device(path: Option<String>) {
RENDER_DEVICE.with(|d| *d.borrow_mut() = path.map(PathBuf::from));
}
/// The configured render node, or the first one on the system.
///
/// Scanning is a fallback rather than the plan: on a single-GPU box it is
/// right, and on a multi-GPU box picking the wrong one fails at import with a
/// message naming the device, which is better than refusing to try.
fn render_device() -> Result<PathBuf, String> {
if let Some(path) = RENDER_DEVICE.with(|d| d.borrow().clone()) {
return Ok(path);
}
let mut nodes: Vec<PathBuf> = glob::glob("/dev/dri/renderD*")
.map_err(|e| format!("could not scan /dev/dri: {e}"))?
.filter_map(Result::ok)
.collect();
nodes.sort();
nodes.into_iter().next().ok_or_else(|| {
"no render node found in /dev/dri; pass --render-device".to_string()
})
}
/// Why a read did not happen.
enum ReadError {
/// The GPU path could not be set up at all. Reported once, at setup.
Unavailable,
/// Setup worked and this particular buffer could not be read.
Failed(String),
}
/// Build a renderer on the configured render device.
fn build_renderer() -> Result<GlesRenderer, String> {
let path = render_device()?;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.map_err(|e| format!("could not open {}: {e}", path.display()))?;
let gbm = smithay::backend::allocator::gbm::GbmDevice::new(file)
.map_err(|e| format!("could not create a GBM device on {}: {e}", path.display()))?;
// SAFETY: the GBM device stays alive for the life of the display, which
// lives in the thread-local below for the life of the process.
let display = unsafe { EGLDisplay::new(gbm) }
.map_err(|e| format!("could not open an EGL display: {e}"))?;
let context =
EGLContext::new(&display).map_err(|e| format!("could not create an EGL context: {e}"))?;
// SAFETY: the context is current only on this thread, and the renderer is
// thread-local so it can never be used from another.
unsafe { GlesRenderer::new(context) }.map_err(|e| format!("could not create a renderer: {e}"))
}
/// Copy a dmabuf back to CPU memory as RGBA8.
///
/// Returns `Err` with something worth printing when the GPU path is not
/// available at all — that is a configuration answer, not a transient one, so
/// the caller should say it rather than retry silently.
fn read_dmabuf(dmabuf: &Dmabuf) -> Result<(u32, u32, Vec<u8>), ReadError> {
RENDERER.with(|cell| {
let mut slot = cell.borrow_mut();
if slot.is_none() {
match build_renderer() {
Ok(renderer) => *slot = Some(Some(renderer)),
Err(e) => {
// Remembered as a failure so the next poll does not repeat
// the whole EGL setup just to fail the same way.
tracing::warn!("GPU readback unavailable: {e}");
*slot = Some(None);
}
}
}
let Some(renderer) = slot.as_mut().and_then(|r| r.as_mut()) else {
// Already explained at setup; saying it again per capture would
// report one problem twice in two different words.
return Err(ReadError::Unavailable);
};
let size = dmabuf.size();
let texture = renderer
.import_dmabuf(dmabuf, None)
.map_err(|e| ReadError::Failed(format!("could not import the buffer: {e}")))?;
let region = Rectangle::new(Point::from((0, 0)), Size::from((size.w, size.h)));
let mapping = renderer
.copy_texture(
&texture,
region,
smithay::backend::allocator::Fourcc::Abgr8888,
)
.map_err(|e| ReadError::Failed(format!("could not copy the texture back: {e}")))?;
let bytes = renderer
.map_texture(&mapping)
.map_err(|e| ReadError::Failed(format!("could not map the copied texture: {e}")))?;
Ok((size.w as u32, size.h as u32, bytes.to_vec()))
})
}
/// Read a `wl_buffer` that is backed by a dmabuf.
///
/// `None` when the buffer is not a dmabuf at all, or when the GPU path is
/// unavailable — the caller has already tried shm, so there is nothing left to
/// distinguish and a failure here means "not readable by any route".
pub fn from_wl_buffer(
buffer: &smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer,
) -> Option<crate::screenshot_wire::Capture> {
let dmabuf = smithay::wayland::dmabuf::get_dmabuf(buffer).ok()?;
match read_dmabuf(dmabuf) {
Ok((width, height, rgba)) => Some(crate::screenshot_wire::Capture {
width,
height,
rgba,
}),
// Setup already said why, once, and repeating it per capture would
// report one problem twice in two different words.
Err(ReadError::Unavailable) => None,
Err(ReadError::Failed(e)) => {
// Loudly the first time and quietly afterwards. A capture polled
// every few hundred milliseconds would otherwise either bury the
// log or -- at debug level, which is off by default -- never say
// anything at all, leaving `Unreadable` with no explanation
// anywhere.
if !WARNED.with(|w| w.replace(true)) {
tracing::warn!("GPU readback failed: {e}");
} else {
tracing::debug!("GPU readback failed: {e}");
}
None
}
}
}

View File

@@ -0,0 +1,581 @@
//! Smithay protocol handler implementations for nescope.
//!
//! - `CompositorHandler` on `NescopeState`
//! - `XdgShellHandler` on `NescopeState`
//! - `SeatHandler` on `NescopeState`
//! - `XwmHandler` on `CalloopData` (calloop dispatch type) + stub on `NescopeState`
//! - All delegate macros
use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::desktop::Window;
use smithay::input::pointer::{CursorImageStatus, PointerHandle};
use smithay::input::{Seat, SeatHandler, SeatState};
use smithay::output::Output;
use smithay::reexports::wayland_server::protocol::wl_buffer;
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
use smithay::reexports::wayland_server::protocol::wl_seat::WlSeat;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Rectangle, Serial};
use smithay::wayland::buffer::BufferHandler;
use smithay::wayland::compositor::{
BufferAssignment, CompositorClientState, CompositorHandler, CompositorState, SurfaceAttributes,
is_sync_subsurface, with_states,
};
use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
use smithay::wayland::output::OutputHandler;
use smithay::wayland::pointer_constraints::{PointerConstraintsHandler, with_pointer_constraint};
use smithay::wayland::seat::WaylandFocus;
use smithay::wayland::selection::SelectionHandler;
use smithay::wayland::selection::data_device::{
ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler,
};
use smithay::wayland::shell::xdg::{
PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState,
};
use smithay::wayland::shm::{ShmHandler, ShmState, with_buffer_contents};
use smithay::wayland::xwayland_shell::{XWaylandShellHandler, XWaylandShellState};
use smithay::xwayland::xwm::{Reorder, ResizeEdge, WmWindowProperty, XwmId};
use smithay::xwayland::{X11Surface, X11Wm, XWaylandClientData, XwmHandler};
use crate::focus::KeyboardFocusTarget;
use crate::state::{CalloopData, ClientState, NescopeState};
// ===========================================================================
// BufferHandler / ShmHandler
// ===========================================================================
impl BufferHandler for NescopeState {
fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {}
}
impl ShmHandler for NescopeState {
fn shm_state(&self) -> &ShmState {
&self.shm_state
}
}
// ===========================================================================
// CompositorHandler
// ===========================================================================
impl CompositorHandler for NescopeState {
fn compositor_state(&mut self) -> &mut CompositorState {
&mut self.compositor_state
}
fn client_compositor_state<'a>(
&self,
client: &'a smithay::reexports::wayland_server::Client,
) -> &'a CompositorClientState {
if let Some(state) = client.get_data::<ClientState>() {
return &state.compositor_state;
}
if let Some(state) = client.get_data::<XWaylandClientData>() {
return &state.compositor_state;
}
panic!("Client has neither ClientState nor XWaylandClientData");
}
fn commit(&mut self, surface: &WlSurface) {
self.hdr.commit(surface);
if is_sync_subsurface(surface) {
return;
}
// Hold a reference to the committed buffer — delays wl_buffer.release
// until the next frame tick, starving the swapchain of images in FIFO mode.
smithay::wayland::compositor::with_states(surface, |states| {
let mut cached = states
.cached_state
.get::<smithay::wayland::compositor::SurfaceAttributes>();
let attrs = cached.current();
if let Some(smithay::wayland::compositor::BufferAssignment::NewBuffer(ref wl_buf)) =
attrs.buffer
{
self.held_buffer = Some(wl_buf.clone());
}
});
// Notify the window of the commit so it can refresh its cached state.
if let Some(window) = self
.space
.elements()
.find(|w| {
w.toplevel()
.map(|t| t.wl_surface() == surface)
.unwrap_or(false)
})
.cloned()
{
window.on_commit();
}
}
fn destroyed(&mut self, surface: &WlSurface) {
self.hdr.surface_destroyed(surface);
self.vulkan_surfaces.remove(surface);
}
}
// ===========================================================================
// DmabufHandler — accept everything; nescope never imports the buffers itself
// ===========================================================================
impl DmabufHandler for NescopeState {
fn dmabuf_state(&mut self) -> &mut DmabufState {
&mut self.dmabuf_state
}
fn dmabuf_imported(
&mut self,
_global: &DmabufGlobal,
_dmabuf: Dmabuf,
notifier: ImportNotifier,
) {
// Accept unconditionally — libhudless reads buffers
// directly from the game's Vulkan queue; nescope doesn't need to.
let _ = notifier.successful::<NescopeState>();
}
}
// ===========================================================================
// XDG shell
// ===========================================================================
impl XdgShellHandler for NescopeState {
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
&mut self.xdg_shell_state
}
fn new_toplevel(&mut self, surface: ToplevelSurface) {
// Tell the toplevel to fill the entire virtual output.
surface.with_pending_state(|state| {
state.size = Some((self.width as i32, self.height as i32).into());
});
surface.send_configure();
let window = Window::new_wayland_window(surface);
self.space.map_element(window.clone(), (0, 0), false);
self.set_keyboard_focus_to_window(&window);
tracing::debug!("New XDG toplevel");
}
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
// Remove the dead window from the space.
let elem = self
.space
.elements()
.find(|w| {
w.toplevel()
.map(|t| t.wl_surface() == surface.wl_surface())
.unwrap_or(false)
})
.cloned();
if let Some(elem) = elem {
self.space.unmap_elem(&elem);
tracing::debug!("Unmapped destroyed XDG toplevel");
}
// Re-focus the next window (if any).
self.determine_and_apply_focus();
}
fn new_popup(&mut self, _: PopupSurface, _: PositionerState) {}
fn grab(&mut self, _: PopupSurface, _: WlSeat, _: Serial) {}
fn reposition_request(&mut self, _: PopupSurface, _: PositionerState, _: u32) {}
}
// ===========================================================================
// SeatHandler
// ===========================================================================
impl SeatHandler for NescopeState {
type KeyboardFocus = KeyboardFocusTarget;
type PointerFocus = KeyboardFocusTarget;
type TouchFocus = WlSurface;
fn seat_state(&mut self) -> &mut SeatState<Self> {
&mut self.seat_state
}
fn focus_changed(&mut self, _seat: &Seat<Self>, focused: Option<&KeyboardFocusTarget>) {
let kind = focused
.map(|f| match f {
KeyboardFocusTarget::Window(_) => "wayland window",
KeyboardFocusTarget::ProxiedX11 { .. } => "proxied x11",
})
.unwrap_or("none");
tracing::debug!("keyboard focus changed to {kind}");
}
fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) {
let kind = match &image {
CursorImageStatus::Hidden => "hidden",
CursorImageStatus::Named(n) => {
tracing::debug!("cursor: named({n})");
"named"
}
CursorImageStatus::Surface(_) => {
tracing::debug!("cursor: custom surface");
"surface"
}
};
tracing::trace!("cursor_image set to {kind}");
// Try to capture custom cursor pixels from SHM buffer
let captured = if let CursorImageStatus::Surface(ref surface) = image {
let buffer_opt: Option<wl_buffer::WlBuffer> = with_states(surface, |data| {
let mut attrs = data.cached_state.get::<SurfaceAttributes>();
match attrs.current().buffer {
Some(BufferAssignment::NewBuffer(ref buf)) => Some(buf.clone()),
_ => None,
}
});
buffer_opt.and_then(|buffer| {
match with_buffer_contents(&buffer, |ptr, _len, data| {
let offset = data.offset as isize;
let src = unsafe { ptr.offset(offset) };
let pixel_bytes = (data.width * data.height * 4) as usize;
let stride = data.stride as usize;
let is_xrgb = data.format
== smithay::reexports::wayland_server::protocol::wl_shm::Format::Xrgb8888;
let mut rgba = vec![0u8; pixel_bytes];
for row in 0..data.height as usize {
let src_start = row * stride;
let dst_row = (data.height as usize - 1 - row) * data.width as usize * 4;
unsafe {
std::ptr::copy_nonoverlapping(
src.add(src_start),
rgba.as_mut_ptr().add(dst_row),
data.width as usize * 4,
);
}
}
if is_xrgb {
for px in rgba.chunks_exact_mut(4) {
px[3] = 255;
}
}
let preview = if rgba.len() >= 16 {
&rgba[..16]
} else {
&rgba[..]
};
tracing::debug!(
"cursor: captured {}x{} xrgb={} preview={:02x?}",
data.width,
data.height,
is_xrgb,
preview,
);
nesprotocol::input::CursorImageData {
x: 0.0,
y: 0.0,
width: data.width as u16,
height: data.height as u16,
hotspot_x: 0,
hotspot_y: 0,
rgba,
}
}) {
Ok(d) => Some(d),
Err(_) => {
tracing::debug!("cursor: surface buffer is not SHM, using box fallback");
None
}
}
})
} else {
None
};
self.cursor_image_data = captured;
self.cursor_image_sent = false; // allow re-send for new cursor surface
if self.cursor_image_data.is_some() {
tracing::debug!(
"cursor: captured custom image {}x{} (BGRA)",
self.cursor_image_data.as_ref().unwrap().width,
self.cursor_image_data.as_ref().unwrap().height,
);
}
self.cursor_status = image;
}
fn led_state_changed(&mut self, _: &Seat<Self>, _: smithay::input::keyboard::LedState) {}
}
// ===========================================================================
// Selection / data device
// ===========================================================================
impl SelectionHandler for NescopeState {
type SelectionUserData = ();
}
impl DataDeviceHandler for NescopeState {
fn data_device_state(&self) -> &DataDeviceState {
&self.data_device_state
}
}
impl ClientDndGrabHandler for NescopeState {}
impl ServerDndGrabHandler for NescopeState {}
// ===========================================================================
// Output
// ===========================================================================
impl OutputHandler for NescopeState {
fn output_bound(&mut self, _output: Output, _wl_output: WlOutput) {}
}
// ===========================================================================
// Pointer constraints
// ===========================================================================
impl PointerConstraintsHandler for NescopeState {
fn new_constraint(&mut self, surface: &WlSurface, pointer: &PointerHandle<Self>) {
if let Some(focus) = pointer.current_focus() {
if focus.wl_surface().as_deref() == Some(surface) {
with_pointer_constraint(surface, pointer, |c| {
if let Some(c) = c {
c.activate();
}
});
}
}
}
fn cursor_position_hint(
&mut self,
surface: &WlSurface,
pointer: &PointerHandle<Self>,
location: Point<f64, Logical>,
) {
if with_pointer_constraint(surface, pointer, |c| c.is_some_and(|c| c.is_active())) {
use smithay::wayland::seat::WaylandFocus;
let origin = self
.space
.elements()
.find_map(|w| (w.wl_surface().as_deref() == Some(surface)).then(|| w.geometry()))
.unwrap_or_default()
.loc
.to_f64();
pointer.set_location(origin + location);
}
}
}
// ===========================================================================
// XWayland shell
// ===========================================================================
impl XWaylandShellHandler for NescopeState {
fn xwayland_shell_state(&mut self) -> &mut XWaylandShellState {
&mut self.xwayland_shell_state
}
fn surface_associated(&mut self, _xwm: XwmId, _wl_surface: WlSurface, surface: X11Surface) {
tracing::debug!(window_id = surface.window_id(), "X11 surface associated");
self.determine_and_apply_focus();
}
}
impl XWaylandShellHandler for CalloopData {
fn xwayland_shell_state(&mut self) -> &mut XWaylandShellState {
&mut self.state.xwayland_shell_state
}
fn surface_associated(&mut self, xwm: XwmId, wl_surface: WlSurface, surface: X11Surface) {
XWaylandShellHandler::surface_associated(&mut self.state, xwm, wl_surface, surface);
}
}
// ===========================================================================
// XwmHandler — real implementation on CalloopData
// ===========================================================================
impl XwmHandler for CalloopData {
fn xwm_state(&mut self, _xwm: XwmId) -> &mut X11Wm {
self.state.xwm.as_mut().expect("XWM not initialized")
}
fn new_window(&mut self, _xwm: XwmId, window: X11Surface) {
tracing::debug!(
window_id = window.window_id(),
title = ?window.title(),
"New X11 window"
);
}
fn new_override_redirect_window(&mut self, _xwm: XwmId, window: X11Surface) {
tracing::debug!(
window_id = window.window_id(),
"New override-redirect window"
);
}
fn map_window_request(&mut self, _xwm: XwmId, window: X11Surface) {
tracing::debug!(
title = ?window.title(),
class = ?window.class(),
"X11 map_window_request"
);
let geo = Rectangle::new(
(0, 0).into(),
(self.state.width as i32, self.state.height as i32).into(),
);
if let Err(e) = window.configure(geo) {
tracing::warn!("configure failed: {e}");
}
if let Err(e) = window.set_mapped(true) {
tracing::error!("set_mapped failed: {e}");
return;
}
let win = Window::new_x11_window(window);
self.state.space.map_element(win, (0, 0), true);
self.state.determine_and_apply_focus();
}
fn mapped_override_redirect_window(&mut self, _xwm: XwmId, window: X11Surface) {
let location = window.geometry().loc;
let win = Window::new_x11_window(window);
self.state.space.map_element(win, location, false);
}
fn unmapped_window(&mut self, _xwm: XwmId, window: X11Surface) {
let was_focused = Some(window.window_id()) == self.state.focused_x11_window;
let elem = self
.state
.space
.elements()
.find(|e| e.x11_surface().map(|x| x == &window).unwrap_or(false))
.cloned();
if let Some(elem) = elem {
self.state.space.unmap_elem(&elem);
}
if !window.is_override_redirect() {
let _ = window.set_mapped(false);
}
if was_focused {
self.state.focused_x11_window = None;
self.state.determine_and_apply_focus();
}
}
fn destroyed_window(&mut self, _xwm: XwmId, _window: X11Surface) {}
fn configure_request(
&mut self,
_xwm: XwmId,
window: X11Surface,
_x: Option<i32>,
_y: Option<i32>,
w: Option<u32>,
h: Option<u32>,
_reorder: Option<Reorder>,
) {
// Honor size requests within reason but always keep position at (0,0).
let mut geo = window.geometry();
if let Some(w) = w {
geo.size.w = w as i32;
}
if let Some(h) = h {
geo.size.h = h as i32;
}
let _ = window.configure(geo);
}
fn configure_notify(
&mut self,
_xwm: XwmId,
window: X11Surface,
geometry: Rectangle<i32, Logical>,
_above: Option<u32>,
) {
let target_elem = self
.state
.space
.elements()
.find(|e| e.x11_surface().map(|x| x == &window).unwrap_or(false))
.cloned();
if let Some(elem) = target_elem {
self.state.space.map_element(elem, geometry.loc, false);
}
}
fn property_notify(&mut self, _xwm: XwmId, _window: X11Surface, property: WmWindowProperty) {
// Only recalculate focus when the window class changes (steam_app_* detection).
if matches!(property, WmWindowProperty::Class) {
self.state.determine_and_apply_focus();
}
}
fn resize_request(&mut self, _: XwmId, _: X11Surface, _: u32, _: ResizeEdge) {}
fn move_request(&mut self, _: XwmId, _: X11Surface, _: u32) {}
}
// ===========================================================================
// XwmHandler stub on NescopeState
//
// `delegate_xwayland_shell!(NescopeState)` generates Dispatch impls with a
// `NescopeState: XwmHandler` bound. Only `xwm_state()` is ever called on
// this path (surface association). Real WM logic lives in the CalloopData impl.
// ===========================================================================
impl XwmHandler for NescopeState {
fn xwm_state(&mut self, _xwm: XwmId) -> &mut X11Wm {
self.xwm.as_mut().expect("XWM not initialized")
}
fn new_window(&mut self, _: XwmId, _: X11Surface) {}
fn new_override_redirect_window(&mut self, _: XwmId, _: X11Surface) {}
fn map_window_request(&mut self, _: XwmId, _: X11Surface) {}
fn mapped_override_redirect_window(&mut self, _: XwmId, _: X11Surface) {}
fn unmapped_window(&mut self, _: XwmId, _: X11Surface) {}
fn destroyed_window(&mut self, _: XwmId, _: X11Surface) {}
fn configure_request(
&mut self,
_: XwmId,
_: X11Surface,
_: Option<i32>,
_: Option<i32>,
_: Option<u32>,
_: Option<u32>,
_: Option<Reorder>,
) {
}
fn configure_notify(
&mut self,
_: XwmId,
_: X11Surface,
_: Rectangle<i32, Logical>,
_: Option<u32>,
) {
}
fn property_notify(&mut self, _: XwmId, _: X11Surface, _: WmWindowProperty) {}
fn resize_request(&mut self, _: XwmId, _: X11Surface, _: u32, _: ResizeEdge) {}
fn move_request(&mut self, _: XwmId, _: X11Surface, _: u32) {}
}
// ===========================================================================
// Delegate macros
// ===========================================================================
smithay::delegate_compositor!(NescopeState);
smithay::delegate_dmabuf!(NescopeState);
smithay::delegate_shm!(NescopeState);
smithay::delegate_xdg_shell!(NescopeState);
smithay::delegate_seat!(NescopeState);
smithay::delegate_data_device!(NescopeState);
smithay::delegate_output!(NescopeState);
smithay::delegate_relative_pointer!(NescopeState);
smithay::delegate_pointer_constraints!(NescopeState);
smithay::delegate_xwayland_shell!(NescopeState);
smithay::delegate_viewporter!(NescopeState);
smithay::delegate_presentation!(NescopeState);

840
apps/nescope/src/hdr.rs Normal file
View File

@@ -0,0 +1,840 @@
//! HDR / color management protocol handlers.
//!
//! Two signalling paths feed into [`HdrState`]:
//!
//! 1. **`wp_color_management_v1`** — the standard staging Wayland protocol.
//! Wine/Proton/SDL2 uses this when the game requests an HDR swapchain via
//! standard Vulkan color-space extensions.
//!
//! 2. **`gamescope_swapchain_factory_v2`** — Valve's private protocol used by
//! the gamescope WSI Vulkan layer. This is the primary path for Steam
//! games using PROTON_ENABLE_NVAPI / HDR10_ST2084.
//!
//! nescope never performs color conversion itself — it just tracks which color
//! space the active surface has declared so that an external capture library
//! (e.g. the Vulkan vkcapture layer) can retrieve it via the public API.
#![allow(unused)]
use std::collections::HashMap;
use std::sync::Mutex;
use smithay::reexports::wayland_protocols::wp::color_management::v1::server::{
wp_color_management_output_v1, wp_color_management_surface_feedback_v1,
wp_color_management_surface_v1, wp_color_manager_v1, wp_image_description_creator_icc_v1,
wp_image_description_creator_params_v1, wp_image_description_info_v1, wp_image_description_v1,
};
use smithay::reexports::wayland_protocols::wp::color_representation::v1::server::{
wp_color_representation_manager_v1, wp_color_representation_surface_v1,
};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
};
use crate::protocols::{
gamescope_swapchain::GamescopeSwapchain,
gamescope_swapchain_factory_v2::GamescopeSwapchainFactoryV2,
};
use crate::state::NescopeState;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// Simplified color space used by the external capture library.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSpace {
/// BT.709 primaries, sRGB EOTF.
Srgb,
/// BT.2020 primaries, PQ (ST 2084) EOTF — HDR10.
Bt2020Pq,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransferFunction {
Gamma22,
St2084Pq,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Primaries {
Srgb,
Bt2020,
}
/// A resolved per-surface color / HDR description.
#[derive(Debug, Clone, Copy)]
pub struct ImageDescription {
pub transfer_function: TransferFunction,
pub primaries: Primaries,
pub max_cll: Option<u32>,
pub max_fall: Option<u32>,
pub mastering_luminance: Option<(u32, u32)>,
pub mastering_primaries: Option<[(u32, u32); 3]>,
pub white_point: Option<(u32, u32)>,
}
impl ImageDescription {
pub fn srgb() -> Self {
Self {
transfer_function: TransferFunction::Gamma22,
primaries: Primaries::Srgb,
max_cll: None,
max_fall: None,
mastering_luminance: None,
mastering_primaries: None,
white_point: None,
}
}
pub fn bt2020_pq() -> Self {
Self {
transfer_function: TransferFunction::St2084Pq,
primaries: Primaries::Bt2020,
max_cll: None,
max_fall: None,
mastering_luminance: None,
mastering_primaries: None,
white_point: None,
}
}
pub fn color_space(self) -> ColorSpace {
if self.primaries == Primaries::Bt2020
&& self.transfer_function == TransferFunction::St2084Pq
{
ColorSpace::Bt2020Pq
} else {
ColorSpace::Srgb
}
}
}
// ---------------------------------------------------------------------------
// Resource user-data
// ---------------------------------------------------------------------------
pub struct ColorSurfaceData {
pub surface: WlSurface,
}
pub struct ImageDescriptionUserData {
pub desc: ImageDescription,
}
pub struct CreatorParamsUserData {
pub params: Mutex<CreatorParams>,
}
#[derive(Debug, Default)]
pub struct CreatorParams {
transfer_function: Option<TransferFunction>,
primaries: Option<Primaries>,
max_cll: Option<u32>,
max_fall: Option<u32>,
mastering_luminance: Option<(u32, u32)>,
mastering_primaries: Option<[(u32, u32); 3]>,
white_point: Option<(u32, u32)>,
}
pub struct ColorOutputData;
pub struct ColorSurfaceFeedbackData {
pub surface: WlSurface,
}
pub struct ImageDescriptionInfoData;
pub struct IccCreatorData;
pub struct ColorRepresentationSurfaceData;
// User data for gamescope protocol objects.
pub struct SwapchainFactoryData;
pub struct SwapchainData {
pub surface: WlSurface,
}
// ---------------------------------------------------------------------------
// HdrState
// ---------------------------------------------------------------------------
/// Per-compositor HDR / color management state.
pub struct HdrState {
/// Whether HDR protocols are advertised to clients.
pub enabled: bool,
/// Pending (not-yet-committed) image descriptions keyed by surface.
pending: HashMap<WlSurface, Option<ImageDescription>>,
/// Committed image descriptions keyed by surface.
current: HashMap<WlSurface, ImageDescription>,
}
impl HdrState {
/// Create state and, if `enabled`, register the protocol globals.
pub fn new(display: &DisplayHandle, enabled: bool) -> Self {
if enabled {
display.create_global::<NescopeState, wp_color_manager_v1::WpColorManagerV1, _>(1, ());
display.create_global::<NescopeState, wp_color_representation_manager_v1::WpColorRepresentationManagerV1, _>(1, ());
register_gamescope_swapchain(display);
tracing::info!(
"HDR protocols registered (wp_color_management_v1 + gamescope_swapchain)"
);
}
Self {
enabled,
pending: HashMap::new(),
current: HashMap::new(),
}
}
// ── Pending state ─────────────────────────────────────────────────────
pub fn set_pending(&mut self, surface: &WlSurface, desc: ImageDescription) {
tracing::debug!(
surface_id = ?surface.id(),
color_space = ?desc.color_space(),
"HDR: set_pending"
);
self.pending.insert(surface.clone(), Some(desc));
}
pub fn unset_pending(&mut self, surface: &WlSurface) {
self.pending.insert(surface.clone(), None);
}
/// Apply pending state on `wl_surface.commit`.
pub fn commit(&mut self, surface: &WlSurface) {
if let Some(pending) = self.pending.remove(surface) {
match pending {
Some(desc) => {
tracing::debug!(
surface_id = ?surface.id(),
color_space = ?desc.color_space(),
"HDR: committed"
);
self.current.insert(surface.clone(), desc);
}
None => {
self.current.remove(surface);
}
}
}
}
pub fn surface_destroyed(&mut self, surface: &WlSurface) {
self.pending.remove(surface);
self.current.remove(surface);
}
// ── Queries ───────────────────────────────────────────────────────────
/// Active color space of the fullscreen surface.
///
/// Returns `Bt2020Pq` if any mapped surface has declared BT.2020+PQ,
/// otherwise `Srgb`.
pub fn color_space(&self) -> ColorSpace {
for desc in self.current.values() {
if desc.color_space() == ColorSpace::Bt2020Pq {
return ColorSpace::Bt2020Pq;
}
}
ColorSpace::Srgb
}
/// HDR metadata from the active surface, if available.
pub fn hdr_metadata(&self) -> Option<HdrMetadata> {
for desc in self.current.values() {
if desc.color_space() != ColorSpace::Bt2020Pq {
continue;
}
if desc.max_cll.is_none()
&& desc.max_fall.is_none()
&& desc.mastering_luminance.is_none()
{
continue;
}
let sat = |v: u32| v.min(u16::MAX as u32) as u16;
return Some(HdrMetadata {
display_primaries: desc.mastering_primaries.map_or([(0, 0); 3], |p| {
[
(sat(p[0].0), sat(p[0].1)),
(sat(p[1].0), sat(p[1].1)),
(sat(p[2].0), sat(p[2].1)),
]
}),
white_point: desc.white_point.map_or((0, 0), |(x, y)| (sat(x), sat(y))),
max_luminance: desc.mastering_luminance.map_or(0, |(_, max)| max),
min_luminance: desc.mastering_luminance.map_or(0, |(min, _)| min),
max_cll: sat(desc.max_cll.unwrap_or(0)),
max_fall: sat(desc.max_fall.unwrap_or(0)),
});
}
None
}
}
/// Static HDR10 metadata for the capture layer.
#[derive(Debug, Clone, Copy)]
pub struct HdrMetadata {
/// CIE 1931 xy primaries in 0.00002 units.
pub display_primaries: [(u16, u16); 3],
/// CIE 1931 xy white point in 0.00002 units.
pub white_point: (u16, u16),
/// Max mastering luminance in 0.0001 cd/m².
pub max_luminance: u32,
/// Min mastering luminance in 0.0001 cd/m².
pub min_luminance: u32,
/// Max content light level in cd/m².
pub max_cll: u16,
/// Max frame-average light level in cd/m².
pub max_fall: u16,
}
// ---------------------------------------------------------------------------
// gamescope_swapchain — register global
// ---------------------------------------------------------------------------
const VK_COLOR_SPACE_HDR10_ST2084_EXT: u32 = 1000104008;
pub fn register_gamescope_swapchain(display: &DisplayHandle) {
display.create_global::<NescopeState, GamescopeSwapchainFactoryV2, _>(1, ());
}
// ---------------------------------------------------------------------------
// gamescope_swapchain_factory_v2 — Global + Dispatch
// ---------------------------------------------------------------------------
impl GlobalDispatch<GamescopeSwapchainFactoryV2, ()> for NescopeState {
fn bind(
_: &mut Self,
_: &DisplayHandle,
_: &Client,
resource: New<GamescopeSwapchainFactoryV2>,
_: &(),
data_init: &mut DataInit<'_, Self>,
) {
tracing::debug!("gamescope_swapchain_factory_v2 bound");
data_init.init(resource, SwapchainFactoryData);
}
}
impl Dispatch<GamescopeSwapchainFactoryV2, SwapchainFactoryData> for NescopeState {
fn request(
_: &mut Self,
_: &Client,
_: &GamescopeSwapchainFactoryV2,
request: <GamescopeSwapchainFactoryV2 as Resource>::Request,
_: &SwapchainFactoryData,
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
use crate::protocols::gamescope_swapchain_factory_v2::Request;
match request {
Request::CreateSwapchain { surface, callback } => {
tracing::debug!("gamescope_swapchain_factory_v2: create_swapchain");
data_init.init(callback, SwapchainData { surface });
}
Request::Destroy => {}
}
}
}
// ---------------------------------------------------------------------------
// gamescope_swapchain — Dispatch
// ---------------------------------------------------------------------------
impl Dispatch<GamescopeSwapchain, SwapchainData> for NescopeState {
fn request(
state: &mut Self,
_: &Client,
_: &GamescopeSwapchain,
request: <GamescopeSwapchain as Resource>::Request,
data: &SwapchainData,
_: &DisplayHandle,
_: &mut DataInit<'_, Self>,
) {
use crate::protocols::gamescope_swapchain::Request;
match request {
Request::SwapchainFeedback {
vk_colorspace,
vk_format,
vk_engine_name,
..
} => {
tracing::debug!(
vk_colorspace,
vk_format,
vk_engine_name,
"gamescope_swapchain: swapchain_feedback — registering as Vulkan surface"
);
// Record this as a known Vulkan surface (used for focus routing).
state.vulkan_surfaces.insert(data.surface.clone());
if vk_colorspace == VK_COLOR_SPACE_HDR10_ST2084_EXT {
state
.hdr
.set_pending(&data.surface, ImageDescription::bt2020_pq());
} else {
state
.hdr
.set_pending(&data.surface, ImageDescription::srgb());
}
}
Request::OverrideWindowContent {
x11_window,
gamescope_xwayland_server_id: _,
} => {
tracing::debug!(
x11_window,
"gamescope_swapchain: override_window_content — WSI bypass surface"
);
state.vulkan_surfaces.insert(data.surface.clone());
state.override_window_surface(x11_window, data.surface.clone());
}
Request::SetHdrMetadata {
display_primary_red_x,
display_primary_red_y,
display_primary_green_x,
display_primary_green_y,
display_primary_blue_x,
display_primary_blue_y,
white_point_x,
white_point_y,
max_display_mastering_luminance,
min_display_mastering_luminance,
max_cll,
max_fall,
} => {
tracing::debug!(
max_cll,
max_fall,
max_display_mastering_luminance,
min_display_mastering_luminance,
"gamescope_swapchain: set_hdr_metadata"
);
let desc = ImageDescription {
transfer_function: TransferFunction::St2084Pq,
primaries: Primaries::Bt2020,
max_cll: Some(max_cll),
max_fall: Some(max_fall),
// max_display_mastering_luminance is in cd/m², normalize to 0.0001 units.
mastering_luminance: Some((
min_display_mastering_luminance,
max_display_mastering_luminance.saturating_mul(10000),
)),
mastering_primaries: Some([
(display_primary_red_x, display_primary_red_y),
(display_primary_green_x, display_primary_green_y),
(display_primary_blue_x, display_primary_blue_y),
]),
white_point: Some((white_point_x, white_point_y)),
};
state.hdr.set_pending(&data.surface, desc);
}
Request::SetPresentMode { .. } | Request::SetPresentTime { .. } | Request::Destroy => {}
}
}
}
// ===========================================================================
// wp_color_manager_v1
// ===========================================================================
impl GlobalDispatch<wp_color_manager_v1::WpColorManagerV1, ()> for NescopeState {
fn bind(
_: &mut Self,
_: &DisplayHandle,
_: &Client,
resource: New<wp_color_manager_v1::WpColorManagerV1>,
_: &(),
data_init: &mut DataInit<'_, Self>,
) {
tracing::debug!("wp_color_manager_v1 bound");
let res = data_init.init(resource, ());
res.supported_intent(wp_color_manager_v1::RenderIntent::Perceptual);
res.supported_feature(wp_color_manager_v1::Feature::Parametric);
res.supported_feature(wp_color_manager_v1::Feature::SetPrimaries);
res.supported_feature(wp_color_manager_v1::Feature::SetMasteringDisplayPrimaries);
res.supported_feature(wp_color_manager_v1::Feature::ExtendedTargetVolume);
res.supported_feature(wp_color_manager_v1::Feature::SetLuminances);
res.supported_feature(wp_color_manager_v1::Feature::WindowsScrgb);
res.supported_tf_named(wp_color_manager_v1::TransferFunction::Srgb);
res.supported_tf_named(wp_color_manager_v1::TransferFunction::Gamma22);
res.supported_tf_named(wp_color_manager_v1::TransferFunction::St2084Pq);
res.supported_primaries_named(wp_color_manager_v1::Primaries::Srgb);
res.supported_primaries_named(wp_color_manager_v1::Primaries::Bt2020);
res.done();
}
}
impl Dispatch<wp_color_manager_v1::WpColorManagerV1, ()> for NescopeState {
fn request(
_: &mut Self,
_: &Client,
_: &wp_color_manager_v1::WpColorManagerV1,
request: wp_color_manager_v1::Request,
_: &(),
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
match request {
wp_color_manager_v1::Request::Destroy => {}
wp_color_manager_v1::Request::GetSurface { id, surface } => {
data_init.init(id, ColorSurfaceData { surface });
}
wp_color_manager_v1::Request::GetOutput { id, .. } => {
data_init.init(id, ColorOutputData);
}
wp_color_manager_v1::Request::GetSurfaceFeedback { id, surface } => {
data_init.init(id, ColorSurfaceFeedbackData { surface });
}
wp_color_manager_v1::Request::CreateParametricCreator { obj } => {
data_init.init(
obj,
CreatorParamsUserData {
params: Mutex::new(CreatorParams::default()),
},
);
}
wp_color_manager_v1::Request::CreateIccCreator { obj } => {
data_init.init(obj, IccCreatorData);
}
wp_color_manager_v1::Request::CreateWindowsScrgb { image_description } => {
// Windows scRGB is declared as BT.2020+PQ by Proton's gamescope WSI
// after converting the surface, so treat it as HDR.
let res = data_init.init(
image_description,
ImageDescriptionUserData {
desc: ImageDescription::bt2020_pq(),
},
);
res.ready(0);
}
_ => {}
}
}
}
// ===========================================================================
// wp_color_management_surface_v1
// ===========================================================================
impl Dispatch<wp_color_management_surface_v1::WpColorManagementSurfaceV1, ColorSurfaceData>
for NescopeState
{
fn request(
state: &mut Self,
_: &Client,
_: &wp_color_management_surface_v1::WpColorManagementSurfaceV1,
request: wp_color_management_surface_v1::Request,
data: &ColorSurfaceData,
_: &DisplayHandle,
_: &mut DataInit<'_, Self>,
) {
match request {
wp_color_management_surface_v1::Request::SetImageDescription {
image_description,
..
} => {
if let Some(d) = image_description.data::<ImageDescriptionUserData>() {
state.hdr.set_pending(&data.surface, d.desc);
}
}
wp_color_management_surface_v1::Request::UnsetImageDescription => {
state.hdr.unset_pending(&data.surface);
}
_ => {}
}
}
}
// ===========================================================================
// wp_image_description_creator_params_v1
// ===========================================================================
impl
Dispatch<
wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1,
CreatorParamsUserData,
> for NescopeState
{
fn request(
_: &mut Self,
_: &Client,
_: &wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1,
request: wp_image_description_creator_params_v1::Request,
data: &CreatorParamsUserData,
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
match request {
wp_image_description_creator_params_v1::Request::Create { image_description } => {
let p = data.params.lock().unwrap();
let desc = ImageDescription {
transfer_function: p.transfer_function.unwrap_or(TransferFunction::Gamma22),
primaries: p.primaries.unwrap_or(Primaries::Srgb),
max_cll: p.max_cll,
max_fall: p.max_fall,
mastering_luminance: p.mastering_luminance,
mastering_primaries: p.mastering_primaries,
white_point: p.white_point,
};
let r = data_init.init(image_description, ImageDescriptionUserData { desc });
r.ready(0);
}
wp_image_description_creator_params_v1::Request::SetTfNamed { tf } => {
let tf = match tf.into_result() {
Ok(wp_color_manager_v1::TransferFunction::St2084Pq) => {
TransferFunction::St2084Pq
}
_ => TransferFunction::Gamma22,
};
data.params.lock().unwrap().transfer_function = Some(tf);
}
wp_image_description_creator_params_v1::Request::SetPrimariesNamed { primaries } => {
let p = match primaries.into_result() {
Ok(wp_color_manager_v1::Primaries::Bt2020) => Primaries::Bt2020,
_ => Primaries::Srgb,
};
data.params.lock().unwrap().primaries = Some(p);
}
wp_image_description_creator_params_v1::Request::SetMaxCll { max_cll } => {
data.params.lock().unwrap().max_cll = Some(max_cll);
}
wp_image_description_creator_params_v1::Request::SetMaxFall { max_fall } => {
data.params.lock().unwrap().max_fall = Some(max_fall);
}
wp_image_description_creator_params_v1::Request::SetMasteringLuminance {
min_lum,
max_lum,
} => {
// max_lum is in cd/m², min_lum is already in 0.0001 cd/m² units.
data.params.lock().unwrap().mastering_luminance =
Some((min_lum, max_lum.saturating_mul(10000)));
}
wp_image_description_creator_params_v1::Request::SetMasteringDisplayPrimaries {
r_x,
r_y,
g_x,
g_y,
b_x,
b_y,
w_x,
w_y,
} => {
// Protocol values are in 1/1,000,000 chromaticity; convert to 0.00002 units.
let to_cta = |v: i32| (v.max(0) as u32) / 20;
let mut p = data.params.lock().unwrap();
p.mastering_primaries = Some([
(to_cta(r_x), to_cta(r_y)),
(to_cta(g_x), to_cta(g_y)),
(to_cta(b_x), to_cta(b_y)),
]);
p.white_point = Some((to_cta(w_x), to_cta(w_y)));
}
_ => {}
}
}
}
// ===========================================================================
// wp_image_description_v1
// ===========================================================================
impl Dispatch<wp_image_description_v1::WpImageDescriptionV1, ImageDescriptionUserData>
for NescopeState
{
fn request(
_: &mut Self,
_: &Client,
_: &wp_image_description_v1::WpImageDescriptionV1,
request: wp_image_description_v1::Request,
data: &ImageDescriptionUserData,
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
if let wp_image_description_v1::Request::GetInformation { information } = request {
let info = data_init.init(information, ImageDescriptionInfoData);
match data.desc.transfer_function {
TransferFunction::St2084Pq => {
info.tf_named(wp_color_manager_v1::TransferFunction::St2084Pq)
}
TransferFunction::Gamma22 => {
info.tf_named(wp_color_manager_v1::TransferFunction::Gamma22)
}
}
match data.desc.primaries {
Primaries::Bt2020 => info.primaries_named(wp_color_manager_v1::Primaries::Bt2020),
Primaries::Srgb => info.primaries_named(wp_color_manager_v1::Primaries::Srgb),
}
info.done();
}
}
}
// ===========================================================================
// Minimal stubs for remaining protocol objects
// ===========================================================================
impl Dispatch<wp_image_description_info_v1::WpImageDescriptionInfoV1, ImageDescriptionInfoData>
for NescopeState
{
fn request(
_: &mut Self,
_: &Client,
_: &wp_image_description_info_v1::WpImageDescriptionInfoV1,
_: wp_image_description_info_v1::Request,
_: &ImageDescriptionInfoData,
_: &DisplayHandle,
_: &mut DataInit<'_, Self>,
) {
}
}
impl Dispatch<wp_color_management_output_v1::WpColorManagementOutputV1, ColorOutputData>
for NescopeState
{
fn request(
state: &mut Self,
_: &Client,
_: &wp_color_management_output_v1::WpColorManagementOutputV1,
request: wp_color_management_output_v1::Request,
_: &ColorOutputData,
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
if let wp_color_management_output_v1::Request::GetImageDescription { image_description } =
request
{
let desc = if state.hdr.enabled {
ImageDescription::bt2020_pq()
} else {
ImageDescription::srgb()
};
let r = data_init.init(image_description, ImageDescriptionUserData { desc });
r.ready(0);
}
}
}
impl
Dispatch<
wp_color_management_surface_feedback_v1::WpColorManagementSurfaceFeedbackV1,
ColorSurfaceFeedbackData,
> for NescopeState
{
fn request(
state: &mut Self,
_: &Client,
_: &wp_color_management_surface_feedback_v1::WpColorManagementSurfaceFeedbackV1,
request: wp_color_management_surface_feedback_v1::Request,
_: &ColorSurfaceFeedbackData,
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
match request {
wp_color_management_surface_feedback_v1::Request::GetPreferred {
image_description,
}
| wp_color_management_surface_feedback_v1::Request::GetPreferredParametric {
image_description,
} => {
let desc = if state.hdr.enabled {
ImageDescription::bt2020_pq()
} else {
ImageDescription::srgb()
};
let r = data_init.init(image_description, ImageDescriptionUserData { desc });
r.ready(0);
}
_ => {}
}
}
}
impl Dispatch<wp_image_description_creator_icc_v1::WpImageDescriptionCreatorIccV1, IccCreatorData>
for NescopeState
{
fn request(
_: &mut Self,
_: &Client,
_: &wp_image_description_creator_icc_v1::WpImageDescriptionCreatorIccV1,
request: wp_image_description_creator_icc_v1::Request,
_: &IccCreatorData,
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
if let wp_image_description_creator_icc_v1::Request::Create { image_description } = request
{
let r = data_init.init(
image_description,
ImageDescriptionUserData {
desc: ImageDescription::srgb(),
},
);
r.failed(
wp_image_description_v1::Cause::Unsupported,
"ICC profiles not supported".into(),
);
}
}
}
impl GlobalDispatch<wp_color_representation_manager_v1::WpColorRepresentationManagerV1, ()>
for NescopeState
{
fn bind(
_: &mut Self,
_: &DisplayHandle,
_: &Client,
resource: New<wp_color_representation_manager_v1::WpColorRepresentationManagerV1>,
_: &(),
data_init: &mut DataInit<'_, Self>,
) {
let r = data_init.init(resource, ());
r.supported_alpha_mode(wp_color_representation_surface_v1::AlphaMode::Straight);
r.supported_alpha_mode(
wp_color_representation_surface_v1::AlphaMode::PremultipliedElectrical,
);
r.supported_coefficients_and_ranges(
wp_color_representation_surface_v1::Coefficients::Identity,
wp_color_representation_surface_v1::Range::Full,
);
r.done();
}
}
impl Dispatch<wp_color_representation_manager_v1::WpColorRepresentationManagerV1, ()>
for NescopeState
{
fn request(
_: &mut Self,
_: &Client,
_: &wp_color_representation_manager_v1::WpColorRepresentationManagerV1,
request: wp_color_representation_manager_v1::Request,
_: &(),
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
if let wp_color_representation_manager_v1::Request::GetSurface { id, .. } = request {
data_init.init(id, ColorRepresentationSurfaceData);
}
}
}
impl
Dispatch<
wp_color_representation_surface_v1::WpColorRepresentationSurfaceV1,
ColorRepresentationSurfaceData,
> for NescopeState
{
fn request(
_: &mut Self,
_: &Client,
_: &wp_color_representation_surface_v1::WpColorRepresentationSurfaceV1,
_: wp_color_representation_surface_v1::Request,
_: &ColorRepresentationSurfaceData,
_: &DisplayHandle,
_: &mut DataInit<'_, Self>,
) {
}
}

394
apps/nescope/src/input.rs Normal file
View File

@@ -0,0 +1,394 @@
//! Programmatic input injection into the Smithay seat.
//!
//! Unlike the proxy version, nescope does **not** receive input from a host
//! compositor. Instead callers (e.g. a streaming server, a test harness, or
//! a control socket handler) send [`InputEvent`]s over a
//! [`calloop::channel::Channel`] that is registered in the event loop.
//!
//! # Usage
//!
//! ```ignore
//! // Obtained when calling NescopeState::new()
//! let input_tx: calloop::channel::Sender<InputEvent> = ...;
//!
//! // From any thread:
//! input_tx.send(InputEvent::KeyDown { keycode: 28 }).unwrap(); // evdev KEY_ENTER
//! ```
//!
//! Keycodes follow the Linux evdev convention (the same as used in
//! Moonshine's `CompositorInputEvent`). The compositor adds 8 to convert
//! them to X11/xkbcommon keycodes before passing them to Smithay.
#![allow(dead_code)]
use smithay::backend::input::{Axis, AxisSource, ButtonState, KeyState};
use smithay::input::keyboard::{FilterResult, Keycode};
use smithay::input::pointer::{AxisFrame, ButtonEvent, MotionEvent, RelativeMotionEvent};
use smithay::utils::{Logical, Point, SERIAL_COUNTER};
use smithay::wayland::pointer_constraints::{PointerConstraint, with_pointer_constraint};
use smithay::wayland::seat::WaylandFocus;
use nesprotocol::input::{self as nestri_input, DecodedInput};
use crate::focus::KeyboardFocusTarget;
use crate::state::NescopeState;
// ── Wire protocol constants (mirrors nestri-protocol/src/input.rs) ──
const WIRE_INPUT_KEY: u8 = 0;
const WIRE_INPUT_MOUSE_MOVE: u8 = 1;
const WIRE_INPUT_MOUSE_BUTTON: u8 = 2;
const WIRE_INPUT_MOUSE_WHEEL: u8 = 3;
const WIRE_KEY_DOWN: u8 = 1;
const WIRE_BTN_LEFT: u32 = 0x110; // BTN_LEFT
const WIRE_BTN_MIDDLE: u32 = 0x112; // BTN_MIDDLE
const WIRE_BTN_RIGHT: u32 = 0x111; // BTN_RIGHT
// ---------------------------------------------------------------------------
// Public event type
// ---------------------------------------------------------------------------
/// Events that can be injected into the compositor seat programmatically.
///
/// All coordinates are in logical (output-space) pixels.
/// Keycodes are Linux evdev keycodes (NOT X11 keycodes).
#[derive(Debug, Clone)]
pub enum InputEvent {
// ── Keyboard ─────────────────────────────────────────────────────────
/// Key pressed. `keycode` is a Linux evdev keycode (8 will be added
/// internally to produce an X11 keycode).
KeyDown { keycode: u32 },
/// Key released.
KeyUp { keycode: u32 },
// ── Pointer — absolute ───────────────────────────────────────────────
/// Absolute pointer position. Coordinates are in the Moonlight client's
/// coordinate space; they are scaled to the compositor output.
MouseMoveAbsolute {
x: f64,
y: f64,
/// Client screen width used for coordinate mapping.
screen_width: f64,
/// Client screen height used for coordinate mapping.
screen_height: f64,
},
// ── Pointer — relative ───────────────────────────────────────────────
/// Relative pointer delta in logical pixels.
MouseMoveRelative { dx: f64, dy: f64 },
// ── Pointer — buttons ────────────────────────────────────────────────
/// Mouse button pressed. `button` is a Linux button code
/// (e.g. `BTN_LEFT = 0x110`).
MouseButtonDown { button: u32 },
/// Mouse button released.
MouseButtonUp { button: u32 },
// ── Pointer — scroll ─────────────────────────────────────────────────
/// Vertical scroll. Positive = up, negative = down.
ScrollVertical { amount: f64 },
/// Horizontal scroll. Positive = right, negative = left.
ScrollHorizontal { amount: f64 },
}
// ---------------------------------------------------------------------------
// Injection
// ---------------------------------------------------------------------------
/// Process a single [`InputEvent`] injected from outside the compositor.
///
/// Called from the calloop idle callback after draining the input channel.
pub fn process_input(event: InputEvent, state: &mut NescopeState) {
let serial = SERIAL_COUNTER.next_serial();
let time = state.clock.now().as_millis();
// Track pointer activity for cursor inactivity timer.
match event {
InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } => {}
_ => state.last_pointer_activity = std::time::Instant::now(),
}
// One-time X11 focus reset when the gamescope WSI surface is active.
if state.override_surface.is_some() && state.x11_focus_needs_reset {
state.sync_x11_focus();
}
match event {
InputEvent::KeyDown { keycode } => {
if let Some(kb) = state.seat.get_keyboard() {
// Auto-focus the topmost window if nothing has focus yet.
if kb.current_focus().is_none() {
state.determine_and_apply_focus();
}
// evdev → X11/xkbcommon keycode: add 8.
kb.input::<(), _>(
state,
Keycode::from(keycode + 8),
KeyState::Pressed,
serial,
time,
|_, _, _| FilterResult::Forward,
);
}
}
InputEvent::KeyUp { keycode } => {
if let Some(kb) = state.seat.get_keyboard() {
if kb.current_focus().is_none() {
state.determine_and_apply_focus();
}
kb.input::<(), _>(
state,
Keycode::from(keycode + 8),
KeyState::Released,
serial,
time,
|_, _, _| FilterResult::Forward,
);
}
}
InputEvent::MouseMoveAbsolute {
x,
y,
screen_width,
screen_height,
} => {
let output_size = state
.output
.current_mode()
.map(|m| m.size)
.unwrap_or((state.width as i32, state.height as i32).into());
let new_x = if screen_width > 0.0 {
x / screen_width * output_size.w as f64
} else {
x
};
let new_y = if screen_height > 0.0 {
y / screen_height * output_size.h as f64
} else {
y
};
state.cursor_position = Point::from((new_x, new_y));
clamp_cursor(state);
let under = surface_under(state);
let pointer = state.seat.get_pointer().unwrap();
pointer.motion(
state,
under,
&MotionEvent {
location: state.cursor_position,
serial,
time,
},
);
pointer.frame(state);
}
InputEvent::MouseMoveRelative { dx, dy } => {
if !state.cursor_initialized {
let size = state
.output
.current_mode()
.map(|m| m.size)
.unwrap_or((state.width as i32, state.height as i32).into());
state.cursor_position = Point::from((size.w as f64 / 2.0, size.h as f64 / 2.0));
state.cursor_initialized = true;
tracing::debug!("cursor initialized to center: {:?}", state.cursor_position);
}
let delta = Point::from((dx, dy));
let pointer = state.seat.get_pointer().unwrap();
// Check for a pointer lock constraint.
let mut locked = false;
let under = surface_under(state);
if let Some((ref target, _)) = under {
if let Some(surf) = target.wl_surface() {
with_pointer_constraint(&surf, &pointer, |c| {
if let Some(c) = c {
if c.is_active() {
if let PointerConstraint::Locked(_) = &*c {
locked = true;
}
}
}
});
}
}
pointer.relative_motion(
state,
under.clone(),
&RelativeMotionEvent {
delta,
delta_unaccel: delta,
utime: time as u64,
},
);
state.cursor_position += delta;
clamp_cursor(state);
if locked {
pointer.frame(state);
return;
}
pointer.motion(
state,
under.clone(),
&MotionEvent {
location: state.cursor_position,
serial,
time,
},
);
pointer.frame(state);
}
InputEvent::MouseButtonDown { button } => {
let pointer = state.seat.get_pointer().unwrap();
pointer.button(
state,
&ButtonEvent {
serial,
time,
button,
state: ButtonState::Pressed,
},
);
pointer.frame(state);
}
InputEvent::MouseButtonUp { button } => {
let pointer = state.seat.get_pointer().unwrap();
pointer.button(
state,
&ButtonEvent {
serial,
time,
button,
state: ButtonState::Released,
},
);
pointer.frame(state);
}
InputEvent::ScrollVertical { amount } => {
let pointer = state.seat.get_pointer().unwrap();
pointer.axis(
state,
AxisFrame::new(time)
.source(AxisSource::Wheel)
.value(Axis::Vertical, -amount),
);
pointer.frame(state);
}
InputEvent::ScrollHorizontal { amount } => {
let pointer = state.seat.get_pointer().unwrap();
pointer.axis(
state,
AxisFrame::new(time)
.source(AxisSource::Wheel)
.value(Axis::Horizontal, amount),
);
pointer.frame(state);
}
}
}
// ---------------------------------------------------------------------------
// Wire-protocol decoder
// ---------------------------------------------------------------------------
/// Decode a single input event from the nestri guest-hub wire protocol
/// and convert it to an [`InputEvent`] for the compositor seat.
///
/// Uses the shared [`nesprotocol::input::decode_input_event`] and maps:
/// - Button 0 → `BTN_LEFT` (0x110), 1 → `BTN_MIDDLE` (0x112), 2 → `BTN_RIGHT` (0x111)
/// - Mouse wheel `dy` → `ScrollVertical`
///
/// Returns `None` if the buffer cannot be decoded.
pub fn decode_wire_event(data: &[u8]) -> Option<InputEvent> {
match nestri_input::decode_input_event(data)? {
DecodedInput::Key { down, keycode } => Some(if down {
InputEvent::KeyDown {
keycode: keycode as u32,
}
} else {
InputEvent::KeyUp {
keycode: keycode as u32,
}
}),
DecodedInput::MouseMove { dx, dy } => Some(InputEvent::MouseMoveRelative {
dx: dx as f64,
dy: dy as f64,
}),
DecodedInput::MouseButton { button, down } => {
let btn = match button {
0 => 0x110, // BTN_LEFT
1 => 0x112, // BTN_MIDDLE
2 => 0x111, // BTN_RIGHT
_ => return None,
};
Some(if down {
InputEvent::MouseButtonDown { button: btn }
} else {
InputEvent::MouseButtonUp { button: btn }
})
}
DecodedInput::MouseWheel { dx: _dx, dy } => {
Some(InputEvent::ScrollVertical { amount: dy as f64 })
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Clamp the cursor to the output bounds.
fn clamp_cursor(state: &mut NescopeState) {
let size = state
.output
.current_mode()
.map(|m| m.size)
.unwrap_or((state.width as i32, state.height as i32).into());
state.cursor_position.x = state.cursor_position.x.clamp(0.0, (size.w - 1) as f64);
state.cursor_position.y = state.cursor_position.y.clamp(0.0, (size.h - 1) as f64);
}
/// Find the focused target under the current cursor position.
pub fn surface_under(state: &NescopeState) -> Option<(KeyboardFocusTarget, Point<f64, Logical>)> {
if state.override_surface.is_some() {
if let Some(wid) = state.focused_x11_window {
for window in state.space.elements() {
if let Some(x11) = window.x11_surface() {
if x11.window_id() == wid {
let loc = state.space.element_geometry(window)?.loc;
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
}
}
}
}
let (window, loc) = state.space.element_under(state.cursor_position)?;
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
}
// Try element_under first
if let Some((window, loc)) = state.space.element_under(state.cursor_position) {
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
}
// Fallback: use the keyboard-focused window if any window is mapped
if let Some(w) = state.space.elements().next() {
if let Some(geo) = state.space.element_geometry(w) {
return Some((KeyboardFocusTarget::Window(w.clone()), geo.loc.to_f64()));
}
}
None
}

View File

@@ -0,0 +1,84 @@
use std::io::{self, Read};
use std::os::unix::io::{AsFd, BorrowedFd};
use std::os::unix::net::UnixStream;
use calloop::{EventSource, Interest, Mode, Poll, PostAction, Readiness, Token, TokenFactory};
pub struct InputIpcSource {
stream: UnixStream,
buf: Vec<u8>,
}
impl InputIpcSource {
pub fn connect(path: &str) -> io::Result<Self> {
let stream = UnixStream::connect(path)?;
stream.set_nonblocking(true)?;
Ok(Self { stream, buf: Vec::new() })
}
pub fn try_clone(&self) -> io::Result<UnixStream> {
self.stream.try_clone()
}
}
impl AsFd for InputIpcSource {
fn as_fd(&self) -> BorrowedFd<'_> {
self.stream.as_fd()
}
}
impl EventSource for InputIpcSource {
type Event = Vec<u8>;
type Metadata = ();
type Ret = ();
type Error = io::Error;
fn process_events<F>(
&mut self,
_readiness: Readiness,
_token: Token,
mut callback: F,
) -> Result<PostAction, Self::Error>
where
F: FnMut(Self::Event, &mut Self::Metadata),
{
let mut tmp = [0u8; 4096];
loop {
match self.stream.read(&mut tmp) {
Ok(0) => {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "IPC socket closed"));
}
Ok(n) => {
self.buf.extend_from_slice(&tmp[..n]);
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(e) => return Err(e),
}
}
while self.buf.len() >= 2 {
let payload_len = u16::from_le_bytes([self.buf[0], self.buf[1]]) as usize;
let frame_total = 2 + payload_len;
if self.buf.len() < frame_total {
break;
}
let payload = self.buf[2..frame_total].to_vec();
self.buf.drain(..frame_total);
callback(payload, &mut ());
}
Ok(PostAction::Continue)
}
fn register(&mut self, poll: &mut Poll, factory: &mut TokenFactory) -> calloop::Result<()> {
unsafe { poll.register(self.as_fd(), Interest::READ, Mode::Level, factory.token()) }
}
fn reregister(&mut self, poll: &mut Poll, factory: &mut TokenFactory) -> calloop::Result<()> {
poll.reregister(self.as_fd(), Interest::READ, Mode::Level, factory.token())
}
fn unregister(&mut self, poll: &mut Poll) -> calloop::Result<()> {
poll.unregister(self.as_fd())
}
}

View File

@@ -0,0 +1,127 @@
//! libinput backend — monitors real and virtual (inputtino) input devices
//! via udev and feeds keyboard/pointer events into the Smithay seat.
use std::os::unix::io::{AsRawFd, OwnedFd};
use std::path::Path;
use smithay::reexports::input::{
self as libinput,
event::{
self,
keyboard::KeyboardEventTrait,
pointer::PointerScrollEvent,
},
};
use crate::input::{InputEvent, process_input};
use crate::state::NescopeState;
// Use the input crate's Axis (not smithay's) for scroll methods
use libinput::event::pointer::Axis as InputAxis;
struct SessionInterface;
impl libinput::LibinputInterface for SessionInterface {
fn open_restricted(&mut self, path: &Path, flags: i32) -> Result<OwnedFd, i32> {
smithay::reexports::rustix::fs::open(
path,
smithay::reexports::rustix::fs::OFlags::from_bits_truncate(flags as u32),
smithay::reexports::rustix::fs::Mode::empty(),
)
.map_err(|e| e.raw_os_error())
}
fn close_restricted(&mut self, fd: OwnedFd) {
drop(fd);
}
}
/// Create a new libinput context with udev monitoring.
pub fn create_libinput() -> Result<libinput::Libinput, Box<dyn std::error::Error>> {
let mut ctx = libinput::Libinput::new_with_udev(SessionInterface);
ctx.udev_assign_seat("seat0")
.map_err(|()| std::io::Error::new(std::io::ErrorKind::Other, "udev_assign_seat failed"))?;
tracing::info!("libinput context created, fd={}", ctx.as_raw_fd());
Ok(ctx)
}
/// Dispatch pending libinput events and inject them into the Smithay seat.
pub fn dispatch_libinput(ctx: &mut libinput::Libinput, state: &mut NescopeState) {
let mut event_count = 0u32;
if let Err(e) = ctx.dispatch() {
tracing::error!("libinput dispatch error: {e}");
return;
}
for event in &mut *ctx {
event_count += 1;
match event {
libinput::Event::Keyboard(kev) => {
let keycode = kev.key();
let ev = match kev.key_state() {
event::keyboard::KeyState::Pressed => InputEvent::KeyDown { keycode },
event::keyboard::KeyState::Released => InputEvent::KeyUp { keycode },
};
process_input(ev, state);
}
libinput::Event::Pointer(pev) => {
match pev {
event::PointerEvent::Motion(ev) => {
process_input(
InputEvent::MouseMoveRelative { dx: ev.dx(), dy: ev.dy() },
state,
);
}
event::PointerEvent::MotionAbsolute(ev) => {
let size = state
.output
.current_mode()
.map(|m| m.size)
.unwrap_or((state.width as i32, state.height as i32).into());
process_input(
InputEvent::MouseMoveAbsolute {
x: ev.absolute_x_transformed(size.w as u32),
y: ev.absolute_y_transformed(size.h as u32),
screen_width: size.w as f64,
screen_height: size.h as f64,
},
state,
);
}
event::PointerEvent::Button(ev) => {
let button = ev.button();
let down = matches!(
ev.button_state(),
event::pointer::ButtonState::Pressed
);
let ev = if down {
InputEvent::MouseButtonDown { button }
} else {
InputEvent::MouseButtonUp { button }
};
process_input(ev, state);
}
event::PointerEvent::ScrollWheel(ev) => handle_scroll(ev, state),
event::PointerEvent::ScrollFinger(ev) => handle_scroll(ev, state),
event::PointerEvent::ScrollContinuous(ev) => handle_scroll(ev, state),
_ => {} // PointerAxis deprecated, covered above
}
state.last_pointer_activity = std::time::Instant::now();
}
_ => {}
}
}
if event_count > 0 {
tracing::trace!("libinput: dispatched {event_count} events");
}
}
fn handle_scroll<SE: PointerScrollEvent>(sev: SE, state: &mut NescopeState) {
if sev.has_axis(InputAxis::Vertical) {
let amount = sev.scroll_value(InputAxis::Vertical);
process_input(InputEvent::ScrollVertical { amount }, state);
}
if sev.has_axis(InputAxis::Horizontal) {
let amount = sev.scroll_value(InputAxis::Horizontal);
process_input(InputEvent::ScrollHorizontal { amount }, state);
}
}

722
apps/nescope/src/main.rs Normal file
View File

@@ -0,0 +1,722 @@
//! 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` | Set to the XWayland display (`:N`) |
//! | `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;
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,
/// 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(),
);
//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 = format!(":{}", args.x_display),
"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 nestri-guest-hub 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).
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
{
//if let Some(xdisplay) = data.state.xdisplay {
data.state.game_launched = true;
tracing::info!("Launching {:?}", command[0]);
let mut cmd = std::process::Command::new(&command[0]);
cmd.args(&command[1..])
//.env("DISPLAY", format!(":{xdisplay}"))
.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)
// Provide also WAYLAND_DISPLAY, so if the game or application
// is Wayland-native and doesn't support older X11 it'll still run.
.env("WAYLAND_DISPLAY", &gamescope_wayland_socket);
if args.hdr {
tracing::debug!(
gamescope_wayland_socket,
"Setting GAMESCOPE_WAYLAND_DISPLAY for application"
);
cmd.env("GAMESCOPE_WAYLAND_DISPLAY", &gamescope_wayland_socket);
cmd.env("ENABLE_GAMESCOPE_WSI", "1");
// DXVK's dxgi.dll gates HDR color space exposure on this env var.
// Without it, both DX11 (DXVK) and DX12 (vkd3d-proton via DXVK dxgi)
// games will not see 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;
}
}
//}
}
// ── 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);
}
}
}
}

View File

@@ -0,0 +1,194 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="gamescope_swapchain">
<copyright>
Copyright © 2023 Joshua Ashton for Valve Software
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<description summary="gamescope-specific swapchain protocol">
This is a private Gamescope protocol. Regular Wayland clients must not use
it.
</description>
<interface name="gamescope_swapchain_factory_v2" version="1">
<request name="destroy" type="destructor"></request>
<request name="create_swapchain">
<description summary="create Gamescope swapchain interface">
</description>
<arg name="surface" type="object" interface="wl_surface"
summary="target surface"/>
<arg name="callback" type="new_id" interface="gamescope_swapchain"
summary="new swapchain object"/>
</request>
</interface>
<interface name="gamescope_swapchain" version="1">
<request name="destroy" type="destructor"></request>
<request name="override_window_content">
<description summary="override an X11's window wl_surface">
Xwayland creates a wl_surface for each X11 window. It sends a
WL_SURFACE_ID client message to indicate the mapping between the X11
windows and the wl_surface objects.
This request overrides this mapping for a given X11 window, allowing an
X11 client to submit buffers via the Wayland protocol. The override
only affects buffer submission. Everything else (e.g. input events)
still uses Xwayland's WL_SURFACE_ID.
x11_server is gotten by the GAMESCOPE_XWAYLAND_SERVER_ID property on the
root window of the associated server.
</description>
<arg name="gamescope_xwayland_server_id" type="uint" summary="gamescope xwayland server ID"/>
<arg name="x11_window" type="uint" summary="X11 window ID"/>
</request>
<request name="swapchain_feedback">
<description summary="provide swapchain feedback">
Provide swapchain feedback to the compositor.
This is what the useless tearing protocol should have been.
Absolutely not enough information in the final protocol to do what we want for SteamOS --
which is have the Allow Tearing toggle apply to *both* Mailbox + Immediate and NOT fifo,
essentially acting as an override for tearing on/off for games.
The upstream protocol is very useless for our usecase here.
Provides image count ahead of time instead of needing to try and calculate it from
an initial stall if we are doing low latency.
Provides colorspace info for us to do HDR for both HDR10 PQ and scRGB.
The upstream HDR efforts seem to have no interest in supporting scRGB but we *need* that so /shrug
We can do it here now! Yipee!
Swapchain feedback solves so many problems! :D
</description>
<arg name="image_count" type="uint" summary="image count of swapchain"/>
<arg name="vk_format" type="uint" summary="VkFormat of swapchain"/>
<arg name="vk_colorspace" type="uint" summary="VkColorSpaceKHR of swapchain"/>
<arg name="vk_composite_alpha" type="uint" summary="VkCompositeAlphaFlagBitsKHR of swapchain"/>
<arg name="vk_pre_transform" type="uint" summary="VkSurfaceTransformFlagBitsKHR of swapchain"/>
<arg name="vk_clipped" type="uint" summary="clipped (VkBool32) of swapchain"/>
<arg name="vk_engine_name" type="string" summary="Engine name"/>
</request>
<request name="set_present_mode">
<description summary="Add a fifo queue constraint"/>
<arg name="vk_present_mode" type="uint" summary="VkPresentModeKHR"/>
</request>
<request name="set_hdr_metadata">
<description summary="set HDR metadata for a surface">
Forward HDR metadata from Vulkan to the compositor.
HDR Metadata Infoframe as per CTA 861.G spec.
This is expected to match exactly with the spec.
display_primary_*:
Color Primaries of the Data.
Specifies X and Y coordinates.
These are coded as unsigned 16-bit values in units of
0.00002, where 0x0000 represents zero and 0xC350
represents 1.0000.
white_point_*:
White Point of Colorspace Data.
Specifies X and Y coordinates.
These are coded as unsigned 16-bit values in units of
0.00002, where 0x0000 represents zero and 0xC350
represents 1.0000.
max_display_mastering_luminance:
Max Mastering Display Luminance.
This value is coded as an unsigned 16-bit value in units of 1 cd/m2,
where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2.
max_display_mastering_luminance:
Min Mastering Display Luminance.
This value is coded as an unsigned 16-bit value in units of
0.0001 cd/m2, where 0x0001 represents 0.0001 cd/m2 and 0xFFFF
represents 6.5535 cd/m2.
max_cll:
Max Content Light Level.
This value is coded as an unsigned 16-bit value in units of 1 cd/m2,
where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2.
max_fall:
Max Frame Average Light Level.
This value is coded as an unsigned 16-bit value in units of 1 cd/m2,
where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2.
</description>
<arg name="display_primary_red_x" type="uint" summary="red primary x coordinate"/>
<arg name="display_primary_red_y" type="uint" summary="red primary y coordinate"/>
<arg name="display_primary_green_x" type="uint" summary="green primary x coordinate"/>
<arg name="display_primary_green_y" type="uint" summary="green primary y coordinate"/>
<arg name="display_primary_blue_x" type="uint" summary="blue primary x coordinate"/>
<arg name="display_primary_blue_y" type="uint" summary="blue primary y coordinate"/>
<arg name="white_point_x" type="uint" summary="white point x coordinate"/>
<arg name="white_point_y" type="uint" summary="white point y coordinate"/>
<arg name="max_display_mastering_luminance" type="uint" summary="max display mastering luminance"/>
<arg name="min_display_mastering_luminance" type="uint" summary="min display mastering luminance"/>
<arg name="max_cll" type="uint" summary="max content light level"/>
<arg name="max_fall" type="uint" summary="max frame average light level"/>
</request>
<request name="set_present_time">
<description summary="display timing of next commit">
Sets the display timing of the next commit.
This gets reset to 0s in the compositor's state after a commit.
</description>
<arg name="present_id" type="uint" summary="application provided presentation id"/>
<arg name="desired_present_time_hi" type="uint" summary="high part of the desired presentation time for this commit. Uses CLOCK_MONOTONIC. 0 = present as soon as possible."/>
<arg name="desired_present_time_lo" type="uint" summary="low part of the desired presentation time for this commit. Uses CLOCK_MONOTONIC. 0 = present as soon as possible."/>
</request>
<event name="past_present_timing">
<description summary="information about past presentation">
Gives information on the past presentation timing
</description>
<arg name="present_id" type="uint" summary="application provided presentation id"/>
<arg name="desired_present_time_hi" type="uint" summary="high part of the desired presentation time for the commit. (from the app)"/>
<arg name="desired_present_time_lo" type="uint" summary="low part of the desired presentation time for the commit. (from the app)"/>
<arg name="actual_present_time_hi" type="uint" summary="high part of the actual present time for this commit."/>
<arg name="actual_present_time_lo" type="uint" summary="low part of the actual present time for this commit."/>
<arg name="earliest_present_time_hi" type="uint" summary="high part of the refresh time that Gamescope thought this commit was done by."/>
<arg name="earliest_present_time_lo" type="uint" summary="low part of the refresh time that Gamescope thought this commit was done by."/>
<arg name="present_margin_hi" type="uint" summary="high part of the difference between earliest present time and the earliest latch time"/>
<arg name="present_margin_lo" type="uint" summary="low part of the difference between earliest present time and the earliest latch time"/>
</event>
<event name="refresh_cycle">
<description summary="information about refresh cycle for this swapchain">
Gives information on the refresh cycle for this swapchain
</description>
<arg name="refresh_cycle_hi" type="uint" summary="high part of the refresh cycle in nanos"/>
<arg name="refresh_cycle_lo" type="uint" summary="low part of the refresh cycle in nanos"/>
</event>
<event name="retired">
<description summary="Swapchain was remotely retired"></description>
</event>
</interface>
</protocol>

View File

@@ -0,0 +1,16 @@
//! Generated Wayland protocol bindings for the gamescope swapchain protocol.
#![allow(non_upper_case_globals, non_camel_case_types, unused)]
use smithay::reexports::wayland_server;
use wayland_server::protocol::*;
pub mod __interfaces {
use super::wayland_server;
use wayland_server::backend as wayland_backend;
use wayland_server::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("src/protocols/gamescope-swapchain.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_server_code!("src/protocols/gamescope-swapchain.xml");

View File

@@ -0,0 +1,264 @@
//! Reading a window's pixels out, for things that are not the game.
//!
//! nescope does not render. It is a headless compositor whose job is to make a
//! game happy so `nescapture` can pull Vulkan frames straight off it — the
//! frames never pass through here at all.
//!
//! That works for games and not for anything else. A Steam client showing a
//! login QR is not a Vulkan application, so nothing captures it, and the person
//! who needs to scan that QR has no way to see it.
//!
//! This is the way out: a client surface's committed buffer, read directly and
//! handed to whoever asked. No rendering, no compositing, no swapchain.
//!
//! # Only shm buffers can be read
//!
//! nescope accepts dmabuf and never imports it — see `DmabufHandler` — because
//! nothing here needs the pixels. So a surface backed by dmabuf cannot be read
//! by this path, and [`Status::Unreadable`] says so rather than returning
//! something wrong.
//!
//! In practice that makes **software rendering a requirement, not a
//! preference**, for anything meant to be captured this way. A Steam client
//! started with its browser GPU-accelerated will hand over dmabuf and be
//! invisible here.
//!
//! # Shape
//!
//! nescope dials out, exactly as [`crate::input_ipc`] does, so whatever
//! supervises it is the listener and there is no race against a socket that
//! does not exist yet. One byte in, one framed image out:
//!
//! ```text
//! -> [u8 request] 0x01 = capture
//! <- [u8 status][u32 LE width][u32 LE height][RGBA…]
//! ```
//!
//! Width and height are zero unless the status is [`Status::Ok`].
use std::io::{self, Read, Write};
use std::os::unix::io::{AsFd, BorrowedFd};
use std::os::unix::net::UnixStream;
pub use crate::screenshot_wire::{Capture, Status};
pub use crate::screenshot_wire::{REQUEST_CAPTURE, encode_reply};
use smithay::desktop::{Space, Window};
use smithay::reexports::wayland_server::protocol::wl_buffer;
use smithay::wayland::seat::WaylandFocus;
use smithay::wayland::compositor::{BufferAssignment, SurfaceAttributes, with_states};
use smithay::wayland::shm::with_buffer_contents;
/// Read the pixels of the frontmost mapped window.
///
/// Deliberately the *frontmost* rather than a composite of everything: nescope
/// does not composite, and inventing a stacking order here would be guessing at
/// something no one has asked for. One window is what a Steam login screen is.
pub fn capture_frontmost(space: &Space<Window>) -> (Status, Option<Capture>) {
// Front to back rather than the frontmost alone. A client often maps a
// small helper window over its real one -- a splash, a tooltip, an
// override-redirect popup -- and reading only the topmost would report
// nothing readable while the window somebody wants is sitting right
// behind it.
let mut saw_window = false;
let mut saw_gpu_buffer = false;
for window in space.elements().rev() {
saw_window = true;
let Some(surface) = window.wl_surface() else {
continue;
};
let buffer = with_states(&surface, |data| {
let mut attrs = data.cached_state.get::<SurfaceAttributes>();
match attrs.current().buffer {
Some(BufferAssignment::NewBuffer(ref buf)) => Some(buf.clone()),
_ => None,
}
});
let Some(buffer) = buffer else {
continue;
};
// `with_buffer_contents` fails for anything that is not shm, which is
// how a GPU-rendered client is told apart from one that has not drawn.
match read_shm(&buffer) {
Ok(Some(capture)) => return (Status::Ok, Some(capture)),
Ok(None) => continue,
// Not shm. Try the GPU: importing the dmabuf and copying it back
// is the only way to see a client that renders on hardware, which
// under XWayland is every client, since smithay spawns it with
// glamor enabled and no way to ask for otherwise.
Err(()) => {
saw_gpu_buffer = true;
match crate::gpu_readback::from_wl_buffer(&buffer) {
Some(capture) => return (Status::Ok, Some(capture)),
// Stepped over rather than given up on: a GPU-backed
// splash often sits in front of a readable window.
None => continue,
}
}
}
}
// Ordered by which answer is most actionable. A readable window anywhere
// wins; failing that, `Unreadable` now means the GPU path was tried and
// could not do it either -- a real failure rather than a limitation --
// and it outranks "nothing has drawn" because it will not resolve by
// waiting.
match (saw_gpu_buffer, saw_window) {
(true, _) => (Status::Unreadable, None),
(false, true) => (Status::NoBuffer, None),
(false, false) => (Status::NoSurface, None),
}
}
/// Copy an shm buffer out as RGBA. `Err` means it is not shm at all.
fn read_shm(buffer: &wl_buffer::WlBuffer) -> Result<Option<Capture>, ()> {
with_buffer_contents(buffer, |ptr, _len, data| {
let width = data.width.max(0) as usize;
let height = data.height.max(0) as usize;
let stride = data.stride.max(0) as usize;
if width == 0 || height == 0 {
return None;
}
let is_xrgb =
data.format == smithay::reexports::wayland_server::protocol::wl_shm::Format::Xrgb8888;
let mut rgba = vec![0u8; width * height * 4];
for row in 0..height {
// SAFETY: the compositor guarantees the pool covers
// offset + stride * height, and each row copy stays inside it.
unsafe {
std::ptr::copy_nonoverlapping(
ptr.offset(data.offset as isize).add(row * stride),
rgba.as_mut_ptr().add(row * width * 4),
width * 4,
);
}
}
if is_xrgb {
// The alpha byte is undefined in XRGB, and decoding a QR from a
// fully transparent image finds nothing.
for px in rgba.chunks_exact_mut(4) {
px[3] = 255;
}
}
Some(Capture {
width: width as u32,
height: height as u32,
rgba,
})
})
.map_err(|_| ())
}
/// Write one reply to an already-cloned socket half.
pub fn write_reply_to(
stream: &mut UnixStream,
status: Status,
capture: Option<&Capture>,
) -> io::Result<()> {
let bytes = encode_reply(status, capture);
// Blocking for the write: a capture is megabytes and the reader is waiting
// for it, so a partial non-blocking write would have to be buffered and
// re-driven for no benefit.
stream.set_nonblocking(false)?;
let result = stream.write_all(&bytes);
let _ = stream.set_nonblocking(true);
result
}
/// The socket nescope reads requests from and writes images to.
pub struct ScreenshotIpcSource {
stream: UnixStream,
}
impl ScreenshotIpcSource {
pub fn connect(path: &str) -> io::Result<Self> {
let stream = UnixStream::connect(path)?;
stream.set_nonblocking(true)?;
Ok(Self { stream })
}
/// A writable handle to the same socket.
///
/// The source itself is moved into the event loop, so replies go out
/// through a clone — the same split `input_ipc` uses for its write side.
pub fn try_clone_writer(&self) -> io::Result<UnixStream> {
self.stream.try_clone()
}
}
impl AsFd for ScreenshotIpcSource {
fn as_fd(&self) -> BorrowedFd<'_> {
self.stream.as_fd()
}
}
impl calloop::EventSource for ScreenshotIpcSource {
/// One request byte per event.
type Event = u8;
type Metadata = ();
type Ret = ();
type Error = io::Error;
fn process_events<F>(
&mut self,
_readiness: calloop::Readiness,
_token: calloop::Token,
mut callback: F,
) -> Result<calloop::PostAction, Self::Error>
where
F: FnMut(Self::Event, &mut Self::Metadata),
{
let mut tmp = [0u8; 64];
loop {
match self.stream.read(&mut tmp) {
Ok(0) => return Ok(calloop::PostAction::Remove),
Ok(n) => {
for byte in &tmp[..n] {
callback(*byte, &mut ());
}
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(calloop::PostAction::Continue)
}
fn register(
&mut self,
poll: &mut calloop::Poll,
factory: &mut calloop::TokenFactory,
) -> calloop::Result<()> {
// SAFETY: the fd stays owned by this source for as long as it is
// registered, and is unregistered before the stream is dropped —
// the same contract `input_ipc` relies on.
unsafe {
poll.register(
self.stream.as_fd(),
calloop::Interest::READ,
calloop::Mode::Level,
factory.token(),
)
}
}
fn reregister(
&mut self,
poll: &mut calloop::Poll,
factory: &mut calloop::TokenFactory,
) -> calloop::Result<()> {
poll.reregister(
self.stream.as_fd(),
calloop::Interest::READ,
calloop::Mode::Level,
factory.token(),
)
}
fn unregister(&mut self, poll: &mut calloop::Poll) -> calloop::Result<()> {
poll.unregister(self.stream.as_fd())
}
}

View File

@@ -0,0 +1,104 @@
//! The screenshot wire format, shared by the compositor and `nescope-shot`.
//!
//! Split from the compositor half so the tool can speak the protocol without
//! linking a Wayland compositor and an EGL renderer to do it — and so the two
//! cannot drift apart on the format, which duplicating it would invite.
//!
//! ```text
//! -> [u8 request] 0x01 = capture
//! <- [u8 status][u32 LE width][u32 LE height][RGBA…]
//! ```
//!
//! Width and height are zero unless the status is [`Status::Ok`].
/// Ask for a picture of what is on screen.
pub const REQUEST_CAPTURE: u8 = 0x01;
/// How a capture turned out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum Status {
Ok = 0,
/// No window is mapped at all.
NoSurface = 1,
/// A surface exists but could not be read by any route — not as shm, and
/// not by importing it from the GPU either.
Unreadable = 2,
/// Windows are mapped, but none has drawn anything readable yet.
///
/// Distinct from [`Status::NoSurface`] on purpose: "nothing is running" and
/// "something is running but has not drawn" send you to different places,
/// and one status for both means watching a client start up tells you
/// nothing about which is happening.
NoBuffer = 3,
}
/// A captured image, in RGBA8, top row first.
pub struct Capture {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// Serialise a reply. Split out from the socket so it can be asserted without
/// one.
pub fn encode_reply(status: Status, capture: Option<&Capture>) -> Vec<u8> {
let mut out = Vec::new();
out.push(status as u8);
match capture {
Some(c) if status == Status::Ok => {
out.extend_from_slice(&c.width.to_le_bytes());
out.extend_from_slice(&c.height.to_le_bytes());
out.extend_from_slice(&c.rgba);
}
// A non-Ok status carries no pixels, and says so with zeroed
// dimensions rather than by the reader having to know.
_ => out.extend_from_slice(&[0u8; 8]),
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reply_carries_its_dimensions_before_its_pixels() {
let capture = Capture {
width: 2,
height: 1,
rgba: vec![1, 2, 3, 4, 5, 6, 7, 8],
};
let bytes = encode_reply(Status::Ok, Some(&capture));
assert_eq!(bytes[0], Status::Ok as u8);
assert_eq!(u32::from_le_bytes(bytes[1..5].try_into().unwrap()), 2);
assert_eq!(u32::from_le_bytes(bytes[5..9].try_into().unwrap()), 1);
assert_eq!(&bytes[9..], &capture.rgba[..]);
// The reader sizes its buffer from the header, so this has to be exact.
assert_eq!(bytes.len(), 9 + (2 * 1 * 4));
}
#[test]
fn a_failure_carries_no_pixels_and_zero_dimensions() {
// A reader that trusted a non-zero size on a failed capture would wait
// for bytes that are never sent.
for status in [Status::NoSurface, Status::Unreadable, Status::NoBuffer] {
let bytes = encode_reply(status, None);
assert_eq!(bytes.len(), 9, "{status:?}");
assert_eq!(bytes[0], status as u8);
assert!(bytes[1..9].iter().all(|b| *b == 0), "{status:?}");
}
}
#[test]
fn pixels_are_dropped_when_the_status_is_not_ok() {
// Guards against a caller passing both a failure and a stale capture:
// the status is what the reader believes, so the two must agree.
let capture = Capture {
width: 4,
height: 4,
rgba: vec![0xff; 64],
};
assert_eq!(encode_reply(Status::Unreadable, Some(&capture)).len(), 9);
}
}

1013
apps/nescope/src/state.rs Normal file

File diff suppressed because it is too large Load Diff

7
apps/nescope/src/xwm.rs Normal file
View File

@@ -0,0 +1,7 @@
//! X11 window manager helpers.
//!
//! The `XwmHandler` implementation lives in `handlers.rs` on `CalloopData`
//! because the `X11Wm` calloop event source dispatches through it.
//!
//! This file is reserved for future X11 atom helpers, window-policy tweaks,
//! or gamescope X11 atom extensions that outgrow the handlers module.