diff --git a/apps/nescope/scripts/verify-hdr-formats.sh b/apps/nescope/scripts/verify-hdr-formats.sh new file mode 100755 index 00000000..d3dbfaab --- /dev/null +++ b/apps/nescope/scripts/verify-hdr-formats.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# Report the surface formats a client actually sees under nescope. +# +# HDR reaches a game as a (VkFormat, VkColorSpaceKHR) pair on its swapchain +# surface. Everything else -- the colour-management protocol, the dmabuf format +# list, the encoder's matrix and transfer tags -- is downstream of whether that +# pair was ever offered. So this asks the one question directly, from inside a +# real client process, using the loader's own enumeration rather than ours. +# +# It deliberately does not check pixels. verify-hdr.sh in nescapture does that, +# and the two are answering different questions: this one is "was HDR on the +# menu", that one is "did the samples come out where the standard says". A pass +# here and a fail there means we converted wrongly; a fail here means the game +# never had the option and anything downstream is moot. +# +# The two surfaces a client can be on need separate answers, so there are two +# modes: +# +# (default) The Wayland surface, which is where HDR actually comes +# from: Mesa pairs the colour spaces it learns from +# wp_color_manager_v1 with the pixel formats it derives from +# the dmabuf list. Guards both halves. Each is silently +# absent when broken, and the colour space alone is what made +# an 8-bit surface look like working HDR. +# +# --expect-layer The XCB surface, for diagnosing the legacy route only. +# Mesa offers no HDR colour space on XWayland at all, so the +# HDR pairs can only come from a WSI layer inside the game's +# process -- gamescope's approach, which predates Wayland +# colour management. nescope does not ship such a layer and +# does not enable one, because capture cannot see the colour +# space it hides and would tag PQ samples as BT.709. This +# mode passes only with a layer loaded, e.g. +# +# VK_ADD_IMPLICIT_LAYER_PATH= \ +# apps/nescope/scripts/verify-hdr-formats.sh --expect-layer +# +# A failure here is the expected state, not a regression. +# +# Exit codes are distinct on purpose: 0 pass, 1 the formats are wrong, 2 the +# environment or the probe failed and this run measured nothing. +# +# Usage: apps/nescope/scripts/verify-hdr-formats.sh [--expect-layer] +set -euo pipefail + +MODE="baseline" +NESCOPE_ARGS=() +if [ "${1:-}" = "--expect-layer" ]; then + MODE="layer" + # nescope no longer sets this, on purpose, so the legacy layer stays inert in + # normal use. This mode is the one place that wants it, so it opts in here + # rather than relying on the compositor to leave a switch flipped. + export ENABLE_GAMESCOPE_WSI=1 + # And the route only exists on the XCB surface, which needs a server. + NESCOPE_ARGS=(--xwayland) +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +: "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}" +export XDG_RUNTIME_DIR + +for tool in vulkaninfo python3; do + command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2; exit 1; } +done + +echo "building…" +cargo build --release -p nescope --manifest-path "$ROOT/Cargo.toml" >/dev/null + +# The probe runs as nescope's child, so it sees exactly the environment a game +# would. Which display variables it inherits is itself a result -- a child with +# no DISPLAY is a native Wayland client, and that is the path with no HDR -- so +# record them before enumerating anything. +cat > "$WORK/probe.sh" <<'PROBE' +#!/usr/bin/env bash +echo "child_wayland_display=${WAYLAND_DISPLAY:-unset}" +echo "child_display=${DISPLAY:-unset}" +echo "child_enable_gamescope_wsi=${ENABLE_GAMESCOPE_WSI:-unset}" +echo "---VULKANINFO---" +# Keep stderr. A Vulkan init failure and a missing HDR format both end up as +# "no formats" otherwise, and only one of them is this script's business. +if vulkaninfo 2>&1; then + echo "probe_vulkaninfo=ok" +else + echo "probe_vulkaninfo=failed rc=$?" +fi +PROBE +chmod +x "$WORK/probe.sh" + +echo "enumerating surface formats under nescope…" +NESCOPE_RC=0 +timeout 60 "$ROOT/target/release/nescope" --hdr "${NESCOPE_ARGS[@]}" \ + --width 1280 --height 720 \ + -- "$WORK/probe.sh" > "$WORK/probe.out" 2>"$WORK/nescope.log" || NESCOPE_RC=$? + +# A compositor that died, or a probe that never got a Vulkan instance, is an +# environment failure. Reporting it as missing HDR formats would be the exact +# confusion this script exists to prevent, so say which one it was and show the +# log rather than deleting it with the temp dir. +if [ "$NESCOPE_RC" -eq 124 ]; then + echo + echo "FAIL — nescope did not exit within 60s; the run was killed." >&2 + echo "This is an environment failure, not an HDR result. Last log lines:" >&2 + tail -20 "$WORK/nescope.log" >&2 + exit 2 +fi + +if grep -q "^probe_vulkaninfo=failed" "$WORK/probe.out"; then + echo + echo "FAIL — vulkaninfo failed inside the compositor." >&2 + echo "This is an environment failure, not an HDR result:" >&2 + sed -n "/---VULKANINFO---/,/probe_vulkaninfo=failed/p" "$WORK/probe.out" \ + | grep -iE "error|cannot|failed|no such" | head -10 >&2 || true + exit 2 +fi + +if ! grep -q "^probe_vulkaninfo=ok" "$WORK/probe.out"; then + echo + echo "FAIL — the probe did not run to completion (nescope rc=$NESCOPE_RC)." >&2 + echo "This is an environment failure, not an HDR result. Last log lines:" >&2 + tail -20 "$WORK/nescope.log" >&2 + exit 2 +fi + +python3 - "$WORK/probe.out" "$MODE" <<'PY' +import re, sys + +path, mode = sys.argv[1], sys.argv[2] +text = open(path, errors="replace").read() + +env = dict(re.findall(r"^(child_\w+)=(.*)$", text, re.M)) +print() +print(f"child WAYLAND_DISPLAY: {env.get('child_wayland_display', '?')}") +print(f"child DISPLAY: {env.get('child_display', '?')}") +print(f"ENABLE_GAMESCOPE_WSI: {env.get('child_enable_gamescope_wsi', '?')}") + +# Pull the format lists from the presentable-surface section of the first real +# GPU. llvmpipe is enumerated too and would double every count, so skip any +# adapter that names it -- a software rasteriser's opinion about HDR is not the +# thing under test. +# +# Keep the XCB and Wayland surfaces apart. Once the child has a DISPLAY it has +# both, they carry different formats, and merging them would let one path's HDR +# support stand in for the other's. The XCB list is the one a Proton game sees. +# One adapter only, and named in the output. Appending every adapter's formats +# into one list would let a second GPU satisfy the checks while the one the game +# runs on lacks the formats entirely -- a pass that means nothing. There is no +# way to ask vulkaninfo which adapter a game would pick, so this takes the first +# hardware one and says which, leaving a multi-GPU host to be read rather than +# guessed at. +section = text.split("Presentable Surfaces", 1) +by_path = {"xcb": [], "wayland": []} +gpu, path, chosen_gpu, other_gpus = None, None, None, [] +if len(section) > 1: + block, cur_fmt = section[1], None + for line in block.splitlines(): + m = re.match(r"\s*GPU id\s*:\s*\d+\s*\((.+?)\)\s*\[(.+?)\]", line) + if m: + gpu, exts = m.group(1), m.group(2) + path = "wayland" if "wayland_surface" in exts else "xcb" + if "llvmpipe" not in gpu: + if chosen_gpu is None: + chosen_gpu = gpu + elif gpu != chosen_gpu and gpu not in other_gpus: + other_gpus.append(gpu) + continue + # Skip the software rasteriser, and every hardware adapter after the + # first: their formats are not the ones under test. + if gpu is None or "llvmpipe" in gpu or gpu != chosen_gpu: + continue + m = re.match(r"\s*format\s*=\s*(\S+)", line) + if m: + cur_fmt = m.group(1) + continue + m = re.match(r"\s*colorSpace\s*=\s*(\S+)", line) + if m and cur_fmt and path: + # vulkaninfo pads its listing with FORMAT_UNDEFINED entries when a + # layer appends to the surface format list. Checked against the API + # directly with the two-call pattern -- the count and the entries + # agree there, and VK_INCOMPLETE comes back on a short buffer -- so + # these are an artifact of the listing, not formats a client sees. + if cur_fmt != "FORMAT_UNDEFINED": + by_path[path].append((cur_fmt, m.group(1))) + cur_fmt = None + +# A game under XWayland presents through the XCB surface, so that is the list +# under test whenever it exists. Fall back to the Wayland one when the child +# never got a DISPLAY, which is the only case where it is what a game would use. +on_xwayland = bool(by_path["xcb"]) +formats = by_path["xcb"] if on_xwayland else by_path["wayland"] + +fails = [] +if not formats: + fails.append("no surface formats enumerated at all — the probe never reached " + "a surface, so this run measured nothing") + +print(f"\nadapter under test: {chosen_gpu or '(none found)'}") +if other_gpus: + print(f" ignoring {len(other_gpus)} other adapter(s): {', '.join(other_gpus)}") + print(" a game may not pick the one above; check it is the render device") +print(f"path under test: {'XCB (XWayland)' if on_xwayland else 'native Wayland'}") +if on_xwayland: + print(f" (native Wayland surface offers {len(by_path['wayland'])} formats, not under test)") +print(f"\nsurface formats offered: {len(formats)}") +for f, cs in formats: + print(f" {f:<34} {cs}") + +spaces = {cs for _, cs in formats} +depth10 = [f for f, _ in formats if "10" in f and "B8G8R8A8" not in f] +depth16 = [f for f, _ in formats if "16G16" in f or "SFLOAT" in f] + +print() +print(f"HDR10_ST2084 offered: {'yes' if any('ST2084' in s for s in spaces) else 'no'}") +print(f"scRGB linear offered: {'yes' if any('EXTENDED_SRGB_LINEAR' in s for s in spaces) else 'no'}") +print(f"10-bit formats offered: {'yes' if depth10 else 'no'}") +print(f"FP16 formats offered: {'yes' if depth16 else 'no'}") + +if formats: + if mode == "baseline": + # Only what is genuinely working today. The point of this mode is to + # notice if the colour-management path stops being wired up at all, + # which would otherwise look identical to plain SDR. + # Guard both halves of what the compositor itself controls: the colour + # spaces, which come from the colour-management protocol, and the pixel + # formats, which come from the dmabuf list. Each fails silently on its + # own -- a missing format is simply absent, with nothing logged. + wl = by_path["wayland"] + wl_spaces = {cs for _, cs in wl} + if not any("ST2084" in s for s in wl_spaces): + fails.append("HDR10_ST2084_EXT is no longer offered on the Wayland " + "surface — the wp_color_manager_v1 path has stopped " + "being advertised") + if not [f for f, _ in wl if "10" in f and "B8G8R8A8" not in f]: + fails.append("no 10-bit pixel format on the Wayland surface — Mesa " + "drops any format lacking either its alpha or its " + "opaque FourCC spelling, so check both are advertised") + if not [f for f, _ in wl if "16G16" in f]: + fails.append("no FP16 pixel format on the Wayland surface — the " + "scRGB path needs R16G16B16A16_SFLOAT") + else: + # The three a WSI layer injects. Until one exists these all fail, and + # that is the expected reading, not a defect in this script. + if not any("ST2084" in s for s in spaces): + fails.append("HDR10_ST2084_EXT not offered") + if not depth10: + fails.append("no 10-bit format offered — PQ over 8-bit BGRA bands; a " + "WSI layer must inject A2B10G10R10/A2R10G10B10") + if not any("EXTENDED_SRGB_LINEAR" in s for s in spaces): + fails.append("EXTENDED_SRGB_LINEAR_EXT not offered — Proton titles on " + "the scRGB path find no matching format and fall back to SDR") + if not depth16: + fails.append("no FP16 format offered — the scRGB path needs " + "R16G16B16A16_SFLOAT") + +# The display the child inherited decides which of the two code paths it is on, +# and only one of them can carry HDR today. Worth saying plainly either way, +# because a native-Wayland client failing the layer checks above is failing for +# a reason that has nothing to do with formats. +if env.get("child_display", "unset") == "unset": + print("\nnote: the child had no DISPLAY and ran as a native Wayland client,") + print(" which is the intended configuration — HDR is only offered on the") + print(" Wayland surface. Pass --xwayland to nescope for X11-only software,") + print(" at the cost of that software's HDR.") +else: + print("\nnote: the child had a DISPLAY, so XWayland is running. A game that") + print(" presents through it gets no HDR: Mesa offers no HDR colour space") + print(" on the XWayland surface.") + +print() +if fails: + print("FAIL" + (" (expected until the WSI layer lands)" if mode == "layer" else "")) + for f in fails: + print(f" - {f}") + sys.exit(1) +print("PASS") +PY diff --git a/apps/nescope/src/hdr.rs b/apps/nescope/src/hdr.rs index 9d946991..87b9c3b3 100644 --- a/apps/nescope/src/hdr.rs +++ b/apps/nescope/src/hdr.rs @@ -1,18 +1,82 @@ -//! HDR / color management protocol handlers. +//! HDR / colour management protocol handlers. //! -//! Two signalling paths feed into [`HdrState`]: +//! # HDR here is Wayland colour management //! -//! 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. +//! A game gets HDR by being a Wayland client and asking for it through +//! `wp_color_manager_v1`, which Mesa turns into HDR colour spaces on the +//! surface. That is the whole mechanism, it works, and it needs nothing outside +//! this tree. //! -//! 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. +//! Measured on RDNA4, and the asymmetry is the point: //! -//! 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. +//! | surface | formats offered | HDR | +//! |---|---|---| +//! | Wayland | 21, incl. `A2B10G10R10` and `R16G16B16A16_SFLOAT` | yes | +//! | XWayland | 2, both 8-bit sRGB | no -- "surface offers no format in HDR10_ST2084_EXT" | +//! +//! Verified past the swapchain too, not just on the format list: a client +//! requesting `A2B10G10R10` + `HDR10_ST2084` comes out `yuv420p10le`, full +//! range, `bt2020nc` / `smpte2084` / `bt2020`. +//! +//! So the launch environment always sets `PROTON_ENABLE_WAYLAND=1` -- not as an +//! HDR switch but as the way a Windows title reaches the compositor at all, +//! since Proton renders through XWayland otherwise and XWayland is off by +//! default. `--hdr` adds `DXVK_HDR=1` (DXVK's dxgi gates HDR exposure on it). +//! Those two are what HDR needs. +//! +//! Mesa pairs the colour spaces it learns here with the pixel formats it +//! derives from our `zwp_linux_dmabuf_v1` list, so both halves have to be +//! present -- the surface offered nothing but `B8G8R8A8` until the format list +//! advertised the opaque FourCC spellings alongside the alpha ones. See the +//! list in `state.rs`, which is where that constraint lives. +//! +//! # `gamescope_swapchain_factory_v2` is the legacy route, and stays off +//! +//! Also implemented here, because it costs little and a host may deliberately +//! want it. It predates Wayland colour management and works the other way +//! round: a WSI layer inside the game's process appends HDR colour spaces Mesa +//! never offered, rewrites `imageColorSpace` to `SRGB_NONLINEAR` so the driver +//! is never told HDR is happening, and reports the real colour space to the +//! compositor over this protocol instead. +//! +//! It is not how we do HDR, for three reasons that all point the same way: +//! +//! - It needs a Vulkan layer this tree does not ship. Verified working with +//! gamescope's own, unmodified -- the XML here is byte-identical to theirs, +//! and the atoms written in `state.rs` are what it reads. +//! - It only helps the XWayland path, which is the path without HDR anyway. +//! - **Capture reads the colour space it hides.** A game asking for HDR10 +//! through it has its ten-bit PQ samples encoded and tagged BT.709 SDR, at +//! full frame rate, decoding cleanly. Recorded where that value is read, in +//! the capture layer's swapchain hook. +//! +//! That last one makes enabling it worse than leaving it off: it trades no HDR +//! for wrong HDR. So `GAMESCOPE_WAYLAND_DISPLAY` is set for the child but +//! `ENABLE_GAMESCOPE_WSI` deliberately is not, which leaves the layer inert +//! unless someone opts in. If anyone ever does want this path, the colour space +//! it reports arrives here and the capture layer cannot see it, so it would +//! need a channel from this process to that one. +//! +//! # What HDR does not cover +//! +//! A game that cannot be a Wayland client gets SDR, and XWayland is off by +//! default -- it costs input latency and a compositing hop, which is the wrong +//! trade for a streaming box, and Proton does not need it. `--xwayland` turns +//! it on for the shrinking set of X11-only native software, which then runs +//! without HDR: Mesa offers no HDR colour space on the XWayland surface, and +//! nothing in this module can change that. +//! +//! Still unexercised: no game has run, and the scRGB/FP16 arm has had no pixels +//! through it -- only HDR10 PQ. +//! +//! # Signalling paths, for reference +//! +//! Both feed [`HdrState`], which tracks the colour space the active surface has +//! declared. This module never converts anything itself. +//! +//! 1. **`wp_color_manager_v1`** -- the standard protocol, and the live one. +//! 2. **`gamescope_swapchain_factory_v2`** -- the legacy route described above, +//! reachable only if a WSI layer is present and opted into. #![allow(unused)] use std::collections::HashMap; use std::sync::Mutex; diff --git a/apps/nescope/src/main.rs b/apps/nescope/src/main.rs index 2f972087..7bd8316f 100644 --- a/apps/nescope/src/main.rs +++ b/apps/nescope/src/main.rs @@ -25,7 +25,8 @@ //! | Variable | Effect | //! |----------------|-----------------------------------------------| //! | `WAYLAND_DISPLAY` | Set by nescope before spawning the game | -//! | `DISPLAY` | Set to the XWayland display (`:N`) | +//! | `DISPLAY` | XWayland display (`:N`), only with `--xwayland` | +//! | `PROTON_ENABLE_WAYLAND` | Set to `1` always, so Proton renders through Wayland | //! | `XCURSOR_THEME` | XCursor theme name for the software cursor | //! | `XCURSOR_SIZE` | XCursor size in pixels | //! | `RUST_LOG` | Tracing filter (e.g. `nescope=debug`) | @@ -45,6 +46,11 @@ use std::os::unix::process::CommandExt; use std::sync::Arc; use std::time::Duration; +/// How long to wait for XWayland to report a display before giving up. Startup +/// is normally tens of milliseconds; this only has to be longer than a slow +/// machine's worst case, not tuned. +const XWAYLAND_TIMEOUT_SECS: u64 = 10; + use calloop::generic::Generic; use calloop::signals::{Signal, Signals}; use calloop::timer::Timer; @@ -96,6 +102,17 @@ struct Args { #[arg(long, env = "NESCOPE_HDR")] hdr: bool, + /// Run XWayland, for Linux-native software with no Wayland support. + /// + /// Off by default, and that is the point. XWayland costs input latency and + /// a compositing hop, which is the wrong trade for a streaming box. Windows + /// titles do not need it -- Proton renders through Wayland when told to, + /// which is what the launch environment does -- and HDR is only offered on + /// the Wayland surface, so a game routed through XWayland loses it too. + /// Turn this on for the shrinking set of X11-only native software. + #[arg(long, env = "NESCOPE_XWAYLAND")] + xwayland: bool, + /// Wayland socket name (created in $XDG_RUNTIME_DIR). #[arg(long, default_value = "nescope-0", env = "NESCOPE_SOCKET")] socket: String, @@ -278,7 +295,9 @@ fn main() { args.hdr, args.render_device.clone(), ); - //state.init_xwayland(&loop_handle, Some(args.x_display)); + if args.xwayland { + state.init_xwayland(&loop_handle, Some(args.x_display)); + } // Said out loud because in compositor mode nothing else can work them out. // A process started by the hub rather than by nescope has no inherited @@ -286,7 +305,11 @@ fn main() { if args.command.is_empty() { tracing::info!( wayland_display = %socket_name.to_string_lossy(), - display = format!(":{}", args.x_display), + display = if args.xwayland { + format!(":{}", args.x_display) + } else { + "(none — XWayland off; pass --xwayland if you need it)".to_string() + }, "compositor mode — point clients at these and they will connect" ); } @@ -402,6 +425,11 @@ fn main() { // Run with a 1-second timeout so the idle closure fires even when no // Wayland events arrive (needed for zombie reaping and auto-exit checks). + // Deadline for XWayland to come up. The launch below waits on it, so if it + // never arrives there is nothing to wait for and no game to run. + let startup = std::time::Instant::now(); + let mut xwayland_timed_out = false; + event_loop .run(Some(Duration::from_secs(1)), &mut data, move |data| { // ── Reap zombie children ────────────────────────────────── @@ -415,62 +443,110 @@ fn main() { && 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]); + // Only wait on XWayland when we are the ones providing it. + // Without --xwayland there is no display coming, so waiting + // would mean never launching. + if !args.xwayland || data.state.xdisplay.is_some() { + data.state.game_launched = true; + tracing::info!("Launching {:?}", command[0]); - let mut cmd = std::process::Command::new(&command[0]); + 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); + cmd.args(&command[1..]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + // Put the game in its own process group so we can + // kill the whole tree at once with kill(-pgid, …). + .process_group(0) + .env("WAYLAND_DISPLAY", &gamescope_wayland_socket); - if args.hdr { - tracing::debug!( - gamescope_wayland_socket, - "Setting GAMESCOPE_WAYLAND_DISPLAY for application" + // DISPLAY only if XWayland is actually running. Setting it + // otherwise points clients at a server that is not there, + // which is what the compositor used to do. + if let Some(xdisplay) = data.state.xdisplay { + cmd.env("DISPLAY", format!(":{xdisplay}")); + } + + // Proton renders through XWayland unless this is set, and + // XWayland is off by default -- so without this a Windows + // title has no display at all. Unconditional for that + // reason: it is how the game reaches the compositor, not + // an HDR switch. It is also what makes HDR reachable, since + // colour management only exists on the Wayland surface -- + // measured here, that surface offers 21 formats including + // HDR10 over A2B10G10R10 while the XWayland one offers two, + // both 8-bit sRGB. + cmd.env("PROTON_ENABLE_WAYLAND", "1"); + + if args.hdr { + // DXVK's dxgi.dll gates HDR colour space exposure on + // this. Without it neither DX11 nor DX12 (vkd3d-proton + // through DXVK's dxgi) sees HDR as available. + cmd.env("DXVK_HDR", "1"); + + // Left set, but deliberately without ENABLE_GAMESCOPE_WSI + // alongside it, so it is inert unless somebody opts in. + // + // That pair activates gamescope's WSI layer, which + // predates Wayland colour management and works by + // hiding HDR from the driver and reporting it to the + // compositor out of band. We do not want it: it needs a + // layer this image does not ship, it only helps the + // XWayland path, and capture reads the colour space it + // hides -- measured, a game asking for HDR10 through it + // has its PQ samples encoded and tagged BT.709 SDR. + // Enabling it would trade no HDR for wrong HDR. + tracing::debug!( + gamescope_wayland_socket, + "HDR: Wayland colour management; gamescope WSI not enabled" + ); + cmd.env("GAMESCOPE_WAYLAND_DISPLAY", &gamescope_wayland_socket); + } + + // Detect GPU vendor from render device and set VK_DRIVER_FILES + // so the game uses the same GPU as nescope. + if let Some(ref rd) = args.render_device { + if let Some(icd_path) = detect_gpu_icd(rd) { + cmd.env("VK_ICD_FILENAMES", &icd_path); + cmd.env("VK_DRIVER_FILES", &icd_path); // Mesa fallback + tracing::info!("GPU ICD → {icd_path}"); + } + } + + match cmd.spawn() { + Ok(child) => { + let pid = child.id(); + tracing::info!("Game process spawned (pid {pid})"); + data.primary_pid = Some(pid as i32); + data.game_pgid = Some(pid as i32); // PGID == PID due to .process_group(0) + data.game_process = Some(child); + } + Err(e) => { + tracing::error!("Failed to launch {:?}: {e}", command[0]); + data.loop_signal.stop(); + return; + } + } + } else if args.xwayland + && !xwayland_timed_out + && startup.elapsed() > Duration::from_secs(XWAYLAND_TIMEOUT_SECS) + { + // Waiting forever is the outcome to avoid: the auto-exit + // below only runs once a game has been launched, so a + // display that never arrives leaves nescope polling with no + // game and nothing logged. Smithay does not always report a + // failed XWayland as an error -- an Xwayland that exits + // immediately simply never becomes ready -- so this is a + // deadline, not an error handler. + xwayland_timed_out = true; + tracing::error!( + "XWayland did not become ready within {XWAYLAND_TIMEOUT_SECS}s — \ + cannot launch a game without a display" ); - 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"); + kill_all_children(); + data.loop_signal.stop(); } - - // 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 ────────────────────────────────── diff --git a/apps/nescope/src/state.rs b/apps/nescope/src/state.rs index 53659437..f4fca629 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -370,7 +370,13 @@ impl NescopeState { NescopeState::open_x11_input_conn(data, display_number); } XWaylandEvent::Error => { - tracing::error!("XWayland crashed at startup"); + // The game launch waits for a display number, so a dead + // XWayland means it will never start. Nothing downstream can + // notice that: the auto-exit path keys off a game having been + // launched, so without stopping here nescope would poll + // forever with no game and no reason given. + tracing::error!("XWayland crashed at startup — cannot run a game without it"); + data.loop_signal.stop(); } }); @@ -934,38 +940,53 @@ where use smithay::wayland::dmabuf::{DmabufFeedbackBuilder, DmabufState}; use std::os::unix::fs::MetadataExt; - // Formats declared to XWayland for DRI3. The actual pixel format used - // by the game's Vulkan swapchain is independent of this list. - let formats = [ - Format { - code: Fourcc::Argb8888, - modifier: Modifier::Linear, - }, - Format { - code: Fourcc::Xrgb8888, - modifier: Modifier::Linear, - }, - Format { - code: Fourcc::Abgr8888, - modifier: Modifier::Linear, - }, - Format { - code: Fourcc::Abgr2101010, - modifier: Modifier::Linear, - }, - Format { - code: Fourcc::Argb2101010, - modifier: Modifier::Linear, - }, - Format { - code: Fourcc::Argb8888, - modifier: Modifier::Invalid, - }, - Format { - code: Fourcc::Xrgb8888, - modifier: Modifier::Invalid, - }, - ]; + // Formats offered to clients through zwp_linux_dmabuf_v1. This list does + // decide what pixel formats a game's Vulkan swapchain can use: Mesa's + // Wayland WSI derives its surface formats from it, so a format missing here + // is a format no client can select. + // + // Each VkFormat needs BOTH its alpha and its opaque FourCC spelling. + // Mesa tracks those as two flags on one VkFormat -- ARGB8888 contributes + // the alpha flag, XRGB8888 the opaque one -- and skips any format that does + // not carry both, so advertising only the alpha variant silently drops it. + // That is a quiet failure: the format simply never appears, with nothing + // logged at either end. + // + // The 10-bit and FP16 pairs are what carry HDR. A game asks for HDR through + // a WSI layer that injects the HDR colour spaces, but the layer re-checks + // the requested VkFormat against the driver's own surface list and refuses + // the swapchain outright if it is absent. So the colour space and the pixel + // format come from two different places, and HDR needs both. + let formats = { + use std::iter::once; + + // Only LINEAR and INVALID, deliberately. Naming a driver's tiled + // modifiers here would mean hard-coding one vendor's values into a list + // sent to every client, and it buys nothing: INVALID leaves the choice + // of tiling to the driver, which picks its own optimal layout, and + // LINEAR is universally supported as the fallback. Measured on RDNA4 -- + // adding that vendor's eight tiled modifiers changes neither the + // formats a client is offered nor whether an HDR swapchain is created. + // + // alpha spelling, opaque spelling + const PAIRS: &[(Fourcc, Fourcc)] = &[ + (Fourcc::Argb8888, Fourcc::Xrgb8888), + (Fourcc::Abgr8888, Fourcc::Xbgr8888), + (Fourcc::Abgr2101010, Fourcc::Xbgr2101010), + (Fourcc::Argb2101010, Fourcc::Xrgb2101010), + (Fourcc::Abgr16161616f, Fourcc::Xbgr16161616f), + ]; + + PAIRS + .iter() + .flat_map(|&(alpha, opaque)| once(alpha).chain(once(opaque))) + .flat_map(|code| { + once(Modifier::Linear) + .chain(once(Modifier::Invalid)) + .map(move |modifier| Format { code, modifier }) + }) + .collect::>() + }; let mut dmabuf_state = DmabufState::new();