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

@@ -83,61 +83,40 @@
# PS5 DualSense adaptive triggers in Forza Horizon 4 / 5.
services.forzaTrigger.enable = true;
# Declarative game-file mods on top of Steam.
# Declarative game-file mods on top of Steam (file drops only; Steam launch
# options live in `programs.steam.config.apps` below).
#
# 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".
#
# fh5-optiscaler: drop OptiScaler v0.9.1 + a FH5-tuned OptiScaler.ini into
# the install dir to enable FSR 4 INT8 on this RDNA 3 Navi 32 box.
# OptiScaler intercepts FH5's DLSS/XeSS calls and reroutes them through
# the bundled FFX SDK. Settings flipped from upstream defaults:
# Fsr4Update auto -> true # FSR 4 path required for INT8 RDNA 3
# Dxgi auto -> false # FH5 wiki: required for 0.9
# DlssReactiveMaskBias auto -> 0.65 # FH5 wiki: fixes flickering lights, reduces car ghosting
# 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.
#
# Required one-time per-game setup in Steam (Proton picker is not
# declarable from NixOS; launch options are written declaratively below):
# 1. Properties -> Compatibility: force "GE-Proton" (proton-ge-bin is
# already in extraCompatPackages; pick the latest installed).
# Steam must be closed when the launch options write happens, or
# Steam will overwrite our localconfig.vdf edit on shutdown.
# 2. 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 (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.
#
# Caveats:
# - OptiScaler.ini is dropped with mode = "init" so in-game overlay edits
# persist. The awk-patched template below is only written on first apply
# (or after manual deletion). To push a new default into an existing
# 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.
#
# fh5-vkd3d-no-hvv: append `VKD3D_CONFIG=no_upload_hvv` to FH5's launch
# options. Disables vkd3d-proton's host-visible VRAM upload heap, which
# works around a Proton stutter/crash on this box. Kept as its own mod
# (no files, just env) so the workaround can be removed independently of
# OptiScaler when a future Proton release fixes the underlying issue.
services.gameMods =
let
optiPkg = pkgs.optiscaler;
# OptiScaler.ini ships with CRLF line endings, so we use awk with explicit
# RS/ORS to keep the rewrites line-ending agnostic. The regex is matched
# against the CR-stripped record, the original CRLF is restored on output.
optiIni = pkgs.runCommand "OptiScaler-fh5.ini" { } ''
awk -v RS='\r\n' -v ORS='\r\n' '
/^Fsr4Update=auto$/ { print "Fsr4Update=true"; next }
/^Dxgi=auto$/ { print "Dxgi=false"; next }
/^DlssReactiveMaskBias=auto$/ { print "DlssReactiveMaskBias=0.65"; next }
{ print }
' ${optiPkg}/OptiScaler.ini > $out
'';
fromOpti = relpath: {
source = "${optiPkg}/${relpath}";
mode = "create";
@@ -152,24 +131,14 @@
};
mods."fh5-optiscaler" = {
steamAppId = 1551360;
# RDNA 3 (Navi 32) FSR 4 path on Linux:
# PROTON_FSR4_UPGRADE=1 opts FH5 into Proton's FSR 4 upgrade.
# DXIL_SPIRV_CONFIG=wmma_rdna3_... fixes broken visuals on RDNA 3 per
# the OptiScaler FSR4 wiki.
# Other FH5-targeting mods (e.g. fh5-vkd3d-no-hvv) append their own
# entries to this list; the module joins them and adds %command%.
launchOptions = [
"PROTON_FSR4_UPGRADE=1"
"DXIL_SPIRV_CONFIG=wmma_rdna3_workaround"
];
files = {
# 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 = optiIni;
# init: drop the patched template once, then leave it alone so
# the in-game overlay can persist user tweaks.
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";
};
}
@@ -188,9 +157,30 @@
"libxess_fg.dll"
] fromOpti;
};
mods."fh5-vkd3d-no-hvv" = {
steamAppId = 1551360;
launchOptions = [ "VKD3D_CONFIG=no_upload_hvv" ];
};
# Steam launch options + per-app compat-tool pin 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.
programs.steam.config = {
enable = true;
closeSteam = true;
defaultCompatTool = "GE-Proton";
apps."fh5" = {
id = 1551360;
compatTool = "GE-Proton";
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

@@ -17,7 +17,7 @@
# controller (Settings → Controller → "PlayStation Configuration Support":
# OFF). Bluetooth works too but the udev/hidraw path is more reliable
# over USB.
# - in Forza, HUD options \u2192 set Data Out: ON, Data Out IP: 127.0.0.1,
# - in Forza, HUD options set Data Out: ON, Data Out IP: 127.0.0.1,
# Data Out IP Port: 5300, and (FM7 only) Data Out Packet Format: CAR DASH.
#
# System-interaction notes:
@@ -26,102 +26,17 @@
# in pydualsense's source). Forza Horizon is single-player so this is
# usually fine. If you need to pin a specific controller, the cleanest
# route is monkey-patching `pydualsense.__find_device`.
# - The included `dualsensectl` will be overwritten by our BG thread within
# ~4 ms; use `systemctl --user stop forza-trigger` first when debugging.
# - `pkgs.dualsensectl` is intentionally NOT installed by default
# (single-shot writes from it get overwritten by our BG thread within
# ~4 ms). Bring it in ad-hoc with `nix-shell -p dualsensectl` and stop
# this service first via `systemctl --user stop forza-trigger`.
# - Hot-plug recovery happens in-process: the daemon polls pydualsense's BG
# thread liveness and re-runs `pydualsense.init()` on disconnect. systemd's
# `Restart=on-failure` exists only as a crash-recovery safety net.
let
cfg = config.services.forzaTrigger;
python = pkgs.python3;
# CFFI bindings to libhidapi. Upstream is flok/hidapi-cffi published on
# PyPI under the name `hidapi-usb`. The shipped hidapi.py picks a libhidapi
# soname via ffi.dlopen() — we substitute absolute store paths so the
# interpreter inside our wrapped python env can find it without
# LD_LIBRARY_PATH gymnastics.
hidapi-usb = python.pkgs.buildPythonPackage rec {
pname = "hidapi-usb";
version = "0.3.2";
format = "setuptools";
# PyPI's project URL slug uses a hyphen (`hidapi-usb`) but the sdist file
# itself is PEP-625-normalized to an underscore (`hidapi_usb-…`). Stock
# fetchPypi assumes they match — they don't here, so fetch by direct URL.
src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/55/80/960ae94b615e26a7d1aeebe8e9fefda2f25608bf1016f9aec268b328c35e/hidapi_usb-${version}.tar.gz";
hash = "sha256-oxp+2i+qqYd1uwiS2Dh8/PzO62iYQQXpR936MnDIFk0=";
};
propagatedBuildInputs = [ python.pkgs.cffi ];
postPatch = ''
substituteInPlace hidapi.py \
--replace-fail "'libhidapi-hidraw.so'," "'${pkgs.hidapi}/lib/libhidapi-hidraw.so'," \
--replace-fail "'libhidapi-hidraw.so.0'," "'${pkgs.hidapi}/lib/libhidapi-hidraw.so.0',"
'';
pythonImportsCheck = [ "hidapi" ];
meta = {
description = "CFFI wrapper for hidapi (used by pydualsense)";
homepage = "https://github.com/flok/hidapi-cffi";
license = lib.licenses.bsd3;
};
};
pydualsense = python.pkgs.buildPythonPackage rec {
pname = "pydualsense";
version = "0.7.5";
format = "pyproject";
src = python.pkgs.fetchPypi {
pname = "pydualsense";
inherit version;
hash = "sha256-YgX8AJE4f8p7geKT3xlCD0Mlh1GcyHpBz4rEIqdwKgs=";
};
nativeBuildInputs = [ python.pkgs.poetry-core ];
propagatedBuildInputs = [ hidapi-usb ];
pythonImportsCheck = [ "pydualsense" ];
meta = {
description = "Control your PS5 DualSense controller from Python";
homepage = "https://github.com/flok/pydualsense";
license = lib.licenses.mit;
};
};
# Single-file Forza UDP packet parser. Pinned to a known-good commit; the
# repo is dormant (last commit 2021) but the FH4 packet layout is frozen
# and FH5 reuses it byte-for-byte.
fdp = python.pkgs.buildPythonPackage {
pname = "fdp";
version = "0-unstable-2021-05-28";
format = "other";
src = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/nettrom/forza_motorsport/61845cb7ff4082211292a51ce3c49edbfd2d6503/fdp.py";
hash = "sha256-osFaVF9VaEzU4dp3x6KN6OF7SXsd9ZBwvilU+xTT7mM=";
};
dontUnpack = true;
installPhase = ''
runHook preInstall
install -Dm644 $src $out/${python.sitePackages}/fdp.py
runHook postInstall
'';
pythonImportsCheck = [ "fdp" ];
meta = {
description = "ForzaDataPacket Forza Motorsport / Horizon UDP packet parser";
homepage = "https://github.com/nettrom/forza_motorsport";
license = lib.licenses.mit;
};
};
pythonPackages = import ./python-packages.nix { inherit lib pkgs; };
inherit (pythonPackages) pydualsense fdp;
forzaTrigger = pkgs.writers.writePython3Bin "forza-trigger" {
libraries = [
@@ -173,12 +88,7 @@ in
KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", MODE="0660", TAG+="uaccess"
'';
environment.systemPackages = [
forzaTrigger
# CLI companion for sanity-checking the controller (battery, lightbar,
# raw trigger modes, monitor add/remove events).
pkgs.dualsensectl
];
environment.systemPackages = [ forzaTrigger ];
# User-level service so it inherits the seat-bound uaccess ACL on
# /dev/hidraw* and dies cleanly when the user logs out.

View File

@@ -0,0 +1,107 @@
# Python packages forza_trigger.py imports that aren't in nixpkgs. Returns
# an attrset consumed by ./default.nix.
#
# Bumping a version: change `version` and `hash`, then `nix build` — Nix
# fails with the new sha256 in the error message, paste it back in.
{
lib,
pkgs,
python ? pkgs.python3,
}:
let
py = python.pkgs;
in
rec {
# CFFI bindings to libhidapi (flok/hidapi-cffi on PyPI). pydualsense's
# `import hidapi` resolves to this — nixpkgs' python3Packages.hidapi is the
# Cython wrapper from trezor/cython-hidapi which exposes a different
# `import hid` API and can't satisfy pydualsense.
hidapi-usb = py.buildPythonPackage rec {
pname = "hidapi-usb";
version = "0.3.2";
format = "setuptools";
# PyPI's project URL slug uses a hyphen (`hidapi-usb`) but the sdist file
# itself is PEP-625-normalized to an underscore (`hidapi_usb-…`). Stock
# fetchPypi assumes they match — they don't here, so fetch by direct URL.
src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/55/80/960ae94b615e26a7d1aeebe8e9fefda2f25608bf1016f9aec268b328c35e/hidapi_usb-${version}.tar.gz";
hash = "sha256-oxp+2i+qqYd1uwiS2Dh8/PzO62iYQQXpR936MnDIFk0=";
};
propagatedBuildInputs = [ py.cffi ];
# Upstream's hidapi.py walks a tuple of soname strings via ffi.dlopen()
# until one resolves. Pin the two Linux hidraw entries to absolute store
# paths so the wrapped Python in our writePython3Bin closure finds them
# without LD_LIBRARY_PATH wrapping. The libusb / iohidmanager / dylib /
# dll entries are dead code on Linux. --replace-fail makes a rename in
# upstream's tuple a loud build error rather than a silent ImportError
# at runtime.
postPatch = ''
substituteInPlace hidapi.py \
--replace-fail "'libhidapi-hidraw.so'," "'${pkgs.hidapi}/lib/libhidapi-hidraw.so'," \
--replace-fail "'libhidapi-hidraw.so.0'," "'${pkgs.hidapi}/lib/libhidapi-hidraw.so.0',"
'';
pythonImportsCheck = [ "hidapi" ];
meta = {
description = "CFFI wrapper for hidapi (used by pydualsense)";
homepage = "https://github.com/flok/hidapi-cffi";
license = lib.licenses.bsd3;
};
};
pydualsense = py.buildPythonPackage rec {
pname = "pydualsense";
version = "0.7.5";
format = "pyproject";
src = py.fetchPypi {
inherit pname version;
hash = "sha256-YgX8AJE4f8p7geKT3xlCD0Mlh1GcyHpBz4rEIqdwKgs=";
};
nativeBuildInputs = [ py.poetry-core ];
propagatedBuildInputs = [ hidapi-usb ];
pythonImportsCheck = [ "pydualsense" ];
meta = {
description = "Control your PS5 DualSense controller from Python";
homepage = "https://github.com/flok/pydualsense";
license = lib.licenses.mit;
};
};
# Single-file Forza UDP packet parser (nettrom/forza_motorsport). Pinned to
# a known-good commit; the repo is dormant (last commit 2021) but the FH4
# packet layout is frozen and FH5 reuses it byte-for-byte.
fdp = py.buildPythonPackage {
pname = "fdp";
version = "0-unstable-2021-05-28";
format = "other";
src = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/nettrom/forza_motorsport/61845cb7ff4082211292a51ce3c49edbfd2d6503/fdp.py";
hash = "sha256-osFaVF9VaEzU4dp3x6KN6OF7SXsd9ZBwvilU+xTT7mM=";
};
dontUnpack = true;
installPhase = ''
runHook preInstall
install -Dm644 $src $out/${python.sitePackages}/fdp.py
runHook postInstall
'';
pythonImportsCheck = [ "fdp" ];
meta = {
description = "ForzaDataPacket Forza Motorsport / Horizon UDP packet parser";
homepage = "https://github.com/nettrom/forza_motorsport";
license = lib.licenses.mit;
};
};
}

View File

@@ -0,0 +1,39 @@
; OptiScaler.ini overrides for Forza Horizon 5 on RDNA 3 (Navi 32) under
; Linux/Proton with FSR 4 INT8 enabled. Tested on OptiScaler 0.9.1.
;
; Keys not listed here fall through to OptiScaler's "auto" defaults
; (Config.cpp: missing keys silently resolve to std::nullopt -> _defaultValue).
;
; Sources:
; - https://github.com/optiscaler/OptiScaler/wiki/Forza-Horizon-5
; - https://github.com/optiscaler/OptiScaler/wiki/FSR4-Compatibility-List
;
; Companion launch-option env vars (set via programs.steam.config in
; hosts/yarn/default.nix):
; PROTON_FSR4_UPGRADE=1
; DXIL_SPIRV_CONFIG=wmma_rdna3_workaround (RDNA 3 INT8 visuals fix)
; VKD3D_CONFIG=no_upload_hvv (vkd3d-proton stutter workaround)
[Inputs]
; FSR2 inputs are buggy in FH5; OptiScaler 0.7.8+ auto-disables them per game
; but explicit is safer across version bumps. Source: FH5 wiki ("Known Issues").
EnableFsr2Inputs=false
[FSR]
; DirectX 12 Agility SDK upgrade. Required for the FSR 4 path on RDNA 3 INT8
; under Proton (the FFX SDK shipped with the game predates FSR 4). Source:
; FSR4 Compatibility wiki, "Linux Setup" section.
Fsr4Update=true
; FH5 wiki: 0.65 fixes flickering lights and reduces car ghosting with FSR-FG.
DlssReactiveMaskBias=0.65
; FSR 4 on RDNA 3 IQ recommendation; mitigates white flashes and artifacting
; from the INT8 emulation path. Source: FSR4 Compatibility wiki, "Image Quality".
FsrNonLinearColorSpace=true
[Spoofing]
; FH5 wiki recommendation. The auto default on AMD spoofs to NVIDIA, which
; OptiScaler 0.9 deliberately drops for FH5; pinning false here makes the
; behavior version-proof.
Dxgi=false