diff --git a/modules/desktop-game-mods.nix b/modules/desktop-game-mods.nix
index 8da04fd..9ce8ee7 100644
--- a/modules/desktop-game-mods.nix
+++ b/modules/desktop-game-mods.nix
@@ -14,7 +14,10 @@
# don't silently undo them.
#
# 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/
) | "prefix"
# (steamapps/compatdata//pfx — for user-storage mods)
#
@@ -55,6 +58,7 @@ let
type = types.enum [
"replace"
"create"
+ "init"
];
default = "replace";
description = ''
@@ -64,6 +68,11 @@ let
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 {
@@ -95,6 +104,27 @@ let
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`).
+ '';
+ };
};
};
@@ -126,6 +156,28 @@ let
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 =
@@ -142,7 +194,7 @@ let
import vdf
MOD_DATA = json.loads(${lib.escapeShellArg modData})
- BACKUP_SUFFIX = ".nix-backup"
+ LAUNCH_OPTIONS_DATA = json.loads(${lib.escapeShellArg launchOptionsData})
def find_steam_root():
@@ -225,7 +277,7 @@ let
target = os.path.join(root, target_rel)
target_existed = os.path.exists(target)
- if not target_existed and mode != "create":
+ if not target_existed and mode == "replace":
print(
f"game-mods: target '{target}' does not exist"
f" (mode={mode}), skipping",
@@ -233,6 +285,14 @@ let
)
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:
@@ -252,23 +312,109 @@ let
os.makedirs(os.path.dirname(target), exist_ok=True)
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)
- 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//config/localconfig.vdf
+ → UserLocalConfigStore → Software → Valve → Steam
+ → apps → "" → 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
- for entry in MOD_DATA:
- apply_mod(
- str(entry["steamAppId"]),
- entry["target"],
- entry["source"],
- entry["mode"],
- entry["location"],
+ 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)
@@ -304,6 +450,12 @@ in
"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";