Fallout from the documentation sweep: verifying doc claims against the code
turned up defects in the code and the shipped templates. Mostly comments that
describe behaviour we no longer have — which is how the docs went wrong in the
first place, since someone reads the comment and writes the page.
The one that mattered: a fresh deb/RPM/Arch install could not start the host at
all. The unit's `EnvironmentFile=` had no `-`, making host.env mandatory, and no
package creates it — all three ship only the templates under /usr/share and the
postinst merely prints the copy command. So `systemctl --user enable --now
punktfunk-host` died on "Failed to load environment files". Every field in
HostConfig::from_env resolves through unwrap_or/filter/None, so absent means all
defaults, exactly like a hand-run `serve`; the Nix module already wrote it as
`-${environmentFile}`. The Deck installer's own generated unit gets the same
prefix for the case where an operator later removes the file.
Shipped templates: PUNKTFUNK_SECURE_DDA is read by nothing (DDA/WGC are gone;
IDD-push is the sole Windows capture path and the secure desktop is
unconditional), so it stops being written into a fresh host.env;
PUNKTFUNK_INPUT_BACKEND offered a `uinput` value that does not exist and omitted
`kwin`, which is what a KDE session actually resolves to; PUNKTFUNK_RENDER_ADAPTER
no longer claims to pick a "Desktop-Duplication" GPU.
Comments corrected rather than deleted, since each explains a real why:
PUNKTFUNK_10BIT is default-on with explicit-off grammar, not an operator opt-in;
GNOME reaches EIS through Mutter's direct RemoteDesktop API, so it needs no portal
approval and is headless-capable; and the host does not run in session 0 — the
service is the session-0 supervisor and the host runs as SYSTEM in the interactive
console session, which is why game_term has to bind the input desktop at all.
packaging/bazzite/update-punktfunk.sh is now installed to /usr/share/punktfunk/,
so the command the docs promised exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
punktfunk host on a Steam Deck
Run a punktfunk host on a Steam Deck — stream its Game Mode (or KDE desktop) to other devices. (Streaming to a Deck is the client; use the Flatpak + Decky plugin instead.)
User-facing guide: docs-site → "SteamOS (Host)" (docs-site/content/docs/steamos-host.md).
This README is the deep reference for what the scripts do and how to operate them by hand.
Why build on-device (not a package or prebuilt binary)
SteamOS 3 is an immutable, read-only Arch base:
- No
pacman -Sfor system libs;/usris read-only and reset on A/B updates. - A prebuilt binary is fragile — it links the system FFmpeg/glibc, and a SteamOS update can bump those sonames out from under it (the same class of breakage as the NVIDIA-driver-after-update issue).
- The host needs unsandboxed
/dev/uinput+/dev/uhid, PipeWire, the compositor, and VAAPI — so Flatpak (the normal Deck app channel) doesn't fit. Flatpak/Decky are for the client.
So the host is built natively inside a Debian-trixie distrobox (pf2), chosen because its
FFmpeg/glibc ABI matches SteamOS's — the resulting binary runs natively on SteamOS (the container
is only the build environment; punktfunk-host is launched directly, not via distrobox enter). A
rebuild always matches the running OS. Encode is VAAPI on the Deck's AMD GPU (NVENC on NVIDIA),
auto-selected by PUNKTFUNK_ENCODER=auto.
The honest trade-off: on-device building costs a slow first install (~10–15 min, ~1 GB of image +
toolchain) and adds moving parts (apt mirrors, rustup, bun) — the price of an install that can
always chase the OS. Both failure modes of that chase are automated away now:
punktfunk-rebuild-check rebuilds when an OS update breaks the binary's links, and the
atomic-update keep list preserves the /etc tuning. The eventual lighter-weight alternative is a
CI-prebuilt bundle with the volatile libraries (FFmpeg et al.) vendored under an $ORIGIN rpath —
OS-update-proof without a toolchain on the device — worth it once SteamOS host volume justifies
per-release artifact signing/hosting; the from-source path would stay as the dev/fallback route.
The web console is the one part that stays in the container at runtime: it's a Nitro bun
build (bun both builds and runs it — the bun-preset output uses Bun.serve with TLS,
serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does
distrobox enter pf2 -- … bun .output/server/index.mjs. bun is provisioned in the container.
Scripts
| Script | What it does |
|---|---|
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. |
git clone https://git.unom.io/unom/punktfunk ~/punktfunk
bash ~/punktfunk/scripts/steamdeck/install.sh # PIN pairing required (secure default)
bash ~/punktfunk/scripts/steamdeck/install.sh --open # trusted LAN: accept unpaired clients
bash ~/punktfunk/scripts/steamdeck/install.sh --no-web # host only, no web console
bash ~/punktfunk/scripts/steamdeck/install.sh --no-gamestream # native punktfunk/1 only, no Moonlight surface
bash ~/punktfunk/scripts/steamdeck/update.sh # after pulling new source
Note: unlike a bare serve (native-only by default), the Deck install enables --gamestream by
default so stock Moonlight clients work out of the box; --no-gamestream turns that surface off.
Env overrides: PUNKTFUNK_SRC (source dir, default ~/punktfunk), PUNKTFUNK_BOX (container name,
default pf2), PUNKTFUNK_MGMT_PORT (47990), PUNKTFUNK_WEB_PORT (47992).
What gets installed
- Binary:
~/punktfunk/target-steamos/release/punktfunk-host(built inpf2, run natively). - Config:
~/.config/punktfunk/host.env(encoder/compositor) andweb.env(generated web login password + session secret). Trust material (cert.pem,mgmt-token,punktfunk1-paired.json) lives here too and persists across updates. - Services:
~/.config/systemd/user/punktfunk-host.service(runsserve --gamestream --mgmt-bind 0.0.0.0:47990,+ --openif chosen —--gamestreamadds the Moonlight-compat planes so the Deck's Game Mode also streams to stock Moonlight; the nativepunktfunk/1plane is always on),punktfunk-web.service,punktfunk-rebuild-check.service(post-OS-update self-heal, enabled), andpunktfunk-scripting.service(plugin runner, opt-in — enable it once you use plugins/scripts). Linger is enabled so they run without a login session. - Plugin runner: the deb's payload laid out user-scoped (read-only
/usrcan't take the package): wrapper~/.local/bin/punktfunk-scripting, pinnedbunin~/.local/lib/punktfunk-scripting/, bundle in~/.local/share/punktfunk-scripting/. - HDR gamescope:
~/.local/bin/punktfunk-gamescope(gamescope + thepipewire-hdrpatches, built inpf2, run natively — it does not replace the system gamescope; only the sessions the host spawns use it).host.envgainsPUNKTFUNK_GAMESCOPE_BIN=pointing at it — a linebuild-gamescope.shmaintains 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=0inhost.envforces SDR. Verify withpunktfunk-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/uhidaccess),/etc/modules-load.d/punktfunk.conf(vhci-hcdfor the native Deck pad),$USERin theinputgroup — and/etc/atomic-update.conf.d/punktfunk.conf, which registers the three files on SteamOS's atomic-update keep list so A/B OS updates carry them over (verified: without it an update silently strips them — pads degrade to Xbox 360, buffers drop to 208 KB).
Operating
systemctl --user status punktfunk-host punktfunk-web
journalctl --user -u punktfunk-host -f # watch sessions / pairing PIN
systemctl --user restart punktfunk-host # after editing host.env
Pair from the web console (Devices → arm pairing) or directly from a client with the host's PIN. The
host advertises over mDNS as _punktfunk._udp, so clients discover it automatically.
Gotchas
- distrobox required. If missing:
curl -sfL https://raw.githubusercontent.com/89luca89/distrobox/main/install | sh -s -- --prefix ~/.local(then ensure~/.local/binis on PATH). - First build is slow (~10–15 min + ~1 GB toolchain/image). Incremental afterwards.
- No passwordless sudo → the installer skips the sysctl/udev/input steps with a warning; high
bitrates will drop packets until you apply
99-punktfunk-net.confand joininputyourself. - Game Mode auto-suspend drops the host off the network on idle — disable it (Settings → Power) for a headless host.
- WiFi tx ceiling ≈ 250 Mbps goodput (a Deck hardware/driver packet-rate limit, band-independent); fine for 1080p/1440p60. A wired dock lifts it.
- After a SteamOS update nothing should be needed: the
/etctuning survives via the atomic-update keep list, andpunktfunk-rebuild-checkrebuilds the binary automatically if the new base actually broke its library links (first session start after the update takes the build's few minutes in that case). A manualupdate.shremains harmless.