game-mod: extend module

This commit is contained in:
2026-05-02 20:08:09 -04:00
parent 07583b6f96
commit bb983a88e2
2 changed files with 110 additions and 39 deletions

View File

@@ -87,11 +87,17 @@
# The module discovers FH5's install path from Steam's library metadata # 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 # 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. # 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 = { services.gameMods = {
enable = true; enable = true;
mods."fh5-no-intro" = { mods."fh5-no-intro" = {
steamAppId = 1551360; steamAppId = 1551360;
files."media/UI/Videos/T10_MS_Combined.bk2".empty = true; files."media/UI/Videos/T10_MS_Combined.bk2".empty = true;
files."media/UI/Videos/hires/T10_MS_Combined.bk2".empty = true;
}; };
}; };
} }

View File

@@ -6,18 +6,27 @@
}: }:
# Declarative game file mods. Define per-game file replacements keyed by # Declarative game file mods. Define per-game file replacements keyed by
# Steam App ID. The module produces a `game-mods-apply` script that # Steam App ID. The module produces a `game-mods-apply` script that
# discovers the game install directory from Steam's own library metadata # discovers the game install directory (and Proton prefix) from Steam's
# (so it survives drive moves and library reconfigurations) and applies # own library metadata, so it survives drive moves and library
# the specified file operations. # reconfigurations.
# #
# A systemd timer re-applies mods daily so Steam verify/update restores # A systemd timer re-applies mods daily so Steam verify/update restores
# don't silently undo them. # don't silently undo them.
# #
# Example: # Each file op picks two axes:
# services.gameMods.mods."fh5-no-intro" = { # mode = "replace" (default, skip if absent) | "create" (mkdir + write)
# location = "install" (default, steamapps/common/<dir>) | "prefix"
# (steamapps/compatdata/<appid>/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; # steamAppId = 1551360;
# files."media/UI/Videos/T10_MS_Combined.bk2" = { # files."media/UI/Videos/T10_MS_Combined.bk2".empty = true;
# 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 let
@@ -42,6 +51,36 @@ let
default = null; default = null;
description = "Replace the file with contents from this store path."; 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/<dir>`).
- `prefix`: the Proton/Wine prefix root (`steamapps/compatdata/<appid>/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. # Resolve the on-disk source for a single file op (empty stub or user-provided path).
gameModFiles = lib.mapAttrs ( resolveSource =
_: mod: relPath: op:
lib.mapAttrs ( if op.empty then
relPath: op: pkgs.runCommand "game-mod-empty" { } "touch $out"
if op.empty then else if op.source != null then
pkgs.runCommand "game-mod-empty" { } "touch $out" op.source
else if op.source != null then else
op.source throw "game-mod file '${relPath}': set empty = true or provide a 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 # Generate mod data as JSON for the apply script. Each entry is
# { appId, target (relative), source (Nix store path) }. # { steamAppId, target, source, mode, location }.
modData = modData =
let let
entries = lib.concatLists ( entries = lib.concatLists (
lib.mapAttrsToList ( lib.mapAttrsToList (
modName: mod: _modName: mod:
lib.mapAttrsToList (relPath: source: { lib.mapAttrsToList (relPath: op: {
inherit (mod) steamAppId; inherit (mod) steamAppId;
target = relPath; target = relPath;
inherit source; source = resolveSource relPath op;
}) (gameModFiles.${modName} or { }) inherit (op) mode location;
}) mod.files
) cfg.mods ) cfg.mods
); );
in in
@@ -155,42 +191,69 @@ let
return None return None
def apply_mod(appid, target_rel, source): def find_compatdata_pfx(appid):
game_root = find_game_root(appid) """Return the Proton/Wine prefix root for `appid`, or None if not found."""
if game_root is None: 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( print(
f"game-mods: Steam app {appid} not found" f"game-mods: {location} root for app {appid}"
" in any library, skipping", " not found, skipping",
file=sys.stderr, file=sys.stderr,
) )
return return
target = os.path.join(game_root, target_rel) target = os.path.join(root, target_rel)
if not os.path.exists(target): target_existed = os.path.exists(target)
if not target_existed and mode != "create":
print( print(
f"game-mods: target '{target}'" f"game-mods: target '{target}' does not exist"
" does not exist, skipping", f" (mode={mode}), skipping",
file=sys.stderr, file=sys.stderr,
) )
return return
# Already applied? # 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: with open(target, "rb") as ft, open(source, "rb") as fs:
if ft.read() == fs.read(): if ft.read() == fs.read():
return return
# Back up the original (once). # Back up the original (once), if it existed.
backup = target + BACKUP_SUFFIX backup = target + BACKUP_SUFFIX
if not os.path.exists(backup): if target_existed and not os.path.exists(backup):
shutil.copy2(target, backup) shutil.copy2(target, backup)
print( print(
f"game-mods: backed up {target} -> {backup}", f"game-mods: backed up {target} -> {backup}",
file=sys.stderr, file=sys.stderr,
) )
if not target_existed:
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copy2(source, target) 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(): def main():
@@ -203,6 +266,8 @@ let
str(entry["steamAppId"]), str(entry["steamAppId"]),
entry["target"], entry["target"],
entry["source"], entry["source"],
entry["mode"],
entry["location"],
) )
print("game-mods: done", file=sys.stderr) print("game-mods: done", file=sys.stderr)