diff --git a/flake.lock b/flake.lock
index 6dd8cd9..09d0cb6 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1235,15 +1235,16 @@
"systems": "systems_10"
},
"locked": {
- "lastModified": 1773421780,
- "narHash": "sha256-bkZGvIWV8JavLMQ2KzddrMCf9YBeiIM4OgdiwKcFx8M=",
- "owner": "different-name",
+ "lastModified": 1777785758,
+ "narHash": "sha256-lPCklrUYn8ZydCaHb33YcWSLV05/j2ukPiY8fkhIRCg=",
+ "owner": "Titaniumtown",
"repo": "steam-config-nix",
- "rev": "7b8021b2739733c547e2fe02739e6b8452813aa7",
+ "rev": "ef8f67a02da61595314c76978a01546500978106",
"type": "github"
},
"original": {
- "owner": "different-name",
+ "owner": "Titaniumtown",
+ "ref": "pr/write-files",
"repo": "steam-config-nix",
"type": "github"
}
diff --git a/flake.nix b/flake.nix
index 4267e62..82b3031 100644
--- a/flake.nix
+++ b/flake.nix
@@ -83,7 +83,9 @@
inputs.nixpkgs.follows = "nixpkgs";
};
steam-config-nix = {
- url = "github:different-name/steam-config-nix";
+ # tracking the pr/write-files branch (per-app `files` option) until
+ # it lands upstream in different-name/steam-config-nix.
+ url = "github:Titaniumtown/steam-config-nix/pr/write-files";
inputs.nixpkgs.follows = "nixpkgs";
};
# Google's official agent-skills for Android development (Apache 2.0).
diff --git a/hosts/yarn/default.nix b/hosts/yarn/default.nix
index bf548b8..65dc6d1 100644
--- a/hosts/yarn/default.nix
+++ b/hosts/yarn/default.nix
@@ -16,7 +16,6 @@
./lact.nix
./vr.nix
./forza-trigger
- ../../modules/desktop-game-mods.nix
inputs.impermanence.nixosModules.impermanence
];
@@ -83,62 +82,81 @@
# PS5 DualSense adaptive triggers in Forza Horizon 4 / 5.
services.forzaTrigger.enable = true;
- # Declarative game-file mods on top of Steam (file drops only; Steam launch
- # options live in `programs.steam.config.apps` below).
+ # Steam per-app declarative config via steam-config-nix:
+ # - launch-option env vars (PROTON_FSR4_UPGRADE et al)
+ # - file drops into the install dir (FH5 intro stubs, OptiScaler DLLs +
+ # hand-written FH5/RDNA3 INI). Backups land next to replaced files with
+ # a `.steam-config-nix-backup` suffix on first apply.
#
- # fh5-no-intro: stub the 40 MB T10/Microsoft Studios cold-start splash.
- # Two copies live in the install (SD + hires); engine picks one based on
- # the installed asset profile, so stub both. PCGamingWiki documents both
- # paths under "Skip intro video".
+ # The patcher runs as a system oneshot at activation; closeSteam = true
+ # ensures Steam is shut down before the localconfig.vdf write so Steam
+ # can't clobber it on next exit. File drops happen in the same run but
+ # don't share that concern \u2014 they only touch steamapps/common/
+ # and steamapps/compatdata//pfx.
#
- # fh5-optiscaler: drop OptiScaler v0.9.1 + a hand-written FH5/RDNA3 INI into
- # the install dir to enable FSR 4 INT8 on this Navi 32 box. OptiScaler
- # intercepts FH5's DLSS/XeSS calls and reroutes them through the bundled
- # FFX SDK. Override values + sources live in
- # ./optiscaler-fh5-rdna3.ini; keys not listed there fall through to
- # OptiScaler's "auto" defaults.
+ # OptiScaler intercepts FH5's DLSS/XeSS calls and reroutes them through
+ # the bundled FFX SDK. Override values + sources for the FH5/RDNA3 path
+ # live in ./optiscaler-fh5-rdna3.ini; keys not listed there fall through
+ # to OptiScaler's "auto" defaults.
#
- # Required one-time per-game setup the user has to do in Steam (the
- # in-game upscaler picker has no public API):
- # - In-game: switch the Upscaling option from FSR 2.2 to DLSS or XeSS
- # (FSR 2 inputs aren't intercepted). Press Insert to open the Opti
- # overlay and set the FFX upscaler to FSR 4.
+ # Required one-time per-game setup the user has to do in Steam (no API):
+ # - Properties > Compatibility: pick the GE-Proton tool by hand. The
+ # `compatTool` option is intentionally unset \u2014 nixpkgs registers
+ # proton-ge-bin under its versioned id (e.g. GE-Proton10-34), and
+ # writing the generic "GE-Proton" string silently falls back to
+ # bundled Proton.
+ # - In-game: switch the Upscaling option from FSR 2.2 to DLSS or XeSS
+ # (FSR 2 inputs aren't intercepted). Press Insert to open the Opti
+ # overlay and set the FFX upscaler to FSR 4.
#
- # Caveats:
- # - OptiScaler.ini is dropped with mode = "init" so in-game overlay edits
- # persist. The hand-written template is only written on first apply (or
- # after manual deletion). To push a new default into an existing
- # install: rm OptiScaler.ini in the FH5 dir, then `systemctl start
- # game-mods`. The DLLs and other static assets stay mode = "create" so
- # they're updated on every OptiScaler version bump.
- # - OptiScaler's installation page warns against use with online games.
- # FH5 has no kernel-mode anti-cheat but Playground does server-side
- # telemetry. Use at your own risk.
- services.gameMods =
+ # OptiScaler.ini is dropped with mode = "init" so in-game overlay edits
+ # persist; the hand-written template is only written on first apply (or
+ # after manual deletion). To push a new default into an existing install:
+ # `rm OptiScaler.ini` in the FH5 dir, then trigger a redeploy. The DLLs
+ # and other static assets stay mode = "create" so they're updated on
+ # every OptiScaler version bump.
+ #
+ # OptiScaler's installation page warns against use with online games.
+ # FH5 has no kernel-mode anti-cheat but Playground does server-side
+ # telemetry. Use at your own risk.
+ programs.steam.config =
let
- optiPkg = pkgs.optiscaler;
fromOpti = relpath: {
- source = "${optiPkg}/${relpath}";
+ source = "${pkgs.optiscaler}/${relpath}";
mode = "create";
};
in
{
enable = true;
- mods."fh5-no-intro" = {
- steamAppId = 1551360;
- files."media/UI/Videos/T10_MS_Combined.bk2".empty = true;
- files."media/UI/Videos/hires/T10_MS_Combined.bk2".empty = true;
- };
- mods."fh5-optiscaler" = {
- steamAppId = 1551360;
+ closeSteam = true;
+ apps."fh5" = {
+ id = 1551360;
+ launchOptions.env = {
+ # OptiScaler FSR 4 INT8 path on this RDNA 3 (Navi 32) box.
+ # PROTON_FSR4_UPGRADE opts FH5 into Proton's FSR 4 DLL upgrade;
+ # DXIL_SPIRV_CONFIG fixes the broken visuals the wmma RDNA3
+ # emulation path otherwise produces. Source: OptiScaler FSR4 wiki
+ # Linux Setup.
+ PROTON_FSR4_UPGRADE = "1";
+ DXIL_SPIRV_CONFIG = "wmma_rdna3_workaround";
+ # vkd3d-proton stutter/crash workaround on this box; remove when a
+ # future Proton release fixes the upload-hvv path upstream.
+ VKD3D_CONFIG = "no_upload_hvv";
+ };
files = {
+ # FH5 cold-start splash. Two copies live in the install (SD +
+ # hires); the engine picks one based on the installed asset
+ # profile, so stub both. PCGamingWiki documents both paths under
+ # "Skip intro video".
+ "media/UI/Videos/T10_MS_Combined.bk2".empty = true;
+ "media/UI/Videos/hires/T10_MS_Combined.bk2".empty = true;
+
# OptiScaler.dll is renamed to dxgi.dll so FH5's DLL search order
# picks it up as the dxgi shim per the OptiScaler FH5 wiki page.
"dxgi.dll" = fromOpti "OptiScaler.dll";
+
"OptiScaler.ini" = {
source = ./optiscaler-fh5-rdna3.ini;
- # init: drop the template once, then leave it alone so the in-game
- # overlay can persist user tweaks.
mode = "init";
};
}
@@ -158,33 +176,4 @@
] fromOpti;
};
};
-
- # Steam launch options via steam-config-nix (different-name/steam-config-
- # nix). The patcher runs as a system oneshot at activation; closeSteam =
- # true ensures Steam is shut down before the localconfig.vdf write so Steam
- # can't clobber it on its next exit.
- #
- # No declarative compat-tool pin: nixpkgs' proton-ge-bin registers under
- # its versioned tool id (e.g. GE-Proton10-34), not the generic "GE-Proton"
- # string steam-config-nix's README assumes, so writing the latter resolved
- # to no installed tool and silently fell back to bundled Proton. Pick the
- # tool in Steam UI > Properties > Compatibility instead.
- programs.steam.config = {
- enable = true;
- closeSteam = true;
- apps."fh5" = {
- id = 1551360;
- launchOptions.env = {
- # OptiScaler FSR 4 INT8 path on this RDNA 3 (Navi 32) box.
- # PROTON_FSR4_UPGRADE opts FH5 into Proton's FSR 4 DLL upgrade;
- # DXIL_SPIRV_CONFIG fixes the broken visuals the wmma RDNA3 emulation
- # path otherwise produces. Source: OptiScaler FSR4 wiki Linux Setup.
- PROTON_FSR4_UPGRADE = "1";
- DXIL_SPIRV_CONFIG = "wmma_rdna3_workaround";
- # vkd3d-proton stutter/crash workaround on this box; remove when a
- # future Proton release fixes the upload-hvv path upstream.
- VKD3D_CONFIG = "no_upload_hvv";
- };
- };
- };
}
diff --git a/modules/desktop-game-mods.nix b/modules/desktop-game-mods.nix
deleted file mode 100644
index c5bdd0a..0000000
--- a/modules/desktop-game-mods.nix
+++ /dev/null
@@ -1,360 +0,0 @@
-{
- 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 (and Proton prefix) from Steam's
-# own library metadata, so it survives drive moves and library
-# reconfigurations.
-#
-# A systemd timer re-applies mods daily so Steam verify/update restores
-# don't silently undo them.
-#
-# Each file op picks two axes:
-# 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/) | "prefix"
-# (steamapps/compatdata//pfx — for user-storage mods)
-#
-# Per-app Steam launch options live in `programs.steam.config.apps.`
-# 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.
-# services.gameMods.mods."fh5" = {
-# steamAppId = 1551360;
-# files."media/UI/Videos/T10_MS_Combined.bk2".empty = true;
-# files."drive_c/users/steamuser/AppData/Local/ForzaHorizon5/User_SteamLocalStorageDirectory/ConnectedStorage/ForzaUserConfigSelections/mods/audio/foo.xml" = {
-# source = ./files/foo.xml;
-# location = "prefix";
-# mode = "create";
-# };
-# };
-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.";
- };
- mode = mkOption {
- type = types.enum [
- "replace"
- "create"
- "init"
- ];
- default = "replace";
- description = ''
- How to handle a missing target.
-
- - `replace` (default): only act when the target already exists; otherwise skip.
- 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 {
- type = types.enum [
- "install"
- "prefix"
- ];
- default = "install";
- description = ''
- Root the relative target path is resolved against.
-
- - `install` (default): the Steam install directory (`steamapps/common/`).
- - `prefix`: the Proton/Wine prefix root (`steamapps/compatdata//pfx`).
- Use this for mods that write into the game's user storage under
- `drive_c/users/steamuser/AppData/...`.
- '';
- };
- };
- };
-
- 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.";
- };
- };
- };
-
- # Resolve the on-disk source for a single file op (empty stub or user-provided path).
- resolveSource =
- 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";
-
- # Generate mod data as JSON for the apply script. Each entry is
- # { steamAppId, target, source, mode, location }.
- modData =
- let
- entries = lib.concatLists (
- lib.mapAttrsToList (
- _modName: mod:
- lib.mapAttrsToList (relPath: op: {
- inherit (mod) steamAppId;
- target = relPath;
- source = resolveSource relPath op;
- inherit (op) mode location;
- }) mod.files
- ) 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 find_compatdata_pfx(appid):
- """Return the Proton/Wine prefix root for `appid`, or None if not found."""
- steam_root = find_steam_root()
- for lib_path in find_libraries(steam_root):
- pfx = os.path.join(
- lib_path, "steamapps", "compatdata", str(appid), "pfx"
- )
- if os.path.isdir(pfx):
- return pfx
- return None
-
-
- def find_root(appid, location):
- """Resolve the directory `location`-relative paths apply under."""
- if location == "install":
- return find_game_root(appid)
- if location == "prefix":
- return find_compatdata_pfx(appid)
- raise ValueError(f"unknown location {location!r}")
-
-
- def apply_mod(appid, target_rel, source, mode, location):
- root = find_root(appid, location)
- if root is None:
- print(
- f"game-mods: {location} root for app {appid}"
- " not found, skipping",
- file=sys.stderr,
- )
- return
-
- target = os.path.join(root, target_rel)
- target_existed = os.path.exists(target)
-
- if not target_existed and mode == "replace":
- print(
- f"game-mods: target '{target}' does not exist"
- f" (mode={mode}), skipping",
- file=sys.stderr,
- )
- 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:
- if ft.read() == fs.read():
- return
-
- # Back up the original (once), if it existed.
- backup = target + BACKUP_SUFFIX
- if target_existed and not os.path.exists(backup):
- shutil.copy2(target, backup)
- print(
- f"game-mods: backed up {target} -> {backup}",
- file=sys.stderr,
- )
-
- if not target_existed:
- os.makedirs(os.path.dirname(target), exist_ok=True)
-
- shutil.copy2(source, target)
- # 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 MOD_DATA:
- for entry in MOD_DATA:
- apply_mod(
- str(entry["steamAppId"]),
- entry["target"],
- entry["source"],
- entry["mode"],
- entry["location"],
- )
- 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"
- ];
- # 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";
- 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;
- };
- };
- };
-}