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:
2026-05-03 00:25:08 -04:00
parent 1e8c294a80
commit e010b4e3c1
7 changed files with 271 additions and 285 deletions

View File

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