diff --git a/hosts/yarn/forza-trigger/forza_trigger.py b/hosts/yarn/forza-trigger/forza_trigger.py index b54252a..1381a34 100644 --- a/hosts/yarn/forza-trigger/forza_trigger.py +++ b/hosts/yarn/forza-trigger/forza_trigger.py @@ -144,18 +144,17 @@ CAR_CLASS_COLORS = { } COLOR_CLASS_X = (105, 182, 72) # green for above-S2 -# --- IsRaceOn debounce ------------------------------------------------------ -# 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 +# --- Race detection --------------------------------------------------------- # Hysteresis on is_race_on flag flips. FH5 emits packets where the bit # alternates True/False at packet rate during menu/loading transitions. # 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 -# filter alternation, short enough to keep race-start/end responsive. -IN_RACE_HYSTERESIS_PACKETS = 30 +# before commit_in_race commits a flip. Stationary in-race (start grid, +# pause screen, post-crash reset) FH5 emits a slow ~1s oscillation that +# 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) -------------------------------- # 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 # of the OPPOSITE flag value before flipping committed_in_race. 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 — # 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. @@ -370,8 +366,7 @@ class DaemonState: self.last_brake_freq = 0.0 self.was_throttle_slipping = False self.was_brake_slipping = False - self.last_rpm = 0.0 - self.rpm_accumulator = 0 + self.in_race_pending_count = 0 self.last_color = (0, 0, 0) @@ -380,22 +375,19 @@ class DaemonState: def is_race_on(pkt: ForzaDataPacket, state: DaemonState) -> bool: - """Read the raw is_race_on flag with FH5's RPM-stuck-true workaround - (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) + """Read the is_race_on flag from the current Forza packet. - if abs(current_rpm - state.last_rpm) < 1e-3 and power <= 0: - state.rpm_accumulator += 1 - if state.rpm_accumulator > RPM_ACCUMULATOR_TRIGGER_RACE_OFF: - raw_flag = False - else: - state.rpm_accumulator = 0 - state.last_rpm = current_rpm - return raw_flag + FH5's per-packet oscillation on this field is handled upstream by + commit_in_race (hysteresis), not here. Previously we carried Cosmii's + RPM-stability workaround (legacy_Program.cs:89-109) which forced the + flag false when RPM was unchanged AND power <= 0 for 200+ packets. + That caused false-positive menu transitions while stationary in-race + (idle RPM is constant, power may hover near zero) — the daemon would + oscillate between race/menu states and produce audible trigger clicks. + 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: diff --git a/hosts/yarn/forza-trigger/test_forza_trigger.py b/hosts/yarn/forza-trigger/test_forza_trigger.py index 244c2f0..ed82730 100644 --- a/hosts/yarn/forza-trigger/test_forza_trigger.py +++ b/hosts/yarn/forza-trigger/test_forza_trigger.py @@ -165,45 +165,26 @@ class TestCombinedSlip(unittest.TestCase): self.assertAlmostEqual(rear, 0.3) -# --- Race-on debounce tests ------------------------------------------------- +# --- Race-on tests ---------------------------------------------------------- class TestIsRaceOn(unittest.TestCase): - """is_race_on returns the RAW flag (with FH5 RPM-stuck workaround) - only — hysteresis lives in commit_in_race so the run-loop can sequence - transition handling correctly. Tests here exercise just the raw read. - """ + """is_race_on reads the raw flag from the packet. Hysteresis lives in + commit_in_race; the old RPM-stuck workaround was removed (caused false + menu transitions while stationary in-race).""" - def test_simple_yes(self): + def test_reads_flag_true(self): s = ft.DaemonState() - pkt = FakePacket(is_race_on=1.0, current_engine_rpm=4000, power=200) - self.assertTrue(ft.is_race_on(pkt, s)) - self.assertEqual(s.rpm_accumulator, 0) + self.assertTrue(ft.is_race_on(FakePacket(is_race_on=1.0), s)) - def test_explicit_no(self): + def test_reads_flag_false(self): s = ft.DaemonState() - pkt = FakePacket(is_race_on=0.0) - self.assertFalse(ft.is_race_on(pkt, s)) + self.assertFalse(ft.is_race_on(FakePacket(is_race_on=0.0), s)) - def test_rpm_stuck_overrides_flag_after_threshold(self): - # FH5's stuck-flag bug: is_race_on=1 but RPM and power show the car - # is actually in a menu (power<=0, RPM unchanged). is_race_on must - # return False once the accumulator trips, regardless of the flag. + def test_defaults_to_flag_value(self): + # FakePacket defaults is_race_on=1.0; is_race_on just reads it. s = ft.DaemonState() - pkt = FakePacket(is_race_on=1.0, current_engine_rpm=800, power=0) - 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) - + self.assertTrue(ft.is_race_on(FakePacket(), s)) class TestCommitInRace(unittest.TestCase): """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_throttle_freq = 99.0 s.last_brake_freq = 99.0 - s.rpm_accumulator = 50 - s.last_rpm = 4000 + s.in_race_pending_count = 50 s.reset() @@ -477,8 +457,7 @@ class TestDaemonStateReset(unittest.TestCase): self.assertEqual(s.last_brake_resistance, 1.0) self.assertEqual(s.last_throttle_freq, 0.0) self.assertEqual(s.last_brake_freq, 0.0) - self.assertEqual(s.rpm_accumulator, 0) - self.assertEqual(s.last_rpm, 0.0) + self.assertEqual(s.in_race_pending_count, 0) def test_reset_clears_lightbar_dedup(self): # last_color MUST be cleared on idle reset. The `apply_lightbar`