Skip to content

feat: add Gantry race control overlay - #654

Draft
23Kev wants to merge 15 commits into
tariknz:mainfrom
23Kev:feat/gantry-v2
Draft

feat: add Gantry race control overlay#654
23Kev wants to merge 15 commits into
tariknz:mainfrom
23Kev:feat/gantry-v2

Conversation

@23Kev

@23Kev 23Kev commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds the Gantry overlay - a race control view for spectating and replay review. It shows a full standings list, a live incident feed, and a lap-by-lap gap graph, and it is built for a spare screen rather than for use while driving.

Incidents are detected in the main process from telemetry: off-tracks, pit entries, sustained slow running, sudden stops, black flags, and car-to-car contact. Clicking an incident jumps the replay to it. Clicking a driver in the standings points the camera at that car.

Still draft. Two architecture items are outstanding and will be added to this PR:

  • The Gantry imports several pieces directly from the Standings widget, which the layering rules do not allow. Those pieces need to move somewhere both widgets can share.
  • A few store issues: incidents are not re-fetched when the subsession changes, and the load path bypasses the incident cap.

How it is built

Incident detection follows the processor and channel pattern rather than its own wiring:

  • IncidentProcessor implements TelemetryProcessor<Incident[]>. It is deterministic and does no I/O.
  • IncidentRuntime owns the channel publishing, persistence, and performance metrics.
  • Incidents are delivered over a named raceControl.incidents channel, so only the Gantry window receives them rather than every overlay on every screen.

One deliberate difference from FuelProjectionRuntime: the incident processor is created eagerly and is not gated on subscriber count. Gating it would stop detection and disk logging whenever the Gantry window was closed.

Incident storage is async and debounced, with a synchronous flush on quit. The stored list is capped and old sessions are pruned on a retention setting.

The six race control IPC handlers validate renderer input before acting; three of them pass values through to the sim.

Testing notes

The detection logic was built and corrected against real race logs across many sessions - AI races, spectated races, and practice - and several false-positive classes were fixed that way. The two most recent commits (the lap gap reset and the processor conversion) are covered by unit tests but have not yet had a session in the sim.

One unrelated test fails locally on Windows: tools/telemetry-replay/validator.spec.ts hits ENOTEMPTY cleaning up its own temp directory. It fails the same way on a clean main and this branch does not touch tools/.

Screenshots

Before

N/A - new widget.

After

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • Performance improvement
  • Refactoring (no functional changes)
  • Documentation update
  • Dependency update

Checklist

  • I have discussed this change in the discord server
  • I have tested this in iRacing (either in an online session or with AI)
  • All tests pass locally via npm test
  • I have added tests that prove my fix is effective or that my feature works
  • I have run npm run lint and fixed any issues
  • I have performed a self-review of my own code
  • I have added/updated Storybook stories for visual changes
  • I have updated the README.md (if applicable)
  • I have updated defaultDashboard.ts if introducing new widgets or configurations (if applicable)

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2115a5be-ad38-41ae-97a1-93e87986a075

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

23Kev and others added 14 commits August 7, 2026 21:18
Ports the Gantry feature from feat/gantry (merge-base ~105 commits behind)
onto current main. Replanted rather than rebased: 27 files carry over
unchanged, the registration edits were re-done by hand against main's
current structure, and four contested integrations were ported by intent.

Deliberate deviations from the original branch:

- Gantry window: ported the window itself, dropped the bundled
  hasInteractiveWidgets mouse-event rework. No widget config defines
  `interactive`, so it was dead code, and it conflicts with main's
  rewritten click-through/shrink-wrap handling.
- App.tsx: the Gantry route now sits inside ErrorBoundary (main added it
  after the branch diverged) so a crash no longer white-screens the window
  with no recovery.
- padCarNum: fixed an off-by-one. /^0+/ on an all-zero number like "00"
  also consumed the significant digit and over-counted the padding;
  leading zeros are now derived by length difference.
- Settings nav: main moved widget entries into menuItems.ts, so the Gantry
  entry goes there rather than into SettingsMenu.tsx.
- Dropped GANTRY_PERF_INVESTIGATION.md (scratch notes, superseded by
  main's perf harness).
- Replaced stale react-hooks/exhaustive-deps suppressions with the real
  dependencies; main migrated to @eslint-react and the old rule name no
  longer resolves. The missing deps were all stable Zustand actions.

The native irsdk_node.cc change routes ReplaySearchSessionTime and
ReplaySetPlayPosition through the int broadcast overload; the float
overload scales by 65536 and corrupted the seek target. This needs a
native rebuild to take effect.

Known issues carried over and NOT fixed here — see the review notes:
standings freeze after first paint, lap graph only receives the sliced
driver list, no session-lifecycle reset on either new store, sync fs I/O
in incidentStorage, and a false-positive incident burst when replay is
paused. These are follow-up commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
calculateSpeed returned 0 for three different "cannot compute" cases:
a non-advancing session clock, an unchanged position, and backwards
movement. Callers could not tell those apart from a genuinely stationary
car, so each one fed 0 km/h into the rolling average and tripped the
sustained-slow crash detector.

Two observed consequences:

- Pausing or scrubbing a replay freezes SessionTime, so every on-track
  car reported 0 km/h and was crashed within ~0.4s. This fired during the
  exact workflow the feature exists for.
- Remote cars' CarIdxLapDistPct arrives over the network slower than we
  poll, so a moving car produces ticks with an unchanged position. A car
  trickling toward pit entry accumulated enough of these to be reported
  as crashed several seconds before PitEntry was recognised.

calculateSpeed now returns null for all three cases. A null sample is
skipped rather than buffered, so the car keeps its last known speed, and
the two speed-based detectors (sustained-slow, sudden-stop) sit out that
tick. slowFrameCount is held rather than reset on a null tick: resetting
would let a genuinely stopped car escape detection whenever its position
failed to refresh.

Thresholds, PitEntry, off-track and slowdown-flag detection are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eport

Two false-classification bugs in the crash detectors, both found from live
race logs.

Crashes off the racing surface were never reported as crashes. Both
detectors gated on `surface === OnTrack`, but a car in a gravel trap or
against a barrier reports OffTrack — so the most common crash there is
only ever produced an OffTrack incident. A driver who speared off into
the barrier at the Nordschleife was logged as "Off-track for 3 frames"
and nothing more. Both detectors now accept OnTrack or OffTrack. Pit
surfaces stay excluded: a stationary car in its pit stall is not an
incident.

The race-start grid teleport was reported as a crash for every car. On a
practice/qualifying -> race changeover iRacing lifts each car off the
track at whatever speed it was doing and sets it down stationary on the
grid, which leaves racing speeds in the sudden-stop buffer and then reads
as stopped:

  Crash car=22 sudden-stop "Speed dropped from 226.2 to 0.2 km/h"
  Crash car=6  sudden-stop "Speed dropped from 209.9 to 0.1 km/h"

Sustained-slow already gated on sessionState === Racing; sudden-stop never
did. Gridding happens in GetInCar/Warmup/ParadeLaps, so the same gate
discards the sequence. Stale buffer entries age out within
suddenStopFrames ticks, long before the green flag.

Also adds the positive sudden-stop test the suite never had — without it,
gating the detector could have disabled it outright with nothing failing —
plus coverage for a car coming to rest in gravel and for a car sitting
stationary in its pit stall.

updateSession no longer logs on every session-YAML republish (~1/sec); it
logs only when the driver roster actually changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two drivers who collided were each logged only as "Off-track for 3
frames", with nothing tying them together or marking the incident as
serious. iRacing's telemetry carries no collision flag, so contact has to
be inferred.

When a car's off-track debounce trips, the detector now looks for another
car that had an incident-worthy moment within 3 seconds and 30 metres of
the same track position. If it finds one, the incident is emitted as a
Crash instead of an OffTrack and the evidence names the other driver.
Incident stays single-car, so storage and the UI are unchanged — the
incident is upgraded in place rather than gaining a new type.

The window is deliberately loose. In the logged collision the two cars'
incidents were 2 seconds apart: one spun immediately while the other ran
on and only left the road later. Same-tick matching would have missed it
entirely. The trade-off is that two cars independently running wide at the
same corner can be paired, which is why the evidence reads "likely
contact" rather than asserting it.

Anomalies are also recorded when either crash detector fires, so a car
that is hit and stops still pairs with a partner that leaves the road
afterwards. Only the most recent moment per car is kept, so the map is
bounded by field size; it is cleared with carStates on a session change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The standings tab took one snapshot when it loaded and never updated
again, so positions, gaps and pit status were frozen. Each view now
reads standings itself, so only that view redraws instead of the whole
window.

The lap graph was only tracking the few drivers near you, not the whole
field.

Neither of the new stores was ever cleared, so incidents and lap history
carried over between sessions. Both now reset when the sim disconnects,
and the incident list is capped.

Also widened the debug frame history from 10 frames to 60 — half a
second was only capturing the aftermath of a crash, not the impact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Incident storage used synchronous file reads and writes, which stall the
main process and every renderer with it. Worst of all, saving one incident
re-read and re-parsed the whole session file first, so the cost grew with
every incident logged.

Incidents are now kept in memory and written asynchronously, with writes
debounced by 250ms so a burst becomes one write. Pending writes are
flushed on quit so nothing is lost.

Also fixes a race where two incidents arriving at once could overwrite
each other, and updates two call sites for the now-async API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A car that stopped after its qualifying laps was logged as a crash. The
check for "is the session running" was SessionState.Racing, which is also
true during qualifying and practice, so any car parking up looked like an
incident.

The detector now reads the session type from the session data and only
reports a stopped car during a race. Sudden stops are still detected in
every session, so a real impact in qualifying is still caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two cars running wide at the same corner were being reported as contact,
because the check was just "another car had an incident nearby at about
the same time". At tracks where cars regularly cut a corner, that happens
constantly.

Contact now also requires that at least one of the two cars lost a
meaningful amount of speed. A car that runs wide at 113 km/h and carries
straight on at the same pace has missed the apex, not been hit.

Each car tracks a recent peak speed that decays over about two seconds,
so "lost speed" means slower than it was a moment ago rather than slower
than its best all lap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gap and Int were empty in the standings. Those values are only worked out
when the settings say they're enabled, and the Gantry was passing nothing.

The lap graph drew every driver in the same colour with no legend, so the
lines were impossible to tell apart. It now colours up to 8 drivers (the
player plus the leaders) and draws the rest as faint grey context lines,
because colouring 40 drivers is unreadable no matter what palette you use.
A legend lists the coloured drivers and says how many others are on the
chart. Clicking a driver highlights their line.

Colours follow the driver, not their position, so a car keeps its colour as
positions change through the race.

Axis labels were 8px and nearly invisible; they're now readable, and the
Y axis says what it's actually measuring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lap graph mixed practice data into the race. Lap gaps are stored
against a lap number, so practice lap 5 and race lap 5 landed in the same
slot. Nothing ever cleared the store between sessions.

It now listens to the session lifecycle events the main process already
publishes, and clears on a session change or a disconnect.

Each car's last seen lap number is cleared at the same time. Without that
the new session's early laps look like going backwards, and no gaps get
recorded until cars pass their old lap count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows the same shape as fuel projection. Incidents now go over a named
channel, so only the Gantry window receives them instead of every overlay
on every screen.

Detection logic is unchanged - wrapped, not rewritten, with its tests
untouched.

Also picked up:

- The six race control IPC handlers now validate renderer input. Three of
  them pass values to the sim.
- Detection and publishing show up separately in the performance log.
- Detection keeps running when the Gantry window is closed, so incidents
  are still recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds an Auto/km/h/mph picker like the other overlays have. Values are
still saved in km/h, so incident detection does not change. Only the
settings inputs convert.

Rebuilds the settings screen to use the same tabs and rows as every
other overlay, and gives each setting a description.

Auto cannot read iRacing's unit setting in the settings window, so it
shows km/h there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every tab, filter chip, dropdown, replay button and legend entry now
explains what it does on hover and on keyboard focus.

The window is full of overflow-hidden panels, so the tooltip renders
into document.body through a portal and is placed from the trigger's
rect. Otherwise it gets clipped.

Also adds a header row to the standings so the columns can be
explained once, instead of on every driver row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main added src/frontend/utils/units.ts as the single home for speed
conversion, so the Gantry copy has to go. Deletes gantryUnits.ts and
the two hooks that wrapped it. Nothing used them.

Renames the setting to speedUnit with 'mph' | 'km/h' | 'auto' values,
matching Battle, so it passes straight into resolveSpeedUnit with no
mapping in between.

Auto now falls back to mph when it cannot read iRacing's setting. That
is what the shared util does. It used to fall back to km/h.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tariknz

tariknz commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Hey @23Kev here's a prompt to migrate the remaining telemetry to channels:


Please migrate the Gantry PR (#654, branch `feat/gantry-v2`) completely off the legacy renderer telemetry
  stream.

  First rebase the branch onto the latest `main` after the current telemetry-channel migration lands, then use
  the existing typed channel infrastructure. Read `docs/ARCHITECTURE_RULES.md` before changing anything.

  Known Gantry legacy dependencies:

  1. `GantryIncidents.tsx`
     - Replace `useTelemetryValue('IsReplayPlaying')`.
     - Read `isReplayPlaying` from `useTrackStateSnapshot()`.

  2. `LapGapStoreUpdater.tsx`
     - Replace `useTelemetryValuesRounded('CarIdxLap', 0)`.
     - Read `carIdxLap` from `useStandingsSnapshot(enabled)`.
     - Keep the subscription conditional so the standings processor is not activated when the updater is
     inactive.
     - Preserve the existing lap-gap behavior and reset semantics.

  3. `GantrySettings.tsx`
     - Replace `useTelemetryValue('DisplayUnits')`.
     - Read `displayUnits` from `useTrackStateSnapshot()`.
     - Preserve the existing `auto` speed-unit fallback when no snapshot is available.

  4. Gantry renderer/provider setup
     - Remove the Gantry route’s `TelemetryProvider`.
     - Add/update Gantry’s `widgetRuntimeDefinition.ts` so it declares:
       - `legacyTelemetry: false`
       - `track-state.snapshot`
       - `standings.snapshot`
       - any other channels already consumed by Gantry
     - Use appropriate explicit channel rates. Track state should not be requested faster than Gantry needs;
     standings should remain at a supported sortable rate, normally 5 Hz.
     - Do not add a new broad Gantry telemetry channel or copy raw telemetry into a snapshot.

  Session data is separate from raw telemetry. Gantry currently uses `useSessionDrivers()`, so retain
  `SessionProvider`/`sessionData: true` unless that dependency is also deliberately migrated in this PR.

  Validate all supported paths:

  - Standard Electron Gantry window
  - Browser-source/WebSocket Gantry renderer
  - Demo/mock mode
  - Live iRacing lifecycle and disconnect/reset behavior
  - Existing Gantry stories
  - Gantry-only renderer with no other widget accidentally supplying legacy providers

  Add or update tests proving:

  - A Gantry-only renderer does not mount `TelemetryProvider`.
  - Gantry declares and subscribes to the required typed channels.
  - Replay state still affects the incident display.
  - Lap-gap recording receives `carIdxLap` from `standings.snapshot`.
  - Auto display units work from `track-state.snapshot`.
  - Missing/reset snapshots do not retain stale replay, lap, or unit state.

  Run:

  npm run lint
  npm run test -- --no-coverage
  npm run irsdk:replay:app:curated

  Also run the relevant Storybook build/tests if available.

  Before committing, search the Gantry code and its dedicated route/providers for:

  useTelemetry
  useTelemetryValue
  useTelemetryValues
  useTelemetryStore
  TelemetryProvider

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants