forza-trigger: stop emitting mode 0x05 every frame in pre-race idle

The previous fix used canonical Off (mode 0x05) everywhere we wanted the
trigger to feel released \u2014 pre-race per-frame, idle timeout, shutdown.
Per Sony's docs (Nielk1 Rev 6) mode 0x05 "actively returns the trigger
stop to the neutral position". Re-asserting it 60 times/sec from main
thread, propagated by pydualsense's BG thread to the controller at
~250 Hz, made the trigger motor audibly whine as the firmware repeatedly
snapped the (already-neutral) trigger back to neutral.

Right answer: hybrid. One-shot 0x05 on the in-race \u2192 not-in-race
transition (and on the telemetry-idle timeout) so the firmware actually
retracts the motor; mode 0x00 (TriggerModes.Off, no-op clear) for
steady-state pre-race / idle frames so we're not yelling RESET in the
firmware's ear forever.

Implementation: prev_in_race tracks the last frame's race state. Steady
non-race frames call _apply_normal (mode 0x00); the first frame after a
race-end transition calls _apply_off (mode 0x05). pydualsense's BG
thread holds the 0x05 in memory long enough (one main-thread frame =
~16ms = ~4 BG iterations) to publish it to the controller before main
switches the in-memory state to 0x00.

Restores _apply_normal and DS_MODE_NORMAL that the previous commit
deleted. Updates divergence #4 in the module docstring.
This commit is contained in:
2026-05-02 21:22:21 -04:00
parent eb4cd0782d
commit 7f9a57978a

View File

@@ -61,14 +61,19 @@ 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).
4. Trigger release combines mode 0x05 (active) with mode 0x00 (steady-state).
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.
returns the trigger stop to neutral. RacingDSX-on-Windows gets away with
0x00 because something on Windows (Steam Input or the OS) reliably resets
the motor on focus loss; on Linux nothing does, and R2 keeps residual
tension after a race ends. But re-asserting 0x05 every frame in steady-state
pre-race causes the trigger motor to audibly whine as the firmware repeatedly
snaps the (already-neutral) trigger back to neutral. So we use 0x05 as a
one-shot on the in-race \u2192 not-in-race transition (and on the telemetry-idle
timeout), then mode 0x00 for steady-state pre-race / idle frames \u2014 motor
stays released, no continuous retraction noise.
## Threading note
@@ -132,9 +137,11 @@ LOG = logging.getLogger("forza-trigger")
# --- Mode bytes ---------------------------------------------------------------
# pydualsense's IntFlag aliases happen to cover the modes we need:
# TriggerModes.Off = 0x00 (no-op; clears command without retracting motor)
# 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 # 0x00 "clear command"; motor stays in last-set physical state
DS_MODE_OFF = TriggerModes(0x05)
DS_MODE_SIMPLE_VIBRATION = TriggerModes.Pulse_B
DS_MODE_FEEDBACK = TriggerModes.Rigid_A
@@ -335,6 +342,22 @@ def _apply_simple_vibration(trig, position: int, amplitude: int, frequency: int)
trig.mode = DS_MODE_SIMPLE_VIBRATION
def _apply_normal(trig) -> None:
"""Mode 0x00 (TriggerModes.Off) + zero forces.
Per Sony's docs (Nielk1 Rev 6) mode 0x00 is a *clear/no-op* command \u2014 the
firmware's last-set physical effect persists. We use this for steady-state
pre-race / idle frames after `_apply_off` has already retracted the motor
via mode 0x05. Re-asserting 0x05 every frame causes the motor to audibly
whine as the firmware repeatedly snaps the (already-neutral) trigger back
to neutral.
"""
for i in range(7):
trig.forces[i] = 0
trig.mode = DS_MODE_NORMAL
def reset_triggers(ds: pydualsense) -> None:
"""Both triggers to canonical Off (mode 0x05). Actively retracts the motor."""
_apply_off(ds.triggerL)
@@ -711,6 +734,7 @@ def run(host: str, port: int, debug: bool) -> int:
forza_state = _ForzaState()
last_seen = 0.0
in_race = False
prev_in_race = False # for transition detection \u2014 see _apply_normal docstring
have_telemetry = False # True between the first packet and the IDLE_TIMEOUT_S reset
try:
@@ -735,10 +759,15 @@ def run(host: str, port: int, debug: bool) -> int:
# 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)
# One-shot 0x05 to actively retract the trigger motor; the BG
# thread will publish it ~12 times in the next 50ms before main
# thread loops back here. Subsequent idle iterations don't
# re-enter this branch (have_telemetry is now False).
reset_triggers(ds)
reset_lightbar(ds)
have_telemetry = False
in_race = False
prev_in_race = False
continue
pkt = parse_packet(data)
@@ -751,12 +780,19 @@ def run(host: str, port: int, debug: bool) -> int:
in_race = forza_is_race_on(pkt, forza_state)
if not in_race:
# 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.
# Transition into pre-race: one-shot mode 0x05 to actively
# retract the trigger motor. Subsequent steady-state frames
# send mode 0x00 (no command); re-asserting 0x05 every frame
# makes the firmware audibly whine retracting an already-
# neutral trigger. Divergence #4 in the module docstring.
if prev_in_race:
_apply_off(ds.triggerL)
_apply_off(ds.triggerR)
else:
_apply_normal(ds.triggerL)
_apply_normal(ds.triggerR)
apply_lightbar_pre_race(ds, pkt, forza_state)
prev_in_race = False
continue
if debug:
@@ -780,6 +816,7 @@ def run(host: str, port: int, debug: bool) -> int:
apply_lightbar_in_race(ds, pkt)
apply_left_trigger(ds, pkt, brake_state)
apply_right_trigger(ds, pkt, throttle_state)
prev_in_race = True
except KeyboardInterrupt:
LOG.info("shutting down")
finally: