diff --git a/.gitea/workflows/arch.yml b/.gitea/workflows/arch.yml index 7df5fd8e..5a5d635e 100644 --- a/.gitea/workflows/arch.yml +++ b/.gitea/workflows/arch.yml @@ -112,10 +112,48 @@ jobs: makepkg -f -d --holdver ls -lh "$GITHUB_WORKSPACE/dist" + # The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a + # completely different dependency set, published into the same repo so `pacman -S + # punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ. + # + # CACHED on `packaging/gamescope/**`: it depends on nothing else in this repo, so a normal + # push restores the built package instead of spending ~10 minutes on someone else's C++ tree. + # Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary + # against newer system libs is the same risk the distro's own package carries between rebuilds. + - uses: actions/cache@v4 + id: gamescope + with: + path: dist-gamescope + key: punktfunk-gamescope-arch-${{ hashFiles('packaging/gamescope/**') }} + + - name: Build punktfunk-gamescope (makepkg) + if: steps.gamescope.outputs.cache-hit != 'true' + # Best-effort: punktfunk-host works without it (SDR on the gamescope backend), and a + # failure building gamescope must not cost the packages this workflow exists to publish. + run: | + set -x + pacman -S --noconfirm --needed \ + glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \ + libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \ + hwdata luajit pipewire seatd sdl2-compat vulkan-icd-loader wayland \ + xcb-util-errors xcb-util-wm xorg-xwayland \ + meson cmake glm wayland-protocols benchmark libxcursor || true + mkdir -p dist-gamescope && chown builder: dist-gamescope + chown -R builder: packaging/gamescope + if sudo -u builder env PKGDEST="$GITHUB_WORKSPACE/dist-gamescope" \ + bash -c 'cd packaging/gamescope && makepkg -f -d --holdver'; then + ls -lh dist-gamescope + else + echo "::warning::punktfunk-gamescope failed to build — Arch boxes stay SDR on the gamescope backend this run" + rm -rf dist-gamescope # never cache a failed build (an empty path is not saved) + fi + - name: Publish to the Gitea Arch registry env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | + # The gamescope companion rides the same loop (same repo, same channel). + cp -f dist-gamescope/*.pkg.tar.zst dist/ 2>/dev/null || true for pkg in dist/*.pkg.tar.zst; do echo "uploading $pkg" NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p') diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index 745ae12a..20f7fd14 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -59,6 +59,11 @@ jobs: dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers # sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling. dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted + # Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The + # sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and + # on a cache hit (the common case) nothing else in this job would have pulled libavif / + # luajit / seatd / SDL2 in. Cheap, and it tracks gamescope's dep list for us. + dnf -y install gamescope || true # bun builds the punktfunk-web console (--with web). Baked into the image; install it # here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note). command -v bun >/dev/null || { @@ -124,13 +129,62 @@ jobs: done echo "published to $OWNER/rpm/$GROUP" + # The HDR-capable gamescope the sysext carries (packaging/gamescope) — what lets the + # gamescope backend stream 10-bit BT.2020 PQ instead of 8-bit SDR. + # + # CACHED, and that is the whole reason this is affordable: it is a ~10-minute C++ meson build + # of an entirely separate tree that depends on NOTHING in this repo except + # `packaging/gamescope/**` (the patches and the upstream pin, which lives in the build + # script). So the key is that directory's hash and a normal push restores a binary instead of + # building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly + # like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60). + - uses: actions/cache@v4 + id: gamescope + with: + path: gs-cache + key: punktfunk-gamescope-f${{ matrix.fedver }}-${{ hashFiles('packaging/gamescope/**') }} + + - name: Build the HDR gamescope + if: steps.gamescope.outputs.cache-hit != 'true' + # Best-effort ON PURPOSE. The sysext is the primary Bazzite delivery path and works without + # this binary (the host just stays SDR on the gamescope backend, which is what every + # release before this one did) — so a hiccup building someone else's tree must not cost the + # whole image. It is loud, though: the warning below, and `--gamescope` silently absent + # downstream is impossible because build-sysext.sh verifies the +pfhdr marker itself. + run: | + set -x + # `dnf builddep` 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 + # bites (wlroots' configure dies on a missing xserver.wrap several minutes in). + dnf -y install dnf-plugins-core meson ninja-build glslc || true + dnf builddep -y gamescope || true + dnf -y install xorg-x11-server-Xwayland-devel || true + if bash packaging/gamescope/build-punktfunk-gamescope.sh \ + --destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then + install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope + else + echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)" + fi + # The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext # image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that # `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree. - name: Build the sysext image run: | + # Execute it here rather than only handing it over: build-sysext.sh treats an unusable + # --version as fatal (rightly — it is how the +pfhdr marker is read), and a cached binary + # whose runtime libs are missing from this container must cost the image its HDR, not the + # image itself. + gs=() + if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then + gs=(--gamescope gs-cache/punktfunk-gamescope) + echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)" + else + echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)" + fi bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \ --out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \ + "${gs[@]}" \ dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \ dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \ dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm diff --git a/packaging/gamescope/PKGBUILD b/packaging/gamescope/PKGBUILD index be0c2db0..17055a61 100644 --- a/packaging/gamescope/PKGBUILD +++ b/packaging/gamescope/PKGBUILD @@ -13,9 +13,13 @@ # See ../README.md (packaging/gamescope/README.md) for what the patches do and why. pkgname=punktfunk-gamescope # `_gsver` is the upstream gamescope this is built from; `pkgrel` moves when only our patches do. +# `_gsrev` MUST name the same commit as GAMESCOPE_REV in build-punktfunk-gamescope.sh — makepkg +# needs it statically here, so it is the one thing that cannot be shared; build() asserts it. +# The `.pfhdrN` suffix is the patch-set revision the binary stamps into its banner (see README.md); +# bump it with the marker so pacman sees a new version when only our patches moved. _gsver=3.16.25 _gsrev=8c676c399c761e4540587f61004c957993d12fea -pkgver="${_gsver}.pfhdr1" +pkgver="${_gsver}.pfhdr2" pkgrel=1 pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming" arch=('x86_64' 'aarch64') @@ -32,36 +36,53 @@ depends=('glslang' 'libcap' 'libdrm' 'libinput' 'libx11' 'libxcomposite' 'libxda 'vulkan-icd-loader' 'wayland' 'xcb-util-errors' 'xcb-util-wm' 'xorg-xwayland') makedepends=('git' 'meson' 'ninja' 'cmake' 'glm' 'vulkan-headers' 'wayland-protocols' 'benchmark' 'libxcursor') -# The submodules are pinned by the rev, so the git source is reproducible; `#commit=` makes -# makepkg check it out and `--recurse-submodules` pulls wlroots/vkroots/libliftoff/… with it. -source=("git+https://github.com/ValveSoftware/gamescope.git#commit=${_gsrev}" - '0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch' - '0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch') -# SKIP on the git source: the commit hash above IS the integrity check. -sha256sums=('SKIP' 'SKIP' 'SKIP') +# Only the upstream tree is a `source=`. The patches are NOT listed: they are applied by the shared +# build script out of `patches/` beside this file, which is what keeps this package from drifting +# from the sysext / by-hand builds (a stale filename here used to mean a silently different binary). +# The submodules are pinned by the rev, so the git source is reproducible; `#commit=` makes makepkg +# check it out and `--recurse-submodules` pulls wlroots/vkroots/libliftoff/… with it. +source=("git+https://github.com/ValveSoftware/gamescope.git#commit=${_gsrev}") +# SKIP: the commit hash above IS the integrity check. +sha256sums=('SKIP') options=('!lto' '!debug') install="${pkgname}.install" prepare() { cd gamescope git submodule update --init --recursive --depth 1 - # `git am` (not `patch`): the patches are format-patch output, and applying them as commits keeps - # `git describe --dirty` from stamping a `+` into the version banner on top of our `+pfhdr1`. - git -c user.name=punktfunk -c user.email=packages@unom.io \ - am "${srcdir}"/000*.patch } build() { - arch-meson gamescope build \ - --buildtype=release \ - -Dpipewire=enabled - meson compile -C build + # ONE recipe. The patch set, the meson options and — load-bearing for a package — the + # `force_fallback_for` that keeps wlroots STATIC all live in the shared script. Arch has + # `wlroots` in its repos, so a plain `arch-meson` here would link it shared and ship a + # punktfunk-gamescope that dies with `libwlroots-0.19.so: cannot open shared object file` on + # every machine that installs the package but not that dev library. + # + # `$startdir` is this file's directory: the package is built from the punktfunk checkout + # (`makepkg -si` in packaging/gamescope), which is the only documented way to build it. + grep -q "^GAMESCOPE_REV=\"${_gsrev}\"" "${startdir}/build-punktfunk-gamescope.sh" || { + echo "PKGBUILD _gsrev drifted from GAMESCOPE_REV in build-punktfunk-gamescope.sh" >&2 + return 1 + } + bash "${startdir}/build-punktfunk-gamescope.sh" \ + --srcdir "${srcdir}/gamescope" \ + --destdir "${srcdir}/stage" \ + --prefix /usr \ + --jobs "$(nproc)" + # The marker is the host's whole HDR detection (`gamescope_patch_level`) — a build that lost the + # stamp would install a package that promises nothing and says so nowhere. + "${srcdir}/stage/usr/bin/punktfunk-gamescope" --version 2>&1 | grep -q '+pfhdr' || { + echo "built binary carries no +pfhdr marker" >&2 + return 1 + } } package() { # ONLY the compositor, under our own name: a full `meson install` would also lay down # gamescopectl / gamescopereaper / gamescopestream and the WSI layer, colliding file-for-file # with the distro's `gamescope` package. The host only ever execs the compositor. - install -Dm755 build/src/gamescope "${pkgdir}/usr/bin/punktfunk-gamescope" + install -Dm755 "${srcdir}/stage/usr/bin/punktfunk-gamescope" \ + "${pkgdir}/usr/bin/punktfunk-gamescope" install -Dm644 gamescope/LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" } diff --git a/packaging/gamescope/README.md b/packaging/gamescope/README.md index e7060417..6720d87e 100644 --- a/packaging/gamescope/README.md +++ b/packaging/gamescope/README.md @@ -47,8 +47,18 @@ Bump it whenever a patch adds or changes something the host must know about befo 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); a missing cursor would be silent. Worth a post-spawn `/proc//cmdline` check -if that ever bites. +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//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 @@ -107,6 +117,28 @@ 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.yml` → `build-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.yml` → `makepkg` 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) ```sh diff --git a/packaging/nix/gamescope.nix b/packaging/nix/gamescope.nix index 040beec5..531c617b 100644 --- a/packaging/nix/gamescope.nix +++ b/packaging/nix/gamescope.nix @@ -3,7 +3,13 @@ # # 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 two patches and a rename. +# 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 @@ -20,23 +26,44 @@ patchDir, }: 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 = - gamescope.unwrapped or (throw '' - punktfunk-gamescope needs `gamescope.unwrapped` (the buildable derivation behind nixpkgs' - gamescope wrapper) and this nixpkgs does not expose it. Update nixpkgs, or build the - compositor with packaging/gamescope/build-punktfunk-gamescope.sh instead. - ''); + 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"; - patches = (old.patches or [ ]) ++ [ - "${patchDir}/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch" - "${patchDir}/0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch" - ]; + # 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 `+pfhdr1 (gcc …)` with no version at all — which the host's diagnostic + # banner would read `+pfhdr2 (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 "") + ''