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; - }; - }; - }; -}