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.
|
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
|
||||||
|
|||||||
@@ -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
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user