game-mods: list-merged launchOptions, init mode, writable targets
Three additions on top of the file-replacement scaffolding: - mode = "init": create-on-first-apply, leave-alone-otherwise. For files the application writes back to (configs edited in-game, save files). Operator pushes a new template by deleting the target. - chmod 644 after every copy. shutil.copy2 preserved the source's /nix/store mode (0o444), which made dropped DLL configs read-only. Apps that wrote back (OptiScaler "Save INI") got EACCES, which in OptiScaler's case cascaded into CreateSwapChainForHwnd returning E_FAIL and crashed FH5 on launch. - launchOptions = listOf str. Multiple mods targeting the same steamAppId have their lists concatenated (mod-name alphabetical), joined with spaces, %command% appended once. Written into Steam's per-app block in userdata/<id>/config/localconfig.vdf via vdf parse + atomic os.replace. Idempotent. - X-ConfigHash on the systemd unit so switch-to-configuration switch re-runs apply when the manifest changes.
This commit is contained in:
@@ -14,7 +14,10 @@
|
||||
# don't silently undo them.
|
||||
#
|
||||
# Each file op picks two axes:
|
||||
# mode = "replace" (default, skip if absent) | "create" (mkdir + write)
|
||||
# mode = "replace" (default, skip if absent)
|
||||
# | "create" (mkdir + write, idempotent)
|
||||
# | "init" (create if absent, leave alone otherwise — for
|
||||
# files the game writes back to)
|
||||
# location = "install" (default, steamapps/common/<dir>) | "prefix"
|
||||
# (steamapps/compatdata/<appid>/pfx — for user-storage mods)
|
||||
#
|
||||
@@ -55,6 +58,7 @@ let
|
||||
type = types.enum [
|
||||
"replace"
|
||||
"create"
|
||||
"init"
|
||||
];
|
||||
default = "replace";
|
||||
description = ''
|
||||
@@ -64,6 +68,11 @@ let
|
||||
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.
|
||||
- `init`: like `create` but only on first apply. If the target already exists,
|
||||
leave it alone, even if its content differs from the source. Use this for
|
||||
files the application is expected to write back to (config files edited by
|
||||
in-game overlays, save files, etc.) so user changes persist across mod
|
||||
re-applies. To push a new template, delete the target and re-run.
|
||||
'';
|
||||
};
|
||||
location = mkOption {
|
||||
@@ -95,6 +104,27 @@ let
|
||||
default = { };
|
||||
description = "Files to modify, keyed by path relative to game root.";
|
||||
};
|
||||
launchOptions = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [
|
||||
"PROTON_FSR4_UPGRADE=1"
|
||||
"DXIL_SPIRV_CONFIG=wmma_rdna3_workaround"
|
||||
];
|
||||
description = ''
|
||||
Components to prepend to Steam's per-app LaunchOptions string.
|
||||
Every mod that targets the same steamAppId contributes; the lists
|
||||
are concatenated (in mod-name alphabetical order, preserving each
|
||||
mod's internal order), joined with single spaces, and the literal
|
||||
`%command%` is appended once at the end. List entries must not
|
||||
contain `%command%` themselves.
|
||||
|
||||
An empty list (default) leaves Steam's existing LaunchOptions value
|
||||
untouched, so Steam-UI edits and other games' launch options are
|
||||
unaffected. Typical entries are env-var assignments
|
||||
(`PROTON_FSR4_UPGRADE=1`) or wrappers (`gamemoderun`).
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -126,6 +156,28 @@ let
|
||||
in
|
||||
builtins.toJSON entries;
|
||||
|
||||
# Launch-options data as JSON. Mods are grouped by steamAppId, their
|
||||
# launchOptions lists are concatenated (mod-name alphabetical order from
|
||||
# mapAttrsToList), joined with spaces, and `%command%` is appended once.
|
||||
# AppIds whose total contribution is empty are omitted entirely so the
|
||||
# apply script leaves Steam's existing LaunchOptions field untouched.
|
||||
launchOptionsData =
|
||||
let
|
||||
perMod = lib.mapAttrsToList (_modName: mod: {
|
||||
appId = toString mod.steamAppId;
|
||||
inherit (mod) launchOptions;
|
||||
}) cfg.mods;
|
||||
grouped = lib.foldl' (
|
||||
acc: e: acc // { ${e.appId} = (acc.${e.appId} or [ ]) ++ e.launchOptions; }
|
||||
) { } perMod;
|
||||
nonEmpty = lib.filterAttrs (_appId: parts: parts != [ ]) grouped;
|
||||
entries = lib.mapAttrsToList (appId: parts: {
|
||||
steamAppId = lib.toInt appId;
|
||||
launchOptions = (lib.concatStringsSep " " parts) + " %command%";
|
||||
}) nonEmpty;
|
||||
in
|
||||
builtins.toJSON entries;
|
||||
|
||||
python = pkgs.python3.withPackages (ps: [ ps.vdf ]);
|
||||
|
||||
gameModsApply =
|
||||
@@ -142,7 +194,7 @@ let
|
||||
import vdf
|
||||
|
||||
MOD_DATA = json.loads(${lib.escapeShellArg modData})
|
||||
BACKUP_SUFFIX = ".nix-backup"
|
||||
LAUNCH_OPTIONS_DATA = json.loads(${lib.escapeShellArg launchOptionsData})
|
||||
|
||||
|
||||
def find_steam_root():
|
||||
@@ -225,7 +277,7 @@ let
|
||||
target = os.path.join(root, target_rel)
|
||||
target_existed = os.path.exists(target)
|
||||
|
||||
if not target_existed and mode != "create":
|
||||
if not target_existed and mode == "replace":
|
||||
print(
|
||||
f"game-mods: target '{target}' does not exist"
|
||||
f" (mode={mode}), skipping",
|
||||
@@ -233,6 +285,14 @@ let
|
||||
)
|
||||
return
|
||||
|
||||
# init mode: write only on first apply, then it's user-owned. Even if
|
||||
# the source content has drifted (e.g. a new tuning landed in the host
|
||||
# config), leave the on-disk file alone so in-game overlay edits and
|
||||
# other user changes are preserved across re-applies. Operator pushes
|
||||
# a new template by deleting the target and re-running.
|
||||
if target_existed and mode == "init":
|
||||
return
|
||||
|
||||
# Already applied?
|
||||
if target_existed and os.path.isfile(target) and os.path.isfile(source):
|
||||
with open(target, "rb") as ft, open(source, "rb") as fs:
|
||||
@@ -252,15 +312,99 @@ let
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
|
||||
shutil.copy2(source, target)
|
||||
verb = "created" if not target_existed else "replaced"
|
||||
# The source lives in /nix/store with mode 0o444. shutil.copy2 carries
|
||||
# those bits across, leaving the dropped file read-only. Apps that
|
||||
# write back to their own config (e.g. OptiScaler's "Save INI") then
|
||||
# silently fail with EACCES. Force a writable mode so user/in-app
|
||||
# edits can persist; init mode then keeps them across re-applies.
|
||||
os.chmod(target, 0o644)
|
||||
if mode == "init":
|
||||
verb = "initialized"
|
||||
elif target_existed:
|
||||
verb = "replaced"
|
||||
else:
|
||||
verb = "created"
|
||||
print(f"game-mods: {verb} {target}", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
if not MOD_DATA:
|
||||
print("game-mods: no mods configured", file=sys.stderr)
|
||||
|
||||
|
||||
def apply_launch_options():
|
||||
"""Write declarative launch options into Steam's localconfig.vdf.
|
||||
|
||||
Steam stores per-app launch options at:
|
||||
userdata/<accountid>/config/localconfig.vdf
|
||||
→ UserLocalConfigStore → Software → Valve → Steam
|
||||
→ apps → "<appid>" → LaunchOptions
|
||||
|
||||
We iterate every userdata directory so multi-account setups work.
|
||||
Writes are idempotent (skip when the value already matches).
|
||||
An atomic write (temp + os.replace) avoids torn reads by Steam.
|
||||
"""
|
||||
if not LAUNCH_OPTIONS_DATA:
|
||||
print("game-mods: no launch options configured", file=sys.stderr)
|
||||
return
|
||||
|
||||
steam_root = find_steam_root()
|
||||
userdata_root = os.path.join(steam_root, "userdata")
|
||||
if not os.path.isdir(userdata_root):
|
||||
print(
|
||||
"game-mods: userdata dir not found, skipping launch options",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
for entry in LAUNCH_OPTIONS_DATA:
|
||||
appid = str(entry["steamAppId"])
|
||||
desired_lo = entry["launchOptions"]
|
||||
|
||||
for account_dir in sorted(os.listdir(userdata_root)):
|
||||
config_path = os.path.join(
|
||||
userdata_root, account_dir, "config", "localconfig.vdf"
|
||||
)
|
||||
if not os.path.isfile(config_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(config_path, "r", errors="replace") as f:
|
||||
data = vdf.load(f, mapper=dict)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"game-mods: failed to parse {config_path}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
|
||||
apps = (
|
||||
data.setdefault("UserLocalConfigStore", {})
|
||||
.setdefault("Software", {})
|
||||
.setdefault("Valve", {})
|
||||
.setdefault("Steam", {})
|
||||
.setdefault("apps", {})
|
||||
)
|
||||
app_block = apps.setdefault(appid, {})
|
||||
|
||||
current_lo = app_block.get("LaunchOptions", "")
|
||||
if current_lo == desired_lo:
|
||||
continue # already applied, skip write
|
||||
|
||||
app_block["LaunchOptions"] = desired_lo
|
||||
|
||||
tmp_path = config_path + ".tmp"
|
||||
with open(tmp_path, "w") as f:
|
||||
vdf.dump(data, f, pretty=True)
|
||||
os.replace(tmp_path, config_path)
|
||||
|
||||
print(
|
||||
f"game-mods: set launch options for app {appid}"
|
||||
f" in account {account_dir}:"
|
||||
f" {desired_lo!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print("game-mods: launch options done", file=sys.stderr)
|
||||
def main():
|
||||
if MOD_DATA:
|
||||
for entry in MOD_DATA:
|
||||
apply_mod(
|
||||
str(entry["steamAppId"]),
|
||||
@@ -270,6 +414,8 @@ let
|
||||
entry["location"],
|
||||
)
|
||||
|
||||
apply_launch_options()
|
||||
|
||||
print("game-mods: done", file=sys.stderr)
|
||||
|
||||
|
||||
@@ -304,6 +450,12 @@ in
|
||||
"media-games.mount"
|
||||
"local-fs.target"
|
||||
];
|
||||
# Hash of the materialized mod manifest. Embedding it as a custom [Unit]
|
||||
# field forces the unit text to change whenever any mod entry, source
|
||||
# path, mode, or location changes — so `switch-to-configuration switch`
|
||||
# re-runs the apply step on the same boot instead of waiting for the
|
||||
# next daily timer firing. systemd ignores X- prefixed unit fields.
|
||||
unitConfig.X-ConfigHash = builtins.hashString "sha256" (modData + launchOptionsData);
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${gameModsApply}/bin/game-mods-apply";
|
||||
|
||||
Reference in New Issue
Block a user