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
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.
175 lines
9.5 KiB
Bash
Executable File
175 lines
9.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Wrap a built punktfunk pacman package into a systemd-sysext image — the update-survivable way to
|
|
# add it to an immutable Arch-derived distro (SteamOS 3): the .raw overlays /usr read-only from the
|
|
# writable /var/lib/extensions/, so it persists across A/B OS updates with no `steamos-readonly
|
|
# disable`. Works for either split package — on a Steam Deck you'd wrap the CLIENT. Needs
|
|
# `bsdtar`/`tar`, `squashfs-tools` (mksquashfs).
|
|
#
|
|
# Usage: bash build-sysext.sh [--gamescope <punktfunk-gamescope-*.pkg.tar.zst>] \
|
|
# <punktfunk-{host,client}-*.pkg.tar.zst>
|
|
# Output: <pkgname>.raw (e.g. punktfunk-client.raw)
|
|
#
|
|
# --gamescope folds the HDR-capable gamescope companion package (packaging/gamescope) into a HOST
|
|
# image as /usr/bin/punktfunk-gamescope — what lets the gamescope backend stream 10-bit BT.2020 PQ
|
|
# instead of 8-bit SDR (the host prefers that name on PATH and attempts HDR by default). Mirrors
|
|
# the Bazzite image's fold-in, including the honesty check: the binary is verified by executing
|
|
# its `+pfhdr` banner, never trusted by filename. Omit it and the image is exactly what it was —
|
|
# the host then stays SDR on that backend, by design.
|
|
#
|
|
# Capabilities in the image: NEVER on usr/bin/punktfunk-host, `cap_sys_nice=ep` on
|
|
# usr/bin/punktfunk-encode-worker (best-effort), and none on punktfunk-gamescope.
|
|
#
|
|
# ⚠ Capabilities are NOT lost on the way in — that was this comment's earlier claim and it is
|
|
# false: mksquashfs records security.capability, and the published Bazzite 0.26.0-1 image really
|
|
# did carry `cap_sys_nice=ep` on usr/bin/punktfunk-host. The host is left uncapped on purpose. A
|
|
# capability on the HOST binary makes it unidentifiable to KWin (which resolves a client's
|
|
# /proc/<pid>/exe to match it against a .desktop, and cannot read it for a capability-carrying
|
|
# process) and kills every Desktop-mode session.
|
|
#
|
|
# ⚠ And it is NOT enough to leave it out here: pacman scriptlets never run for a sysext, so the
|
|
# `setcap` in punktfunk-host.install cannot reach this image either way. The encode worker is
|
|
# therefore capped on the staging tree below — this is the only place a sysext can acquire it — and
|
|
# both halves of the matrix are asserted before mksquashfs, exactly as
|
|
# packaging/bazzite/build-sysext.sh does. `punktfunk-gamescope` is a compositor, not a KWin client,
|
|
# so it is unaffected by the host rule and simply runs without a capability here, pacing slightly
|
|
# worse.
|
|
set -euo pipefail
|
|
|
|
GAMESCOPE=""
|
|
if [ "${1:-}" = "--gamescope" ]; then
|
|
GAMESCOPE="${2:?--gamescope needs a punktfunk-gamescope package}"; shift 2
|
|
fi
|
|
# No braces in the message: a literal `}` inside ${1:?...} terminates the expansion early and
|
|
# corrupts $PKG (the tail of the message gets appended to the value — a real field bug).
|
|
PKG="${1:?usage: build-sysext.sh [--gamescope <pkg>] <punktfunk-host|client pkg.tar.zst>}"
|
|
[ -f "$PKG" ] || { echo "no such package: $PKG" >&2; exit 1; }
|
|
# Derive the package name from the file (pkgname is everything before the -<version>).
|
|
NAME="$(basename "$PKG" | sed -E 's/-[0-9].*//')"
|
|
[ -n "$NAME" ] || { echo "could not derive package name from $PKG" >&2; exit 1; }
|
|
if [ -n "$GAMESCOPE" ] && [ "$NAME" != "punktfunk-host" ]; then
|
|
echo "--gamescope only makes sense for a punktfunk-host image (got: $NAME)" >&2; exit 1
|
|
fi
|
|
|
|
STAGE="$(mktemp -d)"
|
|
trap 'rm -rf "$STAGE"' EXIT
|
|
|
|
# A pacman package is a (zstd) tarball; a sysext only carries /usr (the host /etc, /var are the
|
|
# system's). Extract just usr/ from the payload.
|
|
if command -v bsdtar >/dev/null 2>&1; then
|
|
bsdtar -C "$STAGE" -xf "$PKG" usr
|
|
else
|
|
tar -C "$STAGE" -xf "$PKG" usr
|
|
fi
|
|
|
|
# The HDR gamescope companion (see --gamescope in the header). Verified by its banner marker
|
|
# rather than trusted by filename: an unpatched gamescope shipped under this name would make the
|
|
# host promise HDR it cannot deliver, and the punktfunk/1 Welcome cannot take that back
|
|
# mid-session. Executing the staged binary needs a build box the binary runs on (the Arch CI
|
|
# container qualifies; it built it).
|
|
if [ -n "$GAMESCOPE" ]; then
|
|
[ -f "$GAMESCOPE" ] || { echo "no such package: $GAMESCOPE" >&2; exit 1; }
|
|
if command -v bsdtar >/dev/null 2>&1; then
|
|
bsdtar -C "$STAGE" -xf "$GAMESCOPE" usr
|
|
else
|
|
tar -C "$STAGE" -xf "$GAMESCOPE" usr
|
|
fi
|
|
GS_BIN="$STAGE/usr/bin/punktfunk-gamescope"
|
|
[ -x "$GS_BIN" ] || { echo "$GAMESCOPE did not provide usr/bin/punktfunk-gamescope" >&2; exit 1; }
|
|
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
|
|
echo "$GAMESCOPE's binary has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
|
|
# The package carries the Vulkan WSI layer alongside the compositor and the extraction above takes
|
|
# the whole `usr`, so this is an assertion rather than a step — but a silent one is exactly how
|
|
# this went wrong before: an image with the compositor and no layer streams HDR while every game
|
|
# inside it renders SDR, and nothing anywhere says why.
|
|
for f in usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so \
|
|
usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json; do
|
|
[ -f "$STAGE/$f" ] || { echo "$GAMESCOPE has no $f — no game HDR without it" >&2; exit 1; }
|
|
done
|
|
echo "folded in $("$GS_BIN" --version 2>&1 | head -1) + its WSI layer"
|
|
fi
|
|
|
|
# The marker systemd-sysext requires to merge the image. ID=_any merges onto ANY host os-release
|
|
# (SteamOS, Arch, Bazzite); ARCHITECTURE pins it to x86-64 so it's never merged on the wrong arch.
|
|
install -d "$STAGE/usr/lib/extension-release.d"
|
|
cat > "$STAGE/usr/lib/extension-release.d/extension-release.$NAME" <<EOF
|
|
ID=_any
|
|
ARCHITECTURE=x86-64
|
|
EOF
|
|
|
|
# CAP_SYS_NICE on the encode worker (see the header). A pacman payload carries no capabilities and
|
|
# no scriptlet ever runs for a sysext, so without this the SteamOS image ships the lever inert —
|
|
# on the box with the smallest GPU shared between game and encode. Needs CAP_SETFCAP, i.e. root or
|
|
# fakeroot; a plain-user build simply ships without it, which is a pacing loss and nothing more.
|
|
#
|
|
# `getcap` on an uncapped file exits 0 and prints nothing, so an empty read is unambiguous; the
|
|
# output form differs across libcap versions ("path cap_sys_nice=ep" since ~2.36, "path =
|
|
# cap_sys_nice+ep" before), hence the normalizer.
|
|
_pf_caps_of() {
|
|
local raw; raw="$(getcap "$1" 2>/dev/null || true)"
|
|
[ -n "$raw" ] || { printf ''; return 0; }
|
|
printf '%s' "${raw#* }" | sed -e 's/^= *//' -e 's/+/=/' -e 's/[[:space:]]*$//'
|
|
}
|
|
|
|
# BEFORE granting: refuse a capability that arrived from somewhere else. The setcap below would
|
|
# overwrite it and ship a correct-looking image while the surprise went unreported everywhere else.
|
|
# Order matters: assert first, then grant, or the "anything else" arm can never fire.
|
|
if command -v getcap >/dev/null 2>&1 && [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
|
|
arrived_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")"
|
|
case "$arrived_caps" in
|
|
''|cap_sys_nice=ep) : ;;
|
|
*)
|
|
echo "ERROR: staged usr/bin/punktfunk-encode-worker ARRIVED carrying '$arrived_caps'." >&2
|
|
echo " A pacman payload carries no capabilities, so something else granted it — find" >&2
|
|
echo " out what, because it is doing the same on the plain package path, unchecked." >&2
|
|
exit 1 ;;
|
|
esac
|
|
fi
|
|
|
|
if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
|
|
if setcap 'cap_sys_nice=ep' "$STAGE/usr/bin/punktfunk-encode-worker" 2>/dev/null; then
|
|
echo "granted CAP_SYS_NICE to usr/bin/punktfunk-encode-worker (GPU-priority lever active)"
|
|
else
|
|
echo "WARNING: could not setcap CAP_SYS_NICE on usr/bin/punktfunk-encode-worker (need" >&2
|
|
echo " root/CAP_SETFCAP) — the image ships without it and PyroWave encodes at" >&2
|
|
echo " default GPU priority." >&2
|
|
fi
|
|
fi
|
|
|
|
# Assert the final matrix before it is sealed into a read-only squashfs: host EMPTY (hard fail),
|
|
# worker exactly cap_sys_nice=ep or nothing at all (missing is fine — the grant is best-effort).
|
|
if command -v getcap >/dev/null 2>&1; then
|
|
if [ -f "$STAGE/usr/bin/punktfunk-host" ]; then
|
|
staged_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-host")"
|
|
if [ -n "$staged_caps" ]; then
|
|
echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2
|
|
echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2
|
|
echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2
|
|
echo " The GPU-priority capability belongs on usr/bin/punktfunk-encode-worker, never here." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
|
|
worker_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")"
|
|
case "$worker_caps" in
|
|
'') echo "note: usr/bin/punktfunk-encode-worker ships uncapped — PyroWave encodes at default GPU priority" ;;
|
|
cap_sys_nice=ep) : ;;
|
|
*)
|
|
echo "ERROR: staged usr/bin/punktfunk-encode-worker carries '$worker_caps'," >&2
|
|
echo " expected exactly 'cap_sys_nice=ep' (or nothing at all)." >&2
|
|
echo " Refusing to bake an unexpected capability into a read-only image." >&2
|
|
exit 1 ;;
|
|
esac
|
|
fi
|
|
fi
|
|
|
|
OUT="$NAME.raw"
|
|
rm -f "$OUT"
|
|
mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet
|
|
echo "built $OUT"
|
|
echo " install: sudo cp $OUT /var/lib/extensions/ && sudo systemctl enable --now systemd-sysext"
|
|
if [ "$NAME" = "punktfunk-host" ]; then
|
|
echo " then: systemctl --user enable --now punktfunk-host"
|
|
else
|
|
echo " then: run 'punktfunk-client' (or let the Decky plugin launch it)"
|
|
fi
|