game-mods: list-merged launchOptions, init mode, writable targets

Three additions on top of the file-replacement scaffolding:

- mode = "init": create-on-first-apply, leave-alone-otherwise. For
  files the application writes back to (configs edited in-game, save
  files). Operator pushes a new template by deleting the target.

- chmod 644 after every copy. shutil.copy2 preserved the source's
  /nix/store mode (0o444), which made dropped DLL configs read-only.
  Apps that wrote back (OptiScaler "Save INI") got EACCES, which in
  OptiScaler's case cascaded into CreateSwapChainForHwnd returning
  E_FAIL and crashed FH5 on launch.

- launchOptions = listOf str. Multiple mods targeting the same
  steamAppId have their lists concatenated (mod-name alphabetical),
  joined with spaces, %command% appended once. Written into Steam's
  per-app block in userdata/<id>/config/localconfig.vdf via vdf
  parse + atomic os.replace. Idempotent.

- X-ConfigHash on the systemd unit so switch-to-configuration switch
  re-runs apply when the manifest changes.
This commit is contained in:
2026-05-02 23:15:25 -04:00
parent b25cb4a90f
commit 9250147c36

View File

@@ -14,7 +14,10 @@
# don't silently undo them. # don't silently undo them.
# #
# Each file op picks two axes: # Each file op picks two axes:
# mode = "replace" (default, skip if absent) | "create" (mkdir + write) # 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" # location = "install" (default, steamapps/common/<dir>) | "prefix"
# (steamapps/compatdata/<appid>/pfx — for user-storage mods) # (steamapps/compatdata/<appid>/pfx — for user-storage mods)
# #
@@ -55,6 +58,7 @@ let
type = types.enum [ type = types.enum [
"replace" "replace"
"create" "create"
"init"
]; ];
default = "replace"; default = "replace";
description = '' description = ''
@@ -64,6 +68,11 @@ let
A backup is written next to the target on first apply. A backup is written next to the target on first apply.
- `create`: create the target (and parent directories) if absent; replace it - `create`: create the target (and parent directories) if absent; replace it
otherwise. No backup is written when the target did not exist. 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 { location = mkOption {
@@ -95,6 +104,27 @@ let
default = { }; default = { };
description = "Files to modify, keyed by path relative to game root."; 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`).
'';
};
}; };
}; };
@@ -126,6 +156,28 @@ let
in in
builtins.toJSON entries; 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 ]); python = pkgs.python3.withPackages (ps: [ ps.vdf ]);
gameModsApply = gameModsApply =
@@ -142,7 +194,7 @@ let
import vdf import vdf
MOD_DATA = json.loads(${lib.escapeShellArg modData}) MOD_DATA = json.loads(${lib.escapeShellArg modData})
BACKUP_SUFFIX = ".nix-backup" LAUNCH_OPTIONS_DATA = json.loads(${lib.escapeShellArg launchOptionsData})
def find_steam_root(): def find_steam_root():
@@ -225,7 +277,7 @@ let
target = os.path.join(root, target_rel) target = os.path.join(root, target_rel)
target_existed = os.path.exists(target) target_existed = os.path.exists(target)
if not target_existed and mode != "create": if not target_existed and mode == "replace":
print( print(
f"game-mods: target '{target}' does not exist" f"game-mods: target '{target}' does not exist"
f" (mode={mode}), skipping", f" (mode={mode}), skipping",
@@ -233,6 +285,14 @@ let
) )
return 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? # Already applied?
if target_existed and os.path.isfile(target) and os.path.isfile(source): if target_existed and os.path.isfile(target) and os.path.isfile(source):
with open(target, "rb") as ft, open(source, "rb") as fs: with open(target, "rb") as ft, open(source, "rb") as fs:
@@ -252,23 +312,109 @@ let
os.makedirs(os.path.dirname(target), exist_ok=True) os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copy2(source, target) shutil.copy2(source, target)
verb = "created" if not target_existed else "replaced" # 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) print(f"game-mods: {verb} {target}", file=sys.stderr)
def main():
if not MOD_DATA:
print("game-mods: no mods configured", 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 return
for entry in MOD_DATA: steam_root = find_steam_root()
apply_mod( userdata_root = os.path.join(steam_root, "userdata")
str(entry["steamAppId"]), if not os.path.isdir(userdata_root):
entry["target"], print(
entry["source"], "game-mods: userdata dir not found, skipping launch options",
entry["mode"], file=sys.stderr,
entry["location"],
) )
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) print("game-mods: done", file=sys.stderr)
@@ -304,6 +450,12 @@ in
"media-games.mount" "media-games.mount"
"local-fs.target" "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 = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
ExecStart = "${gameModsApply}/bin/game-mods-apply"; ExecStart = "${gameModsApply}/bin/game-mods-apply";