game-mods: init

Add override for fh5 startup video
This commit is contained in:
2026-05-01 16:23:01 -04:00
parent 002579d58e
commit af8f651202
2 changed files with 273 additions and 0 deletions

View File

@@ -16,6 +16,7 @@
./lact.nix ./lact.nix
./vr.nix ./vr.nix
./forza-trigger ./forza-trigger
../../modules/desktop-game-mods.nix
inputs.impermanence.nixosModules.impermanence inputs.impermanence.nixosModules.impermanence
]; ];
@@ -81,4 +82,16 @@
# PS5 DualSense adaptive triggers in Forza Horizon 4 / 5. # PS5 DualSense adaptive triggers in Forza Horizon 4 / 5.
services.forzaTrigger.enable = true; 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;
};
};
} }

View File

@@ -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;
};
};
};
}