game-mods: init
Add override for fh5 startup video
This commit is contained in:
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
260
modules/desktop-game-mods.nix
Normal file
260
modules/desktop-game-mods.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user