feat(gamescope/hdr): HDR is attempted by default and every install path ships the patched gamescope
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 3m28s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Successful in 5m47s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 6m5s
docker / deploy-docs (push) Successful in 35s
deb / build-publish-host (push) Successful in 6m35s
ci / rust (push) Successful in 6m38s
arch / build-publish (push) Successful in 9m13s
apple / swift (push) Failing after 13m37s
apple / screenshots (push) Skipped
windows-host / package (push) Successful in 17m4s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 39s

Field report (RX 5700 / SteamOS): 10-bit HEVC "unsupported" — but the GPU was
never the blocker (RADV probed Main10 fine on that box). The session stayed SDR
because stock gamescope offers no 10-bit PQ capture formats and nothing shipped
our pipewire-hdr build outside the Bazzite sysext, while PUNKTFUNK_GAMESCOPE_HDR
additionally defaulted off everywhere. Close all three gaps:

- PUNKTFUNK_GAMESCOPE_HDR now defaults ON (explicit-off grammar, the flip the
  config comment planned post-canary). Safe by construction: the +pfhdr banner
  probe, managed-spawn term, and client 10-bit cap keep stock-gamescope boxes on
  the exact 8-bit path. The nix module's gamescopeHdr (default true now) only
  controls whether the patched binary is on PATH; it no longer sets the env.
- SteamOS: new scripts/steamdeck/build-gamescope.sh builds the HDR gamescope in
  the same pf2 trixie distrobox (apt dep list validated by a full build in a
  debian:trixie container), installs ~/.local/bin/punktfunk-gamescope, and
  maintains PUNKTFUNK_GAMESCOPE_BIN in host.env ONLY while the binary passes its
  on-glass --version/+pfhdr check — a stale absolute override would break
  session spawning, not just HDR. Wired into install.sh (§3b), update.sh
  (retrofit), and rebuild-check.sh (ldd-probes it like the host binary).
- Arch: build-sysext.sh gains a --gamescope fold-in (banner-verified by
  execution, host-image-only), and punktfunk-host optdepends the companion.
  Deliberately NO CI-published host sysext: per the 2026-07-22 packaging
  verdict a prebuilt SteamOS host breaks on A/B soname bumps (and /var is
  per-partition-set), so arch.yml records that instead — SteamOS hosts use the
  distrobox flow above.

Also fixes a latent bug that made build-sysext.sh fail every invocation as
documented: a literal `}` inside `${1:?usage …{host,client}…}` terminates the
expansion early and corrupts $PKG (caught by the new synthetic-package test,
which also covers the fold-in, the unmarked-gamescope refusal, and the
client-image guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:50:31 +02:00
co-authored by Claude Fable 5
parent 78926be4ac
commit 19392918ff
19 changed files with 290 additions and 60 deletions
+7
View File
@@ -210,6 +210,13 @@ jobs:
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
# per-partition-set), which is the standing packaging verdict behind the on-device
# distrobox build (scripts/steamdeck/, see scripts/steamdeck/README.md). That flow builds
# its own HDR gamescope too. packaging/arch/build-sysext.sh remains a by-hand tool for the
# Deck CLIENT image and for operators who accept the prebuilt-host trade-off.
- name: Publish to the Gitea Arch registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+7 -5
View File
@@ -151,10 +151,12 @@ pub struct HostConfig {
/// gamescope's PipeWire node the 10-bit PQ capture formats; the host probes for it and stays
/// SDR when it isn't installed, so this knob only decides whether HDR is *attempted*.
///
/// Default OFF for the canary release, then default-on (matching `PUNKTFUNK_10BIT`'s
/// explicit-off grammar). It gates the whole feature — spawn flags included — so an operator
/// who hits a bad interaction can turn the gamescope backend back into exactly today's 8-bit
/// path with one env var and no downgrade path to trip over.
/// Default ON (explicit-off grammar, matching `PUNKTFUNK_10BIT`) since the post-0.22.3 flip:
/// the capability chain behind it (the `+pfhdr` banner probe, managed spawn, the client's
/// 10-bit cap, the per-source downgrade latch) keeps a stock-gamescope box on today's 8-bit
/// path, so the knob's remaining job is the emergency escape hatch — an operator who hits a
/// bad interaction sets `=0` and the gamescope backend is exactly the old SDR path again,
/// spawn flags included.
pub gamescope_hdr: bool,
/// `PUNKTFUNK_GAMESCOPE_SDR_NITS` — the luminance SDR content is mapped to inside the PQ
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`, default 400).
@@ -270,7 +272,7 @@ impl HostConfig {
// its first frames at all; `=0` is the A/B + escape hatch.
gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true),
// Default OFF for one canary release (design §4 rollout), then flip the `unwrap_or`.
gamescope_hdr: env_on("PUNKTFUNK_GAMESCOPE_HDR").unwrap_or(false),
gamescope_hdr: env_on("PUNKTFUNK_GAMESCOPE_HDR").unwrap_or(true),
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| (1..=10_000).contains(n)),
+4 -1
View File
@@ -49,6 +49,7 @@ printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo
```sh
sudo pacman -Sy punktfunk-host # the streaming host
sudo pacman -S punktfunk-web # optional: the browser management console (pairing + status)
sudo pacman -S punktfunk-gamescope # optional: HDR (10-bit BT.2020 PQ) off gamescope sessions
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
@@ -155,5 +156,7 @@ sudo pacman -U punktfunk-host-*.pkg.tar.zst
NVENC/EGL come from the NVIDIA driver (`nvidia-utils`); on a GPU-less builder, symlink the CUDA
stub into the link path first (the `PKGBUILD` header documents this). Full details, the
Fedora→Arch dependency map, and the SteamOS systemd-sysext path are in
Fedora→Arch dependency map, and the systemd-sysext mechanism are in
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
(For a **SteamOS host**, use the [on-device installer](/docs/steamos-host) instead — it builds
the host and the HDR gamescope against the running OS.)
+3 -3
View File
@@ -152,9 +152,9 @@ These apply to the **Gaming Mode (gamescope)** path; the KDE Desktop path is una
current gamescope is fine; this only bites if you've pinned an old one.
- **The mouse cursor isn't included in the captured image** — a gamescope limitation for now. (The
KDE Desktop path renders the cursor normally.)
- **HDR needs the punktfunk gamescope build** (`punktfunk-gamescope`, shipped in the sysext) plus
`PUNKTFUNK_GAMESCOPE_HDR=1`. Without it sessions stream SDR — correctly, including SDR versions
of HDR games.
- **HDR needs the punktfunk gamescope build** (`punktfunk-gamescope`, shipped in the sysext) and
is attempted by default when it is present (`PUNKTFUNK_GAMESCOPE_HDR=0` forces SDR). Without
the build sessions stream SDR — correctly, including SDR versions of HDR games.
Canonical list: [gamescope → Known limits](/docs/gamescope#known-limits).
+2 -2
View File
@@ -59,7 +59,7 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
| `PUNKTFUNK_GAMESCOPE_HDR` | `1` · `0` *(default off)* | Allow HDR (10-bit BT.2020 PQ) sessions on the gamescope backend. Needs the `punktfunk-gamescope` build — see [HDR on gamescope](/docs/gamescope#hdr-on-gamescope). Without it, or without the build, sessions stream SDR. |
| `PUNKTFUNK_GAMESCOPE_HDR` | `1` · `0` *(default on)* | Allow HDR (10-bit BT.2020 PQ) sessions on the gamescope backend. Needs the `punktfunk-gamescope` build — see [HDR on gamescope](/docs/gamescope#hdr-on-gamescope); without the build, sessions stream SDR. Set `0` to force SDR. |
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | e.g. `400` | On an HDR gamescope session, the luminance SDR content (desktop, Steam overlay, SDR games) is mapped to inside the PQ container. Unset = gamescope's own default of 400. |
| `PUNKTFUNK_GAMESCOPE_BIN` | path | Force a specific gamescope binary for the sessions the host spawns. Unset = prefer `punktfunk-gamescope` on `PATH`, then `gamescope`. |
| `PUNKTFUNK_SESSION_WATCH` | `1` · `0` | Follow a Gaming ↔ Desktop switch **mid-stream** (rebuild the backend in place, no reconnect). **On by default** on Bazzite/SteamOS; set `0` to disable. |
@@ -92,7 +92,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. Windows host, plus the Linux **GNOME 50+ GameStream desktop mirror** (`PUNKTFUNK_VIDEO_SOURCE=portal`, mirrored monitor in HDR mode — check with `punktfunk-host hdr-probe`). Also the Linux **gamescope** backend with the `punktfunk-gamescope` build + `PUNKTFUNK_GAMESCOPE_HDR=1`. The other Linux virtual displays (Mutter, KWin, wlroots) stay 8-bit: their virtual-monitor screencasts are SDR-only upstream. |
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. Windows host, plus the Linux **GNOME 50+ GameStream desktop mirror** (`PUNKTFUNK_VIDEO_SOURCE=portal`, mirrored monitor in HDR mode — check with `punktfunk-host hdr-probe`). Also the Linux **gamescope** backend with the `punktfunk-gamescope` build (on by default too). The other Linux virtual displays (Mutter, KWin, wlroots) stay 8-bit: their virtual-monitor screencasts are SDR-only upstream. |
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. |
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
+9 -12
View File
@@ -113,7 +113,7 @@ a model. See the full [Configuration reference](/docs/configuration) for every o
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
| `PUNKTFUNK_SESSION_WATCH` | `1` · `0` | Follow a Gaming ↔ Desktop switch mid-stream (rebuild in place, no reconnect). On by default on Bazzite/SteamOS; set `0` to disable. |
| `PUNKTFUNK_GAMESCOPE_HDR` | `1` · `0` *(default off)* | Allow HDR (10-bit BT.2020 PQ) sessions. Needs `punktfunk-gamescope` — see below. |
| `PUNKTFUNK_GAMESCOPE_HDR` | `1` · `0` *(default on)* | Allow HDR (10-bit BT.2020 PQ) sessions. Needs `punktfunk-gamescope` (SDR otherwise) — see below. Set `0` to force SDR. |
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | e.g. `400` | On an HDR session, how bright SDR content (the desktop, the Steam overlay, an SDR game) is inside the PQ container. gamescope's default is 400. |
| `PUNKTFUNK_GAMESCOPE_BIN` | path | Force a specific gamescope binary. Otherwise the host prefers `punktfunk-gamescope` on `PATH`, then `gamescope`. |
@@ -130,17 +130,14 @@ 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.
- **Arch / SteamOS** — the `punktfunk-gamescope` package.
- **NixOS**`services.punktfunk.host.gamescopeHdr = true;` (it also sets the env var below).
- **Arch** — the `punktfunk-gamescope` package.
- **SteamOS (Steam Deck installer)** — built and wired automatically by
`scripts/steamdeck/install.sh` / `update.sh`.
- **NixOS**`services.punktfunk.host.gamescopeHdr` (default `true`).
- **Anything else**`bash packaging/gamescope/build-punktfunk-gamescope.sh` from the source tree.
Then turn it on:
```sh
PUNKTFUNK_GAMESCOPE_HDR=1
```
and check what the host thinks it can do:
HDR is attempted by default once the build is present (`PUNKTFUNK_GAMESCOPE_HDR=0` forces SDR).
Check what the host thinks it can do:
```sh
punktfunk-host hdr-probe
@@ -186,8 +183,8 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un
- **HDR needs the punktfunk gamescope build.** A stock gamescope's capture output is 8-bit SDR, so
sessions stream SDR — correctly, including SDR versions of HDR games. Install
`punktfunk-gamescope` (gamescope plus a small patch that teaches its capture node the 10-bit
BT.2020 PQ formats) and set `PUNKTFUNK_GAMESCOPE_HDR=1`, and a 10-bit-capable client streams
true HDR10. See [HDR on gamescope](#hdr-on-gamescope) below.
BT.2020 PQ formats), and a 10-bit-capable client streams true HDR10 — no knob needed.
See [HDR on gamescope](#hdr-on-gamescope) below.
To stream the KDE Plasma desktop of a Steam box instead, see [KDE Plasma](/docs/kde). To bring up the
web console and pair a client, see [The Web Console](/docs/web-console).
+1 -1
View File
@@ -105,7 +105,7 @@ see [Status & Progress](/docs/status).
(`PUNKTFUNK_VIDEO_SOURCE=portal`) negotiates the 10-bit PQ formats and encodes HEVC Main10
BT.2020 PQ. The **gamescope** virtual output is no longer blocked either — the
`punktfunk-gamescope` build carries the small patch its capture node was missing, so
`PUNKTFUNK_GAMESCOPE_HDR=1` streams true HDR10 (on-glass validation pending; the patch is
installing it streams true HDR10 by default (on-glass validation pending; the patch is
offered upstream as
[gamescope#2126](https://github.com/ValveSoftware/gamescope/issues/2126)). What stays blocked
upstream is HDR on **Mutter's virtual monitors**`RecordVirtual` streams are still SDR-only
+1 -1
View File
@@ -40,7 +40,7 @@ host is newer than the Linux host.)
mirror an HDR monitor over the GameStream desktop-capture source
(`PUNKTFUNK_VIDEO_SOURCE=portal`): the portal negotiates the 10-bit PQ screencast formats GNOME
50 added and encodes Main10 PQ. And a **gamescope** host can stream HDR off its virtual output
with the `punktfunk-gamescope` build plus `PUNKTFUNK_GAMESCOPE_HDR=1` — games get real HDR
with the `punktfunk-gamescope` build (on by default when installed) — games get real HDR
surfaces and the composite is captured as 10-bit BT.2020 PQ. (Run `punktfunk-host hdr-probe` to
check either; both are pending on-glass validation.) The **other** Linux virtual displays —
Mutter, KWin, wlroots — still stream 8-bit: their virtual-monitor screencasts are SDR-only
+16
View File
@@ -126,6 +126,22 @@ streams it at your client's resolution; in Desktop Mode it streams the KDE deskt
auto-detects which session is live per connection. See [Steam / gamescope](/docs/gamescope) for the
attach-vs-managed detail and known limits.
## HDR (10-bit BT.2020 PQ)
The installer also builds `punktfunk-gamescope` — gamescope plus the small patch that adds the
10-bit PQ formats to its capture node (see [HDR on gamescope](/docs/gamescope#hdr-on-gamescope)) —
and points the host at it. Nothing to configure: with it in place, a 10-bit-capable client with
HDR enabled streams true HDR10 from Game Mode; anything missing and the session streams 8-bit SDR
instead (never a mislabelled picture). Check what the host resolved with:
```sh
~/punktfunk/target-steamos/release/punktfunk-host hdr-probe
```
The build is best-effort: if it fails, the installer says so loudly and everything else keeps
working in SDR — re-run `update.sh` to retry. `PUNKTFUNK_GAMESCOPE_HDR=0` in
`~/.config/punktfunk/host.env` forces SDR deliberately.
## Updating
After pulling new source, rebuild and restart in one step (config + pairings persist):
+1
View File
@@ -138,6 +138,7 @@ package_punktfunk-host() {
optdepends=('pipewire-pulse: PulseAudio-API audio from games/apps (real `pulseaudio` also works)'
'nvidia-utils: NVENC hardware encode + GPU EGL/CUDA zero-copy (REQUIRED to encode on NVIDIA)'
'gamescope: per-session nested compositor backend (no desktop login needed) — needs >=3.16.22'
'punktfunk-gamescope: HDR (10-bit BT.2020 PQ) streaming on the gamescope backend — attempted by default when installed'
'kwin: stream a KDE Plasma desktop (kwin VirtualDisplay backend)'
'mutter: stream a GNOME desktop (Mutter RecordVirtual backend)'
'sway: stream a wlroots desktop (Sway VirtualDisplay backend)'
+21 -12
View File
@@ -114,28 +114,37 @@ NVENC/EGL come from the NVIDIA driver: `sudo pacman -S --needed nvidia-utils`. A
| NVIDIA driver (NVENC/EGL/CUDA) | `nvidia-utils` *(optdepend — never a hard dep)* |
| Compositor backends | `gamescope` (≥3.16.22) / `kwin` / `mutter` / `sway` *(optdepends)* |
## SteamOS 3 (immutable) — use a systemd-sysext
## Immutable Arch (SteamOS 3) — the systemd-sysext mechanism
SteamOS has a **read-only `/usr` on A/B partitions**, and every OS update reimages the rootfs —
so `steamos-readonly disable` + `pacman` (and flatpak/distrobox) are fragile or unusable for a
host that needs `/dev/uinput`, `/dev/uhid`, the host PipeWire socket, the GPU render node, and the
right to spawn a compositor. The update-survivable, SteamOS-blessed mechanism is a
**systemd-sysext**: an overlay image merged read-only over `/usr` at boot, living in the writable
`/var/lib/extensions/` (so it persists across A/B updates, no readonly-disable).
so `steamos-readonly disable` + `pacman` is fragile for anything that must survive updates. The
SteamOS-blessed overlay mechanism is a **systemd-sysext**: an image merged read-only over `/usr`
at boot, living in the writable `/var/lib/extensions/`.
> **For a SteamOS HOST this is NOT the supported path** — that is
> [`scripts/steamdeck/install.sh`](../../scripts/steamdeck/) (the on-device distrobox build,
> which also builds the HDR gamescope). A host sysext carries a prebuilt binary that breaks on
> the next SteamOS soname bump, and `/var` — where sysexts live — is per-A/B-partition-set.
> The mechanism below is what the Deck **client** image uses (next section), and an option for
> operators on other immutable Arch derivatives who accept the prebuilt trade-off.
Build the package, then wrap its `/usr` payload into a sysext image:
```sh
# 1. build the pacman package (needs an Arch environment / container)
# 1. build the pacman packages (needs an Arch environment / container)
cd packaging/arch && PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
# 2. turn it into a sysext .raw (extracts the package's /usr into an image + extension-release)
bash build-sysext.sh punktfunk-host-*.pkg.tar.zst
# 3. on the SteamOS box:
( cd ../gamescope && makepkg -f -d --holdver ) # optional: the HDR gamescope companion
# 2. turn it into a sysext .raw (extracts the packages' /usr into an image + extension-release);
# --gamescope folds the HDR build into a HOST image (verified by its +pfhdr banner)
bash build-sysext.sh --gamescope ../gamescope/punktfunk-gamescope-*.pkg.tar.zst punktfunk-host-*.pkg.tar.zst
# 3. on the box:
sudo cp punktfunk-host.raw /var/lib/extensions/
sudo systemctl enable --now systemd-sysext # merges it; survives OS updates
sudo systemctl enable --now systemd-sysext # merges it
systemctl --user enable --now punktfunk-host # the user unit is now under /usr/lib
```
The udev rule, sysctl, and systemd **user** unit all live under `/usr/lib`, so the merged sysext
exposes them. `systemd-sysext refresh` re-merges after a reboot.
exposes them. `systemd-sysext refresh` re-merges after a reboot. (One HDR nuance of the sysext
path: file capabilities don't survive it, so gamescope runs without `CAP_SYS_NICE` — everything
works, frame pacing is marginally worse than the pacman install, whose `.install` sets the cap.)
## Steam Deck — the client (what the Decky plugin launches)
+40 -2
View File
@@ -5,15 +5,34 @@
# 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 <punktfunk-{host,client}-*.pkg.tar.zst>
# 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. (No CAP_SYS_NICE inside the image: file
# capabilities don't survive this squashfs path — gamescope runs without it, pacing slightly
# worse, same as the Bazzite sysext.)
set -euo pipefail
PKG="${1:?usage: build-sysext.sh <punktfunk-{host,client}-*.pkg.tar.zst>}"
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
@@ -26,6 +45,25 @@ 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; }
echo "folded in $("$GS_BIN" --version 2>&1 | head -1)"
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"
+4 -3
View File
@@ -40,9 +40,10 @@ PUNKTFUNK_GAMESCOPE_ATTACH=1
#
# HDR (10-bit BT.2020 PQ) on the gamescope path. Needs `punktfunk-gamescope` — gamescope plus the
# patch that teaches its capture node the 10-bit formats — which the sysext ships alongside the
# host; it does NOT replace the system gamescope. Off by default while the feature soaks; a host
# without the patched binary stays SDR either way. `punktfunk-host hdr-probe` says what it found.
# PUNKTFUNK_GAMESCOPE_HDR=1
# host; it does NOT replace the system gamescope. ON by default when that binary is present; a
# host without it stays SDR either way. `punktfunk-host hdr-probe` says what it found. To force
# SDR on the gamescope backend:
# PUNKTFUNK_GAMESCOPE_HDR=0
#
# On an HDR session, how bright SDR content (desktop, Steam overlay, SDR games) is inside the PQ
# container. gamescope's default is 400 nits; raise it if whites look dim on your TV.
+9 -12
View File
@@ -166,19 +166,16 @@ in
gamescopeHdr = mkOption {
type = types.bool;
default = false;
default = true;
description = ''
Put `punktfunk-gamescope` gamescope carrying punktfunk's `pipewire-hdr` patches on
the host service's PATH and turn HDR on for the gamescope backend
(`PUNKTFUNK_GAMESCOPE_HDR=1`), so a 10-bit-capable client can stream true HDR10
(BT.2020 PQ) off a gamescope virtual output.
the host service's PATH, so a 10-bit-capable client can stream true HDR10 (BT.2020 PQ)
off a gamescope virtual output. HDR is attempted by default once the binary is present
(`PUNKTFUNK_GAMESCOPE_HDR=0` in `settings`/`environmentFile` forces SDR).
It does NOT replace the system's `gamescope`: the binary has its own name and the host
prefers it only for the sessions it spawns itself. Costs a gamescope build from source.
Off by default while the feature soaks (design §4 rollout); the host also stays SDR on
its own if the patched binary is somehow absent, so this is a policy switch, not a
promise.
prefers it only for the sessions it spawns itself. Costs a gamescope build from source
set `false` to skip that build; the host then stays SDR on the gamescope backend.
'';
};
@@ -365,9 +362,9 @@ in
(optional (cfg.host.settings != { }) "${hostSettingsFile}")
++ (optional (cfg.host.environmentFile != null) "-${toString cfg.host.environmentFile}");
};
# Layered UNDER `settings`/`environmentFile` (systemd's Environment= loses to a later
# EnvironmentFile), so an explicit `PUNKTFUNK_GAMESCOPE_HDR` in either still wins.
environment = mkIf cfg.host.gamescopeHdr { PUNKTFUNK_GAMESCOPE_HDR = "1"; };
# No PUNKTFUNK_GAMESCOPE_HDR here: the host defaults it on, and the capability probe on
# the resolved binary keeps a build-less box SDR. `gamescopeHdr` only controls whether
# the patched binary is on PATH; `settings`/`environmentFile` can still set =0 to force SDR.
};
})
+11 -3
View File
@@ -40,9 +40,10 @@ serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service
| Script | What it does |
|--------|--------------|
| `install.sh` | Idempotent installer: ensure the `pf2` distrobox + toolchain → build host + web + **plugin runner** → write config → tune sysctl + udev + `vhci-hcd` + `input` group and **register it on SteamOS's atomic-update keep list** (sudo) → install + start `punktfunk-host` / `punktfunk-web` systemd **user** services with linger, plus the **rebuild check** below. |
| `update.sh` | Rebuild everything from the current source and restart the services (config + pairings persist). `--pull` does `git pull` first. Also retrofits anything a newer install.sh writes (runner, keep-list registration, rebuild check) onto older installs. |
| `rebuild-check.sh` | The post-OS-update self-heal (run by `punktfunk-rebuild-check.service` before the host at session start): `ldd`-probes the binary — milliseconds when healthy, a full `update.sh` rebuild only when a SteamOS update actually broke its library links. |
| `install.sh` | Idempotent installer: ensure the `pf2` distrobox + toolchain → build host + web + **plugin runner** → write config → build the **HDR gamescope** below → tune sysctl + udev + `vhci-hcd` + `input` group and **register it on SteamOS's atomic-update keep list** (sudo) → install + start `punktfunk-host` / `punktfunk-web` systemd **user** services with linger, plus the **rebuild check** below. |
| `update.sh` | Rebuild everything from the current source and restart the services (config + pairings persist). `--pull` does `git pull` first. Also retrofits anything a newer install.sh writes (runner, HDR gamescope, keep-list registration, rebuild check) onto older installs. |
| `build-gamescope.sh` | Build gamescope + the `pipewire-hdr` patches (`packaging/gamescope`) in the same distrobox and install it as `~/.local/bin/punktfunk-gamescope`, wiring `PUNKTFUNK_GAMESCOPE_BIN` into `host.env` — what lets Game Mode stream **10-bit BT.2020 PQ (HDR)** instead of 8-bit SDR. Best-effort: a failure warns and the host streams SDR. Content-stamped — a no-op unless `packaging/gamescope/` changed or the binary broke. |
| `rebuild-check.sh` | The post-OS-update self-heal (run by `punktfunk-rebuild-check.service` before the host at session start): `ldd`-probes the host binary **and the HDR gamescope** — milliseconds when healthy, a full `update.sh` rebuild only when a SteamOS update actually broke library links. |
```sh
git clone https://git.unom.io/unom/punktfunk ~/punktfunk
@@ -74,6 +75,13 @@ default `pf2`), `PUNKTFUNK_MGMT_PORT` (47990), `PUNKTFUNK_WEB_PORT` (47992).
- **Plugin runner:** the deb's payload laid out user-scoped (read-only `/usr` can't take the
package): wrapper `~/.local/bin/punktfunk-scripting`, pinned `bun` in
`~/.local/lib/punktfunk-scripting/`, bundle in `~/.local/share/punktfunk-scripting/`.
- **HDR gamescope:** `~/.local/bin/punktfunk-gamescope` (gamescope + the `pipewire-hdr` patches,
built in `pf2`, run natively — it does **not** replace the system gamescope; only the sessions
the host spawns use it). `host.env` gains `PUNKTFUNK_GAMESCOPE_BIN=` pointing at it — a line
`build-gamescope.sh` maintains and removes again if the binary ever stops working, because a
stale absolute override would break session spawning, not just HDR. HDR is attempted by
default when present; `PUNKTFUNK_GAMESCOPE_HDR=0` in `host.env` forces SDR. Verify with
`punktfunk-host hdr-probe`.
- **System tuning (sudo):** `/etc/sysctl.d/99-punktfunk-net.conf` (32 MB UDP buffers — the #1
high-bitrate lever), `/etc/udev/rules.d/60-punktfunk.rules` (`uinput`/`uhid` access),
`/etc/modules-load.d/punktfunk.conf` (`vhci-hcd` for the native Deck pad), `$USER` in the `input`
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# punktfunk — build the HDR-capable `punktfunk-gamescope` for a SteamOS host (called by
# install.sh and update.sh; safe to run by hand).
#
# Stock gamescope offers no 10-bit PQ formats on its PipeWire capture node, so every session on
# the gamescope backend stays 8-bit SDR. This builds gamescope + punktfunk's `pipewire-hdr`
# patches (the ONE canonical recipe, packaging/gamescope/build-punktfunk-gamescope.sh) inside the
# same Debian-trixie distrobox the host is built in, installs it as
# ~/.local/bin/punktfunk-gamescope, and pins the host to it via PUNKTFUNK_GAMESCOPE_BIN in
# host.env. HDR is on by default in the host; with this binary present a 10-bit-capable client
# streams true HDR10 from Game Mode.
#
# BEST-EFFORT BY DESIGN, mirroring the CI packaging paths: streaming works without it (sessions
# stay SDR), so a failure here warns loudly and exits 0 — it must never cost the host install or
# update it runs inside. The one hard rule is honesty at the seams: host.env points at the binary
# ONLY while `--version` proves it runs on SteamOS glass and carries the `+pfhdr` marker; on any
# failure both the binary and the host.env line are removed (a stale absolute override would
# otherwise break gamescope session spawning entirely, which is worse than SDR).
#
# The build clones gamescope fresh each run (submodules included). That costs bandwidth, but it
# only runs when the packaging/gamescope tree changed (pin bump, patch change — tracked by a
# content stamp) or the installed binary stopped working; a no-op re-run is milliseconds.
set -euo pipefail
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
BOX="${PUNKTFUNK_BOX:-pf2}"
GS_BIN="$HOME/.local/bin/punktfunk-gamescope"
STAMP="$HOME/.local/share/punktfunk/gamescope.stamp"
HOST_ENV="$HOME/.config/punktfunk/host.env"
PKGDIR="$SRC/packaging/gamescope"
# host.env may only name the binary while it verifiably works (see header). sed -i is fine: the
# file is ours (install.sh §3) and the line is one this script wrote.
unwire() {
[ -f "$HOST_ENV" ] || return 0
if grep -q '^PUNKTFUNK_GAMESCOPE_BIN=' "$HOST_ENV"; then
sed -i '/^# HDR gamescope built by scripts\/steamdeck/d;/^PUNKTFUNK_GAMESCOPE_BIN=/d' "$HOST_ENV"
warn "host.env: removed PUNKTFUNK_GAMESCOPE_BIN (no working punktfunk-gamescope) — sessions stream SDR"
fi
}
wire() {
[ -f "$HOST_ENV" ] || return 0 # install.sh runs this after §3 wrote host.env; by-hand runs may predate it
grep -q '^PUNKTFUNK_GAMESCOPE_BIN=' "$HOST_ENV" && return 0
printf '\n# HDR gamescope built by scripts/steamdeck/build-gamescope.sh (10-bit BT.2020 PQ capture;\n# rebuilt by update.sh; PUNKTFUNK_GAMESCOPE_HDR=0 forces SDR).\nPUNKTFUNK_GAMESCOPE_BIN=%s\n' "$GS_BIN" >> "$HOST_ENV"
ok "host.env: PUNKTFUNK_GAMESCOPE_BIN=$GS_BIN"
}
# The on-glass truth: the binary must RUN ON STEAMOS (not merely in the build container — this is
# the ABI seam the whole distrobox approach exists for) and carry the +pfhdr banner marker.
verifies() { [ -x "$GS_BIN" ] && "$GS_BIN" --version 2>&1 | head -1 | grep -q '+pfhdr'; }
[ -d "$PKGDIR/patches" ] || { warn "no $PKGDIR — skipping the HDR gamescope build"; exit 0; }
# Content stamp over the packaging tree: a pin bump or patch edit re-triggers the build; anything
# else makes this a fast no-op.
want_stamp="$(find "$PKGDIR" -type f | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -d' ' -f1)"
if [ -f "$STAMP" ] && [ "$(cat "$STAMP")" = "$want_stamp" ] && verifies; then
ok "punktfunk-gamescope up to date ($("$GS_BIN" --version 2>&1 | head -1))"
wire
exit 0
fi
log "Building punktfunk-gamescope (HDR 10-bit capture; ~5-10 min, best-effort)"
# gamescope's build deps in the box (trixie names, mirroring packaging/gamescope/PKGBUILD — keep
# the two lists in step). Provisioned here, not in install.sh's main pass, so a dep problem can
# only ever cost this feature. glm/stb come in as meson wraps; wlroots/libliftoff/vkroots/
# libdisplay-info are vendored submodules — none of those need packages.
if ! distrobox enter "$BOX" -- bash -lc '
set -e
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends \
meson ninja-build cmake glslang-tools \
libwayland-dev wayland-protocols libxkbcommon-dev \
libx11-dev libxcomposite-dev libxdamage-dev libxext-dev libxmu-dev \
libxrender-dev libxres-dev libxtst-dev libxxf86vm-dev libxcursor-dev \
libxcb-errors-dev libxcb-icccm4-dev libxcb-ewmh-dev \
libxcb1-dev libxcb-composite0-dev libxcb-render0-dev libxcb-xfixes0-dev \
libxcb-shm0-dev libxcb-res0-dev libxcb-present-dev libxcb-xinput-dev \
libxcb-randr0-dev libxcb-dri3-dev libxcb-shape0-dev \
libcap-dev libdrm-dev libinput-dev libudev-dev libseat-dev \
libvulkan-dev libglm-dev libpixman-1-dev libeis-dev \
libavif-dev libdecor-0-dev hwdata libluajit-5.1-dev \
libpipewire-0.3-dev libspa-0.2-dev libsdl2-dev \
xwayland liblcms2-dev >/dev/null
' ; then
warn "could not provision gamescope build deps in '$BOX' — sessions stay SDR (re-run update.sh to retry)"
exit 0
fi
if ! distrobox enter "$BOX" -- bash -lc "
set -e
bash '$PKGDIR/build-punktfunk-gamescope.sh' --prefix \"\$HOME/.local\" --no-setcap
"; then
warn "punktfunk-gamescope failed to build — sessions stay SDR (re-run update.sh to retry)"
unwire
exit 0
fi
if verifies; then
# CAP_SYS_NICE is gamescope's scheduling boost — worth it, never required. Filesystem caps
# can't be set from the rootless box, so it happens here on the SteamOS side.
if sudo -n true 2>/dev/null; then
sudo setcap 'CAP_SYS_NICE=eip' "$GS_BIN" 2>/dev/null \
&& ok "setcap CAP_SYS_NICE (frame-pacing boost)" \
|| warn "could not setcap CAP_SYS_NICE (gamescope still works, pacing slightly worse)"
fi
mkdir -p "$(dirname "$STAMP")"
printf '%s' "$want_stamp" > "$STAMP"
wire
ok "punktfunk-gamescope ready: $("$GS_BIN" --version 2>&1 | head -1)"
else
# Built in the box but does not run on SteamOS (an ABI/soname seam) or lacks the marker. A
# broken binary must not linger where PATH or host.env could resolve it.
warn "built binary failed its on-glass check on SteamOS — removing it; sessions stay SDR"
"$GS_BIN" --version 2>&1 | head -3 >&2 || true
rm -f "$GS_BIN" "$STAMP"
unwire
fi
exit 0
+8
View File
@@ -245,6 +245,14 @@ else
[ "$WITH_WEB" = 1 ] && ok "web.env exists (login password unchanged)"
fi
# --- 3b. HDR gamescope (punktfunk-gamescope, best-effort) ------------------
# Stock gamescope offers only 8-bit capture, so Game Mode streams SDR. The punktfunk build adds
# the 10-bit BT.2020 PQ formats, and the host attempts HDR by default the moment it is present
# (PUNKTFUNK_GAMESCOPE_HDR=0 forces SDR). Best-effort by design: on any failure this warns and
# the host streams SDR — see build-gamescope.sh, which also wires PUNKTFUNK_GAMESCOPE_BIN into
# host.env only while the binary provably runs on SteamOS.
PUNKTFUNK_SRC="$SRC" PUNKTFUNK_BOX="$BOX" bash "$SRC/scripts/steamdeck/build-gamescope.sh"
# --- 4. system tuning (needs sudo: UDP buffers + gamepad udev rule + vhci-hcd + input group) --------
log "System tuning (UDP buffers + gamepad rules + vhci-hcd + input group)"
# sudo was acquired up front in preflight (SUDO_OK) so this never stalls behind the long build; a
+18 -3
View File
@@ -18,14 +18,29 @@ export PATH="$HOME/.local/bin:$PATH"
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
BIN="$SRC/target-steamos/release/punktfunk-host"
NEED=0
if [ ! -x "$BIN" ]; then
echo "punktfunk-host binary missing at $BIN — running a full rebuild" >&2
elif ! ldd "$BIN" 2>/dev/null | grep -q "not found"; then
exit 0 # every library resolves — nothing to do
else
NEED=1
elif ldd "$BIN" 2>/dev/null | grep -q "not found"; then
echo "punktfunk-host no longer loads after a SteamOS update — its missing libraries:" >&2
ldd "$BIN" 2>/dev/null | grep "not found" >&2 || true
echo "rebuilding against the new OS tree (this takes a few minutes; streaming resumes after)" >&2
NEED=1
fi
# The HDR gamescope companion chases OS libraries the same way. Probe it only when host.env pins
# it (build-gamescope.sh wires that line only while the binary works) — a break here would not
# just lose HDR, it would break gamescope session SPAWNING via the stale absolute override, so it
# rebuilds with the same urgency as the host binary.
GS_BIN="$(sed -n 's/^PUNKTFUNK_GAMESCOPE_BIN=//p' "$HOME/.config/punktfunk/host.env" 2>/dev/null | head -1)"
if [ -n "$GS_BIN" ]; then
if [ ! -x "$GS_BIN" ] || ldd "$GS_BIN" 2>/dev/null | grep -q "not found"; then
echo "punktfunk-gamescope no longer loads after a SteamOS update — rebuilding it" >&2
NEED=1
fi
fi
[ "$NEED" = 0 ] && exit 0 # everything resolves — nothing to do (every normal boot)
exec bash "$SRC/scripts/steamdeck/update.sh"
+6
View File
@@ -52,6 +52,12 @@ sed 's|^ExecStart=.*|ExecStart=%h/.local/bin/punktfunk-scripting|' \
systemctl --user daemon-reload
ok "plugin runner rebuilt (opt-in service: systemctl --user enable --now punktfunk-scripting)"
# HDR gamescope (punktfunk-gamescope): rebuild when the packaging tree changed or the installed
# binary stopped working — also RETROFITS it onto older installs that predate it (fast no-op
# otherwise). Best-effort; on failure the host streams SDR (see build-gamescope.sh).
log "HDR gamescope (punktfunk-gamescope)"
PUNKTFUNK_SRC="$SRC" PUNKTFUNK_BOX="$BOX" bash "$SRC/scripts/steamdeck/build-gamescope.sh"
# Retrofit the post-OS-update rebuild check (install.sh §5) onto older installs: probes the host
# binary with ldd at session start and re-runs this script when a SteamOS update broke its links.
if [ ! -f "$HOME/.config/systemd/user/punktfunk-rebuild-check.service" ]; then