From 9250147c36319c1aa6df360b3cbd8f796cb0b172 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Sat, 2 May 2026 23:15:25 -0400 Subject: [PATCH] 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//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. --- modules/desktop-game-mods.nix | 180 +++++++++++++++++++++++++++++++--- 1 file changed, 166 insertions(+), 14 deletions(-) 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";