game-mods: drop in-house launchOptions writer, hardcode FH5 ini
Replaces three handfuls of custom code with upstream / static data:
- Per-app Steam launch options now declared via different-name/steam-
config-nix's `programs.steam.config.apps.<n>` instead of a custom
~70-line `apply_launch_options` Python function. The dropped writer
was racy: it edited localconfig.vdf without checking for a running
Steam, so any timer firing while Steam was open would lose its
changes on the next Steam shutdown. steam-config-nix's `closeSteam`
flag closes that race.
Also moves the GE-Proton compat-tool pin to declarative config —
one fewer manual click in Steam UI to remember.
- `mods.<>.launchOptions` option, the `launchOptionsData` aggregation,
and `LAUNCH_OPTIONS_DATA` are removed from desktop-game-mods.nix.
The module now does file-drops only; Steam config lives in its own
`programs.steam.config` namespace, where it belongs.
fh5-vkd3d-no-hvv (which existed only to set VKD3D_CONFIG) collapses
into the FH5 launchOptions block in hosts/yarn/default.nix.
- `unitConfig.X-ConfigHash` on game-mods.service is replaced with
`restartTriggers`. NixOS already emits `X-Restart-Triggers=<hash>`
on the unit; the workaround was redundant. The Type=oneshot,
RemainAfterExit=no semantics make `systemctl restart` re-run
ExecStart cleanly on hash change.
- The awk pipeline that patched OptiScaler's stock OptiScaler.ini at
build time is replaced with a hand-written hosts/yarn/optiscaler-
fh5-rdna3.ini containing only the keys we override (5 of them).
OptiScaler's Config::readString defaults missing keys to "auto"
(Config.cpp:1568), so a minimal file is sufficient. Side benefits:
one upstream-source dependency removed, a key-rename in upstream
becomes a behavior change rather than a silent awk-no-match.
Override values + sources:
Fsr4Update=true FH5 wiki, FSR4 Linux Setup
DlssReactiveMaskBias=0.65 FH5 wiki, "Known Issues"
FsrNonLinearColorSpace=true FSR4 wiki, "Image Quality"
EnableFsr2Inputs=false FH5 wiki, "Known Issues"
Dxgi=false FH5 wiki
- forza-trigger's three custom Python derivations (pydualsense,
hidapi-usb, fdp) factored out of default.nix into a sibling
python-packages.nix. Same logic, single-purpose file. Bumping a
version is now a one-place hash roll.
- pkgs.dualsensectl removed from the daemon's environment.system-
Packages. Single-shot writes from the CLI get clobbered by the BG
sendReport thread within ~4ms anyway, so the tool is only useful
with the daemon stopped — not worth the unconditional install.
Bring it in ad-hoc with `nix-shell -p dualsensectl`.
This commit is contained in:
@@ -21,11 +21,9 @@
|
||||
# location = "install" (default, steamapps/common/<dir>) | "prefix"
|
||||
# (steamapps/compatdata/<appid>/pfx — for user-storage mods)
|
||||
#
|
||||
# Each mod can also declare `launchOptions = [ "FOO=bar" ]`. Lists from
|
||||
# every mod targeting the same Steam App ID are concatenated (mod-name
|
||||
# alphabetical), joined with spaces, and `%command%` is appended once.
|
||||
# The result is written into Steam's per-app block in localconfig.vdf
|
||||
# so it persists across Steam restarts.
|
||||
# Per-app Steam launch options live in `programs.steam.config.apps.<name>`
|
||||
# from the steam-config-nix flake — declare them there alongside `compatTool`
|
||||
# and other Steam config that this module deliberately does not touch.
|
||||
#
|
||||
# Example: stub the cold-start intro video, plus drop a sound mod XML into
|
||||
# the Wine prefix's user storage.
|
||||
@@ -110,27 +108,6 @@ 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`).
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -162,28 +139,6 @@ 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 =
|
||||
@@ -200,7 +155,6 @@ let
|
||||
import vdf
|
||||
|
||||
MOD_DATA = json.loads(${lib.escapeShellArg modData})
|
||||
LAUNCH_OPTIONS_DATA = json.loads(${lib.escapeShellArg launchOptionsData})
|
||||
BACKUP_SUFFIX = ".nix-backup"
|
||||
|
||||
|
||||
@@ -334,82 +288,6 @@ let
|
||||
print(f"game-mods: {verb} {target}", 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:
|
||||
@@ -420,9 +298,6 @@ let
|
||||
entry["mode"],
|
||||
entry["location"],
|
||||
)
|
||||
|
||||
apply_launch_options()
|
||||
|
||||
print("game-mods: done", file=sys.stderr)
|
||||
|
||||
|
||||
@@ -457,12 +332,13 @@ 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);
|
||||
# Re-run on every config change. The unit is Type=oneshot with
|
||||
# RemainAfterExit=no, so it goes inactive after each apply; switch-to-
|
||||
# configuration restarts it (which re-runs the ExecStart) when the hash
|
||||
# of any trigger changes — picking up new mod entries, source paths,
|
||||
# modes, and locations on the same boot rather than waiting for the
|
||||
# daily timer.
|
||||
restartTriggers = [ modData ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${gameModsApply}/bin/game-mods-apply";
|
||||
|
||||
Reference in New Issue
Block a user