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.
|
# don't silently undo them.
|
||||||
#
|
#
|
||||||
# Each file op picks two axes:
|
# 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"
|
# location = "install" (default, steamapps/common/<dir>) | "prefix"
|
||||||
# (steamapps/compatdata/<appid>/pfx — for user-storage mods)
|
# (steamapps/compatdata/<appid>/pfx — for user-storage mods)
|
||||||
#
|
#
|
||||||
@@ -55,6 +58,7 @@ let
|
|||||||
type = types.enum [
|
type = types.enum [
|
||||||
"replace"
|
"replace"
|
||||||
"create"
|
"create"
|
||||||
|
"init"
|
||||||
];
|
];
|
||||||
default = "replace";
|
default = "replace";
|
||||||
description = ''
|
description = ''
|
||||||
@@ -64,6 +68,11 @@ let
|
|||||||
A backup is written next to the target on first apply.
|
A backup is written next to the target on first apply.
|
||||||
- `create`: create the target (and parent directories) if absent; replace it
|
- `create`: create the target (and parent directories) if absent; replace it
|
||||||
otherwise. No backup is written when the target did not exist.
|
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 {
|
location = mkOption {
|
||||||
@@ -95,6 +104,27 @@ let
|
|||||||
default = { };
|
default = { };
|
||||||
description = "Files to modify, keyed by path relative to game root.";
|
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
|
in
|
||||||
builtins.toJSON entries;
|
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 ]);
|
python = pkgs.python3.withPackages (ps: [ ps.vdf ]);
|
||||||
|
|
||||||
gameModsApply =
|
gameModsApply =
|
||||||
@@ -142,7 +194,7 @@ let
|
|||||||
import vdf
|
import vdf
|
||||||
|
|
||||||
MOD_DATA = json.loads(${lib.escapeShellArg modData})
|
MOD_DATA = json.loads(${lib.escapeShellArg modData})
|
||||||
BACKUP_SUFFIX = ".nix-backup"
|
LAUNCH_OPTIONS_DATA = json.loads(${lib.escapeShellArg launchOptionsData})
|
||||||
|
|
||||||
|
|
||||||
def find_steam_root():
|
def find_steam_root():
|
||||||
@@ -225,7 +277,7 @@ let
|
|||||||
target = os.path.join(root, target_rel)
|
target = os.path.join(root, target_rel)
|
||||||
target_existed = os.path.exists(target)
|
target_existed = os.path.exists(target)
|
||||||
|
|
||||||
if not target_existed and mode != "create":
|
if not target_existed and mode == "replace":
|
||||||
print(
|
print(
|
||||||
f"game-mods: target '{target}' does not exist"
|
f"game-mods: target '{target}' does not exist"
|
||||||
f" (mode={mode}), skipping",
|
f" (mode={mode}), skipping",
|
||||||
@@ -233,6 +285,14 @@ let
|
|||||||
)
|
)
|
||||||
return
|
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?
|
# Already applied?
|
||||||
if target_existed and 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:
|
||||||
@@ -252,23 +312,109 @@ let
|
|||||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
|
||||||
shutil.copy2(source, target)
|
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)
|
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
|
return
|
||||||
|
|
||||||
for entry in MOD_DATA:
|
steam_root = find_steam_root()
|
||||||
apply_mod(
|
userdata_root = os.path.join(steam_root, "userdata")
|
||||||
str(entry["steamAppId"]),
|
if not os.path.isdir(userdata_root):
|
||||||
entry["target"],
|
print(
|
||||||
entry["source"],
|
"game-mods: userdata dir not found, skipping launch options",
|
||||||
entry["mode"],
|
file=sys.stderr,
|
||||||
entry["location"],
|
|
||||||
)
|
)
|
||||||
|
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"]),
|
||||||
|
entry["target"],
|
||||||
|
entry["source"],
|
||||||
|
entry["mode"],
|
||||||
|
entry["location"],
|
||||||
|
)
|
||||||
|
|
||||||
|
apply_launch_options()
|
||||||
|
|
||||||
print("game-mods: done", file=sys.stderr)
|
print("game-mods: done", file=sys.stderr)
|
||||||
|
|
||||||
@@ -304,6 +450,12 @@ in
|
|||||||
"media-games.mount"
|
"media-games.mount"
|
||||||
"local-fs.target"
|
"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 = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
ExecStart = "${gameModsApply}/bin/game-mods-apply";
|
ExecStart = "${gameModsApply}/bin/game-mods-apply";
|
||||||
|
|||||||
Reference in New Issue
Block a user