game-mod: extend module
This commit is contained in:
@@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
|
||||||
lib.mapAttrs (
|
|
||||||
relPath: op:
|
relPath: op:
|
||||||
if op.empty then
|
if op.empty then
|
||||||
pkgs.runCommand "game-mod-empty" { } "touch $out"
|
pkgs.runCommand "game-mod-empty" { } "touch $out"
|
||||||
else if op.source != null then
|
else if op.source != null then
|
||||||
op.source
|
op.source
|
||||||
else
|
else
|
||||||
throw "game-mod file '${relPath}': set empty = true or provide a source"
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user