{ 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) # location = "install" (default, steamapps/common/) | "prefix" # (steamapps/compatdata//pfx — for user-storage mods) # # 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" ]; 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. ''; }; 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."; }; }; }; # 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; 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}) 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 != "create": print( f"game-mods: target '{target}' does not exist" f" (mode={mode}), skipping", file=sys.stderr, ) 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) verb = "created" if not target_existed else "replaced" print(f"game-mods: {verb} {target}", file=sys.stderr) def main(): if not MOD_DATA: print("game-mods: no mods configured", file=sys.stderr) return for entry in MOD_DATA: apply_mod( str(entry["steamAppId"]), entry["target"], entry["source"], entry["mode"], entry["location"], ) 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" ]; 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; }; }; }; }