Files
netris-nestri/apps/nescope
KAAL1 (Bingus) e70f05e245 fix(nescope): make HDR reachable — start XWayland, advertise the opaque FourCCs (#314)
Three related fixes. Together they take HDR from unreachable to working
end to end on the XWayland path.

## XWayland was never started

Three lines had been commented out since the initial import: the call
that spawns XWayland, the guard that waits for it, and the `DISPLAY`
handed to the child. Every game therefore launched as a native Wayland
client. Nothing reported it -- the compositor still logged the X display
it was telling clients to point at, which is why it read as working.

That is also why HDR never fired. The colour space is signalled over a
protocol whose Vulkan layer lives in the game process and finds the
compositor through the X11 root window, so the one path able to carry it
was the one path no game was on. `ENABLE_GAMESCOPE_WSI` and `DXVK_HDR`
were already being set, which switched that layer on and then handed it
a
display it could not use.

Restoring the guard also fixes the ordering it was written for: the
launch
now happens after XWayland reports ready rather than ~40ms before it.

## Mesa was dropping every format we advertised alpha-only

Mesa tracks two flags per VkFormat -- one contributed by a format alpha
FourCC, one by its opaque FourCC -- and skips any format carrying only
one:

```c
if (!(disp_fmt->flags & WSI_WL_FMT_ALPHA) ||
   !(disp_fmt->flags & WSI_WL_FMT_OPAQUE))
   continue;
```

We advertised `ARGB8888` and `XRGB8888`, so `B8G8R8A8` survived and made
the list look like it was working. Everything else was alpha-only and
was
dropped in silence -- `ABGR8888` had been advertised all along while
`R8G8B8A8` never once appeared on a surface. Adding the opaque spellings
takes the surface from 6 formats to 21 and restores the three that carry
HDR.

The comment above that list claimed it was for XWayland DRI3 and that a
game swapchain format was independent of it. It was the opposite: the
list decides what a game can select, and deleting an entry removes that
format from every client.

## Verified against swapchains, not format lists

A client asking for `A2B10G10R10` + `HDR10_ST2084` now gets a swapchain
and the compositor is told colorspace `1000104008`; one asking for
`R16G16B16A16_SFLOAT` + scRGB linear gets `1000104002`. Previously both
were refused at creation -- the WSI layer re-checks the requested format
against the driver own surface list, so the colour space and the pixel
format arrive from two different places and only one was being supplied.

`apps/nescope/scripts/verify-hdr-formats.sh` asks what a client is
offered
from inside a child process, keeping the XCB and Wayland surfaces apart
since a game presents through the XCB one. The default mode guards both
halves of what the compositor controls; `--expect-layer` states the full
target and passes once a WSI layer is present. No new dependencies
(`vulkaninfo` + `python3`).

## Still open

HDR is XWayland-only, documented as a FIXME in `hdr.rs`. A WSI layer
binds
the swapchain factory on its own Wayland connection while a native
client
surface lives on the client one, and object IDs do not cross
connections.
The FIXME records the fix both reference implementations point at, and
the
trap to avoid when we take it: gating format injection on "the
compositor
supports HDR" rather than on being able to signal the surface hands a
client PQ pixels that arrive tagged as SDR, with nothing reporting an
error.

nescope ships no WSI layer of its own; the above was verified with the
stock gamescope one, which drives our protocol unmodified.















<!-- greptile_comment -->

<h3>Greptile Summary</h3>

This PR makes HDR-capable native Wayland presentation reachable, adds
the alpha/opaque DMA-BUF FourCC pairs Mesa requires, makes XWayland an
explicit compatibility mode, and adds an HDR surface-format diagnostic.
- Starts XWayland only with `--xwayland`, waits for readiness before
launching the child, and stops cleanly if startup fails or times out.
- Routes Proton through Wayland unconditionally while retaining
`DXVK_HDR` as an HDR-specific setting.
- Advertises paired alpha and opaque FourCC variants with portable
modifiers.
- Separates XCB and Wayland probe results, selects one hardware adapter,
and distinguishes probe failures from format regressions.
- Documents the limitations of the legacy gamescope WSI path and the
external-layer dependency.

<h3>Confidence Score: 5/5</h3>

The PR appears safe to merge; no outstanding correctness, security, or
repository-rule issue remains.

All previous findings are resolved in the current code, including the
XWayland failure lifecycle, removal of unsupported vendor modifiers,
corrected HDR documentation, reliable diagnostic failure handling,
per-GPU format selection, and unconditional Proton Wayland routing. The
changes since the previous review preserve diagnostic output handling
without introducing a new failure.

<h3>Important Files Changed</h3>




| Filename | Overview |
|----------|----------|
| apps/nescope/src/main.rs | Adds opt-in XWayland lifecycle handling,
readiness timeout, conditional DISPLAY propagation, and unconditional
Proton Wayland routing; the previous launch-environment finding is
fixed. |
| apps/nescope/src/state.rs | Stops the event loop on reported XWayland
startup failure and advertises portable paired alpha/opaque DMA-BUF
formats without vendor-specific modifiers. |
| apps/nescope/src/hdr.rs | Documents the working native Wayland HDR
path and accurately distinguishes it from the external, deliberately
disabled gamescope WSI route. |
| apps/nescope/scripts/verify-hdr-formats.sh | Adds a diagnostic that
keeps GPU and surface paths separate and now preserves the intended exit
behavior when filtered Vulkan diagnostics contain no matching lines. |


<h3>Flowchart</h3>

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Launch[nescope child launch] --> Mode{--xwayland?}
  Mode -->|No| Wayland[Native Wayland surface]
  Mode -->|Yes| Wait[Start and await XWayland]
  Wait -->|Ready| XCB[XCB / XWayland surface]
  Wait -->|Error or 10s timeout| Stop[Log failure and stop]
  Wayland --> Formats[Paired alpha and opaque FourCCs]
  Formats --> HDR[HDR10 and scRGB formats available]
  XCB --> SDR[X11 compatibility path without native HDR]
  Proton[Proton child] -->|PROTON_ENABLE_WAYLAND=1| Wayland
```

<sub>Reviews (9): Last reviewed commit: ["nescope/scripts: guard the
diagnostic
pi..."](f8bdd68f87)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60368324)</sub>

**Context used:**

- Knowledge Base — [Compositor, display, and input
control](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/compositor-input.md)
- Knowledge Base — [Streaming host
runtime](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/streaming-runtime.md)

<!-- /greptile_comment -->
2026-09-04 19:18:46 +03:00
..
2026-08-26 18:01:41 +03:00
2026-08-26 18:01:41 +03:00
2026-08-26 18:01:41 +03:00

nescope

A lightweight headless Wayland compositor designed for game capture scenarios. nescope creates a complete compositor environment for games while delegating frame capture to an external Vulkan interception layer.

Overview

nescope is built on top of Smithay, the Rust Wayland compositor framework. Unlike traditional compositors that manage window rendering and buffer scanout, nescope operates in headless mode—it provides the Wayland/X11 environment games expect but never allocates GBM buffers or performs rendering.

┌─────────────────┐
│   nescope       │  ← Headless Wayland compositor
├─────────────────┤
│ Virtual Output  │  ← 1920x1080 @ 60Hz (configurable)
│ XWayland        │  ← X11 compatibility layer
│ DMA-BUF (v4)    │  ← Required for XWayland DRI3
│ wp_color_mgmt   │  ← HDR signaling
│ gamescope WSI   │  ← Vulkan bypass protocol
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│   Game          │  ← Render target
└────────┬────────┘
         │ frame capture
         ▼
┌─────────────────┐
│   nescapture    │  ← Vulkan layer, inside the game's own process
└─────────────────┘

Architecture

Core Components

Module Purpose
main.rs CLI entry point, event loop, process management
state.rs NescopeState — central compositor state, Wayland globals
handlers.rs Smithay protocol handlers (CompositorHandler, XdgShellHandler)
xwm.rs X11 window management
focus.rs Keyboard focus routing for X11 windows
input.rs Input decoding and injection via calloop::channel
input_ipc.rs The client side of neshub's input socket
libinput_backend.rs Real input devices, when there are any
hdr.rs HDR / colour management protocol handlers
gpu_readback.rs Reading a dmabuf back to the CPU, for screenshots
screenshot_ipc.rs Serving captures to whoever is listening
screenshot_wire.rs The screenshot frame format
protocols/ Generated gamescope swapchain bindings
bin/nescope-shot.rs Ask a running nescope what is on screen

Design Decisions

  1. Headless Operation: nescope never allocates GBM buffers. The DMA-BUF global exists solely so XWayland can initialize DRI3/GBM/glamor.

  2. Frame Callbacks: Driven by an internal calloop timer at the configured FPS, not by a real scanout. This keeps the game's render loop alive.

  3. Input via Channel: Input events arrive through a calloop::channel::Sender<InputEvent> rather than being decoded from a proxy connection. This allows external code (streaming servers, test harnesses) to inject input.

  4. Process Subreaper: nescope registers as a subreaper (PR_SET_CHILD_SUBREAPER) so orphaned descendants — a launcher that execs the real client and exits, say — are reparented to it rather than to PID 1, and can be cleaned up reliably.

  5. Two shapes, one binary: given a command after --, nescope launches it and exits when it and its windows are gone. Given none, it comes up as a plain compositor and waits for something to connect. The second is what a real session needs, where the processes that draw are started by something else entirely.

Building

cargo build --release

Usage

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
  --socket <NAME>          Wayland socket name [default: nescope-0]
  --input-ipc <PATH>       neshub's input socket [default: /tmp/nestri-input.sock]
  --screenshot-ipc <PATH>  Serve screenshots here [default: off]
  --render-device <PATH>   GPU to pin the workload to, e.g. /dev/dri/renderD128
  --x-display <N>          XWayland display number [default: 1]

The command is optional; see design decision 5 above.

Environment Variables

Variable Effect
DISPLAY XWayland display (:N)
NESCOPE_WIDTH Override --width
NESCOPE_HEIGHT Override --height
NESCOPE_FPS Override --fps
NESCOPE_HDR Enable HDR
NESCOPE_SOCKET Override socket name
NESCOPE_INPUT_IPC Override --input-ipc
NESCOPE_SCREENSHOT_IPC Override --screenshot-ipc
NESCOPE_RENDER_DEVICE Override --render-device
NESCOPE_X_DISPLAY Override --x-display
RUST_LOG Tracing filter (e.g., nescope=debug)

Example

# Wrap one program at 1440p with HDR
nescope --width 2560 --height 1440 --hdr -- %command%

# Plain compositor mode: come up and wait for clients
nescope --x-display 1

# Debug logging
RUST_LOG=nescope=debug nescope -- %command%

Seeing what is on screen

nescope is headless, so a black stream, a window that never mapped and a client rendering fine all look identical from outside. nescope-shot is the listener side of the screenshot socket:

# start this first — nescope dials out
nescope-shot --socket /tmp/nestri-screenshot.sock --watch --out shot.ppm
nescope --screenshot-ipc /tmp/nestri-screenshot.sock -- <program>

HDR Support

nescope supports two HDR signaling paths:

  1. wp_color_management_v1 — Standard Wayland staging protocol used by Wine/Proton/SDL2 when requesting HDR via Vulkan color-space extensions (disabled by default).

  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.

When --hdr is enabled, nescope:

  • Advertises both protocol globals
  • Sets ENABLE_GAMESCOPE_WSI=1 and GAMESCOPE_WAYLAND_DISPLAY=$(socket_path_here) on the X11 root window
  • Tracks per-surface color space for external capture layer retrieval

Shutdown Behavior

  • First SIGINT/SIGTERM: Sets an atomic flag; the event loop exits cleanly, killing all child process groups.
  • Second signal: Falls through to OS default handler (hard kill).

nescope waits 5 seconds after the last mapped window disappears before exiting, allowing save-game/cleanup processes to complete.

Dependencies

  • smithay — Wayland compositor framework (wayland_frontend, xwayland, backend_drm, desktop, renderer_pixman)
  • wayland-client — Connects to host compositor (if needed)
  • calloop — Event loop
  • tracing — Structured logging
  • clap — CLI parsing
  • x11rb — X11 atom management

What nescope does not do

Frames reach the client through nescapture, which captures inside the workload's process, and neshub, which muxes and sends. Input arrives the same way in reverse. nescope provides the display those components need and injects what they hand it; it never touches the network.

Licence

Apache 2.0. See LICENSE.