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)