forza-trigger: actively release trigger and clear lightbar on idle

Two issues in the deployed daemon:

  1. After FH5 exits, the lightbar stayed lit. reset_triggers() touched
     only triggers; pydualsense's BG sendReport thread kept re-publishing
     whatever TouchpadColor we last set, so the controller stayed in the
     last race color forever.

  2. R2 had residual tension in FH5's main menu and on the desktop after
     a race. Pre-race / idle states were emitting RacingDSX's NormalTrigger
     (mode byte 0x00), which per Sony's docs (Nielk1 Rev6) only clears
     state without retracting the trigger motor; mode 0x05 (canonical Off
     / Reset) actively returns the trigger to neutral. RacingDSX-on-Windows
     gets away with 0x00 because something else (Steam Input or the OS)
     reliably resets the motor on focus loss; on Linux nothing does.

Fixes:
  - Drop _apply_normal/DS_MODE_NORMAL. Use _apply_off (mode 0x05) for every
    'release the trigger' intent: pre-race per-frame, idle timeout, mid-race
    zero-strength fallback, shutdown.
  - Add reset_lightbar() that writes RGB(0,0,0).
  - Track have_telemetry and fire the idle-timeout branch whenever
    telemetry has been silent for IDLE_TIMEOUT_S, regardless of in_race.
    Reset both triggers and lightbar in that branch.

Documented as divergence #4 in the module docstring.
This commit is contained in:
2026-05-02 18:10:23 -04:00
parent dd8f712056
commit b115e8f189

View File

@@ -61,6 +61,15 @@ output report at fixed offsets. That is exactly what we want.
on those cars. Mask `cpi & 0xFF` in `apply_lightbar_pre_race` to match
RacingDSX byte-for-byte if you want bug-faithful Windows-equivalent dimming.
4. Trigger release uses canonical Off (mode 0x05) instead of Normal (mode 0x00).
RacingDSX dispatches `TriggerMode.Normal` for pre-race / between-race state,
which becomes mode byte 0x00. Per Sony's docs (Nielk1 Rev 6), mode 0x00 only
*clears* state and does not retract the trigger motor; mode 0x05 *actively*
returns the trigger stop to neutral. On Linux/pydualsense, RacingDSX's 0x00
leaves R2 with residual tension after a race ends because nothing forces a
physical reset (Windows DSX or Steam Input must do it on that platform).
We always emit 0x05 for any \"trigger should feel free\" intent.
## Threading note
pydualsense's `sendReport` background thread reads `triggerR/L.mode` and
@@ -123,11 +132,9 @@ LOG = logging.getLogger("forza-trigger")
# --- Mode bytes ---------------------------------------------------------------
# pydualsense's IntFlag aliases happen to cover the modes we need:
# TriggerModes.Off = 0x00 (between-race idle; pydualsense's name)
# TriggerModes(0x05) = 0x05 (canonical Sony Off / Reset; mid-race zero-force)
# TriggerModes(0x05) = 0x05 (canonical Sony Off / Reset)
# TriggerModes.Pulse_B = 0x06 (Simple_Vibration / Simple_AutomaticGun)
# TriggerModes.Rigid_A = 0x21 (Feedback, canonical)
DS_MODE_NORMAL = TriggerModes.Off
DS_MODE_OFF = TriggerModes(0x05)
DS_MODE_SIMPLE_VIBRATION = TriggerModes.Pulse_B
DS_MODE_FEEDBACK = TriggerModes.Rigid_A
@@ -229,30 +236,21 @@ def _is_in_motion(pkt: ForzaDataPacket) -> bool:
# --- Effect encoders ----------------------------------------------------------
def _apply_normal(trig) -> None:
"""`TriggerMode.Normal` in DSX's vocabulary \u2014 mode byte 0, all params 0.
Mirrors `DualSense_USB_Updated.cs` `bytes2 = NormalTrigger = 0L` then
`array[11..17,20] = bytes2[0..7]`. Used between races / on idle, matching
RacingDSX's `GetPreRaceInstructions()`.
"""
# Write forces before mode so pydualsense's BG sendReport thread, which
# reads mode then forces non-atomically (~250 Hz USB / ~1 kHz BT), is more
# likely to observe a self-consistent (mode, forces) pair. See the
# threading-hazard note in the module docstring.
for i in range(7):
trig.forces[i] = 0
trig.mode = DS_MODE_NORMAL
def _apply_off(trig) -> None:
"""Canonical Sony Off / Reset \u2014 mode byte 0x05, all params 0.
Mirrors `TriggerEffectGenerator.Reset()` in DSX. Per Sony's docs, mode 5
actively returns the trigger stop to the neutral position; mode 0 just
clears state. DSX uses Reset() as the fall-through for `Resistance(0,0)`,
so we route mid-race zero-strength fallbacks here for byte-perfect parity.
Mirrors `TriggerEffectGenerator.Reset()` in DSX. Per Sony's docs (Nielk1
Rev 6), mode 0x05 *actively* returns the trigger stop to the neutral
position; mode 0x00 (TriggerModes.Off, what RacingDSX writes for its
NormalTrigger pre-race state) only clears state without retracting the
motor, so the trigger stays in whatever Feedback/Vibration position was
last applied. We route every \"release the trigger\" intent here \u2014
pre-race, idle-timeout, mid-race zero-strength fallback, shutdown.
"""
# Write forces before mode so pydualsense's BG sendReport thread, which
# reads mode then forces non-atomically, is more likely to observe a
# self-consistent (mode, forces) pair. See module-docstring threading note.
for i in range(7):
trig.forces[i] = 0
trig.mode = DS_MODE_OFF
@@ -338,8 +336,20 @@ def _apply_simple_vibration(trig, position: int, amplitude: int, frequency: int)
def reset_triggers(ds: pydualsense) -> None:
_apply_normal(ds.triggerL)
_apply_normal(ds.triggerR)
"""Both triggers to canonical Off (mode 0x05). Actively retracts the motor."""
_apply_off(ds.triggerL)
_apply_off(ds.triggerR)
def reset_lightbar(ds: pydualsense) -> None:
"""Lightbar to off (RGB 0,0,0).
Used when telemetry has been idle long enough that we should stop asserting
a race color \u2014 e.g. Forza exited or hasn't started a session yet. Without
this, pydualsense's BG sendReport thread keeps re-publishing whatever
`TouchpadColor` we last set, so the controller stays lit indefinitely.
"""
ds.light.setColorI(0, 0, 0)
# --- RacingDSX math primitives ------------------------------------------------
@@ -701,6 +711,7 @@ def run(host: str, port: int, debug: bool) -> int:
forza_state = _ForzaState()
last_seen = 0.0
in_race = False
have_telemetry = False # True between the first packet and the IDLE_TIMEOUT_S reset
try:
while True:
@@ -716,10 +727,17 @@ def run(host: str, port: int, debug: bool) -> int:
try:
data, _ = sock.recvfrom(2048)
last_seen = now
have_telemetry = True
except socket.timeout:
if in_race and (now - last_seen) > IDLE_TIMEOUT_S:
LOG.info("forza idle for %.1fs \u2014 resetting triggers", IDLE_TIMEOUT_S)
# Reset on telemetry-idle regardless of in_race state. After Forza
# exits with the user in its main menu (is_race_on=0 packets just
# before exit, so in_race was already False), the old check would
# leave the last lightbar/trigger state asserted forever.
if have_telemetry and (now - last_seen) > IDLE_TIMEOUT_S:
LOG.info("forza idle for %.1fs \u2014 resetting controller", IDLE_TIMEOUT_S)
reset_triggers(ds)
reset_lightbar(ds)
have_telemetry = False
in_race = False
continue
@@ -733,11 +751,11 @@ def run(host: str, port: int, debug: bool) -> int:
in_race = forza_is_race_on(pkt, forza_state)
if not in_race:
# GetPreRaceInstructions: lightbar -> car class color, both
# triggers -> Normal (mode 0x00). Re-asserted every frame to
# mirror RacingDSX's per-packet emission.
_apply_normal(ds.triggerL)
_apply_normal(ds.triggerR)
# Pre-race: lightbar -> car class color, both triggers actively
# released to neutral (mode 0x05, Sony's canonical Off). RacingDSX
# writes mode 0x00 (Normal) here; see divergence #4 in module docstring.
_apply_off(ds.triggerL)
_apply_off(ds.triggerR)
apply_lightbar_pre_race(ds, pkt, forza_state)
continue