forza-trigger: remove RPM-stuck workaround + bump hysteresis to 120
Two separate causes of stationary trigger pulsing, both fixed: 1. Hysteresis too short. 30 packets (0.5s) was shorter than FH5's stationary oscillation period (~1s per state in start-grid/pause screen contexts). Bumped to 120 (2s at 60Hz). 2. RPM-stuck workaround removed entirely. Cosmii's RPM_ACCUMULATOR (legacy_Program.cs:89-109) forced is_race_on false after 3.3s of constant RPM + zero power. While stationary in-race (idle RPM constant, power near zero), this would trip, causing a false menu transition. Engine idle flutter on power could reset and re-trigger it, producing a slow oscillation with clicks on each edge. FH5 has been observed to correctly clear is_race_on between races (confirmed via live strace), so the workaround is unnecessary. Removed: RPM_ACCUMULATOR_TRIGGER_RACE_OFF constant, is_race_on's debounce logic, DaemonState.last_rpm and .rpm_accumulator fields. is_race_on is now a one-liner: return bool(is_race_on field). Tests: 57/57 pass. TestIsRaceOn simplified from 4 to 3 tests. DaemonState reset test no longer checks removed fields.
This commit is contained in:
@@ -144,18 +144,17 @@ CAR_CLASS_COLORS = {
|
|||||||
}
|
}
|
||||||
COLOR_CLASS_X = (105, 182, 72) # green for above-S2
|
COLOR_CLASS_X = (105, 182, 72) # green for above-S2
|
||||||
|
|
||||||
# --- IsRaceOn debounce ------------------------------------------------------
|
# --- Race detection ---------------------------------------------------------
|
||||||
# FH5 sometimes leaves is_race_on=1 after menu transitions. Count samples
|
|
||||||
# where RPM is stable AND power ≤ 0; once the count exceeds the threshold,
|
|
||||||
# override is_race_on to false. ~3.3s at 60Hz packet rate. (legacy_Program.cs:35)
|
|
||||||
RPM_ACCUMULATOR_TRIGGER_RACE_OFF = 200
|
|
||||||
|
|
||||||
# Hysteresis on is_race_on flag flips. FH5 emits packets where the bit
|
# Hysteresis on is_race_on flag flips. FH5 emits packets where the bit
|
||||||
# alternates True/False at packet rate during menu/loading transitions.
|
# alternates True/False at packet rate during menu/loading transitions.
|
||||||
# This many consecutive packets of the OPPOSITE current state are required
|
# This many consecutive packets of the OPPOSITE current state are required
|
||||||
# before is_race_on() commits a flip. ~0.5s at 60Hz — long enough to
|
# before commit_in_race commits a flip. Stationary in-race (start grid,
|
||||||
# filter alternation, short enough to keep race-start/end responsive.
|
# pause screen, post-crash reset) FH5 emits a slow ~1s oscillation that
|
||||||
IN_RACE_HYSTERESIS_PACKETS = 30
|
# 30 packets (0.5s) doesn't cover. 120 packets = 2s at 60Hz — short enough
|
||||||
|
# that a real race-start transition still feels responsive, long enough to
|
||||||
|
# filter the stationary oscillation.
|
||||||
|
IN_RACE_HYSTERESIS_PACKETS = 120
|
||||||
|
|
||||||
# --- User-facing intensity scales (env vars) --------------------------------
|
# --- User-facing intensity scales (env vars) --------------------------------
|
||||||
# Read once at module import. Process restart picks up new values.
|
# Read once at module import. Process restart picks up new values.
|
||||||
@@ -346,9 +345,6 @@ class DaemonState:
|
|||||||
# receipt). The hysteresis counter requires N consecutive packets
|
# receipt). The hysteresis counter requires N consecutive packets
|
||||||
# of the OPPOSITE flag value before flipping committed_in_race.
|
# of the OPPOSITE flag value before flipping committed_in_race.
|
||||||
in_race_pending_count: int = 0
|
in_race_pending_count: int = 0
|
||||||
# IsRaceOn debounce.
|
|
||||||
last_rpm: float = 0.0
|
|
||||||
rpm_accumulator: int = 0
|
|
||||||
# Last-known valid car class / CPI. We always update on observation —
|
# Last-known valid car class / CPI. We always update on observation —
|
||||||
# Cosmii's `> 0` guard treats Class-D (enum value 0) as "no info" and
|
# Cosmii's `> 0` guard treats Class-D (enum value 0) as "no info" and
|
||||||
# leaves the lightbar showing the previous class color, which is wrong.
|
# leaves the lightbar showing the previous class color, which is wrong.
|
||||||
@@ -370,8 +366,7 @@ class DaemonState:
|
|||||||
self.last_brake_freq = 0.0
|
self.last_brake_freq = 0.0
|
||||||
self.was_throttle_slipping = False
|
self.was_throttle_slipping = False
|
||||||
self.was_brake_slipping = False
|
self.was_brake_slipping = False
|
||||||
self.last_rpm = 0.0
|
|
||||||
self.rpm_accumulator = 0
|
|
||||||
self.in_race_pending_count = 0
|
self.in_race_pending_count = 0
|
||||||
self.last_color = (0, 0, 0)
|
self.last_color = (0, 0, 0)
|
||||||
|
|
||||||
@@ -380,22 +375,19 @@ class DaemonState:
|
|||||||
|
|
||||||
|
|
||||||
def is_race_on(pkt: ForzaDataPacket, state: DaemonState) -> bool:
|
def is_race_on(pkt: ForzaDataPacket, state: DaemonState) -> bool:
|
||||||
"""Read the raw is_race_on flag with FH5's RPM-stuck-true workaround
|
"""Read the is_race_on flag from the current Forza packet.
|
||||||
(Cosmii's RPM_ACCUMULATOR debounce — legacy_Program.cs:89-109) applied.
|
|
||||||
Hysteresis on flag flips is the caller's job (see commit_in_race).
|
|
||||||
"""
|
|
||||||
raw_flag = bool(_safe_field(pkt, "is_race_on", 0.0))
|
|
||||||
current_rpm = _safe_field(pkt, "current_engine_rpm", 0.0)
|
|
||||||
power = _safe_field(pkt, "power", 0.0)
|
|
||||||
|
|
||||||
if abs(current_rpm - state.last_rpm) < 1e-3 and power <= 0:
|
FH5's per-packet oscillation on this field is handled upstream by
|
||||||
state.rpm_accumulator += 1
|
commit_in_race (hysteresis), not here. Previously we carried Cosmii's
|
||||||
if state.rpm_accumulator > RPM_ACCUMULATOR_TRIGGER_RACE_OFF:
|
RPM-stability workaround (legacy_Program.cs:89-109) which forced the
|
||||||
raw_flag = False
|
flag false when RPM was unchanged AND power <= 0 for 200+ packets.
|
||||||
else:
|
That caused false-positive menu transitions while stationary in-race
|
||||||
state.rpm_accumulator = 0
|
(idle RPM is constant, power may hover near zero) — the daemon would
|
||||||
state.last_rpm = current_rpm
|
oscillate between race/menu states and produce audible trigger clicks.
|
||||||
return raw_flag
|
FH5 has been observed to correctly set is_race_on=False between races
|
||||||
|
(confirmed via live strace on yarn), so the workaround is removed.
|
||||||
|
"""
|
||||||
|
return bool(_safe_field(pkt, "is_race_on", 0.0))
|
||||||
|
|
||||||
|
|
||||||
def commit_in_race(raw_flag: bool, state: DaemonState) -> bool:
|
def commit_in_race(raw_flag: bool, state: DaemonState) -> bool:
|
||||||
|
|||||||
@@ -165,45 +165,26 @@ class TestCombinedSlip(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(rear, 0.3)
|
self.assertAlmostEqual(rear, 0.3)
|
||||||
|
|
||||||
|
|
||||||
# --- Race-on debounce tests -------------------------------------------------
|
# --- Race-on tests ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestIsRaceOn(unittest.TestCase):
|
class TestIsRaceOn(unittest.TestCase):
|
||||||
"""is_race_on returns the RAW flag (with FH5 RPM-stuck workaround)
|
"""is_race_on reads the raw flag from the packet. Hysteresis lives in
|
||||||
only — hysteresis lives in commit_in_race so the run-loop can sequence
|
commit_in_race; the old RPM-stuck workaround was removed (caused false
|
||||||
transition handling correctly. Tests here exercise just the raw read.
|
menu transitions while stationary in-race)."""
|
||||||
"""
|
|
||||||
|
|
||||||
def test_simple_yes(self):
|
def test_reads_flag_true(self):
|
||||||
s = ft.DaemonState()
|
s = ft.DaemonState()
|
||||||
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=4000, power=200)
|
self.assertTrue(ft.is_race_on(FakePacket(is_race_on=1.0), s))
|
||||||
self.assertTrue(ft.is_race_on(pkt, s))
|
|
||||||
self.assertEqual(s.rpm_accumulator, 0)
|
|
||||||
|
|
||||||
def test_explicit_no(self):
|
def test_reads_flag_false(self):
|
||||||
s = ft.DaemonState()
|
s = ft.DaemonState()
|
||||||
pkt = FakePacket(is_race_on=0.0)
|
self.assertFalse(ft.is_race_on(FakePacket(is_race_on=0.0), s))
|
||||||
self.assertFalse(ft.is_race_on(pkt, s))
|
|
||||||
|
|
||||||
def test_rpm_stuck_overrides_flag_after_threshold(self):
|
def test_defaults_to_flag_value(self):
|
||||||
# FH5's stuck-flag bug: is_race_on=1 but RPM and power show the car
|
# FakePacket defaults is_race_on=1.0; is_race_on just reads it.
|
||||||
# is actually in a menu (power<=0, RPM unchanged). is_race_on must
|
|
||||||
# return False once the accumulator trips, regardless of the flag.
|
|
||||||
s = ft.DaemonState()
|
s = ft.DaemonState()
|
||||||
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=800, power=0)
|
self.assertTrue(ft.is_race_on(FakePacket(), s))
|
||||||
s.last_rpm = 800
|
|
||||||
for _ in range(ft.RPM_ACCUMULATOR_TRIGGER_RACE_OFF):
|
|
||||||
self.assertTrue(ft.is_race_on(pkt, s))
|
|
||||||
self.assertFalse(ft.is_race_on(pkt, s))
|
|
||||||
|
|
||||||
def test_rpm_change_resets_accumulator(self):
|
|
||||||
s = ft.DaemonState()
|
|
||||||
s.rpm_accumulator = 50
|
|
||||||
s.last_rpm = 800
|
|
||||||
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=4000, power=100)
|
|
||||||
ft.is_race_on(pkt, s)
|
|
||||||
self.assertEqual(s.rpm_accumulator, 0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommitInRace(unittest.TestCase):
|
class TestCommitInRace(unittest.TestCase):
|
||||||
"""Hysteresis on the raw is_race_on flag. FH5 emits packets where
|
"""Hysteresis on the raw is_race_on flag. FH5 emits packets where
|
||||||
@@ -468,8 +449,7 @@ class TestDaemonStateReset(unittest.TestCase):
|
|||||||
s.last_brake_resistance = 99.0
|
s.last_brake_resistance = 99.0
|
||||||
s.last_throttle_freq = 99.0
|
s.last_throttle_freq = 99.0
|
||||||
s.last_brake_freq = 99.0
|
s.last_brake_freq = 99.0
|
||||||
s.rpm_accumulator = 50
|
s.in_race_pending_count = 50
|
||||||
s.last_rpm = 4000
|
|
||||||
|
|
||||||
s.reset()
|
s.reset()
|
||||||
|
|
||||||
@@ -477,8 +457,7 @@ class TestDaemonStateReset(unittest.TestCase):
|
|||||||
self.assertEqual(s.last_brake_resistance, 1.0)
|
self.assertEqual(s.last_brake_resistance, 1.0)
|
||||||
self.assertEqual(s.last_throttle_freq, 0.0)
|
self.assertEqual(s.last_throttle_freq, 0.0)
|
||||||
self.assertEqual(s.last_brake_freq, 0.0)
|
self.assertEqual(s.last_brake_freq, 0.0)
|
||||||
self.assertEqual(s.rpm_accumulator, 0)
|
self.assertEqual(s.in_race_pending_count, 0)
|
||||||
self.assertEqual(s.last_rpm, 0.0)
|
|
||||||
|
|
||||||
def test_reset_clears_lightbar_dedup(self):
|
def test_reset_clears_lightbar_dedup(self):
|
||||||
# last_color MUST be cleared on idle reset. The `apply_lightbar`
|
# last_color MUST be cleared on idle reset. The `apply_lightbar`
|
||||||
|
|||||||
Reference in New Issue
Block a user