# 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 ]; }