Wave-2 PW1, second half. The companion commit wires `PYROWAVE_QUEUE_PRIORITY` into the Linux PyroWave device; this is what makes it work on a packaged host. Measured on .21 (RTX 5070 Ti, NVIDIA 610.43.02), same binary in both arms: as packaged (no capability) every class refused, REALTIME *and* HIGH -> default priority same binary, cap_sys_nice+ep granted REALTIME on the FIRST attempt, no downgrade RADV behaves the same way. So this is not the RADV-specific "expect one downgrade to HIGH" the plan predicted — without the capability there is no elevated priority at all, on any vendor, and the knob is decoration. Worth being precise about what is being granted, because it is a network-facing daemon. CAP_SYS_NICE permits raising scheduling priority (nice, ioprio, affinity, RT class) and nothing else: no filesystem access, no network privilege, no user switching, and it is NOT setuid. The repo already ships exactly this capability on its gamescope binary for the same reason. Two side effects that will otherwise confuse someone debugging: a capability-carrying binary is AT_SECURE, so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for it (note this box was propped up by exactly such a shim during the ffmpeg-9 soname break — that workaround would now be silently ignored), and core dumps are suppressed by default. Per packaging path, because none of them are the same: - Arch: a `_grant_sched_capability` in the scriptlet, called from post_install AND post_upgrade — a replaced binary is a new inode, so the capability does not survive an upgrade by itself. - Debian: the same setcap in the postinst `configure` branch. - RPM: `%caps(cap_sys_nice=ep)` on the binary in `%files`, which is the rpm-native form — rpm then applies it on install, restores it on upgrade, and verifies it. A `%post setcap` does none of those. - NixOS: `security.wrappers`, because a store path is read-only and shared and cannot be setcap'd. The unit's ExecStart moves to `config.security.wrapperDir` — without that the wrapper exists and the service still runs the uncapped store path, which is the whole failure this fixes. - Steam Deck: setcap in the installer's sudo block. That box needs it most (one small Van Gogh GPU shared between the game and the encode). The binary lives under $HOME, so unlike the /etc drop-ins it survives a SteamOS A/B update on its own and needs no atomic-keep entry — but it does need re-applying after each rebuild, which re-running the installer does. - Bazzite sysext: at IMAGE BUILD time, before mksquashfs. It cannot be done in the merge hook (a merged sysext's /usr is read-only squashfs) and it cannot ride in from the RPM either — rpm keeps capabilities in its own header and `rpm2cpio | cpio` carries only the payload, so the staged file arrives with none. mksquashfs does record security.capability (only security.selinux is excluded), so a setcap on the staging tree is what lands in the image. Needs root/CAP_SETFCAP; a plain-user CI build warns and ships without it rather than failing a release over a performance lever. Every one of them is best-effort and cannot fail an install: a box without libcap, or a filesystem that cannot store capabilities, simply runs at default priority exactly as it does today. Documented in the same PR — the configuration row now says the packages grant it, and running-as-a-service gets a section explaining what it is, how to check it (`getcap`), and how to remove it (`setcap -r`, or just `PYROWAVE_QUEUE_PRIORITY=off`), including the two debugging side effects. Verified: the Arch scriptlet grants the capability from a fake package root exactly as pacman would invoke it, and the resulting binary reaches REALTIME end to end on the RTX 5070 Ti; the RPM spec's %caps line parses under rpmspec in a Fedora 41 container; the NixOS module parses under nix-instantiate; all five edited shell scripts pass `bash -n`. No Rust file changed in this commit, so the CI-parity Rust gates from the companion commit still stand.
punktfunk on Arch Linux / SteamOS
Packaging for punktfunk on Arch and Arch-derived immutable distros. The PKGBUILD is a split
package producing punktfunk-host (the gaming-rig host) and punktfunk-client (the native
GTK4/libadwaita Linux client) — mirrors the rpm subpackages (packaging/rpm/punktfunk.spec) and the
deb build scripts. On a Steam Deck used as a client you want punktfunk-client (it's what the
Decky plugin launches); on a gaming rig, punktfunk-host.
Steam Deck as a HOST: don't use this PKGBUILD — SteamOS's read-only root makes
makepkg/sysext awkward, and a prebuilt binary breaks on OS library bumps. Use the on-device build script instead:scripts/steamdeck/install.sh(it builds in a Debian-trixie distrobox ABI-matched to SteamOS and uses VAAPI on the Deck's AMD GPU). The Deck host path is the one exception to "host encode is NVENC-only" below.
A third member, punktfunk-web (the browser management console — pairing + status), is
opt-in: build it by setting PF_WITH_WEB=1, which requires bun at build time (bun-bin
from the AUR if it isn't in your repos). bun is also the runtime — the console serves HTTPS
(HTTP/1.1 over TLS) via Bun.serve, so the package vendors the bun binary (no nodejs dependency). A
default makepkg builds only host+client with no JS tooling — mirroring the RPM spec's %bcond_with web.
Host encode: NVENC on NVIDIA, VAAPI on AMD/Intel (
PUNKTFUNK_ENCODER=autopicks one). The host now has a VAAPI encoder + zero-copy dmabuf path alongside NVENC/CUDA, sopunktfunk-hostworks on Arch + NVIDIA and AMD/Intel (incl. the Steam Deck — see the on-device path above). The client decodes via VAAPI on AMD/Intel with a software fallback.
Install from the binary repo (recommended)
CI (.gitea/workflows/arch.yml) builds this PKGBUILD in an archlinux:base-devel container on
every push and publishes the packages to the Gitea Arch package registry — a plain pacman
repo, so an Arch box installs and updates punktfunk with pacman -Syu like everything else.
Two repos mirror the deb/rpm channels: punktfunk (release tags) and punktfunk-canary
(rolling main-branch builds, versioned X.Y.Z-0.<run#> so a later release always outranks
them). Enable exactly one.
The registry signs the repo database and every package, so first import its key into pacman's keyring (a one-time step — after this, packages install signature-verified):
# 1. Trust the registry signing key.
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
| sudo pacman-key --add -
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
# printf, not a heredoc, so this works in fish too (CachyOS's default shell has no `<<EOF`).
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
| sudo tee -a /etc/pacman.conf >/dev/null
# 3. Sync + install.
sudo pacman -Sy punktfunk-host # gaming rig
sudo pacman -Sy punktfunk-client # the native GTK4 Linux client
sudo pacman -Sy punktfunk-web # optional browser management console
(No SigLevel line needed — pacman's default Required DatabaseOptional verifies the signed
packages against the key you just trusted. Arch is rolling, so the packages are built against
current Arch sonames — keep the box itself updated too.)
Step 2 appends, so running it twice leaves two [punktfunk] blocks and every later pacman
run opens with error: could not register 'punktfunk' database (database already registered).
It is harmless — pacman ignores the duplicate and carries on — but to silence it, delete the
extra block from /etc/pacman.conf.
Then the same first-run steps as a source build (printed by the install scriptlet): input
group, host.env, systemctl --user enable --now punktfunk-host — see the next section.
If pacman says unable to satisfy dependency 'libavcodec.so=…'
:: unable to satisfy dependency 'libavcodec.so=62-64' required by punktfunk-host
punktfunk-host links FFmpeg, so it depends on the exact libav sonames it was built against —
FFmpeg 8 provides libavcodec.so=62, FFmpeg 9 provides libavcodec.so=63. This message means the
package on offer was built against a different FFmpeg major than your box has. Because pacman
prepares the whole transaction at once, it stops your entire pacman -Syu, not just this package.
The bound is deliberate. Without it the upgrade succeeds and leaves a host binary that cannot
start at all — exit 127 before main(), in a systemd restart loop, with nothing in its own log
to explain it (ldd /usr/bin/punktfunk-host | grep 'not found' is the one-line diagnosis).
sudo pacman -Syyu— a forced db refresh, in case the matching build is already published. Comparepacman -Si punktfunk-hostagainst yourpacman -Q ffmpeg.- Still refused? Then we published a build made against the wrong FFmpeg — please report it. The
repair arrives as a higher pkgrel of the same version (
0.25.0-2), so a later-Syupicks it up with nothing to undo. - To let the rest of the system upgrade in the meantime:
sudo pacman -Syu --ignore punktfunk-host. If pacman still refuses (your installed copy is the one carrying the bound), remove it withsudo pacman -Rdd punktfunk-host, upgrade, and install it again once the rebuild lands. Either way the host stays down until then — that is the soname break itself, not a second fault.
Build from source — Arch Linux (mutable)
cd packaging/arch
# Build the working tree (CI / dev) — no git fetch:
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
# …or build the tagged release the AUR way:
makepkg -si
# …add the web console too (needs bun / bun-bin):
PF_WITH_WEB=1 PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
aarch64 (Arch Linux ARM) — the client
The PKGBUILD declares arch=('x86_64' 'aarch64'). On aarch64 it builds the client only —
pkgname drops punktfunk-host, so makepkg never enters the host's build or package path, and
build() skips the host/tray cargo invocations and their NVENC/Vulkan-encode features. The host
stays x86-only because its encode stack (NVENC/QSV/AMF) is.
Nothing else changes — run the same command on an Arch Linux ARM box:
cd packaging/arch
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
# -> punktfunk-client-<ver>-<rel>-aarch64.pkg.tar.zst (no punktfunk-host package)
There is no cross-compile path here: makepkg builds for CARCH, so this wants a real aarch64
Arch machine (or an emulated Arch Linux ARM container, which is slow). Unlike the deb, it has
not been verified end to end yet — there is no official arm64 Arch container to test in.
Then the standard first-run (printed by the install scriptlet):
sudo usermod -aG input "$USER" # virtual gamepads; re-login after
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamescope backend
systemctl --user enable --now punktfunk-host
# Web console (if you installed the punktfunk-web package): enable it + read the login password.
systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # open https://<host-ip>:47992
NVENC/EGL come from the NVIDIA driver: sudo pacman -S --needed nvidia-utils. Arch's stock
ffmpeg already has NVENC built in — no RPM-Fusion-style swap needed (unlike Fedora).
Runtime dependency map (Fedora/Debian → Arch)
| Need | Arch package |
|---|---|
| FFmpeg + NVENC | ffmpeg (NVENC built in) |
| PipeWire + session mgr | pipewire wireplumber |
| PulseAudio-API audio for games | pipewire-pulse (host optdepend — real pulseaudio also works; never a hard dep, it CONFLICTS with pulseaudio) |
| Opus / input injection | opus libei |
| GL/EGL + gbm + xkb + wayland | libglvnd mesa libxkbcommon wayland |
| NVIDIA driver (NVENC/EGL/CUDA) | nvidia-utils (optdepend — never a hard dep) |
| Compositor backends | gamescope (≥3.16.22) / kwin / mutter / sway (optdepends) |
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 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(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:
# 1. build the pacman packages (needs an Arch environment / container)
cd packaging/arch && PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
( 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
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. (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)
To stream to a Deck, you install punktfunk-client there — same sysext mechanism, but
wrapping the client package instead. The split makepkg produces both .pkg.tar.zst files; on the
Deck use the client one:
cd packaging/arch && PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
bash build-sysext.sh punktfunk-client-*.pkg.tar.zst # → punktfunk-client.raw
# on the Deck:
sudo cp punktfunk-client.raw /var/lib/extensions/
sudo systemctl enable --now systemd-sysext
sudo pacman -S --needed libva-mesa-driver # VAAPI hw decode on the Deck's AMD APU
Now punktfunk-client is on PATH, so the Decky plugin finds and
launches it (punktfunk-client --connect host:port) — gamescope composites its video like a game.
The client needs no /dev/uinput or compositor-spawning rights (it captures input and decodes),
so it's a much lighter sysext than the host.
Firewall
Stock Arch ships no firewall — every port is open by default, so there is nothing to do.
Spins that enable one do not get their ports opened for you: an Arch package never touches the
admin's running firewall. CachyOS is the common case — it ships ufw enabled by default (not
firewalld), so out of the box the host is unreachable until you allow it. Some other spins (e.g.
EndeavourOS) enable firewalld instead.
The punktfunk-host package ships openers for both — a ufw application profile
(/etc/ufw/applications.d/punktfunk) and firewalld service definitions
(/usr/lib/firewalld/services/) — so enabling is one command whichever you run:
# ufw (CachyOS, and Ubuntu once you enable ufw) — reads the profile at once, no reload needed:
sudo ufw allow punktfunk-native # the native-only host (the default)
sudo ufw allow punktfunk-gamestream # …or add this for the Moonlight/GameStream host
# firewalld (EndeavourOS and other Fedora-like spins):
sudo firewall-cmd --reload # pick up the installed def
sudo firewall-cmd --permanent --add-service=punktfunk-native
# --add-service=punktfunk-gamestream # …for the Moonlight host
sudo firewall-cmd --reload
punktfunk-gamestream opens the fixed Moonlight ports + mDNS; punktfunk-native opens the QUIC
control port (UDP 9777) + mDNS + the mgmt/library API (TCP 47990, HTTPS + mTLS). Enable both if the
host runs serve --gamestream (which serves both planes). The data plane is an ephemeral UDP port the client opens with a hole-punch, so
there is no fixed data port in either service — the host streams back out through the path the
client opened, which any firewall that allows outbound UDP (the default) passes. The mgmt REST API
(TCP 47990, HTTPS + mTLS) binds all interfaces by default so paired clients can browse the game library
— the punktfunk-native profile opens it. Off-loopback it serves only read-only status/library to a
paired client cert and keeps the admin surface loopback-only (--mgmt-bind 127.0.0.1:47990 to opt out).
If you installed the web console (punktfunk-web) and want it reachable from another device,
open its port with the matching one-liner — sudo ufw allow punktfunk-web or sudo firewall-cmd --permanent --add-service=punktfunk-web && sudo firewall-cmd --reload — which opens TCP 47992
(HTTPS, login-gated). The mgmt API (47990) is opened for paired clients by the punktfunk-native
profile (game-library browsing over mTLS); off-loopback it serves only read-only status/library and
keeps admin loopback-only.
Prefer explicit rules (or a firewall the shipped profiles don't cover)? Open the ports directly.
The native punktfunk/1 plane:
- QUIC control plane: UDP 9777 (
serve --native-port Nto change). - Data plane: a separate UDP port. By default it's random — the host binds
0.0.0.0:0and tells the client which port it got. Video flows host → client, but the client sends the first packet (a hole-punch), so the host learns the client's real source and streams back — this traverses NAT / inter-VLAN with no forwarded port. You normally don't open it: if a deny-inbound firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay, pin it withserve --data-port <PORT>(orPUNKTFUNK_DATA_PORT): the host binds that fixed port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the client's reported address to be reachable (flat LAN / a non-remapping port-forward).
And the GameStream / Moonlight ports (fixed) — only needed if you run the host with
serve --gamestream (opt-in, trusted LAN only); bare serve is native-only and doesn't open these:
| Port | Proto | Purpose |
|---|---|---|
| 47984 | TCP | HTTPS nvhttp (paired, mutual-TLS) |
| 47989 | TCP | HTTP nvhttp (/serverinfo, /pair PIN flow) |
| 48010 | TCP | RTSP handshake |
| 47998–48010 | UDP | Video RTP (+ FEC), ENet control (47999), audio (48000) |
| 5353 | UDP | mDNS auto-discovery |
The mgmt API (TCP 47990, HTTPS + mTLS) binds all interfaces by default so paired clients can browse the
game library — the punktfunk-native profile opens it. Off-loopback it serves only read-only
status/library to a paired client cert; the admin surface stays loopback-only. Pass
--mgmt-bind 127.0.0.1:47990 to keep it loopback-only (then leave 47990 closed).
With ufw (explicit ports, instead of the shipped punktfunk-native/punktfunk-gamestream profile):
sudo ufw allow 9777/udp # punktfunk/1 control plane
sudo ufw allow 47990/tcp # mgmt/library API (HTTPS + mTLS; LAN = read-only, paired)
sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
sudo ufw allow 47998,47999,48000/udp # GameStream video/control/audio
sudo ufw allow 5353/udp # mDNS discovery
# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches
# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port
# 9778` and `ufw allow 9778/udp`.
With raw nftables (add to your inet filter input chain):
udp dport 9777 accept # punktfunk/1 control plane
tcp dport 47990 accept # mgmt/library API (HTTPS + mTLS; LAN = read-only, paired)
tcp dport { 47984, 47989, 48010 } accept
udp dport { 47998-48000, 5353 } accept # GameStream video/control/audio + mDNS
# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s
# fallback). Pin it with `serve --data-port <PORT>` to open exactly one instead.
Files
PKGBUILD— split package:punktfunk-host+punktfunk-client(builds the working tree viaPF_SRCDIR, or a git tag for AUR).punktfunk-host.install/punktfunk-client.install— pacman scriptlets (udev reload + sysctl + first-run hint, incl. the ufw/firewalld enable command for whichever is present), mirror the RPM%post/ deb postinst.- The firewall openers are shared across all Linux packaging and live in
../linux/: the ufw application profile (punktfunk.ufw→/etc/ufw/applications.d/punktfunk) and the firewalld service definitions (punktfunk-native.xml/punktfunk-gamestream.xml/punktfunk-web.xml→/usr/lib/firewalld/services/). None auto-enabled; see Firewall above. build-sysext.sh— wraps either built.pkg.tar.zstinto asystemd-sysext.rawfor SteamOS (derives the name from the package, so it works for host or client).