Files
nixfiles/docs/hosts/console.md
T
Emma ThorpeandClaude Opus 5 4e3347cb8c fix(console): make a failed Steam session diagnosable, add shortcut tooling
Autologin into the gamescope Steam session does not take on the real
hardware; the television lands on ReGreet instead. The config side is
correct -- initial_session is present in the generated greetd.toml and
services.greetd.restart evaluates to false -- so the likely cause is the
session starting and then dying, which greetd answers by falling through
to default_session.

greetd hands a session the VT for its standard streams, so whatever the
session prints on its way down is erased as soon as the greeter repaints.
Run the launcher under systemd-cat so the reason survives in the journal
under the steam-session identifier.

Steam's non-Steam-game Browse dialog and its library-folder picker both
go through the XDG portal FileChooser interface and do not open here
(ValveSoftware/steam-for-linux#9447, #10415). Add steamtinkerlaunch,
which writes shortcuts.vdf without the dialog, and document
library_folder_add as the equivalent route for the library folder.

Docs also catch up with the host's actual state: hardware-configuration
.nix is a real scan now, standalone pcsx2 is installed, and the untested
claims become the list of open problems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:32:24 +01:00

20 KiB

Console — living-room games machine

Flake host: lyrathorpe-console. Desktop (portable = false, imports ../../modules/desktop.nix). Files: configuration.nix, nvidia.nix, gaming.nix, hardware-configuration.nix.

A 4th-generation Core i7 (Haswell) on a UEFI board with an NVIDIA GeForce GTX 1070 8 GB, wired to a television. It boots straight into Steam Big Picture and is driven from the sofa with a Bluetooth controller; keyboard and mouse are supported but secondary.

Disk

hardware-configuration.nix is a real nixos-generate-config scan from the installed machine: GPT with a vfat ESP at /boot and an ext4 root, both addressed by UUID, and no swap device. Nothing else in the host config depends on the disk layout.

Bootloader

Ordinary PC UEFI firmware, so systemd-boot with canTouchEfiVariables = true — unlike the Mac Pro, this board is trusted with efibootmgr NVRAM writes.

boot.loader.timeout = 0: the boot menu is unreadable from a sofa and unusable without a keyboard, so the default entry boots immediately. Hold space at power-on to get the menu back and pick an older generation. configurationLimit = 10 stops the ESP filling up.

Graphics — GTX 1070

Everything driver-related is in nvidia.nix. The GTX 1070 is Pascal (GP104), so it is under the same driver constraint as the Mac Pro's Quadro P400:

  • Driver branch 580 (nvidiaPackages.legacy_580), not the nixpkgs default (production, currently 595.x). 580 is the last branch supporting Maxwell/Pascal/Volta, maintained as an LTS branch until Aug 2028; a newer branch does not drive this card at all.
  • modesetting.enable = true — mandatory for Wayland. Without nvidia-drm.modeset=1 neither gamescope nor wlroots gets a usable GBM device, and both the Steam session and Sway fail to start.
  • open = false — the open kernel modules require Turing or later.
  • Sway runs with --unsupported-gpu; wlroots refuses the proprietary driver otherwise. gamescope and cage/ReGreet need no such flag.
  • hardware.graphics.enable32Bit pulls in the lib32 NVIDIA userspace that 32-bit Steam titles and Proton's 32-bit prefixes link against.

The driver is unfree, so it is not in the binary cache: the kernel module is compiled on the machine. On a Haswell i7 that is a few minutes, not the Mac Pro's ordeal, but it recurs on every kernel bump. The package names are allowlisted in unfreePackages in flake.nix.

Verify after a rebuild:

nvidia-smi
lsmod | grep nvidia    # nvidia, nvidia_modeset, nvidia_drm

Session model — autologin into Steam

gaming.nix sets programs.steam.gamescopeSession.enable, which registers a steam.desktop Wayland session (gamescope wrapping Steam in tenfoot mode) and installs a steam-gamescope launcher. greetd — already present on every Sway host for ReGreet, see ../../modules/sway.nix — gets an initial_session pointing at that launcher:

boot
 └─ greetd initial_session (autologin as lyrathorpe)
     └─ gamescope --steam -- steam -tenfoot -pipewire-dmabuf
         ├─ Steam library / Proton titles
         ├─ [non-Steam] RetroArch
         └─ [non-Steam] Clone Hero

quit Steam → greetd default_session → ReGreet → pick Sway (keyboard + mouse)

So there is no greeter at boot and no keyboard needed. Quitting Steam drops back to ReGreet, where the ordinary Sway session is available for everything else. services.greetd.restart defaults to false once initial_session is set, which is what stops a logout looping straight back into autologin.

Three details worth knowing:

  • The launcher is referenced as /run/current-system/sw/bin/steam-gamescope. The steam module builds that script privately and only adds it to environment.systemPackages, so there is no package attribute to point at.
  • programs.gamescope.capSysNice = true installs gamescope as a setcap wrapper in /run/wrappers/bin instead of the system profile. That path precedes the system profile on PATH, so steam-gamescope still resolves it.
  • greetd gives the session the VT for its standard streams, so anything the Steam session prints as it dies is erased the moment the greeter repaints. initial_session therefore runs the launcher under systemd-cat, and the output is readable after the fact with journalctl -b -t steam-session.

When the television lands on ReGreet instead of Steam

greetd starts initial_session once per boot — the check is the presence of /run/greetd.run — and if the session process starts and then exits, greetd falls through to default_session, i.e. ReGreet. A gamescope that dies on startup is therefore indistinguishable from autologin never having run, unless the logs are read:

journalctl -b -t steam-session   # what the session itself said
journalctl -b -u greetd          # whether greetd started it at all
ls -l /run/greetd.run            # exists => greetd has already had its one go

If greetd never even reaches the session, it prints unable to start greeter and exits — there is no greeter to fall back to and the screen stays blank. Recover over SSH (modules/ssh.nix), or hold space at power-on and boot the previous generation.

To reproduce a session failure by hand, switch to a free VT and run steam-gamescope there. Running it from inside Sway is not the same test: gamescope picks its nested Wayland backend when WAYLAND_DISPLAY is set, and it is the DRM backend — the one the boot session uses — that is in question.

The greeter is Dvorak, like every other host here (modules/sway.nix forces XKB_DEFAULT_VARIANT=dvorak on cage). Only relevant if someone else has to type a password.

Adding RetroArch, PCSX2 and Clone Hero to Big Picture

All three are installed system-wide but are not Steam titles. Added as Steam shortcuts they appear in Big Picture and inherit Steam Input, so the controller works in them without further configuration.

Steam's own Games → Add a Non-Steam Game is the documented route and is also the one that tends not to work. Both that dialog's Browse button and the library-folder picker below go through the XDG desktop portal FileChooser interface; when the portal is unreachable the client silently falls back to a legacy chooser that is blank or inert — Valve #9447 and #10415, neither fixed. Check the portal side before blaming the client:

busctl --user introspect org.freedesktop.portal.Desktop \
  /org/freedesktop/portal/desktop | grep FileChooser

modules/sway.nix enables xdg-desktop-portal-gtk, which is the backend that implements FileChooser (xdg-desktop-portal-wlr implements only screenshot and screencast), and the generated sway-portals.conf routes everything but those two to gtk. If the interface is missing from the introspection above, the backend is not running and that is the fault to chase.

Independent of the dialog, steamtinkerlaunch writes shortcuts.vdf directly:

# Steam must be closed -- it rewrites shortcuts.vdf on exit.
steamtinkerlaunch addnonsteamgame -an="RetroArch"  -ep="$(command -v retroarch)"
steamtinkerlaunch addnonsteamgame -an="PCSX2"      -ep="$(command -v pcsx2-qt)"
steamtinkerlaunch addnonsteamgame -an="Clone Hero" -ep="$(command -v clonehero)"

Emulation — RetroArch

gaming.nix builds RetroArch through pkgs.retroarch-bare.wrapper, which wires up the packaged assets, core info and joypad autoconfig profiles. Cores:

System Core
NES nestopia
SNES snes9x
Game Boy / Color gambatte
Game Boy Advance mgba
Nintendo 64 mupen64plus
Nintendo DS melonds
GameCube / Wii dolphin
Master System / Game Gear / Mega Drive genesis-plus-gx
32X / Mega CD picodrive
Saturn beetle-saturn
Dreamcast / NAOMI flycast
PlayStation beetle-psx-hw
PlayStation 2 pcsx2 (LRPS2)
PSP ppsspp
Arcade fbneo, mame2003-plus
DOS dosbox-pure

A handful of settings are applied on every launch via --appendconfig, so they are fixed policy rather than saved preferences — changing them in the UI will not stick. Everything else stays user-editable as normal.

  • menu_driver = ozone — the controller-navigable menu.
  • video_fullscreen = true.
  • input_menu_toggle_gamepad_combo = 2L3+R3 opens the RetroArch menu from inside a running core. Without a pad combo there is no way to exit a game without a keyboard. No retro system emulated here has L3/R3 on its own controller, so the binding cannot collide with a game.
  • system_directory, savefile_directory, savestate_directory, playlist_directory, screenshot_directory, thumbnails_directory and rgui_browser_directory — all pointed at the shared library described below, rather than scattered through ~/.config/retroarch.

An unrecognised key in an appended config is ignored silently, so those key names are worth keeping in step with upstream if RetroArch is ever bumped across a major version.

BIOS files and expectations

Several cores need BIOS or firmware images that are not redistributable and are therefore not packaged. Drop them in /srv/games/bios, which is RetroArch's system directory on this host:

  • PlayStation 2 (pcsx2) — a PS2 BIOS dump. The core will not boot anything without one.
  • Saturn (beetle-saturn) — region BIOS images.
  • Dreamcast (flycast) — dc_boot.bin / dc_flash.bin for most titles.
  • Nintendo DS (melonds) — optional, but DSi mode and some titles want the real BIOS/firmware.

Be honest about the two heaviest cores. dolphin and pcsx2 are libretro ports of emulators whose upstream effort goes into their standalone builds; the cores lag on compatibility and are the first place to look when a GameCube, Wii or PS2 title misbehaves.

That has already happened for PS2: the LRPS2 core was not good enough in practice, so standalone pkgs.pcsx2 is installed in gaming.nix alongside the core and is what PS2 titles should be run through. pkgs.dolphin-emu is not installed; add it the same way if GameCube or Wii turns out to need it.

Both are controller-driven and can be added to Big Picture the same way as RetroArch. The hardware is not the limit here — a GTX 1070 and a Haswell i7 run PS2 and Wii comfortably.

Five cores (snes9x, genesis-plus-gx, picodrive, fbneo, mame2003-plus) carry upstream licences with non-commercial or no-redistribution-for-profit clauses, so their derivation names are in unfreePackages in flake.nix. Nothing else in the core set needs it.

Games library layout

Content lives under /srv/games, deliberately outside any home directory: it is bulky, it is the thing most likely to move to its own disk, and it is shared between Steam, RetroArch and Clone Hero rather than owned by one of them. Mounting a second drive at /srv/games is the only change that move needs.

gaming.nix creates the tree with systemd.tmpfiles.rules at every boot:

/srv/games/
├── roms/                 # RetroArch content browser opens here
│   ├── nes/  snes/  gb/  gbc/  gba/  n64/  nds/  gc/  wii/
│   ├── mastersystem/  gamegear/  megadrive/  sega32x/  segacd/
│   ├── saturn/  dreamcast/
│   ├── psx/  ps2/  psp/
│   └── arcade/  dos/
├── bios/                 # RetroArch system dir: BIOS and firmware
├── saves/                # in-game saves
├── states/               # save states
├── playlists/
├── screenshots/
├── thumbnails/
├── steam/                # second Steam library folder
└── clonehero/
    ├── songs/
    └── backgrounds/

Directory names under roms/ follow the libretro/ES-DE convention, so a scraper or a second frontend recognises them without anything being renamed.

Everything is lyrathorpe:users mode 2775. The setgid bit matters: the owning group is carried onto anything created inside, so a second account — or an rsync from another machine — does not leave behind files the TV user cannot write. The rules create directories if missing and otherwise leave them alone; nothing here removes or rewrites content.

RetroArch is pointed at these paths declaratively. The other two have to be told once, in their own UIs:

  • Steam — Settings → Storage → the + control → add /srv/games/steam as a library folder. Games installed there survive a reinstall of the OS. That picker is the broken one described under Adding RetroArch, PCSX2 and Clone Hero to Big Picture; when it comes up blank, use Steam's own console instead — open Steam with steam -console, pick the CONSOLE tab and run:

    library_folder_add /srv/games/steam
    

    The directory must already exist and be writable by the user Steam runs as, which systemd.tmpfiles guarantees. The command does not create it.

  • Clone Hero — set the song library path to /srv/games/clonehero/songs from its settings screen. Clone Hero keeps its own config in ~/.clonehero.

Steam and Proton

programs.steam.enable already arranges most of what Proton needs, and it is worth recording so it is not re-litigated:

  • hardware.graphics with enable32Bit — the 32-bit GL/Vulkan userspace Proton's 32-bit prefixes link against.
  • Steam's udev rules (hardware.steam-hardware.enable) — controller and hidraw access, which is also what lets dualsensectl talk to a DualSense.
  • 32-bit PipeWire ALSA (services.pipewire.alsa.support32Bit), derived from alsa.enable, which gaming.nix turns on for the older native titles that talk to ALSA directly rather than through the Pulse shim.
  • Wine's fonts — Liberation, DejaVu, FreeFont and the Noto set arrive with fonts.enableDefaultPackages plus modules/common-nixos.nix. Liberation is metric-compatible with the Microsoft core fonts, so text lays out correctly without shipping unfree corefonts.
  • vm.max_map_count is 1048576 in the nixpkgs default sysctls, above what DX12 and Unreal titles need. No override required — this is the one people usually copy from Arch wiki posts and it is already handled.

What is not covered by default, and is set explicitly in gaming.nix:

  • systemd.settings.Manager.DefaultLimitNOFILE = "1024:1048576". esync opens one eventfd per Wine synchronisation object and runs out against systemd's default 524288 hard limit in the heaviest titles. Only the hard limit is raised; the soft limit stays at 1024, because lifting that breaks select()-based programs elsewhere on the system.
  • extraCompatPackages = [ pkgs.proton-ge-bin ]. The module puts its steamcompattool output on STEAM_EXTRA_COMPAT_TOOLS_PATHS, which is what makes GE-Proton appear in the client's compatibility list.
  • protontricks.enable — winetricks against a Proton prefix, the standard repair when a title needs a runtime (dotnet, vcrun, Media Foundation) Proton does not ship.
  • programs.gamemode.enable — applies the performance CPU governor around games that request it.

One thing is not declarative: Steam Play must be switched on in the client, once, per account — Settings → Compatibility → Enable Steam Play for all other titles. Nix cannot set this; it lives in Steam's own config.

Verify the Proton side after installing:

vulkaninfo --summary            # 64-bit ICD
nvidia-smi                      # driver up
ulimit -Hn                      # expect 1048576
# in a game's launch options, to confirm esync/fsync are active:
#   PROTON_LOG=1 %command%     → ~/steam-<appid>.log

Controllers

  • Xbox One / Series over Bluetoothhardware.xpadneo.enable. The out-of-tree driver; the in-kernel xpad handles these badly over Bluetooth (wrong button mapping, no rumble). The module enables bluez itself.
  • Xbox 360, wired — in-kernel xpad, autoloaded by udev on plug-in. Nothing to configure. The kernel is built with CONFIG_JOYSTICK_XPAD=m, CONFIG_JOYSTICK_XPAD_FF=y (rumble) and CONFIG_JOYSTICK_XPAD_LEDS=y. The 360 wireless PC receiver uses the same driver and works the same way.
  • DualSense / DualShock 4 — in-kernel hid-playstation, over both USB and Bluetooth. No driver config. dualsensectl is installed for LED, battery and microphone control; it works because Steam's udev rules grant hidraw access.
  • Clone Hero guitars — plain USB HID gamepads, handled in-kernel. Nothing to configure.

hardware.bluetooth.powerOnBoot is set so the adapter is up before the Steam session starts and a pad can reconnect unattended. settings.General.Experimental = true is what enables battery level reporting for Bluetooth gamepads — it is still behind bluez's experimental flag.

Pair a new controller from Big Picture (Settings → Controller), or from a Sway session with bluetoothctl. If a pad connects but no input arrives, check journalctl -b -u bluetooth and confirm hid_xpadneo is loaded (lsmod | grep xpadneo).

The Xbox One / Series USB wireless dongle is deliberately not configured. It needs hardware.xone.enable, which blacklists xpad — that would break the wired 360 pads — as well as mt76x2u, and pulls in proprietary dongle firmware. Not worth the side effects unless that dongle is actually in use, and if it ever is, the 360 pads have to be re-tested.

Open problems

The machine is installed and Sway works. Still outstanding:

  • Autologin into Steam does not happen — the television comes up on ReGreet. Not yet root-caused; the config side is correct (initial_session is present in the generated greetd.toml and services.greetd.restart evaluates to false), so the suspicion is that gamescope starts and dies on the proprietary NVIDIA driver, which greetd treats as a finished session and answers by starting the greeter. gaming.nix sets GBM_BACKEND=nvidia-drm and __GLX_VENDOR_LIBRARY_NAME=nvidia, the standard fix, but the combination has a history of needing more. Read journalctl -b -t steam-session first — see When the television lands on ReGreet instead of Steam.
  • Steam's file pickers do not open — both the non-Steam-game Browse dialog and the library-folder picker. Upstream client bugs with usable workarounds; see the two sections above.
  • Boot into Big Picture has not been exercised, since autologin has not run.
  • Television resolution and refresh. gamescope takes the output's native mode by default. Add gamescopeSession.args = [ "-W" "3840" "-H" "2160" "-r" "60" ] if a specific mode is wanted.

There is no HDMI-CEC configuration here, so the TV remote will not drive the box; that needs a Pulse-Eight adapter or a working CEC bridge on the board. Nothing boots to a splash screen either — Plymouth was left out deliberately, as early KMS with the proprietary driver makes it unreliable.

Networking

Wired NetworkManager from ../../modules/desktop.nix; modules/ssh.nix adds key-only sshd, which is the practical way to administer a machine with no keyboard attached. The firewall is default-deny (modules/workstation.nix); Steam Remote Play and local network game transfers open their own ports through programs.steam.