steam-config-nix: move to my fork and drop gameMods

This commit is contained in:
2026-05-03 01:30:49 -04:00
parent e6ac7b433e
commit 0df5b74265
4 changed files with 67 additions and 435 deletions

11
flake.lock generated
View File

@@ -1235,15 +1235,16 @@
"systems": "systems_10" "systems": "systems_10"
}, },
"locked": { "locked": {
"lastModified": 1773421780, "lastModified": 1777785758,
"narHash": "sha256-bkZGvIWV8JavLMQ2KzddrMCf9YBeiIM4OgdiwKcFx8M=", "narHash": "sha256-lPCklrUYn8ZydCaHb33YcWSLV05/j2ukPiY8fkhIRCg=",
"owner": "different-name", "owner": "Titaniumtown",
"repo": "steam-config-nix", "repo": "steam-config-nix",
"rev": "7b8021b2739733c547e2fe02739e6b8452813aa7", "rev": "ef8f67a02da61595314c76978a01546500978106",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "different-name", "owner": "Titaniumtown",
"ref": "pr/write-files",
"repo": "steam-config-nix", "repo": "steam-config-nix",
"type": "github" "type": "github"
} }

View File

@@ -83,7 +83,9 @@
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
steam-config-nix = { 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"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# Google's official agent-skills for Android development (Apache 2.0). # Google's official agent-skills for Android development (Apache 2.0).

View File

@@ -16,7 +16,6 @@
./lact.nix ./lact.nix
./vr.nix ./vr.nix
./forza-trigger ./forza-trigger
../../modules/desktop-game-mods.nix
inputs.impermanence.nixosModules.impermanence inputs.impermanence.nixosModules.impermanence
]; ];
@@ -83,62 +82,81 @@
# PS5 DualSense adaptive triggers in Forza Horizon 4 / 5. # PS5 DualSense adaptive triggers in Forza Horizon 4 / 5.
services.forzaTrigger.enable = true; services.forzaTrigger.enable = true;
# Declarative game-file mods on top of Steam (file drops only; Steam launch # Steam per-app declarative config via steam-config-nix:
# options live in `programs.steam.config.apps` below). # - 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. # The patcher runs as a system oneshot at activation; closeSteam = true
# Two copies live in the install (SD + hires); engine picks one based on # ensures Steam is shut down before the localconfig.vdf write so Steam
# the installed asset profile, so stub both. PCGamingWiki documents both # can't clobber it on next exit. File drops happen in the same run but
# paths under "Skip intro video". # don't share that concern \u2014 they only touch steamapps/common/<dir>
# and steamapps/compatdata/<id>/pfx.
# #
# fh5-optiscaler: drop OptiScaler v0.9.1 + a hand-written FH5/RDNA3 INI into # OptiScaler intercepts FH5's DLSS/XeSS calls and reroutes them through
# the install dir to enable FSR 4 INT8 on this Navi 32 box. OptiScaler # the bundled FFX SDK. Override values + sources for the FH5/RDNA3 path
# intercepts FH5's DLSS/XeSS calls and reroutes them through the bundled # live in ./optiscaler-fh5-rdna3.ini; keys not listed there fall through
# FFX SDK. Override values + sources live in # to OptiScaler's "auto" defaults.
# ./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 # Required one-time per-game setup the user has to do in Steam (no API):
# in-game upscaler picker has no public 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 # - 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 # (FSR 2 inputs aren't intercepted). Press Insert to open the Opti
# overlay and set the FFX upscaler to FSR 4. # overlay and set the FFX upscaler to FSR 4.
# #
# Caveats: # OptiScaler.ini is dropped with mode = "init" so in-game overlay edits
# - OptiScaler.ini is dropped with mode = "init" so in-game overlay edits # persist; the hand-written template is only written on first apply (or
# persist. The hand-written template is only written on first apply (or # after manual deletion). To push a new default into an existing install:
# after manual deletion). To push a new default into an existing # `rm OptiScaler.ini` in the FH5 dir, then trigger a redeploy. The DLLs
# install: rm OptiScaler.ini in the FH5 dir, then `systemctl start # and other static assets stay mode = "create" so they're updated on
# game-mods`. The DLLs and other static assets stay mode = "create" so # every OptiScaler version bump.
# they're updated on every OptiScaler version bump. #
# - OptiScaler's installation page warns against use with online games. # OptiScaler's installation page warns against use with online games.
# FH5 has no kernel-mode anti-cheat but Playground does server-side # FH5 has no kernel-mode anti-cheat but Playground does server-side
# telemetry. Use at your own risk. # telemetry. Use at your own risk.
services.gameMods = programs.steam.config =
let let
optiPkg = pkgs.optiscaler;
fromOpti = relpath: { fromOpti = relpath: {
source = "${optiPkg}/${relpath}"; source = "${pkgs.optiscaler}/${relpath}";
mode = "create"; mode = "create";
}; };
in in
{ {
enable = true; enable = true;
mods."fh5-no-intro" = { closeSteam = true;
steamAppId = 1551360; apps."fh5" = {
files."media/UI/Videos/T10_MS_Combined.bk2".empty = true; id = 1551360;
files."media/UI/Videos/hires/T10_MS_Combined.bk2".empty = true; 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";
}; };
mods."fh5-optiscaler" = {
steamAppId = 1551360;
files = { 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 # 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. # picks it up as the dxgi shim per the OptiScaler FH5 wiki page.
"dxgi.dll" = fromOpti "OptiScaler.dll"; "dxgi.dll" = fromOpti "OptiScaler.dll";
"OptiScaler.ini" = { "OptiScaler.ini" = {
source = ./optiscaler-fh5-rdna3.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"; mode = "init";
}; };
} }
@@ -158,33 +176,4 @@
] fromOpti; ] 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";
};
};
};
} }

View File

@@ -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/<dir>) | "prefix"
# (steamapps/compatdata/<appid>/pfx — for user-storage mods)
#
# 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.
# 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/<dir>`).
- `prefix`: the Proton/Wine prefix root (`steamapps/compatdata/<appid>/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;
};
};
};
}