No description
  • Rust 69.7%
  • Shell 23.3%
  • Nix 3.9%
  • C 1.4%
  • Makefile 1%
  • Other 0.7%
Find a file
Max Fritz 9b7db0663e
All checks were successful
ci / nix-checks (push) Successful in 30s
ci / verify-scripts (push) Successful in 33s
release / arch (push) Successful in 52s
release / deb (push) Successful in 1m7s
release / publish (push) Successful in 10s
release / nix-closure (push) Successful in 33s
release: v0.5.0
Every version location in one commit: Cargo.toml (+ Cargo.lock),
packaging/arch/PKGBUILD (pkgrel back to 1), packaging/debian/changelog,
both versions in flake.nix, hollowdeck-game(1) and the README's install
line.

Highlights (full list in debian/changelog): opt-in desktop Steam as the
steam user, so Proton games work from the desktop on the shared install
(sudo rule live only while paused, revocable by commenting it out, NixOS
option allowDesktopSteamAsSteam); one desktop handoff per user;
cgroup-based foreign-Steam detection, so the guards also catch a desktop
Steam that runs as the steam user; adopt --fix-perms / doctor --fix give
back foreign-owned files with a symlink-race-free fd-relative walk;
provision no longer restarts the session for desktop-side files; NixOS
paths that survive rebuilds. Includes the persistent pause shipped
earlier as 0.4.0-2 / 0.4.0+2.

Still pairs with hollowdeck-gamescope 3.16.24-16 (no patch changes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:48:54 +01:00
.forgejo CI: fix the gamescope patch gate — it could never have passed 2026-08-15 23:19:04 +02:00
docs docs: what the clean runs established, and what they did not 2026-10-08 23:29:28 +02:00
hollowdeck-viewer build: declare the MSRV (1.85, Debian trixie) and drop a 1.87 API 2026-10-09 01:07:12 +02:00
hollowdeckd adopt: the walk-repair test skips where setgid is forbidden 2026-11-02 22:48:54 +01:00
packaging release: v0.5.0 2026-11-02 22:48:54 +01:00
patches gamescope: rebase the patch series onto 3.16.31 2026-11-02 21:29:52 +01:00
prompts stage6: spec — dual release pipelines (hollowdeck v*, gamescope-* bumps) + patch-rot canary 2026-07-10 15:48:49 +02:00
session release: v0.5.0 2026-11-02 22:48:54 +01:00
tests daemon: desktop Steam run as the steam user is a foreign Steam 2026-11-02 22:48:54 +01:00
tools/render-redirect stage6: code-review fixes — shim coverage, doctor validation gaps, shim feedback 2026-07-19 10:57:32 +02:00
.gitignore tests: run the release workflow's build jobs locally (release-local.sh) 2026-10-09 10:42:45 +02:00
Cargo.lock release: v0.5.0 2026-11-02 22:48:54 +01:00
Cargo.toml release: v0.5.0 2026-11-02 22:48:54 +01:00
CLAUDE.md adopt: fd-relative ownership repair; doctor reports ownership on its own line 2026-11-02 22:48:54 +01:00
flake.lock gamescope: rebase the patch series onto 3.16.31 2026-11-02 21:29:52 +01:00
flake.nix release: v0.5.0 2026-11-02 22:48:54 +01:00
README.md release: v0.5.0 2026-11-02 22:48:54 +01:00

hollowdeck

A headless Steam gaming host built on gamescope. Steam Big Picture runs in a gamescope --backend headless session as a dedicated steam user — no monitor, no dummy plug, no EDID hacks. You reach it two ways:

  • Locally with hollowdeck-viewer: a zero-codec, zero-copy client. Frames travel from gamescope to your desktop as PipeWire dma-bufs and never leave VRAM — no encode/decode in the local path.
  • Remotely with stock Steam Remote Play (e.g. a Steam Deck). Zero client-side configuration; the session's default 1280x800@60 output makes the stream pixel-perfect 1:1 on a Deck.

Requirements

  • Linux: Arch-based (CachyOS is the primary target), Debian, or NixOS.
  • GPU: any common one. AMD and Intel are tested; NVIDIA should work with a modern driver (535 or newer recommended) but is currently untested.
  • A Wayland desktop — only where you run the local viewer. The host package itself is fine on display-less machines (Remote Play only).

Install

All dependencies come with the packages. There are two: hollowdeck (the host — daemon + session, works on display-less machines) and hollowdeck-viewer (the local client — needs a Wayland desktop). On a normal gaming desktop install both; on a headless Remote-Play-only box, just the first.

Arch / CachyOS

git clone https://git.nixfritz.de/max/hollowdeck && cd hollowdeck
cd packaging/arch && makepkg -si        # builds+installs both packages
sudo hollowdeckd provision --grant $USER

Debian

git clone https://git.nixfritz.de/max/hollowdeck && cd hollowdeck
cp -a packaging/debian debian && dpkg-buildpackage -us -uc -b
sudo apt install ../hollowdeck_0.5.0_amd64.deb
sudo hollowdeckd provision --grant $USER

(or grab prebuilt packages from https://git.nixfritz.de/max/hollowdeck/releases)

NixOS

inputs.hollowdeck.url = "git+https://git.nixfritz.de/max/hollowdeck";

# in your configuration:
imports = [ hollowdeck.nixosModules.hollowdeck ];
services.hollowdeck = {
  enable = true;
  grantUsers = [ "you" ];
};

(fully declarative — no provision step; more options in the Development section)

On Arch/Debian, log out and back in once after the grant so the new group memberships apply to your desktop session. To update later: install the new package and re-run sudo hollowdeckd provision.

Manual pages

hollowdeckd(8), hollowdeck-viewer(1) and hollowdeck-game(1) — installed by all three packages, including the paths, environment variables and exit status that the --help output has no room for. The two binaries render their own pages from the CLI definition, so a source build gets them from provision too; without any install: hollowdeckd man | man -l -.

Shell completions

The packages install bash, zsh and fish completions for both binaries — nothing to enable, just start a new shell. A source build gets them from provision (into /usr/local/share). Anywhere else, generate them yourself:

hollowdeckd completions zsh > ~/.zfunc/_hollowdeckd   # a dir on your $fpath
hollowdeck-viewer --completions fish > ~/.config/fish/completions/hollowdeck-viewer.fish

elvish and powershell work as arguments too. One fish caveat for source builds only: fish finds /usr/local/share/fish/vendor_completions.d only when XDG_DATA_DIRS is set (a desktop session sets it; a bare TTY login may not) — the packaged /usr path is always searched.

First login

Steam's first login is interactive (once):

hollowdeck-viewer

Log in through the viewer; credentials persist in the session afterwards. hollowdeckd bootstrap prints this flow. If this machine already has Steam, skip the fresh login and adopt your existing install instead (next section).

Use your existing Steam library

Don't download your games twice. Adoption moves your desktop Steam install (games, saves, login, Proton prefixes) into the shared location and leaves a symlink at the old path:

sudo hollowdeckd adopt --from ~/.local/share/Steam
  • Same filesystem: an instant move, no extra disk. Across filesystems: a copy, with the original kept as Steam.pre-hollowdeck until you delete it.
  • Afterwards there is ONE Steam install, shared between the session and desktop Steam — but only one of them may run at a time. The Steam menu entry is replaced with a handoff launcher that pauses the session, runs desktop Steam, and resumes on exit. Manual equivalent: hollowdeckd pause / hollowdeckd resume. Pause is refused while a game, viewer, or Remote Play stream is active; resume is refused while desktop Steam is still running. A pause survives reboots: the session stays down until you run hollowdeckd resume.
  • Optional: run desktop Steam as the steam user. By default desktop Steam runs as you, so files it creates in the shared install are yours and Proton games fail (wine and Proton refuse files they do not own). To make the handoff run Steam as steam on your display and audio instead (one uid owns everything), opt in per desktop user: mkdir -p ~/.config/hollowdeck && echo steam > ~/.config/hollowdeck/desktop-steam (or set HOLLOWDECK_DESKTOP_STEAM=steam). It needs sudo, xhost and setfacl, and you must be in the gaming group; the package ships a sudoers rule for exactly one helper (hollowdeck-steam-as-steam), and the launcher grants steam access to your X server and your PulseAudio and PipeWire sockets for the duration and revokes it afterwards. Costs: no tray icon or notifications from the desktop session, and native games' out-of-tree saves (~/.config, ~/.local/share) land in the steam home. Set it back to self to return to the default. The sudo rule itself applies to every gaming member once installed (the helper refuses unless the session is paused); to revoke it, comment out the rule line in /etc/sudoers.d/hollowdeck-desktop and keep the file — a deleted file is restored by the next upgrade (NixOS: services.hollowdeck.allowDesktopSteamAsSteam = false). On NixOS, re-run hollowdeckd adopt --from <old path> once if you adopted before this was fixed, so steam.desktop points at the stable profile path.
  • If Steam updates ever leave files with wrong permissions or ownership: sudo hollowdeckd doctor --fix (or adopt --fix-perms), with desktop Steam closed — the repair refuses while it runs.

Run

hollowdeck-viewer --fullscreen
  • Ctrl+Alt+G toggles pointer capture (games get relative mouse/mouselook; starts captured — --no-capture to start released).
  • --gamepad /dev/input/eventN mirrors a physical controller into the session.
  • --stats prints fps and the zero-copy state once per second.
  • Connecting switches the session to your output's resolution; disconnecting reverts it to 1280x800 (a running game is never interrupted by this).
  • Remote Play needs nothing: pair a Steam Deck as usual.

Custom Proton builds (GE-Proton via ProtonUp-Qt etc.) install as usual — the session picks them up automatically: the daemon restarts the session once it is idle (never mid-game), and hollowdeckd status lists tools still pending.

Hybrid graphics (iGPU + dGPU)

None of this applies to single-GPU hosts — hollowdeck needs zero GPU configuration there. On hybrid desktops (integrated + discrete), GPU placement decides real behavior: which card runs the session and games, whether Remote Play hardware encode works at all (Steam's 32-bit streaming host is hardwired to the boot GPU), and how the local viewer coexists with your desktop compositor.

The full guide — recommended layout, session pinning, the render-node redirect shim for dGPU hardware encode, 32-bit VAAPI packaging pitfalls, and viewer/compositor interactions — lives in docs/hybrid-graphics.md.

On NixOS the session/game env knobs are services.hollowdeck.sessionEnvironment and services.hollowdeck.gameEnvironment.

Status & troubleshooting

sudo hollowdeckd doctor    # full health check: PASS/WARN/FAIL per subsystem
hollowdeckd status         # quick session state

doctor checks provisioning, units, gamescope's PipeWire support, input permissions, hardware video encode, and the shared install's permissions — run it first whenever anything misbehaves. status shows the session unit, processes, current resolution, and PipeWire reachability, plus paused state and pending compat tools.

  • "busy" on connect: another viewer or a Remote Play stream is active, or desktop Steam is running (one client, one Steam — by design).
  • "paused" on connect: the session was handed off to desktop Steam — quit it and run hollowdeckd resume.
  • Viewer connects but there's no video (and status shows no gamescope node): your gamescope was built without PipeWire support — it's a compile-time option (-Dpipewire=enabled). Distro packages have it; sudo hollowdeckd doctor verifies yours.
  • Video works but one CPU core burns and the viewer log warns about a memcpy fallback: zero-copy silently broke — the dma-buf format/modifier negotiation fell back to CPU copies, usually after a Mesa/driver update. The viewer logs every negotiation outcome; file an issue with that log line.
  • Remote Play pairing asks for a PIN and the host has no display: pairing shows a PIN on the connecting device and pops an entry dialog inside the host session. With a viewer, just type it; on a display-less host, over SSH: sudo hollowdeckd screenshot --show renders the session in your terminal (any truecolor terminal works) so you can confirm the dialog is up, then hollowdeckd pin 1234 types the digits into it. (A Deck logged into the same Steam account usually pairs without a PIN.)
  • Remote Play stream looks soft or eats CPU: the hardware encoder isn't active. AMD/Intel: install your VAAPI driver (libva-mesa-driver / intel-media-driver; vainfo should list an Enc entrypoint). NVIDIA: Steam uses NVENC directly, and its streaming host is a 32-bit binary — the usual missing piece is the 32-bit encode library (lib32-nvidia-utils on Arch, libnvidia-encode1 + i386 on Debian). If the streaming log says libx264, you're on the software fallback.
  • Big Picture's power menu (Shutdown/Restart) works: hollowdeck ships the SteamOS helper shims Steam calls, plus a polkit rule scoped to exactly the steam user and the poweroff/reboot actions. Revoke it any time by deleting /usr/share/polkit-1/rules.d/49-hollowdeck-power.rules. Suspend is deliberately not granted (a sleeping host vanishes from Remote Play clients); the rule file documents how to add it.
  • "Switch to Desktop" in Big Picture is not supported and currently WEDGES the session — there is no desktop session on a hollowdeck host to switch to, and no session-select shim variant we tried convinces Steam to come back on its own (known limitation, don't press it). Recover by restarting the session unit (as root: systemctl --machine steam@ --user restart hollowdeck-session) or rebooting. To use desktop Steam on the shared install, use the guarded handoff instead: hollowdeckd pause, play, hollowdeckd resume — or just launch Steam from your desktop menu (the adopt-installed launcher does pause/resume for you).
  • Session logs: journalctl -M steam@.host --user -u hollowdeck-session (as root), control daemon: ... -u hollowdeck-control.

Limitations

  • X11 desktops (i3, plain Xorg sessions) cannot run the local viewer. The viewer is a Wayland client by construction — importing gamescope's dma-buf onto a Wayland surface is what keeps the local path zero-copy — so under X11 it exits immediately with connecting to the Wayland compositor (is WAYLAND_DISPLAY set?). The host session is unaffected: gamescope --backend headless creates its own Wayland display and Xwayland and never touches your desktop's display server, so an X11 machine still works as a Remote Play host — you just have no local window. Local viewing there means running the viewer inside a nested Wayland compositor (cage, or sway with WLR_BACKENDS=x11), which adds a composite pass and gives up direct scanout.
  • The first login is interactive, but does not need a local desktop: sudo hollowdeckd screenshot --show renders Steam's login QR code in the terminal, and scanning it with the Steam mobile app completes the login over SSH. The viewer just makes it more comfortable.
  • One client at a time (viewer or Remote Play) — Steam was never meant to run multiple games at once.
  • Native Linux games that save outside the Steam directory keep their saves per-user; adoption does not migrate or unify those (Steam Cloud usually covers them). Proton saves live inside the install and move with it.
  • Flatpak Steam / ProtonUp-Qt need flatpak override --filesystem=/var/lib/hollowdeck/steam.

Development

Everything below is for working on hollowdeck itself. CLAUDE.md is the authoritative spec; prompts/stage-*.md define the stages and tests/stage-N-verify.sh is each stage's definition of done (scripts that need the real GPU host detect CI and print SKIP with a manual checklist).

Architecture

systemd user service (user: steam, linger enabled)
  └── gamescope --backend headless  (default output: 1280x800 @ 60)
        └── steam -gamepadui  (Big Picture)
        ├── PipeWire screencast node (dma-buf, zero-copy)  ← hollowdeck-viewer (video)
        ├── PipeWire virtual sink (audio)                   ← hollowdeck-viewer (audio)
        ├── input injection (gamepad: uinput;               ← hollowdeck-viewer (input)
        │                    kbd/mouse: XTest → Xwayland)
        └── Steam Remote Play (built-in, untouched)         ← Steam Deck / remote clients

Two binaries, both Rust:

  • hollowdeckd — host side. One-shot root provision/adopt, then an unprivileged control daemon running as the steam user (control socket, resolution lifecycle, pause/resume handoff, compat-tool auto-apply).
  • hollowdeck-viewer — local client. PipeWire dma-buf video, session audio playback, input injection, session-cursor overlay.

Design decisions (short version — CLAUDE.md is authoritative)

  • Headless, no DRM connector. The only local access path is the viewer.
  • 1280x800@60 default so Remote Play to a Steam Deck is 1:1 out of the box.
  • Zero-copy local video. PipeWire screencast with dma-buf sharing; a memcpy fallback is treated as a bug, not a mode.
  • Input: virtual gamepad via uinput (games read gamepads directly, so this is safe). Keyboard/mouse via XTest into the session's Xwayland — spike- verified (raw uinput is dead headless AND echoes into the desktop; XTest reaches pointer-lock games through Xwayland's XTEST→libei bridge).
  • Resolution lifecycle: viewer connects → session switches to the viewer's native resolution; disconnect → back to 1280x800. Two mechanisms: with the patched gamescope (patches/, adds gamescopectl headless_set_mode) the output resizes LIVE — Steam and all streams survive; otherwise a clean session restart (graceful: Steam exits before gamescope). The daemon probes for the command and picks automatically; the restart path is supported forever. To run the patched gamescope: NixOS services.hollowdeck.gamescopePackage = hollowdeck.packages.….gamescope-hollowdeck; Arch: packaging/arch/gamescope/ builds hollowdeck-gamescope, a SESSION-SCOPED install under /usr/lib/hollowdeck/gamescope — it never replaces or conflicts with the system gamescope; the session wrapper prefers it when present and falls back otherwise. Keeping it in sync: the package pins a validated tag on purpose (the system gamescope updates freely); bump the pkgver when hollowdeck revalidates, and the nix gamescope-hollowdeck build doubles as the early-warning canary — it tracks nixpkgs' gamescope, so nix flake check fails the moment the patch stops applying to a newer upstream. Long-term exit: upstream the patch to gamescope, after which all of this dissolves.
  • Single client, exclusive access. One viewer at a time; connects are refused while a Remote Play stream is active; a running game is never killed.
  • Access model: membership in the gaming group authorizes local access (control socket and shared PipeWire socket are steam:gaming 0660).
  • Audio: the steam user's PipeWire owns no ALSA hardware; it gets a virtual sink. Remote Play captures the sink; the viewer plays its monitor stream out through your desktop's audio stack.
  • Steam data: ONE shared install under the steam user's home, adopted from the desktop install, desktop Steam via pause/resume handoff — never two clients on one install (that corrupts manifests and Proton prefixes).
  • Remote path is stock Steam Remote Play. No Sunshine/Moonlight; host-side we only make sure VAAPI hardware encode is actually in use.

Status

Pre-alpha, all stages implemented. Verified on real hardware end to end: zero-copy video, audio, full input with pointer capture, resolution lifecycle, exclusivity, Steam-library adoption with desktop handoff, and compat-tool auto-apply. Open hardware validations: the 4a live-resize patch at runtime, and the stage-6 hardened units (bisect notes are in the unit files).

Stage Content State
0 Scaffold: workspace, devShell, packaging stubs, verify script done
1 Provisioning + headless gamescope session under systemd done (verified on hardware)
1.5 Spike: input path go/no-go, cross-user PipeWire verification done — DECISION: xtest (docs/input-path.md)
2 Viewer: zero-copy video + session audio done (60 fps dmabuf zero-copy verified on hardware)
3 Viewer: input injection (gamepad uinput; kbd/mouse XTest) done (verified on hardware)
4 Resolution lifecycle; Steam data adoption + handoff (4c) done — 4b+4c verified on hardware; 4a patch builds, runtime verify pending
5 Packaging: NixOS module, PKGBUILD, .deb done (nix build + module eval verified; arch/deb build via makepkg/container)
6 Hardening, docs, doctor, CI, release done (hardened units pending hardware validation)

NixOS

{
  inputs.hollowdeck.url = "git+https://git.nixfritz.de/max/hollowdeck"; # or a local path

  # in your configuration:
  imports = [ hollowdeck.nixosModules.hollowdeck ];
  services.hollowdeck = {
    enable = true;
    grantUsers = [ "alice" ];        # local viewer access
    defaultResolution = "1280x800@60";
    openFirewall = true;             # Steam Remote Play ports
    # gamescopePackage = hollowdeck.packages.x86_64-linux.gamescope-hollowdeck;
  };
}

Everything provision does on other distros is declarative here (steam user, linger, udev, PipeWire drop-ins via tmpfiles symlinks, ConditionUser-scoped user units) — hollowdeckd provision is not needed on NixOS.

CI & releases

Forgejo Actions (.forgejo/workflows/, needs a runner with the docker label and a GITHUB_TOKEN-capable Forgejo — no extra secrets):

  • ci.yml — every push/PR: cargo build/test/clippy, stage 0/5/6 verify scripts (packaging targets SKIP in the container — expected), nix build + flake check.
  • release.yml — v* tags: gates the tag against Cargo.toml and the PKGBUILD version, builds both split packages as .pkg.tar.zst and .deb plus a nix closure (nix-store --importable), attaches everything to the release.
  • gamescope-release.yml — gamescope-<upstream> tags: verifies patches/*.patch still apply against that upstream tag (fail fast = rebase needed), builds the session-scoped hollowdeck-gamescope package, publishes it. Bump flow: edit pkgver in packaging/arch/gamescope/PKGBUILD, validate the live resize on hardware, push the tag.
  • canary.yml — weekly: builds .#gamescope-hollowdeck against current nixpkgs (--override-input), so patch rot is detected the week upstream moves, not when a user builds. Opens an issue on failure (deduplicated).

Building for development

nix develop          # toolchain + pipewire/wayland/vulkan deps
cargo build --workspace
./tests/stage-4-verify.sh   # (as root on the target host)
./tests/stage-5-verify.sh   # packaging (nix/makepkg/containers, as user)
./tests/stage-6-verify.sh   # hardening, doctor, README, CI files
./tests/release-local.sh    # the release WORKFLOW's build jobs via act (as user; never publishes)

Local-viewer performance is profiled per stage (hollowdeck-viewer --profile), benched with tests/profile-viewer.sh, and drilled into with GPU/CPU/timeline tools — the methodology (and how to tell whether the session, the viewer, or the desktop compositor is the bottleneck) is in docs/profiling.md.

Shaders are their own problem: a game that recompiles its pipelines on every launch is burning more CPU than anything hollowdeck does, and the cache that is supposed to prevent it belongs to the GPU driver, not to Steam. Which cache, where it lives under the steam user, how to find out whether the game or Steam's background precache is the one burning the CPU, and the environment that fixes it (in session-env, not game-env) are in docs/shader-cache.md; tests/shader-cache-watch.sh is the measurement.

Package-less install from a source build (what the packages automate): cargo build --release --workspace, then sudo ./target/release/hollowdeckd provision --grant $USER — provision detects the non-packaged layout and installs binaries, units, wrapper scripts, udev/sysusers/tmpfiles itself (to /usr/local + /etc). Idempotent; re-run after every rebuild. Runtime dependencies you must provide yourself in this mode: gamescope (with PipeWire support — Arch's build has it), steam, pipewire, wireplumber, xorg-xhost, acl, rsync (adopt across filesystems), and Remote Play hardware encode libraries: VAAPI for AMD/Intel (libva-mesa-driver / intel-media-driver; Debian va-driver-all; NixOS hardware.graphics.extraPackages), 32-bit NVENC libs for NVIDIA (lib32-nvidia-utils).

Note for non-NixOS hosts: nix-toolchain binaries link nix's glibc, so running GUI binaries (the viewer) works either inside nix develop (the shell exports nix's own Mesa/EGL runtime) or by building with the distro toolchain (pacman -S rust, then plain cargo build). A nix-built viewer run from a plain host shell fails to dlopen libEGL — that's expected, not a bug.