From b25cb4a90fa676302f09e577b1cf54b1fcc94177 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Sat, 2 May 2026 21:22:21 -0400 Subject: [PATCH] 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. --- hosts/yarn/forza-trigger/forza_trigger.py | 57 +++++++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/hosts/yarn/forza-trigger/forza_trigger.py b/hosts/yarn/forza-trigger/forza_trigger.py index e33c48e..18dc1b8 100644 --- a/hosts/yarn/forza-trigger/forza_trigger.py +++ b/hosts/yarn/forza-trigger/forza_trigger.py @@ -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. - _apply_off(ds.triggerL) - _apply_off(ds.triggerR) + # 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: