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 bb983a88e2
commit b25cb4a90f

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 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. 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, 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 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* *clears* state and does not retract the trigger motor; mode 0x05 *actively*
returns the trigger stop to neutral. On Linux/pydualsense, RacingDSX's 0x00 returns the trigger stop to neutral. RacingDSX-on-Windows gets away with
leaves R2 with residual tension after a race ends because nothing forces a 0x00 because something on Windows (Steam Input or the OS) reliably resets
physical reset (Windows DSX or Steam Input must do it on that platform). the motor on focus loss; on Linux nothing does, and R2 keeps residual
We always emit 0x05 for any \"trigger should feel free\" intent. 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 ## Threading note
@@ -132,9 +137,11 @@ LOG = logging.getLogger("forza-trigger")
# --- Mode bytes --------------------------------------------------------------- # --- Mode bytes ---------------------------------------------------------------
# pydualsense's IntFlag aliases happen to cover the modes we need: # 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(0x05) = 0x05 (canonical Sony Off / Reset)
# TriggerModes.Pulse_B = 0x06 (Simple_Vibration / Simple_AutomaticGun) # TriggerModes.Pulse_B = 0x06 (Simple_Vibration / Simple_AutomaticGun)
# TriggerModes.Rigid_A = 0x21 (Feedback, canonical) # 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_OFF = TriggerModes(0x05)
DS_MODE_SIMPLE_VIBRATION = TriggerModes.Pulse_B DS_MODE_SIMPLE_VIBRATION = TriggerModes.Pulse_B
DS_MODE_FEEDBACK = TriggerModes.Rigid_A 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 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: def reset_triggers(ds: pydualsense) -> None:
"""Both triggers to canonical Off (mode 0x05). Actively retracts the motor.""" """Both triggers to canonical Off (mode 0x05). Actively retracts the motor."""
_apply_off(ds.triggerL) _apply_off(ds.triggerL)
@@ -711,6 +734,7 @@ def run(host: str, port: int, debug: bool) -> int:
forza_state = _ForzaState() forza_state = _ForzaState()
last_seen = 0.0 last_seen = 0.0
in_race = False 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 have_telemetry = False # True between the first packet and the IDLE_TIMEOUT_S reset
try: try:
@@ -735,10 +759,15 @@ def run(host: str, port: int, debug: bool) -> int:
# leave the last lightbar/trigger state asserted forever. # leave the last lightbar/trigger state asserted forever.
if have_telemetry and (now - last_seen) > IDLE_TIMEOUT_S: if have_telemetry and (now - last_seen) > IDLE_TIMEOUT_S:
LOG.info("forza idle for %.1fs \u2014 resetting controller", 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_triggers(ds)
reset_lightbar(ds) reset_lightbar(ds)
have_telemetry = False have_telemetry = False
in_race = False in_race = False
prev_in_race = False
continue continue
pkt = parse_packet(data) 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) in_race = forza_is_race_on(pkt, forza_state)
if not in_race: if not in_race:
# Pre-race: lightbar -> car class color, both triggers actively # Transition into pre-race: one-shot mode 0x05 to actively
# released to neutral (mode 0x05, Sony's canonical Off). RacingDSX # retract the trigger motor. Subsequent steady-state frames
# writes mode 0x00 (Normal) here; see divergence #4 in module docstring. # 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.triggerL)
_apply_off(ds.triggerR) _apply_off(ds.triggerR)
else:
_apply_normal(ds.triggerL)
_apply_normal(ds.triggerR)
apply_lightbar_pre_race(ds, pkt, forza_state) apply_lightbar_pre_race(ds, pkt, forza_state)
prev_in_race = False
continue continue
if debug: if debug:
@@ -780,6 +816,7 @@ def run(host: str, port: int, debug: bool) -> int:
apply_lightbar_in_race(ds, pkt) apply_lightbar_in_race(ds, pkt)
apply_left_trigger(ds, pkt, brake_state) apply_left_trigger(ds, pkt, brake_state)
apply_right_trigger(ds, pkt, throttle_state) apply_right_trigger(ds, pkt, throttle_state)
prev_in_race = True
except KeyboardInterrupt: except KeyboardInterrupt:
LOG.info("shutting down") LOG.info("shutting down")
finally: finally: