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