forza-trigger: gate throttle on clutch state

User report: with the clutch in (pedal pressed, engine disconnected from
wheels), steering left still produced resistance on R2. The throttle
shouldn't have any feel when it's mechanically irrelevant.

RacingDSX's throttle resistance formula is
`avgAccel = sqrt(0.25*X^2 + 1.0*Z^2)`
derived from the accelerometer alone. It never checks clutch state, so
cornering G-forces keep producing trigger resistance even while the
clutch pedal is floored. Bug.

Fix: when Forza's clutch byte > 128 (clutch fully or mostly disengaged)
bypass the entire throttle path \u2014 slip detection and non-slip Feedback
both \u2014 and release the trigger. Uses the same one-shot 0x05 (active
retract) on transition + steady-state 0x00 (no-op) pattern as the
in-race \u2192 not-in-race transition (divergence #4) so we don't get the
trigger-motor whine from re-asserting 0x05 every frame.

Brake is unaffected: brake calipers operate independently of clutch
state, so ABS feel during clutch-in is still correct.

For auto-clutch users this also produces brief (~100 ms) trigger
relaxations during shifts \u2014 physically accurate (the engine *is*
momentarily disconnected during a shift) and matches the haptic feel of
a real manual transmission.

Documented as divergence #5 in the module docstring.
This commit is contained in:
2026-05-03 00:22:32 -04:00
parent c9ddc8f8f2
commit 1e8c294a80

View File

@@ -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 timeout), then mode 0x00 for steady-state pre-race / idle frames \u2014 motor
stays released, no continuous retraction noise. 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 ## Threading note
pydualsense's `sendReport` background thread reads `triggerR/L.mode` and 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 GREEN_FLOOR = 50 # Math.Max(..., 50) on green channel in non-redline path
RACE_OFF_RPM_FRAMES = 200 # ForzaParser.RPMAccumulatorTriggerRaceOff 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) --------------------------------------------- # --- Reset on idle (UDP timeout) ---------------------------------------------
# Not present in RacingDSX; an additional safety so the controller doesn't get # Not present in RacingDSX; an additional safety so the controller doesn't get
# stuck if Forza is killed mid-race or the network drops. # 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: class _TriggerState:
__slots__ = ("last_resistance", "last_freq") __slots__ = ("last_resistance", "last_freq", "prev_clutched")
def __init__(self, init_resistance: int) -> None: def __init__(self, init_resistance: int) -> None:
# Mirrors RacingDSX's `int lastThrottleResistance` / `int lastBrakeResistance`. # Mirrors RacingDSX's `int lastThrottleResistance` / `int lastBrakeResistance`.
self.last_resistance: int = int(init_resistance) self.last_resistance: int = int(init_resistance)
self.last_freq: int = 0 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) ---------------- # --- 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: 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_x = _safe(pkt, "acceleration_x")
accel_z = _safe(pkt, "acceleration_z") accel_z = _safe(pkt, "acceleration_z")
avg_accel = math.sqrt( avg_accel = math.sqrt(