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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user