Files
punktfunk/packaging/gamescope
enricobuehlerandClaude Opus 5 53ff313046
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
ci / web (push) Successful in 52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m8s
ci / docs-site (push) Successful in 2m13s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 40s
ci / rust (push) Canceled after 5m49s
ci / rust-arm64 (push) Canceled after 5m47s
arch / build-publish (push) Successful in 15m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m46s
fix(packaging/gamescope): the shipped compositor starts on SteamOS — a rolling-distro libstdc++ never followed it there
`punktfunk-gamescope-3.16.25.pfhdr2-1` off the pacman repo cannot start on SteamOS 3.8.16:
`/usr/lib/libstdc++.so.6: version 'GLIBCXX_3.4.35' not found`. The Arch container CI builds it in
is on gcc 16.1.1; SteamOS ships libstdc++ 3.4.34 and moves when Valve says so. Nothing else about
the binary was wrong — every other soname resolved on the box, and glibc was never close (it asks
for 2.38 at most against SteamOS's 2.41) — so the one dynamic C++ runtime was the whole reason the
gamescope backend's own most important platform got a package that dies at `--version`.

The C++ runtime therefore goes static, for the same reason wlroots already does: this binary is
built on a rolling distro and has to start on a frozen one. It is safe here because gamescope
links no shared C++ library at all — its NEEDED list is all C, and glslang/SPIRV are build-time
only — so no C++ ABI crosses a shared boundary. Cost is ~1 MB (5.9 → 7.1). The flags are appended
to LDFLAGS rather than passed as `-Dcpp_link_args`, which would replace the value meson derives
from the environment and silently drop makepkg's `-z relro`/`-z now`/`--as-needed`.

A static runtime is invisible in a passing build and only surfaces as a binary that will not start
somewhere else, so the build now asserts it: no `libstdc++` in NEEDED, which needs no version
threshold to check and turns the regression back into a build failure.

Verified by building in `archlinux:base-devel` — the environment arch.yml uses, gcc 16.1.1 and
glibc 2.44, both far newer than the target — and running the result on SteamOS 3.8.16 with nothing
supplied: banner `punktfunk-gamescope version 3.16.25-4-g6bbe157+pfhdr2`, no libstdc++ in NEEDED,
max GLIBC_2.38, every soname resolving. The host's whole HDR gate chain then answers on SteamOS
for the first time (780M / RADV PHOENIX): 10-bit PQ capture offered, cursor painted in-node,
native-plane HDR and GameStream HDR capable both true once `PUNKTFUNK_GAMESCOPE_HDR` is on, false
with the distro's gamescope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:23:35 +02:00
..

punktfunk-gamescope — gamescope with 10-bit HDR PipeWire capture

Upstream gamescope's built-in PipeWire node is SDR-only: build_format_params() offers BGRx and NV12, and paint_pipewire() hardcodes a Gamma-2.2 composite with the SDR screenshot LUT set. An HDR game therefore reaches every capture consumer already tone-mapped down — which is why the punktfunk gamescope backend has always streamed 8-bit, even though games can render HDR on a headless gamescope today (--hdr-enabled --hdr-debug-force-support).

The patches here add the missing half, and nothing else. See punktfunk-planning/design/gamescope-hdr-virtual-output.md for the full design.

Patch What Upstream?
0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch Offer SPA xRGB_210LE/xBGR_210LE with MANDATORY SMPTE ST.2084 + BT.2020 props, map them to DRM_FORMAT_XRGB2101010/XBGR2101010, and composite them with g_ScreenshotColorMgmtLutsHDR + EOTF_PQ Yes — offered against gamescope#2126
0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch --pipewire-composite-cursor (off by default): paint the pointer into the capture stream, using the same MouseCursor::paint call the scanout composite uses Yes — independently useful to any consumer with no cursor of its own
0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch Append +pfhdr<N> to the --version banner No — ours only, retired when the two above land upstream

Why the cursor patch matters more than it looks

gamescope keeps the pointer out of its PipeWire node — it lives on a hardware plane for scanout — so punktfunk has always reconstructed it from XFixes and blended it into every frame host-side. That blend is what forces the encode path onto its compute colour-conversion arm: the zero-copy RGB-direct encode source (VK_VALVE_video_encode_rgb_conversion) hands the captured buffer to a fixed-function front end with no blend stage. Painting the cursor into the node removes the reason for the blend, and with it a full-frame pass per frame — a gamescope session becomes the first one that can be genuinely zero-copy end to end.

Why the marker exists

punktfunk decides a session's shape before the virtual display exists: the bit depth at handshake time (irrevocable — a PQ stream handed to an 8-bit encoder is a deliberate hard error), and whether the host must composite the cursor before the encoder is even opened. Both answers must therefore be static properties of the resolved binary, not optimistic negotiations. The host runs <gamescope> --version once per boot and reads the revision — see gamescope_patch_level() in crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs.

The number is a monotonic patch-set revision, so one probe answers every capability:

Level Adds
+pfhdr1 10-bit BT.2020/PQ capture formats
+pfhdr2 …and --pipewire-composite-cursor

Bump it whenever a patch adds or changes something the host must know about before it spawns.

⚠️ The two indirect spawn modes (the GAMESCOPE_BIN wrapper for gamescope-session-plus, and the SteamOS PATH shim) pass these flags through PF_HDR_ARGS, so they share one dependency: if the session ignores GAMESCOPE_BIN/PATH and execs the distro's gamescope, it gets neither the HDR formats nor the cursor flag. HDR fails loudly there (the capture negotiation times out and latches an SDR downgrade) — but a missing cursor would be silent, because the host was told the compositor would paint the pointer and so painted none itself.

Both managed paths therefore verify after spawn: once the session's node appears, verify_managed_spawn_flags reads the running compositor's /proc/<pid>/cmdline and refuses the session if a flag we passed isn't there. The plan is fixed by then (cursor_blend feeds the encoder open, which precedes the display), so the session cannot be corrected in place — instead the capability is latched off for the process and the spawn fails, and the retry resolves a correct SDR host-composited session. One rejected attempt per boot, then it converges.

The check fails open at every ambiguity: no flags expected, or no readable gamescope in /proc, says nothing. Only a compositor we can see, missing a flag we can name, fails.

Which binary the host runs

Resolution order, applied identically by the bare spawn, the GAMESCOPE_BIN wrapper (gamescope-session-plus) and the SteamOS PATH shim:

  1. PUNKTFUNK_GAMESCOPE_BIN — absolute path override
  2. punktfunk-gamescope on PATH
  3. gamescope

So installing this build under the name punktfunk-gamescope is enough; nothing replaces the distro's gamescope.

Building

Pinned upstream: 8c676c39 (master, 2026-07-27 — tags through 3.16.25). The patches apply cleanly to that commit; they touch src/pipewire.cpp, src/steamcompmgr.cpp and src/meson.build only.

git clone https://github.com/ValveSoftware/gamescope.git
cd gamescope
git checkout 8c676c39
git submodule update --init --recursive          # or let meson fetch the subprojects
git am /path/to/punktfunk/packaging/gamescope/patches/*.patch

meson setup build/ --prefix=/usr -Dpipewire=enabled
ninja -C build/
# install as punktfunk-gamescope, NOT as gamescope
install -Dm755 build/src/gamescope /usr/bin/punktfunk-gamescope

Build dependencies

They are gamescope's, not ours, and they vary by distro. Two shortcuts that work:

# Fedora / Bazzite (inside a toolbox/distrobox — the host is immutable)
sudo dnf install -y dnf-plugins-core meson ninja-build glslc
sudo dnf builddep -y gamescope
sudo dnf install -y xorg-x11-server-Xwayland-devel      # NOT pulled by builddep; wlroots needs it

# Arch / SteamOS — see the makedepends in ./PKGBUILD

⚠️ dnf builddep gamescope resolves Fedora's packaged gamescope, which is older than the master we pin, so it can come up short. xorg-x11-server-Xwayland-devel is the one that actually bit (2026-07-28, Fedora 43): without it wlroots' configure fails with Neither a subproject directory nor a xserver.wrap file was found, several minutes into an otherwise clean run. If a different one surfaces, meson names it — install and re-run with --srcdir so the clone is not repeated.

gamescope needs CAP_SYS_NICE for its realtime priority; the distro packages set it on their own binary. Mirror it if you install ours system-wide:

setcap 'CAP_SYS_NICE=eip' /usr/bin/punktfunk-gamescope

How each channel ships it

Four packaging paths, one build recipe — build-punktfunk-gamescope.sh — because the two things that are easy to get wrong (which patches get applied, and whether wlroots is linked statically) must not be decided twice.

Channel Built by Notes
Bazzite / Fedora Atomic .gitea/workflows/rpm.ymlbuild-sysext.sh --gamescope Inside the matching Fedora container, per major — the binary is soname-coupled to its base exactly like the RPM
Arch / SteamOS .gitea/workflows/arch.ymlmakepkg on ./PKGBUILD Its own pkgbase in the same pacman repo; pacman -S punktfunk-gamescope
NixOS packaging/nix/gamescope.nix (an overrideAttrs on nixpkgs' gamescope) The one path that does NOT call the script — nixpkgs already solves the submodules, and a nix closure names every library it links
Anything else the script, by hand See Building above

Both CI builds are cached on packaging/gamescope/** and best-effort. Cached because this tree depends on nothing else in the repo, so a normal push restores a binary instead of spending ten minutes on someone else's C++; best-effort because punktfunk works without it (SDR on the gamescope backend, which is what every release before this one did) and a hiccup building gamescope must not cost the packages those workflows exist to publish. A failed build emits a ::warning:: and is never cached, so the next run retries.

Note what is NOT in that table: the .deb. Debian/Ubuntu boxes build it by hand for now.

Verifying the patch on a box (P0 exit)

punktfunk-gamescope --version                    # must contain +pfhdr2
punktfunk-gamescope --backend headless -W 1920 -H 1080 -r 60 \
    --hdr-enabled --hdr-debug-force-support --pipewire-composite-cursor -- vkcube &
pw-dump | grep -A40 '"gamescope"'                # node offers xRGB_210LE / xBGR_210LE

The stream is only 10-bit once a consumer asks for it: the formats are listed last, so any consumer that negotiates the 8-bit stream today keeps negotiating it bit-for-bit.

Rebase policy

The functional patch is two files and mirrors code that already exists in-tree (the HDR AVIF screenshot path), so it rebases cheaply. We pin the gamescope commit we ship; when upstream takes it, both patches are dropped and the host's capability probe becomes a plain version floor.