Files
punktfunk/packaging/nix/gamescope.nix
enricobuehler 082c65755f
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 3m29s
ci / docs-site (pull_request) Successful in 3m53s
android / android (pull_request) Successful in 5m4s
ci / rust (pull_request) Successful in 5m9s
apple / swift (pull_request) Successful in 2m0s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Failing after 6m17s
fix(packaging): every channel ships the WSI layer, so in-game HDR works off a stock install
The previous commit built the layer and taught the host to use it, but only the
Arch PKGBUILD carried the files, so every other channel still landed on the
no-game-HDR fallback. This finishes the job.

The packaging scripts now take `--stage`, the DESTDIR the gamescope build script
wrote, instead of a path to one binary. That is the part worth keeping: the next
file this package needs will not require a new flag in four scripts and two
workflows. CI caches the whole staged tree for the same reason. The gs-cache key
already hashes packaging/gamescope/**, which this commit changes, so stale caches
in the old single-file shape cannot be restored into the new layout.

Channels, all of them:
  rpm     spec gains Source1/Source2 and %files entries
  deb     build-gamescope-deb.sh copies the layer into the package root
  Arch    PKGBUILD (previous commit); the sysext extracts the whole usr tree
  sysext  bazzite takes --gamescope-stage; arch asserts the layer arrived
  nix     the derivation keeps, renames and rewrites the layer rather than
          deleting it with everything else

A missing layer is fatal in every one of them, not best-effort. A package that
carries the compositor without it looks completely healthy and then silently
denies every game an HDR10 swapchain -- the exact failure this whole change
exists to end, so it must not be possible to ship it again by accident.

Two things needed care:

The layer manifest carries an ABSOLUTE library_path baked in at build time, so
every channel has to install the .so at exactly that path. That means literal
/usr/lib/punktfunk, not %{_libdir} (which is /usr/lib64 on Fedora) and not a
Debian multiarch triplet. Nothing links the .so by soname -- the loader dlopens
it by that path -- so multilib has no claim here. The rpm and nix install checks
now read the path back out of the manifest and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent
shape of this bug.

NixOS has no /usr, so the layer lives inside the gamescope derivation and the
host's path is overridable via PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR, which the
module sets -- the same posture as PUNKTFUNK_GAMESCOPE_BIN, and documented.

The manifest rewrite moved out of a heredoc into
packaging/gamescope/rewrite-wsi-layer-manifest.py because the FHS builds and the
Nix store both need it and must rename the layer identically; two copies would
drift into a host looking for a name only one of them produces.

Verified: 214 pf-vdisplay tests pass in a linux container, clippy -D warnings and
rustfmt clean, bash -n on all five changed shell scripts, both workflow YAMLs
parse, and the rewrite script was run against a synthetic FROG manifest to
confirm it renames/repoints/regates while preserving the `functions` block --
which is the field that decides whether the layer loads at all.
NOT verified: no nix on this machine, so gamescope.nix, flake.nix and the module
are unevaluated; no gamescope build, no package build of any kind, and no game
has taken an HDR swapchain on glass.
2026-08-13 23:37:48 +02:00

142 lines
7.9 KiB
Nix

# `punktfunk-gamescope` — nixpkgs' gamescope carrying punktfunk's `pipewire-hdr` patches, exposed
# under its own name so it sits BESIDE the system gamescope instead of replacing it.
#
# An override rather than a from-scratch derivation on purpose: gamescope vendors wlroots,
# vkroots, libliftoff, libdisplay-info, SPIRV-Headers and reshade as git submodules plus two meson
# wraps, and nixpkgs already solves all of that. What we add is the patch set and a rename.
#
# This is also why the override needs none of the `force_fallback_for` armour
# `build-punktfunk-gamescope.sh` carries: that exists because meson silently prefers a SYSTEM
# wlroots when the build host has one and links it shared, producing a binary that starts only on
# machines with that dev library. A nix closure names every library it links, so the outcome is
# whatever nixpkgs' own gamescope already does — reproducibly.
#
# `gamescope` in nixpkgs is a wrapper (it wires the WSI layer + capabilities); the buildable
# derivation is `gamescope.unwrapped` — patching the wrapper would be a no-op, so this asserts on
# it rather than silently shipping an unpatched binary.
#
# Version drift: the patches are applied to whatever gamescope your nixpkgs pins, NOT to the
# commit `packaging/gamescope/build-punktfunk-gamescope.sh` names. Both hunks sit in code that has
# been stable across the 3.16 series (`src/pipewire.cpp`'s format builders, `paint_pipewire()` in
# `src/steamcompmgr.cpp`), so this normally just works — and when it does not, the build fails
# loudly at `patchPhase` rather than producing a gamescope that quietly cannot do HDR.
#
# ⚠️ Kept deliberately free of any dependency on the pinned rev. The pin moved past upstream's
# `vulkan_get_rgb10_capture_format()` (`ff6b924`, after 3.16.25) to fix red/blue on NVIDIA, and it
# would have been natural to have patch `0001` call it — that is what the host-side note in
# `crates/pf-capture/src/linux/pw_pods.rs` proposes. It does NOT, precisely so this derivation
# keeps building against a nixpkgs that still pins 3.16.25, where that symbol does not exist and
# the failure would be an opaque C++ error rather than a patch conflict. Patch `0001` gets the
# same outcome version-independently by offering `xBGR_210LE` ahead of `xRGB_210LE`.
{
lib,
gamescope,
python3,
patchDir,
manifestRewriter,
}:
let
# As of nixos-unstable (checked 2026-07-28) `gamescope` IS the buildable derivation — pname
# "gamescope", version 3.16.25, carrying `src`/`patches`/`mesonFlags`. Revisions that wrap it
# (to wire the WSI layer + capabilities) expose the build as `.unwrapped`, so prefer that where
# it exists and take `gamescope` itself otherwise.
#
# The check is on the RESULT, not on which attribute we found: `overrideAttrs` on a symlinkJoin
# wrapper succeeds and does nothing, which would hand us an UNPATCHED gamescope installed under
# our own name — the single worst outcome here, because the host reads the name as a promise of
# HDR. (`installCheckPhase` below greps for the marker as the second line of defence; this one
# fails at eval, before anything is built.)
base = gamescope.unwrapped or gamescope;
unwrapped =
if base ? src then
base
else
throw ''
punktfunk-gamescope needs a buildable gamescope derivation (one with a `src` that
`overrideAttrs` can patch); this nixpkgs' `gamescope` is neither that nor a wrapper
exposing `.unwrapped`. Update nixpkgs, or build the compositor with
packaging/gamescope/build-punktfunk-gamescope.sh instead.
'';
in
unwrapped.overrideAttrs (old: {
pname = "punktfunk-gamescope";
# Read the patch DIRECTORY rather than naming files: `builtins.attrNames` sorts
# lexicographically, which for `000N-` prefixes is exactly the apply order, and a patch added or
# renamed upstream of this file can no longer leave nix silently building a subset. (That already
# happened once — this list still named the level-1 banner patch after level 2 landed, so a nix
# build would have shipped a binary with no cursor patch and a marker claiming otherwise.)
patches =
(old.patches or [ ])
++ map (f: "${patchDir}/${f}") (
builtins.filter (lib.hasSuffix ".patch") (builtins.attrNames (builtins.readDir patchDir))
);
# nixpkgs builds from a `fetchFromGitHub` src, so there is no `.git` for `git describe` and the
# banner would read `+pfhdrN (gcc …)` with no version at all — which the host's diagnostic
# version gate then misreads (it takes the first X.Y.Z triple it finds, i.e. the compiler's).
# Substituting the real version in keeps `--version` honest AND keeps our marker.
postPatch = (old.postPatch or "") + ''
substituteInPlace src/meson.build \
--replace-fail \
"vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()" \
"vcs_tag = '${old.version}'"
'';
# Ship the compositor, renamed, AND the WSI layer built beside it. Everything else nixpkgs
# installs (gamescopectl, gamescopereaper, gamescopestream, .desktop files) belongs to the real
# gamescope package — duplicating it here would put two of each on PATH.
#
# The layer is not dressing: a game nested under this compositor gets its HDR10 swapchain from it
# or from nowhere, and a layer built for a DIFFERENT gamescope makes the compositor reject the
# client's swapchain_feedback and kills every Vulkan client. So it travels with the binary it was
# built against. It is renamed and re-homed under $out/lib/punktfunk, with its own enable
# variable, so it sits beside the system gamescope's layer rather than shadowing it — the Vulkan
# loader deduplicates implicit layers by name, so two of the same name would be a coin toss.
#
# Staged through $TMPDIR because the prune below removes $out/lib and $out/share wholesale.
postInstall = (old.postInstall or "") + ''
layerSo=$(find $out -type f -name 'libVkLayer_*gamescope_wsi*.so' | head -1)
layerJson=$(find $out -type f -name '*gamescope_wsi*.json' | head -1)
if [ -z "$layerSo" ] || [ -z "$layerJson" ]; then
echo "punktfunk-gamescope: this nixpkgs' gamescope built no WSI layer, so no game under the" >&2
echo " compositor could ever obtain an HDR10 swapchain" >&2
exit 1
fi
cp "$layerSo" "$TMPDIR/pf-layer.so"
${python3}/bin/python3 ${manifestRewriter} \
"$layerJson" "$TMPDIR/pf-layer.json" \
"$out/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
find $out -mindepth 1 -maxdepth 1 ! -name bin -exec rm -rf {} +
find $out/bin -mindepth 1 ! -name gamescope -delete
mv $out/bin/gamescope $out/bin/punktfunk-gamescope
install -Dm0755 "$TMPDIR/pf-layer.so" \
"$out/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm0644 "$TMPDIR/pf-layer.json" \
"$out/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
'';
# `gamescope --version` exits non-zero on some builds; the grep is the real assertion.
doInstallCheck = true;
installCheckPhase = ''
runHook preInstallCheck
$out/bin/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' \
|| { echo "punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take"; exit 1; }
# The manifest must name a library this derivation actually installed. A manifest pointing at a
# path that does not exist is the worst shape of this bug: the loader reads it, finds nothing,
# and carries on silently, so the box looks healthy and every game renders SDR.
lib=$(sed -n 's/.*"library_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
$out/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json)
[ -f "$lib" ] \
|| { echo "punktfunk-gamescope: the layer manifest points at $lib, which is not installed"; exit 1; }
runHook postInstallCheck
'';
meta = (old.meta or { }) // {
description = "gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming";
mainProgram = "punktfunk-gamescope";
};
})