diff --git a/hosts/yarn/forza-trigger/forza_trigger.py b/hosts/yarn/forza-trigger/forza_trigger.py index 18dc1b8..4cadb5d 100644 --- a/hosts/yarn/forza-trigger/forza_trigger.py +++ b/hosts/yarn/forza-trigger/forza_trigger.py @@ -75,6 +75,18 @@ output report at fixed offsets. That is exactly what we want. timeout), then mode 0x00 for steady-state pre-race / idle frames \u2014 motor stays released, no continuous retraction noise. +5. Throttle gated on clutch state. Forza emits a `clutch` byte (0..255). When + the clutch is disengaged (byte > 128) the engine is mechanically disconnected + from the wheels and the throttle pedal can't transmit power; the trigger has + no business resisting. RacingDSX's throttle resistance formula is + `avgAccel = sqrt(0.25*X\u00b2 + 1.0*Z\u00b2)` derived from the accelerometer alone + with no clutch check, so the trigger keeps producing resistance from + cornering G-forces while the clutch is in. We bypass the throttle path + entirely when clutch > 128, releasing the trigger using the same one-shot- + then-steady pattern as divergence #4. Auto-clutch users will notice ~100 ms + trigger relaxations during shifts; that's actually physically accurate \u2014 + the engine *is* momentarily disconnected during a shift. + ## Threading note pydualsense's `sendReport` background thread reads `triggerR/L.mode` and @@ -203,6 +215,16 @@ RPM_REDLINE_RATIO = 0.9 # Profile.RPMRedlineRatio GREEN_FLOOR = 50 # Math.Max(..., 50) on green channel in non-redline path RACE_OFF_RPM_FRAMES = 200 # ForzaParser.RPMAccumulatorTriggerRaceOff +# --- Clutch gate (throttle only) --------------------------------------------- +# Forza emits `clutch` 0..255 (0 = pedal up / engaged / engine connected to +# wheels, 255 = pedal floored / fully disengaged). With the clutch disengaged +# the throttle pedal is mechanically irrelevant \u2014 pressing it just revs the +# engine without transmitting power. RacingDSX has no clutch gate, so its +# `avgAccel = sqrt(0.25*X\u00b2 + 1.0*Z\u00b2)` formula keeps producing throttle +# resistance from cornering G-forces even while the clutch is in. +CLUTCH_DISENGAGE_THRESHOLD = 128 + + # --- Reset on idle (UDP timeout) --------------------------------------------- # Not present in RacingDSX; an additional safety so the controller doesn't get # stuck if Forza is killed mid-race or the network drops. @@ -405,12 +427,17 @@ def _safe(pkt: ForzaDataPacket, name: str, default: float = 0.0) -> float: class _TriggerState: - __slots__ = ("last_resistance", "last_freq") + __slots__ = ("last_resistance", "last_freq", "prev_clutched") def __init__(self, init_resistance: int) -> None: # Mirrors RacingDSX's `int lastThrottleResistance` / `int lastBrakeResistance`. self.last_resistance: int = int(init_resistance) self.last_freq: int = 0 + # Throttle only: tracks last frame's clutch state so the throttle path + # can fire one-shot 0x05 (active retract) on the engaged-\u2192-disengaged + # transition and 0x00 (no-op) for steady-state held-in. See + # CLUTCH_DISENGAGE_THRESHOLD and divergence #5 in the module docstring. + self.prev_clutched: bool = False # --- Forza game-level persistent state (ForzaParser.cs fields) ---------------- @@ -526,7 +553,23 @@ def apply_lightbar_in_race(ds: pydualsense, pkt: ForzaDataPacket) -> None: def apply_right_trigger(ds: pydualsense, pkt: ForzaDataPacket, st: _TriggerState) -> None: - """Mirrors `Parser.GetInRaceRightTriggerInstruction()` line for line.""" + """Mirrors `Parser.GetInRaceRightTriggerInstruction()` line for line, with + one divergence: the throttle is released when the clutch is disengaged. + See divergence #5 in the module docstring.""" + # Clutch gate: 0..255, byte > 128 means "clutch fully or mostly pressed"; + # engine is mechanically disconnected, so the throttle pedal can't transmit + # power and shouldn't have any feel. One-shot 0x05 on transition into the + # clutched state, then steady-state 0x00 to avoid the trigger-motor whine + # described in divergence #4. + if int(_safe(pkt, "clutch", 0.0)) > CLUTCH_DISENGAGE_THRESHOLD: + if not st.prev_clutched: + _apply_off(ds.triggerR) + else: + _apply_normal(ds.triggerR) + st.prev_clutched = True + return + st.prev_clutched = False + accel_x = _safe(pkt, "acceleration_x") accel_z = _safe(pkt, "acceleration_z") avg_accel = math.sqrt(