feat(gaming): generic umu game installer, skill, and desktop entries (#411)

This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-08 14:59:11 +08:00
1 parent efd279c7d1
commit 9e2633f496
14 files changed
+901 -39

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
---
name: nix-config-umu-game
description:
Use when installing a Windows game launcher (二次元 / gacha or any non-Steam game) on a NixOS
desktop via umu-launcher, given an installer URL or an .exe, or when such a launcher opens an
invisible, transparent, black, or empty window under Wine.
---
# Installing a Windows game launcher with umu
The bundled `scripts/umu-install.nu` renders the files in `scripts/templates/`. It creates a prefix,
runs the installer, and writes launchers. Use the Nix DW-Proton default unless research identifies a
specific installed release; then select it with `PROTONPATH` and pin that path in the game's `conf`.
**Every per-game detail lives under `~/Games/`**, never in this repo.
## Bundled files
| Path | What |
| ------------------------------- | --------------------------------------------------- |
| `scripts/umu-install.nu` | installer and template renderer |
| `scripts/templates/*.tpl` | non-executable templates; placeholders use `@NAME@` |
| `scripts/tests/test_install.py` | offline regression tests |
## Layout
| Path | What |
| ------------------------------- | -------------------------------------------------------- |
| `~/Games/global.conf` | optional, sourced by every game before its own `conf` |
| `~/Games/<name>/prefix` | the Wine prefix (`WINEPREFIX`) |
| `~/Games/<name>/conf` | optional per-game overrides (`ENABLE_MANGOHUD=0`, ...) |
| `~/Games/<name>/prelaunch` | optional executable, run before every launch |
| `~/Games/<name>/run` | generated launcher |
| `~/Games/<name>/exec` | generated helper: `exec <exe-or-tool> [args...]` |
| `~/Games/<name>/<name>.desktop` | generated desktop entry |
| `~/Games/<name>/uninstall` | generated: drop the desktop entry and (with `y`) the dir |
Run, from the repo root:
`nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu <name> <setup.exe> <launcher> [gameid] [setup-args...]`
`<launcher>` is relative to the prefix and `GAMEID` defaults to `umu-default`. At install,
`PROTONPATH` resolves from `$PROTONPATH`, `$UMU_PROTONPATH` (the Nix-provided DW-Proton), then
Steam's per-user `compatibilitytools.d`. At launch, the generated `run` script checks the current
`$PROTONPATH` and `$UMU_PROTONPATH` before its saved default. A game's `conf` is sourced after those
defaults, so set `PROTONPATH` there to pin a particular installed version across launches.
## Procedure
1. **Research first.** Mandatory before inventing any fix: a documented fix beats a custom patch
every time. Check the game's Lutris installer YAML as data only
(`https://lutris.net/api/installers/<game-slug>` encodes `winetricks` verbs, `write_file`
content, and `prelaunch_command`; do not install or run Lutris), then ProtonDB
(`protondb.com/app/<id>`), Steam Community / r/linux_gaming threads, and GitHub issues for the
launcher and Proton. Record whether reports require a Proton family and exact release. If no
exact release is required, use the Nix-provided DW-Proton default. If one is required, inspect
`$UMU_PROTONPATH` and installed Steam tools under `~/.local/share/Steam/compatibilitytools.d/` or
`~/.steam/root/compatibilitytools.d/`; select the matching installed path with `PROTONPATH`.
Prefix the installer command with `PROTONPATH="<selected-tool-directory>"` and set the same value
in the game's `conf` to pin it across launches; otherwise the runtime Nix `UMU_PROTONPATH` may
take precedence. If the required release is absent from Nix and Steam, stop and ask the user
before downloading it from elsewhere. Do not substitute a different release or use
ProtonPlus/Lutris to fetch one.
2. **Slug.** Pick a lowercase `<name>` (e.g. `wuthering-waves`).
3. **GAMEID.** The umu database maps a title to a `GAMEID` whose protonfixes add CJK fonts, drop the
`SteamOS`/`SteamDeck` vars, and keep Wine's `Documents` inside the prefix. **Without it some
games save into the host home or the wrong prefix.** Fetch
`https://raw.githubusercontent.com/Open-Wine-Components/umu-database/main/umu-database.csv`,
match the title, and use its `umu-<id>`; if nothing matches, leave the default.
4. **Download.** `curl -fL <url> -o ~/Games/<name>/setup.exe`. The CN store pages are JS/token
driven, so the user normally supplies the URL or the file itself.
5. **Install.** Run the bundled script. The first run also downloads umu's Steam runtime and the
protonfix fonts, so it is slow. The installer is usually a GUI the user clicks through, but
**extra args after `[gameid]` go to `setup.exe`**, so try a silent flag first (`/S`, `/quiet`,
`--silent`); if it ignores them, fall back to clicking. On some CN installers the last page
auto-starts the launcher. The script writes `run`/`exec` even if the installer exits non-zero, so
a killed installer is not fatal.
6. **Find the launcher.** If `<launcher>` is only known after install, locate it under
`~/Games/<name>/prefix/drive_c` and set `LAUNCHER` in `~/Games/<name>/conf`. Re-running the
installer script invokes `setup.exe` again; only do so if that installer is known to handle
reruns.
7. **Per-game fix.** Port step 1 findings into `~/Games/<name>/prelaunch` (bash, `chmod +x`):
`winetricks` verbs via `~/Games/<name>/exec winetricks <verbs>`, file patches or registry tweaks
via `~/Games/<name>/exec`. Never commit the fix to this repo.
8. **Re-run every launch, not once.** A launcher that self-updates overwrites files it patches, so
those patches belong in `prelaunch`.
9. **Verify.** `~/Games/<name>/run` must open a visible launcher window. MangoHud is enabled by
default with FPS and frame time at the top-left; set `ENABLE_MANGOHUD=0` in `conf` to disable it,
or override `MANGOHUD_CONFIG`. Re-running `prelaunch` must be idempotent. The launcher may then
download the game body itself (tens of GB) -- that is the launcher's job, not this skill's, so
the install is **done** once the window is usable. `ENABLE_LOG=1` captures `last-run.log` when
something misbehaves.
## Invisible / transparent launcher window
A WebView2/.NET (WPF) launcher that shows an empty or fully transparent window is rendering with
`AllowsTransparency`. Fix the DLL, not Wine:
1. `find ~/Games/<name>/prefix/drive_c -iname 'launcher_main.dll'` (usually under a `X.Y.Z.W`
version dir).
2. Rename that WPF property in place, keeping the byte length so PE offsets stay valid:
`bbe -e 's/\x12AllowsTransparency/\x09IsEnabled\x1bA\x00\x03AAAAA/' <dll>`.
3. Put it in `~/Games/<name>/prelaunch` so it is re-applied on every launch, because a launcher
self-update restores the original DLL. Restart the launcher after patching.
## Half-width tile instead of fullscreen
Niri tiles a new window into a column of `default-column-width { proportion 0.500000; }` (50%), and
a launcher that only opens a large borderless window cannot take over the screen by itself: with a
4K panel at `scale 1.5` Niri's logical space is 2560x1440, so a window asking for the physical
3840x2160 becomes an ordinary half-width tile. Nothing is broken -- the umu app-id is just not
covered by a rule.
Every umu/Proton game reports `steam_app_<GAMEID>`. In this repo
`home/linux/gui/niri/conf/windowrules.kdl` matches `app-id="^steam_app_[0-9]+$"` and sets
`open-fullscreen true`, so games open fullscreen without a per-game rule. `niri validate` checks the
live config, `Mod+Shift+F` (`fullscreen-window`) toggles an already-open window, and setting the
in-game resolution to the compositor's logical size (2560x1440 here) avoids the mismatch too.
A launcher shares that app id with its game, so the rule also catches the launcher's own windows: a
helper that is still untitled while it maps, and the announcement popup (公告 / Announcement), which
on this launcher renders as a pure white rectangle. Fullscreening either one buries the UI that
opened it, so both are excluded. A window rule applies when a window opens, so an already-open
offender needs `Mod+Shift+F` on it or a relaunch; drop `open-fullscreen true` altogether if a
launcher keeps surprising you -- the game then costs one `Mod+Shift+F` per session instead.
## Mojibake that is not a missing font
Latin-looking garbage such as `我已阅读` where the launcher should say 我已阅读并同意, while
other Chinese on the same window renders fine, is UTF-8 decoded as Windows-1252. Confirm the
pattern:
```bash
python3 -c "print('我已阅读并同意'.encode('utf-8').decode('cp1252','replace'))"
```
If that prints exactly what the launcher shows, the bug is identified and you can stop here.
The string itself is fine and so are the fonts: the same launcher
renders 请输入手机号 and 登录 correctly, and the garbled label exists as correct UTF-16 in
`KRSDKEx.dll`. The bytes are simply decoded with a single-byte Western codepage.
**This is not the prefix locale.** Setting `LANG=zh_CN.UTF-8` in `~/Games/<name>/conf` does change
the prefix (verified in this repo: `"ACP"` went `1252` -> `936` and `"LocaleName"` `en-US` ->
`zh-CN`), and the garbling stayed **byte-for-byte identical**. The launcher decodes those bytes as
Latin-1 no matter what Windows codepage it is told to use, so this is an upstream bug in its own
text pipeline: report it, or accept it -- the launcher still works. `LANG` is nevertheless the lever
for the prefix locale, not `LC_ALL`: Proton clears `LC_ALL` unless `HOST_LC_ALL` is set.
So: check the bytes first, do not install more CJK fonts, and do not promise a locale fix.
## Silent install
`umu-install.nu <name> <setup.exe> <launcher> <gameid> /S` passes `/S` to the installer. Not all
installers honour it; try `/quiet` or `--silent` too, then fall back to the GUI.
## Desktop entry and persistence
The script also writes `~/.local/share/applications/<name>.desktop` (and a copy at
`~/Games/<name>/<name>.desktop`), so the game shows up in the desktop launcher. Under an
impermanence setup both `~/Games` and `~/.local/share/applications` must be in the host's
`preservation.preserveAt` list; in this repo `~/Games` and `.local/share/umu` already are, and
`hosts/idols-ai/preservation.nix` now persists `.local/share/applications` too.
## Uninstall
Run `~/Games/<name>/uninstall`: it removes the desktop entry and then asks before deleting
`~/Games/<name>` (prefix + game files). To keep the game and only drop the menu entry, delete the
`.desktop` file by hand.
## Optimize the launch
`~/Games/<name>/conf` (or `~/Games/global.conf`) is a shell file the generated `run` sources.
Defaults are `ENABLE_MANGOHUD=1`, `MANGOHUD_CONFIG="fps=1,frametime=1,position=top-left"`,
`ENABLE_GAMEMODE=0`, `ENABLE_GAMESCOPE=0`, `GAMESCOPE_ARGS="-f"`, and `ENABLE_LOG=0`. Set
`ENABLE_MANGOHUD=0` to hide the overlay, `ENABLE_GAMESCOPE=1` +
`GAMESCOPE_ARGS="-f -w 3840 -h 2160"` for the handheld / multi-monitor case, `ENABLE_GAMEMODE=1` for
GameMode, and `ENABLE_LOG=1` to capture the run. A dGPU wrapper still goes outside:
`nvidia-offload ~/Games/<name>/run`. Shader caches are kept in `~/Games/<name>/shader-cache`.
## Escape hatches
- `~/Games/<name>/exec <exe-or-tool> [args...]` runs anything in the prefix with the right env: an
absolute path to a game exe or repair tool, or a bare Wine tool (`winecfg`, `explorer`, `regedit`,
`uninstaller`), which it runs as `umu-run <Proton's wine> <tool>`. Both forms are handed to
`umu-run`, so they execute inside umu's Steam runtime container -- that is what makes them work on
a non-FHS distro like NixOS, where Proton's wine cannot start on its own.
- `~/Games/<name>/run <game args>` passes extra args to the launcher.
- Kill a stuck prefix with `pkill -f '/Games/<name>/prefix'`, or
`rm -f ~/Games/<name>/prefix/pfx.lock`.
## Common mistakes
- Committing a game name, URL, or patch to this repo instead of `~/Games/<name>/`.
- Patching once instead of in `prelaunch` -- the next launcher self-update undoes it.
- Dropping the `GAMEID` -- the in-game CJK fonts and the save location go wrong.
- Installing more CJK fonts when the text is mojibake -- decode the bytes first (see above).
- Expecting umu to fetch DW-Proton: it only auto-manages GE-Proton / UMU-Proton; use Nix or Steam
installed paths. Ask the user before obtaining a required release from elsewhere.
- Running a Wine tool without umu. `umu-run winecfg` does not work -- umu only special-cases
`winetricks` -- and `$PROTONPATH/files/bin/wine winecfg` fails on any non-FHS distro (on NixOS the
32-bit loader it needs lives only inside the Steam runtime container). Use
`~/Games/<name>/exec winecfg`; `exec` goes through `umu-run`, which provides that container.
- Waiting for the game download before calling the install done: the launcher's own download is out
of scope here.
- On NixOS a game needing 32-bit or Vulkan needs `hardware.graphics.enable32Bit`, and Steam is a
module rather than a package -- see the `nix-config-desktop` skill.
## Prior art
`ESKAP3/umu-skeleton` (filesystem-as-game-manager, global.conf, tools), `gizmo-ds/endfield-run`
(`-exec` installer), `Septa-Serpenta-Seraph/Vesper` (umu + GAMEID notes), `joshsymonds/nix-config`
(research-first debugging), `olafkfreund/nixarchy` (NixOS gaming skill).
## Regression tests
From the repo root:
```bash
python3 .agents/skills/nix-config-umu-game/scripts/tests/test_install.py
```
Requires `python3`, `nu`, `bash`, and `shellcheck`. Uses temporary homes and a stub `umu-run`; no
game downloads, Wine windows, or changes to existing games. It checks template permissions, rendered
shell syntax and lint, installer arguments and failures, configuration precedence, prelaunch,
logging, launch wrappers, literal paths, runtime Proton overrides, tool routing, and uninstall
confirmation.
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Generated by the nix-config-umu-game skill -- run a tool in this prefix.
# exec <exe-or-tool> [args...] e.g. exec winecfg / exec explorer
set -euo pipefail
export WINEPREFIX=@WINEPREFIX@
DEFAULT_PROTONPATH=@PROTONPATH@
export PROTONPATH="${PROTONPATH:-${UMU_PROTONPATH:-$DEFAULT_PROTONPATH}}"
export GAMEID=@GAMEID@
# Bare wine tools are not executables on disk; run them through Proton wine.
case "${1:-}" in
winecfg|wineboot|explorer|regedit|control|uninstaller|taskmgr)
exec umu-run "$PROTONPATH/files/bin/wine" "$@" ;;
*)
exec umu-run "$@" ;;
esac
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=@DISPLAY_NAME@
Comment=Windows game launcher installed via umu
Exec=@RUN@
Path=@DIR@
Terminal=false
Categories=Game;
StartupNotify=false
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Generated by the nix-config-umu-game skill -- re-run its script to regenerate.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
# Defaults; ~/Games/global.conf and this game conf/ override them.
WINEPREFIX=@WINEPREFIX@
DEFAULT_PROTONPATH=@PROTONPATH@
PROTONPATH="${PROTONPATH:-${UMU_PROTONPATH:-$DEFAULT_PROTONPATH}}"
GAMEID=@GAMEID@
LAUNCHER=@LAUNCHER@
ENABLE_GAMEMODE=0
ENABLE_GAMESCOPE=0
GAMESCOPE_ARGS="-f"
ENABLE_LOG=0
ENABLE_MANGOHUD=1
MANGOHUD_CONFIG="fps=1,frametime=1,position=top-left"
# shellcheck source=/dev/null
if [ -f "$HERE/../global.conf" ]; then . "$HERE/../global.conf"; fi
# shellcheck source=/dev/null
if [ -f "$HERE/conf" ]; then . "$HERE/conf"; fi
export WINEPREFIX PROTONPATH GAMEID MANGOHUD_CONFIG
export VKD3D_SHADER_CACHE_PATH="$HERE/shader-cache"
mkdir -p "$VKD3D_SHADER_CACHE_PATH"
PRE=""
if [ "$ENABLE_GAMEMODE" = "1" ] && command -v gamemoderun >/dev/null 2>&1; then PRE="gamemoderun"; fi
if [ "$ENABLE_GAMESCOPE" = "1" ] && command -v gamescope >/dev/null 2>&1; then PRE="$PRE gamescope $GAMESCOPE_ARGS --"; fi
if [ "$ENABLE_MANGOHUD" = "1" ] && command -v mangohud >/dev/null 2>&1; then PRE="$PRE mangohud"; fi
if [ -x "$HERE/prelaunch" ]; then "$HERE/prelaunch"; fi
if [ "$ENABLE_LOG" = "1" ]; then
set -o pipefail
$PRE umu-run "$WINEPREFIX/$LAUNCHER" "$@" 2>&1 | tee "$HERE/last-run.log"
else
exec $PRE umu-run "$WINEPREFIX/$LAUNCHER" "$@"
fi
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Generated by the nix-config-umu-game skill.
set -euo pipefail
GAMES_DIR_PATH=@GAMES_DIR@
if [[ -L "$GAMES_DIR_PATH" ]]; then
echo "refusing symlinked Games directory: $GAMES_DIR_PATH" >&2
exit 1
fi
GAMES_DIR="$(cd "$GAMES_DIR_PATH" && pwd -P)"
NAME=@NAME@
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd -P)"
if [[ "$HERE" != "$GAMES_DIR/$NAME" ]]; then
echo "refusing to remove path outside $GAMES_DIR/$NAME: $HERE" >&2
exit 1
fi
rm -f @DESKTOP_FILE@
echo "desktop entry removed"
printf "Delete %s and all its files? [y/N] " "$HERE"
read -r ans
if [ "$ans" = y ] || [ "$ans" = Y ]; then
cd /
rm -rf "$HERE"
echo "removed $HERE"
else
echo "kept $HERE"
fi
@@ -0,0 +1,352 @@
"""Offline regression tests; needs nu, bash and shellcheck, but no Wine."""
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1]
class InstallerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="umu-test-")
self.addCleanup(self.temp.cleanup)
root = Path(self.temp.name)
home = root / "home"
home.mkdir()
self.home = home
bin_dir = root / "bin"
bin_dir.mkdir()
self.bin_dir = bin_dir
self.proton = root / "proton"
(self.proton / "files/bin").mkdir(parents=True)
(self.proton / "proton").touch()
self.log = root / "call"
stub = bin_dir / "umu-run"
stub.write_text("""#!/usr/bin/env bash
mkdir -p "$WINEPREFIX"
printf "%s\n" "$WINEPREFIX" "$PROTONPATH" "$GAMEID" "$@" > "$CALL_LOG"
exit "${STUB_EXIT:-0}"
""")
stub.chmod(0o755)
self.mangohud_log = root / "mangohud-call"
hud = bin_dir / "mangohud"
hud.write_text("""#!/usr/bin/env bash
printf "%s\n" "${MANGOHUD_CONFIG-unset}" "$@" > "$MANGOHUD_LOG"
exec "$@"
""")
hud.chmod(0o755)
self.env = dict(
os.environ,
HOME=str(home),
PROTONPATH=str(self.proton),
CALL_LOG=str(self.log),
MANGOHUD_LOG=str(self.mangohud_log),
PATH=str(bin_dir) + os.pathsep + os.environ["PATH"],
)
for key in ("XDG_DATA_HOME", "BASH_ENV", "ENV"):
self.env.pop(key, None)
self.game = home / "Games/test-game"
self.launcher = "drive_c/Program Files/Test/launcher.exe"
self.gameid = "umu-test"
def invoke_install(self, name, **extra):
return subprocess.run(
[
"nu",
str(SCRIPTS / "umu-install.nu"),
name,
"setup.exe",
self.launcher,
self.gameid,
"/S",
],
env=self.env | extra,
cwd=self.home,
capture_output=True,
text=True,
check=False,
)
def install(self, **extra):
result = self.invoke_install("test-game", **extra)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def call(self, name, *args, input=None):
result = subprocess.run(
[str(self.game / name), *args],
env=self.env,
input=input,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return self.log.read_text().splitlines()
def test_name_must_be_one_slug_component_before_any_install_work(self):
games = self.home / "Games"
outside = self.home / "escaped"
invalid_names = [str(outside), "../escaped", "nested/game", ".."]
for name in invalid_names:
with self.subTest(name=name):
result = self.invoke_install(name)
self.assertNotEqual(result.returncode, 0)
self.assertIn("lowercase slug", result.stderr)
self.assertFalse(self.log.exists())
self.assertFalse(games.exists())
self.assertFalse(outside.exists())
def test_install_rejects_symlinked_games_root(self):
outside = self.home / "outside-games"
outside.mkdir()
(self.home / "Games").symlink_to(outside, target_is_directory=True)
result = self.invoke_install("test-game")
self.assertNotEqual(result.returncode, 0)
self.assertIn("symlink", result.stderr)
self.assertFalse(self.log.exists())
self.assertEqual(list(outside.iterdir()), [])
def test_uninstall_refuses_games_root_replaced_by_symlink(self):
self.install()
games = self.home / "Games"
relocated = self.home / "relocated-games"
games.rename(relocated)
games.symlink_to(relocated, target_is_directory=True)
result = subprocess.run(
[str(self.game / "uninstall")],
env=self.env,
input="y\n",
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertTrue((relocated / "test-game/run").exists())
def test_install_rejects_symlinked_game_directory(self):
games = self.home / "Games"
games.mkdir()
outside = self.home / "outside"
outside.mkdir()
(games / "test-game").symlink_to(outside, target_is_directory=True)
result = self.invoke_install("test-game")
self.assertNotEqual(result.returncode, 0)
self.assertIn("symlink", result.stderr)
self.assertFalse(self.log.exists())
self.assertEqual(list(outside.iterdir()), [])
def test_install_rejects_symlinked_prefix(self):
game = self.home / "Games/test-game"
game.mkdir(parents=True)
outside = self.home / "outside"
outside.mkdir()
(game / "prefix").symlink_to(outside, target_is_directory=True)
result = self.invoke_install("test-game")
self.assertNotEqual(result.returncode, 0)
self.assertIn("symlink", result.stderr)
self.assertFalse(self.log.exists())
self.assertEqual(list(outside.iterdir()), [])
def test_install_rejects_dangling_symlink_prefix(self):
game = self.home / "Games/test-game"
game.mkdir(parents=True)
outside = self.home / "missing-prefix-target"
(game / "prefix").symlink_to(outside, target_is_directory=True)
result = self.invoke_install("test-game")
self.assertNotEqual(result.returncode, 0)
self.assertIn("symlink", result.stderr)
self.assertFalse(self.log.exists())
self.assertFalse(outside.exists())
def test_uninstall_refuses_directory_moved_outside_games(self):
self.install()
outside = self.home / "outside"
self.game.rename(outside)
self.game.symlink_to(outside, target_is_directory=True)
result = subprocess.run(
[str(self.game / "uninstall")],
env=self.env,
input="y\n",
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(outside.exists())
self.assertTrue((outside / "run").exists())
def test_templates_are_separate_and_non_executable(self):
for name in (
"run.sh.tpl",
"exec.sh.tpl",
"uninstall.sh.tpl",
"launcher.desktop.tpl",
):
path = SCRIPTS / "templates" / name
self.assertTrue(path.is_file(), str(path))
self.assertFalse(os.access(path, os.X_OK), str(path))
def test_run_enables_minimal_top_left_mangohud_by_default(self):
self.install()
self.call("run")
hud_call = self.mangohud_log.read_text().splitlines()
self.assertEqual(hud_call[0], "fps=1,frametime=1,position=top-left")
self.assertEqual(
hud_call[1:], ["umu-run", str(self.game / "prefix" / self.launcher)]
)
def test_run_can_disable_mangohud_per_game(self):
self.install()
(self.game / "conf").write_text("ENABLE_MANGOHUD=0\n")
values = self.call("run")
self.assertFalse(self.mangohud_log.exists())
self.assertEqual(values[3:], [str(self.game / "prefix" / self.launcher)])
def test_generation_and_setup_arguments(self):
self.install()
self.assertEqual(self.log.read_text().splitlines()[3:], ["setup.exe", "/S"])
for name in ("run", "exec", "uninstall"):
path = self.game / name
self.assertTrue(os.access(path, os.X_OK))
subprocess.run(["bash", "-n", str(path)], check=True)
subprocess.run(["shellcheck", str(path)], check=True)
desktop = self.home / ".local/share/applications/test-game.desktop"
self.assertIn("Name=Test Game", desktop.read_text())
self.assertIn("Exec=" + str(self.game / "run"), desktop.read_text())
self.assertEqual(
desktop.read_bytes(), (self.game / "test-game.desktop").read_bytes()
)
def test_nonzero_installer_still_generates_launchers(self):
self.install(STUB_EXIT="7")
self.assertTrue((self.game / "run").exists())
def test_config_prelaunch_and_launch_arguments(self):
self.install()
(self.game.parent / "global.conf").write_text('GAMEID="global"\n')
(self.game / "conf").write_text('GAMEID="local"\nENABLE_LOG=1\n')
prelaunch = self.game / "prelaunch"
prelaunch.write_text('#!/usr/bin/env bash\ntouch "$WINEPREFIX/../patched"\n')
prelaunch.chmod(0o755)
self.install()
values = self.call("run", "--game-arg")
self.assertEqual(values[2], "local")
self.assertEqual(
values[3:], [str(self.game / "prefix" / self.launcher), "--game-arg"]
)
self.assertTrue((self.game / "patched").exists())
self.assertTrue((self.game / "last-run.log").exists())
def test_launcher_path_is_quoted_as_data(self):
self.launcher = "drive_c/quote'\"$HOME/launcher.exe"
self.install()
values = self.call("run")
self.assertEqual(values[3:], [str(self.game / "prefix" / self.launcher)])
def test_values_containing_placeholder_text_are_not_rerendered(self):
self.home = self.home.parent / "@GAMEID@"
self.home.mkdir()
self.env["HOME"] = str(self.home)
self.game = self.home / "Games/test-game"
self.launcher = "drive_c/@PROTONPATH@/launcher.exe"
self.gameid = "$(printf injected)"
self.install()
values = self.call("run")
self.assertEqual(values[0], str(self.game / "prefix"))
self.assertEqual(values[3], str(self.game / "prefix" / self.launcher))
def test_game_conf_pins_installed_proton_over_system_default(self):
self.install()
selected = self.home / "steam-dwproton"
system_default = self.home / "system-dwproton"
(selected / "files/bin").mkdir(parents=True)
(selected / "proton").touch()
(system_default / "files/bin").mkdir(parents=True)
(system_default / "proton").touch()
(self.game / "conf").write_text(f"PROTONPATH='{selected}'\n")
self.env.pop("PROTONPATH")
self.env["UMU_PROTONPATH"] = str(system_default)
self.assertEqual(self.call("run")[1], str(selected))
def test_runtime_proton_override(self):
self.install()
self.env["PROTONPATH"] = str(self.home / "override")
self.assertEqual(self.call("run")[1], self.env["PROTONPATH"])
self.assertEqual(
self.call("exec", "winecfg")[3], str(self.home / "override/files/bin/wine")
)
def test_gamemode_and_gamescope_wrappers(self):
self.install()
gamemode_log = self.home / "gamemode.log"
gamescope_log = self.home / "gamescope.log"
self.env.update(WRAPPER_LOG=str(gamemode_log), GAMESCOPE_LOG=str(gamescope_log))
wrappers = {
"gamemoderun": '#!/usr/bin/env bash\nprintf "called\\n" > "$WRAPPER_LOG"\nexec "$@"\n',
"gamescope": '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$GAMESCOPE_LOG"\nwhile [ "$1" != -- ]; do shift; done\nshift\nexec "$@"\n',
}
for name, script in wrappers.items():
path = self.bin_dir / name
path.write_text(script)
path.chmod(0o755)
(self.game / "conf").write_text(
'ENABLE_GAMEMODE=1\nENABLE_GAMESCOPE=1\nGAMESCOPE_ARGS="-f -w 1920"\n'
)
self.call("run")
self.assertEqual(gamemode_log.read_text(), "called\n")
self.assertEqual(
gamescope_log.read_text().splitlines()[:6],
["-f", "-w", "1920", "--", "mangohud", "umu-run"],
)
def test_exec_routes_wine_tools(self):
self.install()
self.assertEqual(
self.call("exec", "winecfg")[3:],
[str(self.proton / "files/bin/wine"), "winecfg"],
)
self.assertEqual(
self.call("exec", "/tmp/tool.exe", "argument")[3:],
["/tmp/tool.exe", "argument"],
)
def test_uninstall_confirmation(self):
self.install()
desktop = self.home / ".local/share/applications/test-game.desktop"
self.call("uninstall", input="n\n")
self.assertTrue(self.game.exists())
self.assertFalse(desktop.exists())
subprocess.run(
[str(self.game / "uninstall")],
env=self.env,
input="y\n",
text=True,
check=True,
capture_output=True,
)
self.assertFalse(self.game.exists())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,184 @@
#!/usr/bin/env nu
#
# Install a Windows game launcher into its own umu-launcher Wine prefix, and
# generate launch/exec/desktop/uninstall helpers for it.
#
# umu-launcher is the Proton backend Lutris/Heroic use: it creates and runs a
# detached Wine prefix without Steam, and a GAMEID from the umu database pulls
# in that game's protonfixes (fonts, env quirks, save location, ...). This
# installer is fully generic -- it knows no game names. Every per-game detail
# lives under ~/Games/<name>/:
#
# prefix the Wine prefix (WINEPREFIX)
# prelaunch optional executable, run before every launch
# conf optional shell file overriding the generated defaults
# run generated launcher
# exec generated helper: run any exe/wine tool in the prefix
# <name>.desktop generated desktop entry
# uninstall generated: drop the desktop entry and (with confirmation) this dir
#
# ~/Games/global.conf, if present, is sourced by every run script before the
# game's own conf; both may set ENABLE_GAMEMODE, ENABLE_GAMESCOPE,
# GAMESCOPE_ARGS, ENABLE_LOG, PROTONPATH, GAMEID, LAUNCHER, ...
#
# PROTONPATH is resolved from $PROTONPATH, then $UMU_PROTONPATH (exported by
# modules/nixos/desktop/gaming.nix from pkgs.dwproton-bin), then Steam's
# per-user compatibilitytools.d.
#
# nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu wuthering-waves \
# ~/Downloads/WutheringWaves_setup_3.5.0.exe \
# "drive_c/Program Files/Wuthering Waves/launcher.exe" \
# umu-3513350
#
# Extra arguments after [gameid] are passed to the installer, e.g. "/S" or
# "/quiet" for a silent install.
def games-dir [] {
$nu.home-dir | path join "Games"
}
# First candidate that actually contains a proton script.
def resolve-protonpath [] {
let explicit = ($env.PROTONPATH? | default "")
if (not ($explicit | is-empty)) and (($explicit | path join "proton") | path exists) {
return $explicit
}
let umu = ($env.UMU_PROTONPATH? | default "")
if (not ($umu | is-empty)) and (($umu | path join "proton") | path exists) {
return $umu
}
let steam = ($nu.home-dir | path join ".local/share/Steam/compatibilitytools.d/dwproton")
if (($steam | path join "proton") | path exists) {
return $steam
}
null
}
# "wuthering-waves" -> "Wuthering Waves"
def reject-symlink [path: string, label: string] {
let kind = (try { $path | path type } catch { "missing" })
if $kind == "symlink" {
print --stderr $"error: ($label) must not be a symlink: ($path)"
exit 2
}
}
def display-name [name: string] {
$name | split row "-" | each { |w| $w | str capitalize } | str join " "
}
def usage [] {
print "Usage: .agents/skills/nix-config-umu-game/scripts/umu-install.nu <name> <setup.exe> <launcher> [gameid] [setup-args...]"
print ""
print " <name> directory under ~/Games (e.g. wuthering-waves)"
print " <setup.exe> installer to run through umu (a host path)"
print " <launcher> launcher to run later, relative to the prefix"
print " [gameid] umu database id for protonfixes (default umu-default)"
print " [setup-args] extra args for setup.exe, e.g. /S or /quiet"
print ""
print "Writes ~/Games/<name>/{run,exec,<name>.desktop,uninstall}. A per-game"
print "fix goes in ~/Games/<name>/prelaunch; overrides go in ~/Games/<name>/conf"
print "or ~/Games/global.conf. PROTONPATH comes from $PROTONPATH or"
print "$UMU_PROTONPATH."
}
# Quote substituted shell values as data, including literal apostrophes.
def shell-quote [value: string] {
"'" + ($value | str replace --all "'" "'\\''") + "'"
}
def render-template [name: string, values: record] {
let template = ($env.FILE_PWD | path join "templates" $name)
open --raw $template | str replace --all --regex '@([A-Z_]+)@' { |key|
$values | get $key
}
}
def write-run [name: string, launcher: string] {
let dir = ((games-dir) | path join $name)
let values = {
WINEPREFIX: (shell-quote $env.WINEPREFIX)
PROTONPATH: (shell-quote $env.PROTONPATH)
GAMEID: (shell-quote $env.GAMEID)
LAUNCHER: (shell-quote $launcher)
}
mkdir $dir
for file in [run exec] {
let target = ($dir | path join $file)
render-template $"($file).sh.tpl" $values | save --force $target
^chmod +x $target
}
}
def write-integration [name: string] {
let dir = ((games-dir) | path join $name)
let apps = ($nu.home-dir | path join ".local/share/applications")
let entry = ($apps | path join $"($name).desktop")
let desktop = (render-template "launcher.desktop.tpl" {
DISPLAY_NAME: (display-name $name)
RUN: ($dir | path join "run")
DIR: $dir
})
mkdir $apps
$desktop | save --force ($dir | path join $"($name).desktop")
$desktop | save --force $entry
let uninstall = ($dir | path join "uninstall")
render-template "uninstall.sh.tpl" {
DESKTOP_FILE: (shell-quote $entry)
GAMES_DIR: (shell-quote (games-dir))
NAME: (shell-quote $name)
} | save --force $uninstall
^chmod +x $uninstall
}
def main [...args: string] {
if ($args | length) < 3 {
usage
exit 1
}
let name = ($args | get 0)
if not ($name =~ '^[a-z0-9]+(-[a-z0-9]+)*$') {
print --stderr "error: name must be a lowercase slug (letters, digits, hyphens)"
exit 2
}
let setup = ($args | get 1)
let launcher = ($args | get 2)
let gameid = (if ($args | length) >= 4 { $args | get 3 } else { "umu-default" })
let setupArgs = (if ($args | length) >= 5 { $args | skip 4 } else { [] })
let root = (games-dir)
reject-symlink $root "Games directory"
let dir = ($root | path join $name)
let prefix = ($dir | path join "prefix")
reject-symlink $dir "game directory"
reject-symlink $prefix "Wine prefix"
let protonpath = (resolve-protonpath)
if $protonpath == null {
print --stderr "error: no Proton found. Set PROTONPATH or UMU_PROTONPATH, or rebuild NixOS so modules/nixos/desktop/gaming.nix exports UMU_PROTONPATH."
exit 1
}
$env.PROTONPATH = $protonpath
$env.WINEPREFIX = $prefix
$env.GAMEID = $gameid
print $"installing '($name)' into ($env.WINEPREFIX)"
print $" PROTONPATH = ($env.PROTONPATH)"
print $" GAMEID = ($env.GAMEID)"
print "run the installer, then exit it (do not start the game)."
let inst = (^umu-run $setup ...$setupArgs | complete)
if $inst.exit_code != 0 {
print --stderr $"installer exited ($inst.exit_code); writing the launchers anyway"
}
let prelaunch = ($dir | path join "prelaunch")
if ($prelaunch | path exists) {
print $"running prelaunch: ($prelaunch)"
run-external $prelaunch
}
write-run $name $launcher
write-integration $name
print $"done. launch with: ($dir)/run"
}
+3
View File
@@ -109,6 +109,9 @@ is discovered by OpenCode and compatible tools.
wiring a new host into `hosts/`, `outputs/`, networking, secrets, and the eval tests. wiring a new host into `hosts/`, `outputs/`, networking, secrets, and the eval tests.
- [`.agents/skills/nix-config-debug/SKILL.md`](./.agents/skills/nix-config-debug/SKILL.md) — mapping - [`.agents/skills/nix-config-debug/SKILL.md`](./.agents/skills/nix-config-debug/SKILL.md) — mapping
a broken eval, build, activation, unit, host, or MicroVM guest to the command that localizes it. a broken eval, build, activation, unit, host, or MicroVM guest to the command that localizes it.
- [`.agents/skills/nix-config-umu-game/SKILL.md`](./.agents/skills/nix-config-umu-game/SKILL.md) —
installing a Windows game launcher via the umu installer it bundles; per-game fixes stay under
`~/Games/`.
- [`.agents/skills/nixpkgs-review/SKILL.md`](./.agents/skills/nixpkgs-review/SKILL.md) — reviewing - [`.agents/skills/nixpkgs-review/SKILL.md`](./.agents/skills/nixpkgs-review/SKILL.md) — reviewing
nixpkgs PRs locally first, with GHA for cross-architecture or larger reviews. nixpkgs PRs locally first, with GHA for cross-architecture or larger reviews.
- [`.agents/skills/nixpkgs-patched/SKILL.md`](./.agents/skills/nixpkgs-patched/SKILL.md) — carrying - [`.agents/skills/nixpkgs-patched/SKILL.md`](./.agents/skills/nixpkgs-patched/SKILL.md) — carrying
+6 -27
View File
@@ -1,6 +1,5 @@
{ {
pkgs, pkgs,
osConfig,
config, config,
lib, lib,
... ...
@@ -12,7 +11,7 @@ in
{ {
options.modules.desktop = { options.modules.desktop = {
gaming = { gaming = {
enable = mkEnableOption "Install Game Suite(steam, lutris, etc)"; enable = mkEnableOption "Enable desktop gaming support";
}; };
}; };
@@ -20,19 +19,19 @@ in
# ========================================================================== # ==========================================================================
# Other Optimizations # Other Optimizations
# Usage: # Usage:
# Lutris - enable advanced options, go to the System options -> Command prefix, add: `mangohud` # UMU game launchers use MangoHud by default; set ENABLE_MANGOHUD=0 in their conf to disable.
# Steam - add this as a launch option: `mangohud %command%` / `gamemoderun %command%` # Steam - add this as a launch option: `mangohud %command%` / `gamemoderun %command%`
# ========================================================================== # ==========================================================================
# The nix-config-umu-game skill and the launchers it generates rely on
# umu-launcher, mangohud, winetricks and bbe from this list.
home.packages = home.packages =
(with pkgs; [ (with pkgs; [
# https://github.com/flightlessmango/MangoHud # https://github.com/flightlessmango/MangoHud
# a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more. # a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more.
mangohud mangohud
# GUI for installing custom Proton versions like GE_Proton
# proton - a Wine distribution aimed at gaming
protonplus
# Script to install various redistributable runtime libraries in Wine. # Script to install various redistributable runtime libraries in Wine.
winetricks winetricks
# https://github.com/Open-Wine-Components/umu-launcher # https://github.com/Open-Wine-Components/umu-launcher
@@ -53,25 +52,5 @@ in
}) })
]); ]);
# Game launchers for Epic/GOG/Ubisoft/etc. (use Steam + DWProton for common games)
# a GUI game launcher for Steam/GoG/Epic/Ubisoft
# https://lutris.net/games?ordering=-popularity
programs.lutris = {
enable = true;
defaultWinePackage = pkgs.proton-ge-bin;
steamPackage = osConfig.programs.steam.package;
protonPackages = [ pkgs.proton-ge-bin ];
winePackages = with pkgs; [
wineWow64Packages.full
];
extraPackages = with pkgs; [
winetricks
gamescope
gamemode
mangohud
umu-launcher
];
};
}; };
} }
+25 -9
View File
@@ -56,25 +56,41 @@ window-rule {
// Entertainment applications - open in workspace 4 // Entertainment applications - open in workspace 4
window-rule { window-rule {
match app-id="steam" // niri matches app-id anywhere in the string, so a bare "steam" would also
// catch the `steam_app_<GAMEID>` game windows below and hand them
// `open-maximized`, which fights their fullscreen rule. Anchor it so this
// only affects the Steam client window itself.
match app-id="^steam$"
open-on-workspace "4entertainment" open-on-workspace "4entertainment"
open-maximized true
} }
window-rule { window-rule {
match app-id="steam_app_default" match app-id="steam_app_default"
open-on-workspace "4entertainment" open-on-workspace "4entertainment"
} }
window-rule {
// Games launched through umu/Proton -- including the launchers the
// nix-config-umu-game skill generates -- report `steam_app_<GAMEID>`.
// Without a rule Niri tiles them at the default 50% column width, and a
// DXGI "fullscreen" window larger than the output (a 4K panel here runs at
// scale 1.5, so Niri's logical space is 2560x1440) cannot take over the
// screen by itself.
//
// The launcher shares this app id with the game, so the rule also catches
// its own windows: a helper that is still untitled while it maps, and the
// announcement popup (公告), which on this launcher renders as a pure white
// rectangle. Fullscreening either one buries the UI that opened it, so
// both are excluded here.
match app-id="^steam_app_[0-9]+$"
exclude title="^$"
exclude title="^(公告|Announcement|Notice)$"
open-on-workspace "4entertainment"
open-fullscreen true
}
window-rule { window-rule {
match app-id="heroic" match app-id="heroic"
open-on-workspace "4entertainment" open-on-workspace "4entertainment"
} }
window-rule {
match app-id="net.lutris.Lutris"
open-on-workspace "4entertainment"
}
window-rule {
match app-id="com.vysp3r.ProtonPlus"
open-on-workspace "4entertainment"
}
window-rule { window-rule {
// Run anime games on Linux // Run anime games on Linux
match app-id="^moe.launcher" match app-id="^moe.launcher"
+5 -2
View File
@@ -264,6 +264,11 @@ in
# ====================================== # ======================================
"Games" "Games"
# .desktop entries created by games/installers (including the umu
# launchers the nix-config-umu-game skill generates). Home Manager
# rewrites its mimeapps.list symlink into the bind mount at activation,
# so that survives too.
".local/share/applications"
".steam" ".steam"
".config/MangoHud" ".config/MangoHud"
".config/blender" ".config/blender"
@@ -273,8 +278,6 @@ in
".local/share/Steam" ".local/share/Steam"
".local/state/Heroic" ".local/state/Heroic"
".config/heroic" ".config/heroic"
".config/lutris"
".local/share/lutris"
".local/share/GOG.com" ".local/share/GOG.com"
".local/share/StardewValley" ".local/share/StardewValley"
+7 -1
View File
@@ -17,7 +17,7 @@ in
options.modules.desktop = { options.modules.desktop = {
gaming = { gaming = {
enable = mkEnableOption "Install Game Suite(steam, lutris, etc)"; enable = mkEnableOption "Enable desktop gaming support";
}; };
}; };
@@ -59,6 +59,12 @@ in
]; ];
}; };
# Expose DW-Proton to umu-run: umu ignores Steam's
# STEAM_EXTRA_COMPAT_TOOLS_PATHS, and a store path baked into a generated
# launcher goes stale on every dwproton-bin update. The nix-config-umu-game
# skill and the run/exec scripts it generates read this first.
environment.variables.UMU_PROTONPATH = "${pkgs.dwproton-bin.steamcompattool}";
# GameScope: run a game in its own nested compositor and let it renice # GameScope: run a game in its own nested compositor and let it renice
# itself for steadier frame pacing. GPU-specific PRIME render offload belongs # itself for steadier frame pacing. GPU-specific PRIME render offload belongs
# to the host, not here (see programs.gamescope.env in # to the host, not here (see programs.gamescope.env in
@@ -51,6 +51,12 @@ targets instead of writing a config the core rejects.
the IP rules are not decoration, Steam dials cached CM addresses without a lookup. Not the IP rules are not decoration, Steam dials cached CM addresses without a lookup. Not
`GEOSITE,steam` -- that category includes the community. Sources and how to verify: "Steam `GEOSITE,steam` -- that category includes the community. Sources and how to verify: "Steam
download region" below. download region" below.
- CDN selection depends on launcher probes, not the patch download route. On this host,
`launcher-webstatic.hoyoverse.com` went through a US proxy node while `autopatchcn.bhsr.com` was
DIRECT; the download rose from ~0.5 to 102 MB/s after the proxy group was switched to DIRECT.
`policy.yaml` bypasses proxy only for that observed probe, not game CDNs or broad vendor domains.
Verify future probes in `/connections` and add only confirmed hosts. Reference:
[HoyoPlay API endpoints](https://gist.github.com/DynamiByte/0ad250bbe1930e2736a6d4e6e842bcb2).
- Rule payloads are bare domains: `DOMAIN-SUFFIX,https://qlogo.cn` is invalid. - Rule payloads are bare domains: `DOMAIN-SUFFIX,https://qlogo.cn` is invalid.
- On a systemd-resolved host, sing-tun normally points resolved at the TUN DNS with `resolvectl`. - On a systemd-resolved host, sing-tun normally points resolved at the TUN DNS with `resolvectl`.
The nixpkgs `DynamicUser` sandbox cannot make those privileged D-Bus changes, so resolved's own The nixpkgs `DynamicUser` sandbox cannot make those privileged D-Bus changes, so resolved's own
@@ -102,6 +102,7 @@ rules:
- IP-CIDR,205.196.6.0/24,DIRECT,no-resolve - IP-CIDR,205.196.6.0/24,DIRECT,no-resolve
- IP-CIDR,208.64.200.0/22,DIRECT,no-resolve - IP-CIDR,208.64.200.0/22,DIRECT,no-resolve
- IP-CIDR,208.78.164.0/22,DIRECT,no-resolve - IP-CIDR,208.78.164.0/22,DIRECT,no-resolve
- DOMAIN,launcher-webstatic.hoyoverse.com,DIRECT
# CN consumer services: proxied they fail in ways that look like app bugs -- # CN consumer services: proxied they fail in ways that look like app bugs --
# WeChat pictures spin, JD and Taobao product images go blank. Much of it is # WeChat pictures spin, JD and Taobao product images go blank. Much of it is