forza-trigger: hysteresis on is_race_on (real fix for between-race clicks)

Strace post-deploy showed the daemon flipping every other packet between
in-race state (mode 0x21 FEEDBACK + LED green) and out-of-race state
(mode 0x05 OFF + LED black). 8 writes, 8 transitions in 5s — every
HID OUT report a state change.

Root cause: FH5 emits packets where the is_race_on field alternates
True/False at packet rate during menu/loading/transition states.
Cosmii's RPM_ACCUMULATOR debounce only handles the 'flag stuck True
when we're really in a menu' case (quirk #1); it does nothing for
'flag flipping at packet rate' (quirk #2).

Fix: split the read from the hysteresis. is_race_on now returns the
raw flag with quirk #1 applied. commit_in_race applies a packet-count
hysteresis (IN_RACE_HYSTERESIS_PACKETS = 30 ≈ 0.5s at 60Hz) — only
after N consecutive packets of the new value does the committed
in-race state flip. Alternation just keeps the pending counter
oscillating near 0; it never reaches threshold and the run-loop sees
a stable state.

Architecture: hysteresis in a separate function (not in is_race_on)
because the run-loop must update state.last_in_race AFTER side
effects, not before — otherwise the elif transition-detection
breaks. is_race_on stays pure read; commit_in_race is the gatekeeper;
run-loop sets state.last_in_race once at end of packet handling.

Tests grew 54→58. New TestCommitInRace covers:
  - stable input (no pending growth)
  - worst-case alternation (never commits)
  - N consecutive packets commit
  - matching raw resets pending counter

TestIsRaceOn renamed to reflect new (narrower) responsibility.
This commit is contained in:
2026-05-07 15:11:35 -04:00
parent b9cdc6a9b7
commit 5798caef37
2 changed files with 109 additions and 15 deletions

View File

@@ -150,6 +150,13 @@ COLOR_CLASS_X = (105, 182, 72) # green for above-S2
# override is_race_on to false. ~3.3s at 60Hz packet rate. (legacy_Program.cs:35) # override is_race_on to false. ~3.3s at 60Hz packet rate. (legacy_Program.cs:35)
RPM_ACCUMULATOR_TRIGGER_RACE_OFF = 200 RPM_ACCUMULATOR_TRIGGER_RACE_OFF = 200
# 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
# --- 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.
@@ -330,6 +337,15 @@ class DaemonState:
# path (otherwise stale slip flags suppress the seeding fix in # path (otherwise stale slip flags suppress the seeding fix in
# handle_throttle/handle_brake on the first packet of race 2). # handle_throttle/handle_brake on the first packet of race 2).
last_in_race: bool = False last_in_race: bool = False
# is_race_on hysteresis. Forza Horizon emits packets where the
# is_race_on field alternates True/False at packet rate during
# menu/loading transitions. Without hysteresis, the run-loop
# transitions in-race → menu → in-race → menu every other packet,
# which the controller perceives as periodic clicks (every state
# change forces a fresh HID OUT report; the firmware reacts on
# 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. # IsRaceOn debounce.
last_rpm: float = 0.0 last_rpm: float = 0.0
rpm_accumulator: int = 0 rpm_accumulator: int = 0
@@ -356,6 +372,7 @@ class DaemonState:
self.was_brake_slipping = False self.was_brake_slipping = False
self.last_rpm = 0.0 self.last_rpm = 0.0
self.rpm_accumulator = 0 self.rpm_accumulator = 0
self.in_race_pending_count = 0
self.last_color = (0, 0, 0) self.last_color = (0, 0, 0)
@@ -363,25 +380,51 @@ class DaemonState:
def is_race_on(pkt: ForzaDataPacket, state: DaemonState) -> bool: def is_race_on(pkt: ForzaDataPacket, state: DaemonState) -> bool:
"""Cosmii's IsRaceOn debounce (legacy_Program.cs:89-109). """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.
Returns True iff the car is actively being driven. Combines the explicit Hysteresis on flag flips is the caller's job (see commit_in_race).
is_race_on flag with the RPM-stability + zero-power workaround for FH5's
unreliable flag.
""" """
flag = bool(_safe_field(pkt, "is_race_on", 0.0)) raw_flag = bool(_safe_field(pkt, "is_race_on", 0.0))
current_rpm = _safe_field(pkt, "current_engine_rpm", 0.0) current_rpm = _safe_field(pkt, "current_engine_rpm", 0.0)
power = _safe_field(pkt, "power", 0.0) power = _safe_field(pkt, "power", 0.0)
if abs(current_rpm - state.last_rpm) < 1e-3 and power <= 0: if abs(current_rpm - state.last_rpm) < 1e-3 and power <= 0:
state.rpm_accumulator += 1 state.rpm_accumulator += 1
if state.rpm_accumulator > RPM_ACCUMULATOR_TRIGGER_RACE_OFF: if state.rpm_accumulator > RPM_ACCUMULATOR_TRIGGER_RACE_OFF:
flag = False raw_flag = False
else: else:
state.rpm_accumulator = 0 state.rpm_accumulator = 0
state.last_rpm = current_rpm state.last_rpm = current_rpm
return flag return raw_flag
def commit_in_race(raw_flag: bool, state: DaemonState) -> bool:
"""Apply per-packet hysteresis to the raw flag. Returns the committed
in-race state — the value the run-loop should react to.
FH5 emits packets where is_race_on alternates True/False at packet rate
during menu transitions and loading screens. Without hysteresis, the
run-loop transitions every other packet and the controller emits
audible clicks (every state change forces a fresh HID OUT report; the
firmware reacts on receipt). The hysteresis counter requires
IN_RACE_HYSTERESIS_PACKETS consecutive packets of the new value before
we commit a flip — long enough to filter alternation, short enough
(~0.5s at 60Hz) that legitimate race-start/end transitions stay
responsive.
Note: this function does NOT mutate state.last_in_race. The caller
must update state.last_in_race AFTER running any transition side
effects, otherwise the elif state.last_in_race transition-detection
in the run-loop won't fire.
"""
if raw_flag == state.last_in_race:
state.in_race_pending_count = 0
return state.last_in_race
state.in_race_pending_count += 1
if state.in_race_pending_count < IN_RACE_HYSTERESIS_PACKETS:
return state.last_in_race
state.in_race_pending_count = 0
return raw_flag
# --- Trigger handlers ------------------------------------------------------- # --- Trigger handlers -------------------------------------------------------
@@ -773,7 +816,7 @@ def run(host: str, port: int, exit_on_idle: bool = False) -> int:
if pkt is None: if pkt is None:
continue continue
in_race = is_race_on(pkt, state) in_race = commit_in_race(is_race_on(pkt, state), state)
if in_race: if in_race:
handle_throttle(controller, pkt, state) handle_throttle(controller, pkt, state)
handle_brake(controller, pkt, state) handle_brake(controller, pkt, state)

View File

@@ -169,6 +169,11 @@ class TestCombinedSlip(unittest.TestCase):
class TestIsRaceOn(unittest.TestCase): 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.
"""
def test_simple_yes(self): def test_simple_yes(self):
s = ft.DaemonState() s = ft.DaemonState()
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=4000, power=200) pkt = FakePacket(is_race_on=1.0, current_engine_rpm=4000, power=200)
@@ -180,16 +185,15 @@ class TestIsRaceOn(unittest.TestCase):
pkt = FakePacket(is_race_on=0.0) pkt = FakePacket(is_race_on=0.0)
self.assertFalse(ft.is_race_on(pkt, s)) self.assertFalse(ft.is_race_on(pkt, s))
def test_debounce_overrides_stuck_flag(self): def test_rpm_stuck_overrides_flag_after_threshold(self):
# Simulate FH5's stuck-flag bug: is_race_on=1 but RPM and power show # FH5's stuck-flag bug: is_race_on=1 but RPM and power show the car
# the car is in a menu (power<=0, RPM unchanged). # 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) pkt = FakePacket(is_race_on=1.0, current_engine_rpm=800, power=0)
s.last_rpm = 800 s.last_rpm = 800
# Pump packets until the accumulator trips.
for _ in range(ft.RPM_ACCUMULATOR_TRIGGER_RACE_OFF): for _ in range(ft.RPM_ACCUMULATOR_TRIGGER_RACE_OFF):
self.assertTrue(ft.is_race_on(pkt, s)) self.assertTrue(ft.is_race_on(pkt, s))
# One more samples and we cross the threshold.
self.assertFalse(ft.is_race_on(pkt, s)) self.assertFalse(ft.is_race_on(pkt, s))
def test_rpm_change_resets_accumulator(self): def test_rpm_change_resets_accumulator(self):
@@ -201,6 +205,53 @@ class TestIsRaceOn(unittest.TestCase):
self.assertEqual(s.rpm_accumulator, 0) self.assertEqual(s.rpm_accumulator, 0)
class TestCommitInRace(unittest.TestCase):
"""Hysteresis on the raw is_race_on flag. FH5 emits packets where
the bit alternates True/False at packet rate during menu transitions
and loading screens. commit_in_race must filter those alternations.
"""
def test_stable_no_pending_count_never_grows(self):
s = ft.DaemonState()
s.last_in_race = False
for _ in range(100):
self.assertFalse(ft.commit_in_race(False, s))
self.assertEqual(s.in_race_pending_count, 0)
def test_alternation_does_not_commit(self):
# Worst case from real FH5: flag flips every other packet. We
# must keep returning the committed (initial) value forever.
s = ft.DaemonState()
s.last_in_race = False
for i in range(200):
raw = bool(i % 2)
self.assertFalse(ft.commit_in_race(raw, s))
# Pending count never accumulates past 1 because each True is
# immediately followed by a False that resets it.
self.assertLessEqual(s.in_race_pending_count, 1)
def test_n_consecutive_packets_commit_flip(self):
s = ft.DaemonState()
s.last_in_race = False
# First N-1 same-value packets do NOT commit yet.
for _ in range(ft.IN_RACE_HYSTERESIS_PACKETS - 1):
self.assertFalse(ft.commit_in_race(True, s))
# The Nth commits.
self.assertTrue(ft.commit_in_race(True, s))
# Pending count was reset to 0 on commit.
self.assertEqual(s.in_race_pending_count, 0)
def test_committed_value_returned_when_raw_matches(self):
s = ft.DaemonState()
s.last_in_race = True
# Even though state.last_in_race is True, raw flag matches so
# we just return last_in_race without touching pending count.
s.in_race_pending_count = 5
result = ft.commit_in_race(True, s)
self.assertTrue(result)
self.assertEqual(s.in_race_pending_count, 0)
# --- Trigger handler tests -------------------------------------------------- # --- Trigger handler tests --------------------------------------------------