# 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`](https://code.emmathe.dev/lyrathorpe/nixfiles/src/branch/main/hosts/Console/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`](https://code.emmathe.dev/lyrathorpe/nixfiles/src/branch/main/flake.nix).

Verify after a rebuild:

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

## Session model — autologin into Steam

[`gaming.nix`](https://code.emmathe.dev/lyrathorpe/nixfiles/src/branch/main/hosts/Console/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`](https://code.emmathe.dev/lyrathorpe/nixfiles/src/branch/main/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:

```sh
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](https://github.com/ValveSoftware/steam-for-linux/issues/9447) and
[#10415](https://github.com/ValveSoftware/steam-for-linux/issues/10415), neither
fixed. Check the portal side before blaming the client:

```sh
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:

```sh
# 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 = 2` — **L3+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](#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:

```sh
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 Bluetooth** — `hardware.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](#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`.
