steam-config-nix: move to my fork and drop gameMods
This commit is contained in:
11
flake.lock
generated
11
flake.lock
generated
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
# - In-game: switch the Upscaling option from FSR 2.2 to DLSS or XeSS
|
# `compatTool` option is intentionally unset \u2014 nixpkgs registers
|
||||||
# (FSR 2 inputs aren't intercepted). Press Insert to open the Opti
|
# proton-ge-bin under its versioned id (e.g. GE-Proton10-34), and
|
||||||
# overlay and set the FFX upscaler to FSR 4.
|
# 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
|
||||||
# - 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.
|
||||||
mods."fh5-optiscaler" = {
|
# PROTON_FSR4_UPGRADE opts FH5 into Proton's FSR 4 DLL upgrade;
|
||||||
steamAppId = 1551360;
|
# 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 = {
|
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";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user