Three small follow-ups to 1751603:
- BACKUP_SUFFIX was lost during the launchOptions refactor. apply_mod
references it on every non-skip path (new target, drifted bytes, or
replace mode), so the moment a deployment hit one of those, the
service would NameError at runtime. The bug was latent on yarn
because every dropped file's bytes already matched its source, so
every apply short-circuited at the byte-match check; an empirical
rm libxell.dll + systemctl start reproduced the NameError before
the fix and showed a successful recreate after.
- Mention launchOptions in the leading file docstring. The Example
block already covers file ops; the new option had no entry-level
doc.
- Normalize blank lines between top-level Python defs in the heredoc
(PEP-8 wants exactly two: we had four between apply_mod and
apply_launch_options, zero between apply_launch_options and main).
485 lines
18 KiB
Nix
485 lines
18 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
# Declarative game file mods. Define per-game file replacements keyed by
|
|
# Steam App ID. The module produces a `game-mods-apply` script that
|
|
# discovers the game install directory (and Proton prefix) from Steam's
|
|
# own library metadata, so it survives drive moves and library
|
|
# reconfigurations.
|
|
#
|
|
# A systemd timer re-applies mods daily so Steam verify/update restores
|
|
# don't silently undo them.
|
|
#
|
|
# Each file op picks two axes:
|
|
# mode = "replace" (default, skip if absent)
|
|
# | "create" (mkdir + write, idempotent)
|
|
# | "init" (create if absent, leave alone otherwise — for
|
|
# files the game writes back to)
|
|
# location = "install" (default, steamapps/common/<dir>) | "prefix"
|
|
# (steamapps/compatdata/<appid>/pfx — for user-storage mods)
|
|
#
|
|
# Each mod can also declare `launchOptions = [ "FOO=bar" ]`. Lists from
|
|
# every mod targeting the same Steam App ID are concatenated (mod-name
|
|
# alphabetical), joined with spaces, and `%command%` is appended once.
|
|
# The result is written into Steam's per-app block in localconfig.vdf
|
|
# so it persists across Steam restarts.
|
|
#
|
|
# Example: stub the cold-start intro video, plus drop a sound mod XML into
|
|
# the Wine prefix's user storage.
|
|
# services.gameMods.mods."fh5" = {
|
|
# steamAppId = 1551360;
|
|
# files."media/UI/Videos/T10_MS_Combined.bk2".empty = true;
|
|
# files."drive_c/users/steamuser/AppData/Local/ForzaHorizon5/User_SteamLocalStorageDirectory/ConnectedStorage/ForzaUserConfigSelections/mods/audio/foo.xml" = {
|
|
# source = ./files/foo.xml;
|
|
# location = "prefix";
|
|
# mode = "create";
|
|
# };
|
|
# };
|
|
let
|
|
cfg = config.services.gameMods;
|
|
|
|
inherit (lib)
|
|
types
|
|
mkIf
|
|
mkEnableOption
|
|
mkOption
|
|
;
|
|
|
|
fileOpType = types.submodule {
|
|
options = {
|
|
empty = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Replace the file with an empty (0-byte) file.";
|
|
};
|
|
source = mkOption {
|
|
type = types.nullOr types.path;
|
|
default = null;
|
|
description = "Replace the file with contents from this store path.";
|
|
};
|
|
mode = mkOption {
|
|
type = types.enum [
|
|
"replace"
|
|
"create"
|
|
"init"
|
|
];
|
|
default = "replace";
|
|
description = ''
|
|
How to handle a missing target.
|
|
|
|
- `replace` (default): only act when the target already exists; otherwise skip.
|
|
A backup is written next to the target on first apply.
|
|
- `create`: create the target (and parent directories) if absent; replace it
|
|
otherwise. No backup is written when the target did not exist.
|
|
- `init`: like `create` but only on first apply. If the target already exists,
|
|
leave it alone, even if its content differs from the source. Use this for
|
|
files the application is expected to write back to (config files edited by
|
|
in-game overlays, save files, etc.) so user changes persist across mod
|
|
re-applies. To push a new template, delete the target and re-run.
|
|
'';
|
|
};
|
|
location = mkOption {
|
|
type = types.enum [
|
|
"install"
|
|
"prefix"
|
|
];
|
|
default = "install";
|
|
description = ''
|
|
Root the relative target path is resolved against.
|
|
|
|
- `install` (default): the Steam install directory (`steamapps/common/<dir>`).
|
|
- `prefix`: the Proton/Wine prefix root (`steamapps/compatdata/<appid>/pfx`).
|
|
Use this for mods that write into the game's user storage under
|
|
`drive_c/users/steamuser/AppData/...`.
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
modType = types.submodule {
|
|
options = {
|
|
steamAppId = mkOption {
|
|
type = types.ints.positive;
|
|
description = "Steam App ID of the game.";
|
|
};
|
|
files = mkOption {
|
|
type = types.attrsOf fileOpType;
|
|
default = { };
|
|
description = "Files to modify, keyed by path relative to game root.";
|
|
};
|
|
launchOptions = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [
|
|
"PROTON_FSR4_UPGRADE=1"
|
|
"DXIL_SPIRV_CONFIG=wmma_rdna3_workaround"
|
|
];
|
|
description = ''
|
|
Components to prepend to Steam's per-app LaunchOptions string.
|
|
Every mod that targets the same steamAppId contributes; the lists
|
|
are concatenated (in mod-name alphabetical order, preserving each
|
|
mod's internal order), joined with single spaces, and the literal
|
|
`%command%` is appended once at the end. List entries must not
|
|
contain `%command%` themselves.
|
|
|
|
An empty list (default) leaves Steam's existing LaunchOptions value
|
|
untouched, so Steam-UI edits and other games' launch options are
|
|
unaffected. Typical entries are env-var assignments
|
|
(`PROTON_FSR4_UPGRADE=1`) or wrappers (`gamemoderun`).
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
# Resolve the on-disk source for a single file op (empty stub or user-provided path).
|
|
resolveSource =
|
|
relPath: op:
|
|
if op.empty then
|
|
pkgs.runCommand "game-mod-empty" { } "touch $out"
|
|
else if op.source != null then
|
|
op.source
|
|
else
|
|
throw "game-mod file '${relPath}': set empty = true or provide a source";
|
|
|
|
# Generate mod data as JSON for the apply script. Each entry is
|
|
# { steamAppId, target, source, mode, location }.
|
|
modData =
|
|
let
|
|
entries = lib.concatLists (
|
|
lib.mapAttrsToList (
|
|
_modName: mod:
|
|
lib.mapAttrsToList (relPath: op: {
|
|
inherit (mod) steamAppId;
|
|
target = relPath;
|
|
source = resolveSource relPath op;
|
|
inherit (op) mode location;
|
|
}) mod.files
|
|
) cfg.mods
|
|
);
|
|
in
|
|
builtins.toJSON entries;
|
|
|
|
# Launch-options data as JSON. Mods are grouped by steamAppId, their
|
|
# launchOptions lists are concatenated (mod-name alphabetical order from
|
|
# mapAttrsToList), joined with spaces, and `%command%` is appended once.
|
|
# AppIds whose total contribution is empty are omitted entirely so the
|
|
# apply script leaves Steam's existing LaunchOptions field untouched.
|
|
launchOptionsData =
|
|
let
|
|
perMod = lib.mapAttrsToList (_modName: mod: {
|
|
appId = toString mod.steamAppId;
|
|
inherit (mod) launchOptions;
|
|
}) cfg.mods;
|
|
grouped = lib.foldl' (
|
|
acc: e: acc // { ${e.appId} = (acc.${e.appId} or [ ]) ++ e.launchOptions; }
|
|
) { } perMod;
|
|
nonEmpty = lib.filterAttrs (_appId: parts: parts != [ ]) grouped;
|
|
entries = lib.mapAttrsToList (appId: parts: {
|
|
steamAppId = lib.toInt appId;
|
|
launchOptions = (lib.concatStringsSep " " parts) + " %command%";
|
|
}) nonEmpty;
|
|
in
|
|
builtins.toJSON entries;
|
|
|
|
python = pkgs.python3.withPackages (ps: [ ps.vdf ]);
|
|
|
|
gameModsApply =
|
|
pkgs.writers.writePython3Bin "game-mods-apply"
|
|
{
|
|
libraries = [ python.pkgs.vdf ];
|
|
doCheck = false;
|
|
}
|
|
''
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import vdf
|
|
|
|
MOD_DATA = json.loads(${lib.escapeShellArg modData})
|
|
LAUNCH_OPTIONS_DATA = json.loads(${lib.escapeShellArg launchOptionsData})
|
|
BACKUP_SUFFIX = ".nix-backup"
|
|
|
|
|
|
def find_steam_root():
|
|
"""Resolve the canonical Steam root directory."""
|
|
steam_link = os.path.expanduser("~/.steam/root")
|
|
if os.path.islink(steam_link):
|
|
return os.path.realpath(steam_link)
|
|
alt = os.path.expanduser("~/.steam/steam")
|
|
if os.path.isdir(alt):
|
|
return alt
|
|
return os.path.expanduser("~/.local/share/Steam")
|
|
|
|
|
|
def find_libraries(steam_root):
|
|
"""Return list of Steam library paths from libraryfolders.vdf."""
|
|
vdf_path = os.path.join(steam_root, "config", "libraryfolders.vdf")
|
|
if not os.path.isfile(vdf_path):
|
|
return []
|
|
with open(vdf_path) as f:
|
|
data = vdf.load(f, mapper=dict)
|
|
libraries = []
|
|
for key, entry in data.get("libraryfolders", {}).items():
|
|
if "path" in entry:
|
|
libraries.append(entry["path"])
|
|
return libraries
|
|
|
|
|
|
def find_game_root(appid):
|
|
"""Return the game install directory, or None if not found."""
|
|
steam_root = find_steam_root()
|
|
for lib_path in find_libraries(steam_root):
|
|
manifest = os.path.join(
|
|
lib_path, "steamapps", f"appmanifest_{appid}.acf"
|
|
)
|
|
if not os.path.isfile(manifest):
|
|
continue
|
|
with open(manifest) as f:
|
|
data = vdf.load(f, mapper=dict)
|
|
installdir = data.get("AppState", {}).get("installdir")
|
|
if installdir:
|
|
full = os.path.join(
|
|
lib_path, "steamapps", "common", installdir
|
|
)
|
|
if os.path.isdir(full):
|
|
return full
|
|
return None
|
|
|
|
|
|
def find_compatdata_pfx(appid):
|
|
"""Return the Proton/Wine prefix root for `appid`, or None if not found."""
|
|
steam_root = find_steam_root()
|
|
for lib_path in find_libraries(steam_root):
|
|
pfx = os.path.join(
|
|
lib_path, "steamapps", "compatdata", str(appid), "pfx"
|
|
)
|
|
if os.path.isdir(pfx):
|
|
return pfx
|
|
return None
|
|
|
|
|
|
def find_root(appid, location):
|
|
"""Resolve the directory `location`-relative paths apply under."""
|
|
if location == "install":
|
|
return find_game_root(appid)
|
|
if location == "prefix":
|
|
return find_compatdata_pfx(appid)
|
|
raise ValueError(f"unknown location {location!r}")
|
|
|
|
|
|
def apply_mod(appid, target_rel, source, mode, location):
|
|
root = find_root(appid, location)
|
|
if root is None:
|
|
print(
|
|
f"game-mods: {location} root for app {appid}"
|
|
" not found, skipping",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
target = os.path.join(root, target_rel)
|
|
target_existed = os.path.exists(target)
|
|
|
|
if not target_existed and mode == "replace":
|
|
print(
|
|
f"game-mods: target '{target}' does not exist"
|
|
f" (mode={mode}), skipping",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
# init mode: write only on first apply, then it's user-owned. Even if
|
|
# the source content has drifted (e.g. a new tuning landed in the host
|
|
# config), leave the on-disk file alone so in-game overlay edits and
|
|
# other user changes are preserved across re-applies. Operator pushes
|
|
# a new template by deleting the target and re-running.
|
|
if target_existed and mode == "init":
|
|
return
|
|
|
|
# Already applied?
|
|
if target_existed and os.path.isfile(target) and os.path.isfile(source):
|
|
with open(target, "rb") as ft, open(source, "rb") as fs:
|
|
if ft.read() == fs.read():
|
|
return
|
|
|
|
# Back up the original (once), if it existed.
|
|
backup = target + BACKUP_SUFFIX
|
|
if target_existed and not os.path.exists(backup):
|
|
shutil.copy2(target, backup)
|
|
print(
|
|
f"game-mods: backed up {target} -> {backup}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
if not target_existed:
|
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
|
|
shutil.copy2(source, target)
|
|
# The source lives in /nix/store with mode 0o444. shutil.copy2 carries
|
|
# those bits across, leaving the dropped file read-only. Apps that
|
|
# write back to their own config (e.g. OptiScaler's "Save INI") then
|
|
# silently fail with EACCES. Force a writable mode so user/in-app
|
|
# edits can persist; init mode then keeps them across re-applies.
|
|
os.chmod(target, 0o644)
|
|
if mode == "init":
|
|
verb = "initialized"
|
|
elif target_existed:
|
|
verb = "replaced"
|
|
else:
|
|
verb = "created"
|
|
print(f"game-mods: {verb} {target}", file=sys.stderr)
|
|
|
|
|
|
def apply_launch_options():
|
|
"""Write declarative launch options into Steam's localconfig.vdf.
|
|
|
|
Steam stores per-app launch options at:
|
|
userdata/<accountid>/config/localconfig.vdf
|
|
→ UserLocalConfigStore → Software → Valve → Steam
|
|
→ apps → "<appid>" → LaunchOptions
|
|
|
|
We iterate every userdata directory so multi-account setups work.
|
|
Writes are idempotent (skip when the value already matches).
|
|
An atomic write (temp + os.replace) avoids torn reads by Steam.
|
|
"""
|
|
if not LAUNCH_OPTIONS_DATA:
|
|
print("game-mods: no launch options configured", file=sys.stderr)
|
|
return
|
|
|
|
steam_root = find_steam_root()
|
|
userdata_root = os.path.join(steam_root, "userdata")
|
|
if not os.path.isdir(userdata_root):
|
|
print(
|
|
"game-mods: userdata dir not found, skipping launch options",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
for entry in LAUNCH_OPTIONS_DATA:
|
|
appid = str(entry["steamAppId"])
|
|
desired_lo = entry["launchOptions"]
|
|
|
|
for account_dir in sorted(os.listdir(userdata_root)):
|
|
config_path = os.path.join(
|
|
userdata_root, account_dir, "config", "localconfig.vdf"
|
|
)
|
|
if not os.path.isfile(config_path):
|
|
continue
|
|
|
|
try:
|
|
with open(config_path, "r", errors="replace") as f:
|
|
data = vdf.load(f, mapper=dict)
|
|
except Exception as exc:
|
|
print(
|
|
f"game-mods: failed to parse {config_path}: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
|
|
apps = (
|
|
data.setdefault("UserLocalConfigStore", {})
|
|
.setdefault("Software", {})
|
|
.setdefault("Valve", {})
|
|
.setdefault("Steam", {})
|
|
.setdefault("apps", {})
|
|
)
|
|
app_block = apps.setdefault(appid, {})
|
|
|
|
current_lo = app_block.get("LaunchOptions", "")
|
|
if current_lo == desired_lo:
|
|
continue # already applied, skip write
|
|
|
|
app_block["LaunchOptions"] = desired_lo
|
|
|
|
tmp_path = config_path + ".tmp"
|
|
with open(tmp_path, "w") as f:
|
|
vdf.dump(data, f, pretty=True)
|
|
os.replace(tmp_path, config_path)
|
|
|
|
print(
|
|
f"game-mods: set launch options for app {appid}"
|
|
f" in account {account_dir}:"
|
|
f" {desired_lo!r}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
print("game-mods: launch options done", file=sys.stderr)
|
|
|
|
|
|
def main():
|
|
if MOD_DATA:
|
|
for entry in MOD_DATA:
|
|
apply_mod(
|
|
str(entry["steamAppId"]),
|
|
entry["target"],
|
|
entry["source"],
|
|
entry["mode"],
|
|
entry["location"],
|
|
)
|
|
|
|
apply_launch_options()
|
|
|
|
print("game-mods: done", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
'';
|
|
in
|
|
{
|
|
options.services.gameMods = {
|
|
enable = mkEnableOption "declarative game file mods";
|
|
|
|
mods = mkOption {
|
|
type = types.attrsOf modType;
|
|
default = { };
|
|
description = "Game mods keyed by a short name. Each mod specifies a Steam App ID and a set of file operations.";
|
|
};
|
|
|
|
autoApply = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = "Whether to create systemd timer/service units to re-apply mods automatically.";
|
|
};
|
|
};
|
|
|
|
config = mkIf cfg.enable {
|
|
environment.systemPackages = [ gameModsApply ];
|
|
|
|
systemd.services.game-mods = mkIf cfg.autoApply {
|
|
description = "Apply declarative game file mods";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [
|
|
"media-games.mount"
|
|
"local-fs.target"
|
|
];
|
|
# Hash of the materialized mod manifest. Embedding it as a custom [Unit]
|
|
# field forces the unit text to change whenever any mod entry, source
|
|
# path, mode, or location changes — so `switch-to-configuration switch`
|
|
# re-runs the apply step on the same boot instead of waiting for the
|
|
# next daily timer firing. systemd ignores X- prefixed unit fields.
|
|
unitConfig.X-ConfigHash = builtins.hashString "sha256" (modData + launchOptionsData);
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${gameModsApply}/bin/game-mods-apply";
|
|
RemainAfterExit = "no";
|
|
User = "primary";
|
|
};
|
|
};
|
|
|
|
systemd.timers.game-mods = mkIf cfg.autoApply {
|
|
description = "Daily re-apply of game file mods";
|
|
wantedBy = [ "timers.target" ];
|
|
timerConfig = {
|
|
OnCalendar = "daily";
|
|
Persistent = true;
|
|
RandomizedDelaySec = 1800;
|
|
};
|
|
};
|
|
};
|
|
}
|