From de0b5a6009fba095281a77e1406931b6ac2cb995 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 16:23:01 -0400 Subject: [PATCH] game-mods: init Add override for fh5 startup video --- hosts/yarn/default.nix | 13 ++ modules/desktop-game-mods.nix | 260 ++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 modules/desktop-game-mods.nix diff --git a/hosts/yarn/default.nix b/hosts/yarn/default.nix index 5b4e419..bd71dca 100644 --- a/hosts/yarn/default.nix +++ b/hosts/yarn/default.nix @@ -16,6 +16,7 @@ ./lact.nix ./vr.nix ./forza-trigger + ../../modules/desktop-game-mods.nix inputs.impermanence.nixosModules.impermanence ]; @@ -81,4 +82,16 @@ # PS5 DualSense adaptive triggers in Forza Horizon 4 / 5. services.forzaTrigger.enable = true; + + # Skip the 40 MB T10/Microsoft Studios intro video on startup. + # The module discovers FH5's install path from Steam's library metadata + # and replaces the BK2 file with a 0-byte stub. Re-applies daily via + # systemd timer so Steam verify/updates don't silently undo it. + services.gameMods = { + enable = true; + mods."fh5-no-intro" = { + steamAppId = 1551360; + files."media/UI/Videos/T10_MS_Combined.bk2".empty = true; + }; + }; } diff --git a/modules/desktop-game-mods.nix b/modules/desktop-game-mods.nix new file mode 100644 index 0000000..c4ec272 --- /dev/null +++ b/modules/desktop-game-mods.nix @@ -0,0 +1,260 @@ +{ + 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; + }; + }; + }; +}