feat(packaging): ship punktfunk-gamescope on RPM and apt too
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 26s
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 2m3s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 2m44s
android / android (pull_request) Successful in 4m50s
nix / flake (pull_request) Failing after 16m12s

Until now the patched gamescope reached exactly four kinds of box: the
Bazzite/Fedora-Atomic sysext, the Arch package, the SteamOS installer and a NixOS
option. Everyone else was told to build gamescope from source. A traditional
Fedora-family box — Nobara, plain Fedora, the HTPCs people actually stream from —
therefore ran stock gamescope by default, which streams SDR, cursorless, and
tells every game its display is 60 Hz. That is not a user error; there was no
package to install.

Both new packages REPACK the binary CI already builds rather than building
gamescope again: it is a ~10-minute meson compile of an unrelated tree, cached
per distro base because the binary is soname-coupled to it. The Arch PKGBUILD
stays the one recipe that builds from source, because that is what makepkg is
for.

- packaging/gamescope/punktfunk-gamescope.spec + build-gamescope-rpm.sh. Version
  is derived from the binary's own banner (3.16.25.pfhdr4) — the only source that
  cannot drift from what is in the package. rpmbuild's automatic ELF Requires are
  what stop an f43 build installing on f44.
- packaging/debian/build-gamescope-deb.sh, same shape, with dpkg-shlibdeps for
  Depends.
- rpm.yml packages and publishes it beside the host RPMs; deb.yml gains a cached
  gamescope build (keyed on packaging/gamescope/** alone) and packages it into the
  existing publish loop. Both legs are best-effort, matching the sysext's existing
  rule: no binary, no package, and the host stays on its current SDR path.

Neither package Provides or Conflicts with gamescope — it installs as
/usr/bin/punktfunk-gamescope and only the sessions the host starts itself resolve
it, so a box's own Game Mode keeps using the distro binary.

Both refuse to package a binary without the +pfhdr marker. That marker is the
host's entire capability probe, so a build that lost the patches would install
fine and then silently stream SDR with no cursor.

Verified: build-gamescope-deb.sh produces an installable .deb from a stand-in
binary (correct version derived from the banner, 0755 tree, control fields) and
exits 1 on an unmarked one. The .spec is not yet exercised — no rpm tooling on
the box I had; CI's Fedora leg is its first run.
This commit is contained in:
2026-08-08 15:36:19 +02:00
parent 102f550bba
commit eb8c943572
6 changed files with 362 additions and 0 deletions
+40
View File
@@ -320,6 +320,46 @@ jobs:
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
#
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the patched gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
# a half-built result impossible to ship.
run: |
set -x
apt-get update
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
apt-get build-dep -y gamescope || 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 on noble — no .deb this run (gamescope sessions stay SDR)"
fi
- name: Build punktfunk-gamescope .deb
# Picked up by the publish loop below, which globs dist/*.deb.
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
else
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
fi
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+34
View File
@@ -213,6 +213,40 @@ jobs:
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
# until it existed those users had no packaged route to the patched build at all, and a stock
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
#
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
- name: Package punktfunk-gamescope as an RPM
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/gamescope/build-gamescope-rpm.sh \
--binary gs-cache/punktfunk-gamescope \
--release "$PF_RELEASE"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
fi
- name: Publish punktfunk-gamescope to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
shopt -s nullglob
for rpm in dist/punktfunk-gamescope-*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
echo "uploading $rpm"
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done
# 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.
+19
View File
@@ -161,6 +161,9 @@ To stream real HDR you need `punktfunk-gamescope`: gamescope plus a small patch
own name and does **not** replace your system gamescope — your Gaming Mode keeps using that one.
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
- **Fedora, Nobara and other RPM boxes** — `sudo dnf install punktfunk-gamescope` from the same
Punktfunk repo the host comes from.
- **Debian / Ubuntu** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt repo.
- **Arch** — the `punktfunk-gamescope` package.
- **SteamOS (Steam Deck installer)** — built and wired automatically by
`scripts/steamdeck/install.sh` / `update.sh`.
@@ -200,6 +203,22 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un
capture node, so the overlay is missing from an otherwise perfect picture. Either case is logged
at startup with the version found. Bazzite's and SteamOS's current gamescope is past both; this
only bites if you've pinned an old one.
- **On a stock gamescope, Gaming Mode reports the wrong refresh rate — and offers no resolutions.**
A headless gamescope has no EDID, and upstream's headless connector advertises no display modes
and no refresh rates at all. Steam's in-session display settings then show a single refresh entry
and an empty resolution list, and that one entry is whatever the session was launched with — or
**60 Hz** if the launch flag went missing. Games that pace themselves to the display will hold
themselves there, even though the stream is running at your client's full rate (the client's own
fps counter keeps reading correctly, because the encoder repeats held frames — so the counter is
not the thing to trust here; an in-game fps readout is). `punktfunk-gamescope` publishes the real
mode and rate, and `PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120` puts more than one entry in that
menu. If the host log says *"the session did not start at the mode we asked for"*, a file in
`/etc/gamescope-session-plus/sessions.d/` is overriding `GAMESCOPE_BIN` or setting `GAMESCOPECMD`.
- **The performance overlay (fps / frametime / stats) needs the patched build.** It is mangoapp,
which gamescope draws as an *external overlay* — a layer upstream's capture composite has never
included on any version, so on a stock gamescope you can turn the overlay on and it simply will
not appear in the stream. There is no host-side substitute: the host cannot reconstruct another
process's overlay window. `punktfunk-gamescope` paints it into the capture stream.
- **The cursor comes from the compositor when it can, and from the host otherwise.** A stock
gamescope leaves the pointer out of its captured image, so the host reads it separately and draws
it into every frame — a full pass over the picture, and the fastest encode source cannot blend at
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Package an already-built punktfunk-gamescope binary as a .deb, for the Gitea apt registry.
#
# Counterpart to ../gamescope/build-gamescope-rpm.sh, and the same argument: the binary is a
# ~10-minute meson build of an unrelated tree that CI does once and caches, so this repacks rather
# than rebuilds. The Arch package (../gamescope/PKGBUILD) is the one recipe that builds from source,
# because that is what makepkg is for.
#
# Installed as /usr/bin/punktfunk-gamescope — it does NOT replace the distro's gamescope, and does
# not Provide/Conflict with it. Only the sessions punktfunk-host starts itself resolve this binary
# (PUNKTFUNK_GAMESCOPE_BIN > punktfunk-gamescope > gamescope).
#
# Usage:
# VERSION=3.16.25.pfhdr4~ci42.gdeadbee bash packaging/debian/build-gamescope-deb.sh \
# --binary gs-cache/punktfunk-gamescope [--arch amd64]
# Output: dist/punktfunk-gamescope_<version>_<arch>.deb
set -euo pipefail
BINARY=""
DEB_ARCH=""
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
--arch) DEB_ARCH="${2:?--arch needs a value}"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
PKG="punktfunk-gamescope"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}"
# The marker is the host's whole capability probe: a binary that lost the patches installs fine and
# then silently streams SDR, cursorless, at a 60 Hz-advertising session. Refuse to package it.
BANNER="$("$BINARY" --version 2>&1 | head -1)"
case "$BANNER" in
*'+pfhdr'*) ;;
*) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2
echo " banner: $BANNER" >&2
exit 1 ;;
esac
# Derive the version from the binary when the caller did not pass one — it is the only source that
# cannot drift from what is actually in the package.
if [ -z "${VERSION:-}" ]; then
UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)"
PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')"
[ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; }
VERSION="${UPSTREAM}.${PFHDR}"
fi
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# mktemp gives 0700; the package root has to be world-readable or `dpkg-deb -c` shows the tree as
# root-only and some tooling refuses it.
chmod 0755 "$STAGE"
install -Dm0755 "$BINARY" "$STAGE/usr/bin/punktfunk-gamescope"
mkdir -p "$STAGE/DEBIAN"
# Shared-library dependencies straight from the binary's own ELF NEEDED entries. That is what makes
# the package honest about the Ubuntu release it was compiled on: gamescope links a broad set
# (wlroots, SDL, libliftoff, vulkan, xwayland's libs), and hand-listing them would rot.
DEPS=""
if command -v dpkg-shlibdeps >/dev/null 2>&1; then
# dpkg-shlibdeps insists on running from a package root with a debian/ dir.
mkdir -p "$STAGE/debian"
: > "$STAGE/debian/control"
( cd "$STAGE" && dpkg-shlibdeps -O --ignore-missing-info usr/bin/punktfunk-gamescope 2>/dev/null ) \
> "$STAGE/.shlibdeps" || true
DEPS="$(sed -n 's/^shlibs:Depends=//p' "$STAGE/.shlibdeps" | head -1)"
rm -rf "$STAGE/debian" "$STAGE/.shlibdeps"
fi
[ -n "$DEPS" ] || echo "WARNING: dpkg-shlibdeps produced no Depends — packaging without them" >&2
{
echo "Package: $PKG"
echo "Version: $VERSION"
echo "Architecture: $DEB_ARCH"
echo "Maintainer: unom <packages@unom.io>"
echo "Section: utils"
echo "Priority: optional"
[ -n "$DEPS" ] && echo "Depends: $DEPS"
# Not a hard dependency in either direction: the host works without this binary (SDR,
# host-composited cursor), and someone may want the binary for their own capture consumer.
echo "Recommends: punktfunk-host"
echo "Homepage: https://git.unom.io/unom/punktfunk"
echo "Description: gamescope with punktfunk's PipeWire capture patches"
echo " gamescope built from the upstream revision punktfunk pins, plus the patches in"
echo " packaging/gamescope/patches:"
echo " ."
echo " * 10-bit BT.2020/PQ capture formats, so an HDR game reaches a capture consumer as HDR"
echo " instead of pre-tonemapped SDR."
echo " * --pipewire-composite-cursor: the pointer is painted into the capture stream, so a"
echo " consumer with no cursor of its own gets one and the host stops blending one in."
echo " * A headless session advertises its real mode and refresh rates (and"
echo " --custom-refresh-rates), so Steam and games see the resolution and refresh the stream"
echo " actually runs at instead of an unnamed 60 Hz panel."
echo " * --pipewire-composite-external-overlay: the mangoapp performance overlay is painted"
echo " into the capture stream, so the fps/stats readout is visible remotely."
echo " ."
echo " Installed as /usr/bin/punktfunk-gamescope; your system gamescope is untouched."
} > "$STAGE/DEBIAN/control"
mkdir -p dist
OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb"
dpkg-deb --build --root-owner-group "$STAGE" "$OUT"
echo "==> wrote $OUT"
echo " banner: $BANNER"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Package an already-built punktfunk-gamescope binary as an RPM, for the Gitea RPM registry.
#
# WHY this exists: before it, the only ways to get punktfunk-gamescope were the Bazzite/Atomic
# sysext, the Arch package, the SteamOS installer, a NixOS option — or building gamescope from
# source yourself. A traditional Fedora-family box (Nobara, plain Fedora, Nobara-derived HTPCs)
# had no packaged route at all, which is how a field report ended up on a stock gamescope streaming
# a session that told every game the display was 60 Hz.
#
# The binary is NOT built here; CI builds it once per Fedora major and caches it
# (.gitea/workflows/rpm.yml). See punktfunk-gamescope.spec's header for why repacking beats
# rebuilding.
#
# Usage:
# bash packaging/gamescope/build-gamescope-rpm.sh \
# --binary gs-cache/punktfunk-gamescope \
# [--version 3.16.25] [--release 1] [--outdir dist]
#
# Output: <outdir>/punktfunk-gamescope-<version>-<release>.<arch>.rpm
set -euo pipefail
BINARY=""
# Default the version to the upstream gamescope the pinned revision describes as, suffixed with the
# patch-set revision — same shape as the Arch package's `pkgver`, so the two channels read alike.
VERSION=""
RELEASE="1"
OUTDIR="dist"
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
--version) VERSION="${2:?--version needs a value}"; shift 2 ;;
--release) RELEASE="${2:?--release needs a value}"; shift 2 ;;
--outdir) OUTDIR="${2:?--outdir needs a value}"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
# Derive the version from the binary itself when not told: it is the only source that cannot drift
# from what is actually being packaged. `gamescope version 3.16.25-1-g8c676c3+pfhdr4 (gcc …)` →
# `3.16.25` + the marker. RPM versions may not contain `-`, hence the trailing `.pfhdrN` form.
BANNER="$("$BINARY" --version 2>&1 | head -1)"
case "$BANNER" in
*'+pfhdr'*) ;;
*) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2
echo " banner: $BANNER" >&2
exit 1 ;;
esac
PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')"
if [ -z "$VERSION" ]; then
UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)"
[ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; }
VERSION="${UPSTREAM}.${PFHDR}"
fi
echo "==> packaging $BINARY as punktfunk-gamescope-${VERSION}-${RELEASE}"
echo " banner: $BANNER"
TOP="$(mktemp -d)"
trap 'rm -rf "$TOP"' EXIT
mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS}
install -m0755 "$BINARY" "$TOP/SOURCES/punktfunk-gamescope"
mkdir -p "$OUTDIR"
rpmbuild \
--define "_topdir $TOP" \
--define "pf_version $VERSION" \
--define "pf_release $RELEASE" \
-bb packaging/gamescope/punktfunk-gamescope.spec
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} "$OUTDIR/" \;
echo "==> wrote $(find "$OUTDIR" -name 'punktfunk-gamescope-*.rpm' -newer "$TOP" -print -quit 2>/dev/null || echo "$OUTDIR"/punktfunk-gamescope-*.rpm)"
@@ -0,0 +1,78 @@
# punktfunk-gamescope — gamescope carrying punktfunk's capture patches, installed under its own
# name so it sits BESIDE the distro's gamescope (Game Mode keeps using that one).
#
# This spec REPACKS a binary that was already built, rather than building gamescope itself: the
# build is a ~10-minute meson compile of an unrelated tree that CI already does once per Fedora
# major and caches (.gitea/workflows/rpm.yml). Rebuilding it inside rpmbuild would double that for
# no gain, and would need network access in the build root to fetch the upstream tree.
#
# The counterpart for Arch is packaging/gamescope/PKGBUILD, which DOES build from source, because
# makepkg fetches sources by design and the AUR-style recipe is what an Arch user expects.
#
# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --binary <path-to-punktfunk-gamescope>
Name: punktfunk-gamescope
Version: %{pf_version}
Release: %{pf_release}%{?dist}
Summary: gamescope with punktfunk's PipeWire capture patches (HDR, cursor, overlay, virtual-display modes)
# gamescope is BSD-2-Clause; our patches are contributed under the same terms.
License: BSD-2-Clause
URL: https://git.unom.io/unom/punktfunk
Source0: punktfunk-gamescope
# Not `Provides: gamescope` and not `Conflicts:` either — this ships a differently-named binary and
# is designed to coexist. A box's Game Mode session keeps running the distro's gamescope; only the
# sessions punktfunk-host starts itself resolve this one (PUNKTFUNK_GAMESCOPE_BIN >
# punktfunk-gamescope > gamescope).
#
# The runtime library Requires are auto-generated by rpmbuild from the binary's ELF NEEDED entries,
# which is exactly right here: this binary is soname-coupled to the Fedora major it was compiled
# on, and the generated Requires are what stop it installing on the wrong one.
Recommends: punktfunk-host
# Nothing is compiled here, so there is no debuginfo to extract; without this rpmbuild fails
# looking for sources it was never given.
%global debug_package %{nil}
%description
gamescope built from the upstream revision punktfunk pins, plus the patches in
packaging/gamescope/patches:
* 10-bit BT.2020/PQ capture formats on the PipeWire node, so an HDR game reaches a capture
consumer as HDR instead of pre-tonemapped SDR.
* --pipewire-composite-cursor: paint the pointer into the capture stream, so a consumer with no
cursor of its own gets one and the host stops blending one in, which frees the encoder's
fastest zero-copy source.
* A headless session advertises its real mode and refresh rates (and --custom-refresh-rates), so
Steam and games see the resolution and refresh the stream actually runs at instead of an
unnamed 60 Hz panel.
* --pipewire-composite-external-overlay: paint the mangoapp performance overlay into the capture
stream, so the fps/stats readout is visible to someone watching remotely.
Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched.
%prep
# Nothing to unpack: Source0 IS the binary.
%build
# Nothing to build — see the header.
%install
install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope
%check
# The marker is the host's entire capability probe (`gamescope_patch_level()`): a binary that lost
# the patches would install fine and then silently stream SDR with no cursor. Refuse to package it.
#
# Executed in the build root, which is the same container the binary was compiled in — if that ever
# stops being true this check is the thing that notices.
%{buildroot}%{_bindir}/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' || {
echo "punktfunk-gamescope: the +pfhdr marker is missing the patches did not take" >&2
exit 1
}
%files
%{_bindir}/punktfunk-gamescope
%changelog
# Generated per build; see the git history for the patch set's own changes.