Files
netris-nestri/apps/nescope/src/gpu_readback.rs
Wanjohi 37dd985810 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>
2026-08-26 18:01:41 +03:00

183 lines
7.9 KiB
Rust

//! 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
}
}
}