This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Live tuning advisor for Forza Horizon 6. Browsers cannot receive UDP, which is the constraint the whole architecture works around:
FH6 --UDP :5300 (324-byte packets)--> bridge/powershell/bridge.ps1 --JSON over ws://127.0.0.1:5301--> ui/ (React + Vite, GitHub Pages)
The bridge is deliberately dumb (parse + forward only). All analysis logic is client-side TypeScript, so it iterates via Pages deploys, not bridge updates. Nothing leaves the user's machine; both bridge ports bind loopback only.
All UI work happens in ui/:
cd ui
npm install
npm run dev # http://localhost:5173, connects to ws://127.0.0.1:5301
npm run build # tsc -b (typecheck) && vite build
npm run previewThere is no lint, formatter, or test runner. The only quality gate is strict TypeScript with noUnusedLocals/noUnusedParameters: an unused import fails the build.
node scripts/build-car-db.mjs # regenerate ui/public/cars.json from data/car_id_db.json
powershell -ExecutionPolicy Bypass -File bridge/powershell/bridge.ps1 # run the bridge
node bridge/test-client.mjs # WS smoke test (needs recent Node with global WebSocket).github/workflows/deploy-pages.yml deploys to GitHub Pages on pushes to main touching ui/**, bridge/powershell/bridge.ps1, scripts/copy-bridge.mjs, or the workflow file itself (plus manual dispatch). It sets BASE_PATH=/ForzaTuningAdvisor/, which feeds base in ui/vite.config.ts. Runtime fetches of static assets must be prefixed with import.meta.env.BASE_URL (see carDb.ts, garage/curated.ts) or they 404 in production. Changes to data/ alone do not trigger a deploy, and CI never runs build-car-db.mjs: the committed ui/public/cars.json must be regenerated by hand when data/car_id_db.json changes. npm run dev/build copy bridge/powershell/bridge.ps1 into ui/public/ (gitignored) via scripts/copy-bridge.mjs, so the deployed site serves the script at <site>/bridge.ps1 for the entry page's download button — bridge/powershell/ stays the single source.
The browser never parses Forza binary. Parse-Packet in bridge.ps1 produces the JSON shape, the Telemetry interface in ui/src/types.ts mirrors it, and Docs/forza-data-format.md documents the verified 324-byte FH6 layout behind both. Renaming a field in one silently breaks the others.
FH6 packet facts that older Forza references get wrong:
- 324 bytes little-endian, but the dash section is shifted +12 bytes vs FH5 (unmapped 12-byte block at offsets 232-243). Never reuse FH4/FH5 offset tables.
- When
IsRaceOn == 0(menus, paused) the entire packet is zeroed. The bridge broadcasts those frames anyway; consumers gate onraceOn. - One temperature per tire and no wear data. Classic temp-spread camber/toe analysis is permanently impossible, not future work. The engine instead estimates camber from body roll and toe from straight-line scrub (per-axle slip angle with the steering centered); caster has no signal at all and is checked against per-discipline knowledge windows (
casterMin), like tire pressure (psiWindow). bridge/powershell/diagnostic.ps1is the format-discovery tool that produced the layout, but its own dash offsets are the old FH5 ones. Never copy offsets from it;bridge.ps1and the format doc are the authority.
useTelemetry (WS hook) -> GarageStore.feed -> session.addFrame -> SessionData -> summarize() -> SessionSummary
-> analyzeSession() -> Advice[] -> TuneSection
State pattern: no Context, no store library. One imperative GarageStore instance (ui/src/garage/store.ts) lives in a ref inside useTelemetry; it mutates internally and calls the injected bump(), which increments a rev counter to trigger re-renders. Components re-derive values with useMemo on [garage, rev, ...] and everything is prop-drilled.
SessionData (ui/src/session.ts) is a serializable, mergeable bag of running statistics (sums, maxes, histograms; no raw frame arrays). addFrame ignores frames with raceOn !== 1 and excludes "unclean" frames (kerbs, puddles, rough surface) from suspension/roll/damping metrics. When adding a stat, give it ?? 0 guards in summarize/mergeData so previously saved sessions do not break.
analyzeSession(summary, tune, profile, units) takes three inputs of distinct origin:
SessionSummary: measured symptoms from telemetry.CurrentTune(ui/src/tune.ts): the user's manually entered tuning sheet. Telemetry exposes symptoms, never the tune, so every field is optional. Rules emit an absolute target when the field is known and a directional nudge when it is null; never assume a tune field is set.DisciplineProfile(ui/src/discipline.ts): per-mode (road/dirt/offroad/drift/drag) rule toggles and thresholds. The modes mirror Forza's event types: Dirt is rally (mixed gravel/dirt/tarmac), Offroad is Cross Country. A former separate "rally" id merged into "dirt";normalizeDisciplineIdmaps stored/imported data forward, so never reintroduce that id.
Units affects display formatting only, never analysis. Every rule is double-gated: the discipline flag (p.rules.*) plus minimum evidence (MIN frame counts and/or p.thr.* thresholds); nothing fires before roughly 30 s of driving. One deliberate exception: the tire-pressure window check validates the entered sheet against the discipline's psiWindow (telemetry has no pressure channel), so it needs no telemetry and may fire before any driving. Confidence is hardcoded per rule, never computed from sample size (high = obvious symptom and lever, medium = inference, low = noisy proxy; a few rules pick between tiers by discipline, e.g. the differential cards drop to medium off-road).
Advice.group is assigned at the end from the advice id prefix via groupForId(). Group values must match TUNE_GROUPS ids in tune.ts (tires, gearing, alignment, arb, springs, damping, aero, brakes, diff) or TuneSection silently drops the card. A new rule targeting a lever must set field on the card (there is no fallback mapping in the UI anymore). When a rule can compute an absolute target it also sets apply (a Partial<CurrentTune> the Apply button writes verbatim): compute the target once, game-step rounded, and reuse that value in recommendation, viz, and apply so what the user reads is exactly what lands in the sheet. Rules that need no telemetry (pressure window, caster floor, bump/rebound ratio, soft-ARB surface check) set sheetOnly: true; all other advice is suppressed on levers listed in the workspace's staleFields ("changed — drive to re-measure") to prevent apply-chasing on old data.
The merged TuneSection (ui/src/components/TuneSection.tsx) is both the tune-entry sheet and the advice display: every lever row is an editable input plus its status or advice card. There is no separate tune panel.
- IndexedDB database
ftawith storesworkspaces(key = car ordinal) andsetups(key = id), localStorage fallback when IDB is unavailable.DB_VERSIONis 1 with no upgrade path;WorkspaceandSavedSetupare persisted verbatim, so changing those interfaces is a forward-compatibility hazard for existing user data. - Recording is keyed by the frame's own car ordinal, not the viewed one. An ordinal change finalizes the in-flight session (a session never spans cars) and raises a switch prompt instead of yanking the view.
- Sheet edits are instant; there is no archive prompt.
setTunediffs the sheet and records changed keys inWorkspace.staleFields(only when measured data exists to invalidate). The next session start (freshenPool) drops sessions that ended beforetuneEditedAtand clears the markers, so a fresh drive measures the new setup on a clean pool automatically. - Writes happen on session finalize, explicit UI actions, and build-identity changes detected in a frame (PI/class/drivetrain), never unconditionally per frame. Sessions under
MIN_SAVE_FRAMES(240, about 4 s) are discarded; pools cap atSESSION_CAP(24), silently dropping the oldest. - Export envelope:
{format: "fta-export", version: 1, kind: "full"|"car"|"setup", exportedAt, payload}. Import merges by id and never overwrites; a colliding id with different content imports as a copy, identical content is skipped. Curated setups ship inui/public/curated.json(format: "fta-curated", currently empty by design; curation is a repo-PR workflow).
data/car_id_db.json is the source of truth (full scan metadata). scripts/build-car-db.mjs strips it to the slim ui/public/cars.json the UI fetches at runtime via carDb.ts. Display names are best-effort auto-generation from asset names; unknown ordinals must fall back to "Unknown car (#N)" with full functionality.
- Telemetry is stored and analyzed in native units (m/s, tire temps in degrees F, boost in psi). Conversion happens only at display time via
ui/src/units.ts. - localStorage keys are prefixed
fta.(fta.bridgeUrl,fta.discipline,fta.units). - Design system: Material Design 3 dark, Motorsport Orange + Graphite. Orange = interactive, sky = cool/informational semantics, green/amber/red reserved for status. Explanatory text goes in tooltips/info dots, not body copy. Styling lives in
ui/src/styles.css(plain CSS, no framework). Docs/plans/*.mdare specs written before implementation; where they diverge from code, code is truth.Docs/forza-data-format.mdis the verified packet authority.