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: