- Rust 69.7%
- Shell 23.3%
- Nix 3.9%
- C 1.4%
- Makefile 1%
- Other 0.7%
|
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
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> |
||
|---|---|---|
| .forgejo | ||
| docs | ||
| hollowdeck-viewer | ||
| hollowdeckd | ||
| packaging | ||
| patches | ||
| prompts | ||
| session | ||
| tests | ||
| tools/render-redirect | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
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-hollowdeckuntil 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 runhollowdeckd resume. - Optional: run desktop Steam as the
steamuser. 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 assteamon 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 setHOLLOWDECK_DESKTOP_STEAM=steam). It needssudo,xhostandsetfacl, and you must be in thegaminggroup; the package ships a sudoers rule for exactly one helper (hollowdeck-steam-as-steam), and the launcher grantssteamaccess 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 thesteamhome. Set it back toselfto return to the default. The sudo rule itself applies to everygamingmember once installed (the helper refuses unless the session is paused); to revoke it, comment out the rule line in/etc/sudoers.d/hollowdeck-desktopand keep the file — a deleted file is restored by the next upgrade (NixOS:services.hollowdeck.allowDesktopSteamAsSteam = false). On NixOS, re-runhollowdeckd adopt --from <old path>once if you adopted before this was fixed, sosteam.desktoppoints at the stable profile path. - If Steam updates ever leave files with wrong permissions or ownership:
sudo hollowdeckd doctor --fix(oradopt --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-captureto start released). --gamepad /dev/input/eventNmirrors a physical controller into the session.--statsprints 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
statusshows no gamescope node): your gamescope was built without PipeWire support — it's a compile-time option (-Dpipewire=enabled). Distro packages have it;sudo hollowdeckd doctorverifies 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 --showrenders the session in your terminal (any truecolor terminal works) so you can confirm the dialog is up, thenhollowdeckd pin 1234types 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;vainfoshould list anEncentrypoint). 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-utilson Arch,libnvidia-encode1+ i386 on Debian). If the streaming log sayslibx264, 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 headlesscreates 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, orswaywithWLR_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 --showrenders 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/, addsgamescopectl 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: NixOSservices.hollowdeck.gamescopePackage = hollowdeck.packages.….gamescope-hollowdeck; Arch:packaging/arch/gamescope/buildshollowdeck-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 nixgamescope-hollowdeckbuild doubles as the early-warning canary — it tracks nixpkgs' gamescope, sonix flake checkfails 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
gaminggroup 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 againstCargo.tomland the PKGBUILD version, builds both split packages as.pkg.tar.zstand.debplus a nix closure (nix-store --importable), attaches everything to the release. - gamescope-release.yml —
gamescope-<upstream>tags: verifiespatches/*.patchstill apply against that upstream tag (fail fast = rebase needed), builds the session-scopedhollowdeck-gamescopepackage, publishes it. Bump flow: editpkgverinpackaging/arch/gamescope/PKGBUILD, validate the live resize on hardware, push the tag. - canary.yml — weekly: builds
.#gamescope-hollowdeckagainst 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.