diff --git a/.gitignore b/.gitignore index d9b9b84..52ca498 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ modules/firmware/* # Local scratch project, not part of this flake. tf-inspect/ + +# Per-machine Claude Code settings; not shared configuration. +.claude/ diff --git a/docs/hosts/console.md b/docs/hosts/console.md index adb490a..97cc814 100644 --- a/docs/hosts/console.md +++ b/docs/hosts/console.md @@ -9,17 +9,12 @@ A 4th-generation Core i7 (Haswell) on a UEFI board with an NVIDIA GeForce GTX is driven from the sofa with a Bluetooth controller; keyboard and mouse are supported but secondary. -## Not installed yet +## Disk -`hardware-configuration.nix` in this host directory is a **placeholder**, not a -hardware scan. It exists so the flake evaluates in CI and assumes the install -labels its partitions `nixos` (root, ext4) and `BOOT` (ESP, vfat). Replace the -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 on a missing device -rather than touching the wrong disk. - -Partition the disk GPT with an ESP (vfat, 512 MB is comfortable). Nothing else -in the host config depends on the disk layout. +`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 @@ -88,7 +83,7 @@ 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. -Two details worth knowing: +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 @@ -96,17 +91,92 @@ Two details worth knowing: - `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 and Clone Hero to Big Picture +### The Sway session behind it -Both are installed system-wide but are not Steam titles. Add each once, from a -Sway session, via Steam's **Games → Add a Non-Steam Game**; they then appear in -Big Picture and inherit Steam Input, so the controller works in them without -further configuration. +Two fleet-wide rules in `home/sway.nix` matter more on a television than +anywhere else: + +- Any output running at 4K is forced to **scale 2** (1920x1080 logical). Sway's + config cannot key an output rule on resolution, so a small user service + applies it at session start and again on every output event — which is what + makes it survive the TV being switched off and on. +- The emulators inhibit idle **while focused**, not only while fullscreen. + PCSX2 and Dolphin default to borderless-windowed rather than compositor + fullscreen, so the generic `inhibit_idle fullscreen` rules never fired and + swaylock cut in mid-game. + +Neither applies in the Steam session: `swayidle` is a home-manager user service +wanted by `graphical-session.target` and conditional on `WAYLAND_DISPLAY`, and +the gamescope session starts neither, so nothing locks the screen in Big +Picture. + +### Adding non-Steam titles to Big Picture + +RetroArch, PCSX2, Dolphin and Clone Hero 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 does not work here: the dialog never appears at all. That dialog's +file browsing goes through the XDG desktop portal `FileChooser` interface, and +when the portal is unreachable the client falls back to a legacy chooser that is +blank or inert — Valve +[#9447](https://github.com/ValveSoftware/steam-for-linux/issues/9447), not 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="Dolphin" -ep="$(command -v dolphin-emu)" +steamtinkerlaunch addnonsteamgame -an="Clone Hero" -ep="$(command -v clonehero)" +``` ## Emulation — RetroArch @@ -167,15 +237,15 @@ system directory on this host: 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. If a game does not cooperate, add the standalone -emulators to `environment.systemPackages` in `gaming.nix`: +or PS2 title misbehaves. -```nix -pkgs.dolphin-emu # GameCube / Wii -pkgs.pcsx2 # PlayStation 2 -``` +That has already happened for PS2: the LRPS2 core was not good enough in +practice. **`pkgs.pcsx2` and `pkgs.dolphin-emu` are both installed** in +`gaming.nix` alongside their cores, and are what PS2, GameCube and Wii titles +should be run through. The cores stay for the sake of a single frontend and a +shared save/state layout. -Both are controller-driven and can be added to Big Picture the same way as +Both are controller-driven and are 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. @@ -226,10 +296,56 @@ 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. + library folder. Games installed there survive a reinstall of the OS. See + [When Steam will not register the library folder](#when-steam-will-not-register-the-library-folder) + below; it does not currently take. + - **Clone Hero** — set the song library path to `/srv/games/clonehero/songs` from its settings screen. Clone Hero keeps its own config in `~/.clonehero`. +### When Steam will not register the library folder + +Current behaviour on this host: the file picker opens, Steam creates the +directory structure at `/srv/games/steam`, and the library then never appears in +the Storage UI. So this is not the portal problem the non-Steam-game dialog has +— the picker works and Steam can write. The failure is in the validation Steam +runs before it records the folder in `libraryfolders.vdf`. + +That validation is a write-and-execute probe: Steam drops a +`.steam_exec_test.sh` in the candidate folder, marks it executable and runs it. +If either step fails the folder is silently dropped, which is +[#8450](https://github.com/ValveSoftware/steam-for-linux/issues/8450) and is +usually a `noexec` mount or an ownership problem — neither of which is obviously +true here, since `/srv` is on the ext4 root and the tree is +`lyrathorpe:users` 2775. + +Reproduce the probe **inside Steam's FHS sandbox**, which is where the client +actually runs it — `steam-run` is installed by the steam module for exactly this +kind of thing: + +```sh +steam-run sh -c ' + printf "#!/bin/sh\nexit 0\n" > /srv/games/steam/.steam_exec_test.sh && + chmod +x /srv/games/steam/.steam_exec_test.sh && + /srv/games/steam/.steam_exec_test.sh && + echo "probe ok" +' +rm -f /srv/games/steam/.steam_exec_test.sh +``` + +Then ask the client directly. Steam's console reports a reason where the UI +says nothing — 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. Whatever it +prints, plus `~/.steam/steam/logs/content_log.txt`, is the evidence to work +from. + ## Steam and Proton `programs.steam.enable` already arranges most of what Proton needs, and it is @@ -311,17 +427,27 @@ wired 360 pads — as well as `mt76x2u`, and pulls in proprietary dongle firmwar 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. -## Untested claims +## Open problems -This host has not been built or booted yet. Two things are worth watching on -first boot: +The machine is installed and Sway works. Still outstanding: -- **gamescope on the proprietary NVIDIA driver.** `gaming.nix` sets - `GBM_BACKEND=nvidia-drm` and `__GLX_VENDOR_LIBRARY_NAME=nvidia` for the - session, which is the standard fix, but the combination has a history of - needing tweaks. If the session dies at startup, switch the greeter back to - interactive by commenting out `services.greetd.settings.initial_session`, log - into Sway, and read `journalctl --user -b`. +- **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 will not register `/srv/games/steam` as a library folder.** The picker + opens and the directory structure gets created; the library never appears in + the Storage UI. Not the portal — see + [When Steam will not register the library folder](#when-steam-will-not-register-the-library-folder). +- **Steam's Add a Non-Steam Game dialog never appears.** Separate symptom, + probably the portal `FileChooser`; `steamtinkerlaunch` writes the shortcuts + without it in the meantime. +- **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. diff --git a/docs/keybindings.md b/docs/keybindings.md index 84831fb..8d5d6ae 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -18,8 +18,8 @@ rebuild, never the generated dotfiles. - Letter keys are **keysyms** (the character produced), not physical positions. The keyboard is **Dvorak** (`us`/`dvorak`), so e.g. "Super+s" is whatever key types `s` in Dvorak. -- Shortcuts apply to every Sway host (MBP, T400, Mac Pro); brightness keys are - laptop-only, as noted. +- Shortcuts apply to every Sway host (MBP, T400, Mac Pro, Console); brightness + keys are laptop-only, as noted. --- diff --git a/home/sway.nix b/home/sway.nix index 6374608..1aafa14 100644 --- a/home/sway.nix +++ b/home/sway.nix @@ -30,6 +30,28 @@ let - | ${pkgs.swappy}/bin/swappy -f - ''; + # Sway's config cannot express an output rule keyed on resolution, so scale is + # applied from outside it: once at session start, then on every output event, + # which is what makes it survive a display being unplugged or power-cycled. + # The `.scale != 2` guard stops the subscription feeding itself, since setting + # a scale is itself an output event. + scaleHidpi = pkgs.writeShellScript "sway-scale-hidpi" '' + apply() { + ${pkgs.sway}/bin/swaymsg -r -t get_outputs \ + | ${pkgs.jq}/bin/jq -r '.[] + | select(.active and .current_mode.width >= 3840 and .scale != 2) + | .name' \ + | while read -r name; do + ${pkgs.sway}/bin/swaymsg output "$name" scale 2 + done + } + + apply + ${pkgs.sway}/bin/swaymsg -t subscribe -m '["output"]' | while read -r _; do + apply + done + ''; + # Binding-mode names. The string is both the `modes` attr key and what the # bar's mode indicator shows, so the keys are spelled out in the label. layoutMode = "layout: [s]tacking [w]tabbed [e]split"; @@ -60,6 +82,8 @@ in tap = "enabled"; natural_scroll = "enabled"; }; + # Mice (the MX Master 3 among them) scroll the same way as the touchpads. + input."type:pointer".natural_scroll = "enabled"; # Solid Catppuccin Mocha base as the wallpaper (no image dependency). output."*".bg = "#${ctp.base} solid_color"; @@ -111,6 +135,18 @@ in criteria.class = ".*"; command = "inhibit_idle fullscreen"; } + # Emulators default to borderless-windowed rather than compositor + # fullscreen, so the rules above never fire and swaylock cuts in + # mid-game. Inhibit on focus instead, which does not depend on how the + # application asked to fill the screen. + { + criteria.app_id = "(?i)^(pcsx2|dolphin-emu|retroarch|clone.?hero)"; + command = "inhibit_idle focus"; + } + { + criteria.class = "(?i)^(pcsx2|dolphin-emu|retroarch|clone.?hero)"; + command = "inhibit_idle focus"; + } ]; # Binding modes (submenus). Entered from keybindings below; each action @@ -307,6 +343,24 @@ in Install.WantedBy = [ "sway-session.target" ]; }; + # Every 4K output runs at scale 2 (1920x1080 logical), on every host and + # whatever the display is called. kanshi below owns mode and position; this + # owns scale for the one case that is a fleet-wide rule rather than a + # per-display fact, and the two do not fight because no kanshi profile here + # sets a scale. + systemd.user.services.sway-scale-hidpi = { + Unit = { + Description = "Force scale 2 on 4K Sway outputs"; + PartOf = [ "graphical-session.target" ]; + After = [ "graphical-session.target" ]; + }; + Service = { + ExecStart = "${scaleHidpi}"; + Restart = "on-failure"; + }; + Install.WantedBy = [ "sway-session.target" ]; + }; + # Output/display management. Reacts to hotplug and applies per-display # mode/scale/position. Profiles are hardware-specific: the safe default below # just enables the internal laptop panel; add docked/desktop profiles with the diff --git a/hosts/Console/gaming.nix b/hosts/Console/gaming.nix index a2a07ad..7b103a3 100644 --- a/hosts/Console/gaming.nix +++ b/hosts/Console/gaming.nix @@ -61,6 +61,14 @@ let "clonehero/backgrounds" ]; + # greetd gives a session the VT for stdio, so a failed Steam session leaves no + # evidence once the greeter takes the VT back. Log to the journal instead: + # `journalctl -b -t steam-session`. + steamSession = pkgs.writeShellScript "steam-session" '' + exec ${pkgs.systemd}/bin/systemd-cat --identifier=steam-session \ + /run/current-system/sw/bin/steam-gamescope + ''; + # 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. @@ -178,13 +186,11 @@ in # 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. + # Autologin into the Steam session. 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"; + command = "${steamSession}"; user = tvUser; }; @@ -240,8 +246,16 @@ in environment.systemPackages = [ retroarch pkgs.clonehero + # Standalone GameCube/Wii and PS2, alongside the libretro cores of the same + # name: upstream effort goes into these builds, so they are what the two + # heaviest systems should actually be run through. + pkgs.dolphin-emu pkgs.pcsx2 pkgs.dualsensectl # DualSense LED/battery/mic control from the shell + # Writes shortcuts.vdf from the shell. Steam's own "Add a Non-Steam Game" + # dialog depends on a working portal file picker and frequently does not + # open; see docs/hosts/console.md. + pkgs.steamtinkerlaunch pkgs.mangohud # FPS/frametime overlay; use `mangohud %command%` in Steam pkgs.vulkan-tools # vulkaninfo, for checking the 32/64-bit ICDs Proton needs ]; diff --git a/modules/sway.nix b/modules/sway.nix index 29fdcdc..ffaf558 100644 --- a/modules/sway.nix +++ b/modules/sway.nix @@ -43,6 +43,7 @@ in playerctl # MPRIS media keys sway-launcher-desktop pavucontrol + wlr-randr # output query/adjustment; prints the criteria kanshi keys on ]; }; fonts.packages = with pkgs; [