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:
61
flake.lock
generated
61
flake.lock
generated
@@ -416,6 +416,27 @@
|
|||||||
"type": "github"
|
"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": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems_2"
|
"systems": "systems_2"
|
||||||
@@ -454,7 +475,7 @@
|
|||||||
},
|
},
|
||||||
"flake-utils_3": {
|
"flake-utils_3": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems_10"
|
"systems": "systems_11"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1731533236,
|
"lastModified": 1731533236,
|
||||||
@@ -1103,6 +1124,7 @@
|
|||||||
"rust-overlay": "rust-overlay",
|
"rust-overlay": "rust-overlay",
|
||||||
"senior_project-website": "senior_project-website",
|
"senior_project-website": "senior_project-website",
|
||||||
"srvos": "srvos",
|
"srvos": "srvos",
|
||||||
|
"steam-config-nix": "steam-config-nix",
|
||||||
"trackerlist": "trackerlist",
|
"trackerlist": "trackerlist",
|
||||||
"vpn-confinement": "vpn-confinement",
|
"vpn-confinement": "vpn-confinement",
|
||||||
"website": "website",
|
"website": "website",
|
||||||
@@ -1204,6 +1226,28 @@
|
|||||||
"type": "github"
|
"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": {
|
"systems": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1681028828,
|
"lastModified": 1681028828,
|
||||||
@@ -1234,6 +1278,21 @@
|
|||||||
"type": "github"
|
"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": {
|
"systems_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1681028828,
|
"lastModified": 1681028828,
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
url = "github:ChrisOboe/json2steamshortcut";
|
url = "github:ChrisOboe/json2steamshortcut";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
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).
|
# Google's official agent-skills for Android development (Apache 2.0).
|
||||||
# Consumed by home/progs/pi.nix and exposed under ~/.omp/agent/skills/.
|
# Consumed by home/progs/pi.nix and exposed under ~/.omp/agent/skills/.
|
||||||
android-skills = {
|
android-skills = {
|
||||||
@@ -238,6 +242,7 @@
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
{ nixpkgs.overlays = [ (import ./lib/overlays.nix) ]; }
|
{ nixpkgs.overlays = [ (import ./lib/overlays.nix) ]; }
|
||||||
|
inputs.steam-config-nix.nixosModules.default
|
||||||
./hosts/${hostname}/default.nix
|
./hosts/${hostname}/default.nix
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,61 +83,40 @@
|
|||||||
# 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.
|
# 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.
|
# 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
|
# Two copies live in the install (SD + hires); engine picks one based on
|
||||||
# the installed asset profile, so stub both. PCGamingWiki documents both
|
# the installed asset profile, so stub both. PCGamingWiki documents both
|
||||||
# paths under "Skip intro video".
|
# paths under "Skip intro video".
|
||||||
#
|
#
|
||||||
# fh5-optiscaler: drop OptiScaler v0.9.1 + a FH5-tuned OptiScaler.ini into
|
# fh5-optiscaler: drop OptiScaler v0.9.1 + a hand-written FH5/RDNA3 INI into
|
||||||
# the install dir to enable FSR 4 INT8 on this RDNA 3 Navi 32 box.
|
# the install dir to enable FSR 4 INT8 on this Navi 32 box. OptiScaler
|
||||||
# OptiScaler intercepts FH5's DLSS/XeSS calls and reroutes them through
|
# intercepts FH5's DLSS/XeSS calls and reroutes them through the bundled
|
||||||
# the bundled FFX SDK. Settings flipped from upstream defaults:
|
# FFX SDK. Override values + sources live in
|
||||||
# Fsr4Update auto -> true # FSR 4 path required for INT8 RDNA 3
|
# ./optiscaler-fh5-rdna3.ini; keys not listed there fall through to
|
||||||
# Dxgi auto -> false # FH5 wiki: required for 0.9
|
# OptiScaler's "auto" defaults.
|
||||||
# DlssReactiveMaskBias auto -> 0.65 # FH5 wiki: fixes flickering lights, reduces car ghosting
|
|
||||||
#
|
#
|
||||||
# Required one-time per-game setup in Steam (Proton picker is not
|
# Required one-time per-game setup the user has to do in Steam (the
|
||||||
# declarable from NixOS; launch options are written declaratively below):
|
# in-game upscaler picker has no public API):
|
||||||
# 1. Properties -> Compatibility: force "GE-Proton" (proton-ge-bin is
|
# - In-game: switch the Upscaling option from FSR 2.2 to DLSS or XeSS
|
||||||
# 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
|
# (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:
|
# 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 awk-patched template below is only written on first apply
|
# persist. The hand-written template is only written on first apply (or
|
||||||
# (or after manual deletion). To push a new default into an existing
|
# after manual deletion). To push a new default into an existing
|
||||||
# install: rm OptiScaler.ini in the FH5 dir, then `systemctl start
|
# install: rm OptiScaler.ini in the FH5 dir, then `systemctl start
|
||||||
# game-mods`. The DLLs and other static assets stay mode = "create" so
|
# game-mods`. The DLLs and other static assets stay mode = "create" so
|
||||||
# they're updated on 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.
|
||||||
#
|
|
||||||
# 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 =
|
services.gameMods =
|
||||||
let
|
let
|
||||||
optiPkg = pkgs.optiscaler;
|
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: {
|
fromOpti = relpath: {
|
||||||
source = "${optiPkg}/${relpath}";
|
source = "${optiPkg}/${relpath}";
|
||||||
mode = "create";
|
mode = "create";
|
||||||
@@ -152,24 +131,14 @@
|
|||||||
};
|
};
|
||||||
mods."fh5-optiscaler" = {
|
mods."fh5-optiscaler" = {
|
||||||
steamAppId = 1551360;
|
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 = {
|
files = {
|
||||||
# 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 = optiIni;
|
source = ./optiscaler-fh5-rdna3.ini;
|
||||||
# init: drop the patched template once, then leave it alone so
|
# init: drop the template once, then leave it alone so the in-game
|
||||||
# the in-game overlay can persist user tweaks.
|
# overlay can persist user tweaks.
|
||||||
mode = "init";
|
mode = "init";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -188,9 +157,30 @@
|
|||||||
"libxess_fg.dll"
|
"libxess_fg.dll"
|
||||||
] fromOpti;
|
] 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";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# controller (Settings → Controller → "PlayStation Configuration Support":
|
# controller (Settings → Controller → "PlayStation Configuration Support":
|
||||||
# OFF). Bluetooth works too but the udev/hidraw path is more reliable
|
# OFF). Bluetooth works too but the udev/hidraw path is more reliable
|
||||||
# over USB.
|
# 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.
|
# Data Out IP Port: 5300, and (FM7 only) Data Out Packet Format: CAR DASH.
|
||||||
#
|
#
|
||||||
# System-interaction notes:
|
# System-interaction notes:
|
||||||
@@ -26,102 +26,17 @@
|
|||||||
# in pydualsense's source). Forza Horizon is single-player so this is
|
# in pydualsense's source). Forza Horizon is single-player so this is
|
||||||
# usually fine. If you need to pin a specific controller, the cleanest
|
# usually fine. If you need to pin a specific controller, the cleanest
|
||||||
# route is monkey-patching `pydualsense.__find_device`.
|
# route is monkey-patching `pydualsense.__find_device`.
|
||||||
# - The included `dualsensectl` will be overwritten by our BG thread within
|
# - `pkgs.dualsensectl` is intentionally NOT installed by default
|
||||||
# ~4 ms; use `systemctl --user stop forza-trigger` first when debugging.
|
# (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
|
# - Hot-plug recovery happens in-process: the daemon polls pydualsense's BG
|
||||||
# thread liveness and re-runs `pydualsense.init()` on disconnect. systemd's
|
# thread liveness and re-runs `pydualsense.init()` on disconnect. systemd's
|
||||||
# `Restart=on-failure` exists only as a crash-recovery safety net.
|
# `Restart=on-failure` exists only as a crash-recovery safety net.
|
||||||
let
|
let
|
||||||
cfg = config.services.forzaTrigger;
|
cfg = config.services.forzaTrigger;
|
||||||
python = pkgs.python3;
|
pythonPackages = import ./python-packages.nix { inherit lib pkgs; };
|
||||||
|
inherit (pythonPackages) pydualsense fdp;
|
||||||
# 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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
forzaTrigger = pkgs.writers.writePython3Bin "forza-trigger" {
|
forzaTrigger = pkgs.writers.writePython3Bin "forza-trigger" {
|
||||||
libraries = [
|
libraries = [
|
||||||
@@ -173,12 +88,7 @@ in
|
|||||||
KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", MODE="0660", TAG+="uaccess"
|
KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", MODE="0660", TAG+="uaccess"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
environment.systemPackages = [
|
environment.systemPackages = [ forzaTrigger ];
|
||||||
forzaTrigger
|
|
||||||
# CLI companion for sanity-checking the controller (battery, lightbar,
|
|
||||||
# raw trigger modes, monitor add/remove events).
|
|
||||||
pkgs.dualsensectl
|
|
||||||
];
|
|
||||||
|
|
||||||
# User-level service so it inherits the seat-bound uaccess ACL on
|
# User-level service so it inherits the seat-bound uaccess ACL on
|
||||||
# /dev/hidraw* and dies cleanly when the user logs out.
|
# /dev/hidraw* and dies cleanly when the user logs out.
|
||||||
|
|||||||
107
hosts/yarn/forza-trigger/python-packages.nix
Normal file
107
hosts/yarn/forza-trigger/python-packages.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
39
hosts/yarn/optiscaler-fh5-rdna3.ini
Normal file
39
hosts/yarn/optiscaler-fh5-rdna3.ini
Normal 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
|
||||||
@@ -21,11 +21,9 @@
|
|||||||
# location = "install" (default, steamapps/common/<dir>) | "prefix"
|
# location = "install" (default, steamapps/common/<dir>) | "prefix"
|
||||||
# (steamapps/compatdata/<appid>/pfx — for user-storage mods)
|
# (steamapps/compatdata/<appid>/pfx — for user-storage mods)
|
||||||
#
|
#
|
||||||
# Each mod can also declare `launchOptions = [ "FOO=bar" ]`. Lists from
|
# Per-app Steam launch options live in `programs.steam.config.apps.<name>`
|
||||||
# every mod targeting the same Steam App ID are concatenated (mod-name
|
# from the steam-config-nix flake — declare them there alongside `compatTool`
|
||||||
# alphabetical), joined with spaces, and `%command%` is appended once.
|
# and other Steam config that this module deliberately does not touch.
|
||||||
# The result is written into Steam's per-app block in localconfig.vdf
|
|
||||||
# so it persists across Steam restarts.
|
|
||||||
#
|
#
|
||||||
# Example: stub the cold-start intro video, plus drop a sound mod XML into
|
# Example: stub the cold-start intro video, plus drop a sound mod XML into
|
||||||
# the Wine prefix's user storage.
|
# the Wine prefix's user storage.
|
||||||
@@ -110,27 +108,6 @@ let
|
|||||||
default = { };
|
default = { };
|
||||||
description = "Files to modify, keyed by path relative to game root.";
|
description = "Files to modify, keyed by path relative to game root.";
|
||||||
};
|
};
|
||||||
launchOptions = mkOption {
|
|
||||||
type = types.listOf types.str;
|
|
||||||
default = [ ];
|
|
||||||
example = [
|
|
||||||
"PROTON_FSR4_UPGRADE=1"
|
|
||||||
"DXIL_SPIRV_CONFIG=wmma_rdna3_workaround"
|
|
||||||
];
|
|
||||||
description = ''
|
|
||||||
Components to prepend to Steam's per-app LaunchOptions string.
|
|
||||||
Every mod that targets the same steamAppId contributes; the lists
|
|
||||||
are concatenated (in mod-name alphabetical order, preserving each
|
|
||||||
mod's internal order), joined with single spaces, and the literal
|
|
||||||
`%command%` is appended once at the end. List entries must not
|
|
||||||
contain `%command%` themselves.
|
|
||||||
|
|
||||||
An empty list (default) leaves Steam's existing LaunchOptions value
|
|
||||||
untouched, so Steam-UI edits and other games' launch options are
|
|
||||||
unaffected. Typical entries are env-var assignments
|
|
||||||
(`PROTON_FSR4_UPGRADE=1`) or wrappers (`gamemoderun`).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,28 +139,6 @@ let
|
|||||||
in
|
in
|
||||||
builtins.toJSON entries;
|
builtins.toJSON entries;
|
||||||
|
|
||||||
# Launch-options data as JSON. Mods are grouped by steamAppId, their
|
|
||||||
# launchOptions lists are concatenated (mod-name alphabetical order from
|
|
||||||
# mapAttrsToList), joined with spaces, and `%command%` is appended once.
|
|
||||||
# AppIds whose total contribution is empty are omitted entirely so the
|
|
||||||
# apply script leaves Steam's existing LaunchOptions field untouched.
|
|
||||||
launchOptionsData =
|
|
||||||
let
|
|
||||||
perMod = lib.mapAttrsToList (_modName: mod: {
|
|
||||||
appId = toString mod.steamAppId;
|
|
||||||
inherit (mod) launchOptions;
|
|
||||||
}) cfg.mods;
|
|
||||||
grouped = lib.foldl' (
|
|
||||||
acc: e: acc // { ${e.appId} = (acc.${e.appId} or [ ]) ++ e.launchOptions; }
|
|
||||||
) { } perMod;
|
|
||||||
nonEmpty = lib.filterAttrs (_appId: parts: parts != [ ]) grouped;
|
|
||||||
entries = lib.mapAttrsToList (appId: parts: {
|
|
||||||
steamAppId = lib.toInt appId;
|
|
||||||
launchOptions = (lib.concatStringsSep " " parts) + " %command%";
|
|
||||||
}) nonEmpty;
|
|
||||||
in
|
|
||||||
builtins.toJSON entries;
|
|
||||||
|
|
||||||
python = pkgs.python3.withPackages (ps: [ ps.vdf ]);
|
python = pkgs.python3.withPackages (ps: [ ps.vdf ]);
|
||||||
|
|
||||||
gameModsApply =
|
gameModsApply =
|
||||||
@@ -200,7 +155,6 @@ let
|
|||||||
import vdf
|
import vdf
|
||||||
|
|
||||||
MOD_DATA = json.loads(${lib.escapeShellArg modData})
|
MOD_DATA = json.loads(${lib.escapeShellArg modData})
|
||||||
LAUNCH_OPTIONS_DATA = json.loads(${lib.escapeShellArg launchOptionsData})
|
|
||||||
BACKUP_SUFFIX = ".nix-backup"
|
BACKUP_SUFFIX = ".nix-backup"
|
||||||
|
|
||||||
|
|
||||||
@@ -334,82 +288,6 @@ let
|
|||||||
print(f"game-mods: {verb} {target}", file=sys.stderr)
|
print(f"game-mods: {verb} {target}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def apply_launch_options():
|
|
||||||
"""Write declarative launch options into Steam's localconfig.vdf.
|
|
||||||
|
|
||||||
Steam stores per-app launch options at:
|
|
||||||
userdata/<accountid>/config/localconfig.vdf
|
|
||||||
→ UserLocalConfigStore → Software → Valve → Steam
|
|
||||||
→ apps → "<appid>" → LaunchOptions
|
|
||||||
|
|
||||||
We iterate every userdata directory so multi-account setups work.
|
|
||||||
Writes are idempotent (skip when the value already matches).
|
|
||||||
An atomic write (temp + os.replace) avoids torn reads by Steam.
|
|
||||||
"""
|
|
||||||
if not LAUNCH_OPTIONS_DATA:
|
|
||||||
print("game-mods: no launch options configured", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
steam_root = find_steam_root()
|
|
||||||
userdata_root = os.path.join(steam_root, "userdata")
|
|
||||||
if not os.path.isdir(userdata_root):
|
|
||||||
print(
|
|
||||||
"game-mods: userdata dir not found, skipping launch options",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
for entry in LAUNCH_OPTIONS_DATA:
|
|
||||||
appid = str(entry["steamAppId"])
|
|
||||||
desired_lo = entry["launchOptions"]
|
|
||||||
|
|
||||||
for account_dir in sorted(os.listdir(userdata_root)):
|
|
||||||
config_path = os.path.join(
|
|
||||||
userdata_root, account_dir, "config", "localconfig.vdf"
|
|
||||||
)
|
|
||||||
if not os.path.isfile(config_path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(config_path, "r", errors="replace") as f:
|
|
||||||
data = vdf.load(f, mapper=dict)
|
|
||||||
except Exception as exc:
|
|
||||||
print(
|
|
||||||
f"game-mods: failed to parse {config_path}: {exc}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
apps = (
|
|
||||||
data.setdefault("UserLocalConfigStore", {})
|
|
||||||
.setdefault("Software", {})
|
|
||||||
.setdefault("Valve", {})
|
|
||||||
.setdefault("Steam", {})
|
|
||||||
.setdefault("apps", {})
|
|
||||||
)
|
|
||||||
app_block = apps.setdefault(appid, {})
|
|
||||||
|
|
||||||
current_lo = app_block.get("LaunchOptions", "")
|
|
||||||
if current_lo == desired_lo:
|
|
||||||
continue # already applied, skip write
|
|
||||||
|
|
||||||
app_block["LaunchOptions"] = desired_lo
|
|
||||||
|
|
||||||
tmp_path = config_path + ".tmp"
|
|
||||||
with open(tmp_path, "w") as f:
|
|
||||||
vdf.dump(data, f, pretty=True)
|
|
||||||
os.replace(tmp_path, config_path)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"game-mods: set launch options for app {appid}"
|
|
||||||
f" in account {account_dir}:"
|
|
||||||
f" {desired_lo!r}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("game-mods: launch options done", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if MOD_DATA:
|
if MOD_DATA:
|
||||||
for entry in MOD_DATA:
|
for entry in MOD_DATA:
|
||||||
@@ -420,9 +298,6 @@ let
|
|||||||
entry["mode"],
|
entry["mode"],
|
||||||
entry["location"],
|
entry["location"],
|
||||||
)
|
)
|
||||||
|
|
||||||
apply_launch_options()
|
|
||||||
|
|
||||||
print("game-mods: done", file=sys.stderr)
|
print("game-mods: done", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
@@ -457,12 +332,13 @@ in
|
|||||||
"media-games.mount"
|
"media-games.mount"
|
||||||
"local-fs.target"
|
"local-fs.target"
|
||||||
];
|
];
|
||||||
# Hash of the materialized mod manifest. Embedding it as a custom [Unit]
|
# Re-run on every config change. The unit is Type=oneshot with
|
||||||
# field forces the unit text to change whenever any mod entry, source
|
# RemainAfterExit=no, so it goes inactive after each apply; switch-to-
|
||||||
# path, mode, or location changes — so `switch-to-configuration switch`
|
# configuration restarts it (which re-runs the ExecStart) when the hash
|
||||||
# re-runs the apply step on the same boot instead of waiting for the
|
# of any trigger changes — picking up new mod entries, source paths,
|
||||||
# next daily timer firing. systemd ignores X- prefixed unit fields.
|
# modes, and locations on the same boot rather than waiting for the
|
||||||
unitConfig.X-ConfigHash = builtins.hashString "sha256" (modData + launchOptionsData);
|
# daily timer.
|
||||||
|
restartTriggers = [ modData ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
ExecStart = "${gameModsApply}/bin/game-mods-apply";
|
ExecStart = "${gameModsApply}/bin/game-mods-apply";
|
||||||
|
|||||||
Reference in New Issue
Block a user