{ 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/) | "prefix" # (steamapps/compatdata//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/`). - `prefix`: the Proton/Wine prefix root (`steamapps/compatdata//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//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 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; }; }; }; }