Two separate causes of stationary trigger pulsing, both fixed:
1. Hysteresis too short. 30 packets (0.5s) was shorter than FH5's
stationary oscillation period (~1s per state in start-grid/pause
screen contexts). Bumped to 120 (2s at 60Hz).
2. RPM-stuck workaround removed entirely. Cosmii's RPM_ACCUMULATOR
(legacy_Program.cs:89-109) forced is_race_on false after 3.3s of
constant RPM + zero power. While stationary in-race (idle RPM
constant, power near zero), this would trip, causing a false
menu transition. Engine idle flutter on power could reset and
re-trigger it, producing a slow oscillation with clicks on each
edge. FH5 has been observed to correctly clear is_race_on between
races (confirmed via live strace), so the workaround is unnecessary.
Removed: RPM_ACCUMULATOR_TRIGGER_RACE_OFF constant, is_race_on's
debounce logic, DaemonState.last_rpm and .rpm_accumulator fields.
is_race_on is now a one-liner: return bool(is_race_on field).
Tests: 57/57 pass. TestIsRaceOn simplified from 4 to 3 tests.
DaemonState reset test no longer checks removed fields.
Strace post-deploy showed the daemon flipping every other packet between
in-race state (mode 0x21 FEEDBACK + LED green) and out-of-race state
(mode 0x05 OFF + LED black). 8 writes, 8 transitions in 5s — every
HID OUT report a state change.
Root cause: FH5 emits packets where the is_race_on field alternates
True/False at packet rate during menu/loading/transition states.
Cosmii's RPM_ACCUMULATOR debounce only handles the 'flag stuck True
when we're really in a menu' case (quirk #1); it does nothing for
'flag flipping at packet rate' (quirk #2).
Fix: split the read from the hysteresis. is_race_on now returns the
raw flag with quirk #1 applied. commit_in_race applies a packet-count
hysteresis (IN_RACE_HYSTERESIS_PACKETS = 30 ≈ 0.5s at 60Hz) — only
after N consecutive packets of the new value does the committed
in-race state flip. Alternation just keeps the pending counter
oscillating near 0; it never reaches threshold and the run-loop sees
a stable state.
Architecture: hysteresis in a separate function (not in is_race_on)
because the run-loop must update state.last_in_race AFTER side
effects, not before — otherwise the elif transition-detection
breaks. is_race_on stays pure read; commit_in_race is the gatekeeper;
run-loop sets state.last_in_race once at end of packet handling.
Tests grew 54→58. New TestCommitInRace covers:
- stable input (no pending growth)
- worst-case alternation (never commits)
- N consecutive packets commit
- matching raw resets pending counter
TestIsRaceOn renamed to reflect new (narrower) responsibility.
User reports periodic clicks and LED color changes on the controller
"between races". Theory: out-of-race, the run loop was calling
reset_triggers(controller) AND apply_lightbar(controller, ...) on
every Forza packet (60Hz). Even though both call sites land in
library code that nominally dedupes "same state" writes, in practice
any state change anywhere — including the lightbar dropping a green
channel value as RPM coasts down to idle — triggers a full HID OUT
report rewrite. The OUT report carries the trigger configuration too;
the controller firmware reacts on receipt and produces an audible
click each time.
Fix: out-of-race path becomes edge-triggered. On the in-race → menu
transition we run state.reset() + reset_all() once (turning both
triggers off and the lightbar to (0,0,0)). Subsequent menu packets
make no controller calls at all until in_race flips back to True.
First in-race packet then re-engages handlers and the RPM-driven
lightbar.
Side effect: the menu-mode car-class lightbar coloring is gone — the
bar stays black between races. If we want it back later, it should
be one-shot on the menu transition (NOT updated per-packet). For now
keep it simple: in-race only.
Build clean; tests unchanged (54/54 still pass — they exercise
handlers directly, not the run loop).
Live strace on yarn during gameplay revealed the daemon was correctly
calling effect.vibration(freq=35) on slip events, but the OUT report
on the wire showed mode=0x26 (VIBRATION) with byte 31 (param9 =
frequency) = 0. The controller firmware treats freq=0 as "no
oscillation" — trigger sits silent in vibration mode. That's why the
user reported "doesn't react to slipping or anything" even after the
pedal-off early-return fix.
Root cause is in dualsense-controller 0.3.1 itself:
- WriteStates.__init__ registers individual write-states for trigger
effect params 1-7 only. params 8/9/10 are not registered.
- update_out_report copies from per-state values to the OutReport,
again only params 1-7.
- The OutReport dataclass DEFINES params 1-10 (with defaults of 0)
and Usb01OutReport.to_bytes writes all 10 to the wire.
- effect.vibration() puts frequency in param9 — silently dropped.
- Same hits effect.machine() (params 8,9,10) and effect.galloping()
(param10). effect.feedback/weapon/bow only use params 1-7 so
they happen to work.
Fix is a small upstream-style patch added under patches/dualsense-
controller/ and wired into the dualsense-controller derivation in
hosts/yarn/forza-trigger/python-packages.nix via the patches attr:
in update_out_report, after the param7 assignments, read param8/9/10
directly from the parent TriggerEffect state value (which already
carries them correctly from the call site through _set_value).
Verified post-patch by source-reading the installed library:
out_report.left_trigger_effect_param9 = self.left_trigger_effect.value.param9
out_report.right_trigger_effect_param9 = self.right_trigger_effect.value.param9
(and 4 more for left/right param8/10)
Build-sandbox tests (54/54) pass via the forza-trigger-tests build
gate; full yarn NixOS closure builds clean.
Filing upstream against yesbotics/dualsense-controller-python is the
follow-up; until then this is a local patch.
Live diagnostic on yarn revealed the daemon was receiving 324-byte FH5
packets correctly (5.7MB on the systemd socket; strace showed steady
recvfrom + write to /dev/hidraw7) but writing trigger mode 0x05
(no-resistance) on nearly every tick. Cause: `accel` and `brake` are
0 most of the time during normal play (off-throttle on straight
sections, off-brake when not braking). Both handlers had:
if accel/255 <= THROTTLE_INPUT_THRESHOLD: effect.off(); return
if brake/255 <= BRAKE_INPUT_THRESHOLD: effect.off(); return
Every off-pedal packet set the trigger to OFF. Brief pedal-on moments
set vibration. The result: rapidly oscillating off↔vibration state,
imperceptible at 60 Hz packet rate.
These early-returns were holdovers from the previous Race-Element 1:1
port (variant A), which IS designed to be silent unless slipping.
Variant D's whole point is "always feels something" — Cosmii has no
pedal-off gate, and its baseline branch produces feedback even at
brake=0/accel=0 with strength clamped to MIN.
Fix: remove both early-returns. Foot-off-pedal flows through the
baseline branch and produces feedback(strength=MIN_*_RESISTANCE). The
user feels light constant resistance instead of silence. Trigger only
returns to physical-rest when out-of-race (run-loop's reset_triggers).
Also drop the now-dead BRAKE_INPUT_THRESHOLD / THROTTLE_INPUT_THRESHOLD
constants. Two tests renamed and updated to assert MIN-strength
baseline feedback instead of effect.off() on zero pedal.
54/54 tests pass. Build clean.
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`.