mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:36 +01:00
feat(gaming): generic umu game installer, skill, and desktop entries (#411)
This commit is contained in:
1 parent
efd279c7d1
commit
9e2633f496
14 files changed
+901
-39
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -109,6 +109,9 @@ is discovered by OpenCode and compatible tools.
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
pkgs,
|
||||
osConfig,
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
@@ -12,7 +11,7 @@ in
|
||||
{
|
||||
options.modules.desktop = {
|
||||
gaming = {
|
||||
enable = mkEnableOption "Install Game Suite(steam, lutris, etc)";
|
||||
enable = mkEnableOption "Enable desktop gaming support";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,19 +19,19 @@ in
|
||||
# ==========================================================================
|
||||
# Other Optimizations
|
||||
# Usage:
|
||||
# Lutris - enable advanced options, go to the System options -> Command prefix, add: `mangohud`
|
||||
# Steam - add this as a launch option: `mangohud %command%` / `gamemoderun %command%`
|
||||
# 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%`
|
||||
# ==========================================================================
|
||||
|
||||
# The nix-config-umu-game skill and the launchers it generates rely on
|
||||
# umu-launcher, mangohud, winetricks and bbe from this list.
|
||||
|
||||
home.packages =
|
||||
(with pkgs; [
|
||||
# https://github.com/flightlessmango/MangoHud
|
||||
# a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more.
|
||||
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.
|
||||
winetricks
|
||||
# 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
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -56,25 +56,41 @@ window-rule {
|
||||
|
||||
// Entertainment applications - open in workspace 4
|
||||
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-maximized true
|
||||
}
|
||||
window-rule {
|
||||
match app-id="steam_app_default"
|
||||
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 {
|
||||
match app-id="heroic"
|
||||
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 {
|
||||
// Run anime games on Linux
|
||||
match app-id="^moe.launcher"
|
||||
|
||||
@@ -264,6 +264,11 @@ in
|
||||
# ======================================
|
||||
|
||||
"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"
|
||||
".config/MangoHud"
|
||||
".config/blender"
|
||||
@@ -273,8 +278,6 @@ in
|
||||
".local/share/Steam"
|
||||
".local/state/Heroic"
|
||||
".config/heroic"
|
||||
".config/lutris"
|
||||
".local/share/lutris"
|
||||
|
||||
".local/share/GOG.com"
|
||||
".local/share/StardewValley"
|
||||
|
||||
@@ -17,7 +17,7 @@ in
|
||||
|
||||
options.modules.desktop = {
|
||||
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
|
||||
# itself for steadier frame pacing. GPU-specific PRIME render offload belongs
|
||||
# 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
|
||||
`GEOSITE,steam` -- that category includes the community. Sources and how to verify: "Steam
|
||||
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.
|
||||
- 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
|
||||
|
||||
@@ -102,6 +102,7 @@ rules:
|
||||
- IP-CIDR,205.196.6.0/24,DIRECT,no-resolve
|
||||
- IP-CIDR,208.64.200.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 --
|
||||
# WeChat pictures spin, JD and Taobao product images go blank. Much of it is
|
||||
|
||||
Reference in new issue
Block a user