From eb4cd0782d696d9494c6d47e9040fad60eb13c33 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Sat, 2 May 2026 20:08:09 -0400 Subject: [PATCH] game-mod: extend module --- hosts/yarn/default.nix | 6 ++ modules/desktop-game-mods.nix | 143 ++++++++++++++++++++++++---------- 2 files changed, 110 insertions(+), 39 deletions(-) diff --git a/hosts/yarn/default.nix b/hosts/yarn/default.nix index bd71dca..abc5fa6 100644 --- a/hosts/yarn/default.nix +++ b/hosts/yarn/default.nix @@ -87,11 +87,17 @@ # 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. + # + # Two copies live in the install: media/UI/Videos/T10_MS_Combined.bk2 + # (SD) and .../hires/T10_MS_Combined.bk2 (hires). The engine picks one + # based on the installed asset profile, so we stub both for guaranteed + # coverage. PCGamingWiki documents both paths under "Skip intro video". services.gameMods = { enable = true; mods."fh5-no-intro" = { steamAppId = 1551360; files."media/UI/Videos/T10_MS_Combined.bk2".empty = true; + files."media/UI/Videos/hires/T10_MS_Combined.bk2".empty = true; }; }; } diff --git a/modules/desktop-game-mods.nix b/modules/desktop-game-mods.nix index c4ec272..8da04fd 100644 --- a/modules/desktop-game-mods.nix +++ b/modules/desktop-game-mods.nix @@ -6,18 +6,27 @@ }: # 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. +# 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. # -# Example: -# services.gameMods.mods."fh5-no-intro" = { +# 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."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 @@ -42,6 +51,36 @@ let 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/...`. + ''; + }; }; }; @@ -59,32 +98,29 @@ let }; }; - # 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; + # 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 - # { appId, target (relative), source (Nix store path) }. + # 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: source: { + _modName: mod: + lib.mapAttrsToList (relPath: op: { inherit (mod) steamAppId; target = relPath; - inherit source; - }) (gameModFiles.${modName} or { }) + source = resolveSource relPath op; + inherit (op) mode location; + }) mod.files ) cfg.mods ); in @@ -155,42 +191,69 @@ let return None - def apply_mod(appid, target_rel, source): - game_root = find_game_root(appid) - if game_root is 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: Steam app {appid} not found" - " in any library, skipping", + f"game-mods: {location} root for app {appid}" + " not found, skipping", file=sys.stderr, ) return - target = os.path.join(game_root, target_rel) - if not os.path.exists(target): + 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, skipping", + f"game-mods: target '{target}' does not exist" + f" (mode={mode}), skipping", file=sys.stderr, ) return # Already applied? - if os.path.isfile(target) and os.path.isfile(source): + 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). + # Back up the original (once), if it existed. backup = target + BACKUP_SUFFIX - if not os.path.exists(backup): + 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) - print(f"game-mods: replaced {target}", file=sys.stderr) + verb = "created" if not target_existed else "replaced" + print(f"game-mods: {verb} {target}", file=sys.stderr) def main(): @@ -203,6 +266,8 @@ let str(entry["steamAppId"]), entry["target"], entry["source"], + entry["mode"], + entry["location"], ) print("game-mods: done", file=sys.stderr)