{ 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 from Steam's own library metadata # (so it survives drive moves and library reconfigurations) and applies # the specified file operations. # # A systemd timer re-applies mods daily so Steam verify/update restores # don't silently undo them. # # Example: # services.gameMods.mods."fh5-no-intro" = { # steamAppId = 1551360; # files."media/UI/Videos/T10_MS_Combined.bk2" = { # empty = true; # }; # }; 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."; }; }; }; 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."; }; }; }; # Store each replacement file in the Nix store. gameModFiles = lib.mapAttrs ( _: mod: lib.mapAttrs ( 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" ) mod.files ) cfg.mods; # Generate mod data as JSON for the apply script. Each entry is # { appId, target (relative), source (Nix store path) }. modData = let entries = lib.concatLists ( lib.mapAttrsToList ( modName: mod: lib.mapAttrsToList (relPath: source: { inherit (mod) steamAppId; target = relPath; inherit source; }) (gameModFiles.${modName} or { }) ) 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 apply_mod(appid, target_rel, source): game_root = find_game_root(appid) if game_root is None: print( f"game-mods: Steam app {appid} not found" " in any library, skipping", file=sys.stderr, ) return target = os.path.join(game_root, target_rel) if not os.path.exists(target): print( f"game-mods: target '{target}'" " does not exist, skipping", file=sys.stderr, ) return # Already applied? if 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). backup = target + BACKUP_SUFFIX if not os.path.exists(backup): shutil.copy2(target, backup) print( f"game-mods: backed up {target} -> {backup}", file=sys.stderr, ) shutil.copy2(source, target) print(f"game-mods: replaced {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"], ) 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; }; }; }; }