Merge pull request 'feat(hosts): add lyrathorpe-console, a living-room games machine' (#99) from feat/console-host into main
CI / flake (push) Successful in 4m54s

Reviewed-on: #99
This commit was merged in pull request #99.
This commit is contained in:
2026-08-29 19:23:01 +01:00
7 changed files with 781 additions and 10 deletions
+36
View File
@@ -0,0 +1,36 @@
# Living-room games machine: 4th-gen Core i7 (Haswell) on a UEFI board, wired to
# a television and driven from the sofa with a Bluetooth controller. Desktop host
# -- shared graphical/wired options live in ../../modules/desktop.nix; only
# host-specific settings are here. The games stack (Steam session, RetroArch,
# controllers) is in ./gaming.nix and the GPU in ./nvidia.nix. Install notes:
# see ../../docs/hosts/console.md.
{ ... }:
{
imports = [
./hardware-configuration.nix
./nvidia.nix
./gaming.nix
];
# Haswell: AVX2/FMA/BMI2, i.e. x86-64-v3. Above the fleet default (2), so this
# only records the fact -- no feature flag currently keys off level 3.
features.cpu.microarchLevel = 3;
# Ordinary PC UEFI firmware: systemd-boot, and NVRAM writes are safe here
# (unlike the Mac Pro's Apple EFI, which cannot be trusted with efibootmgr).
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
# The boot menu is unreadable from a sofa and unusable without a keyboard.
# Boot the default immediately; hold space at power-on to get the menu back.
boot.loader.timeout = 0;
# Bound the entry list so the ESP does not fill up with old generations.
boot.loader.systemd-boot.configurationLimit = 10;
networking.hostName = "Console-NixOS";
hardware.cpu.intel.updateMicrocode = true;
# See `man configuration.nix` / the stateVersion docs before changing.
system.stateVersion = "26.05";
}
+247
View File
@@ -0,0 +1,247 @@
# The games stack for the living-room machine: the Steam session that the TV
# boots into, RetroArch with its cores, Clone Hero, and the controller plumbing.
#
# Session model. greetd (from ../../modules/sway.nix, which enables it for
# ReGreet) gets an `initial_session`, so the machine autologins into the
# gamescope Steam session at boot -- no keyboard, no greeter, straight to Big
# Picture. Quitting Steam drops back to greetd's `default_session`, i.e. ReGreet,
# where the ordinary Sway session can be picked for keyboard-and-mouse work.
{ pkgs, ... }:
let
# The account the television autologins as. Must match the user declared for
# this host in the flake host table.
tvUser = "lyrathorpe";
# Games library root. Deliberately outside any home directory: content is
# bulky, is the thing most likely to move to its own disk, and is shared
# between Steam, RetroArch and Clone Hero rather than owned by one of them.
# Mounting a second drive at this path is the only change that needs.
gamesRoot = "/srv/games";
# One ROM directory per emulated system. Names follow the libretro/ES-DE
# convention so a scraper or a second frontend recognises them without
# renaming anything.
romSystems = [
"nes"
"snes"
"gb"
"gbc"
"gba"
"n64"
"nds"
"gc"
"wii"
"mastersystem"
"gamegear"
"megadrive"
"sega32x"
"segacd"
"saturn"
"dreamcast"
"psx"
"ps2"
"psp"
"arcade"
"dos"
];
# Everything under the root that is not a ROM directory. RetroArch is pointed
# at these below; Steam and Clone Hero have to be told about theirs in their
# own UIs (see docs/hosts/console.md).
libraryDirs = [
"bios" # RetroArch system directory: BIOS and firmware images
"saves" # in-game saves
"states" # save states
"playlists"
"screenshots"
"thumbnails"
"steam" # add as a Steam library folder from the client
"clonehero/songs"
"clonehero/backgrounds"
];
# RetroArch and the cores this machine is expected to run. The wrapper already
# points RetroArch at the packaged assets, core info and joypad autoconfig
# profiles; `settings` here is merged on top of those.
retroarch = pkgs.retroarch-bare.wrapper {
cores = with pkgs.libretro; [
# Nintendo
nestopia # NES
snes9x # SNES
gambatte # Game Boy / Color
mgba # Game Boy Advance
mupen64plus # Nintendo 64
melonds # Nintendo DS
dolphin # GameCube / Wii
# Sega
genesis-plus-gx # Master System / Game Gear / Mega Drive
picodrive # 32X / Mega CD
beetle-saturn # Saturn
flycast # Dreamcast / NAOMI
# Sony
beetle-psx-hw # PlayStation, hardware renderer
pcsx2 # PlayStation 2 (LRPS2); needs a PS2 BIOS in RetroArch's system dir
ppsspp # PSP
# Arcade and PC
fbneo
mame2003-plus
dosbox-pure
];
settings = {
# Applied on every launch via --appendconfig, so these three are fixed
# policy rather than saved preferences: changing them in the UI will not
# stick. Everything else stays user-editable as usual.
#
# Ozone is the controller-navigable menu; the TV has no keyboard.
menu_driver = "ozone";
video_fullscreen = "true";
# L3+R3 opens the RetroArch menu from inside a running core
# (INPUT_COMBO_L3_R3). Without a pad combo there is no way to exit a game
# without a keyboard, and no retro system this box emulates has L3/R3 on
# its own controller, so the binding cannot collide with a game.
input_menu_toggle_gamepad_combo = "2";
# Point RetroArch at the shared library instead of scattering content and
# state through ~/.config/retroarch. Key names are RetroArch's own; an
# unrecognised key in an appended config is ignored silently, so they are
# worth keeping in step with upstream.
system_directory = "${gamesRoot}/bios";
savefile_directory = "${gamesRoot}/saves";
savestate_directory = "${gamesRoot}/states";
playlist_directory = "${gamesRoot}/playlists";
screenshot_directory = "${gamesRoot}/screenshots";
thumbnails_directory = "${gamesRoot}/thumbnails";
# Where the content browser opens, so loading a game is a couple of
# D-pad presses rather than a walk up from the filesystem root.
rgui_browser_directory = "${gamesRoot}/roms";
};
};
in
{
programs.steam = {
enable = true;
# Registers the "Steam" wayland session (gamescope wrapping Steam in tenfoot
# mode) with the display manager and installs the steam-gamescope launcher.
gamescopeSession.enable = true;
gamescopeSession.env = {
# gamescope has to be pointed at NVIDIA's GBM implementation and GLX
# vendor explicitly; on the proprietary driver it otherwise fails to get a
# usable device and the session dies at startup.
GBM_BACKEND = "nvidia-drm";
__GLX_VENDOR_LIBRARY_NAME = "nvidia";
};
# Remote Play and local network game transfers are the point of a TV box on
# the same LAN as a desktop; both need their ports open.
remotePlay.openFirewall = true;
localNetworkGameTransfers.openFirewall = true;
# Proton-GE, selectable per title in Steam's compatibility settings. Covers
# the titles where Valve's Proton lags on codecs and anti-cheat shims. The
# module puts its steamcompattool output on STEAM_EXTRA_COMPAT_TOOLS_PATHS,
# which is what makes it appear in the client's Proton version list.
extraCompatPackages = [ pkgs.proton-ge-bin ];
# Winetricks against a Proton prefix: the standard repair tool when a title
# needs a runtime (dotnet, vcrun, Media Foundation) that Proton does not ship.
protontricks.enable = true;
};
# Proton prerequisites beyond what programs.steam already arranges.
#
# Already covered by the steam module, recorded here so it is not re-litigated:
# hardware.graphics 32-bit (the lib32 NVIDIA userspace Proton's 32-bit prefixes
# need), Steam's udev rules, 32-bit PipeWire, and the system fonts Wine renders
# with (Liberation and DejaVu arrive with fonts.enableDefaultPackages).
# vm.max_map_count is 1048576 in the nixpkgs default sysctls, which is above
# what DX12/Unreal titles need -- no override required.
#
# What is not covered: esync opens one eventfd per Wine sync object and runs
# out against systemd's default 524288 hard limit in the heaviest titles.
# Raise the hard limit only; the soft limit stays at the default, because
# lifting that breaks select()-based programs elsewhere on the system.
systemd.settings.Manager.DefaultLimitNOFILE = "1024:1048576";
# capSysNice lets gamescope raise its own scheduling priority, which is what
# keeps the compositor smooth while a game saturates the GPU. It installs
# gamescope as a setcap wrapper instead of a plain systemPackages entry;
# /run/wrappers/bin precedes the system profile on PATH, so steam-gamescope
# still resolves it.
programs.gamescope = {
enable = true;
capSysNice = true;
};
# Applies the performance CPU governor (and drops it again) around games that
# ask for it; Steam's Proton builds and most native titles do.
programs.gamemode.enable = true;
# Autologin into the Steam session. The launcher is not exposed as a package
# by the steam module -- it is built inside it and added to
# environment.systemPackages -- so reference it through the system profile.
# greetd's `restart` option defaults to false once initial_session is set,
# which is what stops a logout from looping straight back into autologin.
services.greetd.settings.initial_session = {
command = "/run/current-system/sw/bin/steam-gamescope";
user = tvUser;
};
# Controllers.
#
# Xbox One/Series pads over Bluetooth need xpadneo: the in-kernel xpad driver
# does not handle them well over BT (wrong button mapping, no rumble). The
# module turns on bluez itself; powerOnBoot is set below so the adapter is up
# before the Steam session starts and a pad can reconnect unattended.
#
# Everything else is in-kernel and needs no configuration: wired Xbox 360 pads
# (and the 360 wireless receiver) via xpad, DualSense/DualShock 4 via
# hid-playstation over USB and Bluetooth, and Clone Hero guitars as plain USB
# HID gamepads. xpadneo does not contend with xpad -- it binds Bluetooth HID
# devices, and the 360 pad is not HID-compliant. hardware.xone is deliberately
# left off: it blacklists xpad, which would break the 360 pads.
#
# hidraw access for the PlayStation pads (LED, battery, dualsensectl) comes
# from Steam's udev rules, which programs.steam enables via
# hardware.steam-hardware.
hardware.xpadneo.enable = true;
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
# Battery level reporting for Bluetooth gamepads is still behind bluez's
# experimental flag.
settings.General.Experimental = true;
};
# The games library, created at boot so the directories exist before anything
# tries to write into them. Mode 2775 is setgid: the owning group is carried
# onto anything created inside, so a second account (or an rsync from another
# machine) does not end up with files the TV user cannot write. Directories
# are created if missing and otherwise left alone -- nothing here removes or
# rewrites content.
systemd.tmpfiles.rules =
let
dir = path: "d ${path} 2775 ${tvUser} users -";
in
[
(dir gamesRoot)
(dir "${gamesRoot}/roms")
]
++ map (system: dir "${gamesRoot}/roms/${system}") romSystems
++ map (sub: dir "${gamesRoot}/${sub}") libraryDirs;
# 32-bit ALSA for the older native titles that talk to ALSA directly rather
# than through the PulseAudio shim; programs.steam derives
# pipewire.alsa.support32Bit from this. PipeWire itself and the Pulse shim
# come from ../../modules/workstation.nix.
services.pipewire.alsa.enable = true;
environment.systemPackages = [
retroarch
pkgs.clonehero
pkgs.dualsensectl # DualSense LED/battery/mic control from the shell
pkgs.mangohud # FPS/frametime overlay; use `mangohud %command%` in Steam
pkgs.vulkan-tools # vulkaninfo, for checking the 32/64-bit ICDs Proton needs
];
}
+57
View File
@@ -0,0 +1,57 @@
# PLACEHOLDER -- not generated by nixos-generate-config.
#
# This host has not been installed yet, so there is no real hardware scan to
# commit. The values below are the conventional defaults for a Haswell UEFI
# desktop and assume the install labels its partitions `nixos` (root, ext4) and
# `BOOT` (ESP, vfat) -- see docs/hosts/console.md. They exist so the flake
# evaluates in CI; they are not a description of the actual machine.
#
# Replace this whole file with the output of `nixos-generate-config` run on the
# machine, and commit that. If the labels do not match, the boot fails loudly on
# a missing device rather than touching the wrong disk.
{
config,
lib,
modulesPath,
...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"xhci_pci"
"ehci_pci"
"ahci"
"nvme"
"usb_storage"
"usbhid"
"sd_mod"
"sr_mod"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
fileSystems."/boot" = {
device = "/dev/disk/by-label/BOOT";
fsType = "vfat";
options = [
"fmask=0022"
"dmask=0022"
];
};
swapDevices = [ ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+54
View File
@@ -0,0 +1,54 @@
# NVIDIA GeForce GTX 1070 8 GB (Pascal, GP104): proprietary driver for the
# gamescope Steam session and the Sway desktop.
#
# Driver branch: 580 (nvidiaPackages.legacy_580), NOT the nixpkgs default
# (`production`, currently 595.x). 580 is the last branch that supports
# Maxwell/Pascal/Volta -- NVIDIA keeps it as an LTS branch to Aug 2028 -- and a
# newer branch simply will not drive this card. Same constraint as the Mac Pro's
# Quadro P400; see hosts/MacPro31/nvidia.nix.
#
# The driver is unfree, so it is not in the binary cache: the kernel module is
# compiled locally on every kernel bump.
{ config, ... }:
{
# Selects the proprietary driver; the module blacklists nouveau/nvidiafb and
# loads nvidia-uvm via a modprobe softdep. Naming is historical -- this option
# drives the kernel/driver choice on Wayland hosts too, which is why it is set
# on a machine that runs no X server.
services.xserver.videoDrivers = [ "nvidia" ];
hardware.nvidia = {
package = config.boot.kernelPackages.nvidiaPackages.legacy_580;
# Required for Wayland: sets nvidia-drm.modeset=1 (and fbdev=1), without
# which neither gamescope nor wlroots gets a usable GBM device and both the
# Steam session and Sway fail to start.
modesetting.enable = true;
# The open kernel modules need Turing or later; Pascal must use the closed
# ones. Explicit because the option has no default on driver >= 560.
open = false;
};
# The NVIDIA module only puts these in boot.kernelModules when
# services.xserver.enable is true, which is false on this Wayland-only host --
# so load them explicitly rather than relying on udev modalias autoloading.
# nvidia_uvm is deliberately absent: the module's modprobe softdep pulls it in
# after the GPU device exists, which is the supported ordering.
boot.kernelModules = [
"nvidia"
"nvidia_modeset"
"nvidia_drm"
];
# wlroots refuses the proprietary NVIDIA driver unless told to proceed. The
# greeter's compositor (cage) and gamescope have no such check; only Sway
# needs the flag, which the module bakes into the wrapper the session's
# .desktop file runs.
programs.sway.extraOptions = [ "--unsupported-gpu" ];
# 32-bit driver libraries for 32-bit Steam titles and Proton's 32-bit
# prefixes: hardware.graphics.enable32Bit pulls in the matching lib32 NVIDIA
# userspace. programs.steam (./gaming.nix) sets it too; stated here as well so
# the GPU's 32-bit story lives with the rest of the GPU config.
hardware.graphics.enable32Bit = true;
}