diff --git a/flake.lock b/flake.lock index 669ce17..6dd8cd9 100644 --- a/flake.lock +++ b/flake.lock @@ -416,6 +416,27 @@ "type": "github" } }, + "flake-parts_4": { + "inputs": { + "nixpkgs-lib": [ + "steam-config-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1765835352, + "narHash": "sha256-XswHlK/Qtjasvhd1nOa1e8MgZ8GS//jBoTqWtrS1Giw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "a34fae9c08a15ad73f295041fec82323541400a9", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems_2" @@ -454,7 +475,7 @@ }, "flake-utils_3": { "inputs": { - "systems": "systems_10" + "systems": "systems_11" }, "locked": { "lastModified": 1731533236, @@ -1103,6 +1124,7 @@ "rust-overlay": "rust-overlay", "senior_project-website": "senior_project-website", "srvos": "srvos", + "steam-config-nix": "steam-config-nix", "trackerlist": "trackerlist", "vpn-confinement": "vpn-confinement", "website": "website", @@ -1204,6 +1226,28 @@ "type": "github" } }, + "steam-config-nix": { + "inputs": { + "flake-parts": "flake-parts_4", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems_10" + }, + "locked": { + "lastModified": 1773421780, + "narHash": "sha256-bkZGvIWV8JavLMQ2KzddrMCf9YBeiIM4OgdiwKcFx8M=", + "owner": "different-name", + "repo": "steam-config-nix", + "rev": "7b8021b2739733c547e2fe02739e6b8452813aa7", + "type": "github" + }, + "original": { + "owner": "different-name", + "repo": "steam-config-nix", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, @@ -1234,6 +1278,21 @@ "type": "github" } }, + "systems_11": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "systems_2": { "locked": { "lastModified": 1681028828, diff --git a/flake.nix b/flake.nix index 4e2dba7..4267e62 100644 --- a/flake.nix +++ b/flake.nix @@ -82,6 +82,10 @@ url = "github:ChrisOboe/json2steamshortcut"; inputs.nixpkgs.follows = "nixpkgs"; }; + steam-config-nix = { + url = "github:different-name/steam-config-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; # Google's official agent-skills for Android development (Apache 2.0). # Consumed by home/progs/pi.nix and exposed under ~/.omp/agent/skills/. android-skills = { @@ -238,6 +242,7 @@ } ) { nixpkgs.overlays = [ (import ./lib/overlays.nix) ]; } + inputs.steam-config-nix.nixosModules.default ./hosts/${hostname}/default.nix ]; }; diff --git a/hosts/yarn/default.nix b/hosts/yarn/default.nix index 3b3d01b..0e1154a 100644 --- a/hosts/yarn/default.nix +++ b/hosts/yarn/default.nix @@ -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"; }; }; + }; } diff --git a/hosts/yarn/forza-trigger/default.nix b/hosts/yarn/forza-trigger/default.nix index 6ccf163..770f2b2 100644 --- a/hosts/yarn/forza-trigger/default.nix +++ b/hosts/yarn/forza-trigger/default.nix @@ -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. diff --git a/hosts/yarn/forza-trigger/python-packages.nix b/hosts/yarn/forza-trigger/python-packages.nix new file mode 100644 index 0000000..2afb336 --- /dev/null +++ b/hosts/yarn/forza-trigger/python-packages.nix @@ -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; + }; + }; +} diff --git a/hosts/yarn/optiscaler-fh5-rdna3.ini b/hosts/yarn/optiscaler-fh5-rdna3.ini new file mode 100644 index 0000000..27fa19d --- /dev/null +++ b/hosts/yarn/optiscaler-fh5-rdna3.ini @@ -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 diff --git a/modules/desktop-game-mods.nix b/modules/desktop-game-mods.nix index 75560da..c5bdd0a 100644 --- a/modules/desktop-game-mods.nix +++ b/modules/desktop-game-mods.nix @@ -21,11 +21,9 @@ # location = "install" (default, steamapps/common/