diff --git a/CHANGELOG.md b/CHANGELOG.md index d7985b87f..8c42ecd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ ## Unreleased ### Features +- Drag and drop recordings into the session import dialog, including matching DuckDB WAL sidecars, while retaining the file picker. +- Restore iRacing Live sidebar access with a dedicated dashboard for driving inputs, timing, fuel, vehicle motion, suspension travel, and temperatures; hide stale readings when telemetry stops. +- Add an overall clean-driving score to the iRacing Latest Session card, based on valid timed laps and excluding pit laps. +- Review observed personal incident-point increases, sampled off-track spans, and recorded clean-lap streaks in saved iRacing sessions, with event links and replay timeline markers limited to recorded lap evidence. +- Keep iRacing replay incident markers alongside the shared replay visualization and telemetry panels. +- Render iRacing comparison laps in Focus View, Spectate and circuit overviews from recorded GPS; project live lap progress onto official track geometry when GPS is unavailable, using replay's map handedness, forward car headings, and official turn labels without treating projected positions as measured racing lines. - Replace category tabs on the AMS2 and iRacing Cars pages with a searchable dropdown that shows the total as a placeholder, clears the search when opened, and preserves category counts and the All categories reset. - Add AMS2 support: native Windows shared-memory telemetry capture, game-specific car and track identity, per-wheel Replay metrics, 235 track maps, car thumbnails, dashboard branding and a separate Driver profile; include Live connection guidance and correct lap capture, export, replay and track-map behavior. Keep adapter availability and native capture development-only. - Show animated suspension springs and chassis movement in Replay and Compare → Spectate; use the lap's observed travel range when no game range is specified, while preserving native normalized readings. @@ -30,6 +36,27 @@ - Show lap classifications separately from validity in expanded session lap tables. - Simplify session timelines into compact circles with single-word event labels, starting positions, and arrows showing the new position. - Provide a Linux x86_64 AppImage for Ubuntu 22.04 and newer, with AppImageUpdate-compatible GitHub release deltas. +- Smooth iRacing lap replay by interpolating the track marker and timeline cursor between recorded telemetry frames. + +### Fixes +- Keep Replay and Compare → Spectate circuit overviews clear of mobile input dashboards and camera/focus controls. +- Restore resized Replay and Spectate panel widths when reopening the same panels, while preserving compact defaults for different panel combinations. +- Keep dashboard summaries available when historical recordings cannot be decoded. +- Hide iRacing tire temperatures and pressure in live and replay telemetry views instead of presenting pit-only snapshots or cold setup pressure as live readings. +- Keep tire silhouettes visible in 2D layouts when temperature telemetry is unavailable, without inventing temperature readings. +- Restore iRacing track edges and 3D track placement for official maps that use symbol-based start/finish markers. +- Remove automatic 3D track centerlines and control the recorded purple trajectory with the Racing line toggle independently of track edges. +- Open iRacing IBT preview and confirmation directly from the session import dialog, preserve the selected ownership, and describe original-file storage accurately. +- Preserve original iRacing IBT recordings through import, replay, and reprocessing without generating BIN captures; seed Navarra through the same production import workflow instead of a downsampled, preconverted fixture. +- Restore iRacing replay's shared 2D/3D visualization and data panels; remove the game-specific replay data panel. +- Detect replay timeline and chart gaps relative to the recording cadence, so regular sparse samples are not painted as missing telemetry. +- Replace the iRacing session review's end-of-lap tire section with the incident report below detected telemetry issues. +- Preserve session lap-selection drafts while choosing the primary lap. +- Prevent live recording startup from failing on Windows, leaving empty capture manifests and recorded laps without replay telemetry. +- Restore recording-layout migration and interrupted-recording recovery on Windows by avoiding unsupported directory synchronization and serializing migration journal reads and replacement. +- Persist the iRacing automatic IBT capture opt-in and restore capture after backend restart. +- Keep iRacing telemetry flowing when native lap timing stalls; mark unresolved lap data incomplete instead of stopping capture. +- Replace iRacing comparison-map yaw ticks with lap-colored travel-direction chevrons and align follow view to the plotted path; retain plain dots when nearby positions cannot establish direction. - Capture LMU opponents by default in a separate removable companion recording; opt out for the next live session, preview and explicitly compare an exact recorded opponent through session, driver, and lap filters with car details and without coverage or telemetry status labels, switch Driver to Me to browse every player lap in the session, export its full interval, or irreversibly remove session-wide opponent data while preserving player telemetry. Compare partial opponent laps even when scoring updates lag behind the player's lap boundary. Keep stale, removed, missing and unrecorded references distinct. - Expand an LMU session on the Sessions page to see recorded opponents immediately, without choosing a player lap, as collapsible driver rows showing their fastest recorded lap and its sector splits. Sort recorded opponents by fastest valid best lap by default. Browse opponents in a scrollable child table that stays within the session table, with lap warnings behind compact info indicators. Expand a driver to replay or compare an exact recorded lap without duplicating recordings, keeping opponent laps together across player recording splits. Opponent Replay fills the available workspace. Keep opponent cars and paths on the flat track plane without changing recorded elevation. - Use black backgrounds for recorded opponent tables and expanded opponent lap rows on the Sessions page. @@ -131,12 +158,18 @@ - Restore dashboard, driver-profile, and Analyse pages when opening the app. - Block screenshots for chat models without verified image support and keep text conversations usable. - Hide the entire Tires section on iRacing live dashboards without changing recorded-lap analysis or other games. +- Show fresh iRacing SDK telemetry even when UDP status is unavailable; hide readings after frames stop. +- Orient the iRacing replay map marker along the plotted lap path instead of simulator-frame yaw. - Hide iRacing 3D track, racing-line, tire-trail, and input overlays because live telemetry does not provide world-position samples. -- Complete first-run sparse recording migration in compiled builds instead of failing. +- Correct iRacing clutch pedal direction so released reads 0% and fully pressed reads 100% in live telemetry and replay. +- Complete first-run sparse recording migration in packaged builds, including Windows. - Skip LMU outline requests when stored track names do not identify one layout. ### Internal +- Align seeded E2E capability and dialog checks with production behavior, isolate native game discovery, and use release-pinned Bun for historical upgrade fixtures. +- Remove the stale tire-pressure capability snapshot assertion; retain parser/resolver coverage that prevents iRacing cold setup pressures from appearing as live readings. +- Run workspace lint through the installed Oxlint CLI and isolate native database test fixtures in subprocesses to keep Windows pre-commit checks reliable. - Preserve Windows directory-sync path errors when validating recording publication directories. - Target comparison lap pickers explicitly in browser checks and wait for rendered frames in the native camera zoom check. - Keep diagnostic dump runs in one BIN per selected source, retain raw Forza/F1 datagrams, stream live telemetry over WebSocket for every game, and bypass telemetry-driven session/database processing and background recording maintenance. @@ -149,7 +182,7 @@ - Use Ubuntu instead of macOS for CI runners and cap Mars memory requests at 6 GB. - Invalidate cached frontend builds when source changes in dependent workspaces. - Extract tracks and cars catalogues into feature-owned UI, server, contract, and E2E workspaces with enforced browser boundaries, inherited Turbo impact inputs, and isolated compiled-app browser tasks. -- Run native Spectate camera and transition checks in a dedicated browser lane while retaining coverage for CPU-compatible rendering paths. +- Run native Spectate camera and transition checks with full Chromium and a required hardware WebGPU adapter in a dedicated lane, retaining CPU-compatible coverage separately. - Verify LMU recorded pit-cycle classifications independently of lap validity, preserving telemetry-quality failures and incomplete tails. - Synchronize zoom-reversal checks with rendered transition frames and wait for concurrent SQLite writers during migration fixture setup. - Keep sparse-capture fixtures schema-valid and verify failed classification recordings without waiting for dashboard work blocked by startup repair prerequisites. @@ -181,7 +214,9 @@ ### Breaking - Legacy full-frame RaceIQ captures are no longer supported for import and replay. Ensure any remaining legacy recordings are migrated when prompted before updating. -### Feature +### Features +- Connect to iRacing on Windows to automatically save completed native .ibt recordings while leaving original files untouched. +- Replay recorded iRacing .ibt driving lines and elevation, with the official map retained as an approximate reference and map-based placement for recordings without GPS data. - New desktop and self-hosted installs restrict dashboard access to this computer until you select additional network interfaces. - Password-protected dashboards use a login screen and browser sessions; API clients must sign in and send the session cookie and a CSRF token for writes. - Use string car and track IDs across imports and integrations instead of ordinal fields. Existing sessions and setup links migrate automatically; downgrading requires restoring a pre-upgrade database backup. @@ -200,6 +235,17 @@ - Choose a recording folder, safely relocate existing recordings, and optionally migrate recordings into per-game `own` and `others` folders. ### Fixes +- Close reference track outlines without joining the ends of recorded driving traces. +- Keep converted recordings from older releases readable after updating, including checkpoint-only F1, iRacing, and LMU captures. +- Stop reopening the recording-organization success dialog after reload when no recordings need migration. +- Show the track layout after the track name with a hyphen and remove the track-detail header subtitle. +- Hide the track-detail Debug tab in production builds. +- Clarify setup browser labels with “Save to my setups” and the “Mine” filter. +- Adapt dashboard activity to the selected period: daily duration for Today, daily bars for This Week, weekday-column calendars for This Month, and monthly totals for This Year; fill the available panel width. +- Show monthly Clean laps trend points for This Year while preserving cumulative clean rates. +- Keep dashboard period controls visible while scrolling. +- Reduce excess space above the dashboard period controls. +- Restore the visible down arrow and solid background on the AI chat scroll-to-bottom button. - Restore migration of existing recordings into organized folders on Windows. - Keep experiment lap counts and review analysis tied to the selected test when importing historical laps or returning to an older setup, and preserve test membership during live updates. - Keep lap-chat follow-ups grounded in the selected lap and its latest analysis, use fresh telemetry for requested corner measurements, and preserve earlier driver notes without asking for internal lap identifiers. @@ -264,6 +310,7 @@ ### Fixes - Keep racing, throttle, and braking lines sharp when zooming into the replay map. +- Restore track edges and tyre details in iRacing 3D replays while labeling map-based positions and historical pit readings. ### Internal - Package organized recording paths in seeded Playwright databases while rejecting unsafe capture paths. diff --git a/apps/backend/scripts/data/seed-db-runner.ts b/apps/backend/scripts/data/seed-db-runner.ts index 4b00e7ffa..7fd1a4290 100644 --- a/apps/backend/scripts/data/seed-db-runner.ts +++ b/apps/backend/scripts/data/seed-db-runner.ts @@ -1,9 +1,10 @@ import { ROOT_DIR } from "@raceiq/backend-core/runtime/config/paths"; +import { db, closeDb, initDb } from "@raceiq/backend-core/db/index"; import { createReadStream, existsSync, readFileSync } from "node:fs"; import { link, mkdir, unlink } from "node:fs/promises"; import { basename, join, relative, resolve, sep } from "node:path"; import { createGunzip } from "node:zlib"; -import { db, closeDb, initDb } from "@raceiq/backend-core/db/index"; +import { commitStagedIbt, stageIbtUpload } from "../../src/imports/iracing-ibt"; import { importSessionBin } from "@raceiq/backend-core/session-capture/import-capture"; import { importSessionFrames } from "@raceiq/backend-core/session-capture/import-pipeline"; import { @@ -24,8 +25,8 @@ import { developmentReleaseFeatures } from "@raceiq/tooling-release/release/deve import { profiles, sessions } from "@raceiq/backend-core/db/schema"; import { and, eq, inArray, like } from "drizzle-orm"; import { stopMaintenanceTasks } from "@raceiq/backend-core/telemetry/live-pipeline"; -import { seedIRacingSession } from "@raceiq/tooling-data/data/seed-db-iracing"; import { assertSafeTarget, seedRowCount } from "@raceiq/tooling-data/data/seed-db-safety"; +import { seedIRacingSession } from "@raceiq/tooling-data/data/seed-db-iracing"; import { insertDemoRows, markOnboardingComplete } from "@raceiq/tooling-data/data/seed-db-demo"; import { removeSeedData } from "@raceiq/tooling-data/data/seed-db-reset"; import { FIXTURES, PROFILE_NAME, parseOptions, SEED_MARKER } from "@raceiq/tooling-data/data/seed-db-options"; @@ -178,6 +179,10 @@ async function main(): Promise { const excludedFixtures = new Set(options.excludedFixtures); for (const game of options.games) { const fixtures = (options.fixtures ?? FIXTURES[game]).filter((fixture) => !excludedFixtures.has(fixture)); + if (fixtures.length === 0) { + console.log(`[DB Seed] ${game}: skipped; no recording fixtures are configured.`); + continue; + } for (const fixture of fixtures) { const partMatch = /^(.*)\.part(\d+)$/i.exec(fixture); if (partMatch && Number(partMatch[2]) !== 1) continue; @@ -195,6 +200,26 @@ async function main(): Promise { const fixturePath = combined?.path ?? partPaths[0]!; try { if (game === "iracing") { + if (!fixturePath.toLowerCase().endsWith(".ibt")) { + await seedIRacingSession(fixturePath); + continue; + } + const existingSessionIds = new Set( + (await db.select({ id: sessions.id }).from(sessions).where(eq(sessions.gameId, game)).all()).map((row) => row.id), + ); + const staged = await stageIbtUpload( + Bun.file(fixturePath).stream(), + basename(fixturePath), + ); + if (!staged.token) throw new Error(`iRacing seed fixture cannot be imported: ${staged.preview.reason ?? fixturePath}`); + const result = await commitStagedIbt(staged.token, "mine", { notifyDriverProfile: false }); + const createdSessionIds = (await db.select({ id: sessions.id }).from(sessions).where(eq(sessions.gameId, game)).all()) + .map((row) => row.id) + .filter((id) => !existingSessionIds.has(id)); + if (createdSessionIds.length === 0) throw new Error(`No iRacing telemetry imported from ${fixturePath}`); + await db.update(sessions).set({ notes: SEED_MARKER }).where(inArray(sessions.id, createdSessionIds)).run(); + importedLapIds.push(...result.laps.filter((lap) => lap.isValid).map((lap) => lap.lapId)); + console.log(`[DB Seed] iracing: ${result.laps.length} laps from ${fixture}`); const importStartedAt = performance.now(); try { await seedIRacingSession(fixturePath); diff --git a/apps/backend/src/games/iracing-incident-report.ts b/apps/backend/src/games/iracing-incident-report.ts new file mode 100644 index 000000000..4a23cfa0f --- /dev/null +++ b/apps/backend/src/games/iracing-incident-report.ts @@ -0,0 +1,173 @@ +import { and, eq, asc } from "drizzle-orm"; +import { stat } from "node:fs/promises"; +import { db } from "@raceiq/backend-core/db/index"; +import { laps, sessions } from "@raceiq/backend-core/db/schema"; +import { getLapById } from "@raceiq/backend-core/db/lap-read-queries"; +import { importedNativeValuesFor, iterateSessionCaptureRecordsFromSource, loadSessionSource, type SessionCaptureSource } from "@raceiq/backend-core/session-capture/source-loader"; +import { createIRacingSourceDecoderState, decodeIRacingSourceFrame } from "@raceiq/capture-formats/iracing/source-frame"; +import { isPitCycleLap } from "@raceiq/analysis-core/racing/laps/pit-cycle"; +import { reduceIRacingIncidents, type IRacingIncidentInput, type IRacingIncidentLapInput } from "@raceiq/game-iracing/incident-report"; +import type { IRacingIncidentCoverageReason, IRacingIncidentEvent, IRacingIncidentReport } from "@raceiq/shared/racing/sessions/iracing-incidents"; + +interface Window { lapId: number; offset: number; count: number; started: boolean; read: number; observations: number; resolved: boolean; ambiguous: boolean } +const scalar = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; +const integer = (value: unknown): number | null => typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null; + +/** Saved windows, not capture-file ownership, define the evidence boundary. */ +export async function getIRacingIncidentReport(sessionId: number): Promise { + const session = await db.select().from(sessions).where(and(eq(sessions.id, sessionId), eq(sessions.gameId, "iracing"))).get(); + if (!session) return null; + const rows = await db.select().from(laps).where(eq(laps.sessionId, sessionId)).orderBy(asc(laps.lapNumber), asc(laps.id)).all(); + const inputs: IRacingIncidentLapInput[] = rows.map(lap => ({ lapId: lap.id, lapNumber: lap.lapNumber, eligibleForCleanStreak: !!lap.isValid && Number.isFinite(lap.lapTime) && lap.lapTime > 0 && !isPitCycleLap(lap) })); + const reasons = new Set(); + const windows: Window[] = []; + const missing: number[] = []; + for (const lap of rows) { + if (integer(lap.rawByteOffset) !== null && integer(lap.rawFrameCount) !== null && lap.rawFrameCount! > 0) windows.push({ lapId: lap.id, offset: lap.rawByteOffset!, count: lap.rawFrameCount!, started: false, read: 0, observations: 0, resolved: false, ambiguous: false }); + else missing.push(lap.id); + } + if (missing.length) reasons.add("missing-lap-window"); + const unavailable = async (reason: IRacingIncidentCoverageReason): Promise => { + reasons.add(reason); + const evidence = await reduceIRacingIncidents(inputs, []); + const { incidentStatus, surfaceStatus, reasons: _reasons, ...metrics } = evidence; + return { sessionId, ...metrics, coverage: { scope: "recorded-laps", totalLaps: rows.length, lapsWithCapture: 0, incidentStatus, surfaceStatus, reasons: [...reasons] } }; + }; + if (session.source === "motec" || session.rawFile?.endsWith(".motec.zip")) return unavailable("unsupported-capture"); + const rawFile = session.rawFile; + if (!rawFile) return unavailable("capture-unavailable"); + try { await stat(rawFile); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return unavailable("capture-unavailable"); throw error; } + if (!windows.length) return unavailable("missing-lap-window"); + windows.sort((left, right) => left.offset - right.offset || left.lapId - right.lapId); + const source: SessionCaptureSource = { + rawFile, source: session.source, gameId: "iracing", + carId: session.carId, trackId: session.trackId, + captureFormatVersion: session.captureFormatVersion, + }; + async function* observations(): AsyncGenerator { + if (source.rawFile.toLowerCase().endsWith(".ibt")) { + const loaded = await loadSessionSource(source); + if (loaded.kind !== "packets") throw new Error("Expected original IBT packet source"); + for (const window of windows) { + const end = window.offset + window.count; + for (let index = window.offset; index < end; index++) { + const packet = loaded.packets[index]; + if (!packet) { + reasons.add("missing-lap-window"); + yield { kind: "break", reason: "capture-gap", lapId: window.lapId }; + break; + } + window.started = true; + window.read++; + if (window.read === window.count) window.resolved = true; + const values = importedNativeValuesFor(packet); + if (!values || !packet.iracing) { + reasons.add("missing-lap-window"); + yield { kind: "break", reason: "capture-gap", lapId: window.lapId }; + continue; + } + window.observations++; + yield { kind: "sample", lapId: window.lapId, rawOffset: index, + nativeSessionUid: packet.sessionUID ?? "", + driverCarIdx: packet.iracing.driverCarIdx, + sessionTick: integer(values.SessionTick), sessionTimeSec: scalar(values.SessionTime), + incidentPoints: scalar(values.PlayerCarMyIncidentCount), surface: scalar(values.PlayerTrackSurface) }; + } + } + for (const window of windows) { + if (!window.started || window.read < window.count) { + reasons.add("missing-lap-window"); + yield { kind: "break", reason: "capture-gap", lapId: window.lapId }; + } + } + return; + } + let decoder = createIRacingSourceDecoderState(); + let inContext = false; + let ownedPreviously = false; + for (const lapId of missing) yield { kind: "break", reason: "capture-gap", lapId }; + for await (const record of iterateSessionCaptureRecordsFromSource(source, { strict: true })) { + if (record.kind !== "frame") { + if (record.kind === "segment-boundary") decoder = createIRacingSourceDecoderState(); + if (record.kind === "segment-context") inContext = true; + if (record.kind === "segment-context-end") inContext = false; + const active = windows.filter(window => window.started && !window.resolved); + if (active.length) for (const window of active) yield { kind: "break", reason: "segment-boundary", lapId: window.lapId }; + else yield { kind: "break", reason: "segment-boundary" }; + ownedPreviously = false; + continue; + } + for (const window of windows) { + if (window.resolved || window.started) continue; + if (record.offset === window.offset) window.started = true; + else if (record.offset > window.offset) { + window.resolved = true; reasons.add("missing-lap-window"); + yield { kind: "break", reason: "capture-gap", lapId: window.lapId }; + } + } + const active = windows.filter(window => window.started && !window.resolved); + if (active.length > 1) { + reasons.add("missing-lap-window"); + for (const window of active) { window.ambiguous = true; yield { kind: "break", reason: "capture-gap", lapId: window.lapId }; } + } + const decoded = decodeIRacingSourceFrame(record.frame, decoder); + if (!decoded) { + decoder = createIRacingSourceDecoderState(); + if (active.length) for (const window of active) yield { kind: "break", reason: "decode-failure", lapId: window.lapId }; + else yield { kind: "break", reason: "decode-failure" }; + } + const owned = active.length === 1 && !inContext && decoded !== null; + if (!owned && ownedPreviously) yield { kind: "break", reason: "capture-gap" }; + if (owned) { + const window = active[0], native = decoded.session, values = decoded.values; + window.observations++; + yield { kind: "sample", lapId: window.lapId, rawOffset: record.offset, nativeSessionUid: `${native.subSessionId}:${native.sessionId}:${native.sessionNum}`, driverCarIdx: native.driverCarIdx, sessionTick: integer(values.SessionTick), sessionTimeSec: scalar(values.SessionTime), incidentPoints: scalar(values.PlayerCarMyIncidentCount), surface: scalar(values.PlayerTrackSurface) }; + } + ownedPreviously = owned; + for (const window of active) { window.read++; if (window.read === window.count) window.resolved = true; } + // Return closes the strict iterator before unrelated malformed or growing tails. + if (windows.every(window => window.resolved)) break; + } + for (const window of windows) { + if (!window.started || window.read < window.count) { + reasons.add("missing-lap-window"); + yield { kind: "break", reason: "capture-gap", lapId: window.lapId }; + } + } + } + let evidence; + try { evidence = await reduceIRacingIncidents(inputs, observations()); } + catch (error) { + if (error instanceof Error && error.message.startsWith("Legacy full-frame captures are not supported")) return unavailable("unsupported-capture"); + throw error; + } + for (const reason of evidence.reasons) reasons.add(reason); + const events: IRacingIncidentEvent[] = []; + const byLap = new Map(); + for (const event of evidence.events) { const group = byLap.get(event.lapId); if (group) group.push(event); else byLap.set(event.lapId, [event]); } + const targets = new Map(); + for (const [lapId, lapEvents] of byLap) { + const lap = await getLapById(lapId); + if (!lap || lap.parseError || lap.sessionId !== sessionId || lap.gameId !== "iracing") continue; + // Build both source-tick and millisecond-only indices once per retained lap. + const lookup = new Map(); + const add = (key: string, index: number) => lookup.set(key, lookup.has(key) ? null : index); + lap.telemetry.forEach((packet, index) => { + if (!Number.isFinite(packet.CurrentRaceTime) || packet.CurrentRaceTime < 0) return; + const key = `${packet.sessionUID}\0${Math.round(packet.CurrentRaceTime * 1000)}`; + add(key, index); + if (integer(packet.iracing?.sessionTick) !== null) add(`${key}\0${packet.iracing!.sessionTick}`, index); + }); + for (const event of lapEvents) { + const reference = event.seekReference; + const key = `${reference.nativeSessionUid}\0${Math.round(reference.sessionTimeSec * 1000)}${reference.sessionTick === null ? "" : `\0${reference.sessionTick}`}`; + const index = lookup.get(key); + if (index === null || index === undefined) continue; + const time = lap.telemetry[index].CurrentLap; + targets.set(event.id, { frameIndex: index, lapTimeSec: Number.isFinite(time) && time >= 0 ? time : null }); + } + } + for (const { seekReference: _reference, ...event } of evidence.events) events.push({ ...event, replay: targets.get(event.id) ?? null }); + const { incidentStatus, surfaceStatus, reasons: _reasons, events: _events, ...metrics } = evidence; + return { sessionId, ...metrics, events, coverage: { scope: "recorded-laps", totalLaps: rows.length, lapsWithCapture: windows.filter(window => window.started && window.read === window.count && window.observations > 0).length, incidentStatus, surfaceStatus, reasons: [...reasons] } }; +} diff --git a/apps/backend/src/imports/iracing-ibt.ts b/apps/backend/src/imports/iracing-ibt.ts index 1276e313f..8037cc86b 100644 --- a/apps/backend/src/imports/iracing-ibt.ts +++ b/apps/backend/src/imports/iracing-ibt.ts @@ -1,26 +1,18 @@ -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - statSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { open as openFile } from "node:fs/promises"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { copyFile, link, open as openFile, unlink } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { join, resolve } from "node:path"; import { resolveDataDir } from "@raceiq/backend-core/runtime/config/data-dir"; import { IbtImportError, - ibtFrames, previewIbtFile, type IbtImportPreview, } from "@raceiq/game-iracing/ibt-preview"; +import { readIbtSource } from "@raceiq/game-iracing/ibt-source"; import { registerImportedIRacingIdentity } from "@raceiq/game-iracing/identity"; -import { importSessionFrames, type ImportSessionResult } from "@raceiq/backend-core/session-capture/import-pipeline"; +import { importSessionPackets, ImportSourceRecorder, type ImportSessionResult } from "@raceiq/backend-core/session-capture/import-pipeline"; +import { resolveRecordingGameDirectory } from "@raceiq/backend-core/runtime/config/recording-directory"; import type { SessionOwnership } from "@raceiq/shared/racing/sessions/types"; - const STAGE_TTL_MS = 30 * 60 * 1000; export const MAX_IBT_BYTES = 8 * 1024 * 1024 * 1024; const TOKEN_PATTERN = @@ -205,38 +197,59 @@ function loadManifest(token: string): { export async function commitStagedIbt( token: string, ownership: SessionOwnership = "mine", + options: { notifyDriverProfile?: boolean } = {}, ): Promise<{ packetCount: number; laps: ImportSessionResult["laps"]; preview: IbtImportPreview; }> { const { paths, manifest } = loadManifest(token); + let publishedPath: string | null = null; + let temporaryPath: string | null = null; + let temporaryOwned = false; try { - try { - await registerImportedIRacingIdentity({ - ...manifest.preview, - carId: manifest.preview.carId === "" ? -1 : Number(manifest.preview.carId), - trackId: manifest.preview.trackId === "" ? -1 : Number(manifest.preview.trackId), - }); - const result = await importSessionFrames( - ibtFrames(paths.ibt, manifest.preview), - "iracing", - { requireLaps: true, ownership }, - ); - return { - packetCount: result.packetCount, - laps: result.laps, - preview: manifest.preview, - }; - } catch (error) { - if ( - error instanceof Error && - error.message === "No complete, importable laps were found" - ) { - throw new IbtImportError(error.message); - } - throw error; + await registerImportedIRacingIdentity({ + ...manifest.preview, + carId: manifest.preview.carId === "" ? -1 : Number(manifest.preview.carId), + trackId: manifest.preview.trackId === "" ? -1 : Number(manifest.preview.trackId), + }); + const directory = resolveRecordingGameDirectory("iracing", ownership); + mkdirSync(directory, { recursive: true }); + const finalPath = join(directory, `ibt-${randomUUID()}.ibt`); + temporaryPath = `${finalPath}.tmp-${randomUUID()}`; + const temporary = await openFile(temporaryPath, "wx"); + temporaryOwned = true; + await temporary.close(); + await copyFile(paths.ibt, temporaryPath); + await link(temporaryPath, finalPath); + publishedPath = finalPath; + await unlink(temporaryPath); + temporaryOwned = false; + const source = await readIbtSource(publishedPath, manifest.preview); + const result = await importSessionPackets( + source.packets, + "iracing", + { + requireLaps: true, + ownership, + sessionSource: "ibt", + recorder: new ImportSourceRecorder(publishedPath), + rollbackFiles: [publishedPath], + notifyDriverProfile: options.notifyDriverProfile, + }, + ); + return { packetCount: result.packetCount, laps: result.laps, preview: manifest.preview }; + } catch (error) { + if (temporaryOwned && temporaryPath) { + try { await unlink(temporaryPath); } catch {} + } + if (publishedPath) { + try { unlinkSync(publishedPath); } catch {} } + if (error instanceof Error && error.message === "No complete, importable laps were found") { + throw new IbtImportError(error.message); + } + throw error; } finally { removeStage(token); } diff --git a/apps/backend/src/laps/archive.ts b/apps/backend/src/laps/archive.ts index 49ec74715..2c3bf04f4 100644 --- a/apps/backend/src/laps/archive.ts +++ b/apps/backend/src/laps/archive.ts @@ -1,20 +1,15 @@ /** * Lap / session ZIP export + import. * - * Telemetry is NOT stored as a per-lap blob any more — the canonical source is - * the per-session raw capture on disk (`sessions.rawFile`: optional 12-byte - * meta frame, then repeated `[uint32 LE len][frame bytes]`), with each lap row - * pointing at a `rawByteOffset` / `rawFrameCount` window inside it. - * - * So an export is just a slice of that frame stream, and an import replays the - * slice through the normal pipeline (`importSessionBin`) — the exact same code - * path as the single-file `.bin` import. Nothing here re-implements parsing or - * lap detection, which is what the old CSV-blob format did (and why it rotted). + * IBT source sessions export their complete original file; BIN captures export + * selected frame windows. Imports reuse the production MoTeC and IBT pipelines + * or the normal BIN importer, preserving each source's native representation. * * Zip layout: * manifest.json — describes every entry * --session.bin.gz — one gzip'd frame slice per session * --session.motec.zip — one complete MoTeC source archive per session + * iracing--session.ibt — one original iRacing IBT per session */ import { zipSync } from "fflate"; import { readFile } from "node:fs/promises"; @@ -84,6 +79,7 @@ import { import { parseF1Header } from "@raceiq/capture-formats/f1-2025/f1-wire"; import type { GameId } from "@raceiq/shared/games/ids"; import { getServerGame } from "@raceiq/backend-core/games/registry"; +import { commitStagedIbt, stageIbtUpload } from "../imports/iracing-ibt"; export const LAPS_ZIP_VERSION = 4; export interface ManifestLap { @@ -93,7 +89,7 @@ export interface ManifestLap { } export interface ManifestEntry { - /** Zip entry name holding this session's gzip'd frame slice or MoTeC source archive. */ + /** Session source bytes: gzip'd frame slice, MoTeC archive, or original IBT. */ file: string; gameId: GameId; /** Session id in the *source* database (informational — import always creates a new session). */ @@ -164,6 +160,7 @@ function captureFileName(memberName: string): string { function fileNamesForZip(files: Record): string[] { return Object.keys(files) + .filter((name) => name.endsWith(".bin") || name.endsWith(".bin.gz") || name.endsWith(".motec.zip") || name.toLowerCase().endsWith(".ibt")) .filter((name) => !name.endsWith(".opponents.bin.gz") && (name.endsWith(".bin") || name.endsWith(".bin.gz") || name.endsWith(".motec.zip"))) .sort(); @@ -619,10 +616,28 @@ export async function buildLapsZip( } } const firstSelected = rows[0]!; - const isMotec = rows.some((row) => row.source === "motec" || row.rawFile?.endsWith(".motec.zip")); - if (isMotec && rows.some((row) => row.captureLayoutVersion != null)) { - throw new Error("Published MoTeC capture layouts cannot be exported as source archives"); + const isIbt = rows.some((row) => row.source === "ibt" || row.rawFile?.toLowerCase().endsWith(".ibt")); + if (isIbt) { + if (rows.some((row) => row.gameId !== "iracing" || row.captureLayoutVersion != null)) { + throw new Error("IBT source archives require original iRacing recordings without published BIN layouts"); + } + const first = allRows.find((row) => row.sessionId === sessionId) ?? firstSelected; + if (!first.rawFile) throw new Error("Original IBT source is missing"); + const sourceBytes = await readFile(first.rawFile); + const trackName = resolveTrackName(first.trackId ?? "", "iracing"); + const carName = resolveCarName(first.carId ?? "", "iracing"); + const fileName = `iracing-${slugify(trackName) || `track${first.trackId ?? ""}`}-session${sessionId}.ibt`; + files[fileName] = sourceBytes; + const sourceLaps = allRows.filter((row) => row.sessionId === sessionId).sort((a, b) => a.lapNumber - b.lapNumber); + entries.push({ + file: fileName, gameId: "iracing", sessionId, + carId: first.carId ?? "", trackId: first.trackId ?? "", + carName, trackName, createdAt: first.createdAt, + laps: sourceLaps.map((r) => ({ lapNumber: r.lapNumber, lapTime: r.lapTime, isValid: r.isValid })), + }); + continue; } + const isMotec = rows.some((row) => row.source === "motec" || row.rawFile?.endsWith(".motec.zip")); if (isMotec) { const first = allRows.find((row) => row.sessionId === sessionId) ?? firstSelected; const rawFile = first.rawFile; @@ -942,10 +957,9 @@ export interface ImportZipResult { } /** - * Import a zip produced by {@link buildLapsZip}: every `.bin`/`.bin.gz` member is - * replayed through the pipeline, landing as a fresh session with its laps - * re-detected. Duplicates are not merged — importing the same zip twice gives - * you the laps twice, same as the single-file `.bin` import. + * Import ZIP members through their production source pipeline: original IBT is + * staged and committed as IBT, MoTeC is restored as MoTeC, and BIN replays as BIN. + * Duplicates are not merged; importing the same archive twice creates sessions twice. */ export async function importLapsZip(zipData: Uint8Array, options: { ownership?: SessionOwnership; identityOverrides?: Record } = {}): Promise { const files = unzipBounded(zipData, LAPS_ZIP_LIMITS); @@ -977,7 +991,7 @@ export async function importLapsZip(zipData: Uint8Array, options: { ownership?: if (names.length === 0) { throw new Error( - "Zip contains no session captures (.bin/.bin.gz/.motec.zip). Exports from an older RaceIQ version can't be imported." + "Zip contains no session captures (.bin/.bin.gz/.motec.zip/.ibt). Exports from an older RaceIQ version can't be imported." ); } @@ -989,6 +1003,29 @@ export async function importLapsZip(zipData: Uint8Array, options: { ownership?: memberBytes.byteLength, ); const entry = manifestEntries.get(name); + if (name.toLowerCase().endsWith(".ibt")) { + try { + if (entry && entry.gameId !== "iracing") throw new Error("IBT manifest metadata must identify iRacing"); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(memberBytes); + controller.close(); + }, + }); + const staged = await stageIbtUpload(stream, name, memberBytes.byteLength); + if (!staged.token) { + skipped++; + errors.push(`${name}: ${staged.preview.reason ?? "IBT preview was not importable"}`); + continue; + } + const result = await commitStagedIbt(staged.token, options.ownership); + laps.push(...result.laps); + } catch (error) { + skipped++; + errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); + } + continue; + } if (name.endsWith(".motec.zip")) { if (!entry) { skipped++; diff --git a/apps/backend/src/routes/games/iracing.ts b/apps/backend/src/routes/games/iracing.ts new file mode 100644 index 000000000..ded8c0ff9 --- /dev/null +++ b/apps/backend/src/routes/games/iracing.ts @@ -0,0 +1,27 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { iracingCaptureManager } from "../../runtime/iracing-capture"; +import { loadSettings, saveSettings } from "@raceiq/backend-core/runtime/config/settings"; +import { IdParamSchema } from "@raceiq/shared/platform/http/route-schemas"; +import { getIRacingIncidentReport } from "../../games/iracing-incident-report"; + +export const iracingRoutes = new Hono() + .get("/api/iracing/sessions/:id/incidents", zValidator("param", IdParamSchema), async (c) => { + const { id } = c.req.valid("param"); + try { + const report = await getIRacingIncidentReport(id); + return report === null ? c.json({ error: "Session not found" }, 404) : c.json(report); + } catch (error) { + console.error("[iRacing incidents] Could not read session report", { sessionId: id, error }); + return c.json({ error: "Incident report could not be read" }, 500); + } + }) + .get("/api/iracing/capture", (c) => c.json(iracingCaptureManager.status())) + .put("/api/iracing/capture", zValidator("json", z.object({ connected: z.boolean() }).strict()), async (c) => { + const { connected } = c.req.valid("json"); + const settings = loadSettings(); + settings.iracingCaptureEnabled = connected; + saveSettings(settings); + return c.json(await iracingCaptureManager.connect(connected)); + }); diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index ec76d8c0c..6558c6e8d 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -18,6 +18,7 @@ import { tuneRoutes } from "./tune-routes"; import { accRoutes } from "./games/acc"; import { acEvoRoutes } from "./games/ac-evo"; import { f125Routes } from "./games/f1-2025"; +import { iracingRoutes } from "./games/iracing"; import { miscRoutes } from "./system/index"; import { cacheRoutes } from "./cache-routes"; import { devRoutes } from "./dev/index"; @@ -54,6 +55,7 @@ const app = new Hono() .route("/", tuneRoutes) .route("/", accCarRoutes) .route("/", accRoutes) + .route("/", iracingRoutes) .route("/", acEvoCarRoutes) .route("/", acEvoRoutes) .route("/", f125Routes) diff --git a/apps/backend/src/routes/laps/resource-routes.ts b/apps/backend/src/routes/laps/resource-routes.ts index cdd78c11f..b505dc627 100644 --- a/apps/backend/src/routes/laps/resource-routes.ts +++ b/apps/backend/src/routes/laps/resource-routes.ts @@ -264,13 +264,18 @@ export const resourceRoutes = new Hono() carId: row.carId, trackId: row.trackId, }); } else { + if (row.rawFile!.toLowerCase().endsWith(".ibt")) { + const original = await Bun.file(row.rawFile!).arrayBuffer(); + c.header("Content-Type", "application/octet-stream"); + c.header("Content-Disposition", `attachment; filename="iracing-session${row.sessionId}.ibt"`); + c.header("Content-Length", String(original.byteLength)); + return c.body(original); + } const loaded = await loadSessionSource({ rawFile: row.rawFile!, source: row.source ?? null, gameId: row.gameId as GameId, carId: row.carId, trackId: row.trackId, captureFormatVersion: row.captureFormatVersion, }); - if (loaded.kind === "packets") { - return c.json({ error: "MoTeC sessions use canonical packets and have no BIN capture" }, 409); - } + if (loaded.kind === "packets") return c.json({ error: "MoTeC sessions use canonical packets and have no BIN capture" }, 409); bytes = loaded.buffer; } } catch (error) { diff --git a/apps/backend/src/runtime/boot.ts b/apps/backend/src/runtime/boot.ts index 970c11463..7c95791cf 100644 --- a/apps/backend/src/runtime/boot.ts +++ b/apps/backend/src/runtime/boot.ts @@ -24,6 +24,7 @@ import { getOnboardingOverride } from "@raceiq/backend-core/runtime/options"; import { openFirstRunDashboard, preventMacSleep } from "@raceiq/backend-core/runtime/desktop"; import { clearHttpPort, startHttpServer } from "./http-server"; import { startNativeSourceSupervisor, type NativeSourceSupervisor } from "./native-sources"; +import { iracingCaptureManager } from "./iracing-capture"; import { installShutdown } from "./shutdown"; import { startStartupWorker, type StartupWorkerHandle } from "./startup-worker"; import { startMaintenanceJobs, startSyncAndStaleSessionJobs } from "./startup-jobs"; @@ -171,6 +172,7 @@ export async function bootServer(options: BootOptions = {}): Promise host === "0.0.0.0" || host === "::" ? "localhost" : host) diff --git a/apps/backend/src/runtime/iracing-capture.ts b/apps/backend/src/runtime/iracing-capture.ts new file mode 100644 index 000000000..284c152d1 --- /dev/null +++ b/apps/backend/src/runtime/iracing-capture.ts @@ -0,0 +1,287 @@ +import { copyFile, lstat, mkdir, readdir, rm } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { IS_WINDOWS } from "@raceiq/backend-core/runtime/platform/shell"; +import { resolveDataDir } from "@raceiq/backend-core/runtime/config/data-dir"; +import { tryGetServerGame } from "@raceiq/backend-core/games/registry"; +import { resolveIRacingTelemetryDirectory, setIRacingDiskLogging, isIRacingTelemetryFileClosed, publishIRacingTelemetryFile } from "@raceiq/game-iracing/disk-logging"; + +export interface IRacingCaptureStatus { + supported: boolean; + connected: boolean; + state: "unavailable" | "disconnected" | "waiting-for-iracing" | "waiting-for-recording" | "recording" | "saving" | "error"; + reason: "unsupported-platform" | "feature-disabled" | "documents-unavailable" | "sdk-command-failed" | "archive-failed" | null; + sourceDirectory: string | null; + archiveDirectory: string; + savedFiles: number; + lastSavedFile: string | null; +} + +export interface IRacingCaptureOptions { + availability?: () => IRacingCaptureStatus["reason"]; + resolveDirectory?: () => string; + archiveDirectory?: string; + setLogging?: (enabled: boolean) => void; + isFileClosed?: (path: string) => boolean; + publishFile?: (stage: string, destination: string) => boolean | Promise; + copyFile?: (source: string, destination: string) => Promise; + now?: () => number; + pollIntervalMs?: number; + settleMs?: number; + shutdownFlushMs?: number; +} + +interface FileMark { size: bigint; mtimeNs: bigint; ctimeNs: bigint; ino: bigint } +interface Candidate { mark: FileMark; stableSince: number } +const SOURCE_FRESH_MS = 5000; +const COMMAND_ACK_MS = 10000; +function sameMark(a: FileMark, b: FileMark) { + return a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.ino === b.ino; +} +function sdkBoolean(value: unknown): boolean | null { + return value === true || value === 1 ? true : value === false || value === 0 ? false : null; +} +async function digest(path: string) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} +function nativeAvailability(): IRacingCaptureStatus["reason"] { + if (!IS_WINDOWS) return "unsupported-platform"; + return tryGetServerGame("iracing") ? null : "feature-disabled"; +} + +/** Runtime opt-in; native originals remain owned by iRacing, never moved or deleted. */ +export class IRacingCaptureManager { + private readonly availability: () => IRacingCaptureStatus["reason"]; + private readonly resolveDirectory: () => string; + private readonly setLogging: (enabled: boolean) => void; + private readonly isFileClosed: (path: string) => boolean; + private readonly publishFile: (stage: string, destination: string) => boolean | Promise; + private readonly copy: (source: string, destination: string) => Promise; + private readonly now: () => number; + private readonly pollIntervalMs: number; + private readonly settleMs: number; + private readonly shutdownFlushMs: number; + private readonly archiveDirectory: string; + private connected = false; + private sourceDirectory: string | null = null; + private baseline = new Map(); + private readonly pending = new Map(); + private lastObservation: number | null = null; + private loggingEnabled: boolean | null = null; + private recordingActive: boolean | null = null; + private ownsLogging = false; + private startRequestedAt: number | null = null; + private error: IRacingCaptureStatus["reason"] = null; + private saved = 0; + private last: string | null = null; + private saving = false; + private queue: Promise = Promise.resolve(); + private timer: NodeJS.Timeout | undefined; + private tickQueued = false; + + constructor(options: IRacingCaptureOptions = {}) { + this.availability = options.availability ?? nativeAvailability; + this.resolveDirectory = options.resolveDirectory ?? resolveIRacingTelemetryDirectory; + this.archiveDirectory = options.archiveDirectory ?? resolve(resolveDataDir(), "sessions", "iracing", "ibt"); + this.setLogging = options.setLogging ?? setIRacingDiskLogging; + this.isFileClosed = options.isFileClosed ?? isIRacingTelemetryFileClosed; + this.publishFile = options.publishFile ?? publishIRacingTelemetryFile; + this.copy = options.copyFile ?? copyFile; + this.now = options.now ?? Date.now; + this.pollIntervalMs = options.pollIntervalMs ?? 1500; + this.settleMs = options.settleMs ?? 1000; + this.shutdownFlushMs = options.shutdownFlushMs ?? 5000; + } + + status(): IRacingCaptureStatus { + const unavailable = this.availability(); + const fresh = this.sourceFresh(); + const state: IRacingCaptureStatus["state"] = unavailable ? "unavailable" : this.error ? "error" + : this.saving || (!this.connected && this.pending.size > 0) ? "saving" + : !this.connected ? "disconnected" : !fresh ? "waiting-for-iracing" + : this.recordingActive === true ? "recording" : "waiting-for-recording"; + return { supported: unavailable === null, connected: this.connected, state, reason: unavailable ?? this.error, + sourceDirectory: this.sourceDirectory, archiveDirectory: this.archiveDirectory, savedFiles: this.saved, lastSavedFile: this.last }; + } + + connect(connected: boolean): Promise { + return this.serial(async () => { + if (!connected) { + // Discover the final in-window file before ending discovery. The pending set + // survives disconnect, but subsequent unrelated files are never harvested. + if (this.connected) await this.scan(true); + this.stopOwnedLogging(); + this.connected = false; + await this.scan(false); + this.updateTimer(); + return; + } + if (this.connected || this.availability() !== null) return; + this.error = null; + try { this.sourceDirectory = this.resolveDirectory(); } + catch (error) { this.fail("documents-unavailable", error); return; } + try { + await mkdir(this.archiveDirectory, { recursive: true }); + this.baseline = await this.snapshotDirectory(); + } catch (error) { this.fail("archive-failed", error); return; } + this.connected = true; + this.startRequestedAt = null; + this.ownsLogging = false; + this.updateLogging(); + this.updateTimer(); + }).then(() => this.status()); + } + + /** Called by the existing live source. No FFI or filesystem work on its hot path. */ + observe(snapshot: { values: Record }, frameTimeMs = this.now()): void { + if (this.lastObservation !== null && frameTimeMs - this.lastObservation > SOURCE_FRESH_MS) this.forgetOwnership(); + this.lastObservation = frameTimeMs; + this.loggingEnabled = sdkBoolean(snapshot.values.IsDiskLoggingEnabled); + this.recordingActive = sdkBoolean(snapshot.values.IsDiskLoggingActive); + } + + tick(): Promise { + return this.serial(async () => { + if (!this.sourceFresh()) this.forgetOwnership(); + if (this.connected) this.updateLogging(); + await this.scan(this.connected); + this.updateTimer(); + }); + } + + async stop(): Promise { + await this.connect(false); + this.clearTimer(); + // SDK stop is asynchronous; allow its final closed file to settle. Use wall + // time for shutdown so a paused/injected simulator clock cannot hang exit. + const deadline = Date.now() + this.shutdownFlushMs; + while (this.pending.size && Date.now() < deadline) { + await new Promise((done) => setTimeout(done, Math.min(250, this.settleMs || 1))); + await this.tick(); + this.clearTimer(); + } + await this.queue; + if (this.pending.size) console.warn("[iRacing capture] Writer still open at shutdown; native originals remain available", [...this.pending.keys()]); + } + + private sourceFresh() { + return this.lastObservation !== null && this.now() - this.lastObservation <= SOURCE_FRESH_MS; + } + private forgetOwnership() { this.ownsLogging = false; this.startRequestedAt = null; } + private updateLogging(): void { + if (!this.sourceFresh()) return; + if (this.loggingEnabled === true || this.recordingActive === true) { + this.startRequestedAt = null; + if (this.error === "sdk-command-failed") this.error = null; + return; + } + if (this.loggingEnabled !== false || this.recordingActive !== false) return; + if (this.startRequestedAt !== null) { + if (this.now() - this.startRequestedAt >= COMMAND_ACK_MS) this.fail("sdk-command-failed", new Error("iRacing did not acknowledge disk logging")); + return; + } + if (this.error === "sdk-command-failed") return; + try { + this.setLogging(true); + this.ownsLogging = true; + this.startRequestedAt = this.now(); + } catch (error) { this.fail("sdk-command-failed", error); } + } + private stopOwnedLogging(): void { + if (this.ownsLogging && this.sourceFresh()) { + try { this.setLogging(false); } + catch (error) { this.fail("sdk-command-failed", error); } + } + this.forgetOwnership(); + } + private fail(reason: IRacingCaptureStatus["reason"], error: unknown): void { + if (this.error !== reason) console.error("[iRacing capture]", reason, error); + this.error = reason; + } + private serial(operation: () => Promise): Promise { + const next = this.queue.then(operation); + this.queue = next.catch((error) => this.fail("archive-failed", error)); + return next; + } + private updateTimer(): void { + if (!this.connected && !this.pending.size) { this.clearTimer(); return; } + if (this.timer || this.pollIntervalMs <= 0) return; + this.timer = setInterval(() => { + if (this.tickQueued) return; + this.tickQueued = true; + void this.tick().catch((error) => this.fail("archive-failed", error)).finally(() => { this.tickQueued = false; }); + }, this.pollIntervalMs); + this.timer.unref(); + } + private clearTimer(): void { clearInterval(this.timer); this.timer = undefined; } + + private async snapshotDirectory(): Promise> { + const result = new Map(); + if (!this.sourceDirectory) return result; + let entries; + try { entries = await readdir(this.sourceDirectory, { withFileTypes: true }); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return result; throw error; } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".ibt")) continue; + try { + const info = await lstat(join(this.sourceDirectory, entry.name), { bigint: true }); + if (info.isFile()) result.set(entry.name, { size: info.size, mtimeNs: info.mtimeNs, ctimeNs: info.ctimeNs, ino: info.ino }); + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + return result; + } + + private async scan(discover: boolean): Promise { + if (!this.sourceDirectory) return; + try { + const current = await this.snapshotDirectory(); + if (discover) { + for (const [name, mark] of current) { + const baseline = this.baseline.get(name); + if (baseline && sameMark(baseline, mark)) continue; + const pending = this.pending.get(name); + if (!pending || !sameMark(pending.mark, mark)) this.pending.set(name, { mark, stableSince: this.now() }); + } + } + for (const [name, candidate] of this.pending) { + const mark = current.get(name); + if (!mark) { this.pending.delete(name); continue; } + if (!sameMark(candidate.mark, mark)) { this.pending.set(name, { mark, stableSince: this.now() }); continue; } + if (this.now() - candidate.stableSince < this.settleMs) continue; + const source = join(this.sourceDirectory, name); + if (!this.isFileClosed(source)) continue; + if (mark.size === 0n) throw new Error("Completed iRacing telemetry file is empty"); + await this.archive(source, name, mark); + this.baseline.set(name, mark); + this.pending.delete(name); + } + } catch (error) { this.fail("archive-failed", error); } + } + + private async archive(source: string, name: string, mark: FileMark): Promise { + const stage = join(this.archiveDirectory, `.${randomUUID()}.tmp`); + this.saving = true; + try { + await this.copy(source, stage); + const from = await lstat(source, { bigint: true }); + const staged = await lstat(stage, { bigint: true }); + if (!from.isFile() || !sameMark(mark, from) || staged.size !== mark.size || !this.isFileClosed(source)) throw new Error("iRacing source changed during archive copy"); + const hash = await digest(stage); + const stem = basename(name, name.slice(-4)).slice(0, 140); + const destination = join(this.archiveDirectory, `${stem}-${hash}.ibt`); + if (await this.publishFile(stage, destination)) { + this.saved++; + this.last = destination; + } else if (await digest(destination) !== hash) throw new Error("Existing iRacing archive content does not match its identity"); + if (this.error === "archive-failed") this.error = null; + } finally { + await rm(stage, { force: true }); + this.saving = false; + } + } +} + +export const iracingCaptureManager = new IRacingCaptureManager(); diff --git a/apps/backend/src/runtime/native-sources.ts b/apps/backend/src/runtime/native-sources.ts index da81054ce..f760cdc65 100644 --- a/apps/backend/src/runtime/native-sources.ts +++ b/apps/backend/src/runtime/native-sources.ts @@ -3,6 +3,7 @@ import { AccSharedMemoryReader } from "@raceiq/game-acc/shared-memory"; import { AcEvoSharedMemoryReader } from "@raceiq/game-ac-evo/shared-memory"; import { IRacingTelemetrySource } from "@raceiq/game-iracing/source"; import { registerLiveIRacingIdentity } from "@raceiq/game-iracing/identity"; +import { iracingCaptureManager } from "./iracing-capture"; import { AMS2TelemetrySource } from "@raceiq/game-ams2/source"; import { LMUTelemetrySource } from "@raceiq/game-lmu/source"; import { isGameRunning } from "@raceiq/backend-core/games/registry"; @@ -31,7 +32,7 @@ export function startNativeSourceSupervisor( recordingGameId: string | null, dumpMode = false, ): NativeSourceSupervisor { - if (!IS_WINDOWS) { + if (!IS_WINDOWS || process.env.RACEIQ_E2E === "1") { return { stop: async () => {} }; } @@ -101,7 +102,12 @@ export function startNativeSourceSupervisor( setAcEvoReader(null); setIracingSource(null); setLmuSource(null); + for (const reader of readers) { + if (reader) trackStop(reader.stop()); + } + await iracingCaptureManager.stop(); setAms2Source(null); + await Promise.allSettled(pendingStops); const stopped = await Promise.allSettled([ ...pendingStops, ...readers.filter(reader => reader !== null).map(async reader => { await reader.stop(); }), diff --git a/apps/backend/test/db/database-path.test.ts b/apps/backend/test/db/database-path.test.ts index 7b9ae7b8b..41221e3d3 100644 --- a/apps/backend/test/db/database-path.test.ts +++ b/apps/backend/test/db/database-path.test.ts @@ -230,7 +230,12 @@ describe("production database path", () => { expect(fresh.prepare("SELECT mode, password_hash FROM dashboard_access WHERE id = 1").get()).toMatchObject({ mode: "pending", password_hash: null }); expect(fresh.prepare("SELECT name FROM sqlite_master WHERE name = 'obsolete_seed_state'").all()).toEqual([]); expect(fresh.prepare("SELECT name FROM schema_migrations WHERE version = 75").get()).toHaveProperty("name", "persist dashboard access and login sessions"); - expect((fresh.prepare("SELECT DISTINCT game_id FROM sessions").all() as { game_id: unknown }[]).map((row) => String(row.game_id))).toEqual(["iracing"]); + const seeded = fresh.prepare("SELECT game_id, raw_file, notes FROM sessions").all() as Array<{ game_id: string; raw_file: string; notes: string }>; + expect(seeded.length).toBeGreaterThan(0); + expect(seeded.every((row) => row.game_id === "iracing" && row.notes?.includes("raceiq-demo-seed-v1"))).toBe(true); + expect(seeded.every((row) => row.raw_file.endsWith(".ibt"))).toBe(true); + const originalIbt = readFileSync(join(REPO_ROOT, "test/artifacts/sessions/iracing-navarra-mx5-2026-10-19.ibt")); + expect(seeded.every((row) => readFileSync(row.raw_file).equals(originalIbt))).toBe(true); } finally { fresh.close(); } diff --git a/apps/backend/test/db/lap-read-batching.test.ts b/apps/backend/test/db/lap-read-batching.test.ts index 25cd76d63..be7ee02a5 100644 --- a/apps/backend/test/db/lap-read-batching.test.ts +++ b/apps/backend/test/db/lap-read-batching.test.ts @@ -1,3 +1,4 @@ +import { updateSessionRawFile } from "@raceiq/backend-core/db/session-queries"; import { afterEach, expect, mock, test } from "bun:test"; import { eq } from "drizzle-orm"; import { db } from "@raceiq/backend-core/db/index"; @@ -46,7 +47,7 @@ mock.module("@raceiq/backend-core/db/telemetry-replay-storage", () => ({ parseSessionLapsBatched: parseSessionLapsBatchedGate, })); -const { getLapsByIds, getSessionLaps } = await import("@raceiq/backend-core/db/lap-read-queries"); +const { getLapById, getLapsByIds, getSessionLaps } = await import("@raceiq/backend-core/db/lap-read-queries"); // Bun module mocks are process-global and cannot be restored. These wrappers const sessionIds: number[] = []; @@ -123,6 +124,51 @@ test("lap export readiness includes usable legacy raw captures and published bun expect(legacyLaps[0]?.standaloneCaptureReady).toBe(true); }); +test("unpublished and empty legacy captures remain unavailable without binary retries", async () => { + const inserted = await db.insert(sessions).values([ + { carId: "1", trackId: "2", gameId: "ac-evo", rawFile: "/captures/legacy-manifest.json" }, + { carId: "1", trackId: "2", gameId: "ac-evo", captureLayoutVersion: 1, captureManifestFile: "/captures/registered-manifest.json", rawFile: "/captures/registered-manifest.json" }, + { carId: "1", trackId: "2", gameId: "ac-evo", rawFile: "/captures/empty.bin" }, + ]).returning({ id: sessions.id }).all(); + sessionIds.push(...inserted.map(({ id }) => id)); + const insertedLaps = await db.insert(laps).values([ + { sessionId: inserted[0]!.id, lapNumber: 1, lapTime: 90, isValid: true, rawByteOffset: 115165, rawFrameCount: 0 }, + { sessionId: inserted[0]!.id, lapNumber: 2, lapTime: 91, isValid: true, rawByteOffset: 115165, rawFrameCount: 1 }, + { sessionId: inserted[1]!.id, lapNumber: 3, lapTime: 92, isValid: true, rawByteOffset: 115165, rawFrameCount: 1 }, + { sessionId: inserted[2]!.id, lapNumber: 4, lapTime: 93, isValid: true, rawByteOffset: 12, rawFrameCount: 0 }, + ]).returning({ id: laps.id }).all(); + const ids = insertedLaps.map(({ id }) => id); + const single = await getSessionLaps("ac-evo", inserted[0]!.id); + expect(single.map(({ telemetryAvailable, standaloneCaptureReady }) => [telemetryAvailable, standaloneCaptureReady])).toEqual([[false, false], [false, false]]); + for (let attempt = 0; attempt < 2; attempt++) { + for (const id of ids) { + const result = await getLapById(id); + expect(result).toMatchObject({ telemetry: [], telemetryAvailable: false, standaloneCaptureReady: false }); + expect(result).not.toHaveProperty("parseError"); + } + } + for (let attempt = 0; attempt < 2; attempt++) { + for (const parallelSessionDecodes of [false, true]) { + const result = await getLapsByIds(ids, { parallelSessionDecodes }); + expect(result.map(({ id, telemetry, parseError }) => [id, telemetry.length, parseError])).toEqual(ids.map((id) => [id, 0, undefined])); + } + } + expect(parseRawLapFrames).not.toHaveBeenCalled(); + expect(parseSessionLapsBatched).not.toHaveBeenCalled(); +}); + +test("manifest registration becomes layout-aware before manifest persistence", async () => { + const session = await db.insert(sessions).values({ carId: "1", trackId: "2", gameId: "ac-evo" }).returning({ id: sessions.id }).get(); + sessionIds.push(session.id); + const inserted = await db.insert(laps).values({ sessionId: session.id, lapNumber: 1, lapTime: 90, isValid: true, rawByteOffset: 115165, rawFrameCount: 1 }).returning({ id: laps.id }).get(); + await updateSessionRawFile(session.id, "/captures/pending/manifest.json", "test-detector"); + + const lap = await getLapById(inserted.id); + expect(lap).toMatchObject({ telemetry: [], telemetryAvailable: false, standaloneCaptureReady: false }); + expect(lap?.parseError).toBeUndefined(); + expect(parseRawLapFrames).not.toHaveBeenCalled(); +}); + test("getLapsByIds decodes different sessions in parallel and preserves order", async () => { stubsActive = true; parallelGate = true; diff --git a/apps/backend/test/driver-profile/load-insights.test.ts b/apps/backend/test/driver-profile/load-insights.test.ts index 795c327f3..ffc016fca 100644 --- a/apps/backend/test/driver-profile/load-insights.test.ts +++ b/apps/backend/test/driver-profile/load-insights.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { eq } from "drizzle-orm"; -import { deleteSession, insertSession } from "@raceiq/backend-core/db/session-queries"; +import { deleteSession, insertSession, updateSessionRawFile } from "@raceiq/backend-core/db/session-queries"; import { insertLap } from "@raceiq/backend-core/db/lap-mutation-queries"; import { cacheSet } from "@raceiq/backend-core/db/telemetry-replay-storage"; import { db } from "@raceiq/backend-core/db/index"; @@ -21,7 +21,8 @@ test("explicit profile request restores missing and stale detector evidence with initServerGameAdapters(); const sessionId = await insertSession("1", "2", "fm-2023"); sessions.push(sessionId); - const lapId = await insertLap(sessionId, 1, 90, true, null, 0, null, null, null, null, null); + await updateSessionRawFile(sessionId, "/virtual/profile-capture.bin", "test"); + const lapId = await insertLap(sessionId, 1, 90, true, 12, 60, null, null, null, null, null); cacheSet(lapId, Array.from({ length: 60 }, (_, i) => ({ gameId: "fm-2023", TimestampMS: i * 1000 / 30, IsRaceOn: 1, Speed: 25, Steer: 0, Accel: 0, Brake: 0, Clutch: 0, HandBrake: 0, @@ -49,7 +50,8 @@ test("concurrent metric reads and forced insight reruns leave current evidence p initServerGameAdapters(); const sessionId = await insertSession("1", "2", "fm-2023"); sessions.push(sessionId); - const lapId = await insertLap(sessionId, 1, 90, true, null, 0, null, null, null, null, null); + await updateSessionRawFile(sessionId, "/virtual/profile-capture.bin", "test"); + const lapId = await insertLap(sessionId, 1, 90, true, 12, 60, null, null, null, null, null); cacheSet(lapId, Array.from({ length: 60 }, (_, i) => ({ gameId: "fm-2023", TimestampMS: i * 1000 / 30, IsRaceOn: 1, Speed: 25, Steer: 0, Accel: 0, Brake: 0, Clutch: 0, HandBrake: 0, diff --git a/apps/backend/test/e2e/classification-migration.test.ts b/apps/backend/test/e2e/classification-migration.test.ts index 7fdc2ee15..f7fad84fd 100644 --- a/apps/backend/test/e2e/classification-migration.test.ts +++ b/apps/backend/test/e2e/classification-migration.test.ts @@ -406,6 +406,17 @@ for (const defect of ["ambiguous_match", "corrupt_source"] as const) { expect(snapshot.failed).toBe(defect === "corrupt_source" ? 1 : 0); expect(snapshot.limited).toBe(defect === "ambiguous_match" ? 1 : 0); if (defect === "corrupt_source") expect(snapshot.aggregatesPending).toBe(true); + if (defect === "corrupt_source") { + // The dashboard worker runs in a separate process; await its real publication rather than mocking its clock. + const dashboardDeadline = Date.now() + 30_000; + let summary = database.prepare("SELECT duration_status,sector_status FROM dashboard_session_summaries WHERE session_id=(SELECT id FROM sessions LIMIT 1)").get() as { duration_status: string; sector_status: string } | undefined; + while (summary?.duration_status !== "unavailable" || summary?.sector_status !== "unavailable") { + if (Date.now() >= dashboardDeadline) break; + await Bun.sleep(50); + summary = database.prepare("SELECT duration_status,sector_status FROM dashboard_session_summaries WHERE session_id=(SELECT id FROM sessions LIMIT 1)").get() as { duration_status: string; sector_status: string } | undefined; + } + expect(summary).toMatchObject({ duration_status: "unavailable", sector_status: "unavailable" }); + } const response = await fetch(`${running.address}/api/sessions/classification-migration-recordings`); expect(response.status).toBe(200); const list = await response.json() as { recordings: Array<{ state: string; reason: string }> }; @@ -435,7 +446,14 @@ for (const defect of ["ambiguous_match", "corrupt_source"] as const) { } finally { await server?.stop(); database?.close(); - await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + database = undefined; + if (process.platform === "win32") { + // libsql's native statement handles can outlive close() until collection on Windows. + Bun.gc(true); + await command(["node", "--eval", "require('node:fs').rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });", directory]); + } else { + await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } } }, 240_000); } diff --git a/apps/backend/test/e2e/dashboard-write-coordination.test.ts b/apps/backend/test/e2e/dashboard-write-coordination.test.ts index 3223c1b6b..c2a1c8ff4 100644 --- a/apps/backend/test/e2e/dashboard-write-coordination.test.ts +++ b/apps/backend/test/e2e/dashboard-write-coordination.test.ts @@ -11,6 +11,10 @@ import { containsSparseCaptureFrames, decompressIfGzipSync } from "@raceiq/captu import { DASHBOARD_PROCESSOR_VERSION } from "@raceiq/backend-core/db/dashboard-summary-queries"; import { dashboardCarIdentity, dashboardTrackIdentity } from "@raceiq/shared/racing/sessions/dashboard"; import type { GameId } from "@raceiq/shared/games/ids"; +import { IRacingIbtReader } from "@raceiq/game-iracing/ibt-reader"; +import type { CanonicalTelemetryEnvelope, SemanticTelemetryReplay } from "@raceiq/shared/telemetry/replay/contracts"; +import type { SyntheticIbtRecording } from "@raceiq/game-iracing/test-support/games/iracing-ibt"; +import { TELEMETRY_CATALOG } from "@raceiq/shared/telemetry/catalog/data"; async function removeDatabaseDirectory(directory: string) { // Release unreachable native handles before deleting the Windows fixture. @@ -218,8 +222,8 @@ async function postFile(backend: Backend, path: string, name: string, bytes: Uin for (const [key,value] of Object.entries(fields)) body.append(key,value); return backend.api(path, { method:"POST", body }); } -async function stagedIbt(backend: Backend) { - const preview = await backend.api("/api/laps/import-ibt/preview", {method:"POST",headers:{"x-file-name":"driven.ibt","x-file-size":String(ibtBytes.byteLength)},body:ibtBytes}); +async function stagedIbt(backend: Backend, bytes = ibtBytes, fileName = "driven.ibt") { + const preview = await backend.api("/api/laps/import-ibt/preview", {method:"POST",headers:{"x-file-name":fileName,"x-file-size":String(bytes.byteLength)},body:bytes}); expect(preview.status).toBe(200); const staged = z.object({token:z.string(),preview:z.object({canImport:z.boolean()})}).parse(await preview.json()); expect(staged.preview.canImport).toBe(true); @@ -465,3 +469,148 @@ test("W-E7: worker exit rolls back mutated projections before a queued HTTP dele expect(restarted.output()).not.toContain("SQLITE_BUSY"); } finally {await restarted?.stop();await f.close();} },180_000); + +test("IBT E1–E5: separate imports retain measured geography and optional brake bias through HTTP replay reload", async () => { + const f = await fixture(); + let reloaded: Backend | undefined; + const recordings: SyntheticIbtRecording[] = []; + const value = (frame: CanonicalTelemetryEnvelope, id: string) => { + const found = frame.values.find((entry) => entry.semanticId === id); + if (!found) throw new Error(`Replay omitted requested channel ${id}`); + return found; + }; + const replay = async (backend: Backend, lapId: number) => { + const response = await backend.api(`/api/laps/${lapId}/semantic-telemetry`, { headers: { "X-Game-Id": "iracing" } }); + expect(response.status).toBe(200); + return await response.json() as SemanticTelemetryReplay; + }; + const atTime = (body: SemanticTelemetryReplay, seconds: number) => { + const frame = body.envelopes.find((entry) => value(entry, "timing.current-race-time").value === seconds); + if (!frame) throw new Error(`Replay omitted SessionTime ${seconds}`); + return frame; + }; + const source = (sessionId: number) => { + const row = z.object({ raw_file: z.string(), source: z.literal("ibt") }).parse( + f.database.prepare("SELECT raw_file, source FROM sessions WHERE id = ?").get(sessionId), + ); + expect(row.raw_file).toEndWith(".ibt"); + return readFileSync(row.raw_file); + }; + const importBytes = async (bytes: Uint8Array, name: string) => { + const response = await stagedIbt(f.backend, bytes, name); + expect(response.status).toBe(200); + const imported = ImportedSchema.parse(await response.json()); + expect(imported.errors ?? []).toEqual([]); + if (!imported.laps?.length) throw new Error("Import produced no laps"); + await drain(f.database); + expect(imported.laps[0]!.sessionId).not.toBe(f.lap.sessionId); + expect(source(imported.laps[0]!.sessionId).equals(Buffer.from(bytes))).toBe(true); + return imported.laps; + }; + const storedColumns = (row: unknown) => { + const { _metadata, ...columns } = z.record(z.string(), z.unknown()).parse(row); + return columns; + }; + try { + // Settle the existing boot-time profile backfill before taking isolation evidence. + await f.backend.stop(); + f.backend = startBackend(f.dataDir); + await f.backend.ready(); + await drain(f.database); + const baselineSession = storedColumns(f.database.prepare("SELECT * FROM sessions WHERE id = ?").get(f.lap.sessionId)); + const baselineLaps = f.database.prepare("SELECT * FROM laps WHERE session_id = ? ORDER BY id").all(f.lap.sessionId).map(storedColumns); + const baselineSource = source(f.lap.sessionId); + const baseline = await replay(f.backend, f.lap.lapId); + for (const id of ["brakes.brake-bias", "motion.latitude", "motion.longitude", "motion.altitude"]) { + expect(value(atTime(baseline, 50), id)).toMatchObject({ value: null, state: "missing" }); + } + const biasedLaps: { lapId: number; sessionId: number; middle: number }[] = []; + for (const middle of [50, NaN]) { + const rows = drivenRows().map((row) => ({ ...row, brakeBias: row.sessionTime === 50 ? 0 : row.sessionTime === 105 ? 100 : 50 })); + rows.splice(3, 0, { sessionTime: 75, sessionTick: 4500, speed: 45, lap: 2, lapDistancePct: 0.5, currentLapTime: 30, brakeBias: middle }); + const recording = createRecording(Number.isNaN(middle) ? "invalid-bias.ibt" : "bias.ibt", rows); + recordings.push(recording); + const [lap] = await importBytes(new Uint8Array(readFileSync(recording.path)), "bias.ibt"); + biasedLaps.push({ ...lap!, middle }); + } + const assertBias = (body: SemanticTelemetryReplay, middle: number) => { + // HTTP intentionally omits provenance; verify the authoritative mapping separately. + expect(TELEMETRY_CATALOG.variables.find((entry) => entry.id === "brakes.brake-bias")?.games.iracing).toMatchObject({ + kind: "direct", nativeUnit: "%", sources: ["iRacing.dcBrakeBias"], + }); + for (const [time, expected] of [[50, 0], [75, middle], [105, 100]]) { + const bias = value(atTime(body, time!), "brakes.brake-bias"); + if (Number.isNaN(expected)) { + expect(bias.value).toBeNull(); + expect(bias.state).not.toBe("ok"); + } else { + expect(bias).toMatchObject({ value: expected, unit: "%", state: "ok", freshness: "fresh" }); + } + } + }; + for (const lap of biasedLaps) assertBias(await replay(f.backend, lap.lapId), lap.middle); + + const realPath = join(ROOT, "test/artifacts/sessions/iracing-navarra-mx5-2026-10-19.ibt"); + const reader = new IRacingIbtReader(realPath); + let native: { values: Readonly> } | null = null; + try { + reader.start(); + while (!reader.done) { + const row = reader.readLatest(); + if (row?.values.SessionTime === 794.8166666697496 && row.values.RPM === 6366.2939453125) { native = row; break; } + } + } finally { await reader.stop(); } + if (!native) throw new Error("Navarra original lacks matching SessionTime/RPM sample"); + const realBytes = new Uint8Array(readFileSync(realPath)); + const realLaps = await importBytes(realBytes, "navarra.ibt"); + let matchingLap: number | undefined; + const assertMeasured = (frame: CanonicalTelemetryEnvelope) => { + for (const [id, expected] of [ + ["motion.latitude", 42.56038796677783], ["motion.longitude", -2.1656309237694855], + ["motion.altitude", 427.71051025390625], ["motion.yaw-north", 0.5409646034240723], + ["engine.current-engine-rpm", 6366.2939453125], ["timing.current-race-time", 794.8166666697496], + ["motion.speed", native!.values.Speed], ["motion.yaw", -Number(native!.values.Yaw)], + ["inputs.accel", Math.round(Number(native!.values.Throttle) * 255)], + ["inputs.brake", Math.round(Number(native!.values.Brake) * 255)], + ["inputs.steer", Math.round(Math.max(-1, Math.min(1, -Number(native!.values.SteeringWheelAngle) / Math.abs(Number(native!.values.SteeringWheelAngleMax)))) * 127)], + ] as const) { + expect(value(frame, id)).toMatchObject({ value: expected, state: "ok", freshness: "fresh" }); + } + for (const id of [ + "motion.position-x", "motion.position-z", "aero.drs-active", + "fuel.ers-store-energy", "fuel.ers-deploy-mode", "fuel.ers-deployed", "fuel.ers-harvested", + "brakes.brake-bias", "brakes.brake-temp", "tires.tire-radius", + "tire.temperature.surface.representative", "tire.temperature.surface.inner", + "tire.temperature.surface.middle", "tire.temperature.surface.outer", + "tire.temperature.core", "tire.temperature.carcass.representative", + ]) expect(value(frame, id)).toMatchObject({ value: null, state: "missing" }); + }; + for (const lap of realLaps) { + const body = await replay(f.backend, lap.lapId); + const frame = body.envelopes.find((entry) => value(entry, "timing.current-race-time").value === 794.8166666697496 && value(entry, "engine.current-engine-rpm").value === 6366.2939453125); + if (frame) { matchingLap = lap.lapId; assertMeasured(frame); break; } + } + expect(matchingLap).toBeDefined(); + await drain(f.database); + // A fresh backend process must reload native values from persisted original bytes. + await f.backend.stop(); + reloaded = startBackend(f.dataDir); + await reloaded.ready(); + assertMeasured(atTime(await replay(reloaded, matchingLap!), 794.8166666697496)); + for (const lap of biasedLaps) assertBias(await replay(reloaded, lap.lapId), lap.middle); + expect(await replay(reloaded, f.lap.lapId)).toEqual(baseline); + expect(source(realLaps[0]!.sessionId).equals(Buffer.from(realBytes))).toBe(true); + expect(storedColumns(f.database.prepare("SELECT * FROM sessions WHERE id = ?").get(f.lap.sessionId))).toEqual(baselineSession); + expect(f.database.prepare("SELECT * FROM laps WHERE session_id = ? ORDER BY id").all(f.lap.sessionId).map(storedColumns)).toEqual(baselineLaps); + expect(source(f.lap.sessionId).equals(baselineSource)).toBe(true); + expect(new Set([f.lap.sessionId, ...biasedLaps.map((lap) => lap.sessionId), realLaps[0]!.sessionId]).size).toBe(4); + } catch (error) { + console.error("IBT HTTP replay assertion failed:", error); + throw error; + } finally { + await reloaded?.stop(); + await f.backend.stop(); + await f.close(); + for (const recording of recordings) recording.cleanup(); + } +}, 180_000); diff --git a/apps/backend/test/e2e/support/upgrade-server.ts b/apps/backend/test/e2e/support/upgrade-server.ts index 2dec6ed21..555cbb9ce 100644 --- a/apps/backend/test/e2e/support/upgrade-server.ts +++ b/apps/backend/test/e2e/support/upgrade-server.ts @@ -3,7 +3,7 @@ import { resolve } from "node:path"; export const ROOT = resolve(import.meta.dir, "../../../../.."); export function upgradeEnvironment(dataDir: string): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env, DATA_DIR: dataDir, NODE_ENV: "production", SERVER_HOST: "127.0.0.1" }; + const env: NodeJS.ProcessEnv = { ...process.env, DATA_DIR: dataDir, NODE_ENV: "production", RACEIQ_E2E: "1", SERVER_HOST: "127.0.0.1" }; delete env.RACEIQ_TEST_MODE; delete env.DB_IN_MEMORY; delete env.RACEIQ_TEST_DATA_DIR; diff --git a/apps/backend/test/experiments/chat-branch-evidence.test.ts b/apps/backend/test/experiments/chat-branch-evidence.test.ts index 825e177a1..0fd7279d0 100644 --- a/apps/backend/test/experiments/chat-branch-evidence.test.ts +++ b/apps/backend/test/experiments/chat-branch-evidence.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { eq } from "drizzle-orm"; import { createExperiment, setSessionHead } from "@raceiq/backend-core/db/experiment-queries"; import { createExperimentVersion, getExperimentVersion } from "@raceiq/backend-core/db/experiment-version-queries"; -import { insertSession } from "@raceiq/backend-core/db/session-queries"; +import { insertSession, updateSessionRawFile } from "@raceiq/backend-core/db/session-queries"; import { db } from "@raceiq/backend-core/db/index"; import { experimentVersions, experiments, laps, sessions } from "@raceiq/backend-core/db/schema"; import { cacheDelete, cacheSet } from "@raceiq/backend-core/db/telemetry-replay-storage"; @@ -57,13 +57,15 @@ const telemetry: TelemetryPacket[] = Array.from({ length: 30 }, (_, frame) => ({ async function addLap(sid: number, versionId: number | null, lapTime: number, opts: { valid?: boolean; excluded?: boolean; packetCount?: number; carSetup?: string | null } = {}): Promise { const raceId = await insertSession("1", "2", experimentGames.get(sid) ?? "acc"); raceSessionIds.push(raceId); + const packetCount = opts.packetCount ?? 30; + await updateSessionRawFile(raceId, `/virtual/branch-${raceId}.bin`, "test"); const row = await db.insert(laps).values({ sessionId: raceId, lapNumber: raceId, lapTime, isValid: opts.valid ?? true, - rawByteOffset: null, rawFrameCount: 0, experimentId: sid, experimentVersionId: versionId, + rawByteOffset: 12, rawFrameCount: packetCount, experimentId: sid, experimentVersionId: versionId, experimentExcluded: opts.excluded ? 1 : 0, carSetup: opts.carSetup ?? null, }).returning({ id: laps.id }).get(); const id = row.id; - if ((opts.packetCount ?? 30) > 0) { cacheSet(id, telemetry.slice(0, opts.packetCount ?? 30)); cachedLapIds.push(id); } + if (packetCount > 0) { cacheSet(id, telemetry.slice(0, packetCount)); cachedLapIds.push(id); } return id; } diff --git a/apps/backend/test/games/shared/semantic-replay-native.test.ts b/apps/backend/test/games/shared/semantic-replay-native.test.ts index 1b19d3338..64a1cb1b8 100644 --- a/apps/backend/test/games/shared/semantic-replay-native.test.ts +++ b/apps/backend/test/games/shared/semantic-replay-native.test.ts @@ -1,3 +1,4 @@ +import type { TelemetryPacket } from "@raceiq/shared/telemetry/types"; import { expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { readFileSync, unlinkSync } from "node:fs"; @@ -8,20 +9,21 @@ import type { LapReplaySource } from "@raceiq/backend-core/db/telemetry-replay-s import { cacheDelete, cacheSet } from "@raceiq/backend-core/db/telemetry-replay-storage"; import { initServerGameAdapters } from "../../../src/games/init"; import { getServerGame } from "@raceiq/backend-core/games/registry"; -import { IRacingSourceFrameEncoder, type IRacingSourceFrameV2 } from "@raceiq/capture-formats/iracing/source-frame"; +import { IRacingSourceFrameEncoder, type IRacingSourceFrameV3 } from "@raceiq/capture-formats/iracing/source-frame"; import { encodeMetaFrame, encodeParserBootstrapFrame, META_FRAME_MAGIC } from "@raceiq/capture-formats/session/framing"; -import { iterateIRacingNativeFramesForTest, queryLapTelemetryBySemanticId } from "@raceiq/backend-core/telemetry/replay"; +import { iterateIRacingNativeFramesForTest, queryLapTelemetryBySemanticId, resolveTelemetryReplay } from "@raceiq/backend-core/telemetry/replay"; import { initGameAdapters } from "@raceiq/game-catalogs/games/init"; import { canonicalizeTelemetryScalar } from "@raceiq/telemetry-core/telemetry/replay/canonicalize"; import { convertSeedFramesToSparse } from "@raceiq/tooling-data/data/seed-capture-conversion"; +import { attachImportedNativeValues } from "@raceiq/backend-core/session-capture/source-loader"; import { packet } from "@raceiq/telemetry-core/test-support/telemetry/resolver"; initGameAdapters(); initServerGameAdapters(); -function frame(sessionTick: number, sessionState: number): IRacingSourceFrameV2 { +function frame(sessionTick: number, sessionState: number): IRacingSourceFrameV3 { return { - schemaVersion: 2, + schemaVersion: 3, session: { sessionId: 202, subSessionId: 205, @@ -49,7 +51,15 @@ function frame(sessionTick: number, sessionState: number): IRacingSourceFrameV2 Lap: 1, LapDist: 100, LapDistPct: 0.02, + Lat: 52.123456, + Lon: -2.987654, + Alt: 143.25, + OilTemp: 80 + sessionTick, + Voltage: 12 + sessionTick / 10, + PlayerCarMyIncidentCount: sessionTick === 2 ? 3 : 0, }, + sessionInfo: "WeekendInfo:\n TrackLatitude: 52.5 m\n TrackLongitude: -2.5 m\n TrackAltitude: 140 m\n", + sessionInfoUpdate: 1, }; } @@ -144,7 +154,7 @@ test("native-values replay skips non-native raw capture identity loading", async await Bun.write(rawFile, Buffer.from("not gzip")); try { await updateSessionRawFile(sessionId, rawFile, "test-detector"); - lapId = await insertLap(sessionId, 1, 1, true, 0, 0, null, null, null, null, null); + lapId = await insertLap(sessionId, 1, 1, true, 0, 3, null, null, null, null, null); cacheSet(lapId, [ packet("f1-2025", { Speed: 10, TimestampMS: 1_000 }), packet("f1-2025", { Speed: 20, TimestampMS: 1_000 }), @@ -181,16 +191,21 @@ test("semantic replay aligns native session frames and hashes decompressed captu await updateSessionRawFile(sessionId, rawFile, "test-detector"); const lapId = await insertLap(sessionId, 1, 1, true, rawByteOffset, 2, null, null, null, null, null); - const replay = await queryLapTelemetryBySemanticId(lapId, ["session.session-state", "motion.speed"], { rawCaptureRequirement: "native-values" }); + const replay = await queryLapTelemetryBySemanticId(lapId, ["session.session-state", "motion.speed", "motion.latitude", "motion.longitude", "motion.altitude", "identity.track.latitude", "identity.track.longitude", "identity.track.altitude", "engine.oil-temperature", "engine.voltage", "race.player-incident-count"], { rawCaptureRequirement: "native-values" }); expect(replay?.envelopes).toHaveLength(2); expect(replay?.envelopes.map((envelope) => envelope.values.find((value) => value.semanticId === "session.session-state")?.value)).toEqual([2, 4]); + expect(replay?.envelopes.map((envelope) => envelope.values.find((value) => value.semanticId === "engine.oil-temperature")?.value)).toEqual([81, 82]); + expect(replay?.envelopes.map((envelope) => envelope.values.find((value) => value.semanticId === "engine.voltage")?.value)).toEqual([12.1, 12.2]); + expect(replay?.envelopes.map((envelope) => envelope.values.find((value) => value.semanticId === "race.player-incident-count")?.value)).toEqual([0, 3]); expect(replay?.envelopes[0].values).toEqual( expect.arrayContaining([ - expect.objectContaining({ - semanticId: "motion.speed", - value: 72.5, - state: "ok", - }), + expect.objectContaining({ semanticId: "motion.speed", value: 72.5, state: "ok" }), + expect.objectContaining({ semanticId: "motion.latitude", value: 52.123456, state: "ok" }), + expect.objectContaining({ semanticId: "motion.longitude", value: -2.987654, state: "ok" }), + expect.objectContaining({ semanticId: "motion.altitude", value: 143.25, state: "ok" }), + expect.objectContaining({ semanticId: "identity.track.latitude", value: 52.5, state: "ok" }), + expect.objectContaining({ semanticId: "identity.track.longitude", value: -2.5, state: "ok" }), + expect.objectContaining({ semanticId: "identity.track.altitude", value: 140, state: "ok" }), ]), ); const expectedHash = `sha256:${createHash("sha256").update(capture).digest("hex")}`; @@ -212,3 +227,66 @@ test("semantic replay aligns native session frames and hashes decompressed captu converted.cleanup(); } }); + +test("native semantic replay matches trimmed and appended frames and rejects ambiguous or absent identities", () => { + const encoder = new IRacingSourceFrameEncoder(); + const meta = Buffer.alloc(12); + meta.writeUInt32LE(META_FRAME_MAGIC, 0); + meta.writeUInt32LE(4, 4); + const native = [frame(1, 2), frame(2, 2), frame(3, 2), frame(3, 2), frame(4, 2)]; + native[0].values.OilTemp = 81; + native[1].values.OilTemp = 82; + native[2].values.OilTemp = 83; + native[3].values.OilTemp = 84; + native[4].values.OilTemp = 85; + native[0].values.Voltage = 12.1; + native[1].values.Voltage = 12.2; + native[2].values.Voltage = 12.3; + native[3].values.Voltage = 12.4; + native[4].values.Voltage = 12.5; + native[0].values.PlayerCarMyIncidentCount = 0; + native[1].values.PlayerCarMyIncidentCount = 1; + native[2].values.PlayerCarMyIncidentCount = 2; + native[3].values.PlayerCarMyIncidentCount = 3; + native[4].values.PlayerCarMyIncidentCount = 4; + const capture = Buffer.concat([meta, ...native.map((value) => record(encoder.encode(value)))]); + const source: LapReplaySource = { + id: 1, sessionId: 1, createdAt: "2026-08-04 00:00:00", gameId: "iracing", + rawFile: null, source: null, carId: "test-car", trackId: "test-track", rawByteOffset: meta.length, rawFrameCount: 4, captureFormatVersion: null, + }; + const retained = [2, 3, 4, 5].map((tick) => packet("iracing", { + sessionUID: "205:202:1", + iracing: { sessionTick: tick } as TelemetryPacket["iracing"], + CurrentRaceTime: tick / 60, + TimestampMS: 1_790_000_000_000 + tick * 50, + })); + const replay = resolveTelemetryReplay(1, source, retained, [ + "engine.oil-temperature", "engine.voltage", "race.player-incident-count", + ], { bytes: capture, contentHash: "test", storageEncoding: "identity" }); + expect(replay.envelopes[0].values.find((value) => value.semanticId === "engine.oil-temperature")?.value).toBe(82); + expect(replay.envelopes[0].values.find((value) => value.semanticId === "engine.voltage")?.value).toBe(12.2); + expect(replay.envelopes[0].values.find((value) => value.semanticId === "race.player-incident-count")?.value).toBe(1); + expect(replay.envelopes[1].values.find((value) => value.semanticId === "engine.oil-temperature")).toMatchObject({ value: null, state: "missing" }); + expect(replay.envelopes[1].values.find((value) => value.semanticId === "race.player-incident-count")).toMatchObject({ value: null, state: "missing" }); + expect(replay.envelopes[2].values.find((value) => value.semanticId === "engine.oil-temperature")?.value).toBe(85); + expect(replay.envelopes[2].values.find((value) => value.semanticId === "engine.voltage")?.value).toBe(12.5); + expect(replay.envelopes[2].values.find((value) => value.semanticId === "race.player-incident-count")?.value).toBe(4); + expect(replay.envelopes[3].values.find((value) => value.semanticId === "engine.oil-temperature")).toMatchObject({ value: null, state: "missing" }); +}); + +test("IBT semantic replay resolves original native values without BIN decoding", () => { + const source: LapReplaySource = { + id: 1, sessionId: 1, createdAt: "2026-08-04 00:00:00", gameId: "iracing", + rawFile: "source.ibt", source: "ibt", carId: "test-car", trackId: "test-track", rawByteOffset: 0, rawFrameCount: 2, captureFormatVersion: null, + }; + const packets = [1, 1].map((tick) => packet("iracing", { + sessionUID: "205:202:1", + iracing: { sessionTick: tick } as TelemetryPacket["iracing"], + CurrentRaceTime: tick / 60, + })); + attachImportedNativeValues(packets, [{ OilTemp: 81 }, { OilTemp: 82 }]); + const replay = resolveTelemetryReplay(1, source, packets, ["engine.oil-temperature"]); + expect(replay.envelopes.map((envelope) => + envelope.values.find((value) => value.semanticId === "engine.oil-temperature")?.value, + )).toEqual([81, 82]); +}); diff --git a/apps/backend/test/imports/iracing-ibt.test.ts b/apps/backend/test/imports/iracing-ibt.test.ts index 5a68b0b06..69a00feeb 100644 --- a/apps/backend/test/imports/iracing-ibt.test.ts +++ b/apps/backend/test/imports/iracing-ibt.test.ts @@ -7,15 +7,17 @@ import { and, eq } from "drizzle-orm"; import { existsSync, readFileSync, - rmSync, + readdirSync, } from "node:fs"; +import { basename } from "node:path"; +import { db } from "@raceiq/backend-core/db/index"; import { getDiscoveredCarName } from "@raceiq/backend-core/db/discovered-cars"; import { getDiscoveredTrackName } from "@raceiq/backend-core/db/discovered-tracks"; -import { db } from "@raceiq/backend-core/db/index"; -import { discoveredCars, discoveredTracks } from "@raceiq/backend-core/db/schema"; +import { resolveRecordingGameDirectory } from "@raceiq/backend-core/runtime/config/recording-directory"; +import { discoveredCars, discoveredTracks, sessions } from "@raceiq/backend-core/db/schema"; import { getLapsRaw } from "@raceiq/backend-core/db/lap-read-queries"; import { deleteSession } from "@raceiq/backend-core/db/session-queries"; -import { commitStagedIbt, stageIbtUpload } from "../../src/imports/iracing-ibt"; +import { cancelStagedIbt, commitStagedIbt, stageIbtUpload } from "../../src/imports/iracing-ibt"; import { previewIbtFile } from "@raceiq/game-iracing/ibt-preview"; import { initServerGameAdapters } from "../../src/games/init"; import { iracingAdapter } from "@raceiq/game-iracing-metadata/index"; @@ -52,7 +54,7 @@ describe("IRacingIbt import workflow", () => { } }); - test("commits a staged IBT through the normal pipeline and canonical recorder", async () => { + test("commits a staged IBT through the normal pipeline and stores original source", async () => { const importedIdentity: SyntheticIdentity = { trackId: 910_099, trackName: "Imported Raceway", @@ -76,13 +78,15 @@ describe("IRacingIbt import workflow", () => { let sessionId: number | null = null; let rawFile: string | null = null; + let filesBeforeImport: Set | null = null; try { const staged = await stageIbtUpload( body, "driven.ibt", bytes.byteLength, ); - expect(staged.token).not.toBeNull(); + const recordingDirectory = resolveRecordingGameDirectory("iracing"); + filesBeforeImport = new Set(existsSync(recordingDirectory) ? readdirSync(recordingDirectory) : []); expect(staged.preview.candidateLapCount).toBe(1); const imported = await commitStagedIbt(staged.token!); @@ -109,11 +113,17 @@ describe("IRacingIbt import workflow", () => { sessionId = imported.laps[0].sessionId; const [stored] = await getLapsRaw([imported.laps[0].lapId]); rawFile = stored?.rawFile ?? null; - expect(rawFile).toEndWith(".bin"); + expect(rawFile).toEndWith(".ibt"); expect(rawFile ? existsSync(rawFile) : false).toBe(true); + expect(rawFile ? readFileSync(rawFile).equals(bytes) : false).toBe(true); + const newFiles = readdirSync(resolveRecordingGameDirectory("iracing")).filter((file) => !filesBeforeImport?.has(file)); + expect(newFiles).toContain(basename(rawFile!)); + expect(newFiles.some((file) => file.endsWith(".bin"))).toBe(false); + const [session] = await db.select({ source: sessions.source }).from(sessions).where(eq(sessions.id, sessionId!)).all(); + expect(session?.source).toBe("ibt"); } finally { if (sessionId !== null) await deleteSession(sessionId); - if (rawFile) rmSync(rawFile, { force: true }); + expect(rawFile ? existsSync(rawFile) : false).toBe(false); await db .delete(discoveredCars) .where( @@ -150,4 +160,21 @@ describe("IRacingIbt import workflow", () => { recording.cleanup(); } }); + test("cancel removes staged IBT and prevents commit", async () => { + const recording = createRecording("cancel.ibt", drivenRows()); + try { + const bytes = readFileSync(recording.path); + const staged = await stageIbtUpload(new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }), "cancel.ibt", bytes.byteLength); + expect(staged.token).not.toBeNull(); + cancelStagedIbt(staged.token!); + await expect(commitStagedIbt(staged.token!)).rejects.toThrow("expired or was already used"); + } finally { + recording.cleanup(); + } + }); }); diff --git a/apps/backend/test/integration-files.txt b/apps/backend/test/integration-files.txt index 28f2855f7..651424700 100644 --- a/apps/backend/test/integration-files.txt +++ b/apps/backend/test/integration-files.txt @@ -105,6 +105,7 @@ apps/backend/test/runtime/http-server.test.ts apps/backend/test/runtime/dashboard-auth.test.ts apps/backend/test/runtime/dump-only.test.ts apps/backend/test/runtime/http-access-compiled.test.ts +apps/backend/test/runtime/iracing-capture.test.ts apps/backend/test/runtime/native-sources.test.ts apps/backend/test/runtime/release-feature-flags.test.ts apps/backend/test/runtime/runtime-options.test.ts @@ -136,6 +137,7 @@ apps/backend/test/session-capture/session-downsample-rewrite.test.ts apps/backend/test/session-capture/session-downsample-routes.test.ts apps/backend/test/session-capture/session-compressor.test.ts apps/backend/test/session-capture/session-recorder.test.ts +apps/backend/test/routes/iracing-incident-report.test.ts apps/backend/test/session-capture/bundle-consumers.test.ts apps/backend/test/session-capture/split-lifecycle.test.ts apps/backend/test/session-capture/live-capture-boundaries.test.ts diff --git a/apps/backend/test/lap-analysis/lap-export-import-roundtrip.test.ts b/apps/backend/test/lap-analysis/lap-export-import-roundtrip.test.ts index b06467ce9..37b3b618d 100644 --- a/apps/backend/test/lap-analysis/lap-export-import-roundtrip.test.ts +++ b/apps/backend/test/lap-analysis/lap-export-import-roundtrip.test.ts @@ -44,6 +44,8 @@ import { initMotecTargets } from "../../src/games/motec-init"; import { decodeLMUSourceFrame, encodeLMUSourceFrame, LMU_MAX_SOURCE_FRAME_SIZE } from "@raceiq/capture-formats/lmu/source-frame"; import { LMU_SHARED_MEMORY_SIZE, LMU_GAME_VERSION_OFFSET, LMU_SESSION_EVENT_OFFSET, LMU_TELEMETRY_HEADER_OFFSET, LMU_TELEMETRY_INFO_OFFSET, LMU_TELEMETRY, LMU_SCORING_INFO, LMU_SCORING_INFO_OFFSET, LMU_SCORING_VEHICLE, LMU_SCORING_VEHICLES_OFFSET } from "@raceiq/capture-formats/lmu/layout"; import type { LmuLiveContext } from "@raceiq/shared/telemetry/lmu"; +import { stageIbtUpload, commitStagedIbt } from "../../src/imports/iracing-ibt"; +import { createRecording, drivenRows } from "@raceiq/game-iracing/test-support/games/iracing-ibt"; import { encodeLmuContext } from "@raceiq/shared/telemetry/lmu-context"; import { importSessionBin } from "@raceiq/backend-core/session-capture/import-capture"; import { readLmuOpponentCaptureFrames, indexLmuOpponentLaps } from "@raceiq/backend-core/lap-analysis/lmu-opponent-laps"; @@ -179,6 +181,42 @@ describe("lap export → import round-trip (real capture)", () => { const match = result.laps.find((l) => Math.abs(l.lapTime - exported.lapTime) < 0.001); expect(match).toBeDefined(); }, 120000); + test("IBT ZIP export and import retain original source bytes", async () => { + const recording = createRecording("archive-source.ibt", drivenRows()); + try { + const sourceBytes = readFileSync(recording.path); + const staged = await stageIbtUpload(new ReadableStream({ + start(controller) { + controller.enqueue(sourceBytes); + controller.close(); + }, + }), "archive-source.ibt", sourceBytes.byteLength); + expect(staged.token).not.toBeNull(); + const imported = await commitStagedIbt(staged.token!); + createdSessions.push(...new Set(imported.laps.map((lap) => lap.sessionId))); + const session = await db.select({ rawFile: sessions.rawFile }).from(sessions) + .where(eq(sessions.id, imported.laps[0]!.sessionId)).get(); + if (session?.rawFile) tmpFiles.push(session.rawFile); + + const exported = await buildLapsZip([imported.laps[0]!.lapId]); + const members = unzipSync(exported.bytes); + const ibtName = Object.keys(members).find((name) => name.toLowerCase().endsWith(".ibt")); + expect(ibtName).toBeDefined(); + expect(Buffer.from(members[ibtName!]!)).toEqual(sourceBytes); + + const roundTrip = await importLapsZip(exported.bytes); + expect(roundTrip.errors).toEqual([]); + expect(roundTrip.imported).toBeGreaterThan(0); + createdSessions.push(...new Set(roundTrip.laps.map((lap) => lap.sessionId))); + for (const sessionId of new Set(roundTrip.laps.map((lap) => lap.sessionId))) { + const row = await db.select({ rawFile: sessions.rawFile }).from(sessions) + .where(eq(sessions.id, sessionId)).get(); + if (row?.rawFile) tmpFiles.push(row.rawFile); + } + } finally { + recording.cleanup(); + } + }, 120000); for (const gameId of KNOWN_GAME_IDS) { const { capture, label } = GAME_CAPTURES[gameId]; diff --git a/apps/backend/test/routes/iracing-incident-report.test.ts b/apps/backend/test/routes/iracing-incident-report.test.ts new file mode 100644 index 000000000..a975141cf --- /dev/null +++ b/apps/backend/test/routes/iracing-incident-report.test.ts @@ -0,0 +1,220 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { db } from "@raceiq/backend-core/db/index"; +import { laps, sessions } from "@raceiq/backend-core/db/schema"; +import { getLapById } from "@raceiq/backend-core/db/lap-read-queries"; +import { iracingRoutes } from "../../src/routes/games/iracing"; +import { createIRacingIncidentFixture, createIRacingIncidentMainFixture, type IRacingIncidentFixture, type IRacingIncidentFixtureRecord } from "../support/iracing-incident-fixture"; +import { initGameAdapters } from "@raceiq/game-catalogs/games/init"; +import { initServerGameAdapters } from "../../src/games/init"; + +initGameAdapters({ iracingAdapter: true, f1Experiments: true, ams2Adapter: false }); +initServerGameAdapters({ iracingAdapter: true, f1Experiments: true, ams2Adapter: false }); + +const sessionIds: number[] = []; +const tempDirs: string[] = []; +afterEach(async () => { + for (const sessionId of sessionIds.splice(0)) { + await db.delete(laps).where(eq(laps.sessionId, sessionId)).run(); + await db.delete(sessions).where(eq(sessions.id, sessionId)).run(); + } + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +interface WindowInput { lapId: number; lapNumber: number; rawByteOffset: number; rawFrameCount: number; isValid?: boolean; lapTime?: number; invalidReason?: string } +async function seed(fixture: IRacingIncidentFixture | null, options: { gameId?: string; source?: string; windows?: WindowInput[]; malformedTail?: Buffer; sharedRawFile?: string } = {}) { + const dir = await mkdtemp(join(tmpdir(), "raceiq-iracing-incidents-")); + tempDirs.push(dir); + const rawFile = fixture ? options.sharedRawFile ?? join(dir, "capture.bin") : options.sharedRawFile ?? null; + if (fixture && !options.sharedRawFile) await writeFile(rawFile!, options.malformedTail ? Buffer.concat([fixture.bytes, options.malformedTail]) : fixture.bytes); + else if (fixture && options.malformedTail) await writeFile(rawFile!, Buffer.concat([fixture.bytes, options.malformedTail])); + const inserted = await db.insert(sessions).values({ gameId: options.gameId ?? "iracing", carId: "incident-fixture-car", trackId: "incident-fixture-track", source: options.source ?? null, rawFile }) + .returning({ id: sessions.id }).get(); + const sessionId = inserted!.id; + sessionIds.push(sessionId); + const windows: WindowInput[] = options.windows ?? fixture?.windows.map((w, i) => ({ lapId: w.lapId, lapNumber: i + 1, rawByteOffset: w.rawByteOffset, rawFrameCount: w.rawFrameCount })) ?? []; + const lapIds: number[] = []; + for (const window of windows) { + const lap = await db.insert(laps).values({ sessionId, lapNumber: window.lapNumber, lapTime: window.lapTime ?? 40, isValid: window.isValid ?? true, + invalidReason: window.invalidReason ?? null, rawByteOffset: window.rawByteOffset, rawFrameCount: window.rawFrameCount }) + .returning({ id: laps.id }).get(); + lapIds.push(lap!.id); + } + return { sessionId, lapIds, windows }; +} +function sample(lapId: number, index: number, values: Record> = {}, extra: Record = {}): IRacingIncidentFixtureRecord { + return { kind: "sample", sample: { lapId, values: { SessionTime: 50 + index * 0.05, SessionTick: 1000 + index, Lap: lapId, + LapCurrentLapTime: index * 0.05, LapLastLapTime: 0, PlayerCarIdx: 0, PlayerCarMyIncidentCount: 0, PlayerTrackSurface: 3, Speed: 20, ...values }, ...extra } }; +} +const report = (id: number) => iracingRoutes.request(`/api/iracing/sessions/${id}/incidents`); + +describe("iRacing incident report endpoint", () => { + test("isolates lap windows in gzip shared capture and resolves trimmed replay cursor with simulator time", async () => { + const records: IRacingIncidentFixtureRecord[] = []; + for (let i = 0; i < 2; i++) records.push(sample(10, i, { SessionTime: 60 + i * .05, SessionTick: 1200 + i, Lap: 0, LapCurrentLapTime: 20 + i * .05 }, { owned: false, frameTimeMs: 1_790_000_000_000 + i * 50 })); + for (let i = 0; i < 8; i++) records.push(sample(11, i, { SessionTime: 60.1 + i * .05, SessionTick: 1300 + i, Lap: 1, LapCurrentLapTime: i * .05, + PlayerCarMyIncidentCount: i >= 2 ? 1 : 0, Speed: i === 2 ? 41.234 : 30 + i }, { frameTimeMs: 1_790_000_000_000 + (i + 2) * 50 })); + records.push(sample(12, 10, { SessionTime: 60.5, SessionTick: 1308, Lap: 2, LapCurrentLapTime: 0, LapLastLapTime: .4 }, { owned: false, frameTimeMs: 1_790_000_000_000 + 500 })); + const fixture = createIRacingIncidentFixture({ records, gzip: true }); + const seeded = await seed(fixture, { windows: [{ lapId: 11, lapNumber: 4, rawByteOffset: fixture.samples[0]!.rawOffset, rawFrameCount: 10, isValid: false }] }); + const response = await report(seeded.sessionId); + expect(response.status).toBe(200); + const body = await response.json() as { observedIncidentPoints: number | null; events: Array<{ kind: string; replay: { frameIndex: number; lapTimeSec: number | null } | null }> }; + expect(body.observedIncidentPoints).toBe(1); + expect(body.events).toHaveLength(1); + expect(body.events[0]!.kind).toBe("incident-points"); + expect(body.events[0]!.replay).not.toBeNull(); + const replay = await getLapById(seeded.lapIds[0]!); + expect(replay).toBeTruthy(); + expect(replay!.telemetry[body.events[0]!.replay!.frameIndex]!.Speed).toBe(41.234); + }); + + test("reports personal count increases and sampled spans from owned windows only", async () => { + const seeded = await seed(createIRacingIncidentMainFixture()); + const response = await report(seeded.sessionId); + expect(response.status).toBe(200); + const body = await response.json() as { observedIncidentPoints: number | null; offTrackSpans: number | null; events: Array<{ kind: string; points?: number }> }; + expect(body.observedIncidentPoints).toBe(3); + expect(body.offTrackSpans).toBe(2); + expect(body.events.filter((e) => e.kind === "incident-points").map((e) => e.points)).toEqual([1, 2]); + expect(body.events).toHaveLength(4); + }); + + test("isolates second session sharing source path from foreign lap frames", async () => { + const fixture = createIRacingIncidentMainFixture(); + const first = await seed(fixture); + const sharedRawFile = (await db.select({ rawFile: sessions.rawFile }).from(sessions).where(eq(sessions.id, first.sessionId)).get())!.rawFile!; + const second = await seed(fixture, { sharedRawFile, windows: [{ lapId: 99, lapNumber: 7, rawByteOffset: fixture.windows[1]!.rawByteOffset, rawFrameCount: fixture.windows[1]!.rawFrameCount }] }); + const body = await (await report(second.sessionId)).json(); + expect(body.coverage.totalLaps).toBe(1); + expect(body.observedIncidentPoints).toBe(2); + expect(body.events.every((event: { lapId: number }) => event.lapId === second.lapIds[0])).toBe(true); + }); + + test("leaves missing or ambiguous retained replay matches without targets", async () => { + const records = [sample(1, 0, { PlayerCarMyIncidentCount: 0 }), sample(1, 1, { PlayerCarMyIncidentCount: 1, SessionTime: 50, SessionTick: 1000 }), + sample(1, 2, { PlayerCarMyIncidentCount: 2, SessionTime: 50, SessionTick: 1000 })]; + const fixture = createIRacingIncidentFixture({ records }); + const window = fixture.windows[0]!; + const seeded = await seed(fixture, { windows: [{ lapId: 1, lapNumber: 1, rawByteOffset: window.rawByteOffset, rawFrameCount: window.rawFrameCount }] }); + const body = await (await report(seeded.sessionId)).json(); + expect(body.events.filter((event: { kind: string }) => event.kind === "incident-points").every((event: { replay: unknown }) => event.replay === null)).toBe(true); + }); + + test("ignores metadata/context as observations and recovers after malformed frame", async () => { + const fixture = createIRacingIncidentFixture({ records: [ + sample(1, 0, { PlayerCarMyIncidentCount: 0 }, { owned: false }), + { kind: "malformed", bytes: Buffer.from([4, 0, 0, 0, 1, 2, 3, 4]) }, + sample(1, 1, { PlayerCarMyIncidentCount: 1 }, { owned: false }), + { kind: "segment-boundary" }, + { kind: "sample", sample: { context: true, values: { SessionTime: 50.1, SessionTick: 1002, PlayerCarMyIncidentCount: 50, PlayerTrackSurface: 0 } } }, + sample(1, 3, { PlayerCarMyIncidentCount: 2 }, { owned: false }), + sample(1, 4, { PlayerCarMyIncidentCount: 3 }, { owned: false }), + ], metadataFrames: 2 }); + const seeded = await seed(fixture, { windows: [{ lapId: 1, lapNumber: 1, rawByteOffset: fixture.samples[0]!.rawOffset, rawFrameCount: fixture.samples.length + 1 }] }); + const response = await report(seeded.sessionId); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.observedIncidentPoints).toBe(1); + expect(body.events.filter((event: { kind: string }) => event.kind === "incident-points").map((event: { points: number }) => event.points)).toEqual([1]); + }); + + test("does not fall back from absent personal count to flags or team counter", async () => { + const records = [0, 1, 2].map((index) => { + const record = sample(1, index, { PlayerIncidents: 9, PlayerCarTeamIncidentCount: 20 + index }); + if (record.kind === "sample") delete record.sample.values.PlayerCarMyIncidentCount; + return record; + }); + const seeded = await seed(createIRacingIncidentFixture({ records })); + const body = await (await report(seeded.sessionId)).json(); + expect(body.observedIncidentPoints).toBeNull(); + expect(body.coverage.incidentStatus).toBe("unavailable"); + expect(body.offTrackSpans).toBe(0); + }); + + test("fails when malformed tail is required by saved lap window", async () => { + const fixture = createIRacingIncidentFixture({ records: [sample(1, 0), sample(1, 1)] }); + const window = fixture.windows[0]!; + const required = await seed(fixture, { malformedTail: Buffer.from([0xff, 0xff, 0xff, 0xff]), windows: [ + { lapId: 1, lapNumber: 1, rawByteOffset: window.rawByteOffset, rawFrameCount: window.rawFrameCount + 1 }, + ] }); + expect((await report(required.sessionId)).status).toBe(500); + }); + + + test("returns unavailable evidence for absent source, MoTeC archive, missing windows and missing file", async () => { + const absent = await seed(null); + expect((await (await report(absent.sessionId)).json()).coverage.reasons).toContain("capture-unavailable"); + const motec = await seed(null, { source: "motec" }); + expect((await (await report(motec.sessionId)).json()).coverage.reasons).toContain("unsupported-capture"); + const fixture = createIRacingIncidentMainFixture(); + const missingWindow = await seed(fixture, { windows: [{ lapId: 1, lapNumber: 1, rawByteOffset: fixture.decompressedBytes.length + 50, rawFrameCount: 2 }] }); + expect((await (await report(missingWindow.sessionId)).json()).coverage.reasons).toContain("missing-lap-window"); + const missingFile = await seed(fixture); + await rm(join(tempDirs[tempDirs.length - 1]!, "capture.bin")); + expect((await (await report(missingFile.sessionId)).json()).coverage.reasons).toContain("capture-unavailable"); + }); + + test("marks short, overlapping and misaligned windows partial", async () => { + const fixture = createIRacingIncidentMainFixture(); + const first = fixture.windows[0]!; + const short = await seed(fixture, { windows: [{ lapId: 1, lapNumber: 1, rawByteOffset: first.rawByteOffset, rawFrameCount: fixture.samples.length + 10 }] }); + expect((await (await report(short.sessionId)).json()).coverage.reasons).toContain("missing-lap-window"); + const overlap = await seed(fixture, { windows: [ + { lapId: 1, lapNumber: 1, rawByteOffset: first.rawByteOffset, rawFrameCount: 2 }, + { lapId: 2, lapNumber: 2, rawByteOffset: first.rawByteOffset, rawFrameCount: 2 }, + ] }); + expect((await (await report(overlap.sessionId)).json()).coverage.reasons).toContain("missing-lap-window"); + const misaligned = await seed(fixture, { windows: [{ lapId: 1, lapNumber: 1, rawByteOffset: first.rawByteOffset + 1, rawFrameCount: 2 }] }); + expect((await (await report(misaligned.sessionId)).json()).coverage.reasons).toContain("missing-lap-window"); + }); + + test("keeps clean-streak eligibility separate and preserves surface evidence when personal counter is absent", async () => { + const records: IRacingIncidentFixtureRecord[] = []; + for (let lap = 1; lap <= 4; lap++) for (let i = 0; i < 3; i++) records.push(sample(lap, (lap - 1) * 3 + i, { PlayerCarMyIncidentCount: 0, PlayerTrackSurface: i === 1 ? 0 : 3, + PlayerIncidents: 99, PlayerCarTeamIncidentCount: 100 })); + const fixture = createIRacingIncidentFixture({ records }); + const windows = fixture.windows.map((w, i) => ({ lapId: w.lapId, lapNumber: i + 1, rawByteOffset: w.rawByteOffset, rawFrameCount: w.rawFrameCount, + isValid: i !== 1, invalidReason: i === 2 ? "outlap" : undefined })); + const eligible = await seed(fixture, { windows }); + const body = await (await report(eligible.sessionId)).json(); + expect(body.coverage.surfaceStatus).not.toBe("unavailable"); + expect(body.offTrackSpans).toBe(4); + expect(body.observedIncidentPoints).toBe(0); + expect(body.longestCleanLapStreak).toBe(1); + + const missingCounter = [sample(21, 0), sample(21, 1, { PlayerTrackSurface: 0 }), sample(21, 2, { PlayerTrackSurface: 3 })]; + for (const record of missingCounter) if (record.kind === "sample") delete record.sample.values.PlayerCarMyIncidentCount; + const surfaceOnly = await seed(createIRacingIncidentFixture({ records: missingCounter })); + const independent = await (await report(surfaceOnly.sessionId)).json(); + expect(independent.observedIncidentPoints).toBeNull(); + expect(independent.offTrackSpans).toBe(1); + }); + + test("decodes context, stops before malformed unowned tail and fails on malformed owned frames", async () => { + const fixture = createIRacingIncidentMainFixture(); + expect((await report((await seed(fixture, { malformedTail: Buffer.from([0xff, 0xff, 0xff, 0xff]) })).sessionId)).status).toBe(200); + const malformed = createIRacingIncidentFixture({ records: [sample(1, 0), { kind: "malformed" }, sample(1, 2)] }); + const failed = await report((await seed(malformed)).sessionId); + expect(failed.status).toBe(500); + expect(JSON.stringify(await failed.json())).not.toContain("capture.bin"); + const context = createIRacingIncidentFixture({ records: [ + { kind: "sample", sample: { owned: false, values: { SessionTime: 50, SessionTick: 1000, PlayerCarMyIncidentCount: 0, PlayerTrackSurface: 3 } } }, + { kind: "segment-boundary" }, { kind: "segment-context" }, + { kind: "sample", sample: { context: true, values: { SessionTime: 50.1, SessionTick: 1002, PlayerCarMyIncidentCount: 0, PlayerTrackSurface: 3 } } }, + { kind: "segment-context-end" }, sample(2, 2), + ] }); + expect((await report((await seed(context)).sessionId)).status).toBe(200); + }); + + test("returns 404 for wrong-game and absent sessions before capture access", async () => { + const wrong = await seed(null, { gameId: "acc" }); + const response = await report(wrong.sessionId); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Session not found" }); + expect((await report(2_000_000_000)).status).toBe(404); + }); +}); diff --git a/apps/backend/test/routes/lap-analysis-route.test.ts b/apps/backend/test/routes/lap-analysis-route.test.ts index 4c2b25553..e737a08e2 100644 --- a/apps/backend/test/routes/lap-analysis-route.test.ts +++ b/apps/backend/test/routes/lap-analysis-route.test.ts @@ -1,5 +1,9 @@ +import type { TelemetryPacket } from "@raceiq/shared/telemetry/types"; import { describe, expect, test } from "bun:test"; +import { analyseSemanticIds } from "@raceiq/shared/games/metric-contracts"; +import { getGame } from "@raceiq/shared/games/registry"; +import { semanticReplayIds } from "@raceiq/lap-replay/server"; import { initGameAdapters } from "@raceiq/game-catalogs/games/init"; import { decodeAlignedLapSet } from "@raceiq/analysis-core/racing/laps/alignment/codec"; import type { EncodedAlignedLapSet } from "@raceiq/analysis-core/racing/laps/alignment/types"; @@ -9,8 +13,97 @@ import { cacheDelete, cacheSet } from "@raceiq/backend-core/db/telemetry-replay- import { insertLap } from "@raceiq/backend-core/db/lap-mutation-queries"; import { lapRoutes } from "../../src/routes/laps/index"; import { packet } from "@raceiq/telemetry-core/test-support/telemetry/resolver"; + initGameAdapters(); +test("semantic replay requests F1 Analyse dependencies", () => { + const ids = semanticReplayIds("f1-2025"); + expect(ids).toEqual(analyseSemanticIds(getGame("f1-2025"))); + expect(ids).toContain("timing.current-lap"); + expect(ids).toContain("timing.lap-fraction"); + expect(ids).toContain("tire.temperature.surface.representative"); + expect(ids).toContain("tire.temperature.surface.inner"); + expect(ids).toContain("tire.temperature.surface.middle"); + expect(ids).toContain("tire.temperature.surface.outer"); + expect(ids).toContain("tire.temperature.core"); + expect(ids).not.toContain("tires.wheel-in-puddle-depth"); +}); + +describe("GET /api/laps/:id/semantic-telemetry", () => { + test("returns full F1 Analyse replay with adapter channels", async () => { + const sessionId = await insertSession("0", "0", "f1-2025"); + await updateSessionRawFile(sessionId, "/virtual/f1-replay.bin", "test"); + const lapId = await insertLap(sessionId, 1, 80.9, true, 12, 2, null, null, null, null, null); + cacheSet(lapId, [ + packet("f1-2025", { TimestampMS: 1_000, Speed: 10 }), + packet("f1-2025", { TimestampMS: 1_017, Speed: 20 }), + ]); + try { + const response = await lapRoutes.request(`/api/laps/${lapId}/semantic-telemetry`, { + headers: { "X-Game-Id": "f1-2025" }, + }); + + expect(response.status).toBe(200); + const body = await response.json() as { + lapId: number; + requestedSemanticIds: readonly string[]; + envelopes: { sequence: number; values: { semanticId: string }[] }[]; + parseError: string | null; + }; + expect(body.lapId).toBe(lapId); + expect(body.parseError).toBeNull(); + expect(body.requestedSemanticIds).toEqual(analyseSemanticIds(getGame("f1-2025"))); + expect(body.envelopes).toHaveLength(2); + expect(body.envelopes.map((envelope) => envelope.sequence)).toEqual([0, 1]); + expect(body.envelopes[0]?.values).toEqual( + expect.arrayContaining([expect.objectContaining({ semanticId: "motion.speed", value: 10 })]), + ); + expect(body.envelopes[1]?.values).toEqual( + expect.arrayContaining([expect.objectContaining({ semanticId: "motion.speed", value: 20 })]), + ); + } finally { + cacheDelete(lapId); + await deleteSession(sessionId); + } + }); +}); + +describe("GET /api/laps/:id/semantic-telemetry?view=track-map", () => { + test("returns compact iRacing GPS and progress samples", async () => { + const sessionId = await insertSession("0", "0", "iracing"); + await updateSessionRawFile(sessionId, "/virtual/iracing-replay.bin", "test"); + const lapId = await insertLap(sessionId, 1, 80.9, true, 12, 2, null, null, null, null, null); + cacheSet(lapId, [0.25, 0.75].map((lapDistancePct, index) => packet("iracing", { + iracing: { lapDistancePct, sessionTick: index + 1 } as TelemetryPacket["iracing"], + CurrentRaceTime: index, + TimestampMS: index * 1_000, + }))); + try { + const response = await lapRoutes.request(`/api/laps/${lapId}/semantic-telemetry?view=track-map`, { + headers: { "X-Game-Id": "iracing" }, + }); + expect(response.status).toBe(200); + const text = await response.text(); + const body = JSON.parse(text) as { + lapId: number; + trackOrigin: { latitude: number; longitude: number } | null; + samples: { latitude: number | null; longitude: number | null; fraction: number | null }[]; + envelopes?: unknown; + requestedSemanticIds?: unknown; + }; + expect(body.lapId).toBe(lapId); + expect(body.trackOrigin).toBeNull(); + expect(body.samples.map((sample) => sample.fraction)).toEqual([0.25, 0.75]); + expect(body.samples.every((sample) => sample.latitude === null && sample.longitude === null)).toBe(true); + expect(body).not.toHaveProperty("envelopes"); + expect(body).not.toHaveProperty("requestedSemanticIds"); + expect(text.length).toBeLessThan(2_000); + } finally { + cacheDelete(lapId); + await deleteSession(sessionId); + } + }); +}); describe("GET /api/laps/review", () => { test("returns only top valid metadata laps for a track/car", async () => { @@ -82,8 +175,9 @@ describe("GET /api/laps/compare-line-spread", () => { describe("POST /api/laps/aligned-telemetry", () => { test("preserves requested order, wheel wear, sectors, and base cache identity", async () => { const sessionId = await insertSession("10", "20", "acc"); - const lapA = await insertLap(sessionId, 1, 61, true, null, 0, null, null, null, [20, 21, 20], null); - const lapB = await insertLap(sessionId, 2, 60, true, null, 0, null, null, null, [19, 21, 20], null); + await updateSessionRawFile(sessionId, "/virtual/alignment-capture.bin", "test"); + const lapA = await insertLap(sessionId, 1, 61, true, 12, 3, null, null, null, [20, 21, 20], null); + const lapB = await insertLap(sessionId, 2, 60, true, 1_012, 3, null, null, null, [19, 21, 20], null); const telemetry = (offset: number) => [0, 1, 2].map((distance, index) => packet("acc", { DistanceTraveled: distance, CurrentLap: index, diff --git a/apps/backend/test/routes/layout-migration.test.ts b/apps/backend/test/routes/layout-migration.test.ts index 86311a6ff..2c99225d1 100644 --- a/apps/backend/test/routes/layout-migration.test.ts +++ b/apps/backend/test/routes/layout-migration.test.ts @@ -301,7 +301,7 @@ describe("recording layout migration", () => { expect((await migration).status).toBe(202); expect(await waitForMigration()).toMatchObject({ status: "success", error: null }); }); - test("rejects a symlinked migration journal without reading its external target", async () => { + test("rejects a linked migration journal without reading its external target", async () => { const root = await mkdtemp(join(tmpdir(), "raceiq-layout-journal-root-")); const external = await mkdtemp(join(tmpdir(), "raceiq-layout-journal-external-")); temporary.push(root, external); @@ -310,7 +310,7 @@ describe("recording layout migration", () => { const journalPath = join(resolveDataDir(), "recording-layout-migration.json"); const contents = JSON.stringify({ outside: true }); await writeFile(target, contents); - await symlink(target, journalPath); + await symlink(process.platform === "win32" ? external : target, journalPath, process.platform === "win32" ? "junction" : "file"); const response = await miscRoutes.request("/api/storage/layout-migration"); expect((await response.json() as { progress: { status: string; error: string | null } }).progress).toMatchObject({ status: "error" }); expect(await readFile(target, "utf8")).toBe(contents); diff --git a/apps/backend/test/routes/recording-directory.test.ts b/apps/backend/test/routes/recording-directory.test.ts index fe65dd7ac..a12a22902 100644 --- a/apps/backend/test/routes/recording-directory.test.ts +++ b/apps/backend/test/routes/recording-directory.test.ts @@ -225,7 +225,7 @@ describe("recording-directory relocation", () => { await mkdir(destination); await mkdir(outside); await writeFile(capture, bytes); - await symlink(outside, join(destination, "test-game")); + await symlink(outside, join(destination, "test-game"), "junction"); saveSettings({ ...loadSettings(), recordingDirectory: source }); await startRecordingDirectoryMove(destination); @@ -340,26 +340,35 @@ describe("recording-directory relocation", () => { await mkdir(join(externalDirectory, "nested"), { recursive: true }); await writeFile(externalFile, bytes); await writeFile(externalCapture, bytes); - await symlink(externalFile, fileLink); - await symlink(externalDirectory, directoryLink); + let fileSymlinkSupported = true; + try { + await symlink(externalFile, fileLink); + } catch (error) { + if (process.platform !== "win32" || !(error instanceof Error) || !("code" in error) || error.code !== "EPERM") throw error; + fileSymlinkSupported = false; + } + await symlink(externalDirectory, directoryLink, "junction"); saveSettings({ ...loadSettings(), recordingDirectory: source }); try { - const fileSessionId = await insertSession("symlink-file-car", "track", "fm-2023"); + const fileSessionId = fileSymlinkSupported ? await insertSession("symlink-file-car", "track", "fm-2023") : undefined; const directorySessionId = await insertSession("symlink-dir-car", "track", "fm-2023"); - sessionIds.push(fileSessionId, directorySessionId); - await db.update(sessions).set({ rawFile: fileLink }).where(eq(sessions.id, fileSessionId)).run(); + if (fileSessionId !== undefined) sessionIds.push(fileSessionId); + sessionIds.push(directorySessionId); + if (fileSessionId !== undefined) await db.update(sessions).set({ rawFile: fileLink }).where(eq(sessions.id, fileSessionId)).run(); await db.update(sessions).set({ rawFile: join(directoryLink, "nested", "capture.bin") }).where(eq(sessions.id, directorySessionId)).run(); await startRecordingDirectoryMove(destination); await waitForMove(); - const fileSession = await db.select().from(sessions).where(eq(sessions.id, fileSessionId)).get(); + if (fileSessionId !== undefined) { + const fileSession = await db.select().from(sessions).where(eq(sessions.id, fileSessionId)).get(); + expect(fileSession?.rawFile).toBe(fileLink); + expect(await readFile(fileLink)).toEqual(bytes); + await expect(readFile(join(destination, "test-game", "file-link.bin"))).rejects.toMatchObject({ code: "ENOENT" }); + } const directorySession = await db.select().from(sessions).where(eq(sessions.id, directorySessionId)).get(); - expect(fileSession?.rawFile).toBe(fileLink); expect(directorySession?.rawFile).toBe(join(directoryLink, "nested", "capture.bin")); - expect(await readFile(fileLink)).toEqual(bytes); expect(await readFile(join(directoryLink, "nested", "capture.bin"))).toEqual(bytes); - await expect(readFile(join(destination, "test-game", "file-link.bin"))).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(join(destination, "test-game", "directory-link", "nested", "capture.bin"))).rejects.toMatchObject({ code: "ENOENT" }); } finally { for (const id of sessionIds) await deleteSession(id); @@ -456,7 +465,7 @@ describe("recording-directory relocation", () => { await expect(readFile(target)).rejects.toMatchObject({ code: "ENOENT" }); await mkdir(dirname(target), { recursive: true }); await writeFile(target, bytes); - const targetInfo = await lstat(target); + const targetInfo = await lstat(target, { bigint: true }); await writeFile(journalPath, JSON.stringify({ ...journal, entries: [{ source: capture, destination: target, size: bytes.length, sha256: digest, published: false, publication: { dev: String(targetInfo.dev), ino: String(targetInfo.ino) } }] })); await recoverRecordingDirectoryMove(); await expect(readFile(target)).rejects.toMatchObject({ code: "ENOENT" }); diff --git a/apps/backend/test/routes/storage-routes.test.ts b/apps/backend/test/routes/storage-routes.test.ts index c128c077f..df63e5182 100644 --- a/apps/backend/test/routes/storage-routes.test.ts +++ b/apps/backend/test/routes/storage-routes.test.ts @@ -48,7 +48,7 @@ describe("session storage stats", () => { await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, Buffer.from("file")); } - await symlink(externalDir, join(root, gameId, "own", "linked-dir"), "dir"); + await symlink(externalDir, join(root, gameId, "own", "linked-dir"), "junction"); const response = await miscRoutes.request("/api/storage/sessions"); const body = await response.json() as { byGame: Record; diff --git a/apps/backend/test/runtime/database-write-coordination.test.ts b/apps/backend/test/runtime/database-write-coordination.test.ts index 31a93d73b..fcce300bf 100644 --- a/apps/backend/test/runtime/database-write-coordination.test.ts +++ b/apps/backend/test/runtime/database-write-coordination.test.ts @@ -1,8 +1,8 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { rm } from "node:fs/promises"; +import { afterEach, describe, expect } from "bun:test"; import { createClient, type Client, type Transaction } from "@libsql/client/sqlite3"; import type { TransactionMode } from "@libsql/client"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; import { join } from "node:path"; import { configureDatabaseWriteWorkerTransport, @@ -13,6 +13,9 @@ import { type WriteLeaseRequest, type WriteLeaseResponse, } from "@raceiq/backend-core/db/write-coordination"; +import { nativeDatabaseTest } from "../support/native-database-test"; + +const test = nativeDatabaseTest(import.meta.path); function deferred() { let resolve!: (value: T) => void; @@ -226,7 +229,7 @@ describe("worker-side admission", () => { }); async function databaseFixture() { - const directory = await mkdtemp(join(tmpdir(), "raceiq-write-resource-")); + const directory = await mkdtemp(join(process.env.RACEIQ_NATIVE_DB_ROOT!, "write-resource-")); const client = createClient({ url: `file:${join(directory, "test.db")}` }); await client.executeMultiple("PRAGMA foreign_keys=ON; CREATE TABLE entries(id INTEGER PRIMARY KEY); CREATE TABLE parents(id INTEGER PRIMARY KEY); CREATE TABLE children(parent_id INTEGER REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED);"); return { client, async close() { diff --git a/apps/backend/test/runtime/iracing-capture.test.ts b/apps/backend/test/runtime/iracing-capture.test.ts new file mode 100644 index 000000000..6f8f5a507 --- /dev/null +++ b/apps/backend/test/runtime/iracing-capture.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { copyFile, link, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { IRacingCaptureManager, type IRacingCaptureOptions } from "../../src/runtime/iracing-capture"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function harness(overrides: IRacingCaptureOptions = {}) { + const root = await mkdtemp(join(tmpdir(), "raceiq-native-ibt-")); + roots.push(root); + const source = join(root, "source"), archive = join(root, "archive"); + await mkdir(source); + let clock = 10000; + const commands: boolean[] = []; + const busy = new Set(); + const manager = new IRacingCaptureManager({ + availability: () => null, resolveDirectory: () => source, archiveDirectory: archive, + now: () => clock, pollIntervalMs: 0, settleMs: 100, shutdownFlushMs: 0, + setLogging: (enabled) => { commands.push(enabled); }, isFileClosed: (path) => !busy.has(path), + publishFile: async (stage, destination) => { + try { await link(stage, destination); return true; } + catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; throw error; } + }, + ...overrides, + }); + return { manager, source, archive, commands, busy, + advance: (ms = 101) => { clock += ms; }, + observe: (enabled: unknown, active: unknown) => manager.observe({ values: { IsDiskLoggingEnabled: enabled, IsDiskLoggingActive: active } }, clock), + settle: async () => { await manager.tick(); clock += 101; await manager.tick(); }, + }; +} + +describe("automatic native iRacing capture", () => { + test("requires opt-in and real SDK acknowledgement, preserves independently enabled logging", async () => { + const h = await harness(); + h.observe(false, false); + await h.manager.tick(); + expect(h.manager.status().state).toBe("disconnected"); + expect(h.commands).toEqual([]); + await h.manager.connect(true); + expect(h.manager.status().state).toBe("waiting-for-recording"); + h.observe(false, false); + await h.manager.tick(); + expect(h.commands).toEqual([true]); + h.observe(true, false); + expect(h.manager.status().state).toBe("waiting-for-recording"); + h.observe(true, true); + expect(h.manager.status().state).toBe("recording"); + await h.manager.connect(false); + expect(h.commands).toEqual([true, false]); + expect(h.manager.status().state).toBe("disconnected"); + + const user = await harness(); + user.observe(true, true); + await user.manager.connect(true); + expect(user.manager.status().state).toBe("recording"); + await user.manager.connect(false); + expect(user.commands).toEqual([]); + }); + + test("missing SDK flags never imply enabled or active logging; stale observations require reconnect evidence", async () => { + const h = await harness(); + await h.manager.connect(true); + expect(h.manager.status().state).toBe("waiting-for-iracing"); + h.observe(undefined, Number.NaN); + await h.manager.tick(); + expect(h.manager.status().state).toBe("waiting-for-recording"); + expect(h.commands).toEqual([]); + h.observe(false, false); + await h.manager.tick(); + h.observe(true, true); + h.advance(6000); + await h.manager.tick(); + expect(h.manager.status().state).toBe("waiting-for-iracing"); + h.observe(false, false); + await h.manager.tick(); + expect(h.commands).toEqual([true, true]); + h.observe(true, true); + expect(h.manager.status().state).toBe("recording"); + await h.manager.stop(); + }); + + test("unacknowledged native command becomes error instead of fabricated recording", async () => { + const h = await harness(); + h.observe(false, false); + await h.manager.connect(true); + h.advance(10001); + h.observe(false, false); + await h.manager.tick(); + // A genuinely disconnected source can reconnect, so maintain fresh observations + // while waiting for the acknowledgement of the new request. + h.advance(3000); h.observe(false, false); await h.manager.tick(); + h.advance(3000); h.observe(false, false); await h.manager.tick(); + h.advance(3000); h.observe(false, false); await h.manager.tick(); + h.advance(1100); h.observe(false, false); await h.manager.tick(); + expect(h.manager.status().state).toBe("error"); + expect(h.manager.status().reason).toBe("sdk-command-failed"); + expect(h.commands).toEqual([true, true]); + await h.manager.stop(); + }); + + test("archives only changed in-window files after the paused writer closes, preserving exact bytes and originals", async () => { + const h = await harness(); + await writeFile(join(h.source, "historical.ibt"), "old recording"); + await h.manager.connect(true); + const path = join(h.source, "new.ibt"), bytes = Buffer.from([0, 255, 1, 7, 42, 0, 9]); + await writeFile(path, bytes); + h.busy.add(path); + await h.settle(); + expect(await readdir(h.archive)).toEqual([]); + h.busy.delete(path); + await h.manager.tick(); + const archived = h.manager.status().lastSavedFile!; + expect(await readFile(archived)).toEqual(bytes); + expect(await readFile(path)).toEqual(bytes); + expect(await readFile(join(h.source, "historical.ibt"), "utf8")).toBe("old recording"); + expect(h.manager.status().savedFiles).toBe(1); + await h.manager.stop(); + }); + + test("collects a final pending file after disconnect but not new independent recordings", async () => { + const h = await harness(); + await h.manager.connect(true); + const last = join(h.source, "last.ibt"); + h.busy.add(last); + await writeFile(last, "unfinished"); + await h.manager.connect(false); + expect(h.manager.status().state).toBe("saving"); + await writeFile(last, "completed final recording"); + await writeFile(join(h.source, "after-disconnect.ibt"), "unrelated future recording"); + h.busy.delete(last); + await h.settle(); + expect(h.manager.status().state).toBe("disconnected"); + expect(await readFile(h.manager.status().lastSavedFile!, "utf8")).toBe("completed final recording"); + expect((await readdir(h.archive)).filter((name) => name.endsWith(".ibt"))).toHaveLength(1); + expect(await readFile(join(h.source, "after-disconnect.ibt"), "utf8")).toBe("unrelated future recording"); + }); + + test("retains filename generations without overwrite and deduplicates identical bytes", async () => { + const h = await harness(); + await h.manager.connect(true); + const path = join(h.source, "reused.ibt"); + await writeFile(path, "first generation"); + await h.settle(); + const first = h.manager.status().lastSavedFile!; + await writeFile(path, "second generation with different bytes"); + await h.settle(); + const second = h.manager.status().lastSavedFile!; + expect(await readFile(first, "utf8")).toBe("first generation"); + expect(await readFile(second, "utf8")).toBe("second generation with different bytes"); + await writeFile(path, "first generation"); + await h.settle(); + expect(h.manager.status().savedFiles).toBe(2); + expect((await readdir(h.archive)).filter((name) => name.endsWith(".ibt"))).toHaveLength(2); + expect(await readFile(path, "utf8")).toBe("first generation"); + await h.manager.stop(); + }); + + test("failed or changing copies publish no partial archive and leave source untouched", async () => { + const h = await harness({ copyFile: async (_source, stage) => { await writeFile(stage, "partial"); throw new Error("Disk full"); } }); + await h.manager.connect(true); + const path = join(h.source, "failure.ibt"); + await writeFile(path, "complete original"); + await h.settle(); + expect(h.manager.status().reason).toBe("archive-failed"); + expect(await readdir(h.archive)).toEqual([]); + expect(await readFile(path, "utf8")).toBe("complete original"); + + const changing = await harness({ copyFile: async (source, stage) => { await copyFile(source, stage); await writeFile(source, "new longer source after copy"); } }); + await changing.manager.connect(true); + const live = join(changing.source, "changed.ibt"); + await writeFile(live, "old bytes"); + await changing.settle(); + expect(changing.manager.status().reason).toBe("archive-failed"); + expect(await readdir(changing.archive)).toEqual([]); + expect(await readFile(live, "utf8")).toBe("new longer source after copy"); + }); + + test("unsupported or disabled native capture stays unavailable without opt-in", async () => { + for (const reason of ["unsupported-platform", "feature-disabled"] as const) { + const h = await harness({ availability: () => reason }); + await h.manager.connect(true); + expect(h.manager.status()).toMatchObject({ state: "unavailable", connected: false, supported: false, reason }); + expect(h.commands).toEqual([]); + } + }); +}); diff --git a/apps/backend/test/runtime/worker-sql.test.ts b/apps/backend/test/runtime/worker-sql.test.ts index 83e99cc9c..aaec7b03e 100644 --- a/apps/backend/test/runtime/worker-sql.test.ts +++ b/apps/backend/test/runtime/worker-sql.test.ts @@ -1,27 +1,18 @@ -import { describe, expect, test } from "bun:test"; +import { rm } from "node:fs/promises"; +import { describe, expect } from "bun:test"; import { createClient, type Client } from "@libsql/client/sqlite3"; import { createWorkerSqlClient, createWorkerSqlHost, type WorkerSqlOperation, type WorkerSqlRequest, type WorkerSqlResponse, type WorkerSqlSender } from "@raceiq/backend-core/db/worker-sql"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { MessageChannel } from "node:worker_threads"; -async function removeDatabaseDirectory(directory: string) { - for (let attempt = 0; ; attempt++) { - try { - await rm(directory, { recursive: true, force: true }); - return; - } catch (error) { - const isBusyOnWindows = process.platform === "win32" && error instanceof Error && "code" in error && error.code === "EBUSY"; - if (!isBusyOnWindows || attempt === 9) throw error; - await new Promise((resolve) => setTimeout(resolve, 200)); - } - } -} +import { nativeDatabaseTest } from "../support/native-database-test"; + +const test = nativeDatabaseTest(import.meta.path); async function withDatabase(run: (path: string) => Promise) { - const directory = await mkdtemp(join(tmpdir(), "raceiq-worker-sql-")); + const directory = await mkdtemp(join(process.env.RACEIQ_NATIVE_DB_ROOT!, "worker-sql-")); try { await run(join(directory, "database.sqlite")); } - finally { await removeDatabaseDirectory(directory); } + finally { await rm(directory, { recursive: true, force: true }); } } function channelFor(client: Client, leaseId: string) { diff --git a/apps/backend/test/session-capture/direct-recorder.test.ts b/apps/backend/test/session-capture/direct-recorder.test.ts index a03b56a9b..0a98b6937 100644 --- a/apps/backend/test/session-capture/direct-recorder.test.ts +++ b/apps/backend/test/session-capture/direct-recorder.test.ts @@ -12,6 +12,9 @@ import { laps, sessions } from "@raceiq/backend-core/db/schema"; import { parsePacket, subscribeParserSource, type ParserReplayLocator } from "@raceiq/backend-core/games/packet-dispatch"; import type { DetectorOwnershipTransition, DetectorOwnershipWindow } from "@raceiq/telemetry-core/processor/ordinal-engine"; import { DirectCaptureRecording } from "@raceiq/backend-core/session-capture/direct-recorder"; +import { iterateSessionCaptureRecordsFromSource } from "@raceiq/backend-core/session-capture/source-loader"; +import { getLapById } from "@raceiq/backend-core/db/lap-read-queries"; +import { updateSessionRawFile } from "@raceiq/backend-core/db/session-queries"; import { initServerGameAdapters } from "../../src/games/init"; import { eq } from "drizzle-orm"; import { runSessionCaptureMaintenanceNow } from "@raceiq/backend-core/session-capture/compressor"; @@ -28,9 +31,9 @@ afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); -function fixtureFrames(): Buffer[] { +function fixtureFrames(count = 4): Buffer[] { const fixture = gunzipSync(readFileSync(join(ROOT_DIR, "test/artifacts/sessions/fm-2023-2026-04-09T21-55-03-186Z.bin.gz"))); - return [...iterateSessionFrames(fixture)].slice(0, 4); + return [...iterateSessionFrames(fixture)].slice(0, count); } function ownershipTransition(kind: DetectorOwnershipTransition["kind"], window: DetectorOwnershipWindow): DetectorOwnershipTransition { @@ -94,10 +97,15 @@ describe("DirectCaptureRecording", () => { const rowA = await db.insert(laps).values({ sessionId: session.id, lapNumber: 1, lapTime: 60, isValid: true, rawByteOffset: offsetA, rawFrameCount: 1 }).returning({ id: laps.id }).get(); const rowB = await db.insert(laps).values({ sessionId: session.id, lapNumber: 2, lapTime: 61, isValid: true, rawByteOffset: offsetB, rawFrameCount: 1 }).returning({ id: laps.id }).get(); if (!rowA || !rowB) throw new Error("Failed creating direct-recorder lap fixtures"); + const manifestPath = join(directory, "capture-manifest.json"); + await updateSessionRawFile(session.id, manifestPath, "test-detector"); + const beforePublication = await getLapById(rowA.id); + expect(beforePublication).toMatchObject({ telemetry: [], telemetryAvailable: false, standaloneCaptureReady: false }); + expect(beforePublication).not.toHaveProperty("parseError"); let closedA!: () => void; const durableCloseA = new Promise((resolve) => { closedA = resolve; }); - const capture = new DirectCaptureRecording("fm-2023", directory, join(directory, "capture-manifest.json"), { + const capture = new DirectCaptureRecording("fm-2023", directory, manifestPath, { writerOptions: { maxBatchDelayMs: 0, onClosed: () => closedA() }, }); captures.push(capture); @@ -136,6 +144,64 @@ describe("DirectCaptureRecording", () => { expect(publishedLaps).toHaveLength(2); expect(publishedLaps.every((lap) => lap.captureChunkId !== null && lap.sourceFrameCount === 1)).toBe(true); expect(publishedLaps.map((lap) => lap.sourceFrameStart)).toEqual([0, 1]); + const publishedSession = await db.select({ + rawFile: sessions.rawFile, captureManifestFile: sessions.captureManifestFile, + captureLayoutVersion: sessions.captureLayoutVersion, + }).from(sessions).where(eq(sessions.id, session.id)).get(); + expect(publishedSession).toEqual({ + rawFile: join(directory, "capture-manifest.json"), + captureManifestFile: join(directory, "capture-manifest.json"), captureLayoutVersion: 1, + }); + const replayFrames: Buffer[] = []; + for await (const record of iterateSessionCaptureRecordsFromSource({ + rawFile: publishedSession!.rawFile!, source: null, gameId: "fm-2023", + carId: "test-car", trackId: "test-track", + }, { strict: true })) { + if (record.kind === "frame") replayFrames.push(record.frame); + } + expect(replayFrames).toEqual([lapAFrame!, boundaryFrame!]); + + }); + it("replays a bound direct capture after its manifest was registered early", async () => { + initServerGameAdapters(); + const directory = await mkdtemp(join(tmpdir(), "raceiq-direct-replay-")); + directories.push(directory); + const frames = fixtureFrames(260); + const session = await db.insert(sessions).values({ gameId: "fm-2023", carId: "test-car", trackId: "test-track", rawFile: null }).returning({ id: sessions.id }).get(); + if (!session) throw new Error("Failed creating direct-replay session fixture"); + sessionIds.push(session.id); + const startByteOffset = META_FRAME_BYTES; + const lap = await db.insert(laps).values({ + sessionId: session.id, lapNumber: 1, lapTime: 60, isValid: true, + rawByteOffset: startByteOffset, rawFrameCount: frames.length, + }).returning({ id: laps.id }).get(); + if (!lap) throw new Error("Failed creating direct-replay lap fixture"); + + const manifestPath = join(directory, "capture-manifest.json"); + await updateSessionRawFile(session.id, manifestPath, "test-detector"); + const pending = await getLapById(lap.id); + expect(pending).toMatchObject({ telemetry: [], telemetryAvailable: false, standaloneCaptureReady: false }); + expect(pending).not.toHaveProperty("parseError"); + + const capture = new DirectCaptureRecording("fm-2023", directory, manifestPath, { writerOptions: { maxBatchDelayMs: 0 } }); + captures.push(capture); + await capture.ready; + capture.attachSession(session.id, Promise.resolve(), () => {}); + const window: DetectorOwnershipWindow = { + revision: 1, lapNumber: 1, startOffset: startByteOffset, startOrdinal: 0, + frameCount: frames.length, status: "provisional", trailingContextFrameCount: 0, bounded: true, + }; + capture.writeRecord(frames[0]!, 100); + capture.processTransitions([ownershipTransition("window-start", window)], null); + for (let index = 1; index < frames.length; index++) capture.writeRecord(frames[index]!, 100 + index); + capture.processTransitions([ownershipTransition("window-finalized", { ...window, revision: 2, status: "final" })], null); + capture.bindLap({ sessionId: session.id, lapId: lap.id, lapNumber: 1, startByteOffset, frameCount: frames.length }); + await capture.stop(); + + const replayed = await getLapById(lap.id); + expect(replayed).toMatchObject({ telemetryAvailable: true, standaloneCaptureReady: true }); + expect(replayed).not.toHaveProperty("parseError"); + expect(replayed?.telemetry.length).toBeGreaterThan(0); }); it("promotes a previously announced boundary without rotating or losing later native frames", async () => { const directory = await mkdtemp(join(tmpdir(), "raceiq-direct-promotion-")); diff --git a/apps/backend/test/session-capture/session-compressor.test.ts b/apps/backend/test/session-capture/session-compressor.test.ts index c03a23bdb..f0c455f73 100644 --- a/apps/backend/test/session-capture/session-compressor.test.ts +++ b/apps/backend/test/session-capture/session-compressor.test.ts @@ -217,7 +217,7 @@ describe("session capture cleanup", () => { const externalCapture = join(externalDir, "external.bin"); writeFileSync(externalCapture, Buffer.alloc(32)); const link = join(root, gameId, "own", "linked-game"); - fs.symlinkSync(externalDir, link, "dir"); + fs.symlinkSync(externalDir, link, "junction"); try { const files = await listSessionCaptureFiles(); expect(files).toEqual(expect.arrayContaining(paths)); diff --git a/apps/backend/test/session-capture/source-loader-stream.test.ts b/apps/backend/test/session-capture/source-loader-stream.test.ts index 2beb7f724..9f23ccde9 100644 --- a/apps/backend/test/session-capture/source-loader-stream.test.ts +++ b/apps/backend/test/session-capture/source-loader-stream.test.ts @@ -402,14 +402,15 @@ test("replays standalone manifest chunks once, including null-emission source fr await expect(async () => { for await (const _record of iterateSessionCaptureRecordsFromSource(source)) {} }).toThrow(/not record-aligned/); - const outsidePath = join(tmpdir(), `raceiq-outside-${Date.now()}.bin`); + const outsideDirectory = mkdtempSync(join(tmpdir(), `raceiq-outside-${Date.now()}-`)); + const outsidePath = join(outsideDirectory, "escape.bin"); writeFileSync(outsidePath, Buffer.concat([prefix, wire(f1), wire(shared), wire(shared)])); - symlinkSync(outsidePath, join(directory, "escape.bin")); + symlinkSync(outsideDirectory, join(directory, "escape-dir"), "junction"); changed.chunks[1].sourceRecordByteOffset = contextPrefix.length; - changed.chunks[0].path = "escape.bin"; + changed.chunks[0].path = "escape-dir/escape.bin"; writeFileSync(rawFile, JSON.stringify(changed)); await expect(async () => { for await (const _record of iterateSessionCaptureRecordsFromSource(source)) {} }).toThrow(/escapes capture directory/); - rmSync(outsidePath); + rmSync(outsideDirectory, { recursive: true, force: true }); }); diff --git a/apps/backend/test/support/iracing-incident-fixture.ts b/apps/backend/test/support/iracing-incident-fixture.ts new file mode 100644 index 000000000..36914dfd3 --- /dev/null +++ b/apps/backend/test/support/iracing-incident-fixture.ts @@ -0,0 +1,234 @@ +import { gzipSync } from "node:zlib"; +import { IRacingSourceFrameEncoder, type IRacingSourceFrameV3 } from "@raceiq/capture-formats/iracing/source-frame"; +import { + encodeFrameLength, + encodeMetaFrame, + encodeSegmentBoundaryFrame, + encodeSegmentContextEndFrame, + encodeSegmentContextFrame, + encodeSparseCaptureFormatFrame, +} from "@raceiq/capture-formats/session/framing"; + +export interface IRacingIncidentFixtureIdentity { + sessionId: number; + subSessionId: number; + sessionNum: number; + driverCarIdx: number; + trackId: number; + trackName: string; + carId: number; + carName: string; +} + +export interface IRacingIncidentFixtureSample { + lapId?: number; + /** False records shared capture data without creating a requested lap window. */ + owned?: boolean; + /** Per-sample native segment identity; omitted fields inherit fixture identity. */ + identity?: Partial; + /** Recorder timestamp prefix, in milliseconds. */ + frameTimeMs?: number; + values: Record>; + /** Mark source frame as segment context: it primes encoding but is not owned evidence. */ + context?: boolean; +} + +export type IRacingIncidentFixtureRecord = + | { kind: "sample"; sample: IRacingIncidentFixtureSample } + | { kind: "segment-boundary" } + | { kind: "segment-context" } + | { kind: "segment-context-end" } + | { kind: "malformed"; bytes?: Buffer }; + +export interface IRacingIncidentFixtureLapWindow { + lapId: number; + rawByteOffset: number; + rawFrameCount: number; + sampleIndexes: number[]; +} + +export interface IRacingIncidentFixture { + /** Stored bytes (gzip encoded when requested); offsets always address decompressed bytes. */ + bytes: Buffer; + decompressedBytes: Buffer; + identity: IRacingIncidentFixtureIdentity; + windows: IRacingIncidentFixtureLapWindow[]; + samples: Array<{ index: number; lapId: number | null; rawOffset: number; values: Readonly>; context: boolean }>; +} + +const DEFAULT_IDENTITY: IRacingIncidentFixtureIdentity = { + sessionId: 202, + subSessionId: 205, + sessionNum: 1, + driverCarIdx: 0, + trackId: 99, + trackName: "Incident Fixture Track", + carId: 42, + carName: "Incident Fixture Car", +}; + +function nativeFrame( + identity: IRacingIncidentFixtureIdentity, + sessionTick: number, + values: IRacingIncidentFixtureSample["values"], +): IRacingSourceFrameV3 { + return { + schemaVersion: 3, + session: { + sessionId: identity.sessionId, + subSessionId: identity.subSessionId, + sessionNum: identity.sessionNum, + driverCarIdx: identity.driverCarIdx, + trackId: identity.trackId, + trackName: identity.trackName, + trackLengthM: 5000, + sectorStarts: [0, 0.5], + carId: identity.carId, + carName: identity.carName, + carClassId: 8, + carClassName: "GT3", + engineIdleRpm: 900, + engineRedlineRpm: 8500, + engineCylinderCount: 8, + }, + values: { SessionTick: sessionTick, SessionTime: sessionTick / 20, SessionNum: identity.sessionNum, ...values }, + sessionInfo: "WeekendInfo:\n TrackName: Incident Fixture Track\n", + sessionInfoUpdate: 1, + }; +} + +/** + * Build deterministic native iRacing capture bytes. Frame offsets are logical + * decompressed offsets, matching session lap-window columns for both plain and gzip. + */ +export function createIRacingIncidentFixture(options: { + records: readonly IRacingIncidentFixtureRecord[]; + identity?: Partial; + gzip?: boolean; + metadataFrames?: number; + sparseFormatMarker?: boolean; +}): IRacingIncidentFixture { + const identity = { ...DEFAULT_IDENTITY, ...options.identity }; + const encoder = new IRacingSourceFrameEncoder(); + const chunks: Buffer[] = [encodeMetaFrame(options.metadataFrames ?? 0)]; + if (options.sparseFormatMarker ?? true) chunks.push(encodeSparseCaptureFormatFrame()); + const windows = new Map(); + const samples: IRacingIncidentFixture["samples"] = []; + let offset = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + let sessionTick = 0; + let sampleIndex = 0; + for (const record of options.records) { + + if (record.kind === "segment-boundary") { + encoder.reset(); + chunks.push(encodeSegmentBoundaryFrame()); + offset += chunks[chunks.length - 1]!.length; + continue; + } + if (record.kind === "segment-context") { + chunks.push(encodeSegmentContextFrame()); + offset += chunks[chunks.length - 1]!.length; + continue; + } + if (record.kind === "segment-context-end") { + chunks.push(encodeSegmentContextEndFrame()); + offset += chunks[chunks.length - 1]!.length; + continue; + } + if (record.kind === "malformed") { + const bytes = record.bytes ?? Buffer.from([0xff, 0xff, 0xff, 0xff, 0x01]); + chunks.push(bytes); + offset += bytes.length; + continue; + } + + const sample = record.sample; + const sampleIdentity = { ...identity, ...sample.identity }; + if (sample.context) { + chunks.push(encodeSegmentContextFrame()); + offset += chunks[chunks.length - 1]!.length; + } + const frameValues = { ...sample.values }; + const frameBytes = encoder.encode(nativeFrame(sampleIdentity, sessionTick++, frameValues)); + const frameOffset = offset; + const framed = Buffer.concat([encodeFrameLength(frameBytes.length, sample.frameTimeMs), frameBytes]); + chunks.push(framed); + offset += framed.length; + if (sample.context) { + chunks.push(encodeSegmentContextEndFrame()); + offset += chunks[chunks.length - 1]!.length; + } + samples.push({ index: sampleIndex++, lapId: sample.lapId ?? null, rawOffset: frameOffset, values: frameValues, context: sample.context ?? false }); + if (sample.lapId !== undefined && sample.owned !== false && !sample.context) { + let window = windows.get(sample.lapId); + if (!window) { + window = { lapId: sample.lapId, rawByteOffset: frameOffset, rawFrameCount: 0, sampleIndexes: [] }; + windows.set(sample.lapId, window); + } else if (window.sampleIndexes[window.sampleIndexes.length - 1] !== sampleIndex - 2) { + throw new Error(`Lap ${sample.lapId} has disjoint sample windows; use distinct lap IDs or explicit windows`); + } + window.rawFrameCount += 1; + window.sampleIndexes.push(sampleIndex - 1); + } + } + + const decompressedBytes = Buffer.concat(chunks); + return { + bytes: options.gzip ? gzipSync(decompressedBytes) : decompressedBytes, + decompressedBytes, + identity, + windows: [...windows.values()], + samples, + }; +} + +/** Main-plan two-lap fixture, with caller-supplied foreign records left unowned. */ +export function createIRacingIncidentMainFixture(options: { gzip?: boolean } = {}): IRacingIncidentFixture { + const records: IRacingIncidentFixtureRecord[] = []; + const laps = [ + { lapId: 1, counters: [0, 0, 1, 1, 1, 1, 1, 1], surfaces: [3, 3, 0, 0, 3, 3, 3, 3] }, + { lapId: 2, counters: [1, 1, 3, 3, 3, 3, 3, 3], surfaces: [3, 3, 3, 0, 0, 3, 3, 3] }, + ]; + let time = 60; + for (const lap of laps) { + for (let index = 0; index < lap.counters.length; index += 1) { + records.push({ kind: "sample", sample: { + lapId: lap.lapId, + values: { + SessionTime: time, + SessionTick: Math.round(time * 60), + Lap: lap.lapId, + LapCurrentLapTime: index * 0.05, + LapLastLapTime: 0, + IsOnTrack: true, + PlayerCarMyIncidentCount: lap.counters[index]!, + PlayerCarIdx: 0, + PlayerTrackSurface: lap.surfaces[index]!, + Speed: 20 + index, + }, + } }); + time += 0.05; + } + } + + for (let index = 0; index < 8; index += 1) { + records.push({ kind: "sample", sample: { + lapId: 3, + owned: false, + values: { + SessionTime: time, + SessionTick: Math.round(time * 60), + Lap: 3, + LapCurrentLapTime: index * 0.05, + LapLastLapTime: 0, + IsOnTrack: true, + PlayerCarMyIncidentCount: 50, + PlayerCarIdx: 1, + PlayerTrackSurface: index === 3 || index === 4 ? 0 : 3, + Speed: 30 + index, + }, + } }); + time += 0.05; + } + return createIRacingIncidentFixture({ records, gzip: options.gzip }); +} diff --git a/apps/backend/test/support/native-database-test.ts b/apps/backend/test/support/native-database-test.ts new file mode 100644 index 000000000..cd7d814bd --- /dev/null +++ b/apps/backend/test/support/native-database-test.ts @@ -0,0 +1,48 @@ +import { test as bunTest } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Parent removes fixture files after process exit releases libSQL's native handles. */ +export function nativeDatabaseTest(file: string) { + return (name: string, scenario: () => void | Promise) => { + if (process.env.RACEIQ_NATIVE_DB_CASE) { + if (process.env.RACEIQ_NATIVE_DB_CASE === name) bunTest(name, scenario); + return; + } + bunTest(name, async () => { + const directory = await mkdtemp(join(tmpdir(), "raceiq-native-db-")); + try { + const config = join(directory, "bunfig.toml"); + await writeFile(config, `[test]\nroot = ${JSON.stringify(directory.replaceAll("\\", "/"))}\npreload = []\n`); + const child = Bun.spawn([process.execPath, "test", "--timeout=10000", `--config=${config}`, file], { + env: { + ...process.env, + DATA_DIR: directory, + RACEIQ_TEST_DATA_DIR: directory, + RACEIQ_TEST_MODE: "1", + RACEIQ_NATIVE_DB_CASE: name, + RACEIQ_NATIVE_DB_ROOT: directory, + }, + stdout: "pipe", + stderr: "pipe", + }); + const deadline = setTimeout(() => child.kill(), 15000); + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (code !== 0) throw new Error(`Native database scenario failed (${code}):\n${stdout}\n${stderr}`); + } finally { + clearTimeout(deadline); + child.kill(); + await child.exited; + } + } finally { + await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, 20000); + }; +} diff --git a/apps/backend/test/telemetry/analysis-telemetry.test.ts b/apps/backend/test/telemetry/analysis-telemetry.test.ts index 43f6291db..5cae5562b 100644 --- a/apps/backend/test/telemetry/analysis-telemetry.test.ts +++ b/apps/backend/test/telemetry/analysis-telemetry.test.ts @@ -33,18 +33,6 @@ describe("analysis telemetry capabilities", () => { source: "unavailable", reason: "source-limitation", }); - expect(analysis.tireTemperature).toEqual({ - source: "direct", - freshness: "pit-snapshot", - display: "per-wheel", - binding: { kind: "value", semanticId: "tire.temperature.carcass.middle" }, - }); - expect(analysis.tirePressure).toEqual({ - source: "direct", - freshness: "static", - display: "cold-pressure", - binding: { kind: "value", semanticId: "tires.tire-pressure" }, - }); expect(analysis.suspensionTravel).toEqual({ source: "direct", freshness: "continuous", @@ -80,7 +68,7 @@ describe("analysis telemetry capabilities", () => { pitTireWearAvailable: true, }, } as TelemetryPacket; - expect(hasTireTemperatureData(newTireSnapshot, analysis.tireTemperature)).toBe(true); + expect(hasTireTemperatureData(newTireSnapshot, analysis.tireTemperature)).toBe(false); expect(hasTireHealthData(newTireSnapshot, analysis.tireHealth)).toBe(true); }); @@ -119,23 +107,6 @@ describe("analysis telemetry capabilities", () => { }); }); - test("advertises every catalog-backed Analyse Data metric", () => { - const supported = { - "fm-2023": ["balance", "gForce", "gripDemand", "traction", "tireTemperature", "surface", "slipRatio", "lateralSlip", "wheelRotation", "tireHealth", "tireWearRate", "suspensionTravel", "suspensionCompressionBias"], - "f1-2025": ["balance", "gForce", "gripDemand", "traction", "tireTemperature", "slipRatio", "slipAngle", "wheelRotation", "tireHealth", "tireWearRate", "tirePressure", "suspensionTravel"], - acc: ["balance", "gForce", "gripDemand", "traction", "tireTemperature", "slipRatio", "slipAngle", "wheelRotation", "tirePressure", "suspensionTravel", "suspensionCompressionBias"], - "ac-evo": ["balance", "gForce", "gripDemand", "traction", "tireTemperature", "slipRatio", "slipAngle", "wheelRotation", "tireHealth", "tireWearRate", "tirePressure", "suspensionTravel", "suspensionCompressionBias"], - iracing: ["balance", "gForce", "tireTemperature", "surface", "tireHealth", "tirePressure", "suspensionTravel"], - lmu: ["balance", "gForce", "gripDemand", "traction", "tireTemperature", "slipRatio", "slipAngle", "wheelRotation", "tireHealth", "tireWearRate", "tirePressure", "suspensionTravel"], - } as const; - - for (const [gameId, metrics] of Object.entries(supported)) { - const analysis = resolveAnalysisTelemetry(getGame(gameId as keyof typeof supported)); - for (const metric of metrics) { - expect(analysis[metric].source, `${gameId}.${metric}`).not.toBe("unavailable"); - } - } - }); test("reports unavailable Analyse features from adapter telemetry contracts", () => { const unavailable = unavailableAnalyseFeatures(getGame("ac-evo"), new Set()); diff --git a/apps/backend/test/telemetry/catalog/artifacts.test.ts b/apps/backend/test/telemetry/catalog/artifacts.test.ts index 84716ad45..f3da1e93d 100644 --- a/apps/backend/test/telemetry/catalog/artifacts.test.ts +++ b/apps/backend/test/telemetry/catalog/artifacts.test.ts @@ -202,16 +202,6 @@ describe("semantic telemetry catalog artifacts", () => { sources: ["iRacing.IsOnTrack"], }, }); - expect(getTelemetryVariable("tires.tire-pressure").games.iracing).toMatchObject({ - kind: "normalized", - nativeUnit: "kPa", - sources: { - FL: ["iRacing.LFcoldPressure"], - FR: ["iRacing.RFcoldPressure"], - RL: ["iRacing.LRcoldPressure"], - RR: ["iRacing.RRcoldPressure"], - }, - }); expect(getTelemetryVariable("session.session-type").games.iracing.kind).toBe( "normalized", diff --git a/apps/backend/test/telemetry/catalog/contracts.test.ts b/apps/backend/test/telemetry/catalog/contracts.test.ts index eaba1b5c6..424ec9fca 100644 --- a/apps/backend/test/telemetry/catalog/contracts.test.ts +++ b/apps/backend/test/telemetry/catalog/contracts.test.ts @@ -205,11 +205,6 @@ describe("semantic telemetry catalog contracts", () => { (variable) => String(variable.id) === "race.is-on-track", ), ).toBe(false); - expect( - TELEMETRY_CATALOG.variables.some( - (variable) => String(variable.id) === "tires.cold-pressure", - ), - ).toBe(false); expect( TELEMETRY_CATALOG.variables .map((variable) => String(variable.id)) @@ -304,9 +299,6 @@ describe("semantic telemetry catalog contracts", () => { "race.team-incident-count", ); expect(sourceSemantics.get("IsOnTrack")).toBe("race.is-race-on"); - expect(sourceSemantics.get("LFcoldPressure")).toBe( - "tires.tire-pressure", - ); expect(sourceSemantics.get("CarIdxFastRepairsUsed")).toBe( "race.competitor.fast-repairs-used", ); diff --git a/apps/backend/test/telemetry/catalog/iracing-sources.test.ts b/apps/backend/test/telemetry/catalog/iracing-sources.test.ts index 62798a3e4..9cdf8e6be 100644 --- a/apps/backend/test/telemetry/catalog/iracing-sources.test.ts +++ b/apps/backend/test/telemetry/catalog/iracing-sources.test.ts @@ -9,26 +9,8 @@ import { } from "../../support/telemetry/catalog"; describe("semantic telemetry catalog iRacing sources", () => { - test("links all 324 captured iRacing SDK variables with source metadata", () => { - expect(IRACING_TELEMETRY_SOURCE_VARIABLES).toHaveLength(324); - expect( - IRACING_TELEMETRY_SOURCE_VARIABLES.filter( - (variable) => variable.recordedByRaceIQ, - ), - ).toHaveLength(74); + test("links captured live and disk-only iRacing variables with source metadata", () => { - expect( - IRACING_TELEMETRY_SOURCE_VARIABLES.find( - (variable) => variable.path === "LFtempCL", - ), - ).toMatchObject({ - unit: "C", - dataType: "float", - count: 1, - description: "LF tire left carcass temperature", - recordedByRaceIQ: true, - semanticId: "tire.temperature.carcass.left", - }); expect( IRACING_TELEMETRY_SOURCE_VARIABLES.find( (variable) => variable.path === "AirDensity", diff --git a/apps/backend/test/telemetry/catalog/semantics.test.ts b/apps/backend/test/telemetry/catalog/semantics.test.ts index db1413be7..de32eb8de 100644 --- a/apps/backend/test/telemetry/catalog/semantics.test.ts +++ b/apps/backend/test/telemetry/catalog/semantics.test.ts @@ -65,15 +65,6 @@ describe("semantic telemetry catalog", () => { expect(core.games.iracing.kind).toBe("unavailable"); }); - test("keeps detailed iRacing carcass bands as direct variables", () => { - for (const band of ["left", "middle", "right"] as const) { - const variable = getTelemetryVariable( - `tire.temperature.carcass.${band}`, - ); - expect(variable.games.iracing.kind).toBe("direct"); - expect(variable.canonicalUnit).toBe("°C"); - } - }); diff --git a/apps/backend/test/telemetry/telemetry-model.test.ts b/apps/backend/test/telemetry/telemetry-model.test.ts index 7876c9b6a..3382d03ed 100644 --- a/apps/backend/test/telemetry/telemetry-model.test.ts +++ b/apps/backend/test/telemetry/telemetry-model.test.ts @@ -19,7 +19,6 @@ const adapterCases: Array<{ tireTemperature: TelemetryModel["tireTemperature"]["packetUnit"]; scalarMetrics: ScalarMetric[]; brakeTemperature: boolean; - tirePressure: boolean; ers: boolean; sourceChannels: SourceChannel[]; }> = [ @@ -29,7 +28,6 @@ const adapterCases: Array<{ tireTemperature: "fahrenheit", scalarMetrics: ["boost", "power", "torque"], brakeTemperature: false, - tirePressure: false, ers: false, sourceChannels: ["clutch", "handBrake"], }, @@ -39,7 +37,6 @@ const adapterCases: Array<{ tireTemperature: "celsius", scalarMetrics: ["power"], brakeTemperature: true, - tirePressure: true, ers: true, sourceChannels: ["clutch", "weather"], }, @@ -49,7 +46,6 @@ const adapterCases: Array<{ tireTemperature: "celsius", scalarMetrics: [], brakeTemperature: true, - tirePressure: true, ers: false, sourceChannels: ["pitStatus"], }, @@ -59,7 +55,6 @@ const adapterCases: Array<{ tireTemperature: "celsius", scalarMetrics: [], brakeTemperature: true, - tirePressure: true, ers: false, sourceChannels: ["weather", "pitStatus"], }, @@ -69,7 +64,6 @@ const adapterCases: Array<{ tireTemperature: "celsius", scalarMetrics: [], brakeTemperature: false, - tirePressure: true, ers: false, sourceChannels: ["clutch", "pitStatus"], }, @@ -79,7 +73,6 @@ const adapterCases: Array<{ tireTemperature: "celsius", scalarMetrics: ["power", "torque"], brakeTemperature: true, - tirePressure: true, ers: false, sourceChannels: ["clutch", "weather", "pitStatus"], }, @@ -97,9 +90,6 @@ describe("game telemetry models", () => { expect(model.brakeTemperature !== undefined).toBe( expected.brakeTemperature, ); - expect(model.tirePressure !== undefined).toBe( - expected.tirePressure, - ); expect(model.ers !== undefined).toBe(expected.ers); const supportedScalars = ( diff --git a/apps/backend/test/tracks/model/iracing-track-map.test.ts b/apps/backend/test/tracks/model/iracing-track-map.test.ts index fb6d263f7..ed6be46a7 100644 --- a/apps/backend/test/tracks/model/iracing-track-map.test.ts +++ b/apps/backend/test/tracks/model/iracing-track-map.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { getIRacingSvgTrackMap, IRACING_MAP_CACHE_VERSION, orientIRacingOvalMap } from "@raceiq/game-iracing/track-map"; +import { IRACING_MAP_CACHE_VERSION, orientIRacingOvalMap } from "@raceiq/game-iracing/track-map"; import { alignIRacingAutoSegmentsToTurnLabels, parseIRacingActiveSvg, @@ -12,6 +12,7 @@ import { getAllIRacingTracks, getIRacingSharedTrackName, getIRacingTrack } from import { gameCatalogDir } from "@raceiq/shared/platform/runtime/data-paths"; import { loadLabelledSegments } from "@raceiq/shared/racing/tracks/storage/meta"; import type { NamedSegment } from "@raceiq/shared/racing/tracks/named-segments"; +import { computeOutlineLength, resolveIRacingProjection } from "@raceiq/tracks-catalogue/server"; const activeSvg = ` @@ -28,6 +29,17 @@ const startFinishSvg = ` `; +const symbolStartFinishSvg = ` + + + + + + + + +`; + const turnsSvg = ` 1 @@ -69,10 +81,10 @@ describe("iRacing official SVG track maps", () => { turnsSvg, ); - expect(map).not.toBeNull(); - expect(map!.points).toHaveLength(512); - expect(map!.points[0].x).toBeCloseTo(-45, 0); - expect(map!.points[0].z).toBeCloseTo(5, 0); + expect(map!.startFinishAligned).toBe(true); + expect(Math.abs(map!.points[0].x + 50)).toBeLessThan(8); + expect(map!.points[0].z).toBeLessThan(10); + expect(map!.forwardDirectionKnown).toBe(true); expect(map!.labels.map((label) => label.text)).toEqual([ "1", "2", @@ -84,6 +96,67 @@ describe("iRacing official SVG track maps", () => { expect(nearestIndex(map!.points, turn1)).toBeLessThan( nearestIndex(map!.points, turn2), ); + const edges = map!.ribbonEdges!; + expect((edges.leftEdge[0].x + edges.rightEdge[0].x) / 2).toBeCloseTo(map!.points[0].x); + expect((edges.leftEdge[0].z + edges.rightEdge[0].z) / 2).toBeCloseTo(map!.points[0].z); + const forward = { x: map!.points[1].x - map!.points[0].x, z: map!.points[1].z - map!.points[0].z }; + const left = { x: edges.leftEdge[0].x - map!.points[0].x, z: edges.leftEdge[0].z - map!.points[0].z }; + expect(-forward.z * left.x + forward.x * left.z).toBeGreaterThan(0); + }); + + test("recognizes symbol-based official start-finish markers without treating direction arrows as markers", () => { + const map = parseIRacingActiveSvg(activeSvg, symbolStartFinishSvg, turnsSvg)!; + expect(map.startFinishAligned).toBe(true); + expect(Math.abs(map.points[0].x + 50)).toBeLessThan(8); + expect(map.points[0].z).toBeLessThan(10); + const directionOnlySvg = symbolStartFinishSvg.replace(/\s*/, "").replace(/\s*/, ""); + expect(parseIRacingActiveSvg(activeSvg, directionOnlySvg, turnsSvg)?.startFinishAligned).toBe(false); + }); + test("resolves exact layout projection in metres without changing source geometry", () => { + const map = parseIRacingActiveSvg(activeSvg, startFinishSvg, turnsSvg)!; + const sourceStart = { ...map.points[0] }; + const projection = resolveIRacingProjection("18", map)!; + expect(computeOutlineLength(projection.points)).toBeCloseTo(getIRacingTrack(18)!.lengthKm * 1000, 5); + expect(projection.points.at(-1)).toEqual(projection.points[0]); + expect(map.points[0]).toEqual(sourceStart); + expect(projection.points[0].x).toBeLessThan(0); + expect(resolveIRacingProjection("99", map)).toBeNull(); + expect(resolveIRacingProjection("18", { ...map, startFinishAligned: false })).toBeNull(); + expect(resolveIRacingProjection("18", { ...map, forwardDirectionKnown: false })).toBeNull(); + expect(resolveIRacingProjection("18", { ...map, points: [{ x: NaN, z: 0 }, { x: 1, z: 1 }] })).toBeNull(); + const alternate = resolveIRacingProjection("50", map)!; + expect(computeOutlineLength(alternate.points)).toBeCloseTo(getIRacingTrack(50)!.lengthKm * 1000, 5); + const projectedEdges = projection.boundaries!; + expect(projectedEdges.leftEdge.at(-1)).toEqual(projectedEdges.leftEdge[0]); + expect(projectedEdges.rightEdge.at(-1)).toEqual(projectedEdges.rightEdge[0]); + expect((projectedEdges.leftEdge[0].x + projectedEdges.rightEdge[0].x) / 2).toBeCloseTo(projection.points[0].x); + expect((projectedEdges.leftEdge[0].z + projectedEdges.rightEdge[0].z) / 2).toBeCloseTo(projection.points[0].z); + expect(resolveIRacingProjection("181", { ...map, ribbonEdges: null })?.boundaries).toBeNull(); + expect(resolveIRacingProjection("181", { ...map, ribbonEdges: { ...map.ribbonEdges!, leftEdge: [{ x: NaN, z: 0 }] } })?.boundaries).toBeNull(); + }); + + test("fails closed when SVG lacks start-finish and forward-direction evidence", () => { + const unmarked = parseIRacingActiveSvg(activeSvg); + expect(unmarked?.startFinishAligned).toBe(false); + expect(unmarked?.forwardDirectionKnown).toBe(false); + const ambiguousTurns = turnsSvg.replace("1 92 90", "1 92 10"); + expect(parseIRacingActiveSvg(activeSvg, startFinishSvg, ambiguousTurns)?.forwardDirectionKnown).toBe(false); + }); + + test("does not invent edges from a single contour and preserves handedness when reversing", () => { + const single = parseIRacingActiveSvg(''); + expect(single?.ribbonEdges).toBeNull(); + const map = parseIRacingActiveSvg(activeSvg, startFinishSvg, turnsSvg)!; + let area = 0; + for (let index = 0; index < map.points.length; index++) { + const point = map.points[index], next = map.points[(index + 1) % map.points.length]; + area += point.x * next.z - next.x * point.z; + } + const reversed = orientIRacingOvalMap(map, area > 0 ? "right" : "left"); + expect(reversed.points[0]).toEqual(map.points[0]); + expect(reversed.points[1]).toEqual(map.points.at(-1)!); + expect(reversed.ribbonEdges!.leftEdge[0]).toEqual(map.ribbonEdges!.rightEdge[0]); + expect(reversed.ribbonEdges!.rightEdge[1]).toEqual(map.ribbonEdges!.leftEdge.at(-1)!); }); test("reads matrix-positioned official turn names", () => { @@ -152,7 +225,7 @@ describe("iRacing official SVG track maps", () => { .toEqual(parseIRacingPitRoadSvg(pitRoadSvg)); }); - test("bundles valid offline maps and returns pit lines from bundle", async () => { + test("bundles offline maps matched to each exact layout URL", () => { const tracks = getAllIRacingTracks(); for (const track of tracks) { const path = resolve(gameCatalogDir("iracing"), "track-maps", `${track.ordinal}.json`); @@ -163,13 +236,6 @@ describe("iRacing official SVG track maps", () => { expect(Array.isArray(cached.labels)).toBe(true); expect(Array.isArray(cached.pitLines)).toBe(true); } - const bundledPath = resolve(gameCatalogDir("iracing"), "track-maps", "238.json"); - const bundled = JSON.parse(readFileSync(bundledPath, "utf8")); - await expect(getIRacingSvgTrackMap(238)).resolves.toEqual({ - points: bundled.points, - labels: bundled.labels, - pitLines: bundled.pitLines, - }); }); test("normalizes oval orientation without changing pit lines", () => { @@ -177,9 +243,13 @@ describe("iRacing official SVG track maps", () => { points: [{ x: 0, z: 0 }, { x: 1, z: 0 }, { x: 1, z: 1 }, { x: 0, z: 1 }], labels: [], pitLines: [{ kind: "pit-road" as const, points: [{ x: 2, z: 2 }, { x: 3, z: 2 }] }], + startFinishAligned: true, + forwardDirectionKnown: false, + ribbonEdges: null, }; expect(orientIRacingOvalMap(map, "left").points).toEqual(map.points); expect(orientIRacingOvalMap(map, "right").points).toEqual([map.points[0], map.points[3], map.points[2], map.points[1]]); expect(orientIRacingOvalMap(map, "right").pitLines).toEqual(map.pitLines); + expect(orientIRacingOvalMap(map, "left").forwardDirectionKnown).toBe(true); }); }); diff --git a/bun.lock b/bun.lock index 62889e7c0..13a81a7d8 100644 --- a/bun.lock +++ b/bun.lock @@ -434,6 +434,7 @@ "@raceiq/telemetry-core": "workspace:*", "@raceiq/telemetry-processor": "workspace:*", "@raceiq/tooling": "workspace:*", + "@raceiq/tooling": "workspace:*", "@raceiq/tooling-data": "workspace:*", "@raceiq/tooling-release": "workspace:*", "@raceiq/tracks-catalogue": "workspace:*", @@ -691,6 +692,7 @@ "devDependencies": { "@playwright/test": "1.62.1", "@raceiq/backend-core": "workspace:*", + "@raceiq/game-catalogs": "workspace:*", "@raceiq/game-lmu": "workspace:*", "@raceiq/lap-replay": "workspace:*", "@raceiq/shared": "workspace:*", diff --git a/client/messages/de.json b/client/messages/de.json index 900e9a0bf..88f2295a2 100644 --- a/client/messages/de.json +++ b/client/messages/de.json @@ -1032,6 +1032,13 @@ "carwire_dims": "Maße", "carwire_tire_info": "Reifeninfo", "carwire_track": "Strecke", + "replay_track_loading": "Streckengeometrie wird geladen…", + "replay_track_error": "Streckengeometrie konnte nicht geladen werden.", + "replay_track_unavailable": "Streckenposition nicht verfügbar: Diese Wiedergabe benötigt ausgerichtete Streckengeometrie und eine gültige Rundendistanz oder native Position.", + "replay_track_projected": "Position und Ränder stammen aus der offiziellen Karte — keine gemessene Fahrlinie oder vermessene Streckenbreite.", + "replay_track_measured": "Gemessene IBT-Fahrspur; offizielle Karte dient nur als Näherungsreferenz.", + "replay_track_bounds_unavailable": "Streckenränder für dieses Layout nicht verfügbar. Die Position basiert auf der Rundendistanz, nicht auf der gemessenen Fahrlinie.", + "carwire_snapshot_stale": "Veraltete Momentaufnahme · nicht live", "carwire_trails": "Spuren", "carwire_saved": "Gespeichert", "sectortimes_s1": "S1", @@ -1717,9 +1724,9 @@ "analyse_importable_laps": "Importierbare Runden", "analyse_on_track_rows": "Rundenzeilen · max. {speed} mph", "analyse_import_cannot": "Diese Aufzeichnung kann nicht importiert werden.", - "analyse_ibt_note": "Der Import erstellt eine normale RaceIQ-iRacing-Session und eine kanonische .bin-Aufzeichnung. Die originale .ibt-Datei wird nicht im Session-Speicher kopiert.", + "analyse_ibt_note": "Der Import bewahrt die originale .ibt-Aufzeichnung mit ihrer ursprünglichen Abtastrate. Keine BIN-Konvertierung.", "session_import_title": "Sessiondaten importieren", - "session_choose_file_hint": "Datei auswählen. Format und Spieldaten werden aus ihrem Inhalt geprüft.", + "session_choose_file_hint": "Datei hier ablegen oder auswählen. Format und Spieldaten werden aus ihrem Inhalt geprüft.", "session_choose_file": "Datei auswählen", "session_no_file": "Keine Datei ausgewählt", "session_reading": "Dateiinhalt wird gelesen…", @@ -1727,7 +1734,7 @@ "session_unsupported": "Dateiinhalt wird nicht unterstützt.", "session_game_detected": "Spiel aus Telemetrieinhalt erkannt.", "session_captures_found": "{count} RaceIQ-Aufzeichnung(en) gefunden.", - "session_ibt_hint": "iRacing-Importe benötigen Vorschau und Bestätigung in Analyse.", + "session_ibt_hint": "Wähle Importieren, um die iRacing-Aufzeichnung anzuzeigen und ihre Runden zu bestätigen.", "session_motec_hint": "MoTeC-Importe benötigen Spiel-, Fahrzeug- und Streckeneinstellungen in Analyse.", "session_imported": "{count} Runde(n) importiert.{skipped}", "session_skipped": " {count} übersprungen.", @@ -2758,7 +2765,114 @@ "storage_delete_after": "Löschen nach", "storage_retention_policy_error": "Automatisches Löschen muss nach dem automatischen Herunterrechnen geplant werden.", "storage_retention_saved": "Aufbewahrungsrichtlinie gespeichert.", - "dash_hostname_invalid": "Nur einen Hostnamen ohne http://, Port oder Pfad eingeben.", + "settings_iracing_title": "Automatische iRacing-Aufzeichnung", + "settings_iracing_description": "Fertige .ibt-Dateien automatisch auf dem Computer archivieren, auf dem RaceIQ läuft.", + "settings_iracing_opt_in_title": "Lokale Aufzeichnung ist optional", + "settings_iracing_opt_in_description": "Verbinden aktiviert die iRacing-Dateiaufzeichnung, solange dieses Backend läuft. Fertige .ibt-Dateien werden kopiert; Originale bleiben unverändert. Gespeicherte Dateien werden nicht automatisch importiert.", + "settings_iracing_connect": "Mit iRacing verbinden", + "settings_iracing_disconnect": "Trennen", + "settings_iracing_pending": "Aufzeichnung wird aktualisiert…", + "settings_iracing_armed": "Automatische Aufzeichnung bleibt bis zum Trennen oder Backend-Neustart aktiviert.", + "settings_iracing_state_unavailable": "Nicht verfügbar", + "settings_iracing_state_disconnected": "Getrennt", + "settings_iracing_state_waiting_iracing": "Warte auf iRacing", + "settings_iracing_state_waiting_recording": "Warte auf Aufzeichnung", + "settings_iracing_state_recording": "Aufzeichnung läuft", + "settings_iracing_state_saving": "Fertige Datei wird gespeichert", + "settings_iracing_state_error": "Aufzeichnungsfehler", + "settings_iracing_reason_unsupported": "Automatische Aufzeichnung benötigt das Windows-Backend auf demselben PC wie iRacing.", + "settings_iracing_reason_feature_disabled": "iRacing-Aufzeichnung ist in diesem Backend deaktiviert.", + "settings_iracing_reason_documents": "iRacing-Telemetrieordner nicht gefunden.", + "settings_iracing_reason_sdk": "Kommunikation mit iRacing fehlgeschlagen.", + "settings_iracing_reason_archive": "Telemetrie konnte nicht archiviert werden.", + "settings_iracing_unavailable_title": "Aufzeichnung nicht verfügbar", + "settings_iracing_query_error_title": "Aufzeichnungsstatus konnte nicht geladen werden", + "settings_iracing_query_error": "Statusabfrage fehlgeschlagen. Backend-Verbindung prüfen und Einstellungen erneut öffnen.", + "settings_iracing_mutation_error_title": "Aufzeichnung konnte nicht geändert werden", + "settings_iracing_mutation_error": "Backend hat Änderung nicht bestätigt. Status prüfen, bevor du es erneut versuchst.", + "settings_iracing_source_directory": "iRacing-Quellordner", + "settings_iracing_archive_directory": "RaceIQ-Archivordner", + "settings_iracing_saved_count": "Seit Backend-Start gespeicherte Dateien", + "settings_iracing_last_file": "Zuletzt gespeicherte Datei", + "settings_iracing_not_available": "Nicht verfügbar", + "settings_iracing_none": "Keine", + "settings_iracing_local_files_note": "Das Windows-Backend muss unter demselben Benutzer und in derselben Sitzung wie iRacing laufen. Ein entfernter Browser kann keine Dateien von einem anderen Gaming-PC erfassen.", + "home_clean_driving_score": "Sauberkeitswertung", + "home_clean_driving_score_description": "{clean}/{total} gültige gezeitete Runden. Boxenrunden ausgeschlossen. Runden-Gültigkeitswertung, kein iRacing Safety Rating.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Live-SDK-Telemetrie", + "iracing_live_receiving": "Telemetrie wird empfangen", + "iracing_live_disconnected": "Backend getrennt", + "iracing_live_waiting_title": "Warte auf iRacing-Telemetrie", + "iracing_live_waiting_description": "Starte iRacing auf dem Windows-PC mit RaceIQ und fahre eine Session. Live-Telemetrie läuft automatisch; IBT-Aufzeichnung ist optional.", + "iracing_live_inputs": "Fahrereingaben", + "iracing_live_motion": "Fahrzeugbewegung", + "iracing_live_suspension": "Federweg", + "iracing_live_lap_progress": "Rundenfortschritt", + "iracing_live_fuel_remaining": "Verbleibender Kraftstoff", + "iracing_live_lateral_g": "Querbeschleunigung", + "iracing_live_longitudinal_g": "Längsbeschleunigung", + "iracing_live_roll": "Wanken", + "iracing_live_pitch": "Nicken", + "iracing_live_snapshot_note": "Reifenkarkassentemperatur und Verschleiß werden beim Boxenstopp aktualisiert; der Druck entspricht dem kalten Setup-Druck. Dies sind keine Live-Reifenmesswerte.", + "iracing_live_position_note": "Nur Live-SDK-Daten. GPS-Fahrlinien erfordern eine aufgezeichnete .ibt-Datei.", + "iracing_incident_report_heading": "Vorfallbericht", + "iracing_incident_report_points": "Beobachtete Vorfallpunkte", + "iracing_incident_report_spans": "Beobachtete Off-Track-Abschnitte", + "iracing_incident_report_duration": "Stichprobenbasierte Off-Track-Zeit", + "iracing_incident_report_streak": "Längste aufgezeichnete Serie sauberer Runden", + "iracing_incident_report_windows": "Vollständig gelesene gespeicherte Rundenfenster {included}/{total}", + "iracing_incident_report_counter": "Persönlicher Zähler: {status}", + "iracing_incident_report_surface": "Oberflächenbelege: {status}", + "iracing_incident_report_available": "Verfügbar", + "iracing_incident_report_partial": "Teilweise", + "iracing_incident_report_unavailable": "Nicht verfügbar", + "iracing_incident_report_scope": "Nur aufgezeichnete Rundenbelege. Nicht aufgezeichnete Zeiträume werden nicht bewertet.", + "iracing_incident_report_explanation": "Ausgangswerte werden nicht addiert. Punktemarkierungen zeigen, wann ein Anstieg des persönlichen Zählers zwischen aufeinanderfolgenden Stichproben beobachtet wurde. Off-Track-Abschnitte beruhen auf Stichproben und sind auf gespeicherte Runden begrenzt. Keines davon belegt Schuld, Kollisionsart oder Safety Rating.", + "iracing_incident_report_loading": "Vorfallbericht wird geladen…", + "iracing_incident_report_error": "Vorfallbericht konnte nicht gelesen werden.", + "iracing_incident_report_retry": "Erneut versuchen", + "iracing_incident_report_no_evidence": "Für diese gespeicherten Rundenfenster sind keine Vorfall- oder Oberflächenbelege verfügbar.", + "iracing_incident_report_empty": "In den abgedeckten Daten wurden keine Punktanstiege oder Off-Track-Abschnitte beobachtet.", + "iracing_incident_report_point_event": "+{points} Vorfallpunkte beobachtet", + "iracing_incident_report_span_event": "Off-Track-Abschnitt", + "iracing_incident_report_sampled_duration": "Stichprobenbasierte Dauer {duration}", + "iracing_incident_report_clipped_start": "Anfang abgeschnitten", + "iracing_incident_report_clipped_end": "Ende abgeschnitten", + "iracing_incident_report_lap_time": "Runde {lap} · Session {time}", + "iracing_incident_report_bounds": "Beobachtet zwischen {previous} und {current} Simulator-Sessionzeit", + "iracing_incident_report_replay": "Replay · Rundenzeit {time}", + "iracing_incident_report_replay_link": "Replay öffnen", + "iracing_incident_report_no_replay": "Replay-Position nicht verfügbar", + "iracing_incident_report_events": "Zeitachse aufgezeichneter Ereignisse", + "iracing_incident_report_markers": "Aufgezeichnete Vorfallmarkierungen", + "iracing_incident_report_marker_loading": "Markierungen werden geladen…", + "iracing_incident_report_marker_error": "Vorfallmarkierungen konnten nicht gelesen werden.", + "iracing_incident_report_marker_unavailable": "Belege für Vorfallmarkierungen nicht verfügbar.", + "iracing_incident_report_marker_partial": "Unvollständige Belege oder Replay-Positionen nicht verfügbar.", + "iracing_incident_report_marker_empty": "In den abgedeckten Rundendaten wurden keine Ereignisse beobachtet.", + "iracing_replay_recorded": "Aufgezeichnete Telemetrie", + "iracing_replay_source_note": "Angezeigt werden nur in dieser Aufzeichnung vorhandene Kanäle.", + "iracing_replay_conditions": "Strecke & Wetter", + "iracing_replay_session": "Aufgezeichnete Sitzung", + "iracing_replay_session_time": "Simulator-Sitzungszeit", + "iracing_replay_coolant": "Kühlmitteltemperatur", + "iracing_replay_oil_temp": "Öltemperatur", + "iracing_replay_oil_pressure": "Öldruck", + "iracing_replay_fuel_pressure": "Kraftstoffdruck", + "iracing_replay_voltage": "Batteriespannung", + "iracing_replay_wind": "Windgeschwindigkeit", + "iracing_replay_wind_direction": "Windrichtung", + "iracing_replay_humidity": "Relative Luftfeuchtigkeit", + "iracing_replay_yaw_rate": "Gierrate", + "iracing_replay_shock_velocity": "Dämpfergeschwindigkeit", + "iracing_replay_brake_line_pressure": "Bremsleitungsdruck", + "iracing_replay_fuel_used": "Kraftstoffverbrauch in aufgezeichneter Runde", + "iracing_replay_steering_torque": "Lenkraddrehmoment", + "iracing_replay_personal_counter": "Persönlicher Vorfallzähler", + "iracing_replay_pit_road": "Auf der Boxengasse", + "iracing_replay_pit_snapshots": "Aufgezeichnete Boxenstopps", + "iracing_replay_empty": "An dieser Replay-Position sind keine verwendbaren Telemetriekanäle vorhanden.", "sessions_capture_split_queued": "Für verlustfreien Lap-Export vorgemerkt", "sessions_capture_split_splitting": "Aufzeichnung wird in Laps aufgeteilt", "sessions_capture_split_verifying": "Lap-Aufzeichnungen werden überprüft", diff --git a/client/messages/en.json b/client/messages/en.json index b0bf04056..a3d41dd1b 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -1034,6 +1034,13 @@ "carwire_dims": "Dims", "carwire_tire_info": "Tire Info", "carwire_track": "Track", + "replay_track_loading": "Loading track geometry…", + "replay_track_error": "Track geometry could not be loaded.", + "replay_track_unavailable": "Track placement unavailable: this replay needs aligned track geometry and valid lap distance or native position.", + "replay_track_projected": "Position and edges use the official map — not a measured racing line or surveyed track width.", + "replay_track_measured": "Measured IBT trajectory; official map is approximate reference only.", + "replay_track_bounds_unavailable": "Track edges unavailable for this layout. Position uses lap distance, not the measured racing line.", + "carwire_snapshot_stale": "Stale snapshot · not live", "carwire_trails": "Trails", "carwire_saved": "Saved", "sectortimes_s1": "S1", @@ -1718,9 +1725,9 @@ "analyse_importable_laps": "Importable laps", "analyse_on_track_rows": "on-track rows · max {speed} mph", "analyse_import_cannot": "This recording cannot be imported.", - "analyse_ibt_note": "Import creates a normal RaceIQ iRacing session and canonical .bin capture. The original .ibt file is not copied into session storage.", + "analyse_ibt_note": "Import preserves the original .ibt recording at its native cadence. No BIN conversion.", "session_import_title": "Import session data", - "session_choose_file_hint": "Choose a file. Format and game metadata are checked from its contents.", + "session_choose_file_hint": "Drag and drop a file here, or choose a file. Format and game metadata are checked from its contents.", "session_choose_file": "Choose file", "session_no_file": "No file selected", "session_reading": "Reading file contents…", @@ -1728,7 +1735,7 @@ "session_unsupported": "File contents are not supported.", "session_game_detected": "Game detected from telemetry content.", "session_captures_found": "{count} RaceIQ capture(s) found.", - "session_ibt_hint": "iRacing imports require preview and confirmation from Analyse.", + "session_ibt_hint": "Choose Import to preview the iRacing recording and confirm its laps.", "session_motec_hint": "MoTeC imports require game, car, and track setup from Analyse.", "session_imported": "Imported {count} lap(s).{skipped}", "session_skipped": " Skipped {count}.", @@ -2759,7 +2766,114 @@ "storage_delete_after": "Delete after", "storage_retention_policy_error": "Automatic deletion must be scheduled after automatic downsampling.", "storage_retention_saved": "Retention policy saved.", - "dash_hostname_invalid": "Enter a hostname only, without http://, a port or a path.", + "settings_iracing_title": "Automatic iRacing capture", + "settings_iracing_description": "Automatically archive completed .ibt files on the computer running RaceIQ.", + "settings_iracing_opt_in_title": "Local capture is opt-in", + "settings_iracing_opt_in_description": "Connect enables iRacing disk telemetry while this backend is running. Completed .ibt files are copied; originals stay untouched. Your choice is saved across backend restarts. Saved files are not automatically imported.", + "settings_iracing_connect": "Connect to iRacing", + "settings_iracing_disconnect": "Disconnect", + "settings_iracing_pending": "Updating capture…", + "settings_iracing_armed": "Automatic capture remains enabled across backend restarts until you disconnect.", + "settings_iracing_state_unavailable": "Unavailable", + "settings_iracing_state_disconnected": "Disconnected", + "settings_iracing_state_waiting_iracing": "Waiting for iRacing", + "settings_iracing_state_waiting_recording": "Waiting for recording", + "settings_iracing_state_recording": "Recording", + "settings_iracing_state_saving": "Saving completed file", + "settings_iracing_state_error": "Capture error", + "settings_iracing_reason_unsupported": "Automatic capture requires the Windows backend on the same PC as iRacing.", + "settings_iracing_reason_feature_disabled": "iRacing capture is disabled by this backend.", + "settings_iracing_reason_documents": "Could not locate the iRacing telemetry directory.", + "settings_iracing_reason_sdk": "Could not communicate with the iRacing simulator.", + "settings_iracing_reason_archive": "Could not save telemetry to the archive directory.", + "settings_iracing_unavailable_title": "Capture unavailable", + "settings_iracing_query_error_title": "Could not load capture status", + "settings_iracing_query_error": "Status request failed. Check backend connection and retry by reopening Settings.", + "settings_iracing_mutation_error_title": "Could not update capture", + "settings_iracing_mutation_error": "The backend did not confirm this capture change. Check status before trying again.", + "settings_iracing_source_directory": "iRacing source directory", + "settings_iracing_archive_directory": "RaceIQ archive directory", + "settings_iracing_saved_count": "Files saved this runtime", + "settings_iracing_last_file": "Last saved file", + "settings_iracing_not_available": "Not available", + "settings_iracing_none": "None", + "settings_iracing_local_files_note": "Run the Windows backend under the same user and session as iRacing. A remote browser cannot collect files from a different gaming PC.", + "home_clean_driving_score": "Clean driving score", + "home_clean_driving_score_description": "{clean}/{total} valid timed laps. Pit laps excluded. Lap-validity score, not iRacing Safety Rating.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Live SDK telemetry", + "iracing_live_receiving": "Receiving telemetry", + "iracing_live_disconnected": "Backend disconnected", + "iracing_live_waiting_title": "Waiting for iRacing telemetry", + "iracing_live_waiting_description": "Start iRacing on the Windows PC running RaceIQ and enter a driving session. Live telemetry is automatic; IBT capture is optional.", + "iracing_live_inputs": "Driver inputs", + "iracing_live_motion": "Vehicle motion", + "iracing_live_suspension": "Suspension travel", + "iracing_live_lap_progress": "Lap progress", + "iracing_live_fuel_remaining": "Fuel remaining", + "iracing_live_lateral_g": "Lateral G", + "iracing_live_longitudinal_g": "Longitudinal G", + "iracing_live_roll": "Roll", + "iracing_live_pitch": "Pitch", + "iracing_live_snapshot_note": "Tire carcass temperature and wear update at pit stops; pressure is cold setup pressure. These are not live tire measurements.", + "iracing_live_position_note": "Live SDK data only. GPS driving lines require a recorded .ibt file.", + "iracing_incident_report_heading": "Incident report", + "iracing_incident_report_points": "Observed incident points", + "iracing_incident_report_spans": "Observed off-track spans", + "iracing_incident_report_duration": "Sampled off-track time", + "iracing_incident_report_streak": "Longest recorded clean-lap streak", + "iracing_incident_report_windows": "Fully read saved lap windows {included}/{total}", + "iracing_incident_report_counter": "Personal counter: {status}", + "iracing_incident_report_surface": "Surface evidence: {status}", + "iracing_incident_report_available": "Available", + "iracing_incident_report_partial": "Partial", + "iracing_incident_report_unavailable": "Unavailable", + "iracing_incident_report_scope": "Recorded lap evidence only. Unrecorded intervals are not assessed.", + "iracing_incident_report_explanation": "Baseline points are not added. Point markers show when a personal counter increase was observed, between consecutive samples. Off-track spans are sampled and clipped to saved laps. Neither proves fault, collision type, or Safety Rating.", + "iracing_incident_report_loading": "Loading incident report…", + "iracing_incident_report_error": "Incident report could not be read.", + "iracing_incident_report_retry": "Retry", + "iracing_incident_report_no_evidence": "Incident and surface evidence unavailable for these saved lap windows.", + "iracing_incident_report_empty": "No point increases/off-track spans observed in covered data.", + "iracing_incident_report_point_event": "+{points} incident points observed", + "iracing_incident_report_span_event": "Off-track span", + "iracing_incident_report_sampled_duration": "Sampled duration {duration}", + "iracing_incident_report_clipped_start": "Clipped start", + "iracing_incident_report_clipped_end": "Clipped end", + "iracing_incident_report_lap_time": "Lap {lap} · session {time}", + "iracing_incident_report_bounds": "Observed between {previous} and {current} simulator session time", + "iracing_incident_report_replay": "Replay · lap time {time}", + "iracing_incident_report_replay_link": "Open replay", + "iracing_incident_report_no_replay": "Replay position unavailable", + "iracing_incident_report_events": "Recorded event timeline", + "iracing_incident_report_markers": "Recorded incident markers", + "iracing_incident_report_marker_loading": "Loading markers…", + "iracing_incident_report_marker_error": "Incident markers could not be read.", + "iracing_incident_report_marker_unavailable": "Incident marker evidence unavailable.", + "iracing_incident_report_marker_partial": "Partial evidence or replay positions unavailable.", + "iracing_incident_report_marker_empty": "No events observed in covered lap data.", + "iracing_replay_recorded": "Recorded telemetry", + "iracing_replay_source_note": "Only channels present in this recording are shown.", + "iracing_replay_conditions": "Track & weather", + "iracing_replay_session": "Recorded session", + "iracing_replay_session_time": "Simulator session time", + "iracing_replay_coolant": "Coolant temperature", + "iracing_replay_oil_temp": "Oil temperature", + "iracing_replay_oil_pressure": "Oil pressure", + "iracing_replay_fuel_pressure": "Fuel pressure", + "iracing_replay_voltage": "Battery voltage", + "iracing_replay_wind": "Wind speed", + "iracing_replay_wind_direction": "Wind direction", + "iracing_replay_humidity": "Relative humidity", + "iracing_replay_yaw_rate": "Yaw rate", + "iracing_replay_shock_velocity": "Shock velocity", + "iracing_replay_brake_line_pressure": "Brake line pressure", + "iracing_replay_fuel_used": "Fuel used in recorded lap", + "iracing_replay_steering_torque": "Steering wheel torque", + "iracing_replay_personal_counter": "Personal incident counter", + "iracing_replay_pit_road": "On pit road", + "iracing_replay_pit_snapshots": "Recorded pit snapshots", + "iracing_replay_empty": "No usable telemetry channels at this replay position.", "sessions_capture_split_queued": "Queued for lossless lap export", "sessions_capture_split_splitting": "Splitting recording into laps", "sessions_capture_split_verifying": "Verifying lap recordings", diff --git a/client/messages/es.json b/client/messages/es.json index a84b254ce..62942805c 100644 --- a/client/messages/es.json +++ b/client/messages/es.json @@ -1032,6 +1032,13 @@ "carwire_dims": "Dimensiones", "carwire_tire_info": "Info. de neumáticos", "carwire_track": "Pista", + "replay_track_loading": "Cargando geometría de la pista…", + "replay_track_error": "No se pudo cargar la geometría de la pista.", + "replay_track_unavailable": "Posición en pista no disponible: esta repetición necesita geometría alineada y distancia de vuelta válida o posición nativa.", + "replay_track_projected": "Posición y bordes según el mapa oficial — no son una trazada medida ni un ancho de pista topográfico.", + "replay_track_measured": "Trayectoria IBT medida; el mapa oficial es solo una referencia aproximada.", + "replay_track_bounds_unavailable": "Bordes no disponibles para este trazado. La posición usa la distancia de vuelta, no la trazada medida.", + "carwire_snapshot_stale": "Instantánea antigua · no en directo", "carwire_trails": "Estelas", "carwire_saved": "Guardado", "sectortimes_s1": "S1", @@ -1716,9 +1723,9 @@ "analyse_importable_laps": "Vueltas importables", "analyse_on_track_rows": "filas en pista · máx. {speed} mph", "analyse_import_cannot": "No se puede importar esta grabación.", - "analyse_ibt_note": "La importación crea una sesión normal de iRacing en RaceIQ y una captura .bin normalizada. El archivo .ibt original no se copia al almacenamiento de sesiones.", + "analyse_ibt_note": "La importación conserva la grabación .ibt original con su frecuencia de muestreo nativa. Sin conversión a BIN.", "session_import_title": "Importar datos de sesión", - "session_choose_file_hint": "Elige un archivo. Se comprobarán el formato y los metadatos del juego a partir de su contenido.", + "session_choose_file_hint": "Arrastra y suelta un archivo aquí, o elige uno. Se comprobarán el formato y los metadatos del juego a partir de su contenido.", "session_choose_file": "Elegir archivo", "session_no_file": "No hay ningún archivo seleccionado", "session_reading": "Leyendo contenido del archivo…", @@ -1726,7 +1733,7 @@ "session_unsupported": "El contenido del archivo no es compatible.", "session_game_detected": "Juego detectado a partir del contenido de telemetría.", "session_captures_found": "Se encontraron {count} captura(s) de RaceIQ.", - "session_ibt_hint": "Las importaciones de iRacing requieren vista previa y confirmación desde Análisis.", + "session_ibt_hint": "Selecciona Importar para previsualizar la grabación de iRacing y confirmar sus vueltas.", "session_motec_hint": "Las importaciones de MoTeC requieren configurar el juego, el coche y el circuito desde Análisis.", "session_imported": "Se importaron {count} vuelta(s).{skipped}", "session_skipped": " Se omitieron {count}.", @@ -2704,6 +2711,114 @@ "access_origins_safety": "Los cambios de red requieren reiniciar RaceIQ. Desactivarla no borra los orígenes guardados. Las protecciones del mismo origen continúan.", "dash_hostname_origin_settings": "Añade este origen exacto en Ajustes → Acceso y seguridad → dominios del navegador: {origin}. Guardar el nombre de host no autoriza el acceso.", "dash_hostname_origin_managed": "Añade este origen exacto a SERVER_ALLOWED_ORIGINS: {origin}. Guardar el nombre de host no autoriza el acceso.", + "settings_iracing_title": "Captura automática de iRacing", + "settings_iracing_description": "Archiva automáticamente los archivos .ibt finalizados en el equipo donde se ejecuta RaceIQ.", + "settings_iracing_opt_in_title": "La captura local requiere tu autorización", + "settings_iracing_opt_in_description": "Conectar activa la telemetría en disco de iRacing mientras se ejecuta este backend. Los archivos .ibt finalizados se copian; los originales no se modifican. Los archivos guardados no se importan automáticamente.", + "settings_iracing_connect": "Conéctate a iRacing", + "settings_iracing_disconnect": "Desconectar", + "settings_iracing_pending": "Actualizando captura…", + "settings_iracing_armed": "La captura automática permanece activa hasta que te desconectes o reinicies el backend.", + "settings_iracing_state_unavailable": "Indisponible", + "settings_iracing_state_disconnected": "Desconectado", + "settings_iracing_state_waiting_iracing": "Esperando a iRacing", + "settings_iracing_state_waiting_recording": "Esperando grabación", + "settings_iracing_state_recording": "Grabación", + "settings_iracing_state_saving": "Guardando archivo finalizado", + "settings_iracing_state_error": "Error de captura", + "settings_iracing_reason_unsupported": "La captura automática requiere el backend de Windows en el mismo equipo que iRacing.", + "settings_iracing_reason_feature_disabled": "La captura de iRacing está deshabilitada por este backend.", + "settings_iracing_reason_documents": "No se pudo ubicar el directorio de telemetría de iRacing.", + "settings_iracing_reason_sdk": "No se pudo comunicar con el simulador de iRacing.", + "settings_iracing_reason_archive": "No se pudo guardar la telemetría en el directorio de archivo.", + "settings_iracing_unavailable_title": "Captura no disponible", + "settings_iracing_query_error_title": "No se pudo cargar el estado de captura", + "settings_iracing_query_error": "La solicitud de estado falló. Verifique la conexión de backend y vuelva a intentarlo volviendo a abrir Configuración.", + "settings_iracing_mutation_error_title": "No se pudo actualizar la captura", + "settings_iracing_mutation_error": "El backend no confirmó este cambio de captura. Verifique el estado antes de volver a intentarlo.", + "settings_iracing_source_directory": "Directorio fuente de iRacing", + "settings_iracing_archive_directory": "Directorio de archivos de RaceIQ", + "settings_iracing_saved_count": "Archivos guardados desde el inicio del backend", + "settings_iracing_last_file": "Último archivo guardado", + "settings_iracing_not_available": "No disponible", + "settings_iracing_none": "Ninguno", + "settings_iracing_local_files_note": "Ejecuta el backend de Windows con el mismo usuario y en la misma sesión que iRacing. Un navegador remoto no puede recoger archivos de otro equipo de juegos.", + "home_clean_driving_score": "Puntuación de conducción limpia", + "home_clean_driving_score_description": "{clean}/{total} vueltas cronometradas válidas. Excluye vueltas de boxes. Puntuación de validez, no el Safety Rating de iRacing.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Telemetría SDK en directo", + "iracing_live_receiving": "Recibiendo telemetría", + "iracing_live_disconnected": "Backend desconectado", + "iracing_live_waiting_title": "Esperando telemetría de iRacing", + "iracing_live_waiting_description": "Inicia iRacing en el PC con Windows que ejecuta RaceIQ y entra en una sesión de conducción. La telemetría en directo es automática; la captura IBT es opcional.", + "iracing_live_inputs": "Controles del piloto", + "iracing_live_motion": "Movimiento del vehículo", + "iracing_live_suspension": "Recorrido de suspensión", + "iracing_live_lap_progress": "Progreso de vuelta", + "iracing_live_fuel_remaining": "Combustible restante", + "iracing_live_lateral_g": "Aceleración lateral", + "iracing_live_longitudinal_g": "Aceleración longitudinal", + "iracing_live_roll": "Balanceo", + "iracing_live_pitch": "Cabeceo", + "iracing_live_snapshot_note": "La temperatura de la carcasa y el desgaste de los neumáticos se actualizan en las paradas; la presión corresponde al setup en frío. No son mediciones de neumáticos en directo.", + "iracing_live_position_note": "Solo datos SDK en directo. Las trazadas GPS requieren un archivo .ibt grabado.", + "iracing_incident_report_heading": "Informe de incidentes", + "iracing_incident_report_points": "Puntos de incidente observados", + "iracing_incident_report_spans": "Tramos fuera de pista observados", + "iracing_incident_report_duration": "Tiempo fuera de pista muestreado", + "iracing_incident_report_streak": "Racha más larga de vueltas limpias registradas", + "iracing_incident_report_windows": "Ventanas de vuelta guardadas leídas por completo {included}/{total}", + "iracing_incident_report_counter": "Contador personal: {status}", + "iracing_incident_report_surface": "Evidencia de superficie: {status}", + "iracing_incident_report_available": "Disponible", + "iracing_incident_report_partial": "Parcial", + "iracing_incident_report_unavailable": "No disponible", + "iracing_incident_report_scope": "Solo evidencia de vueltas registradas. No se evalúan intervalos no registrados.", + "iracing_incident_report_explanation": "Los valores iniciales no se suman. Los marcadores de puntos indican cuándo se observó un aumento del contador personal entre muestras consecutivas. Los tramos fuera de pista se basan en muestras y se limitan a las vueltas guardadas. Ninguno demuestra culpabilidad, tipo de colisión ni Safety Rating.", + "iracing_incident_report_loading": "Cargando informe de incidentes…", + "iracing_incident_report_error": "No se pudo leer el informe de incidentes.", + "iracing_incident_report_retry": "Reintentar", + "iracing_incident_report_no_evidence": "No hay evidencia de incidentes ni de superficie en estas ventanas de vuelta guardadas.", + "iracing_incident_report_empty": "No se observaron aumentos de puntos ni tramos fuera de pista en los datos cubiertos.", + "iracing_incident_report_point_event": "+{points} puntos de incidente observados", + "iracing_incident_report_span_event": "Tramo fuera de pista", + "iracing_incident_report_sampled_duration": "Duración muestreada {duration}", + "iracing_incident_report_clipped_start": "Inicio recortado", + "iracing_incident_report_clipped_end": "Final recortado", + "iracing_incident_report_lap_time": "Vuelta {lap} · sesión {time}", + "iracing_incident_report_bounds": "Observado entre {previous} y {current} de tiempo de sesión del simulador", + "iracing_incident_report_replay": "Replay · tiempo de vuelta {time}", + "iracing_incident_report_replay_link": "Abrir replay", + "iracing_incident_report_no_replay": "Posición del replay no disponible", + "iracing_incident_report_events": "Cronología de eventos registrados", + "iracing_incident_report_markers": "Marcadores de incidentes registrados", + "iracing_incident_report_marker_loading": "Cargando marcadores…", + "iracing_incident_report_marker_error": "No se pudieron leer los marcadores de incidentes.", + "iracing_incident_report_marker_unavailable": "Evidencia de marcadores de incidentes no disponible.", + "iracing_incident_report_marker_partial": "Evidencia parcial o posiciones de replay no disponibles.", + "iracing_incident_report_marker_empty": "No se observaron eventos en los datos de vuelta cubiertos.", + "iracing_replay_recorded": "Telemetría grabada", + "iracing_replay_source_note": "Solo se muestran los canales presentes en esta grabación.", + "iracing_replay_conditions": "Pista y meteorología", + "iracing_replay_session": "Sesión grabada", + "iracing_replay_session_time": "Tiempo de sesión del simulador", + "iracing_replay_coolant": "Temperatura del refrigerante", + "iracing_replay_oil_temp": "Temperatura del aceite", + "iracing_replay_oil_pressure": "Presión del aceite", + "iracing_replay_fuel_pressure": "Presión del combustible", + "iracing_replay_voltage": "Voltaje de la batería", + "iracing_replay_wind": "Velocidad del viento", + "iracing_replay_wind_direction": "Dirección del viento", + "iracing_replay_humidity": "Humedad relativa", + "iracing_replay_yaw_rate": "Velocidad de guiñada", + "iracing_replay_shock_velocity": "Velocidad del amortiguador", + "iracing_replay_brake_line_pressure": "Presión de la línea de freno", + "iracing_replay_fuel_used": "Combustible usado en la vuelta grabada", + "iracing_replay_steering_torque": "Par del volante", + "iracing_replay_personal_counter": "Contador personal de incidentes", + "iracing_replay_pit_road": "En la calle de boxes", + "iracing_replay_pit_snapshots": "Instantáneas de boxes grabadas", + "iracing_replay_empty": "No hay canales de telemetría utilizables en esta posición de replay.", "storage_downsample_action": "Reducir frecuencia de grabaciones…", "storage_downsample_title": "Reducir frecuencia de grabaciones", "storage_downsample_description": "Reduce la frecuencia de telemetría almacenada. Las grabaciones favoritas y las importaciones de MoTeC están protegidas.", diff --git a/client/messages/fi.json b/client/messages/fi.json index 21797f19d..5f93df332 100644 --- a/client/messages/fi.json +++ b/client/messages/fi.json @@ -1663,285 +1663,285 @@ "trackinfo_night_racing": "Yökilpailut", "trackinfo_pit_stalls": "{n} varikkopaikkaa", "trackinfo_guide": "Opas", -"trackinfo_priority": "tärkeys", -"trackinfo_trap": "Ansa:", -"dev_state_title": "Kehittäjän tila", -"dev_state_pause": "Keskeytä", -"dev_state_resume": "Jatka", -"dev_state_server": "Palvelin", -"dev_state_store": "TanStack-tietovarasto", -"dev_state_waiting": "Odotetaan...", -"race_lap": "Kierros", -"insight_previous_event": "Edellinen {label}-tapahtuma", -"insight_next_event": "Seuraava {label}-tapahtuma", -"insight_no_findings": "Ei havaintoja", -"insight_checks": "Tunnistimen tarkistukset", -"insight_check_finding": "Havainto", -"insight_check_clear": "Tarkistettu, ei havaintoja", -"nodata_iracing_guide_toggle": "Näin yhdistät iRacingiin", -"nodata_iracing_step_start": "Käynnistä iRacing-simulaattori tällä Windows-tietokoneella.", -"nodata_iracing_step_enter": "Astu autoon ja aloita ajaminen testi-, harjoitus-, aika-ajo- tai kilpailuistunnossa.", -"nodata_iracing_step_sdk": "RaceIQ yhdistää suoraan iRacingin natiivin jaetun muistin SDK:hon; UDP-asetuksia tai erillistä SDK-käärettä ei tarvita.", -"analyse_lap_option": "Kierros {lap} – {time} — {ownership}{invalid}", -"analyse_session_group": "Ajokerta · {date} {time} · {count} kierrosta", -"analyse_unavailable_features_aria": "Analysoinnissa käytöstä poissa olevat ominaisuudet", -"analyse_unavailable_title": "Ei käytettävissä analysoinnissa", -"analyse_missing": "Puuttuu: {ids}", -"analyse_motec_info_aria": "MoTeC-tuonnin tiedot", -"analyse_motec_info_title": "MoTeC-tuonnin tiedot", -"analyse_available": "Käytettävissä", -"analyse_done": "Valmis", -"analyse_motec_guidance": "Käytä MoTeC-tuonteja ensisijaisesti likimääräisen ajolinjan muodon ja kuljettajan syötteiden vertailuun, äläkä RaceIQ:n natiivin telemetrian täydellisenä korvikkeena.", -"analyse_motec_limits_title": "Mitä näistä tiedoista voi ja ei voi päätellä", -"analyse_canonical_channels": "Vakiokanavat", -"analyse_imported_laps_from": "Tuotiin {count} kierrosta kohteesta {venue}{driver}", -"analyse_lap_short": "Kierros {lap}", -"analyse_pause_title": "Keskeytä (välilyönti)", -"analyse_play_title": "Toista (välilyönti)", -"analyse_pause_aria": "Keskeytä toisto", -"analyse_play_aria": "Jatka toistoa", -"analyse_gap": "{delta} s ero", -"analyse_lap_number": "Kierros {lap}", -"analyse_change_zoom_aria": "Muuta radan zoomauksen toimintaa", -"analyse_change_zoom_title": "Muuta radan zoomauksen toimintaa", -"analyse_chart_throttle": "Kaasu %", -"analyse_chart_brake": "Jarru %", -"analyse_chart_steering": "Ohjaus", -"analyse_track": "Rata", -"analyse_air": "Ilma", -"analyse_import_preview_title": "iRacing IBT -tuonnin esikatselu", -"analyse_recording": "Tallenne", -"analyse_driving": "Ajaminen", -"analyse_importable_laps": "Tuotavat kierrokset", -"analyse_on_track_rows": "radalla-riviä · enintään {speed} mph", -"analyse_import_cannot": "Tätä tallennetta ei voi tuoda.", -"analyse_ibt_note": "Tuonti luo tavallisen RaceIQ:n iRacing-ajokerran ja vakiomuotoisen .bin-tallenteen. Alkuperäistä .ibt-tiedostoa ei kopioida ajokerran tallennustilaan.", -"session_import_title": "Tuo ajokerran tiedot", -"session_choose_file_hint": "Valitse tiedosto. Muoto ja pelin metatiedot tarkistetaan sen sisällöstä.", -"session_choose_file": "Valitse tiedosto", -"session_no_file": "Tiedostoa ei ole valittu", -"session_reading": "Luetaan tiedoston sisältöä…", -"session_detected": "Tunnistettu: {format}", -"session_unsupported": "Tiedoston sisältöä ei tueta.", -"session_game_detected": "Peli tunnistettiin telemetriatiedoista.", -"session_captures_found": "Löytyi {count} RaceIQ-tallennetta.", -"session_ibt_hint": "iRacing-tuonnit on esikatseltava ja vahvistettava Analysoi-näkymässä.", -"session_motec_hint": "MoTeC-tuonnissa peli, auto ja rata määritetään Analysoi-näkymässä.", -"session_imported": "Tuotiin {count} kierrosta.{skipped}", -"session_skipped": " Ohitettiin {count}.", -"session_format_zip": "ZIP-arkisto (.zip)", -"session_format_bin": "Telemetriatallenne (.bin)", -"session_format_ibt": "iRacing-telemetria (.ibt)", -"session_format_motec": "MoTeC-loki (.ld)", -"session_format_unknown": "Tuntematon tiedostomuoto", -"f1live_drs_off": "POIS", -"f1live_unknown": "Tuntematon", -"f1live_ers_none": "Ei mitään", -"f1live_ers_low": "Matala", -"f1live_ers_high": "Korkea", -"f1live_ers_overtake": "Ohitus", -"f1live_weather_unknown": "Tuntematon", -"f1live_weather_track": "Rata", -"analyse_import_motec_title": "Tuo MoTeC-loki", -"analyse_select_ldx": "Valitse .ldx-signaalitiedosto ennen tuontia.", + "trackinfo_priority": "tärkeys", + "trackinfo_trap": "Ansa:", + "dev_state_title": "Kehittäjän tila", + "dev_state_pause": "Keskeytä", + "dev_state_resume": "Jatka", + "dev_state_server": "Palvelin", + "dev_state_store": "TanStack-tietovarasto", + "dev_state_waiting": "Odotetaan...", + "race_lap": "Kierros", + "insight_previous_event": "Edellinen {label}-tapahtuma", + "insight_next_event": "Seuraava {label}-tapahtuma", + "insight_no_findings": "Ei havaintoja", + "insight_checks": "Tunnistimen tarkistukset", + "insight_check_finding": "Havainto", + "insight_check_clear": "Tarkistettu, ei havaintoja", + "nodata_iracing_guide_toggle": "Näin yhdistät iRacingiin", + "nodata_iracing_step_start": "Käynnistä iRacing-simulaattori tällä Windows-tietokoneella.", + "nodata_iracing_step_enter": "Astu autoon ja aloita ajaminen testi-, harjoitus-, aika-ajo- tai kilpailuistunnossa.", + "nodata_iracing_step_sdk": "RaceIQ yhdistää suoraan iRacingin natiivin jaetun muistin SDK:hon; UDP-asetuksia tai erillistä SDK-käärettä ei tarvita.", + "analyse_lap_option": "Kierros {lap} – {time} — {ownership}{invalid}", + "analyse_session_group": "Ajokerta · {date} {time} · {count} kierrosta", + "analyse_unavailable_features_aria": "Analysoinnissa käytöstä poissa olevat ominaisuudet", + "analyse_unavailable_title": "Ei käytettävissä analysoinnissa", + "analyse_missing": "Puuttuu: {ids}", + "analyse_motec_info_aria": "MoTeC-tuonnin tiedot", + "analyse_motec_info_title": "MoTeC-tuonnin tiedot", + "analyse_available": "Käytettävissä", + "analyse_done": "Valmis", + "analyse_motec_guidance": "Käytä MoTeC-tuonteja ensisijaisesti likimääräisen ajolinjan muodon ja kuljettajan syötteiden vertailuun, äläkä RaceIQ:n natiivin telemetrian täydellisenä korvikkeena.", + "analyse_motec_limits_title": "Mitä näistä tiedoista voi ja ei voi päätellä", + "analyse_canonical_channels": "Vakiokanavat", + "analyse_imported_laps_from": "Tuotiin {count} kierrosta kohteesta {venue}{driver}", + "analyse_lap_short": "Kierros {lap}", + "analyse_pause_title": "Keskeytä (välilyönti)", + "analyse_play_title": "Toista (välilyönti)", + "analyse_pause_aria": "Keskeytä toisto", + "analyse_play_aria": "Jatka toistoa", + "analyse_gap": "{delta} s ero", + "analyse_lap_number": "Kierros {lap}", + "analyse_change_zoom_aria": "Muuta radan zoomauksen toimintaa", + "analyse_change_zoom_title": "Muuta radan zoomauksen toimintaa", + "analyse_chart_throttle": "Kaasu %", + "analyse_chart_brake": "Jarru %", + "analyse_chart_steering": "Ohjaus", + "analyse_track": "Rata", + "analyse_air": "Ilma", + "analyse_import_preview_title": "iRacing IBT -tuonnin esikatselu", + "analyse_recording": "Tallenne", + "analyse_driving": "Ajaminen", + "analyse_importable_laps": "Tuotavat kierrokset", + "analyse_on_track_rows": "radalla-riviä · enintään {speed} mph", + "analyse_import_cannot": "Tätä tallennetta ei voi tuoda.", + "analyse_ibt_note": "Tuonti säilyttää alkuperäisen .ibt-tallenteen alkuperäisellä näytteenottotaajuudella. Ei BIN-muunnosta.", + "session_import_title": "Tuo ajokerran tiedot", + "session_choose_file_hint": "Vedä ja pudota tiedosto tähän tai valitse tiedosto. Muoto ja pelin metatiedot tarkistetaan sen sisällöstä.", + "session_choose_file": "Valitse tiedosto", + "session_no_file": "Tiedostoa ei ole valittu", + "session_reading": "Luetaan tiedoston sisältöä…", + "session_detected": "Tunnistettu: {format}", + "session_unsupported": "Tiedoston sisältöä ei tueta.", + "session_game_detected": "Peli tunnistettiin telemetriatiedoista.", + "session_captures_found": "Löytyi {count} RaceIQ-tallennetta.", + "session_ibt_hint": "Valitse Tuo esikatsellaksesi iRacing-tallennetta ja vahvistaaksesi sen kierrokset.", + "session_motec_hint": "MoTeC-tuonnissa peli, auto ja rata määritetään Analysoi-näkymässä.", + "session_imported": "Tuotiin {count} kierrosta.{skipped}", + "session_skipped": " Ohitettiin {count}.", + "session_format_zip": "ZIP-arkisto (.zip)", + "session_format_bin": "Telemetriatallenne (.bin)", + "session_format_ibt": "iRacing-telemetria (.ibt)", + "session_format_motec": "MoTeC-loki (.ld)", + "session_format_unknown": "Tuntematon tiedostomuoto", + "f1live_drs_off": "POIS", + "f1live_unknown": "Tuntematon", + "f1live_ers_none": "Ei mitään", + "f1live_ers_low": "Matala", + "f1live_ers_high": "Korkea", + "f1live_ers_overtake": "Ohitus", + "f1live_weather_unknown": "Tuntematon", + "f1live_weather_track": "Rata", + "analyse_import_motec_title": "Tuo MoTeC-loki", + "analyse_select_ldx": "Valitse .ldx-signaalitiedosto ennen tuontia.", "analyse_motec_unsupported_game": "MoTeC-tuontia ei vielä tueta pelille {game}. Käytä sen sijaan RaceIQ:n omaa tallennetta.", -"analyse_search_cars": "Hae autoja...", -"analyse_search_tracks": "Hae ratoja...", -"analyse_search_setups": "Hae säätöjä...", -"analyse_pick_car": "Valitse ensin auto", -"analyse_setup_optional": "Säätö (valinnainen)", -"analyse_no_log": "Lokia ei ole valittu", -"analyse_importing": "Tuodaan…", -"analyse_zoom_in": "Lähennä karttaa", -"analyse_zoom_out": "Loitonna karttaa", -"analyse_lap_timeline": "Kierroksen aikajana", -"analyse_unknown_class": "Tuntematon luokka", -"analyse_import_close": "Sulje", -"analyse_import_cancel": "Peruuta", -"analyse_import_button": "Tuo", -"analyse_surface": "Pinta", -"analyse_tire_temp": "Renkaan lämpötila", -"analyse_combined": "Yhdistetty", -"analyse_decision": "Päätös", -"analyse_yaw": "Kiertymä", -"analyse_unavailable_short": "Ei käytettävissä", -"analyse_balance_tooltip": "Tasapainon työkaluvihje", -"analyse_dynamics_explanation": "Kiertymänopeus suhteessa ajolinjan kaarevuuteen + etu- ja takarenkaiden sivuluisukulman ero.", -"analyse_dynamics_explanation_yaw": "Kiertymänopeus suhteessa ajolinjan kaarevuuteen (renkaiden sivuluisukulmia ei ole saatavilla tässä pelissä).", -"analyse_dynamics_gated": "Rajattu ehdolla |latG| ≥ 0,25 g — suoran pyöränsutina ohitetaan", -"analyse_dynamics_signals_agree": "SIGNAALIT YHTÄ MIELTÄ — yhdistetty painotuksella 50/50", -"analyse_dynamics_conflict": "RISTIRIITA — käytetään vain sivuluisukulmaa", -"analyse_dynamics_yaw_only": "VAIN KIERTYMÄ — kaarevuussignaali", -"analyse_dynamics_understeer": "+ = aliohjaus (etuosan sivuluisu > takaosan) | − = yliohjaus (runko kiertyy Ay/V:n ohi)", -"analyse_dynamics_oversteer": "+ = aliohjaus (kiertyy liian vähän) | − = yliohjaus (kiertyy liikaa)", -"analyse_unknown_venue": "tuntematon rata", -"analyse_track_zoom_in": "Lähennä karttaa", -"analyse_track_zoom_out": "Loitonna karttaa", -"analyse_ibt_rows": "{count} riviä, {rate} Hz", -"analyse_ibt_driving": "{count} radalla-riviä · enintään {speed} mph", -"analyse_ibt_import": "Tuonti luo tavallisen RaceIQ:n iRacing-ajokerran ja vakiomuotoisen .bin-tallenteen. Alkuperäistä .ibt-tiedostoa ei kopioida ajokerran tallennustilaan.", -"analyse_choose_ownership": "Valitse kierroksen omistajuus", -"sessions_add_note": "Lisää muistiinpano", -"storage_compress_complete": "Pakkaus valmis", -"review_invalid_analyse_selection": "Virheellinen Analysoi-valinta", -"review_back_to_sessions": "Takaisin ajokertoihin", -"review_invalid_track_car_lap_selection": "Radan, auton ja kierroksen valintojen on oltava positiivisia numeroita.", -"review_invalid_session_id_selection": "Ajokertavalinnan tulee sisältää vain positiivinen ajokerran tunniste.", -"review_invalid_session_laps_selection": "Ajokertavalinnassa tulee olla 1–5 yksilöllistä kierrosta ja niiden joukkoon kuuluva ensisijainen kierros.", -"review_invalid_laps_primary_pair": "Ajokertavalinnassa on annettava sekä kierrokset että ensisijainen kierros.", -"review_invalid_primary_session_only": "Ensisijainen kierros kelpaa vain Analysoi-ajokerralle.", -"review_invalid_comparison_lap_ids": "Vertailukierrokset tulee antaa pilkuilla eroteltuna positiivisten ja yksilöllisten kierrostunnisteiden luettelona.", -"review_choose_track_and_car": "Valitse rata ja auto ennen kierrosten valitsemista.", -"review_session_selection_required_analyse": "Analysoi-toiminto edellyttää ajokerran valintaa.", -"review_lap_selection_track_car_required": "Kierrosvalintaan on sisällyttävä sen rata ja auto.", -"review_track_fallback": "Rata {ordinal}", -"review_car_fallback": "Auto {ordinal}", -"review_selected_session": "Valittu ajokerta", -"review_laps_count": "{count} kierrosta", -"review_loading_analyse": "Ladataan Analysoi-tarkastelua…", -"review_load_session_error": "Valittua ajokertaa ei voitu ladata.", -"review_session_not_found": "Ajokertaa ei löytynyt.", -"review_opening_session": "Avataan ajokerran tarkastelua…", -"review_no_matching_session": "Valintaa vastaavaa tallennettua ajokertaa ei löytynyt.", -"review_no_valid_laps": "Tarkasteltavia kelvollisia kierroksia ei ole.", -"review_back_to_session": "← Ajokerta", -"review_selected_laps_primary": "Kierrokset: {count} · Ensisijainen: kierros {lap}", -"review_select_lap": "Valitse kierros", -"review_valid_lap": "kelvollinen kierros", -"review_invalid_lap": "Virheellinen", -"review_overview": "Yleiskatsaus", -"review_sector": "Sektori {sector}", -"review_load_selected_laps_error": "Valittuja kierroksia ei voitu ladata.", -"review_selected_laps_session_error": "Valittujen kierrosten on kuuluttava tähän ajokertaan.", -"review_driver_comment": "Kuljettajan kommentti", -"review_engineer_notes": "Insinöörin muistiinpanot", -"review_legend": "Selite", -"review_range": "Vaihteluväli", -"review_median": "Mediaani", -"review_current_map_value": "Nykyinen arvo karttaa osoitettaessa", -"review_loading": "Ladataan…", -"review_no_telemetry": "Ei telemetriatietoja", -"review_detected_from_telemetry": "Havaittu telemetriasta", -"review_loading_issues": "Ladataan ongelmia…", -"review_no_issues_detected": "Tällä kierroksella ei havaittu ajettavuus- tai rengasongelmia.", -"review_whole_lap": "Koko kierros", -"review_no_issues": "Ei ongelmia", -"review_loading_tyre_state": "Ladataan renkaiden tilaa…", -"review_no_stored_telemetry": "Tälle kierrokselle ei ole tallennettu telemetriatietoja.", -"review_choose_laps_title": "Valitse näytettävät kierrokset", -"review_choose_laps_description": "Valitse 1–5 kierrosta ja niistä yksi ensisijaiseksi.", -"review_close": "Sulje", -"review_search_laps": "Hae kierroksia", -"review_search_laps_placeholder": "Hae kierroksia…", -"review_session_laps": "Ajokerran kierrokset", -"review_primary": "Ensisijainen", -"review_time": "Aika", -"review_status": "Tila", -"review_lap": "Kierros", -"review_display_lap": "Näytä kierros {lap}", -"review_primary_lap": "Ensisijainen kierros {lap}", -"review_no_laps_match": "Haku ei löytänyt kierroksia.", -"review_choose_max_five_laps": "Valitse enintään viisi kierrosta.", -"review_choose_at_least_one_lap": "Valitse vähintään yksi kierros.", -"review_choose_primary_displayed_lap": "Valitse ensisijaiseksi jokin näytetyistä kierroksista.", -"review_cancel": "Peruuta", -"review_apply": "Käytä", -"review_loading_experiment": "Ladataan kokeen tarkastelua…", -"review_experiment_not_found": "Koetta ei löytynyt", -"review_experiment_deleted_detail": "Tätä koetta (#{id}) ei enää ole — se on ehkä poistettu tai hävinnyt tietokannan nollauksen yhteydessä. Kokeeseen viitatut kierrokset saattavat silti löytyä historiastasi.", -"review_back_to_experiments": "Takaisin kokeisiin", -"review_unavailable_for_game": "Tarkastelu ei ole käytettävissä tässä pelissä.", -"review_session": "Ajokerta", -"review_lap_time_spread": "Kierrosaikojen hajonta", -"review_best_lap": "Paras kierros", + "analyse_search_cars": "Hae autoja...", + "analyse_search_tracks": "Hae ratoja...", + "analyse_search_setups": "Hae säätöjä...", + "analyse_pick_car": "Valitse ensin auto", + "analyse_setup_optional": "Säätö (valinnainen)", + "analyse_no_log": "Lokia ei ole valittu", + "analyse_importing": "Tuodaan…", + "analyse_zoom_in": "Lähennä karttaa", + "analyse_zoom_out": "Loitonna karttaa", + "analyse_lap_timeline": "Kierroksen aikajana", + "analyse_unknown_class": "Tuntematon luokka", + "analyse_import_close": "Sulje", + "analyse_import_cancel": "Peruuta", + "analyse_import_button": "Tuo", + "analyse_surface": "Pinta", + "analyse_tire_temp": "Renkaan lämpötila", + "analyse_combined": "Yhdistetty", + "analyse_decision": "Päätös", + "analyse_yaw": "Kiertymä", + "analyse_unavailable_short": "Ei käytettävissä", + "analyse_balance_tooltip": "Tasapainon työkaluvihje", + "analyse_dynamics_explanation": "Kiertymänopeus suhteessa ajolinjan kaarevuuteen + etu- ja takarenkaiden sivuluisukulman ero.", + "analyse_dynamics_explanation_yaw": "Kiertymänopeus suhteessa ajolinjan kaarevuuteen (renkaiden sivuluisukulmia ei ole saatavilla tässä pelissä).", + "analyse_dynamics_gated": "Rajattu ehdolla |latG| ≥ 0,25 g — suoran pyöränsutina ohitetaan", + "analyse_dynamics_signals_agree": "SIGNAALIT YHTÄ MIELTÄ — yhdistetty painotuksella 50/50", + "analyse_dynamics_conflict": "RISTIRIITA — käytetään vain sivuluisukulmaa", + "analyse_dynamics_yaw_only": "VAIN KIERTYMÄ — kaarevuussignaali", + "analyse_dynamics_understeer": "+ = aliohjaus (etuosan sivuluisu > takaosan) | − = yliohjaus (runko kiertyy Ay/V:n ohi)", + "analyse_dynamics_oversteer": "+ = aliohjaus (kiertyy liian vähän) | − = yliohjaus (kiertyy liikaa)", + "analyse_unknown_venue": "tuntematon rata", + "analyse_track_zoom_in": "Lähennä karttaa", + "analyse_track_zoom_out": "Loitonna karttaa", + "analyse_ibt_rows": "{count} riviä, {rate} Hz", + "analyse_ibt_driving": "{count} radalla-riviä · enintään {speed} mph", + "analyse_ibt_import": "Tuonti luo tavallisen RaceIQ:n iRacing-ajokerran ja vakiomuotoisen .bin-tallenteen. Alkuperäistä .ibt-tiedostoa ei kopioida ajokerran tallennustilaan.", + "analyse_choose_ownership": "Valitse kierroksen omistajuus", + "sessions_add_note": "Lisää muistiinpano", + "storage_compress_complete": "Pakkaus valmis", + "review_invalid_analyse_selection": "Virheellinen Analysoi-valinta", + "review_back_to_sessions": "Takaisin ajokertoihin", + "review_invalid_track_car_lap_selection": "Radan, auton ja kierroksen valintojen on oltava positiivisia numeroita.", + "review_invalid_session_id_selection": "Ajokertavalinnan tulee sisältää vain positiivinen ajokerran tunniste.", + "review_invalid_session_laps_selection": "Ajokertavalinnassa tulee olla 1–5 yksilöllistä kierrosta ja niiden joukkoon kuuluva ensisijainen kierros.", + "review_invalid_laps_primary_pair": "Ajokertavalinnassa on annettava sekä kierrokset että ensisijainen kierros.", + "review_invalid_primary_session_only": "Ensisijainen kierros kelpaa vain Analysoi-ajokerralle.", + "review_invalid_comparison_lap_ids": "Vertailukierrokset tulee antaa pilkuilla eroteltuna positiivisten ja yksilöllisten kierrostunnisteiden luettelona.", + "review_choose_track_and_car": "Valitse rata ja auto ennen kierrosten valitsemista.", + "review_session_selection_required_analyse": "Analysoi-toiminto edellyttää ajokerran valintaa.", + "review_lap_selection_track_car_required": "Kierrosvalintaan on sisällyttävä sen rata ja auto.", + "review_track_fallback": "Rata {ordinal}", + "review_car_fallback": "Auto {ordinal}", + "review_selected_session": "Valittu ajokerta", + "review_laps_count": "{count} kierrosta", + "review_loading_analyse": "Ladataan Analysoi-tarkastelua…", + "review_load_session_error": "Valittua ajokertaa ei voitu ladata.", + "review_session_not_found": "Ajokertaa ei löytynyt.", + "review_opening_session": "Avataan ajokerran tarkastelua…", + "review_no_matching_session": "Valintaa vastaavaa tallennettua ajokertaa ei löytynyt.", + "review_no_valid_laps": "Tarkasteltavia kelvollisia kierroksia ei ole.", + "review_back_to_session": "← Ajokerta", + "review_selected_laps_primary": "Kierrokset: {count} · Ensisijainen: kierros {lap}", + "review_select_lap": "Valitse kierros", + "review_valid_lap": "kelvollinen kierros", + "review_invalid_lap": "Virheellinen", + "review_overview": "Yleiskatsaus", + "review_sector": "Sektori {sector}", + "review_load_selected_laps_error": "Valittuja kierroksia ei voitu ladata.", + "review_selected_laps_session_error": "Valittujen kierrosten on kuuluttava tähän ajokertaan.", + "review_driver_comment": "Kuljettajan kommentti", + "review_engineer_notes": "Insinöörin muistiinpanot", + "review_legend": "Selite", + "review_range": "Vaihteluväli", + "review_median": "Mediaani", + "review_current_map_value": "Nykyinen arvo karttaa osoitettaessa", + "review_loading": "Ladataan…", + "review_no_telemetry": "Ei telemetriatietoja", + "review_detected_from_telemetry": "Havaittu telemetriasta", + "review_loading_issues": "Ladataan ongelmia…", + "review_no_issues_detected": "Tällä kierroksella ei havaittu ajettavuus- tai rengasongelmia.", + "review_whole_lap": "Koko kierros", + "review_no_issues": "Ei ongelmia", + "review_loading_tyre_state": "Ladataan renkaiden tilaa…", + "review_no_stored_telemetry": "Tälle kierrokselle ei ole tallennettu telemetriatietoja.", + "review_choose_laps_title": "Valitse näytettävät kierrokset", + "review_choose_laps_description": "Valitse 1–5 kierrosta ja niistä yksi ensisijaiseksi.", + "review_close": "Sulje", + "review_search_laps": "Hae kierroksia", + "review_search_laps_placeholder": "Hae kierroksia…", + "review_session_laps": "Ajokerran kierrokset", + "review_primary": "Ensisijainen", + "review_time": "Aika", + "review_status": "Tila", + "review_lap": "Kierros", + "review_display_lap": "Näytä kierros {lap}", + "review_primary_lap": "Ensisijainen kierros {lap}", + "review_no_laps_match": "Haku ei löytänyt kierroksia.", + "review_choose_max_five_laps": "Valitse enintään viisi kierrosta.", + "review_choose_at_least_one_lap": "Valitse vähintään yksi kierros.", + "review_choose_primary_displayed_lap": "Valitse ensisijaiseksi jokin näytetyistä kierroksista.", + "review_cancel": "Peruuta", + "review_apply": "Käytä", + "review_loading_experiment": "Ladataan kokeen tarkastelua…", + "review_experiment_not_found": "Koetta ei löytynyt", + "review_experiment_deleted_detail": "Tätä koetta (#{id}) ei enää ole — se on ehkä poistettu tai hävinnyt tietokannan nollauksen yhteydessä. Kokeeseen viitatut kierrokset saattavat silti löytyä historiastasi.", + "review_back_to_experiments": "Takaisin kokeisiin", + "review_unavailable_for_game": "Tarkastelu ei ole käytettävissä tässä pelissä.", + "review_session": "Ajokerta", + "review_lap_time_spread": "Kierrosaikojen hajonta", + "review_best_lap": "Paras kierros", "review_spread": "Hajonta", "spectate_line_similarity": "Ajolinjan samankaltaisuus", "spectate_mean_spread": "Keskihajonta: {spread} m", -"review_drill_judged_consistency": "Ajoharjoitus — arvioidaan tasaisuuden perusteella", -"review_setup_version_judged_best": "Asetusversio — arvioidaan parhaan kierroksen perusteella", -"review_driving_drill": "Ajoharjoitus", -"review_setup_version": "Asetusversio", -"review_consistency": "Tasaisuus", -"review_lap_variation": "Kierrosaikojen vaihtelu", -"review_typical_lap_difference": "Tyypillinen kierrosaikaero keskiarvosta. Pienempi ero tarkoittaa tasaisempaa ajoa.", -"review_best": "Paras", -"review_mean": "Keskiarvo", -"review_degradation": "Heikkeneminen", -"review_issues": "Ongelmat", -"review_track": "Rata", -"review_post_lap": "Kierroksen jälkeen", -"review_no_laps_yet": "Ei vielä kierroksia", -"review_sectors": "Sektorit", -"review_empty_dashboard_hint": "Aja stintti ja päätä kierros — tallennetut kierroksesi ja niiden sektorierittely näkyvät täällä.", -"review_primary_lap_label": "Ensisijainen kierros (kierros {lap})", -"review_lap_time_label": "Kierros {lap} — {time}", -"session_format_duckdb": "Le Mans Ultimate -telemetria (.duckdb)", -"session_duckdb_conversion_hint": "LMU-pelin telemetria muunnetaan tavalliseksi RaceIQ-ajokerraksi.", -"session_duckdb_preview": "{car} radalla {track}: {count} suoritettua kierrosta{plural}", -"session_duckdb_wal_hint": "Jos vastaava .duckdb.wal-tiedosto on olemassa, valitse molemmat tiedostot uusimpien tietojen sisällyttämiseksi.", -"settings_open_menu": "Avaa asetusvalikko", -"common_more_info": "Lisätietoja", -"nav_open_navigation": "Avaa navigointi", -"nav_close_navigation": "Sulje navigointi", -"nav_dismiss_settings": "Sulje asetukset", -"nav_close_settings": "Sulje asetukset", -"setup_create_ac_evo_tune_title": "Luo uusi AC EVO -säätö", -"setup_create_acc_tune_title": "Luo uusi ACC-säätö", -"setup_create_tune_title": "Luo uusi säätö", -"aipanel_manage": "Hallinnoi", -"analyse_chart_wheel_rotation": "Ohjauspyörän kierto", -"analyse_chart_surface_temp": "Pinnan lämpötila", -"analyse_chart_tire_temp": "Renkaan lämpötila", -"analyse_dynamics_slip_delta": "Luiston ero", -"analyse_exporting": "Viedään...", -"analyse_export_bin_button": "Vie .bin", -"compare_time_delta": "Aikaero", -"compare_fetching_higher_fidelity_datapoints": "Haetaan tarkempia datapisteitä", -"compare_resize_track_map": "Muuta ratakartan kokoa", -"dash_waiting_lap_data": "Odotetaan kierrostietoja…", -"dash_waiting_tire_data": "Odotetaan rengastietoja…", -"ai_chat_regenerate_confirmation": "Luodaanko vastaus uudelleen? Myöhemmät viestit poistetaan.", -"ai_chat_regenerate_failed": "Keskustelua ei voitu luoda uudelleen", -"ai_chat_add_provider_key": "Lisää tekoälypalvelun avain keskustelua varten.", -"ai_chat_setup_ai": "Määritä tekoäly", -"ai_chat_export_failed": "Keskustelun vientiä ei voitu ladata", -"ai_chat_title": "Tekoälykeskustelu", -"ai_chat_close_fullscreen": "Sulje koko näytön keskustelu", -"ai_chat_open_fullscreen": "Avaa keskustelu koko näytölle", -"ai_chat_actions": "Keskustelutoiminnot", -"ai_chat_copy_json": "Kopioi keskustelu JSON-muodossa", -"ai_chat_export_json": "Vie koko keskustelu JSON-muodossa", -"ai_chat_clear": "Tyhjennä keskustelu", -"ai_chat_read_only_notice": "Tarkastelet aiempaa keskustelua (vain luku). Jatka vaihtamalla uusimpaan.", -"ai_chat_compact_failed": "Tiivistäminen epäonnistui", -"ai_chat_context_filling": "Konteksti täyttyy — harkitse uuden keskustelun aloittamista", -"ai_chat_tokens_short": "tok", -"ai_chat_input_short": "syöte", -"ai_chat_output_short": "tuloste", -"ai_chat_reasoning_short": "päättely", -"ai_chat_cached_short": "välimuistissa", -"ai_chat_previous_generation": "Edellinen keskustelusukupolvi", -"ai_chat_next_generation": "Seuraava keskustelusukupolvi", -"ai_chat_stop_server_turn": "Pysäytä agentin vuoro palvelimella (ei vain tässä näkymässä)", -"ai_chat_compact_title": "Tiivistä keskustelu yhteenvedoksi ja jatka uudessa keskustelussa (tämä keskustelu säilyy vain luku -historiana)", -"ai_chat_compacting": "Tiivistetään…", -"ai_chat_compact_new_chat": "Tiivistä ja aloita uusi keskustelu", -"aipanel_delete_failed": "Analyysin poistaminen epäonnistui", -"aipanel_delete_confirmation": "Poistetaanko kierrosanalyysi? Tätä ei voi perua.", -"ai_chat_lap_analysis": "Kierrosanalyysi", -"ai_chat_attachment_preview": "Liitteen esikatselu", -"ai_chat_image_attachment_preview": "Kuvallisen liitteen esikatselu", -"ai_chat_attachment_image": "Kuva", -"ai_chat_attachment_document": "Asiakirja", -"ai_chat_attachment_file": "Tiedosto", -"ai_chat_upload_failed": "Lataaminen epäonnistui", -"ai_chat_attachment_aria": "{type}-liite{status}", -"ai_chat_uploading": "ladataan", -"ai_chat_remove_file": "Poista tiedosto", -"ai_chat_image_unsupported": "Valittu chat-malli ei tue kuvia.", -"ai_chat_image_unverified": "Valitun chat-mallin kuvatukea ei ole vahvistettu.", -"ai_chat_remove_failed_turn": "Poista epäonnistunut kuvaviesti", -"ai_chat_request_failed_recovery": "Chat-pyyntö epäonnistui. Yritä uudelleen.", + "review_drill_judged_consistency": "Ajoharjoitus — arvioidaan tasaisuuden perusteella", + "review_setup_version_judged_best": "Asetusversio — arvioidaan parhaan kierroksen perusteella", + "review_driving_drill": "Ajoharjoitus", + "review_setup_version": "Asetusversio", + "review_consistency": "Tasaisuus", + "review_lap_variation": "Kierrosaikojen vaihtelu", + "review_typical_lap_difference": "Tyypillinen kierrosaikaero keskiarvosta. Pienempi ero tarkoittaa tasaisempaa ajoa.", + "review_best": "Paras", + "review_mean": "Keskiarvo", + "review_degradation": "Heikkeneminen", + "review_issues": "Ongelmat", + "review_track": "Rata", + "review_post_lap": "Kierroksen jälkeen", + "review_no_laps_yet": "Ei vielä kierroksia", + "review_sectors": "Sektorit", + "review_empty_dashboard_hint": "Aja stintti ja päätä kierros — tallennetut kierroksesi ja niiden sektorierittely näkyvät täällä.", + "review_primary_lap_label": "Ensisijainen kierros (kierros {lap})", + "review_lap_time_label": "Kierros {lap} — {time}", + "session_format_duckdb": "Le Mans Ultimate -telemetria (.duckdb)", + "session_duckdb_conversion_hint": "LMU-pelin telemetria muunnetaan tavalliseksi RaceIQ-ajokerraksi.", + "session_duckdb_preview": "{car} radalla {track}: {count} suoritettua kierrosta{plural}", + "session_duckdb_wal_hint": "Jos vastaava .duckdb.wal-tiedosto on olemassa, valitse molemmat tiedostot uusimpien tietojen sisällyttämiseksi.", + "settings_open_menu": "Avaa asetusvalikko", + "common_more_info": "Lisätietoja", + "nav_open_navigation": "Avaa navigointi", + "nav_close_navigation": "Sulje navigointi", + "nav_dismiss_settings": "Sulje asetukset", + "nav_close_settings": "Sulje asetukset", + "setup_create_ac_evo_tune_title": "Luo uusi AC EVO -säätö", + "setup_create_acc_tune_title": "Luo uusi ACC-säätö", + "setup_create_tune_title": "Luo uusi säätö", + "aipanel_manage": "Hallinnoi", + "analyse_chart_wheel_rotation": "Ohjauspyörän kierto", + "analyse_chart_surface_temp": "Pinnan lämpötila", + "analyse_chart_tire_temp": "Renkaan lämpötila", + "analyse_dynamics_slip_delta": "Luiston ero", + "analyse_exporting": "Viedään...", + "analyse_export_bin_button": "Vie .bin", + "compare_time_delta": "Aikaero", + "compare_fetching_higher_fidelity_datapoints": "Haetaan tarkempia datapisteitä", + "compare_resize_track_map": "Muuta ratakartan kokoa", + "dash_waiting_lap_data": "Odotetaan kierrostietoja…", + "dash_waiting_tire_data": "Odotetaan rengastietoja…", + "ai_chat_regenerate_confirmation": "Luodaanko vastaus uudelleen? Myöhemmät viestit poistetaan.", + "ai_chat_regenerate_failed": "Keskustelua ei voitu luoda uudelleen", + "ai_chat_add_provider_key": "Lisää tekoälypalvelun avain keskustelua varten.", + "ai_chat_setup_ai": "Määritä tekoäly", + "ai_chat_export_failed": "Keskustelun vientiä ei voitu ladata", + "ai_chat_title": "Tekoälykeskustelu", + "ai_chat_close_fullscreen": "Sulje koko näytön keskustelu", + "ai_chat_open_fullscreen": "Avaa keskustelu koko näytölle", + "ai_chat_actions": "Keskustelutoiminnot", + "ai_chat_copy_json": "Kopioi keskustelu JSON-muodossa", + "ai_chat_export_json": "Vie koko keskustelu JSON-muodossa", + "ai_chat_clear": "Tyhjennä keskustelu", + "ai_chat_read_only_notice": "Tarkastelet aiempaa keskustelua (vain luku). Jatka vaihtamalla uusimpaan.", + "ai_chat_compact_failed": "Tiivistäminen epäonnistui", + "ai_chat_context_filling": "Konteksti täyttyy — harkitse uuden keskustelun aloittamista", + "ai_chat_tokens_short": "tok", + "ai_chat_input_short": "syöte", + "ai_chat_output_short": "tuloste", + "ai_chat_reasoning_short": "päättely", + "ai_chat_cached_short": "välimuistissa", + "ai_chat_previous_generation": "Edellinen keskustelusukupolvi", + "ai_chat_next_generation": "Seuraava keskustelusukupolvi", + "ai_chat_stop_server_turn": "Pysäytä agentin vuoro palvelimella (ei vain tässä näkymässä)", + "ai_chat_compact_title": "Tiivistä keskustelu yhteenvedoksi ja jatka uudessa keskustelussa (tämä keskustelu säilyy vain luku -historiana)", + "ai_chat_compacting": "Tiivistetään…", + "ai_chat_compact_new_chat": "Tiivistä ja aloita uusi keskustelu", + "aipanel_delete_failed": "Analyysin poistaminen epäonnistui", + "aipanel_delete_confirmation": "Poistetaanko kierrosanalyysi? Tätä ei voi perua.", + "ai_chat_lap_analysis": "Kierrosanalyysi", + "ai_chat_attachment_preview": "Liitteen esikatselu", + "ai_chat_image_attachment_preview": "Kuvallisen liitteen esikatselu", + "ai_chat_attachment_image": "Kuva", + "ai_chat_attachment_document": "Asiakirja", + "ai_chat_attachment_file": "Tiedosto", + "ai_chat_upload_failed": "Lataaminen epäonnistui", + "ai_chat_attachment_aria": "{type}-liite{status}", + "ai_chat_uploading": "ladataan", + "ai_chat_remove_file": "Poista tiedosto", + "ai_chat_image_unsupported": "Valittu chat-malli ei tue kuvia.", + "ai_chat_image_unverified": "Valitun chat-mallin kuvatukea ei ole vahvistettu.", + "ai_chat_remove_failed_turn": "Poista epäonnistunut kuvaviesti", + "ai_chat_request_failed_recovery": "Chat-pyyntö epäonnistui. Yritä uudelleen.", "ai_chat_experiment_head_stale": "Kokeilun versio muuttui. Päivitä työtila ja lähetä viesti uudelleen.", "ai_chat_remove_failed_turn_error": "Epäonnistunutta kuvaviestiä ei voitu poistaa. Yritä uudelleen.", "ai_chat_add_attachment": "Lisää liite", @@ -2704,6 +2704,121 @@ "access_origins_safety": "Verkkomuutokset vaativat RaceIQ:n uudelleenkäynnistyksen. Poistaminen käytöstä ei poista tallennettuja alkuperiä. Saman alkuperän suojaukset jatkuvat.", "dash_hostname_origin_settings": "Lisää tämä tarkka alkuperä kohtaan Asetukset → Käyttöoikeudet ja suojaus → selaimen verkkotunnukset: {origin}. Isäntänimen tallentaminen ei anna käyttöoikeutta.", "dash_hostname_origin_managed": "Lisää tämä tarkka alkuperä muuttujaan SERVER_ALLOWED_ORIGINS: {origin}. Isäntänimen tallentaminen ei anna käyttöoikeutta.", + "replay_track_loading": "Ladataan ratageometriaa…", + "replay_track_error": "Ratageometriaa ei voitu ladata.", + "replay_track_unavailable": "Sijainti radalla ei ole saatavilla: uusinta tarvitsee kohdistetun ratageometrian ja kelvollisen kierrosmatkan tai alkuperäisen sijainnin.", + "replay_track_projected": "Sijainti ja reunat perustuvat viralliseen karttaan — eivät mitattuun ajolinjaan tai mitattuun radan leveyteen.", + "replay_track_measured": "Mitattu IBT-ajolinja; virallinen kartta on vain likimääräinen viite.", + "replay_track_bounds_unavailable": "Radan reunat eivät ole saatavilla tälle rataversiolle. Sijainti perustuu kierrosmatkaan, ei mitattuun ajolinjaan.", + "carwire_snapshot_stale": "Vanhentunut tilannekuva · ei reaaliaikainen", + "settings_iracing_title": "Automaattinen iRacing-telemetrian tallennus", + "settings_iracing_description": "Arkistoi valmiit .ibt-tiedostot automaattisesti tietokoneelle, jolla RaceIQ toimii.", + "settings_iracing_opt_in_title": "Paikallinen tallennus vaatii hyväksyntäsi", + "settings_iracing_opt_in_description": "Yhdistäminen ottaa iRacingin telemetriatallennuksen käyttöön tämän taustapalvelun käynnin ajaksi. Valmiit .ibt-tiedostot kopioidaan; alkuperäisiä ei muuteta. Tallennettuja tiedostoja ei tuoda automaattisesti.", + "settings_iracing_connect": "Yhdistä iRacingiin", + "settings_iracing_disconnect": "Katkaise yhteys", + "settings_iracing_pending": "Päivitetään telemetrian tallennusta…", + "settings_iracing_armed": "Automaattinen tallennus pysyy käytössä taustapalvelun uudelleenkäynnistyksissä, kunnes katkaiset yhteyden.", + "settings_iracing_state_unavailable": "Ei saatavilla", + "settings_iracing_state_disconnected": "Yhteys katkaistu", + "settings_iracing_state_waiting_iracing": "iRacingia odotellessa", + "settings_iracing_state_waiting_recording": "Odotetaan tallennusta", + "settings_iracing_state_recording": "Tallennus", + "settings_iracing_state_saving": "Tallennetaan valmista tiedostoa", + "settings_iracing_state_error": "Telemetrian tallennusvirhe", + "settings_iracing_reason_unsupported": "Automaattinen sieppaus vaatii Windows-taustajärjestelmän samassa tietokoneessa kuin iRacing.", + "settings_iracing_reason_feature_disabled": "iRacing-kaappaus on poistettu käytöstä tässä taustajärjestelmässä.", + "settings_iracing_reason_documents": "iRacingin telemetriahakemistoa ei voitu paikantaa.", + "settings_iracing_reason_sdk": "Ei voitu kommunikoida iRacing-simulaattorin kanssa.", + "settings_iracing_reason_archive": "Telemetriaa ei voitu tallentaa arkistohakemistoon.", + "settings_iracing_unavailable_title": "Telemetrian tallennus ei ole käytettävissä", + "settings_iracing_query_error_title": "Tallennustilaa ei voitu ladata", + "settings_iracing_query_error": "Tilapyyntö epäonnistui. Tarkista taustayhteys ja yritä uudelleen avaamalla Asetukset uudelleen.", + "settings_iracing_mutation_error_title": "Tallennusta ei voitu päivittää", + "settings_iracing_mutation_error": "Taustapalvelu ei vahvistanut tallennusasetuksen muutosta. Tarkista tila ennen uutta yritystä.", + "settings_iracing_source_directory": "iRacing-lähdehakemisto", + "settings_iracing_archive_directory": "RaceIQ-arkistohakemisto", + "settings_iracing_saved_count": "Taustapalvelun käynnistyksen jälkeen tallennetut tiedostot", + "settings_iracing_last_file": "Viimeksi tallennettu tiedosto", + "settings_iracing_not_available": "Ei saatavilla", + "settings_iracing_none": "Ei mitään", + "settings_iracing_local_files_note": "Suorita Windows-taustapalvelu samalla käyttäjällä ja samassa istunnossa kuin iRacing. Etäselain ei voi kerätä tiedostoja toiselta pelitietokoneelta.", + "home_clean_driving_score": "Puhtaan ajon pisteet", + "home_clean_driving_score_description": "{clean}/{total} hyväksyttyä kellotettua kierrosta. Varikkokierrokset jätetään pois. Kierrosten kelpoisuuspisteet, ei iRacing Safety Rating.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Live-SDK-telemetria", + "iracing_live_receiving": "Vastaanotetaan telemetriaa", + "iracing_live_disconnected": "Taustapalvelin ei ole yhteydessä", + "iracing_live_waiting_title": "Odotetaan iRacing-telemetriaa", + "iracing_live_waiting_description": "Käynnistä iRacing Windows-tietokoneella, jossa RaceIQ on käynnissä, ja siirry ajosessioon. Live-telemetria toimii automaattisesti; IBT-tallennus on valinnainen.", + "iracing_live_inputs": "Kuljettajan ohjaimet", + "iracing_live_motion": "Ajoneuvon liike", + "iracing_live_suspension": "Jousituksen liikevara", + "iracing_live_lap_progress": "Kierroksen eteneminen", + "iracing_live_fuel_remaining": "Polttoainetta jäljellä", + "iracing_live_lateral_g": "Sivuttaiskiihtyvyys", + "iracing_live_longitudinal_g": "Pitkittäiskiihtyvyys", + "iracing_live_roll": "Kallistus", + "iracing_live_pitch": "Nyökkäys", + "iracing_live_snapshot_note": "Renkaan runkolämpötila ja kuluminen päivittyvät varikkokäynneillä; paine on kylmän setupin paine. Nämä eivät ole reaaliaikaisia rengasmittauksia.", + "iracing_live_position_note": "Vain live-SDK-tietoja. GPS-ajolinjat edellyttävät tallennettua .ibt-tiedostoa.", + "iracing_incident_report_heading": "Vahinkoraportti", + "iracing_incident_report_points": "Havaitut vahinkopisteet", + "iracing_incident_report_spans": "Havaitut radalta poistumisen jaksot", + "iracing_incident_report_duration": "Näytteistetty radalta poissaoloaika", + "iracing_incident_report_streak": "Pisin kirjattu puhtaiden kierrosten putki", + "iracing_incident_report_windows": "Tallennetut kierrosjaksot luettu kokonaan {included}/{total}", + "iracing_incident_report_counter": "Henkilökohtainen laskuri: {status}", + "iracing_incident_report_surface": "Pintatiedot: {status}", + "iracing_incident_report_available": "Saatavilla", + "iracing_incident_report_partial": "Osittainen", + "iracing_incident_report_unavailable": "Ei saatavilla", + "iracing_incident_report_scope": "Vain tallennettujen kierrosten tiedot. Tallentamattomia aikavälejä ei arvioida.", + "iracing_incident_report_explanation": "Lähtötason pisteitä ei lisätä. Pistemerkinnät osoittavat, milloin henkilökohtaisen laskurin kasvu havaittiin peräkkäisten näytteiden välillä. Radalta poistumisen jaksot perustuvat näytteisiin ja rajataan tallennettuihin kierroksiin. Kumpikaan ei osoita syyllisyyttä, törmäystyyppiä tai Safety Rating -arvoa.", + "iracing_incident_report_loading": "Vahinkoraporttia ladataan…", + "iracing_incident_report_error": "Vahinkoraporttia ei voitu lukea.", + "iracing_incident_report_retry": "Yritä uudelleen", + "iracing_incident_report_no_evidence": "Vahinko- ja pintatiedot eivät ole saatavilla näiltä tallennetuilta kierrosjaksoilta.", + "iracing_incident_report_empty": "Katetuissa tiedoissa ei havaittu pisteiden kasvua tai radalta poistumisen jaksoja.", + "iracing_incident_report_point_event": "+{points} vahinkopistettä havaittu", + "iracing_incident_report_span_event": "Radalta poistumisen jakso", + "iracing_incident_report_sampled_duration": "Näytteistetty kesto {duration}", + "iracing_incident_report_clipped_start": "Alku rajattu", + "iracing_incident_report_clipped_end": "Loppu rajattu", + "iracing_incident_report_lap_time": "Kierros {lap} · sessio {time}", + "iracing_incident_report_bounds": "Havaittu simulaattorin sessioajan välillä {previous}–{current}", + "iracing_incident_report_replay": "Uusinta · kierrosaika {time}", + "iracing_incident_report_replay_link": "Avaa uusinta", + "iracing_incident_report_no_replay": "Uusintasijainti ei ole saatavilla", + "iracing_incident_report_events": "Kirjattujen tapahtumien aikajana", + "iracing_incident_report_markers": "Kirjatut vahinkomerkinnät", + "iracing_incident_report_marker_loading": "Merkintöjä ladataan…", + "iracing_incident_report_marker_error": "Vahinkomerkintöjä ei voitu lukea.", + "iracing_incident_report_marker_unavailable": "Vahinkomerkintätiedot eivät ole saatavilla.", + "iracing_incident_report_marker_partial": "Tiedot tai uusintasijainnit ovat puutteellisia.", + "iracing_incident_report_marker_empty": "Katetuissa kierrostiedoissa ei havaittu tapahtumia.", + "iracing_replay_recorded": "Tallennettu telemetria", + "iracing_replay_source_note": "Näytössä ovat vain tässä tallenteessa olevat kanavat.", + "iracing_replay_conditions": "Rata ja sää", + "iracing_replay_session": "Tallennettu istunto", + "iracing_replay_session_time": "Simulaattorin istuntoaika", + "iracing_replay_coolant": "Jäähdytysnesteen lämpötila", + "iracing_replay_oil_temp": "Öljyn lämpötila", + "iracing_replay_oil_pressure": "Öljynpaine", + "iracing_replay_fuel_pressure": "Polttoaineen paine", + "iracing_replay_voltage": "Akkujännite", + "iracing_replay_wind": "Tuulen nopeus", + "iracing_replay_wind_direction": "Tuulen suunta", + "iracing_replay_humidity": "Suhteellinen kosteus", + "iracing_replay_yaw_rate": "Kiertonopeus pystyakselin ympäri", + "iracing_replay_shock_velocity": "Iskunvaimentimen nopeus", + "iracing_replay_brake_line_pressure": "Jarrupiirin paine", + "iracing_replay_fuel_used": "Tallennetulla kierroksella käytetty polttoaine", + "iracing_replay_steering_torque": "Ohjauspyörän vääntömomentti", + "iracing_replay_personal_counter": "Kuljettajan oma incident-laskuri", + "iracing_replay_pit_road": "Varikkotiellä", + "iracing_replay_pit_snapshots": "Tallennetut varikkotilannekuvat", + "iracing_replay_empty": "Tässä uusintakohdassa ei ole käyttökelpoisia telemetriakanavia.", "storage_downsample_action": "Harvenna tallenteita…", "storage_downsample_title": "Harvenna tallenteita", "storage_downsample_description": "Pienennä tallennettujen telemetriatietojen näytteenottotaajuutta. Suosikkitallenteet ja MoTeC-tuonnit on suojattu.", @@ -2742,7 +2857,6 @@ "storage_delete_after": "Poista tämän ajan jälkeen", "storage_retention_policy_error": "Automaattinen poisto on ajoitettava automaattisen harvennuksen jälkeen.", "storage_retention_saved": "Säilytyskäytäntö tallennettu.", - "dash_hostname_invalid": "Anna vain isäntänimi ilman http://-alkua, porttia tai polkua.", "sessions_capture_split_queued": "Asetettu jonoon häviötöntä kierrosvientiä varten", "sessions_capture_split_splitting": "Tallennetta jaetaan kierroksiksi", "sessions_capture_split_verifying": "Kierrostallenteita tarkistetaan", diff --git a/client/messages/fr.json b/client/messages/fr.json index 1b3d48db3..e6bf87f1c 100644 --- a/client/messages/fr.json +++ b/client/messages/fr.json @@ -1286,6 +1286,13 @@ "carwire_dims": "Dimensions", "carwire_tire_info": "Infos sur les pneus", "carwire_track": "Circuit", + "replay_track_loading": "Chargement de la géométrie du circuit…", + "replay_track_error": "Impossible de charger la géométrie du circuit.", + "replay_track_unavailable": "Position sur le circuit indisponible : cette rediffusion nécessite une géométrie alignée et une distance de tour valide ou une position native.", + "replay_track_projected": "Position et bords issus de la carte officielle — ni trajectoire mesurée ni largeur de piste relevée.", + "replay_track_measured": "Trajectoire IBT mesurée ; la carte officielle reste une référence approximative.", + "replay_track_bounds_unavailable": "Bords indisponibles pour ce tracé. La position utilise la distance de tour, pas la trajectoire mesurée.", + "carwire_snapshot_stale": "Ancien relevé · pas en direct", "carwire_trails": "Traces", "carwire_saved": "Enregistré", "sectortimes_s1": "S1", @@ -1566,9 +1573,9 @@ "analyse_importable_laps": "Tours importables", "analyse_on_track_rows": "lignes en piste · vitesse max. {speed} mph", "analyse_import_cannot": "Cet enregistrement ne peut pas être importé.", - "analyse_ibt_note": "L’importation crée une session iRacing RaceIQ standard et une capture .bin normalisée. Le fichier .ibt d’origine n’est pas copié dans le stockage des sessions.", + "analyse_ibt_note": "L’importation conserve l’enregistrement .ibt original à sa fréquence d’échantillonnage native. Aucune conversion BIN.", "session_import_title": "Importer des données de session", - "session_choose_file_hint": "Choisissez un fichier. Le format et les métadonnées du jeu sont vérifiés à partir de son contenu.", + "session_choose_file_hint": "Déposez un fichier ici ou choisissez-en un. Le format et les métadonnées du jeu sont vérifiés à partir de son contenu.", "session_choose_file": "Choisir un fichier", "session_no_file": "Aucun fichier sélectionné", "session_reading": "Lecture du contenu du fichier…", @@ -1576,7 +1583,7 @@ "session_unsupported": "Le contenu du fichier n’est pas pris en charge.", "session_game_detected": "Jeu détecté à partir des données de télémétrie.", "session_captures_found": "{count} capture(s) RaceIQ trouvée(s).", - "session_ibt_hint": "Les importations iRacing nécessitent un aperçu et une confirmation depuis Analyse.", + "session_ibt_hint": "Choisissez Importer pour prévisualiser l’enregistrement iRacing et confirmer ses tours.", "session_motec_hint": "Les importations MoTeC nécessitent de configurer le jeu, la voiture et le circuit depuis Analyse.", "session_imported": "{count} tour(s) importé(s).{skipped}", "session_skipped": " {count} ignoré(s).", @@ -2704,6 +2711,114 @@ "access_origins_safety": "Les modifications réseau nécessitent un redémarrage de RaceIQ. La désactivation ne supprime pas les origines enregistrées. Les protections de même origine restent actives.", "dash_hostname_origin_settings": "Ajoutez cette origine exacte dans Paramètres → Accès et sécurité → domaines du navigateur : {origin}. Enregistrer ce nom d’hôte n’autorise pas l’accès.", "dash_hostname_origin_managed": "Ajoutez cette origine exacte à SERVER_ALLOWED_ORIGINS : {origin}. Enregistrer ce nom d’hôte n’autorise pas l’accès.", + "settings_iracing_title": "Capture automatique iRacing", + "settings_iracing_description": "Archivez automatiquement les fichiers .ibt terminés sur l’ordinateur exécutant RaceIQ.", + "settings_iracing_opt_in_title": "La capture locale est facultative", + "settings_iracing_opt_in_description": "La connexion active l’enregistrement de télémétrie iRacing tant que ce serveur fonctionne. Les fichiers .ibt terminés sont copiés sans modifier les originaux. Les fichiers enregistrés ne sont pas importés automatiquement.", + "settings_iracing_connect": "Connectez-vous à iRacing", + "settings_iracing_disconnect": "Déconnecter", + "settings_iracing_pending": "Mise à jour de la capture…", + "settings_iracing_armed": "La capture automatique reste active jusqu’à la déconnexion ou au redémarrage du serveur.", + "settings_iracing_state_unavailable": "Indisponible", + "settings_iracing_state_disconnected": "Déconnecté", + "settings_iracing_state_waiting_iracing": "En attendant iRacing", + "settings_iracing_state_waiting_recording": "En attente d'enregistrement", + "settings_iracing_state_recording": "Enregistrement", + "settings_iracing_state_saving": "Sauvegarde du fichier complété", + "settings_iracing_state_error": "Erreur de capture", + "settings_iracing_reason_unsupported": "La capture automatique nécessite le backend Windows sur le même PC qu'iRacing.", + "settings_iracing_reason_feature_disabled": "La capture iRacing est désactivée par ce backend.", + "settings_iracing_reason_documents": "Impossible de localiser le répertoire de télémétrie iRacing.", + "settings_iracing_reason_sdk": "Impossible de communiquer avec le simulateur iRacing.", + "settings_iracing_reason_archive": "Impossible d'enregistrer la télémétrie dans le répertoire d'archives.", + "settings_iracing_unavailable_title": "Capture indisponible", + "settings_iracing_query_error_title": "Impossible de charger l'état de capture", + "settings_iracing_query_error": "La demande de statut a échoué. Vérifiez la connexion backend et réessayez en rouvrant les paramètres.", + "settings_iracing_mutation_error_title": "Impossible de mettre à jour la capture", + "settings_iracing_mutation_error": "Le backend n'a pas confirmé ce changement de capture. Vérifiez l'état avant de réessayer.", + "settings_iracing_source_directory": "Répertoire des sources d'iRacing", + "settings_iracing_archive_directory": "Répertoire des archives RaceIQ", + "settings_iracing_saved_count": "Fichiers enregistrés depuis le démarrage du serveur", + "settings_iracing_last_file": "Dernier fichier enregistré", + "settings_iracing_not_available": "Pas disponible", + "settings_iracing_none": "Aucun", + "settings_iracing_local_files_note": "Exécutez le serveur Windows avec le même utilisateur et dans la même session qu’iRacing. Un navigateur distant ne peut pas récupérer les fichiers d’un autre PC de jeu.", + "home_clean_driving_score": "Score de conduite propre", + "home_clean_driving_score_description": "{clean}/{total} tours chronométrés valides. Tours des stands exclus. Score de validité des tours, pas le Safety Rating iRacing.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Télémétrie SDK en direct", + "iracing_live_receiving": "Réception de la télémétrie", + "iracing_live_disconnected": "Serveur déconnecté", + "iracing_live_waiting_title": "En attente de la télémétrie iRacing", + "iracing_live_waiting_description": "Lancez iRacing sur le PC Windows exécutant RaceIQ, puis entrez en session de conduite. La télémétrie en direct est automatique ; l’enregistrement IBT est facultatif.", + "iracing_live_inputs": "Commandes du pilote", + "iracing_live_motion": "Mouvement du véhicule", + "iracing_live_suspension": "Débattement de suspension", + "iracing_live_lap_progress": "Progression du tour", + "iracing_live_fuel_remaining": "Carburant restant", + "iracing_live_lateral_g": "Accélération latérale", + "iracing_live_longitudinal_g": "Accélération longitudinale", + "iracing_live_roll": "Roulis", + "iracing_live_pitch": "Tangage", + "iracing_live_snapshot_note": "La température de carcasse et l’usure des pneus sont mises à jour aux arrêts aux stands ; la pression est celle du setup à froid. Ce ne sont pas des mesures de pneus en direct.", + "iracing_live_position_note": "Données SDK en direct uniquement. Les trajectoires GPS nécessitent un fichier .ibt enregistré.", + "iracing_incident_report_heading": "Rapport d’incidents", + "iracing_incident_report_points": "Points d’incident observés", + "iracing_incident_report_spans": "Séquences hors piste observées", + "iracing_incident_report_duration": "Durée hors piste échantillonnée", + "iracing_incident_report_streak": "Plus longue série de tours propres enregistrés", + "iracing_incident_report_windows": "Fenêtres de tours enregistrées lues entièrement {included}/{total}", + "iracing_incident_report_counter": "Compteur personnel : {status}", + "iracing_incident_report_surface": "Données de surface : {status}", + "iracing_incident_report_available": "Disponible", + "iracing_incident_report_partial": "Partiel", + "iracing_incident_report_unavailable": "Indisponible", + "iracing_incident_report_scope": "Données de tours enregistrées uniquement. Les intervalles non enregistrés ne sont pas évalués.", + "iracing_incident_report_explanation": "Les valeurs de référence ne sont pas ajoutées. Les marqueurs indiquent quand une hausse du compteur personnel a été observée entre deux échantillons consécutifs. Les séquences hors piste sont échantillonnées et limitées aux tours enregistrés. Aucun de ces éléments ne prouve une faute, un type de collision ou le Safety Rating.", + "iracing_incident_report_loading": "Chargement du rapport d’incidents…", + "iracing_incident_report_error": "Impossible de lire le rapport d’incidents.", + "iracing_incident_report_retry": "Réessayer", + "iracing_incident_report_no_evidence": "Aucune donnée d’incident ou de surface disponible pour ces fenêtres de tours enregistrées.", + "iracing_incident_report_empty": "Aucune hausse de points ni séquence hors piste observée dans les données couvertes.", + "iracing_incident_report_point_event": "+{points} points d’incident observés", + "iracing_incident_report_span_event": "Séquence hors piste", + "iracing_incident_report_sampled_duration": "Durée échantillonnée {duration}", + "iracing_incident_report_clipped_start": "Début tronqué", + "iracing_incident_report_clipped_end": "Fin tronquée", + "iracing_incident_report_lap_time": "Tour {lap} · session {time}", + "iracing_incident_report_bounds": "Observé entre {previous} et {current} selon le temps de session du simulateur", + "iracing_incident_report_replay": "Replay · temps au tour {time}", + "iracing_incident_report_replay_link": "Ouvrir le replay", + "iracing_incident_report_no_replay": "Position dans le replay indisponible", + "iracing_incident_report_events": "Chronologie des événements enregistrés", + "iracing_incident_report_markers": "Marqueurs d’incident enregistrés", + "iracing_incident_report_marker_loading": "Chargement des marqueurs…", + "iracing_incident_report_marker_error": "Impossible de lire les marqueurs d’incident.", + "iracing_incident_report_marker_unavailable": "Données des marqueurs d’incident indisponibles.", + "iracing_incident_report_marker_partial": "Données partielles ou positions de replay indisponibles.", + "iracing_incident_report_marker_empty": "Aucun événement observé dans les données de tours couvertes.", + "iracing_replay_recorded": "Télémétrie enregistrée", + "iracing_replay_source_note": "Seuls les canaux présents dans cet enregistrement sont affichés.", + "iracing_replay_conditions": "Circuit et météo", + "iracing_replay_session": "Session enregistrée", + "iracing_replay_session_time": "Temps de session du simulateur", + "iracing_replay_coolant": "Température du liquide de refroidissement", + "iracing_replay_oil_temp": "Température d’huile", + "iracing_replay_oil_pressure": "Pression d’huile", + "iracing_replay_fuel_pressure": "Pression de carburant", + "iracing_replay_voltage": "Tension de la batterie", + "iracing_replay_wind": "Vitesse du vent", + "iracing_replay_wind_direction": "Direction du vent", + "iracing_replay_humidity": "Humidité relative", + "iracing_replay_yaw_rate": "Vitesse de lacet", + "iracing_replay_shock_velocity": "Vitesse de l’amortisseur", + "iracing_replay_brake_line_pressure": "Pression de la conduite de frein", + "iracing_replay_fuel_used": "Carburant utilisé sur le tour enregistré", + "iracing_replay_steering_torque": "Couple au volant", + "iracing_replay_personal_counter": "Compteur personnel d’incidents", + "iracing_replay_pit_road": "Dans la voie des stands", + "iracing_replay_pit_snapshots": "Instantanés de stands enregistrés", + "iracing_replay_empty": "Aucun canal de télémétrie exploitable à cette position du replay.", "storage_downsample_action": "Réduire la fréquence des enregistrements…", "storage_downsample_title": "Réduire la fréquence des enregistrements", "storage_downsample_description": "Réduisez la fréquence de télémétrie stockée. Les enregistrements favoris et les imports MoTeC sont protégés.", diff --git a/client/messages/hi.json b/client/messages/hi.json index 42137ef03..e1fa146cf 100644 --- a/client/messages/hi.json +++ b/client/messages/hi.json @@ -1717,9 +1717,9 @@ "analyse_importable_laps": "आयात योग्य गोद", "analyse_on_track_rows": "ऑन-ट्रैक पंक्तियाँ · अधिकतम {speed} मील प्रति घंटे", "analyse_import_cannot": "यह रिकॉर्डिंग आयात नहीं की जा सकती.", - "analyse_ibt_note": "आयात एक सामान्य RaceIQ iRacing सत्र और कैनोनिकल .bin कैप्चर बनाता है। मूल .ibt फ़ाइल को सत्र संग्रहण में कॉपी नहीं किया गया है।", + "analyse_ibt_note": "आयात मूल .ibt रिकॉर्डिंग को उसकी मूल नमूना दर पर सुरक्षित रखता है। कोई BIN रूपांतरण नहीं।", "session_import_title": "सत्र डेटा आयात करें", - "session_choose_file_hint": "एक फ़ाइल चुनें. इसकी सामग्री से प्रारूप और गेम मेटाडेटा की जाँच की जाती है।", + "session_choose_file_hint": "फ़ाइल को यहाँ खींचकर छोड़ें या फ़ाइल चुनें। इसकी सामग्री से प्रारूप और गेम मेटाडेटा की जाँच की जाती है।", "session_choose_file": "फाइलें चुनें", "session_no_file": "कोई फ़ाइल चयनित नहीं", "session_reading": "फ़ाइल सामग्री पढ़ रहा है…", @@ -1727,7 +1727,7 @@ "session_unsupported": "फ़ाइल सामग्री समर्थित नहीं हैं.", "session_game_detected": "टेलीमेट्री सामग्री से गेम का पता चला।", "session_captures_found": "{count} RaceIQ कैप्चर मिले।", - "session_ibt_hint": "iRacing आयात के लिए विश्लेषण से पूर्वावलोकन और पुष्टि की आवश्यकता होती है।", + "session_ibt_hint": "iRacing रिकॉर्डिंग का पूर्वावलोकन करने और उसके लैप की पुष्टि करने के लिए आयात चुनें।", "session_motec_hint": "MoTeC आयात के लिए विश्लेषण से गेम, कार और ट्रैक सेटअप की आवश्यकता होती है।", "session_imported": "आयातित {count} लैप.{skipped}", "session_skipped": "{count} को छोड़ दिया गया।", @@ -2703,7 +2703,6 @@ "access_origins_managed": "SERVER_ALLOWED_ORIGINS द्वारा चालू। यह पर्यावरण सेटिंग अनुमति सूची नियंत्रित करती है; वहीं बदलकर RaceIQ रीस्टार्ट करें।", "access_origins_safety": "नेटवर्क पहुँच सहेजने के लिए RaceIQ रीस्टार्ट करना होगा। इसे बंद करने से सहेजे ओरिजिन नहीं मिटते। समान-ओरिजिन सुरक्षा जारी रहती है।", "dash_hostname_origin_settings": "इस सटीक मूल को सेटिंग्स → एक्सेस और सुरक्षा → ब्राउज़र डोमेन में शामिल करें: {origin}। इस होस्टनाम को सहेजने से पहुंच अधिकृत नहीं होती है।", - "dash_hostname_origin_managed": "SERVER_ALLOWED_ORIGINS में यह सटीक origin शामिल करें: {origin}। इस hostname को सहेजने से पहुँच अधिकृत नहीं होती।", "storage_downsample_action": "रिकॉर्डिंग की सैंपलिंग घटाएँ…", "storage_downsample_title": "रिकॉर्डिंग की सैंपलिंग घटाएँ", "storage_downsample_description": "सहेजी गई टेलीमेट्री की आवृत्ति घटाएँ। पसंदीदा रिकॉर्डिंग और MoTeC आयात सुरक्षित रहते हैं।", @@ -2743,6 +2742,121 @@ "storage_retention_policy_error": "स्वचालित मिटाने का समय, स्वचालित सैंपलिंग घटाने के बाद निर्धारित होना चाहिए।", "storage_retention_saved": "रिटेंशन नीति सहेजी गई।", "dash_hostname_origin_managed": "Include this exact origin in SERVER_ALLOWED_ORIGINS: {origin}. Saving this hostname does not authorize access.", + "replay_track_loading": "ट्रैक ज्यामिति लोड हो रही है…", + "replay_track_error": "ट्रैक ज्यामिति लोड नहीं हो सकी।", + "replay_track_unavailable": "ट्रैक पर स्थान उपलब्ध नहीं: इस रीप्ले के लिए संरेखित ट्रैक ज्यामिति और मान्य लैप दूरी या मूल स्थिति आवश्यक है।", + "replay_track_projected": "स्थिति और किनारे आधिकारिक मानचित्र पर आधारित हैं—मापी गई रेसिंग लाइन या सर्वेक्षित ट्रैक चौड़ाई नहीं।", + "replay_track_measured": "मापी गई IBT ट्रैजेक्टरी; आधिकारिक मानचित्र केवल अनुमानित संदर्भ है।", + "replay_track_bounds_unavailable": "इस लेआउट के लिए ट्रैक किनारे उपलब्ध नहीं हैं। स्थिति मापी गई रेसिंग लाइन के बजाय लैप दूरी पर आधारित है।", + "carwire_snapshot_stale": "पुराना स्नैपशॉट · लाइव नहीं", + "settings_iracing_title": "iRacing स्वचालित कैप्चर", + "settings_iracing_description": "RaceIQ चलाने वाले कंप्यूटर पर पूरी हुई .ibt फ़ाइलों को स्वचालित रूप से संग्रहित करें।", + "settings_iracing_opt_in_title": "स्थानीय कैप्चर स्पष्ट सहमति से चालू होता है", + "settings_iracing_opt_in_description": "कनेक्ट करने पर यह बैकएंड चलने तक iRacing डिस्क टेलीमेट्री सक्षम होती है। पूरी हुई .ibt फ़ाइलों की प्रतिलिपि बनाई जाती है; मूल फ़ाइलें जस की तस रहती हैं। सहेजी गई फ़ाइलें अपने-आप आयात नहीं होतीं।", + "settings_iracing_connect": "iRacing से कनेक्ट करें", + "settings_iracing_disconnect": "डिस्कनेक्ट करें", + "settings_iracing_pending": "कैप्चर अपडेट हो रहा है…", + "settings_iracing_armed": "डिस्कनेक्ट करने या बैकएंड रीस्टार्ट करने तक स्वचालित कैप्चर सक्षम रहेगा।", + "settings_iracing_state_unavailable": "उपलब्ध नहीं", + "settings_iracing_state_disconnected": "डिस्कनेक्ट", + "settings_iracing_state_waiting_iracing": "iRacing की प्रतीक्षा", + "settings_iracing_state_waiting_recording": "रिकॉर्डिंग की प्रतीक्षा", + "settings_iracing_state_recording": "रिकॉर्डिंग", + "settings_iracing_state_saving": "पूरी हुई फ़ाइल सहेजी जा रही है", + "settings_iracing_state_error": "कैप्चर त्रुटि", + "settings_iracing_reason_unsupported": "स्वचालित कैप्चर के लिए iRacing वाले उसी PC पर Windows बैकएंड आवश्यक है।", + "settings_iracing_reason_feature_disabled": "इस बैकएंड में iRacing कैप्चर अक्षम है।", + "settings_iracing_reason_documents": "iRacing टेलीमेट्री निर्देशिका नहीं मिली।", + "settings_iracing_reason_sdk": "iRacing सिम्युलेटर से संचार नहीं हो सका।", + "settings_iracing_reason_archive": "आर्काइव निर्देशिका में टेलीमेट्री सहेजी नहीं जा सकी।", + "settings_iracing_unavailable_title": "कैप्चर उपलब्ध नहीं", + "settings_iracing_query_error_title": "कैप्चर स्थिति लोड नहीं हो सकी", + "settings_iracing_query_error": "स्थिति अनुरोध विफल हुआ। बैकएंड कनेक्शन जाँचें और सेटिंग्स फिर खोलकर पुनः प्रयास करें।", + "settings_iracing_mutation_error_title": "कैप्चर अपडेट नहीं हो सका", + "settings_iracing_mutation_error": "बैकएंड ने इस कैप्चर बदलाव की पुष्टि नहीं की। दोबारा प्रयास करने से पहले स्थिति जाँचें।", + "settings_iracing_source_directory": "iRacing स्रोत निर्देशिका", + "settings_iracing_archive_directory": "RaceIQ आर्काइव निर्देशिका", + "settings_iracing_saved_count": "इस रनटाइम में सहेजी गई फ़ाइलें", + "settings_iracing_last_file": "अंतिम सहेजी गई फ़ाइल", + "settings_iracing_not_available": "उपलब्ध नहीं", + "settings_iracing_none": "कोई नहीं", + "settings_iracing_local_files_note": "Windows बैकएंड को iRacing वाले उसी उपयोगकर्ता और सत्र के अंतर्गत चलाएँ। दूरस्थ ब्राउज़र किसी दूसरे गेमिंग PC से फ़ाइलें एकत्र नहीं कर सकता।", + "home_clean_driving_score": "साफ़ ड्राइविंग स्कोर", + "home_clean_driving_score_description": "{clean}/{total} समयबद्ध लैप मान्य हैं। पिट लैप शामिल नहीं हैं। यह लैप की वैधता का स्कोर है, iRacing Safety Rating नहीं।", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "लाइव SDK टेलीमेट्री", + "iracing_live_receiving": "टेलीमेट्री प्राप्त हो रही है", + "iracing_live_disconnected": "बैकएंड डिस्कनेक्ट है", + "iracing_live_waiting_title": "iRacing टेलीमेट्री की प्रतीक्षा", + "iracing_live_waiting_description": "RaceIQ चलाने वाले Windows PC पर iRacing शुरू करें और ड्राइविंग सत्र में प्रवेश करें। लाइव टेलीमेट्री अपने-आप मिलती है; IBT कैप्चर वैकल्पिक है।", + "iracing_live_inputs": "ड्राइवर इनपुट", + "iracing_live_motion": "वाहन की गति", + "iracing_live_suspension": "सस्पेंशन यात्रा", + "iracing_live_lap_progress": "लैप प्रगति", + "iracing_live_fuel_remaining": "शेष ईंधन", + "iracing_live_lateral_g": "पार्श्व G", + "iracing_live_longitudinal_g": "अनुदैर्ध्य G", + "iracing_live_roll": "रोल", + "iracing_live_pitch": "पिच", + "iracing_live_snapshot_note": "टायर कार्कस तापमान और घिसाव पिट स्टॉप पर अपडेट होते हैं; दबाव ठंडे सेटअप का दबाव है। ये लाइव टायर माप नहीं हैं।", + "iracing_live_position_note": "केवल लाइव SDK डेटा। GPS ड्राइविंग लाइन के लिए रिकॉर्ड की गई .ibt फ़ाइल आवश्यक है।", + "iracing_incident_report_heading": "इंसिडेंट रिपोर्ट", + "iracing_incident_report_points": "देखे गए इंसिडेंट पॉइंट", + "iracing_incident_report_spans": "देखे गए ऑफ-ट्रैक स्पैन", + "iracing_incident_report_duration": "नमूनों में देखा गया ऑफ-ट्रैक समय", + "iracing_incident_report_streak": "सबसे लंबी दर्ज क्लीन-लैप लड़ी", + "iracing_incident_report_windows": "पूरी तरह पढ़ी गई सेव की गई लैप विंडो {included}/{total}", + "iracing_incident_report_counter": "व्यक्तिगत काउंटर: {status}", + "iracing_incident_report_surface": "सतह साक्ष्य: {status}", + "iracing_incident_report_available": "उपलब्ध", + "iracing_incident_report_partial": "आंशिक", + "iracing_incident_report_unavailable": "अनुपलब्ध", + "iracing_incident_report_scope": "केवल दर्ज लैप का साक्ष्य। बिना रिकॉर्ड किए गए अंतराल का आकलन नहीं किया जाता।", + "iracing_incident_report_explanation": "बेसलाइन पॉइंट नहीं जोड़े जाते। पॉइंट मार्कर बताते हैं कि लगातार नमूनों के बीच व्यक्तिगत काउंटर में वृद्धि कब देखी गई। ऑफ-ट्रैक स्पैन नमूनों पर आधारित हैं और सेव की गई लैप तक सीमित हैं। इनमें से कोई भी गलती, टक्कर का प्रकार या Safety Rating साबित नहीं करता।", + "iracing_incident_report_loading": "इंसिडेंट रिपोर्ट लोड हो रही है…", + "iracing_incident_report_error": "इंसिडेंट रिपोर्ट पढ़ी नहीं जा सकी।", + "iracing_incident_report_retry": "फिर कोशिश करें", + "iracing_incident_report_no_evidence": "इन सेव की गई लैप विंडो के लिए इंसिडेंट और सतह साक्ष्य अनुपलब्ध हैं।", + "iracing_incident_report_empty": "कवर किए गए डेटा में कोई पॉइंट वृद्धि/ऑफ-ट्रैक स्पैन नहीं देखा गया।", + "iracing_incident_report_point_event": "+{points} इंसिडेंट पॉइंट देखे गए", + "iracing_incident_report_span_event": "ऑफ-ट्रैक स्पैन", + "iracing_incident_report_sampled_duration": "नमूनों में अवधि {duration}", + "iracing_incident_report_clipped_start": "शुरुआत सीमित", + "iracing_incident_report_clipped_end": "अंत सीमित", + "iracing_incident_report_lap_time": "लैप {lap} · सत्र {time}", + "iracing_incident_report_bounds": "सिम्युलेटर सत्र समय {previous} और {current} के बीच देखा गया", + "iracing_incident_report_replay": "रीप्ले · लैप समय {time}", + "iracing_incident_report_replay_link": "रीप्ले खोलें", + "iracing_incident_report_no_replay": "रीप्ले स्थिति अनुपलब्ध", + "iracing_incident_report_events": "दर्ज घटना समयरेखा", + "iracing_incident_report_markers": "दर्ज इंसिडेंट मार्कर", + "iracing_incident_report_marker_loading": "मार्कर लोड हो रहे हैं…", + "iracing_incident_report_marker_error": "इंसिडेंट मार्कर पढ़े नहीं जा सके।", + "iracing_incident_report_marker_unavailable": "इंसिडेंट मार्कर साक्ष्य अनुपलब्ध।", + "iracing_incident_report_marker_partial": "आंशिक साक्ष्य या रीप्ले स्थितियाँ अनुपलब्ध।", + "iracing_incident_report_marker_empty": "कवर किए गए लैप डेटा में कोई घटना नहीं देखी गई।", + "iracing_replay_recorded": "रिकॉर्ड किया गया टेलीमेट्री डेटा", + "iracing_replay_source_note": "केवल इस रिकॉर्डिंग में मौजूद चैनल दिखाए जाते हैं।", + "iracing_replay_conditions": "ट्रैक और मौसम", + "iracing_replay_session": "रिकॉर्ड किया गया सत्र", + "iracing_replay_session_time": "सिम्युलेटर सत्र समय", + "iracing_replay_coolant": "कूलेंट तापमान", + "iracing_replay_oil_temp": "तेल का तापमान", + "iracing_replay_oil_pressure": "तेल का दबाव", + "iracing_replay_fuel_pressure": "ईंधन का दबाव", + "iracing_replay_voltage": "बैटरी वोल्टेज", + "iracing_replay_wind": "हवा की गति", + "iracing_replay_wind_direction": "हवा की दिशा", + "iracing_replay_humidity": "सापेक्ष आर्द्रता", + "iracing_replay_yaw_rate": "यॉ दर", + "iracing_replay_shock_velocity": "शॉक वेग", + "iracing_replay_brake_line_pressure": "ब्रेक लाइन दबाव", + "iracing_replay_fuel_used": "रिकॉर्ड किए गए लैप में उपयोग किया गया ईंधन", + "iracing_replay_steering_torque": "स्टीयरिंग व्हील टॉर्क", + "iracing_replay_personal_counter": "व्यक्तिगत इंसिडेंट काउंटर", + "iracing_replay_pit_road": "पिट रोड पर", + "iracing_replay_pit_snapshots": "रिकॉर्ड किए गए पिट स्नैपशॉट", + "iracing_replay_empty": "इस रीप्ले स्थिति पर कोई उपयोगी टेलीमेट्री चैनल नहीं है।", "sessions_capture_split_queued": "Queued for lossless lap export", "sessions_capture_split_splitting": "Splitting recording into laps", "sessions_capture_split_verifying": "Verifying lap recordings", diff --git a/client/messages/it.json b/client/messages/it.json index 3531ccf82..22e899684 100644 --- a/client/messages/it.json +++ b/client/messages/it.json @@ -1033,6 +1033,13 @@ "carwire_dims": "Dimensioni", "carwire_tire_info": "Info pneumatici", "carwire_track": "Pista", + "replay_track_loading": "Caricamento della geometria della pista…", + "replay_track_error": "Impossibile caricare la geometria della pista.", + "replay_track_unavailable": "Posizione in pista non disponibile: questo replay richiede geometria allineata e distanza sul giro valida o posizione nativa.", + "replay_track_projected": "Posizione e bordi dalla mappa ufficiale — non sono una traiettoria misurata o una larghezza rilevata della pista.", + "replay_track_measured": "Traiettoria IBT misurata; la mappa ufficiale è solo un riferimento approssimativo.", + "replay_track_bounds_unavailable": "Bordi non disponibili per questo tracciato. La posizione usa la distanza sul giro, non la traiettoria misurata.", + "carwire_snapshot_stale": "Rilevazione precedente · non in tempo reale", "carwire_trails": "Tracce", "carwire_saved": "Salvato", "sectortimes_s1": "S1", @@ -1717,9 +1724,9 @@ "analyse_importable_laps": "Giri importabili", "analyse_on_track_rows": "righe in pista · velocità max {speed} mph", "analyse_import_cannot": "Questa registrazione non può essere importata.", - "analyse_ibt_note": "L'importazione crea una normale sessione iRacing di RaceIQ e una registrazione canonica .bin. Il file .ibt originale non viene copiato nell'archivio delle sessioni.", + "analyse_ibt_note": "L'importazione conserva la registrazione .ibt originale alla frequenza di campionamento nativa. Nessuna conversione BIN.", "session_import_title": "Importa dati di sessione", - "session_choose_file_hint": "Scegli un file. Il formato e i metadati del gioco verranno verificati in base al contenuto.", + "session_choose_file_hint": "Trascina qui un file oppure scegline uno. Il formato e i metadati del gioco verranno verificati in base al contenuto.", "session_choose_file": "Scegli file", "session_no_file": "Nessun file selezionato", "session_reading": "Lettura del contenuto del file…", @@ -1727,7 +1734,7 @@ "session_unsupported": "Il contenuto del file non è supportato.", "session_game_detected": "Gioco rilevato dal contenuto della telemetria.", "session_captures_found": "Trovate {count} registrazioni RaceIQ.", - "session_ibt_hint": "Le importazioni iRacing richiedono anteprima e conferma da Analisi.", + "session_ibt_hint": "Scegli Importa per visualizzare l'anteprima della registrazione iRacing e confermare i giri.", "session_motec_hint": "Le importazioni MoTeC richiedono di impostare gioco, auto e pista da Analisi.", "session_imported": "Importati {count} giri.{skipped}", "session_skipped": " Ignorati {count}.", @@ -2704,6 +2711,114 @@ "access_origins_safety": "Le modifiche di rete richiedono il riavvio di RaceIQ. La disattivazione non elimina le origini salvate. Le protezioni same-origin continuano.", "dash_hostname_origin_settings": "Aggiungi questa origine esatta in Impostazioni → Accesso e sicurezza → domini browser: {origin}. Salvare il nome host non autorizza l’accesso.", "dash_hostname_origin_managed": "Aggiungi questa origine esatta a SERVER_ALLOWED_ORIGINS: {origin}. Salvare il nome host non autorizza l’accesso.", + "settings_iracing_title": "Cattura iRacing automatica", + "settings_iracing_description": "Archivia automaticamente i file .ibt completati sul computer che esegue RaceIQ.", + "settings_iracing_opt_in_title": "L'acquisizione locale è attivabile", + "settings_iracing_opt_in_description": "La connessione abilita la telemetria su disco di iRacing mentre questo backend è in esecuzione. I file .ibt completati vengono copiati senza modificare gli originali. I file salvati non vengono importati automaticamente.", + "settings_iracing_connect": "Connettiti a iRacing", + "settings_iracing_disconnect": "Disconnetti", + "settings_iracing_pending": "Aggiornamento dell'acquisizione…", + "settings_iracing_armed": "L’acquisizione automatica resta attiva fino alla disconnessione o al riavvio del backend.", + "settings_iracing_state_unavailable": "Non disponibile", + "settings_iracing_state_disconnected": "Disconnesso", + "settings_iracing_state_waiting_iracing": "Aspettando iRacing", + "settings_iracing_state_waiting_recording": "In attesa della registrazione", + "settings_iracing_state_recording": "Registrazione", + "settings_iracing_state_saving": "Salvataggio del file completato", + "settings_iracing_state_error": "Errore di cattura", + "settings_iracing_reason_unsupported": "L'acquisizione automatica richiede il backend Windows sullo stesso PC di iRacing.", + "settings_iracing_reason_feature_disabled": "L'acquisizione di iRacing è disabilitata da questo backend.", + "settings_iracing_reason_documents": "Impossibile individuare la directory della telemetria iRacing.", + "settings_iracing_reason_sdk": "Impossibile comunicare con il simulatore iRacing.", + "settings_iracing_reason_archive": "Impossibile salvare i dati di telemetria nella directory di archivio.", + "settings_iracing_unavailable_title": "Acquisizione non disponibile", + "settings_iracing_query_error_title": "Impossibile caricare lo stato di acquisizione", + "settings_iracing_query_error": "Richiesta di stato non riuscita. Controlla la connessione al backend e riprova riaprendo Impostazioni.", + "settings_iracing_mutation_error_title": "Impossibile aggiornare l'acquisizione", + "settings_iracing_mutation_error": "Il backend non ha confermato questa modifica all'acquisizione. Controlla lo stato prima di riprovare.", + "settings_iracing_source_directory": "Directory di origine di iRacing", + "settings_iracing_archive_directory": "Directory di archivio RaceIQ", + "settings_iracing_saved_count": "File salvati dall’avvio del backend", + "settings_iracing_last_file": "Ultimo file salvato", + "settings_iracing_not_available": "Non disponibile", + "settings_iracing_none": "Nessuno", + "settings_iracing_local_files_note": "Esegui il backend Windows con lo stesso utente e nella stessa sessione di iRacing. Un browser remoto non può raccogliere file da un altro PC da gioco.", + "home_clean_driving_score": "Punteggio di guida pulita", + "home_clean_driving_score_description": "{clean}/{total} giri cronometrati validi. Esclusi i giri ai box. Punteggio di validità dei giri, non il Safety Rating di iRacing.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Telemetria SDK in tempo reale", + "iracing_live_receiving": "Ricezione telemetria", + "iracing_live_disconnected": "Backend disconnesso", + "iracing_live_waiting_title": "In attesa della telemetria iRacing", + "iracing_live_waiting_description": "Avvia iRacing sul PC Windows che esegue RaceIQ ed entra in una sessione di guida. La telemetria live è automatica; la registrazione IBT è facoltativa.", + "iracing_live_inputs": "Comandi del pilota", + "iracing_live_motion": "Movimento del veicolo", + "iracing_live_suspension": "Escursione delle sospensioni", + "iracing_live_lap_progress": "Avanzamento del giro", + "iracing_live_fuel_remaining": "Carburante rimanente", + "iracing_live_lateral_g": "Accelerazione laterale", + "iracing_live_longitudinal_g": "Accelerazione longitudinale", + "iracing_live_roll": "Rollio", + "iracing_live_pitch": "Beccheggio", + "iracing_live_snapshot_note": "Temperatura della carcassa e usura degli pneumatici si aggiornano ai pit stop; la pressione è quella del setup a freddo. Non sono misurazioni live degli pneumatici.", + "iracing_live_position_note": "Solo dati SDK live. Le traiettorie GPS richiedono un file .ibt registrato.", + "iracing_incident_report_heading": "Rapporto incidenti", + "iracing_incident_report_points": "Punti incidente osservati", + "iracing_incident_report_spans": "Tratti fuori pista osservati", + "iracing_incident_report_duration": "Tempo fuori pista campionato", + "iracing_incident_report_streak": "Serie più lunga di giri puliti registrati", + "iracing_incident_report_windows": "Finestre dei giri salvati lette integralmente {included}/{total}", + "iracing_incident_report_counter": "Contatore personale: {status}", + "iracing_incident_report_surface": "Evidenza della superficie: {status}", + "iracing_incident_report_available": "Disponibile", + "iracing_incident_report_partial": "Parziale", + "iracing_incident_report_unavailable": "Non disponibile", + "iracing_incident_report_scope": "Solo evidenza dei giri registrati. Gli intervalli non registrati non vengono valutati.", + "iracing_incident_report_explanation": "I valori iniziali non vengono sommati. I marcatori indicano quando è stato osservato un aumento del contatore personale tra campioni consecutivi. I tratti fuori pista sono campionati e limitati ai giri salvati. Nessuno dei due dimostra responsabilità, tipo di collisione o Safety Rating.", + "iracing_incident_report_loading": "Caricamento del rapporto incidenti…", + "iracing_incident_report_error": "Impossibile leggere il rapporto incidenti.", + "iracing_incident_report_retry": "Riprova", + "iracing_incident_report_no_evidence": "Nessuna evidenza di incidenti o superficie disponibile per queste finestre dei giri salvati.", + "iracing_incident_report_empty": "Nessun aumento dei punti o tratto fuori pista osservato nei dati coperti.", + "iracing_incident_report_point_event": "+{points} punti incidente osservati", + "iracing_incident_report_span_event": "Tratto fuori pista", + "iracing_incident_report_sampled_duration": "Durata campionata {duration}", + "iracing_incident_report_clipped_start": "Inizio troncato", + "iracing_incident_report_clipped_end": "Fine troncata", + "iracing_incident_report_lap_time": "Giro {lap} · sessione {time}", + "iracing_incident_report_bounds": "Osservato tra {previous} e {current} secondo il tempo di sessione del simulatore", + "iracing_incident_report_replay": "Replay · tempo sul giro {time}", + "iracing_incident_report_replay_link": "Apri replay", + "iracing_incident_report_no_replay": "Posizione del replay non disponibile", + "iracing_incident_report_events": "Cronologia degli eventi registrati", + "iracing_incident_report_markers": "Marcatori di incidenti registrati", + "iracing_incident_report_marker_loading": "Caricamento marcatori…", + "iracing_incident_report_marker_error": "Impossibile leggere i marcatori di incidenti.", + "iracing_incident_report_marker_unavailable": "Evidenza dei marcatori di incidenti non disponibile.", + "iracing_incident_report_marker_partial": "Evidenza parziale o posizioni del replay non disponibili.", + "iracing_incident_report_marker_empty": "Nessun evento osservato nei dati dei giri coperti.", + "iracing_replay_recorded": "Telemetria registrata", + "iracing_replay_source_note": "Sono mostrati solo i canali presenti in questa registrazione.", + "iracing_replay_conditions": "Pista e meteo", + "iracing_replay_session": "Sessione registrata", + "iracing_replay_session_time": "Tempo di sessione del simulatore", + "iracing_replay_coolant": "Temperatura del liquido di raffreddamento", + "iracing_replay_oil_temp": "Temperatura dell’olio", + "iracing_replay_oil_pressure": "Pressione dell’olio", + "iracing_replay_fuel_pressure": "Pressione del carburante", + "iracing_replay_voltage": "Tensione della batteria", + "iracing_replay_wind": "Velocità del vento", + "iracing_replay_wind_direction": "Direzione del vento", + "iracing_replay_humidity": "Umidità relativa", + "iracing_replay_yaw_rate": "Velocità d’imbardata", + "iracing_replay_shock_velocity": "Velocità dell’ammortizzatore", + "iracing_replay_brake_line_pressure": "Pressione della tubazione freni", + "iracing_replay_fuel_used": "Carburante usato nel giro registrato", + "iracing_replay_steering_torque": "Coppia al volante", + "iracing_replay_personal_counter": "Contatore personale degli incidenti", + "iracing_replay_pit_road": "Sulla corsia dei box", + "iracing_replay_pit_snapshots": "Istantanee dei box registrate", + "iracing_replay_empty": "Nessun canale telemetrico utilizzabile in questa posizione del replay.", "storage_downsample_action": "Riduci frequenza registrazioni…", "storage_downsample_title": "Riduci frequenza registrazioni", "storage_downsample_description": "Riduce la frequenza della telemetria archiviata. Le registrazioni preferite e le importazioni MoTeC sono protette.", diff --git a/client/messages/ja.json b/client/messages/ja.json index e87763907..42c79442a 100644 --- a/client/messages/ja.json +++ b/client/messages/ja.json @@ -1033,6 +1033,13 @@ "carwire_dims": "寸法", "carwire_tire_info": "タイヤ情報", "carwire_track": "コース", + "replay_track_loading": "コース形状を読み込み中…", + "replay_track_error": "コース形状を読み込めませんでした。", + "replay_track_unavailable": "コース上の位置を表示できません。このリプレイには位置合わせ済みのコース形状と有効な周回距離、または元の位置データが必要です。", + "replay_track_projected": "位置とコース端は公式マップに基づきます。実測の走行ラインや測量されたコース幅ではありません。", + "replay_track_measured": "IBTの実測走行軌跡。公式マップは概略参照です。", + "replay_track_bounds_unavailable": "このレイアウトのコース端は表示できません。位置は周回距離に基づき、実測の走行ラインではありません。", + "carwire_snapshot_stale": "過去のスナップショット · ライブ値ではありません", "carwire_trails": "軌跡", "carwire_saved": "保存済み", "sectortimes_s1": "S1", @@ -1717,9 +1724,9 @@ "analyse_importable_laps": "インポート可能なラップ", "analyse_on_track_rows": "走行中の行 · 最高速度 {speed} mph", "analyse_import_cannot": "この記録はインポートできません。", - "analyse_ibt_note": "インポートすると通常のRaceIQ iRacingセッションと標準の.bin記録が作成されます。元の.ibtファイルはセッションストレージにコピーされません。", + "analyse_ibt_note": "元の.ibt記録を元のサンプリングレートで保持します。BIN変換は行いません。", "session_import_title": "セッションデータをインポート", - "session_choose_file_hint": "ファイルを選択してください。内容から形式とゲームのメタデータを確認します。", + "session_choose_file_hint": "ここにファイルをドラッグ&ドロップするか、ファイルを選択してください。内容から形式とゲームのメタデータを確認します。", "session_choose_file": "ファイルを選択", "session_no_file": "ファイルが選択されていません", "session_reading": "ファイルの内容を読み込み中…", @@ -1727,7 +1734,7 @@ "session_unsupported": "このファイル内容には対応していません。", "session_game_detected": "テレメトリーの内容からゲームを検出しました。", "session_captures_found": "RaceIQの記録が{count}件見つかりました。", - "session_ibt_hint": "iRacingのインポートには、分析画面でのプレビューと確認が必要です。", + "session_ibt_hint": "「インポート」を選択してiRacing記録をプレビューし、ラップを確認してください。", "session_motec_hint": "MoTeCのインポートには、分析画面でゲーム、車両、コースの設定が必要です。", "session_imported": "{count}ラップをインポートしました。{skipped}", "session_skipped": " {count}件をスキップしました。", @@ -2704,6 +2711,114 @@ "access_origins_safety": "ネットワーク設定の変更には RaceIQ の再起動が必要です。オフにしても保存済みオリジンは削除されません。同一オリジン保護は継続します。", "dash_hostname_origin_settings": "この正確なオリジンを「設定 → アクセスとセキュリティ → ブラウザー ドメイン」に追加してください: {origin}。ホスト名を保存してもアクセスは許可されません。", "dash_hostname_origin_managed": "この正確なオリジンを SERVER_ALLOWED_ORIGINS に追加してください: {origin}。ホスト名を保存してもアクセスは許可されません。", + "settings_iracing_title": "iRacing の自動キャプチャ", + "settings_iracing_description": "記録が完了した .ibt ファイルを RaceIQ が動作するコンピューターに自動保存します。", + "settings_iracing_opt_in_title": "ローカルキャプチャはオプトインです", + "settings_iracing_opt_in_description": "接続すると、このバックエンドの動作中に iRacing のディスク記録が有効になります。記録済みの .ibt ファイルをコピーし、元のファイルは変更しません。保存したファイルは自動でインポートされません。", + "settings_iracing_connect": "iRacing に接続する", + "settings_iracing_disconnect": "切断する", + "settings_iracing_pending": "キャプチャを更新中…", + "settings_iracing_armed": "切断するかバックエンドを再起動するまで、自動記録が有効です。", + "settings_iracing_state_unavailable": "利用不可", + "settings_iracing_state_disconnected": "切断されました", + "settings_iracing_state_waiting_iracing": "iRacing を待っています", + "settings_iracing_state_waiting_recording": "記録開始を待機中", + "settings_iracing_state_recording": "記録中", + "settings_iracing_state_saving": "完了したファイルの保存", + "settings_iracing_state_error": "キャプチャエラー", + "settings_iracing_reason_unsupported": "自動キャプチャには、iRacing と同じ PC 上に Windows バックエンドが必要です。", + "settings_iracing_reason_feature_disabled": "iRacing キャプチャは、このバックエンドによって無効になっています。", + "settings_iracing_reason_documents": "iRacing テレメトリ ディレクトリが見つかりませんでした。", + "settings_iracing_reason_sdk": "iRacing シミュレーターと通信できませんでした。", + "settings_iracing_reason_archive": "テレメトリをアーカイブ ディレクトリに保存できませんでした。", + "settings_iracing_unavailable_title": "キャプチャは利用できません", + "settings_iracing_query_error_title": "キャプチャステータスをロードできませんでした", + "settings_iracing_query_error": "ステータス要求が失敗しました。バックエンド接続を確認し、設定を再度開いて再試行してください。", + "settings_iracing_mutation_error_title": "キャプチャを更新できませんでした", + "settings_iracing_mutation_error": "バックエンドはこのキャプチャの変更を確認しませんでした。再試行する前にステータスを確認してください。", + "settings_iracing_source_directory": "iRacing ソース ディレクトリ", + "settings_iracing_archive_directory": "RaceIQ アーカイブ ディレクトリ", + "settings_iracing_saved_count": "バックエンド起動後に保存したファイル数", + "settings_iracing_last_file": "最後に保存したファイル", + "settings_iracing_not_available": "利用不可", + "settings_iracing_none": "なし", + "settings_iracing_local_files_note": "iRacing と同じ Windows ユーザーおよびセッションでバックエンドを実行してください。リモートブラウザーから別のゲーム用 PC のファイルを収集することはできません。", + "home_clean_driving_score": "クリーンドライビングスコア", + "home_clean_driving_score_description": "有効な計測ラップは {clean}/{total}。ピットラップは除外。ラップの有効性に基づくスコアで、iRacing Safety Rating ではありません。", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "ライブ SDK テレメトリー", + "iracing_live_receiving": "テレメトリー受信中", + "iracing_live_disconnected": "バックエンド未接続", + "iracing_live_waiting_title": "iRacing のテレメトリーを待機中", + "iracing_live_waiting_description": "RaceIQ を実行している Windows PC で iRacing を起動し、走行セッションに入ってください。ライブテレメトリーは自動です。IBT の記録は任意です。", + "iracing_live_inputs": "ドライバー操作", + "iracing_live_motion": "車両の動き", + "iracing_live_suspension": "サスペンションストローク", + "iracing_live_lap_progress": "ラップ進行状況", + "iracing_live_fuel_remaining": "残燃料", + "iracing_live_lateral_g": "横方向G", + "iracing_live_longitudinal_g": "縦方向G", + "iracing_live_roll": "ロール", + "iracing_live_pitch": "ピッチ", + "iracing_live_snapshot_note": "タイヤのカーカス温度と摩耗はピットストップ時に更新されます。空気圧は冷間時のセットアップ値です。ライブのタイヤ計測値ではありません。", + "iracing_live_position_note": "ライブ SDK データのみです。GPS走行ラインには記録済みの .ibt ファイルが必要です。", + "iracing_incident_report_heading": "インシデントレポート", + "iracing_incident_report_points": "観測されたインシデントポイント", + "iracing_incident_report_spans": "観測されたオフトラック区間", + "iracing_incident_report_duration": "サンプルで観測されたオフトラック時間", + "iracing_incident_report_streak": "記録された最長クリーンラップ連続数", + "iracing_incident_report_windows": "完全に読み取った保存ラップウィンドウ {included}/{total}", + "iracing_incident_report_counter": "個人カウンター:{status}", + "iracing_incident_report_surface": "路面データ:{status}", + "iracing_incident_report_available": "利用可能", + "iracing_incident_report_partial": "一部利用可能", + "iracing_incident_report_unavailable": "利用不可", + "iracing_incident_report_scope": "記録されたラップのデータのみ。未記録の区間は評価されません。", + "iracing_incident_report_explanation": "ベースラインのポイントは加算されません。ポイントマーカーは連続するサンプル間で個人カウンターの増加が観測された時点を示します。オフトラック区間はサンプルに基づき、保存ラップ内に限定されます。いずれも過失、衝突の種類、Safety Rating を証明するものではありません。", + "iracing_incident_report_loading": "インシデントレポートを読み込み中…", + "iracing_incident_report_error": "インシデントレポートを読み取れませんでした。", + "iracing_incident_report_retry": "再試行", + "iracing_incident_report_no_evidence": "これらの保存ラップウィンドウではインシデントおよび路面データを利用できません。", + "iracing_incident_report_empty": "対象データでポイント増加やオフトラック区間は観測されませんでした。", + "iracing_incident_report_point_event": "インシデントポイント +{points} を観測", + "iracing_incident_report_span_event": "オフトラック区間", + "iracing_incident_report_sampled_duration": "サンプル観測時間 {duration}", + "iracing_incident_report_clipped_start": "開始側が限定", + "iracing_incident_report_clipped_end": "終了側が限定", + "iracing_incident_report_lap_time": "ラップ {lap} · セッション {time}", + "iracing_incident_report_bounds": "シミュレーターのセッション時刻 {previous}~{current} の間に観測", + "iracing_incident_report_replay": "リプレイ · ラップタイム {time}", + "iracing_incident_report_replay_link": "リプレイを開く", + "iracing_incident_report_no_replay": "リプレイ位置を利用できません", + "iracing_incident_report_events": "記録イベントのタイムライン", + "iracing_incident_report_markers": "記録されたインシデントマーカー", + "iracing_incident_report_marker_loading": "マーカーを読み込み中…", + "iracing_incident_report_marker_error": "インシデントマーカーを読み取れませんでした。", + "iracing_incident_report_marker_unavailable": "インシデントマーカーデータを利用できません。", + "iracing_incident_report_marker_partial": "データまたはリプレイ位置が一部利用できません。", + "iracing_incident_report_marker_empty": "対象ラップデータでイベントは観測されませんでした。", + "iracing_replay_recorded": "記録されたテレメトリー", + "iracing_replay_source_note": "この記録に含まれるチャンネルのみ表示します。", + "iracing_replay_conditions": "コースと天候", + "iracing_replay_session": "記録されたセッション", + "iracing_replay_session_time": "シミュレーターのセッション時刻", + "iracing_replay_coolant": "冷却水温度", + "iracing_replay_oil_temp": "油温", + "iracing_replay_oil_pressure": "油圧", + "iracing_replay_fuel_pressure": "燃圧", + "iracing_replay_voltage": "バッテリー電圧", + "iracing_replay_wind": "風速", + "iracing_replay_wind_direction": "風向", + "iracing_replay_humidity": "相対湿度", + "iracing_replay_yaw_rate": "ヨーレート", + "iracing_replay_shock_velocity": "ダンパー速度", + "iracing_replay_brake_line_pressure": "ブレーキライン圧", + "iracing_replay_fuel_used": "記録ラップで使用した燃料", + "iracing_replay_steering_torque": "ステアリングトルク", + "iracing_replay_personal_counter": "個人インシデントカウンター", + "iracing_replay_pit_road": "ピットロード上", + "iracing_replay_pit_snapshots": "記録されたピットスナップショット", + "iracing_replay_empty": "このリプレイ位置に利用可能なテレメトリチャンネルはありません。", "storage_downsample_action": "録画のサンプル頻度を下げる…", "storage_downsample_title": "録画のサンプル頻度を下げる", "storage_downsample_description": "保存済みテレメトリーのサンプル頻度を下げます。お気に入りの録画とMoTeCのインポートは保護されます。", diff --git a/client/messages/ko.json b/client/messages/ko.json index f73da0155..ceb2d62e7 100644 --- a/client/messages/ko.json +++ b/client/messages/ko.json @@ -1715,9 +1715,9 @@ "analyse_importable_laps": "가져올 수 있는 랩", "analyse_on_track_rows": "온트랙 행 · 최대 {speed}mph", "analyse_import_cannot": "이 녹음을 가져올 수 없습니다.", - "analyse_ibt_note": "가져오기는 일반적인 RaceIQ iRacing 세션과 표준 .bin 캡처를 생성합니다. 원본 .ibt 파일은 세션 저장소에 복사되지 않습니다.", + "analyse_ibt_note": "가져오기는 원본 .ibt 기록을 원래 샘플링 속도로 보존합니다. BIN 변환은 하지 않습니다.", "session_import_title": "세션 데이터 가져오기", - "session_choose_file_hint": "파일을 선택하세요. 형식과 게임 메타데이터는 해당 콘텐츠에서 확인됩니다.", + "session_choose_file_hint": "파일을 여기로 끌어다 놓거나 선택하세요. 형식과 게임 메타데이터는 파일 내용에서 확인합니다.", "session_choose_file": "파일 선택", "session_no_file": "선택한 파일이 없습니다.", "session_reading": "파일 내용을 읽는 중…", @@ -1725,7 +1725,7 @@ "session_unsupported": "파일 콘텐츠가 지원되지 않습니다.", "session_game_detected": "원격 측정 콘텐츠에서 게임이 감지되었습니다.", "session_captures_found": "{count} RaceIQ 캡처가 발견되었습니다.", - "session_ibt_hint": "iRacing 가져오기에는 Analyse의 미리보기 및 확인이 필요합니다.", + "session_ibt_hint": "가져오기를 선택하여 iRacing 기록을 미리 보고 랩을 확인하세요.", "session_motec_hint": "MoTeC 가져오기에는 Analyse의 게임, 자동차 및 트랙 설정이 필요합니다.", "session_imported": "수입된 {count} 랩.{skipped}", "session_skipped": "{count}를 건너뛰었습니다.", @@ -2701,6 +2701,121 @@ "access_origins_managed": "SERVER_ALLOWED_ORIGINS에 의해 활성화됩니다. 이 환경 설정은 화이트리스트를 제어합니다. 거기에서 변경하고 RaceIQ를 다시 시작하세요.", "access_origins_safety": "네트워크 액세스를 저장하려면 RaceIQ를 다시 시작해야 합니다. 이 기능을 꺼도 저장된 원본은 지워지지 않습니다. 동일 출처 보호는 계속됩니다.", "dash_hostname_origin_settings": "설정 → 액세스 및 보안 → 브라우저 도메인: {origin}에 정확한 출처를 포함하세요. 이 호스트 이름을 저장해도 액세스가 승인되지 않습니다.", + "replay_track_loading": "트랙 형상을 불러오는 중…", + "replay_track_error": "트랙 형상을 불러오지 못했습니다.", + "replay_track_unavailable": "트랙 위치 표시 불가: 이 리플레이에는 정렬된 트랙 형상과 유효한 랩 거리 또는 기본 위치 정보가 필요합니다.", + "replay_track_projected": "위치와 트랙 가장자리는 공식 지도를 사용합니다. 측정된 주행선이나 실측 트랙 폭이 아닙니다.", + "replay_track_measured": "측정된 IBT 궤적입니다. 공식 지도는 대략적인 참고 자료일 뿐입니다.", + "replay_track_bounds_unavailable": "이 레이아웃에서는 트랙 가장자리를 사용할 수 없습니다. 위치는 측정된 주행선이 아니라 랩 거리를 사용합니다.", + "carwire_snapshot_stale": "오래된 스냅샷 · 실시간 아님", + "settings_iracing_title": "iRacing 자동 캡처", + "settings_iracing_description": "RaceIQ가 실행되는 컴퓨터에서 완료된 .ibt 파일을 자동으로 보관합니다.", + "settings_iracing_opt_in_title": "로컬 캡처는 명시적 동의가 필요합니다", + "settings_iracing_opt_in_description": "연결하면 이 백엔드가 실행되는 동안 iRacing 디스크 텔레메트리가 활성화됩니다. 완료된 .ibt 파일은 복사되며 원본은 그대로 유지됩니다. 저장된 파일은 자동으로 가져오지 않습니다.", + "settings_iracing_connect": "iRacing에 연결", + "settings_iracing_disconnect": "연결 해제", + "settings_iracing_pending": "캡처 설정 업데이트 중…", + "settings_iracing_armed": "연결을 해제하거나 백엔드를 다시 시작할 때까지 자동 캡처가 활성화됩니다.", + "settings_iracing_state_unavailable": "사용할 수 없음", + "settings_iracing_state_disconnected": "연결 끊김", + "settings_iracing_state_waiting_iracing": "iRacing 대기 중", + "settings_iracing_state_waiting_recording": "녹화 대기 중", + "settings_iracing_state_recording": "녹화 중", + "settings_iracing_state_saving": "완료된 파일 저장 중", + "settings_iracing_state_error": "캡처 오류", + "settings_iracing_reason_unsupported": "자동 캡처는 iRacing과 동일한 PC에서 실행되는 Windows 백엔드가 필요합니다.", + "settings_iracing_reason_feature_disabled": "이 백엔드에서 iRacing 캡처가 비활성화되어 있습니다.", + "settings_iracing_reason_documents": "iRacing 텔레메트리 디렉터리를 찾을 수 없습니다.", + "settings_iracing_reason_sdk": "iRacing 시뮬레이터와 통신할 수 없습니다.", + "settings_iracing_reason_archive": "아카이브 디렉터리에 텔레메트리를 저장할 수 없습니다.", + "settings_iracing_unavailable_title": "캡처를 사용할 수 없음", + "settings_iracing_query_error_title": "캡처 상태를 불러올 수 없음", + "settings_iracing_query_error": "상태 요청에 실패했습니다. 백엔드 연결을 확인하고 설정을 다시 열어 재시도하세요.", + "settings_iracing_mutation_error_title": "캡처를 업데이트할 수 없음", + "settings_iracing_mutation_error": "백엔드에서 이 캡처 변경을 확인하지 않았습니다. 다시 시도하기 전에 상태를 확인하세요.", + "settings_iracing_source_directory": "iRacing 원본 디렉터리", + "settings_iracing_archive_directory": "RaceIQ 아카이브 디렉터리", + "settings_iracing_saved_count": "이번 실행에서 저장된 파일", + "settings_iracing_last_file": "마지막으로 저장된 파일", + "settings_iracing_not_available": "사용할 수 없음", + "settings_iracing_none": "없음", + "settings_iracing_local_files_note": "Windows 백엔드를 iRacing과 동일한 사용자 및 세션으로 실행하세요. 원격 브라우저는 다른 게이밍 PC에서 파일을 수집할 수 없습니다.", + "home_clean_driving_score": "클린 드라이빙 점수", + "home_clean_driving_score_description": "유효한 계측 랩 {clean}/{total}. 피트 랩 제외. 랩 유효성 점수이며 iRacing Safety Rating이 아닙니다.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "실시간 SDK 텔레메트리", + "iracing_live_receiving": "텔레메트리 수신 중", + "iracing_live_disconnected": "백엔드 연결 끊김", + "iracing_live_waiting_title": "iRacing 텔레메트리 대기 중", + "iracing_live_waiting_description": "RaceIQ를 실행 중인 Windows PC에서 iRacing을 시작하고 주행 세션에 진입하세요. 실시간 텔레메트리는 자동이며 IBT 캡처는 선택 사항입니다.", + "iracing_live_inputs": "운전자 입력", + "iracing_live_motion": "차량 움직임", + "iracing_live_suspension": "서스펜션 스트로크", + "iracing_live_lap_progress": "랩 진행 상황", + "iracing_live_fuel_remaining": "남은 연료", + "iracing_live_lateral_g": "횡가속도", + "iracing_live_longitudinal_g": "종가속도", + "iracing_live_roll": "롤", + "iracing_live_pitch": "피치", + "iracing_live_snapshot_note": "타이어 카커스 온도와 마모도는 피트 스톱 때 갱신됩니다. 압력은 냉간 셋업 압력입니다. 실시간 타이어 측정값이 아닙니다.", + "iracing_live_position_note": "실시간 SDK 데이터만 표시합니다. GPS 주행 라인에는 녹화된 .ibt 파일이 필요합니다.", + "iracing_incident_report_heading": "인시던트 보고서", + "iracing_incident_report_points": "관측된 인시던트 포인트", + "iracing_incident_report_spans": "관측된 오프트랙 구간", + "iracing_incident_report_duration": "샘플링된 오프트랙 시간", + "iracing_incident_report_streak": "기록된 최장 클린 랩 연속 횟수", + "iracing_incident_report_windows": "완전히 읽은 저장 랩 구간 {included}/{total}", + "iracing_incident_report_counter": "개인 카운터: {status}", + "iracing_incident_report_surface": "노면 증거: {status}", + "iracing_incident_report_available": "사용 가능", + "iracing_incident_report_partial": "부분 제공", + "iracing_incident_report_unavailable": "사용 불가", + "iracing_incident_report_scope": "기록된 랩 증거만 표시합니다. 기록되지 않은 구간은 평가하지 않습니다.", + "iracing_incident_report_explanation": "기준값 포인트는 더하지 않습니다. 포인트 마커는 연속된 샘플 사이에서 개인 카운터 증가가 관측된 시점을 나타냅니다. 오프트랙 구간은 샘플 기반이며 저장된 랩 범위로 제한됩니다. 어느 쪽도 과실, 충돌 유형 또는 Safety Rating을 입증하지 않습니다.", + "iracing_incident_report_loading": "인시던트 보고서 불러오는 중…", + "iracing_incident_report_error": "인시던트 보고서를 읽을 수 없습니다.", + "iracing_incident_report_retry": "다시 시도", + "iracing_incident_report_no_evidence": "이 저장 랩 구간에서는 인시던트 및 노면 증거를 사용할 수 없습니다.", + "iracing_incident_report_empty": "포함된 데이터에서 포인트 증가/오프트랙 구간이 관측되지 않았습니다.", + "iracing_incident_report_point_event": "인시던트 포인트 +{points} 관측", + "iracing_incident_report_span_event": "오프트랙 구간", + "iracing_incident_report_sampled_duration": "샘플링된 시간 {duration}", + "iracing_incident_report_clipped_start": "시작 경계 불명확", + "iracing_incident_report_clipped_end": "끝 경계 불명확", + "iracing_incident_report_lap_time": "랩 {lap} · 세션 {time}", + "iracing_incident_report_bounds": "시뮬레이터 세션 시간 {previous}~{current} 사이에서 관측", + "iracing_incident_report_replay": "리플레이 · 랩 타임 {time}", + "iracing_incident_report_replay_link": "리플레이 열기", + "iracing_incident_report_no_replay": "리플레이 위치를 사용할 수 없습니다", + "iracing_incident_report_events": "기록된 이벤트 타임라인", + "iracing_incident_report_markers": "기록된 인시던트 마커", + "iracing_incident_report_marker_loading": "마커 불러오는 중…", + "iracing_incident_report_marker_error": "인시던트 마커를 읽을 수 없습니다.", + "iracing_incident_report_marker_unavailable": "인시던트 마커 증거를 사용할 수 없습니다.", + "iracing_incident_report_marker_partial": "증거 일부 또는 리플레이 위치를 사용할 수 없습니다.", + "iracing_incident_report_marker_empty": "포함된 랩 데이터에서 이벤트가 관측되지 않았습니다.", + "iracing_replay_recorded": "기록된 텔레메트리", + "iracing_replay_source_note": "이 녹화에 있는 채널만 표시됩니다.", + "iracing_replay_conditions": "트랙 및 날씨", + "iracing_replay_session": "기록된 세션", + "iracing_replay_session_time": "시뮬레이터 세션 시간", + "iracing_replay_coolant": "냉각수 온도", + "iracing_replay_oil_temp": "오일 온도", + "iracing_replay_oil_pressure": "오일 압력", + "iracing_replay_fuel_pressure": "연료 압력", + "iracing_replay_voltage": "배터리 전압", + "iracing_replay_wind": "풍속", + "iracing_replay_wind_direction": "풍향", + "iracing_replay_humidity": "상대 습도", + "iracing_replay_yaw_rate": "요 레이트", + "iracing_replay_shock_velocity": "댐퍼 속도", + "iracing_replay_brake_line_pressure": "브레이크 라인 압력", + "iracing_replay_fuel_used": "기록된 랩에서 사용한 연료", + "iracing_replay_steering_torque": "스티어링 휠 토크", + "iracing_replay_personal_counter": "개인 인시던트 카운터", + "iracing_replay_pit_road": "피트 로드 위", + "iracing_replay_pit_snapshots": "기록된 피트 스냅샷", + "iracing_replay_empty": "이 리플레이 위치에 사용할 수 있는 텔레메트리 채널이 없습니다.", "dash_hostname_origin_managed": "SERVER_ALLOWED_ORIGINS에 정확한 출처를 포함하세요: {origin}. 이 호스트 이름을 저장해도 액세스가 승인되지 않습니다.", "recording_frequency_label": "녹화 빈도", "recording_frequency_description": "낮은 빈도는 녹화 파일 크기와 처리량을 줄입니다. 공유 메모리 게임을 불러올 때 UDP가 안전한 기본값입니다. 녹화 빈도는 게임의 텔레메트리 빈도를 초과할 수 없습니다.", @@ -2742,7 +2857,6 @@ "storage_delete_after": "삭제 시점", "storage_retention_policy_error": "자동 삭제는 자동 샘플링 빈도 낮추기 이후로 예약해야 합니다.", "storage_retention_saved": "보존 정책을 저장했습니다.", - "dash_hostname_origin_managed": "SERVER_ALLOWED_ORIGINS: {origin}에 정확한 출처를 포함하세요. 이 호스트 이름을 저장해도 액세스가 승인되지 않습니다.", "sessions_capture_split_queued": "Queued for lossless lap export", "sessions_capture_split_splitting": "Splitting recording into laps", "sessions_capture_split_verifying": "Verifying lap recordings", diff --git a/client/messages/nl.json b/client/messages/nl.json index e4cc3b965..44e6369f6 100644 --- a/client/messages/nl.json +++ b/client/messages/nl.json @@ -1033,6 +1033,13 @@ "carwire_dims": "Afmetingen", "carwire_tire_info": "Bandgegevens", "carwire_track": "Baan", + "replay_track_loading": "Baangeometrie laden…", + "replay_track_error": "Baangeometrie kon niet worden geladen.", + "replay_track_unavailable": "Baanpositie niet beschikbaar: deze replay vereist uitgelijnde baangeometrie en geldige rondeafstand of oorspronkelijke positie.", + "replay_track_projected": "Positie en randen komen uit de officiële kaart — geen gemeten racelijn of ingemeten baanbreedte.", + "replay_track_measured": "Gemeten IBT-traject; officiële kaart is alleen een benaderende referentie.", + "replay_track_bounds_unavailable": "Baanranden niet beschikbaar voor deze indeling. Positie gebruikt rondeafstand, niet de gemeten racelijn.", + "carwire_snapshot_stale": "Oud momentbeeld · niet live", "carwire_trails": "Sporen", "carwire_saved": "Opgeslagen", "sectortimes_s1": "S1", @@ -1717,9 +1724,9 @@ "analyse_importable_laps": "Importeerbare ronden", "analyse_on_track_rows": "regels op circuit · max. {speed} mph", "analyse_import_cannot": "Deze opname kan niet worden geïmporteerd.", - "analyse_ibt_note": "Bij import wordt een normale RaceIQ-iRacing-sessie en canonieke .bin-opname aangemaakt. Het oorspronkelijke .ibt-bestand wordt niet naar de sessieopslag gekopieerd.", + "analyse_ibt_note": "De import bewaart de oorspronkelijke .ibt-opname met de oorspronkelijke bemonsteringsfrequentie. Geen BIN-conversie.", "session_import_title": "Sessiegegevens importeren", - "session_choose_file_hint": "Kies een bestand. De indeling en gamegegevens worden gecontroleerd aan de hand van de inhoud.", + "session_choose_file_hint": "Sleep hier een bestand naartoe of kies een bestand. De indeling en gamegegevens worden gecontroleerd aan de hand van de inhoud.", "session_choose_file": "Bestand kiezen", "session_no_file": "Geen bestand geselecteerd", "session_reading": "Bestandsinhoud lezen…", @@ -1727,7 +1734,7 @@ "session_unsupported": "Bestandsinhoud wordt niet ondersteund.", "session_game_detected": "Game gedetecteerd op basis van telemetrie-inhoud.", "session_captures_found": "{count} RaceIQ-opname(n) gevonden.", - "session_ibt_hint": "Voor iRacing-imports zijn een voorbeeld en bevestiging vanuit Analyse vereist.", + "session_ibt_hint": "Kies Importeren om de iRacing-opname te bekijken en de ronden te bevestigen.", "session_motec_hint": "Voor MoTeC-imports moeten game, auto en circuit in Analyse worden ingesteld.", "session_imported": "{count} ronde(n) geïmporteerd.{skipped}", "session_skipped": " {count} overgeslagen.", @@ -2704,6 +2711,114 @@ "access_origins_safety": "Netwerkwijzigingen vereisen een herstart van RaceIQ. Uitschakelen verwijdert opgeslagen origins niet. Same-originbeveiliging blijft actief.", "dash_hostname_origin_settings": "Voeg deze exacte origin toe bij Instellingen → Toegang en beveiliging → browserdomeinen: {origin}. De hostnaam opslaan geeft geen toegang.", "dash_hostname_origin_managed": "Voeg deze exacte origin toe aan SERVER_ALLOWED_ORIGINS: {origin}. De hostnaam opslaan geeft geen toegang.", + "settings_iracing_title": "Automatische iRacing-opname", + "settings_iracing_description": "Archiveer voltooide .ibt-bestanden automatisch op de computer waarop RaceIQ draait.", + "settings_iracing_opt_in_title": "Lokale opname is opt-in", + "settings_iracing_opt_in_description": "Verbinden schakelt iRacing-schijflogging in zolang deze backend draait. Voltooide .ibt-bestanden worden gekopieerd; originelen blijven ongewijzigd. Opgeslagen bestanden worden niet automatisch geïmporteerd.", + "settings_iracing_connect": "Maak verbinding met iRacing", + "settings_iracing_disconnect": "Verbreek de verbinding", + "settings_iracing_pending": "Opname bijwerken…", + "settings_iracing_armed": "Automatische opname blijft actief totdat je de verbinding verbreekt of de backend herstart.", + "settings_iracing_state_unavailable": "Niet beschikbaar", + "settings_iracing_state_disconnected": "Verbinding verbroken", + "settings_iracing_state_waiting_iracing": "Wachten op iRacing", + "settings_iracing_state_waiting_recording": "Wachten op opname", + "settings_iracing_state_recording": "Opname", + "settings_iracing_state_saving": "Voltooid bestand opslaan", + "settings_iracing_state_error": "Fout bij vastleggen", + "settings_iracing_reason_unsupported": "Voor automatisch vastleggen is de Windows-backend op dezelfde pc als iRacing vereist.", + "settings_iracing_reason_feature_disabled": "iRacing-opname wordt uitgeschakeld door deze backend.", + "settings_iracing_reason_documents": "Kan de iRacing-telemetriemap niet vinden.", + "settings_iracing_reason_sdk": "Kon niet communiceren met de iRacing-simulator.", + "settings_iracing_reason_archive": "Kan telemetrie niet opslaan in de archiefmap.", + "settings_iracing_unavailable_title": "Vastleggen niet beschikbaar", + "settings_iracing_query_error_title": "Kan de opnamestatus niet laden", + "settings_iracing_query_error": "Statusverzoek mislukt. Controleer de backend-verbinding en probeer het opnieuw door Instellingen opnieuw te openen.", + "settings_iracing_mutation_error_title": "Kan opname niet updaten", + "settings_iracing_mutation_error": "De backend heeft deze opnamewijziging niet bevestigd. Controleer de status voordat u het opnieuw probeert.", + "settings_iracing_source_directory": "iRacing-bronmap", + "settings_iracing_archive_directory": "RaceIQ-archiefmap", + "settings_iracing_saved_count": "Bestanden opgeslagen sinds de backendstart", + "settings_iracing_last_file": "Laatst opgeslagen bestand", + "settings_iracing_not_available": "Niet beschikbaar", + "settings_iracing_none": "Geen", + "settings_iracing_local_files_note": "Draai de Windows-backend onder dezelfde gebruiker en in dezelfde sessie als iRacing. Een externe browser kan geen bestanden verzamelen van een andere gaming-pc.", + "home_clean_driving_score": "Score voor schoon rijden", + "home_clean_driving_score_description": "{clean}/{total} geldige getimede ronden. Pitronden uitgesloten. Score op basis van rondegeldigheid, niet de iRacing Safety Rating.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Live SDK-telemetrie", + "iracing_live_receiving": "Telemetrie ontvangen", + "iracing_live_disconnected": "Backend niet verbonden", + "iracing_live_waiting_title": "Wachten op iRacing-telemetrie", + "iracing_live_waiting_description": "Start iRacing op de Windows-pc waarop RaceIQ draait en ga een rijsessie in. Live telemetrie werkt automatisch; IBT-opname is optioneel.", + "iracing_live_inputs": "Bestuurdersinput", + "iracing_live_motion": "Voertuigbeweging", + "iracing_live_suspension": "Veringuitslag", + "iracing_live_lap_progress": "Rondevoortgang", + "iracing_live_fuel_remaining": "Resterende brandstof", + "iracing_live_lateral_g": "Dwarsversnelling", + "iracing_live_longitudinal_g": "Langsversnelling", + "iracing_live_roll": "Rollen", + "iracing_live_pitch": "Stampen", + "iracing_live_snapshot_note": "Bandkarkastemperatuur en slijtage worden bijgewerkt bij pitstops; de druk is de koude setupdruk. Dit zijn geen live bandmetingen.", + "iracing_live_position_note": "Alleen live SDK-gegevens. GPS-racelijnen vereisen een opgenomen .ibt-bestand.", + "iracing_incident_report_heading": "Incidentenrapport", + "iracing_incident_report_points": "Waargenomen incidentpunten", + "iracing_incident_report_spans": "Waargenomen off-trackperiodes", + "iracing_incident_report_duration": "Bemonsterde off-tracktijd", + "iracing_incident_report_streak": "Langste reeks geregistreerde schone ronden", + "iracing_incident_report_windows": "Opgeslagen rondevensters volledig gelezen {included}/{total}", + "iracing_incident_report_counter": "Persoonlijke teller: {status}", + "iracing_incident_report_surface": "Oppervlaktegegevens: {status}", + "iracing_incident_report_available": "Beschikbaar", + "iracing_incident_report_partial": "Gedeeltelijk", + "iracing_incident_report_unavailable": "Niet beschikbaar", + "iracing_incident_report_scope": "Alleen gegevens van geregistreerde ronden. Niet-geregistreerde intervallen worden niet beoordeeld.", + "iracing_incident_report_explanation": "Basispunten worden niet meegeteld. Puntmarkeringen tonen wanneer een stijging van de persoonlijke teller is waargenomen tussen opeenvolgende metingen. Off-trackperiodes zijn bemonsterd en begrensd tot opgeslagen ronden. Geen van beide bewijst schuld, het type botsing of Safety Rating.", + "iracing_incident_report_loading": "Incidentenrapport laden…", + "iracing_incident_report_error": "Incidentenrapport kon niet worden gelezen.", + "iracing_incident_report_retry": "Opnieuw proberen", + "iracing_incident_report_no_evidence": "Incident- en oppervlaktegegevens zijn niet beschikbaar voor deze opgeslagen rondevensters.", + "iracing_incident_report_empty": "Geen puntstijgingen of off-trackperiodes waargenomen in de gedekte gegevens.", + "iracing_incident_report_point_event": "+{points} incidentpunten waargenomen", + "iracing_incident_report_span_event": "Off-trackperiode", + "iracing_incident_report_sampled_duration": "Bemonsterde duur {duration}", + "iracing_incident_report_clipped_start": "Begin begrensd", + "iracing_incident_report_clipped_end": "Einde begrensd", + "iracing_incident_report_lap_time": "Ronde {lap} · sessie {time}", + "iracing_incident_report_bounds": "Waargenomen tussen simulatorsessietijd {previous} en {current}", + "iracing_incident_report_replay": "Replay · rondetijd {time}", + "iracing_incident_report_replay_link": "Replay openen", + "iracing_incident_report_no_replay": "Replaypositie niet beschikbaar", + "iracing_incident_report_events": "Tijdlijn van geregistreerde gebeurtenissen", + "iracing_incident_report_markers": "Geregistreerde incidentmarkeringen", + "iracing_incident_report_marker_loading": "Markeringen laden…", + "iracing_incident_report_marker_error": "Incidentmarkeringen konden niet worden gelezen.", + "iracing_incident_report_marker_unavailable": "Incidentmarkeringsgegevens niet beschikbaar.", + "iracing_incident_report_marker_partial": "Gedeeltelijke gegevens of replayposities niet beschikbaar.", + "iracing_incident_report_marker_empty": "Geen gebeurtenissen waargenomen in gedekte rondegegevens.", + "iracing_replay_recorded": "Opgenomen telemetrie", + "iracing_replay_source_note": "Alleen kanalen die in deze opname aanwezig zijn, worden weergegeven.", + "iracing_replay_conditions": "Circuit en weer", + "iracing_replay_session": "Opgenomen sessie", + "iracing_replay_session_time": "Simulatorsessietijd", + "iracing_replay_coolant": "Koelvloeistoftemperatuur", + "iracing_replay_oil_temp": "Olietemperatuur", + "iracing_replay_oil_pressure": "Oliedruk", + "iracing_replay_fuel_pressure": "Brandstofdruk", + "iracing_replay_voltage": "Accuspanning", + "iracing_replay_wind": "Windsnelheid", + "iracing_replay_wind_direction": "Windrichting", + "iracing_replay_humidity": "Relatieve luchtvochtigheid", + "iracing_replay_yaw_rate": "Giersnelheid", + "iracing_replay_shock_velocity": "Schokdempersnelheid", + "iracing_replay_brake_line_pressure": "Remleidingdruk", + "iracing_replay_fuel_used": "Brandstof verbruikt tijdens opgenomen ronde", + "iracing_replay_steering_torque": "Stuurkoppel", + "iracing_replay_personal_counter": "Persoonlijke incidententeller", + "iracing_replay_pit_road": "Op pitstraat", + "iracing_replay_pit_snapshots": "Opgenomen pitstopsnapshots", + "iracing_replay_empty": "Geen bruikbare telemetriekanalen op deze replaypositie.", "storage_downsample_action": "Opnamefrequentie verlagen…", "storage_downsample_title": "Opnamefrequentie verlagen", "storage_downsample_description": "Verlaag de telemetriesamplefrequentie van opgeslagen opnamen. Favoriete opnamen en MoTeC-imports zijn beschermd.", diff --git a/client/messages/pl.json b/client/messages/pl.json index bc7577f5c..efa8f3c62 100644 --- a/client/messages/pl.json +++ b/client/messages/pl.json @@ -1031,6 +1031,13 @@ "carwire_dims": "Wymiary", "carwire_tire_info": "Informacje o oponach", "carwire_track": "Tor", + "replay_track_loading": "Wczytywanie geometrii toru…", + "replay_track_error": "Nie udało się wczytać geometrii toru.", + "replay_track_unavailable": "Pozycja na torze niedostępna: powtórka wymaga wyrównanej geometrii toru i prawidłowego dystansu okrążenia lub natywnej pozycji.", + "replay_track_projected": "Pozycja i krawędzie z oficjalnej mapy — nie są zmierzoną linią jazdy ani geodezyjną szerokością toru.", + "replay_track_measured": "Zmierzony tor jazdy IBT; oficjalna mapa to tylko przybliżone odniesienie.", + "replay_track_bounds_unavailable": "Krawędzie niedostępne dla tej konfiguracji toru. Pozycja korzysta z dystansu okrążenia, nie ze zmierzonej linii jazdy.", + "carwire_snapshot_stale": "Starszy odczyt · nie na żywo", "carwire_trails": "Ślady", "carwire_saved": "Zapisano", "sectortimes_s1": "S1", @@ -1715,9 +1722,9 @@ "analyse_importable_laps": "Okrążenia do zaimportowania", "analyse_on_track_rows": "wiersze na torze · maks. {speed} mph", "analyse_import_cannot": "Nie można zaimportować tego nagrania.", - "analyse_ibt_note": "Import tworzy zwykłą sesję iRacing RaceIQ i standardowy zapis .bin. Oryginalny plik .ibt nie jest kopiowany do pamięci sesji.", + "analyse_ibt_note": "Import zachowuje oryginalne nagranie .ibt z jego pierwotną częstotliwością próbkowania. Bez konwersji do BIN.", "session_import_title": "Importuj dane sesji", - "session_choose_file_hint": "Wybierz plik. Format i metadane gry zostaną sprawdzone na podstawie jego zawartości.", + "session_choose_file_hint": "Przeciągnij i upuść tutaj plik lub wybierz go. Format i metadane gry zostaną sprawdzone na podstawie jego zawartości.", "session_choose_file": "Wybierz plik", "session_no_file": "Nie wybrano pliku", "session_reading": "Odczytywanie zawartości pliku…", @@ -1725,7 +1732,7 @@ "session_unsupported": "Zawartość pliku nie jest obsługiwana.", "session_game_detected": "Grę wykryto na podstawie zawartości telemetrii.", "session_captures_found": "Znaleziono zapisy RaceIQ: {count}.", - "session_ibt_hint": "Importy iRacing wymagają podglądu i potwierdzenia w Analizie.", + "session_ibt_hint": "Wybierz Importuj, aby wyświetlić podgląd nagrania iRacing i potwierdzić okrążenia.", "session_motec_hint": "Importy MoTeC wymagają wybrania gry, samochodu i toru w Analizie.", "session_imported": "Zaimportowano {count} okrążeń.{skipped}", "session_skipped": " Pominięto: {count}.", @@ -2704,6 +2711,114 @@ "access_origins_safety": "Zmiany sieci wymagają ponownego uruchomienia RaceIQ. Wyłączenie nie usuwa zapisanych źródeł. Ochrona same-origin nadal działa.", "dash_hostname_origin_settings": "Dodaj to dokładne źródło w Ustawienia → Dostęp i bezpieczeństwo → domeny przeglądarki: {origin}. Zapisanie nazwy hosta nie autoryzuje dostępu.", "dash_hostname_origin_managed": "Dodaj to dokładne źródło do SERVER_ALLOWED_ORIGINS: {origin}. Zapisanie nazwy hosta nie autoryzuje dostępu.", + "settings_iracing_title": "Automatyczne przechwytywanie iRacing", + "settings_iracing_description": "Automatycznie archiwizuj ukończone pliki .ibt na komputerze, na którym działa RaceIQ.", + "settings_iracing_opt_in_title": "Przechwytywanie lokalne jest opcjonalne", + "settings_iracing_opt_in_description": "Połączenie włącza zapisywanie telemetrii iRacing na dysku podczas działania tego backendu. Ukończone pliki .ibt są kopiowane; oryginały pozostają niezmienione. Zapisane pliki nie są automatycznie importowane.", + "settings_iracing_connect": "Połącz się z iRacing", + "settings_iracing_disconnect": "Rozłącz", + "settings_iracing_pending": "Aktualizuję przechwytywanie…", + "settings_iracing_armed": "Automatyczny zapis pozostaje aktywny do rozłączenia lub ponownego uruchomienia backendu.", + "settings_iracing_state_unavailable": "Niedostępne", + "settings_iracing_state_disconnected": "Rozłączono", + "settings_iracing_state_waiting_iracing": "Czekam na iRacing", + "settings_iracing_state_waiting_recording": "Czekam na nagranie", + "settings_iracing_state_recording": "Nagranie", + "settings_iracing_state_saving": "Zapisywanie ukończonego pliku", + "settings_iracing_state_error": "Błąd przechwytywania", + "settings_iracing_reason_unsupported": "Automatyczne przechwytywanie wymaga systemu Windows na tym samym komputerze, co iRacing.", + "settings_iracing_reason_feature_disabled": "Przechwytywanie iRacing jest wyłączone przez ten backend.", + "settings_iracing_reason_documents": "Nie można zlokalizować katalogu telemetrii iRacing.", + "settings_iracing_reason_sdk": "Nie można nawiązać połączenia z symulatorem iRacing.", + "settings_iracing_reason_archive": "Nie można zapisać danych telemetrycznych w katalogu archiwum.", + "settings_iracing_unavailable_title": "Przechwytywanie niedostępne", + "settings_iracing_query_error_title": "Nie udało się wczytać stanu przechwytywania", + "settings_iracing_query_error": "Żądanie stanu nie powiodło się. Sprawdź połączenie z backendem i spróbuj ponownie, otwierając ponownie Ustawienia.", + "settings_iracing_mutation_error_title": "Nie można zaktualizować przechwytywania", + "settings_iracing_mutation_error": "Zaplecze nie potwierdziło tej zmiany przechwytywania. Sprawdź stan przed ponowną próbą.", + "settings_iracing_source_directory": "Katalog źródłowy iRacing", + "settings_iracing_archive_directory": "Katalog archiwum RaceIQ", + "settings_iracing_saved_count": "Pliki zapisane od uruchomienia backendu", + "settings_iracing_last_file": "Ostatni zapisany plik", + "settings_iracing_not_available": "Niedostępne", + "settings_iracing_none": "Nic", + "settings_iracing_local_files_note": "Uruchom backend Windows jako ten sam użytkownik i w tej samej sesji co iRacing. Zdalna przeglądarka nie może zbierać plików z innego komputera do gier.", + "home_clean_driving_score": "Ocena czystej jazdy", + "home_clean_driving_score_description": "{clean}/{total} poprawnych okrążeń z pomiarem czasu. Okrążenia z pit stopem pominięte. Ocena ważności okrążeń, nie iRacing Safety Rating.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Telemetria SDK na żywo", + "iracing_live_receiving": "Odbieranie telemetrii", + "iracing_live_disconnected": "Backend rozłączony", + "iracing_live_waiting_title": "Oczekiwanie na telemetrię iRacing", + "iracing_live_waiting_description": "Uruchom iRacing na komputerze z systemem Windows, na którym działa RaceIQ, i rozpocznij sesję jazdy. Telemetria na żywo działa automatycznie; zapis IBT jest opcjonalny.", + "iracing_live_inputs": "Wejścia kierowcy", + "iracing_live_motion": "Ruch pojazdu", + "iracing_live_suspension": "Skok zawieszenia", + "iracing_live_lap_progress": "Postęp okrążenia", + "iracing_live_fuel_remaining": "Pozostałe paliwo", + "iracing_live_lateral_g": "Przyspieszenie boczne", + "iracing_live_longitudinal_g": "Przyspieszenie wzdłużne", + "iracing_live_roll": "Przechył", + "iracing_live_pitch": "Pochylenie", + "iracing_live_snapshot_note": "Temperatura karkasu i zużycie opon są aktualizowane podczas pit stopów; ciśnienie to ciśnienie z zimnego setupu. Nie są to odczyty opon na żywo.", + "iracing_live_position_note": "Tylko dane SDK na żywo. Linie jazdy GPS wymagają nagranego pliku .ibt.", + "iracing_incident_report_heading": "Raport incydentów", + "iracing_incident_report_points": "Zaobserwowane punkty incydentów", + "iracing_incident_report_spans": "Zaobserwowane okresy poza torem", + "iracing_incident_report_duration": "Próbkowany czas poza torem", + "iracing_incident_report_streak": "Najdłuższa zarejestrowana seria czystych okrążeń", + "iracing_incident_report_windows": "W pełni odczytane zapisane okna okrążeń {included}/{total}", + "iracing_incident_report_counter": "Licznik osobisty: {status}", + "iracing_incident_report_surface": "Dane o nawierzchni: {status}", + "iracing_incident_report_available": "Dostępne", + "iracing_incident_report_partial": "Częściowe", + "iracing_incident_report_unavailable": "Niedostępne", + "iracing_incident_report_scope": "Tylko dane zarejestrowanych okrążeń. Niezarejestrowane przedziały nie są oceniane.", + "iracing_incident_report_explanation": "Punkty bazowe nie są doliczane. Znaczniki punktów pokazują, kiedy zaobserwowano wzrost licznika osobistego między kolejnymi próbkami. Okresy poza torem są próbkowane i ograniczone do zapisanych okrążeń. Żadne z tych danych nie dowodzi winy, rodzaju kolizji ani Safety Rating.", + "iracing_incident_report_loading": "Ładowanie raportu incydentów…", + "iracing_incident_report_error": "Nie udało się odczytać raportu incydentów.", + "iracing_incident_report_retry": "Spróbuj ponownie", + "iracing_incident_report_no_evidence": "Dane o incydentach i nawierzchni są niedostępne dla tych zapisanych okien okrążeń.", + "iracing_incident_report_empty": "W objętych danych nie zaobserwowano wzrostu punktów ani okresów poza torem.", + "iracing_incident_report_point_event": "Zaobserwowano +{points} pkt incydentów", + "iracing_incident_report_span_event": "Okres poza torem", + "iracing_incident_report_sampled_duration": "Próbkowany czas trwania {duration}", + "iracing_incident_report_clipped_start": "Początek ucięty", + "iracing_incident_report_clipped_end": "Koniec ucięty", + "iracing_incident_report_lap_time": "Okrążenie {lap} · sesja {time}", + "iracing_incident_report_bounds": "Zaobserwowano między czasem sesji symulatora {previous} a {current}", + "iracing_incident_report_replay": "Powtórka · czas okrążenia {time}", + "iracing_incident_report_replay_link": "Otwórz powtórkę", + "iracing_incident_report_no_replay": "Pozycja w powtórce niedostępna", + "iracing_incident_report_events": "Oś czasu zarejestrowanych zdarzeń", + "iracing_incident_report_markers": "Zarejestrowane znaczniki incydentów", + "iracing_incident_report_marker_loading": "Ładowanie znaczników…", + "iracing_incident_report_marker_error": "Nie udało się odczytać znaczników incydentów.", + "iracing_incident_report_marker_unavailable": "Dane znaczników incydentów niedostępne.", + "iracing_incident_report_marker_partial": "Część danych lub pozycji powtórki jest niedostępna.", + "iracing_incident_report_marker_empty": "Nie zaobserwowano zdarzeń w objętych danych okrążeń.", + "iracing_replay_recorded": "Zarejestrowana telemetria", + "iracing_replay_source_note": "Wyświetlane są tylko kanały obecne w tym nagraniu.", + "iracing_replay_conditions": "Tor i pogoda", + "iracing_replay_session": "Zarejestrowana sesja", + "iracing_replay_session_time": "Czas sesji w symulatorze", + "iracing_replay_coolant": "Temperatura płynu chłodzącego", + "iracing_replay_oil_temp": "Temperatura oleju", + "iracing_replay_oil_pressure": "Ciśnienie oleju", + "iracing_replay_fuel_pressure": "Ciśnienie paliwa", + "iracing_replay_voltage": "Napięcie akumulatora", + "iracing_replay_wind": "Prędkość wiatru", + "iracing_replay_wind_direction": "Kierunek wiatru", + "iracing_replay_humidity": "Wilgotność względna", + "iracing_replay_yaw_rate": "Prędkość kątowa yaw", + "iracing_replay_shock_velocity": "Prędkość amortyzatora", + "iracing_replay_brake_line_pressure": "Ciśnienie w przewodzie hamulcowym", + "iracing_replay_fuel_used": "Zużycie paliwa na zarejestrowanym okrążeniu", + "iracing_replay_steering_torque": "Moment obrotowy kierownicy", + "iracing_replay_personal_counter": "Osobisty licznik incydentów", + "iracing_replay_pit_road": "W alei serwisowej", + "iracing_replay_pit_snapshots": "Zarejestrowane migawki pit stopów", + "iracing_replay_empty": "Brak użytecznych kanałów telemetrii w tej pozycji powtórki.", "storage_downsample_action": "Zmniejsz częstotliwość nagrań…", "storage_downsample_title": "Zmniejsz częstotliwość nagrań", "storage_downsample_description": "Zmniejsz częstotliwość próbkowania zapisanej telemetrii. Ulubione nagrania i importy MoTeC są chronione.", diff --git a/client/messages/pt-BR.json b/client/messages/pt-BR.json index fcdb9384b..bb2957c1b 100644 --- a/client/messages/pt-BR.json +++ b/client/messages/pt-BR.json @@ -1031,6 +1031,13 @@ "carwire_dims": "Dimensões", "carwire_tire_info": "Informações dos pneus", "carwire_track": "Pista", + "replay_track_loading": "Carregando geometria da pista…", + "replay_track_error": "Não foi possível carregar a geometria da pista.", + "replay_track_unavailable": "Posição na pista indisponível: este replay precisa de geometria alinhada e distância de volta válida ou posição nativa.", + "replay_track_projected": "Posição e bordas vêm do mapa oficial — não são a trajetória medida nem a largura topográfica da pista.", + "replay_track_measured": "Trajetória IBT medida; mapa oficial é apenas uma referência aproximada.", + "replay_track_bounds_unavailable": "Bordas indisponíveis para este traçado. A posição usa a distância da volta, não a trajetória medida.", + "carwire_snapshot_stale": "Registro antigo · não ao vivo", "carwire_trails": "Rastros", "carwire_saved": "Salvo", "sectortimes_s1": "S1", @@ -1715,9 +1722,9 @@ "analyse_importable_laps": "Voltas importáveis", "analyse_on_track_rows": "linhas na pista · máx. {speed} mph", "analyse_import_cannot": "Não é possível importar esta gravação.", - "analyse_ibt_note": "A importação cria uma sessão normal do iRacing no RaceIQ e uma captura .bin padronizada. O arquivo .ibt original não é copiado para o armazenamento da sessão.", + "analyse_ibt_note": "A importação preserva a gravação .ibt original na frequência de amostragem nativa. Sem conversão para BIN.", "session_import_title": "Importar dados da sessão", - "session_choose_file_hint": "Escolha um arquivo. O formato e os metadados do jogo são verificados pelo conteúdo.", + "session_choose_file_hint": "Arraste e solte um arquivo aqui ou escolha um arquivo. O formato e os metadados do jogo são verificados pelo conteúdo.", "session_choose_file": "Escolher arquivo", "session_no_file": "Nenhum arquivo selecionado", "session_reading": "Lendo conteúdo do arquivo…", @@ -1725,7 +1732,7 @@ "session_unsupported": "O conteúdo do arquivo não é compatível.", "session_game_detected": "Jogo detectado pelo conteúdo da telemetria.", "session_captures_found": "{count} captura(s) do RaceIQ encontrada(s).", - "session_ibt_hint": "Importações do iRacing exigem prévia e confirmação na Análise.", + "session_ibt_hint": "Escolha Importar para visualizar a gravação do iRacing e confirmar as voltas.", "session_motec_hint": "Importações do MoTeC exigem configuração do jogo, carro e pista na Análise.", "session_imported": "{count} volta(s) importada(s).{skipped}", "session_skipped": " {count} ignorada(s).", @@ -2704,6 +2711,114 @@ "access_origins_safety": "Alterações de rede exigem reiniciar o RaceIQ. Desativar não apaga as origens salvas. As proteções de mesma origem continuam.", "dash_hostname_origin_settings": "Inclua esta origem exata em Configurações → Acesso e segurança → domínios do navegador: {origin}. Salvar o nome do host não autoriza o acesso.", "dash_hostname_origin_managed": "Inclua esta origem exata em SERVER_ALLOWED_ORIGINS: {origin}. Salvar o nome do host não autoriza o acesso.", + "settings_iracing_title": "Captura automática do iRacing", + "settings_iracing_description": "Arquive automaticamente os arquivos .ibt concluídos no computador que executa o RaceIQ.", + "settings_iracing_opt_in_title": "A captura local é opcional", + "settings_iracing_opt_in_description": "Conectar ativa a gravação de telemetria do iRacing em disco enquanto este backend estiver em execução. Os arquivos .ibt concluídos são copiados; os originais permanecem intactos. Os arquivos salvos não são importados automaticamente.", + "settings_iracing_connect": "Conecte-se ao iRacing", + "settings_iracing_disconnect": "Desconectar", + "settings_iracing_pending": "Atualizando captura…", + "settings_iracing_armed": "A captura automática fica ativa até você desconectar ou reiniciar o backend.", + "settings_iracing_state_unavailable": "Indisponível", + "settings_iracing_state_disconnected": "Desconectado", + "settings_iracing_state_waiting_iracing": "Esperando pelo iRacing", + "settings_iracing_state_waiting_recording": "Aguardando gravação", + "settings_iracing_state_recording": "Gravação", + "settings_iracing_state_saving": "Salvando arquivo concluído", + "settings_iracing_state_error": "Erro de captura", + "settings_iracing_reason_unsupported": "A captura automática requer o backend do Windows no mesmo PC que o iRacing.", + "settings_iracing_reason_feature_disabled": "A captura do iRacing está desabilitada por este backend.", + "settings_iracing_reason_documents": "Não foi possível localizar o diretório de telemetria do iRacing.", + "settings_iracing_reason_sdk": "Não foi possível comunicar com o simulador iRacing.", + "settings_iracing_reason_archive": "Não foi possível salvar a telemetria na pasta de arquivo.", + "settings_iracing_unavailable_title": "Captura indisponível", + "settings_iracing_query_error_title": "Não foi possível carregar o status da captura", + "settings_iracing_query_error": "Falha na solicitação de status. Verifique a conexão de back-end e tente novamente reabrindo as Configurações.", + "settings_iracing_mutation_error_title": "Não foi possível atualizar a captura", + "settings_iracing_mutation_error": "O backend não confirmou esta alteração de captura. Verifique o status antes de tentar novamente.", + "settings_iracing_source_directory": "Diretório de origem do iRacing", + "settings_iracing_archive_directory": "Diretório de arquivo RaceIQ", + "settings_iracing_saved_count": "Arquivos salvos desde o início do backend", + "settings_iracing_last_file": "Último arquivo salvo", + "settings_iracing_not_available": "Não disponível", + "settings_iracing_none": "Nenhum", + "settings_iracing_local_files_note": "Execute o backend Windows com o mesmo usuário e na mesma sessão do iRacing. Um navegador remoto não pode coletar arquivos de outro PC de jogos.", + "home_clean_driving_score": "Pontuação de pilotagem limpa", + "home_clean_driving_score_description": "{clean}/{total} voltas cronometradas válidas. Voltas de box excluídas. Pontuação de validade das voltas, não o Safety Rating do iRacing.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Telemetria SDK ao vivo", + "iracing_live_receiving": "Recebendo telemetria", + "iracing_live_disconnected": "Backend desconectado", + "iracing_live_waiting_title": "Aguardando telemetria do iRacing", + "iracing_live_waiting_description": "Inicie o iRacing no PC Windows que executa o RaceIQ e entre em uma sessão de direção. A telemetria ao vivo é automática; a captura IBT é opcional.", + "iracing_live_inputs": "Comandos do piloto", + "iracing_live_motion": "Movimento do veículo", + "iracing_live_suspension": "Curso da suspensão", + "iracing_live_lap_progress": "Progresso da volta", + "iracing_live_fuel_remaining": "Combustível restante", + "iracing_live_lateral_g": "Aceleração lateral", + "iracing_live_longitudinal_g": "Aceleração longitudinal", + "iracing_live_roll": "Rolagem", + "iracing_live_pitch": "Arfagem", + "iracing_live_snapshot_note": "A temperatura da carcaça e o desgaste dos pneus são atualizados nos pit stops; a pressão é a do setup a frio. Essas não são medições ao vivo dos pneus.", + "iracing_live_position_note": "Somente dados SDK ao vivo. Traçados GPS exigem um arquivo .ibt gravado.", + "iracing_incident_report_heading": "Relatório de incidentes", + "iracing_incident_report_points": "Pontos de incidente observados", + "iracing_incident_report_spans": "Trechos observados fora da pista", + "iracing_incident_report_duration": "Tempo amostrado fora da pista", + "iracing_incident_report_streak": "Maior sequência registrada de voltas limpas", + "iracing_incident_report_windows": "Janelas de voltas salvas lidas por completo {included}/{total}", + "iracing_incident_report_counter": "Contador pessoal: {status}", + "iracing_incident_report_surface": "Evidência de superfície: {status}", + "iracing_incident_report_available": "Disponível", + "iracing_incident_report_partial": "Parcial", + "iracing_incident_report_unavailable": "Indisponível", + "iracing_incident_report_scope": "Somente evidências de voltas registradas. Intervalos não registrados não são avaliados.", + "iracing_incident_report_explanation": "Os pontos de referência não são somados. Os marcadores indicam quando foi observado um aumento no contador pessoal, entre amostras consecutivas. Os trechos fora da pista são amostrados e limitados às voltas salvas. Nenhum deles comprova culpa, tipo de colisão ou Safety Rating.", + "iracing_incident_report_loading": "Carregando relatório de incidentes…", + "iracing_incident_report_error": "Não foi possível ler o relatório de incidentes.", + "iracing_incident_report_retry": "Tentar novamente", + "iracing_incident_report_no_evidence": "Evidências de incidentes e superfície indisponíveis para estas janelas de voltas salvas.", + "iracing_incident_report_empty": "Nenhum aumento de pontos ou trecho fora da pista observado nos dados cobertos.", + "iracing_incident_report_point_event": "+{points} pontos de incidente observados", + "iracing_incident_report_span_event": "Trecho fora da pista", + "iracing_incident_report_sampled_duration": "Duração amostrada {duration}", + "iracing_incident_report_clipped_start": "Início truncado", + "iracing_incident_report_clipped_end": "Fim truncado", + "iracing_incident_report_lap_time": "Volta {lap} · sessão {time}", + "iracing_incident_report_bounds": "Observado entre {previous} e {current} do tempo de sessão do simulador", + "iracing_incident_report_replay": "Replay · tempo de volta {time}", + "iracing_incident_report_replay_link": "Abrir replay", + "iracing_incident_report_no_replay": "Posição no replay indisponível", + "iracing_incident_report_events": "Linha do tempo de eventos registrados", + "iracing_incident_report_markers": "Marcadores de incidentes registrados", + "iracing_incident_report_marker_loading": "Carregando marcadores…", + "iracing_incident_report_marker_error": "Não foi possível ler os marcadores de incidentes.", + "iracing_incident_report_marker_unavailable": "Evidências dos marcadores de incidentes indisponíveis.", + "iracing_incident_report_marker_partial": "Evidência parcial ou posições no replay indisponíveis.", + "iracing_incident_report_marker_empty": "Nenhum evento observado nos dados das voltas cobertas.", + "iracing_replay_recorded": "Telemetria gravada", + "iracing_replay_source_note": "Somente os canais presentes nesta gravação são exibidos.", + "iracing_replay_conditions": "Pista e condições climáticas", + "iracing_replay_session": "Sessão gravada", + "iracing_replay_session_time": "Tempo de sessão do simulador", + "iracing_replay_coolant": "Temperatura do líquido de arrefecimento", + "iracing_replay_oil_temp": "Temperatura do óleo", + "iracing_replay_oil_pressure": "Pressão do óleo", + "iracing_replay_fuel_pressure": "Pressão do combustível", + "iracing_replay_voltage": "Tensão da bateria", + "iracing_replay_wind": "Velocidade do vento", + "iracing_replay_wind_direction": "Direção do vento", + "iracing_replay_humidity": "Umidade relativa", + "iracing_replay_yaw_rate": "Taxa de guinada", + "iracing_replay_shock_velocity": "Velocidade do amortecedor", + "iracing_replay_brake_line_pressure": "Pressão da linha de freio", + "iracing_replay_fuel_used": "Combustível usado na volta gravada", + "iracing_replay_steering_torque": "Torque do volante", + "iracing_replay_personal_counter": "Contador pessoal de incidentes", + "iracing_replay_pit_road": "Na pista dos boxes", + "iracing_replay_pit_snapshots": "Registros gravados nos boxes", + "iracing_replay_empty": "Nenhum canal de telemetria utilizável nesta posição do replay.", "storage_downsample_action": "Reduzir frequência das gravações…", "storage_downsample_title": "Reduzir frequência das gravações", "storage_downsample_description": "Reduza a frequência de amostragem da telemetria armazenada. Gravações favoritas e importações do MoTeC estão protegidas.", diff --git a/client/messages/pt-PT.json b/client/messages/pt-PT.json index 3086b1154..349795c79 100644 --- a/client/messages/pt-PT.json +++ b/client/messages/pt-PT.json @@ -1715,9 +1715,9 @@ "analyse_importable_laps": "Voltas importáveis", "analyse_on_track_rows": "linhas na pista · máx. {speed} mph", "analyse_import_cannot": "Não é possível importar esta gravação.", - "analyse_ibt_note": "A importação cria uma sessão normal do iRacing no RaceIQ e uma captura .bin padronizada. O ficheiro .ibt original não é copiado para o armazenamento da sessão.", + "analyse_ibt_note": "A importação preserva a gravação .ibt original à frequência de amostragem nativa. Sem conversão para BIN.", "session_import_title": "Importar dados da sessão", - "session_choose_file_hint": "Escolha um ficheiro. O formato e os metadados do jogo são verificados pelo conteúdo.", + "session_choose_file_hint": "Arraste e largue um ficheiro aqui ou escolha um ficheiro. O formato e os metadados do jogo são verificados pelo conteúdo.", "session_choose_file": "Escolher ficheiro", "session_no_file": "Nenhum ficheiro selecionado", "session_reading": "Lendo conteúdo do ficheiro…", @@ -1725,7 +1725,7 @@ "session_unsupported": "O conteúdo do ficheiro não é compatível.", "session_game_detected": "Jogo detectado pelo conteúdo da telemetria.", "session_captures_found": "{count} captura(s) do RaceIQ encontrada(s).", - "session_ibt_hint": "Importações do iRacing exigem prévia e confirmação na Análise.", + "session_ibt_hint": "Escolha Importar para pré-visualizar a gravação do iRacing e confirmar as voltas.", "session_motec_hint": "Importações do MoTeC exigem definição do jogo, carro e pista na Análise.", "session_imported": "{count} volta(s) importada(s).{skipped}", "session_skipped": " {count} ignorada(s).", @@ -2702,6 +2702,121 @@ "access_origins_enabled_help": "Desativada por padrão. Ative para restringir o acesso do navegador às interfaces selecionadas e às origens listadas abaixo.", "access_origins_managed": "Ativada por SERVER_ALLOWED_ORIGINS. Altere essa variável de ambiente e reinicie o RaceIQ.", "access_origins_safety": "Alterações de rede exigem reiniciar o RaceIQ. Desativar não apaga as origens salvas. As proteções de mesma origem continuam.", + "replay_track_loading": "A carregar a geometria da pista…", + "replay_track_error": "Não foi possível carregar a geometria da pista.", + "replay_track_unavailable": "Posicionamento na pista indisponível: esta repetição precisa de geometria da pista alinhada e de distância de volta válida ou posição nativa.", + "replay_track_projected": "A posição e os limites usam o mapa oficial — não representam uma trajetória medida nem a largura da pista medida.", + "replay_track_measured": "Trajetória medida a partir do IBT; o mapa oficial é apenas uma referência aproximada.", + "replay_track_bounds_unavailable": "Limites da pista indisponíveis para este traçado. A posição usa a distância de volta, não a trajetória medida.", + "carwire_snapshot_stale": "Instantâneo desatualizado · não é em direto", + "settings_iracing_title": "Captura automática do iRacing", + "settings_iracing_description": "Arquive automaticamente ficheiros .ibt concluídos no computador que executa o RaceIQ.", + "settings_iracing_opt_in_title": "A captura local é opcional", + "settings_iracing_opt_in_description": "Ao ligar, ativa a gravação da telemetria do iRacing em disco enquanto este backend estiver em execução. Os ficheiros .ibt concluídos são copiados; os originais não são alterados. Os ficheiros guardados não são importados automaticamente.", + "settings_iracing_connect": "Ligar ao iRacing", + "settings_iracing_disconnect": "Desligar", + "settings_iracing_pending": "A atualizar a captura…", + "settings_iracing_armed": "A captura automática fica ativa até desligar ou reiniciar o backend.", + "settings_iracing_state_unavailable": "Indisponível", + "settings_iracing_state_disconnected": "Desligado", + "settings_iracing_state_waiting_iracing": "À espera do iRacing", + "settings_iracing_state_waiting_recording": "À espera da gravação", + "settings_iracing_state_recording": "A gravar", + "settings_iracing_state_saving": "A guardar ficheiro concluído", + "settings_iracing_state_error": "Erro de captura", + "settings_iracing_reason_unsupported": "A captura automática requer o backend do Windows no mesmo PC que o iRacing.", + "settings_iracing_reason_feature_disabled": "A captura do iRacing está desativada neste backend.", + "settings_iracing_reason_documents": "Não foi possível localizar a pasta de telemetria do iRacing.", + "settings_iracing_reason_sdk": "Não foi possível comunicar com o simulador iRacing.", + "settings_iracing_reason_archive": "Não foi possível guardar a telemetria na pasta de arquivo.", + "settings_iracing_unavailable_title": "Captura indisponível", + "settings_iracing_query_error_title": "Não foi possível carregar o estado da captura", + "settings_iracing_query_error": "O pedido de estado falhou. Verifique a ligação ao backend e tente novamente reabrindo as Definições.", + "settings_iracing_mutation_error_title": "Não foi possível atualizar a captura", + "settings_iracing_mutation_error": "O backend não confirmou esta alteração da captura. Verifique o estado antes de tentar novamente.", + "settings_iracing_source_directory": "Pasta de origem do iRacing", + "settings_iracing_archive_directory": "Pasta de arquivo do RaceIQ", + "settings_iracing_saved_count": "Ficheiros guardados nesta execução", + "settings_iracing_last_file": "Último ficheiro guardado", + "settings_iracing_not_available": "Indisponível", + "settings_iracing_none": "Nenhum", + "settings_iracing_local_files_note": "Execute o backend do Windows com o mesmo utilizador e na mesma sessão do iRacing. Um navegador remoto não consegue recolher ficheiros de outro PC de jogos.", + "home_clean_driving_score": "Pontuação de condução limpa", + "home_clean_driving_score_description": "{clean}/{total} voltas cronometradas válidas. Voltas de boxes excluídas. Pontuação de validade das voltas, não o Safety Rating do iRacing.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Telemetria SDK em direto", + "iracing_live_receiving": "A receber telemetria", + "iracing_live_disconnected": "Backend desligado", + "iracing_live_waiting_title": "À espera da telemetria do iRacing", + "iracing_live_waiting_description": "Inicie o iRacing no PC Windows que executa o RaceIQ e entre numa sessão de condução. A telemetria em direto é automática; a gravação IBT é opcional.", + "iracing_live_inputs": "Comandos do piloto", + "iracing_live_motion": "Movimento do veículo", + "iracing_live_suspension": "Curso da suspensão", + "iracing_live_lap_progress": "Progresso da volta", + "iracing_live_fuel_remaining": "Combustível restante", + "iracing_live_lateral_g": "Aceleração lateral", + "iracing_live_longitudinal_g": "Aceleração longitudinal", + "iracing_live_roll": "Rolamento", + "iracing_live_pitch": "Arfagem", + "iracing_live_snapshot_note": "A temperatura da carcaça e o desgaste dos pneus são atualizados nas paragens nas boxes; a pressão é a do setup a frio. Não são medições dos pneus em direto.", + "iracing_live_position_note": "Apenas dados SDK em direto. Traçados GPS requerem um ficheiro .ibt gravado.", + "iracing_incident_report_heading": "Relatório de incidentes", + "iracing_incident_report_points": "Pontos de incidentes observados", + "iracing_incident_report_spans": "Períodos observados fora da pista", + "iracing_incident_report_duration": "Tempo observado fora da pista", + "iracing_incident_report_streak": "Maior sequência registada de voltas limpas", + "iracing_incident_report_windows": "Janelas de voltas guardadas lidas na íntegra {included}/{total}", + "iracing_incident_report_counter": "Contador pessoal: {status}", + "iracing_incident_report_surface": "Evidência de superfície: {status}", + "iracing_incident_report_available": "Disponível", + "iracing_incident_report_partial": "Parcial", + "iracing_incident_report_unavailable": "Indisponível", + "iracing_incident_report_scope": "Apenas evidência de voltas registadas. Os intervalos não registados não são avaliados.", + "iracing_incident_report_explanation": "Os pontos de referência não são adicionados. Os marcadores de pontos indicam quando foi observado um aumento no contador pessoal, entre amostras consecutivas. Os períodos fora da pista são amostrados e limitados às voltas guardadas. Nenhum destes dados comprova responsabilidade, tipo de colisão ou Safety Rating.", + "iracing_incident_report_loading": "A carregar relatório de incidentes…", + "iracing_incident_report_error": "Não foi possível ler o relatório de incidentes.", + "iracing_incident_report_retry": "Tentar novamente", + "iracing_incident_report_no_evidence": "Evidência de incidentes e superfície indisponível para estas janelas de voltas guardadas.", + "iracing_incident_report_empty": "Não foram observados aumentos de pontos nem períodos fora da pista nos dados abrangidos.", + "iracing_incident_report_point_event": "+{points} pontos de incidentes observados", + "iracing_incident_report_span_event": "Período fora da pista", + "iracing_incident_report_sampled_duration": "Duração amostrada {duration}", + "iracing_incident_report_clipped_start": "Início truncado", + "iracing_incident_report_clipped_end": "Fim truncado", + "iracing_incident_report_lap_time": "Volta {lap} · sessão {time}", + "iracing_incident_report_bounds": "Observado entre {previous} e {current} de tempo de sessão do simulador", + "iracing_incident_report_replay": "Repetição · tempo de volta {time}", + "iracing_incident_report_replay_link": "Abrir repetição", + "iracing_incident_report_no_replay": "Posição na repetição indisponível", + "iracing_incident_report_events": "Cronologia de eventos registados", + "iracing_incident_report_markers": "Marcadores de incidentes registados", + "iracing_incident_report_marker_loading": "A carregar marcadores…", + "iracing_incident_report_marker_error": "Não foi possível ler os marcadores de incidentes.", + "iracing_incident_report_marker_unavailable": "Evidência de marcadores de incidentes indisponível.", + "iracing_incident_report_marker_partial": "Evidência parcial ou posições na repetição indisponíveis.", + "iracing_incident_report_marker_empty": "Não foram observados eventos nos dados das voltas abrangidas.", + "iracing_replay_recorded": "Telemetria gravada", + "iracing_replay_source_note": "São apresentados apenas os canais presentes nesta gravação.", + "iracing_replay_conditions": "Pista e condições meteorológicas", + "iracing_replay_session": "Sessão gravada", + "iracing_replay_session_time": "Tempo de sessão do simulador", + "iracing_replay_coolant": "Temperatura do líquido de refrigeração", + "iracing_replay_oil_temp": "Temperatura do óleo", + "iracing_replay_oil_pressure": "Pressão do óleo", + "iracing_replay_fuel_pressure": "Pressão do combustível", + "iracing_replay_voltage": "Tensão da bateria", + "iracing_replay_wind": "Velocidade do vento", + "iracing_replay_wind_direction": "Direção do vento", + "iracing_replay_humidity": "Humidade relativa", + "iracing_replay_yaw_rate": "Taxa de guinada", + "iracing_replay_shock_velocity": "Velocidade do amortecedor", + "iracing_replay_brake_line_pressure": "Pressão do circuito de travagem", + "iracing_replay_fuel_used": "Combustível consumido na volta gravada", + "iracing_replay_steering_torque": "Binário do volante", + "iracing_replay_personal_counter": "Contador pessoal de incidentes", + "iracing_replay_pit_road": "Na via das boxes", + "iracing_replay_pit_snapshots": "Registos captados nas boxes", + "iracing_replay_empty": "Sem canais de telemetria utilizáveis nesta posição da repetição.", "dash_hostname_origin_settings": "Inclua esta origem exata em Definições → Acesso e segurança → domínios do navegador: {origin}. Guardar o nome do host não autoriza o acesso.", "dash_hostname_origin_managed": "Inclua esta origem exata em SERVER_ALLOWED_ORIGINS: {origin}. Guardar o nome do host não autoriza o acesso.", "storage_downsample_action": "Reduzir frequência das gravações…", diff --git a/client/messages/ru.json b/client/messages/ru.json index 0ac6b0318..bee79ec07 100644 --- a/client/messages/ru.json +++ b/client/messages/ru.json @@ -1031,6 +1031,13 @@ "carwire_dims": "Размеры", "carwire_tire_info": "Данные шин", "carwire_track": "Трасса", + "replay_track_loading": "Загрузка геометрии трассы…", + "replay_track_error": "Не удалось загрузить геометрию трассы.", + "replay_track_unavailable": "Положение на трассе недоступно: повтору нужны выровненная геометрия трассы и корректная дистанция круга либо исходная позиция.", + "replay_track_projected": "Позиция и края взяты из официальной карты — это не измеренная траектория или геодезическая ширина трассы.", + "replay_track_measured": "Измеренная траектория IBT; официальная карта — лишь приблизительный ориентир.", + "replay_track_bounds_unavailable": "Края трассы недоступны для этой конфигурации. Позиция рассчитана по дистанции круга, а не по измеренной траектории.", + "carwire_snapshot_stale": "Предыдущий снимок · не текущие измерения", "carwire_trails": "Следы", "carwire_saved": "Сохранено", "sectortimes_s1": "С1", @@ -1714,9 +1721,9 @@ "analyse_importable_laps": "Круги для импорта", "analyse_on_track_rows": "строк на трассе · макс. скорость {speed} mph", "analyse_import_cannot": "Эту запись нельзя импортировать.", - "analyse_ibt_note": "При импорте создаётся обычная сессия RaceIQ для iRacing и стандартный файл телеметрии .bin. Исходный файл .ibt не копируется в хранилище сессий.", + "analyse_ibt_note": "Импорт сохраняет исходную запись .ibt с исходной частотой дискретизации. Без преобразования в BIN.", "session_import_title": "Импорт данных сессии", - "session_choose_file_hint": "Выберите файл. Формат и метаданные игры определяются по его содержимому.", + "session_choose_file_hint": "Перетащите файл сюда или выберите его. Формат и метаданные игры определяются по его содержимому.", "session_choose_file": "Выбрать файл", "session_no_file": "Файл не выбран", "session_reading": "Чтение содержимого файла…", @@ -1724,7 +1731,7 @@ "session_unsupported": "Содержимое файла не поддерживается.", "session_game_detected": "Игра определена по данным телеметрии.", "session_captures_found": "Найдено файлов записи RaceIQ: {count}.", - "session_ibt_hint": "Для импорта iRacing требуется предварительный просмотр и подтверждение в Analyse.", + "session_ibt_hint": "Нажмите «Импортировать», чтобы просмотреть запись iRacing и подтвердить круги.", "session_motec_hint": "Для импорта MoTeC нужно указать игру, автомобиль и трассу в Analyse.", "session_imported": "Импортировано кругов: {count}.{skipped}", "session_skipped": " Пропущено: {count}.", @@ -2704,6 +2711,114 @@ "access_origins_safety": "Изменения сети требуют перезапуска RaceIQ. Отключение не удаляет сохранённые источники. Защита same-origin продолжает действовать.", "dash_hostname_origin_settings": "Добавьте этот точный источник в Настройки → Доступ и безопасность → домены браузера: {origin}. Сохранение имени хоста не разрешает доступ.", "dash_hostname_origin_managed": "Добавьте этот точный источник в SERVER_ALLOWED_ORIGINS: {origin}. Сохранение имени хоста не разрешает доступ.", + "settings_iracing_title": "Автоматический захват iRacing", + "settings_iracing_description": "Автоматически сохраняйте завершённые файлы .ibt на компьютере, где запущен RaceIQ.", + "settings_iracing_opt_in_title": "Локальный захват доступен по вашему желанию", + "settings_iracing_opt_in_description": "Подключение включает запись телеметрии iRacing на диск во время работы этой серверной части. Завершённые файлы .ibt копируются без изменения оригиналов. Сохранённые файлы не импортируются автоматически.", + "settings_iracing_connect": "Подключитесь к iRacing", + "settings_iracing_disconnect": "Отключить", + "settings_iracing_pending": "Обновление захвата…", + "settings_iracing_armed": "Автоматическая запись включена до отключения или перезапуска серверной части.", + "settings_iracing_state_unavailable": "Недоступно", + "settings_iracing_state_disconnected": "Отключено", + "settings_iracing_state_waiting_iracing": "В ожидании iRacing", + "settings_iracing_state_waiting_recording": "Ожидание записи", + "settings_iracing_state_recording": "Запись", + "settings_iracing_state_saving": "Сохранение готового файла", + "settings_iracing_state_error": "Ошибка захвата", + "settings_iracing_reason_unsupported": "Для автоматического захвата требуется серверная часть Windows на том же компьютере, что и iRacing.", + "settings_iracing_reason_feature_disabled": "Захват iRacing отключен этим сервером.", + "settings_iracing_reason_documents": "Не удалось найти каталог телеметрии iRacing.", + "settings_iracing_reason_sdk": "Не удалось связаться с симулятором iRacing.", + "settings_iracing_reason_archive": "Не удалось сохранить телеметрию в каталог архива.", + "settings_iracing_unavailable_title": "Захват недоступен", + "settings_iracing_query_error_title": "Не удалось загрузить статус захвата.", + "settings_iracing_query_error": "Запрос статуса не выполнен. Проверьте серверное соединение и повторите попытку, повторно открыв Настройки.", + "settings_iracing_mutation_error_title": "Не удалось обновить захват.", + "settings_iracing_mutation_error": "Серверная часть не подтвердила это изменение захвата. Проверьте статус, прежде чем повторить попытку.", + "settings_iracing_source_directory": "исходный каталог iRacing", + "settings_iracing_archive_directory": "Каталог архива RaceIQ", + "settings_iracing_saved_count": "Файлы, сохранённые с запуска серверной части", + "settings_iracing_last_file": "Последний сохраненный файл", + "settings_iracing_not_available": "Недоступно", + "settings_iracing_none": "Нет", + "settings_iracing_local_files_note": "Запускайте серверную часть Windows от того же пользователя и в той же сессии, что и iRacing. Удалённый браузер не может собирать файлы с другого игрового ПК.", + "home_clean_driving_score": "Оценка чистоты вождения", + "home_clean_driving_score_description": "{clean}/{total} действительных кругов с замером времени. Круги с пит-стопом исключены. Оценка валидности кругов, а не iRacing Safety Rating.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Телеметрия SDK в реальном времени", + "iracing_live_receiving": "Получение телеметрии", + "iracing_live_disconnected": "Серверная часть отключена", + "iracing_live_waiting_title": "Ожидание телеметрии iRacing", + "iracing_live_waiting_description": "Запустите iRacing на ПК с Windows, где работает RaceIQ, и начните заезд. Телеметрия в реальном времени поступает автоматически; запись IBT необязательна.", + "iracing_live_inputs": "Действия водителя", + "iracing_live_motion": "Движение автомобиля", + "iracing_live_suspension": "Ход подвески", + "iracing_live_lap_progress": "Прогресс круга", + "iracing_live_fuel_remaining": "Остаток топлива", + "iracing_live_lateral_g": "Боковое ускорение", + "iracing_live_longitudinal_g": "Продольное ускорение", + "iracing_live_roll": "Крен", + "iracing_live_pitch": "Тангаж", + "iracing_live_snapshot_note": "Температура каркаса и износ шин обновляются при остановках в боксах; давление указано для холодных шин по настройкам. Это не текущие измерения шин.", + "iracing_live_position_note": "Только данные SDK в реальном времени. Для GPS-траекторий требуется записанный файл .ibt.", + "iracing_incident_report_heading": "Отчёт об инцидентах", + "iracing_incident_report_points": "Зафиксированные очки инцидентов", + "iracing_incident_report_spans": "Зафиксированные участки вне трассы", + "iracing_incident_report_duration": "Выборочное время вне трассы", + "iracing_incident_report_streak": "Самая длинная зафиксированная серия чистых кругов", + "iracing_incident_report_windows": "Полностью прочитанные сохранённые окна кругов {included}/{total}", + "iracing_incident_report_counter": "Личный счётчик: {status}", + "iracing_incident_report_surface": "Данные о покрытии: {status}", + "iracing_incident_report_available": "Доступно", + "iracing_incident_report_partial": "Частично", + "iracing_incident_report_unavailable": "Недоступно", + "iracing_incident_report_scope": "Только данные записанных кругов. Незарегистрированные интервалы не оцениваются.", + "iracing_incident_report_explanation": "Исходные очки не прибавляются. Маркеры показывают момент наблюдаемого увеличения личного счётчика между последовательными отсчётами. Участки вне трассы определены по выборочным данным и ограничены сохранёнными кругами. Эти данные не доказывают вину, тип столкновения или Safety Rating.", + "iracing_incident_report_loading": "Загрузка отчёта об инцидентах…", + "iracing_incident_report_error": "Не удалось прочитать отчёт об инцидентах.", + "iracing_incident_report_retry": "Повторить", + "iracing_incident_report_no_evidence": "Данные об инцидентах и покрытии недоступны для этих сохранённых окон кругов.", + "iracing_incident_report_empty": "В охваченных данных не наблюдалось увеличений очков или участков вне трассы.", + "iracing_incident_report_point_event": "Наблюдалось +{points} очк. инцидентов", + "iracing_incident_report_span_event": "Участок вне трассы", + "iracing_incident_report_sampled_duration": "Выборочная длительность {duration}", + "iracing_incident_report_clipped_start": "Начало обрезано", + "iracing_incident_report_clipped_end": "Конец обрезан", + "iracing_incident_report_lap_time": "Круг {lap} · сессия {time}", + "iracing_incident_report_bounds": "Наблюдалось между отметками времени симулятора {previous} и {current}", + "iracing_incident_report_replay": "Повтор · время круга {time}", + "iracing_incident_report_replay_link": "Открыть повтор", + "iracing_incident_report_no_replay": "Позиция повтора недоступна", + "iracing_incident_report_events": "Хронология записанных событий", + "iracing_incident_report_markers": "Маркеры записанных инцидентов", + "iracing_incident_report_marker_loading": "Загрузка маркеров…", + "iracing_incident_report_marker_error": "Не удалось прочитать маркеры инцидентов.", + "iracing_incident_report_marker_unavailable": "Данные маркеров инцидентов недоступны.", + "iracing_incident_report_marker_partial": "Данные неполны или позиции повтора недоступны.", + "iracing_incident_report_marker_empty": "В охваченных данных кругов события не наблюдались.", + "iracing_replay_recorded": "Записанная телеметрия", + "iracing_replay_source_note": "Показаны только каналы, присутствующие в этой записи.", + "iracing_replay_conditions": "Трасса и погодные условия", + "iracing_replay_session": "Записанная сессия", + "iracing_replay_session_time": "Время сессии симулятора", + "iracing_replay_coolant": "Температура охлаждающей жидкости", + "iracing_replay_oil_temp": "Температура масла", + "iracing_replay_oil_pressure": "Давление масла", + "iracing_replay_fuel_pressure": "Давление топлива", + "iracing_replay_voltage": "Напряжение аккумулятора", + "iracing_replay_wind": "Скорость ветра", + "iracing_replay_wind_direction": "Направление ветра", + "iracing_replay_humidity": "Относительная влажность", + "iracing_replay_yaw_rate": "Скорость рыскания", + "iracing_replay_shock_velocity": "Скорость перемещения амортизатора", + "iracing_replay_brake_line_pressure": "Давление в тормозной магистрали", + "iracing_replay_fuel_used": "Расход топлива за записанный круг", + "iracing_replay_steering_torque": "Крутящий момент на руле", + "iracing_replay_personal_counter": "Личный счётчик инцидентов", + "iracing_replay_pit_road": "На пит-лейне", + "iracing_replay_pit_snapshots": "Записанные данные на пит-стопах", + "iracing_replay_empty": "В этой позиции повтора нет доступных каналов телеметрии.", "storage_downsample_action": "Снизить частоту записей…", "storage_downsample_title": "Снизить частоту записей", "storage_downsample_description": "Снизить частоту телеметрии в сохранённых записях. Избранные записи и импорты MoTeC защищены.", diff --git a/client/messages/uk.json b/client/messages/uk.json index 08d116753..61d14469b 100644 --- a/client/messages/uk.json +++ b/client/messages/uk.json @@ -1033,6 +1033,13 @@ "carwire_dims": "Розміри", "carwire_tire_info": "Інформація про шини", "carwire_track": "Траса", + "replay_track_loading": "Завантаження геометрії траси…", + "replay_track_error": "Не вдалося завантажити геометрію траси.", + "replay_track_unavailable": "Положення на трасі недоступне: повтор потребує вирівняної геометрії траси та коректної дистанції кола або вихідної позиції.", + "replay_track_projected": "Позиція та краї взяті з офіційної мапи — це не виміряна траєкторія чи геодезична ширина траси.", + "replay_track_measured": "Виміряна траєкторія IBT; офіційна мапа — лише приблизний орієнтир.", + "replay_track_bounds_unavailable": "Краї траси недоступні для цієї конфігурації. Позицію обчислено за дистанцією кола, а не за виміряною траєкторією.", + "carwire_snapshot_stale": "Попередній знімок · не поточні вимірювання", "carwire_trails": "Сліди", "carwire_saved": "Збережено", "sectortimes_s1": "С1", @@ -1717,9 +1724,9 @@ "analyse_importable_laps": "Кола для імпорту", "analyse_on_track_rows": "рядків на трасі · макс. {speed} миль/год", "analyse_import_cannot": "Цей запис неможливо імпортувати.", - "analyse_ibt_note": "Імпорт створює звичайну сесію iRacing у RaceIQ і канонічний запис .bin. Оригінальний файл .ibt не копіюється до сховища сесій.", + "analyse_ibt_note": "Імпорт зберігає оригінальний запис .ibt з його початковою частотою дискретизації. Без перетворення на BIN.", "session_import_title": "Імпорт даних сесії", - "session_choose_file_hint": "Виберіть файл. Формат і метадані гри перевіряються за його вмістом.", + "session_choose_file_hint": "Перетягніть файл сюди або виберіть його. Формат і метадані гри перевіряються за його вмістом.", "session_choose_file": "Вибрати файл", "session_no_file": "Файл не вибрано", "session_reading": "Читання вмісту файлу…", @@ -1727,7 +1734,7 @@ "session_unsupported": "Вміст файлу не підтримується.", "session_game_detected": "Гру визначено за вмістом телеметрії.", "session_captures_found": "Знайдено записів RaceIQ: {count}.", - "session_ibt_hint": "Для імпорту iRacing потрібні попередній перегляд і підтвердження в розділі «Аналіз».", + "session_ibt_hint": "Виберіть «Імпортувати», щоб переглянути запис iRacing і підтвердити кола.", "session_motec_hint": "Для імпорту MoTeC потрібно вибрати гру, авто й трасу в розділі «Аналіз».", "session_imported": "Імпортовано кіл: {count}.{skipped}", "session_skipped": " Пропущено: {count}.", @@ -2704,6 +2711,114 @@ "access_origins_safety": "Зміни мережі потребують перезапуску RaceIQ. Вимкнення не видаляє збережені джерела. Захист same-origin продовжує діяти.", "dash_hostname_origin_settings": "Додайте це точне джерело в Налаштування → Доступ і безпека → домени браузера: {origin}. Збереження імені хоста не надає доступ.", "dash_hostname_origin_managed": "Додайте це точне джерело до SERVER_ALLOWED_ORIGINS: {origin}. Збереження імені хоста не надає доступ.", + "settings_iracing_title": "Автоматичне захоплення iRacing", + "settings_iracing_description": "Автоматично зберігайте завершені файли .ibt на комп’ютері, де працює RaceIQ.", + "settings_iracing_opt_in_title": "Локальне захоплення доступне", + "settings_iracing_opt_in_description": "Підключення вмикає запис телеметрії iRacing на диск під час роботи цього бекенду. Завершені файли .ibt копіюються без зміни оригіналів. Збережені файли не імпортуються автоматично.", + "settings_iracing_connect": "Підключіться до iRacing", + "settings_iracing_disconnect": "Відключити", + "settings_iracing_pending": "Оновлення запису…", + "settings_iracing_armed": "Автоматичний запис увімкнено до відключення або перезапуску бекенду.", + "settings_iracing_state_unavailable": "Недоступний", + "settings_iracing_state_disconnected": "Відключено", + "settings_iracing_state_waiting_iracing": "В очікуванні iRacing", + "settings_iracing_state_waiting_recording": "Чекаю на запис", + "settings_iracing_state_recording": "Запис", + "settings_iracing_state_saving": "Збереження готового файлу", + "settings_iracing_state_error": "Помилка захоплення", + "settings_iracing_reason_unsupported": "Для автоматичного захоплення потрібен бекенд Windows на тому ж ПК, що й iRacing.", + "settings_iracing_reason_feature_disabled": "Захоплення iRacing вимкнено цим сервером.", + "settings_iracing_reason_documents": "Не вдалося знайти каталог телеметрії iRacing.", + "settings_iracing_reason_sdk": "Не вдалося встановити зв’язок із симулятором iRacing.", + "settings_iracing_reason_archive": "Не вдалося зберегти телеметрію в архівний каталог.", + "settings_iracing_unavailable_title": "Запис недоступний", + "settings_iracing_query_error_title": "Не вдалося завантажити статус запису", + "settings_iracing_query_error": "Помилка запиту статусу. Перевірте серверне з’єднання та повторіть спробу, повторно відкривши налаштування.", + "settings_iracing_mutation_error_title": "Не вдалося оновити запис", + "settings_iracing_mutation_error": "Сервер не підтвердив цю зміну захоплення. Перевірте статус перед повторною спробою.", + "settings_iracing_source_directory": "Папка телеметрії iRacing", + "settings_iracing_archive_directory": "Архівний каталог RaceIQ", + "settings_iracing_saved_count": "Файли, збережені з моменту запуску бекенду", + "settings_iracing_last_file": "Останній збережений файл", + "settings_iracing_not_available": "Не доступний", + "settings_iracing_none": "Жодного", + "settings_iracing_local_files_note": "Запускайте бекенд Windows від того самого користувача й у тій самій сесії, що й iRacing. Віддалений браузер не може збирати файли з іншого ігрового ПК.", + "home_clean_driving_score": "Оцінка чистоти водіння", + "home_clean_driving_score_description": "{clean}/{total} дійсних кіл із вимірюванням часу. Кола з піт-стопом виключено. Оцінка дійсності кіл, а не iRacing Safety Rating.", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "Телеметрія SDK у реальному часі", + "iracing_live_receiving": "Отримання телеметрії", + "iracing_live_disconnected": "Бекенд від’єднано", + "iracing_live_waiting_title": "Очікування телеметрії iRacing", + "iracing_live_waiting_description": "Запустіть iRacing на ПК з Windows, де працює RaceIQ, і розпочніть заїзд. Телеметрія в реальному часі надходить автоматично; запис IBT необов’язковий.", + "iracing_live_inputs": "Дії водія", + "iracing_live_motion": "Рух автомобіля", + "iracing_live_suspension": "Хід підвіски", + "iracing_live_lap_progress": "Прогрес кола", + "iracing_live_fuel_remaining": "Залишок пального", + "iracing_live_lateral_g": "Бічне прискорення", + "iracing_live_longitudinal_g": "Поздовжнє прискорення", + "iracing_live_roll": "Крен", + "iracing_live_pitch": "Тангаж", + "iracing_live_snapshot_note": "Температура каркаса шин і знос оновлюються під час зупинок у боксах; тиск — це тиск холодних шин із налаштувань. Це не поточні вимірювання шин.", + "iracing_live_position_note": "Лише дані SDK у реальному часі. Для GPS-траєкторій потрібен записаний файл .ibt.", + "iracing_incident_report_heading": "Звіт про інциденти", + "iracing_incident_report_points": "Зафіксовані бали інцидентів", + "iracing_incident_report_spans": "Зафіксовані відрізки виїзду за межі траси", + "iracing_incident_report_duration": "Виміряний час поза трасою", + "iracing_incident_report_streak": "Найдовша серія чистих записаних кіл", + "iracing_incident_report_windows": "Повністю прочитані вікна збережених кіл {included}/{total}", + "iracing_incident_report_counter": "Особистий лічильник: {status}", + "iracing_incident_report_surface": "Дані про поверхню: {status}", + "iracing_incident_report_available": "Доступно", + "iracing_incident_report_partial": "Частково", + "iracing_incident_report_unavailable": "Недоступно", + "iracing_incident_report_scope": "Лише дані із записаних кіл. Незбережені проміжки не оцінюються.", + "iracing_incident_report_explanation": "Початкові бали не додаються. Маркери показують, коли між послідовними зразками зафіксовано збільшення особистого лічильника. Відрізки виїзду за межі траси вимірюються за зразками й обрізаються до збережених кіл. Жодне з цього не доводить провину, тип зіткнення чи Safety Rating.", + "iracing_incident_report_loading": "Завантаження звіту про інциденти…", + "iracing_incident_report_error": "Не вдалося прочитати звіт про інциденти.", + "iracing_incident_report_retry": "Повторити", + "iracing_incident_report_no_evidence": "Для цих збережених кіл дані про інциденти й поверхню недоступні.", + "iracing_incident_report_empty": "У охоплених даних не зафіксовано збільшень балів чи відрізків поза трасою.", + "iracing_incident_report_point_event": "Зафіксовано +{points} балів інциденту", + "iracing_incident_report_span_event": "Відрізок поза трасою", + "iracing_incident_report_sampled_duration": "Виміряна тривалість {duration}", + "iracing_incident_report_clipped_start": "Початок обрізано", + "iracing_incident_report_clipped_end": "Кінець обрізано", + "iracing_incident_report_lap_time": "Коло {lap} · час сесії {time}", + "iracing_incident_report_bounds": "Зафіксовано між {previous} і {current} за симуляторним часом сесії", + "iracing_incident_report_replay": "Повтор · час кола {time}", + "iracing_incident_report_replay_link": "Відкрити повтор", + "iracing_incident_report_no_replay": "Позиція повтору недоступна", + "iracing_incident_report_events": "Хронологія записаних подій", + "iracing_incident_report_markers": "Маркери записаних інцидентів", + "iracing_incident_report_marker_loading": "Завантаження маркерів…", + "iracing_incident_report_marker_error": "Не вдалося прочитати маркери інцидентів.", + "iracing_incident_report_marker_unavailable": "Дані маркерів інцидентів недоступні.", + "iracing_incident_report_marker_partial": "Дані неповні або позиції повтору недоступні.", + "iracing_incident_report_marker_empty": "У охоплених даних кіл подій не зафіксовано.", + "iracing_replay_recorded": "Записана телеметрія", + "iracing_replay_source_note": "Показано лише канали, наявні в цьому записі.", + "iracing_replay_conditions": "Траса та погода", + "iracing_replay_session": "Записана сесія", + "iracing_replay_session_time": "Час сесії симулятора", + "iracing_replay_coolant": "Температура охолоджувальної рідини", + "iracing_replay_oil_temp": "Температура мастила", + "iracing_replay_oil_pressure": "Тиск мастила", + "iracing_replay_fuel_pressure": "Тиск палива", + "iracing_replay_voltage": "Напруга акумулятора", + "iracing_replay_wind": "Швидкість вітру", + "iracing_replay_wind_direction": "Напрямок вітру", + "iracing_replay_humidity": "Відносна вологість", + "iracing_replay_yaw_rate": "Швидкість рискання", + "iracing_replay_shock_velocity": "Швидкість амортизатора", + "iracing_replay_brake_line_pressure": "Тиск у гальмівній магістралі", + "iracing_replay_fuel_used": "Паливо, використане на записаному колі", + "iracing_replay_steering_torque": "Крутний момент керма", + "iracing_replay_personal_counter": "Особистий лічильник інцидентів", + "iracing_replay_pit_road": "На піт-лейні", + "iracing_replay_pit_snapshots": "Записані знімки піт-стопів", + "iracing_replay_empty": "У цій позиції повтору немає придатних каналів телеметрії.", "storage_downsample_action": "Зменшити частоту записів…", "storage_downsample_title": "Зменшити частоту записів", "storage_downsample_description": "Зменште частоту телеметрії у збережених записах. Улюблені записи та імпорти MoTeC захищено.", diff --git a/client/messages/zh-CN.json b/client/messages/zh-CN.json index 1ccd630a8..43e75d667 100644 --- a/client/messages/zh-CN.json +++ b/client/messages/zh-CN.json @@ -1715,9 +1715,9 @@ "analyse_importable_laps": "可导入圈数", "analyse_on_track_rows": "赛道数据行 · 最高 {speed} mph", "analyse_import_cannot": "无法导入此录制文件。", - "analyse_ibt_note": "导入后会创建常规的 RaceIQ iRacing 赛事和标准 .bin 采集文件。原始 .ibt 文件不会复制到赛事存储中。", + "analyse_ibt_note": "导入会保留原始 .ibt 录制文件及其原始采样率,不进行 BIN 转换。", "session_import_title": "导入赛事数据", - "session_choose_file_hint": "选择文件。系统会根据文件内容检查格式和游戏元数据。", + "session_choose_file_hint": "将文件拖放到此处,或选择文件。系统会根据文件内容检查格式和游戏元数据。", "session_choose_file": "选择文件", "session_no_file": "未选择文件", "session_reading": "正在读取文件内容…", @@ -1725,7 +1725,7 @@ "session_unsupported": "不支持此文件内容。", "session_game_detected": "已根据遥测内容检测到游戏。", "session_captures_found": "找到 {count} 个 RaceIQ 采集文件。", - "session_ibt_hint": "导入 iRacing 文件需要在 Analyse 中预览并确认。", + "session_ibt_hint": "选择“导入”以预览 iRacing 录制文件并确认圈数。", "session_motec_hint": "导入 MoTeC 文件需要在 Analyse 中设置游戏、赛车和赛道。", "session_imported": "已导入 {count} 圈。{skipped}", "session_skipped": " 已跳过 {count} 圈。", @@ -2702,6 +2702,121 @@ "access_origins_safety": "保存网络访问设置需要重启 RaceIQ。关闭此选项不会删除已保存的来源。同源保护仍会继续生效。", "dash_hostname_origin_settings": "请在“设置 → 访问与安全 → 浏览器域名”中添加此确切来源:{origin}。保存此主机名并不代表已授权访问。", "dash_hostname_origin_managed": "请在 SERVER_ALLOWED_ORIGINS 中添加此确切来源:{origin}。保存此主机名并不代表已授权访问。", + "replay_track_loading": "正在加载赛道几何数据…", + "replay_track_error": "无法加载赛道几何数据。", + "replay_track_unavailable": "无法定位赛道位置:此回放需要对齐的赛道几何数据,以及有效的单圈距离或原生位置数据。", + "replay_track_projected": "位置和赛道边缘使用官方地图——并非实测赛车轨迹或实测赛道宽度。", + "replay_track_measured": "基于 IBT 的实测轨迹;官方地图仅作近似参考。", + "replay_track_bounds_unavailable": "此赛道布局没有可用的赛道边缘数据。位置依据单圈距离,而非实测赛车轨迹。", + "carwire_snapshot_stale": "快照已过期 · 非实时", + "settings_iracing_title": "iRacing 自动捕获", + "settings_iracing_description": "自动归档运行 RaceIQ 的计算机上已完成的 .ibt 文件。", + "settings_iracing_opt_in_title": "本地捕获需主动启用", + "settings_iracing_opt_in_description": "连接后,此后端运行期间将启用 iRacing 磁盘遥测。已完成的 .ibt 文件会被复制;原文件保持不变。保存的文件不会自动导入。", + "settings_iracing_connect": "连接 iRacing", + "settings_iracing_disconnect": "断开连接", + "settings_iracing_pending": "正在更新捕获状态…", + "settings_iracing_armed": "自动捕获将保持启用,直到断开连接或重启后端。", + "settings_iracing_state_unavailable": "不可用", + "settings_iracing_state_disconnected": "已断开", + "settings_iracing_state_waiting_iracing": "正在等待 iRacing", + "settings_iracing_state_waiting_recording": "正在等待录制", + "settings_iracing_state_recording": "正在录制", + "settings_iracing_state_saving": "正在保存已完成的文件", + "settings_iracing_state_error": "捕获错误", + "settings_iracing_reason_unsupported": "自动捕获需要在运行 iRacing 的同一台电脑上使用 Windows 后端。", + "settings_iracing_reason_feature_disabled": "此后端已禁用 iRacing 捕获。", + "settings_iracing_reason_documents": "无法找到 iRacing 遥测目录。", + "settings_iracing_reason_sdk": "无法与 iRacing 模拟器通信。", + "settings_iracing_reason_archive": "无法将遥测数据保存到归档目录。", + "settings_iracing_unavailable_title": "捕获不可用", + "settings_iracing_query_error_title": "无法加载捕获状态", + "settings_iracing_query_error": "状态请求失败。请检查后端连接,然后重新打开“设置”重试。", + "settings_iracing_mutation_error_title": "无法更新捕获状态", + "settings_iracing_mutation_error": "后端未确认此次捕获设置更改。请先检查状态,再重试。", + "settings_iracing_source_directory": "iRacing 源目录", + "settings_iracing_archive_directory": "RaceIQ 归档目录", + "settings_iracing_saved_count": "本次运行已保存文件数", + "settings_iracing_last_file": "上次保存的文件", + "settings_iracing_not_available": "不可用", + "settings_iracing_none": "无", + "settings_iracing_local_files_note": "请在与 iRacing 相同的用户和会话下运行 Windows 后端。远程浏览器无法从另一台游戏电脑收集文件。", + "home_clean_driving_score": "干净驾驶评分", + "home_clean_driving_score_description": "{clean}/{total} 个有效计时圈。排除进出站圈。基于圈速有效性的评分,并非 iRacing Safety Rating。", + "iracing_live_title": "iRacing Live", + "iracing_live_subtitle": "实时 SDK 遥测", + "iracing_live_receiving": "正在接收遥测", + "iracing_live_disconnected": "后端已断开", + "iracing_live_waiting_title": "等待 iRacing 遥测", + "iracing_live_waiting_description": "在运行 RaceIQ 的 Windows 电脑上启动 iRacing 并进入驾驶会话。实时遥测自动提供;IBT 捕获为可选项。", + "iracing_live_inputs": "驾驶员输入", + "iracing_live_motion": "车辆运动", + "iracing_live_suspension": "悬架行程", + "iracing_live_lap_progress": "圈速进度", + "iracing_live_fuel_remaining": "剩余燃油", + "iracing_live_lateral_g": "横向 G", + "iracing_live_longitudinal_g": "纵向 G", + "iracing_live_roll": "横滚", + "iracing_live_pitch": "俯仰", + "iracing_live_snapshot_note": "轮胎胎体温度和磨损会在进站时更新;压力为冷态设置压力。这些不是实时轮胎测量值。", + "iracing_live_position_note": "仅显示实时 SDK 数据。GPS 行驶路线需要已录制的 .ibt 文件。", + "iracing_incident_report_heading": "事故报告", + "iracing_incident_report_points": "观测到的事故点数", + "iracing_incident_report_spans": "观测到的驶离赛道区间", + "iracing_incident_report_duration": "采样到的驶离赛道时间", + "iracing_incident_report_streak": "最长连续已记录干净圈数", + "iracing_incident_report_windows": "完整读取的已保存圈窗口 {included}/{total}", + "iracing_incident_report_counter": "个人计数器:{status}", + "iracing_incident_report_surface": "赛道表面证据:{status}", + "iracing_incident_report_available": "可用", + "iracing_incident_report_partial": "部分可用", + "iracing_incident_report_unavailable": "不可用", + "iracing_incident_report_scope": "仅包含已记录圈证据。未记录的时间段不会评估。", + "iracing_incident_report_explanation": "基线点数不会计入。标记显示连续样本间观测到个人计数器增加的时刻。驶离赛道区间基于采样并按已保存圈截断。两者均不能证明责任、碰撞类型或安全评级。", + "iracing_incident_report_loading": "正在加载事故报告…", + "iracing_incident_report_error": "无法读取事故报告。", + "iracing_incident_report_retry": "重试", + "iracing_incident_report_no_evidence": "这些已保存圈窗口没有可用的事故或赛道表面证据。", + "iracing_incident_report_empty": "覆盖数据中未观测到点数增加或驶离赛道区间。", + "iracing_incident_report_point_event": "观测到 +{points} 事故点", + "iracing_incident_report_span_event": "驶离赛道区间", + "iracing_incident_report_sampled_duration": "采样时长 {duration}", + "iracing_incident_report_clipped_start": "起点已截断", + "iracing_incident_report_clipped_end": "终点已截断", + "iracing_incident_report_lap_time": "第 {lap} 圈 · 赛事时间 {time}", + "iracing_incident_report_bounds": "观测范围:模拟器赛事时间 {previous} 至 {current}", + "iracing_incident_report_replay": "回放 · 圈内时间 {time}", + "iracing_incident_report_replay_link": "打开回放", + "iracing_incident_report_no_replay": "回放位置不可用", + "iracing_incident_report_events": "已记录事件时间线", + "iracing_incident_report_markers": "已记录事故标记", + "iracing_incident_report_marker_loading": "正在加载标记…", + "iracing_incident_report_marker_error": "无法读取事故标记。", + "iracing_incident_report_marker_unavailable": "事故标记证据不可用。", + "iracing_incident_report_marker_partial": "证据不完整或回放位置不可用。", + "iracing_incident_report_marker_empty": "覆盖圈数据中未观测到事件。", + "iracing_replay_recorded": "已记录的遥测数据", + "iracing_replay_source_note": "仅显示此录制中包含的通道。", + "iracing_replay_conditions": "赛道和天气", + "iracing_replay_session": "已记录的比赛", + "iracing_replay_session_time": "模拟器比赛时间", + "iracing_replay_coolant": "冷却液温度", + "iracing_replay_oil_temp": "机油温度", + "iracing_replay_oil_pressure": "机油压力", + "iracing_replay_fuel_pressure": "燃油压力", + "iracing_replay_voltage": "电池电压", + "iracing_replay_wind": "风速", + "iracing_replay_wind_direction": "风向", + "iracing_replay_humidity": "相对湿度", + "iracing_replay_yaw_rate": "横摆角速度", + "iracing_replay_shock_velocity": "减振器速度", + "iracing_replay_brake_line_pressure": "制动管路压力", + "iracing_replay_fuel_used": "记录圈中消耗的燃油", + "iracing_replay_steering_torque": "方向盘扭矩", + "iracing_replay_personal_counter": "个人事故计数器", + "iracing_replay_pit_road": "位于维修通道", + "iracing_replay_pit_snapshots": "已记录的进站快照", + "iracing_replay_empty": "此回放位置没有可用的遥测通道。", "recording_frequency_label": "记录频率", "recording_frequency_description": "较低频率可减少录制文件大小和处理量。加载共享内存游戏时,UDP 为安全设置。录制无法超过游戏遥测频率。", "storage_downsample_action": "降低录制采样频率…", diff --git a/client/src/components/BodyAttitude.tsx b/client/src/components/BodyAttitude.tsx index 776f25b21..6af8b4596 100644 --- a/client/src/components/BodyAttitude.tsx +++ b/client/src/components/BodyAttitude.tsx @@ -3,8 +3,9 @@ import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; const toDeg = 180 / Math.PI; const numeric = (frame: SemanticAnalysisFrame, id: keyof SemanticAnalysisFrame["values"]): number | null => { - const value = frame.values[id]; - return typeof value === "number" && Number.isFinite(value) ? value : null; + const value = frame.values[id], state = frame.states[id], freshness = frame.freshness[id]; + return typeof value === "number" && Number.isFinite(value) && (state === undefined || state === "ok") && + (freshness === undefined || freshness === "fresh") ? value : null; }; /** @@ -14,9 +15,10 @@ const numeric = (frame: SemanticAnalysisFrame, id: keyof SemanticAnalysisFrame[" * 3. Compass: arrow rotates with yaw heading */ export function BodyAttitude({ frame }: { frame: SemanticAnalysisFrame }) { - const roll = (numeric(frame, "motion.roll") ?? 0) * toDeg; - const pitch = (numeric(frame, "motion.pitch") ?? 0) * toDeg; - const yaw = (numeric(frame, "motion.yaw") ?? 0) * toDeg; + const rollValue = numeric(frame, "motion.roll"), pitchValue = numeric(frame, "motion.pitch"), yawValue = numeric(frame, "motion.yaw"); + const roll = (rollValue ?? 0) * toDeg; + const pitch = (pitchValue ?? 0) * toDeg; + const yaw = (yawValue ?? 0) * toDeg; const clampedVehicleRoll = Math.max(-25, Math.min(25, roll)); // Fixed vehicle reference, moving horizon: scenery rotates opposite vehicle roll. const horizonRoll = -clampedVehicleRoll; @@ -87,10 +89,10 @@ export function BodyAttitude({ frame }: { frame: SemanticAnalysisFrame }) { {/* Yaw heading at top */} - {yaw.toFixed(0)}° + {yawValue == null ? "—" : `${yaw.toFixed(0)}°`} - R{roll.toFixed(0)}° P{pitch.toFixed(0)}° + R{rollValue == null ? "—" : `${roll.toFixed(0)}°`} P{pitchValue == null ? "—" : `${pitch.toFixed(0)}°`} diff --git a/client/src/components/CarWireframe.tsx b/client/src/components/CarWireframe.tsx index ad4566a90..793c628b9 100644 --- a/client/src/components/CarWireframe.tsx +++ b/client/src/components/CarWireframe.tsx @@ -24,13 +24,17 @@ import { Switch } from "./ui/switch"; import { DropdownMenu } from "./ui/DropdownMenu"; import { SceneRuntime, type SceneSource } from "@raceiq/scene-3d/wireframe/SceneRuntime"; import { ToggleButton } from "./wireframe/ToggleButton"; +import { alignProjectionToMeasured, resolveScenePoses, type TrackProjection } from "@raceiq/scene-3d/wireframe/ScenePlacement"; +import { Alert, AlertDescription } from "./ui/alert"; export const CarWireframe = React.memo(function CarWireframe({ gameId: gameIdProp, frame, telemetry, cursorIdx, - outline, + trackProjection, + trackGeometryLoading = false, + trackGeometryError = false, boundaries, carId, lmuCarClass, @@ -50,7 +54,9 @@ export const CarWireframe = React.memo(function CarWireframe({ frame: SemanticAnalysisFrame; telemetry: SemanticAnalysisFrame[]; cursorIdx: number; - outline: { x: number; z: number }[] | null; + trackProjection?: TrackProjection | null; + trackGeometryLoading?: boolean; + trackGeometryError?: boolean; boundaries?: Pick | null; carId?: string; carModel?: CarModelEnrichment & { hasModel: boolean }; @@ -115,8 +121,28 @@ export const CarWireframe = React.memo(function CarWireframe({ if (!boundaries) return null; return needsTrackFlip(gameId) ? flipBoundaries(boundaries) : boundaries; }, [boundaries, gameId]); + const poses = useMemo(() => resolveScenePoses(gameId, telemetry, trackProjection), [gameId, telemetry, trackProjection]); + const measuredPath = useMemo(() => poses.some((pose) => pose?.source === "measured") + ? poses.map((pose) => pose?.source === "measured" ? { x: pose.x, z: pose.z, ...(pose.y === undefined ? {} : { y: pose.y }) } : { x: Number.NaN, z: Number.NaN }) + : null, [poses]); + const derivedTrack = gameId === "iracing" && poses.some((pose) => pose?.source === "lap-fraction"); + const measuredProjection = useMemo(() => alignProjectionToMeasured(trackProjection, poses), [trackProjection, poses]); + const measuredTrack = gameId === "iracing" && poses.some((pose) => pose?.source === "measured"); + const sceneBoundaries = measuredTrack ? measuredProjection?.boundaries ?? null : derivedTrack ? trackProjection?.boundaries ?? null : flippedBoundaries; + const [placementSource, setPlacementSource] = useState<"native" | "lap-fraction" | "measured" | null>(null); + useEffect(() => { + let animationFrame: number; + const sync = () => { + const next = poses[source.cursorRef.current]?.source ?? null; + setPlacementSource((previous) => previous === next ? previous : next); + animationFrame = requestAnimationFrame(sync); + }; + sync(); + return () => cancelAnimationFrame(animationFrame); + }, [poses, source.cursorRef]); const positionAvailable = (feature: "track" | "racingLine" | "trails" | "inputs") => - supportsPositionDependentSceneFeature(gameId, feature); + supportsPositionDependentSceneFeature(gameId, feature) + || (placementSource !== null && (feature === "track" || feature === "inputs" || (feature === "racingLine" && placementSource === "measured") || placementSource === "native")); const effectiveToggles = { ...toggles, track: toggles.track && positionAvailable("track"), @@ -128,7 +154,7 @@ export const CarWireframe = React.memo(function CarWireframe({ { key: "trails" as const, label: m.carwire_trails(), available: positionAvailable("trails") }, { key: "inputs" as const, label: m.carwire_inputs(), available: positionAvailable("inputs") }, { key: "track" as const, label: m.carwire_track(), available: positionAvailable("track") }, - { key: "racingLine" as const, label: m.overlay_racing_line(), available: positionAvailable("racingLine") && Array.isArray(flippedBoundaries?.raceLine) && flippedBoundaries.raceLine.length > 1 }, + { key: "racingLine" as const, label: m.overlay_racing_line(), available: positionAvailable("racingLine") && ((measuredPath?.length ?? 0) > 1 || (Array.isArray(flippedBoundaries?.raceLine) && flippedBoundaries.raceLine.length > 1)) }, { key: "grid" as const, label: m.carwire_grid(), available: true }, { key: "wheelInfo" as const, label: m.carwire_tire_info(), available: true }, ] @@ -166,7 +192,8 @@ export const CarWireframe = React.memo(function CarWireframe({ }, [source]); useEffect(() => { runtimeRef.current?.updateConfig({ - gameId, frame, telemetry, cursorIdx, outline, boundaries: flippedBoundaries, + gameId, frame, telemetry, cursorIdx, measuredPath, poses, boundaries: sceneBoundaries, + boundarySpace: measuredTrack ? "measured" : derivedTrack ? "lap-fraction" : "native", toggles: effectiveToggles, viewPreset, carModel, modelOffsetX, hideModelWheels: !minimal, autoOrbit: !!autoOrbit, fpsCap: displaySettings.renderFpsCap, fmtTemp, suspThresholds, pressureOptimal, temperatureThresholds: units.thresholds, temperatureSemanticId, @@ -174,8 +201,13 @@ export const CarWireframe = React.memo(function CarWireframe({ inner: m.label_inner(), middle: m.label_middle(), outer: m.label_outer(), core: m.label_core(), carcass: m.label_carcass(), surface: m.label_surface(), }, + wheelLabelMessages: { + pitTemperature: m.analyse_wheels_pit_temp(), carcass: m.label_carcass(), pitHealth: m.analyse_wheels_pit_health(), + coldPressure: m.analyse_wheels_cold_pressure(), pressure: m.analyse_wheels_pressure(), brake: m.analyse_wheels_brake(), + wearRate: m.analyse_wheels_wear_s(), snapshotStale: m.carwire_snapshot_stale(), + }, }); - }, [gameId, frame, telemetry, cursorIdx, outline, flippedBoundaries, toggles, viewPreset, carModel, modelOffsetX, minimal, autoOrbit, displaySettings.renderFpsCap, fmtTemp, suspThresholds, pressureOptimal, units.thresholds, temperatureSemanticId]); + }, [gameId, frame, telemetry, cursorIdx, measuredPath, poses, derivedTrack, measuredTrack, sceneBoundaries, effectiveToggles, viewPreset, carModel, modelOffsetX, minimal, autoOrbit, displaySettings.renderFpsCap, fmtTemp, suspThresholds, pressureOptimal, units.thresholds, temperatureSemanticId]); return (
@@ -184,6 +216,13 @@ export const CarWireframe = React.memo(function CarWireframe({ return `${wheel} ${value != null && Number.isFinite(value) ? fmtTemp(value) : m.analyse_unavailable()}`; }).join(", ")}`} /> {renderError &&
{renderError}
} + {gameId === "iracing" && toggles.track && (trackGeometryLoading || trackGeometryError || !placementSource || !sceneBoundaries || placementSource === "lap-fraction" || placementSource === "measured") && ( + + + {trackGeometryLoading ? m.replay_track_loading() : trackGeometryError ? m.replay_track_error() : placementSource === "measured" ? sceneBoundaries ? m.replay_track_measured() : m.replay_track_unavailable() : placementSource === "lap-fraction" ? sceneBoundaries ? m.replay_track_projected() : m.replay_track_bounds_unavailable() : m.replay_track_unavailable()} + + + )} {/* View toggles */} diff --git a/client/src/components/LiveDashboardPage.tsx b/client/src/components/LiveDashboardPage.tsx index 2f8e1fa3a..705b81ad9 100644 --- a/client/src/components/LiveDashboardPage.tsx +++ b/client/src/components/LiveDashboardPage.tsx @@ -8,6 +8,7 @@ import { ForzaLiveDashboard } from "./ForzaLiveDashboard"; import { F1LiveDashboard } from "./f1/F1LiveDashboard"; import { AccLiveDashboard } from "./acc/AccLiveDashboard"; import { LMULiveDashboard } from "./lmu/LMULiveDashboard"; +import { IRacingLiveDashboard } from "./iracing/IRacingLiveDashboard"; import type { DashboardMode } from "./LiveTelemetry"; export function LiveDashboardPage({ mode }: { mode: DashboardMode }) { @@ -22,11 +23,12 @@ export function LiveDashboardPage({ mode }: { mode: DashboardMode }) { setGameId(game?.id ?? null); return () => setGameId(null); }, [game?.id, setGameId]); - if (!game || !view || view.simulator !== game.id) return ; + if (!game || (game.id !== "iracing" && (!view || view.simulator !== game.id))) return ; switch (liveDashboardForGame(game.id)) { case "forza": return ; case "f1": return ; case "acc": return ; + case "iracing": return ; case "lmu": return ; } } diff --git a/client/src/components/SessionRecap.tsx b/client/src/components/SessionRecap.tsx index 2f35cfd18..8377d0a8c 100644 --- a/client/src/components/SessionRecap.tsx +++ b/client/src/components/SessionRecap.tsx @@ -24,7 +24,8 @@ export type TrackOutlineData = source?: string; pitLines?: PitLine[]; } - | { x: number; z: number }[]; + | { x: number; z: number }[] + | null; export type TrackSectorBounds = { s1End: number; s2End: number } | null; type SectorStatus = "record" | "session-best" | "lost"; diff --git a/client/src/components/analyse/AnalyseChartsPanel.tsx b/client/src/components/analyse/AnalyseChartsPanel.tsx new file mode 100644 index 000000000..b9022aa5e --- /dev/null +++ b/client/src/components/analyse/AnalyseChartsPanel.tsx @@ -0,0 +1,219 @@ +import { getGame } from "@raceiq/shared/games/registry" +import { resolveAnalysisTelemetry } from "@raceiq/shared/racing/analysis/telemetry-capabilities" +import type { GameId } from "@raceiq/shared/games/ids"; +import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } from "react"; +import { WHEEL_COLOR_VARS } from "@raceiq/scene-3d/colors"; +import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; +import { m } from "../../paraglide/messages"; +import { TelemetryChart } from "./AnalyseTelemetryChart"; +import { useUnits } from "../../hooks/useUnits"; +import { buildChartData } from "./chart-data"; + + +export interface ChartsPanelHandle { + timeFracs: number[] | null; + times: number[] | null; + updateCursor: (idx: number) => void; +} + +interface ChartsPanelProps { + gameId: GameId; + semanticFrames: SemanticAnalysisFrame[]; + totalPackets: number; + visualTimeFrac: number | null; + onVisualFracChange: (frac: number | null) => void; + onClickIndex: (idx: number) => void; + onScrubStart: () => void; + speedLabel: string; +} + + +export const AnalyseChartsPanel = memo( + forwardRef(function AnalyseChartsPanel( + { semanticFrames, totalPackets, visualTimeFrac, onVisualFracChange, onClickIndex, onScrubStart, speedLabel, gameId }, + ref, + ) { + const units = useUnits(gameId); + const temperatureMetric = resolveAnalysisTelemetry(getGame(gameId)).tireTemperature; + const isIRacing = gameId === "iracing"; + const tireTemperatureSemanticId = temperatureMetric.source !== "unavailable" && temperatureMetric.binding?.kind === "value" + ? temperatureMetric.binding.semanticId + : "tire.temperature.surface.representative"; + const chartData = useMemo( + () => buildChartData(semanticFrames, tireTemperatureSemanticId, units.temp), + [semanticFrames, tireTemperatureSemanticId, units.temp], + ); + const scrollRef = useRef(null); + const cursorLineRef = useRef(null); + const chartDataRef = useRef(chartData); + chartDataRef.current = chartData; + + // Move shared cursor with a DOM style update; canvas resize/redraw per frame + // made chart scrubbing wait behind a full overlay repaint. + const drawSharedCursor = useCallback( + (idx: number) => { + const line = cursorLineRef.current; + const scroll = scrollRef.current; + if (!line || !scroll) return; + + const w = scroll.clientWidth; + const totalPackets = semanticFrames.length; + const timeFracs = chartDataRef.current?.timeFracs; + if (w <= 0 || totalPackets < 2) { + line.style.display = "none"; + return; + } + + const lowerIndex = Math.max(0, Math.min(semanticFrames.length - 1, Math.floor(idx))); + const upperIndex = Math.min(semanticFrames.length - 1, lowerIndex + 1); + const fraction = Math.max(0, Math.min(1, idx - lowerIndex)); + const firstFrac = timeFracs?.[lowerIndex]; + const nextFrac = timeFracs?.[upperIndex]; + const xFrac = firstFrac != null && nextFrac != null + ? firstFrac + (nextFrac - firstFrac) * fraction + : idx / (totalPackets - 1); + const leftPad = 12 + 40; + const rightPad = 12 + 8; + const chartW = w - leftPad - rightPad; + const cx = Math.max(leftPad + 2, leftPad + xFrac * chartW); + line.style.display = ""; + line.style.height = `${scroll.scrollHeight}px`; + line.style.left = `${Math.round(cx)}px`; + }, + [semanticFrames.length], + ); + + useImperativeHandle( + ref, + () => ({ + get timeFracs() { + return chartDataRef.current?.timeFracs ?? null; + }, + get times() { + return chartDataRef.current?.times ?? null; + }, + updateCursor: drawSharedCursor, + }), + [drawSharedCursor], + ); + + if (!chartData) return null; + const usableSeries = (semanticId: string, series: number[]) => isIRacing + ? series.map((value, index) => { + const frame = semanticFrames[index]; + const state = frame?.states[semanticId]; + const freshness = frame?.freshness[semanticId]; + return (state === undefined || state === "ok") && (freshness === undefined || freshness === "fresh") ? value : NaN; + }) + : series; + const speed = usableSeries("motion.speed", chartData.speed); + const throttle = usableSeries("inputs.accel", chartData.throttle); + const brake = usableSeries("inputs.brake", chartData.brake); + const clutch = usableSeries("inputs.clutch", chartData.clutch); + const rpm = usableSeries("engine.current-engine-rpm", chartData.rpm); + const steering = usableSeries("inputs.steer", chartData.steering); + const hasData = (series: number[]) => series.some(Number.isFinite); + const common = { + totalPackets, + timeFracs: chartData.timeFracs, + visualTimeFrac, + onVisualFracChange, + onClickIndex, + onScrubStart, + }; + + return ( +
+ + ); + }), +); diff --git a/client/src/components/analyse/AnalyseDataPanel.tsx b/client/src/components/analyse/AnalyseDataPanel.tsx index 069a9632f..ed4453626 100644 --- a/client/src/components/analyse/AnalyseDataPanel.tsx +++ b/client/src/components/analyse/AnalyseDataPanel.tsx @@ -120,9 +120,8 @@ export function AnalyseDataContent({ currentFrame, packetNumber, startFuel, game )}
- {currentFrame && } - {currentFrame && ( - <> + {currentFrame && <> +

{m.analyse_section_dynamics()}

@@ -130,8 +129,7 @@ export function AnalyseDataContent({ currentFrame, packetNumber, startFuel, game {getGame(gameId).telemetry.ers && } - - )} + }
); diff --git a/client/src/components/analyse/AnalyseTelemetryChart.tsx b/client/src/components/analyse/AnalyseTelemetryChart.tsx new file mode 100644 index 000000000..7fba92d3d --- /dev/null +++ b/client/src/components/analyse/AnalyseTelemetryChart.tsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { syncCanvasSize } from "../../lib/rendering/canvas-size"; +import { getSemanticCanvasContext } from "@raceiq/scene-3d/rendering/css-canvas"; + +const TELEMETRY_GAP_SECONDS = 0.1; +const TELEMETRY_GAP_TOLERANCE_SECONDS = 0.001; + +export function hasTelemetryGap(previousTime: number, currentTime: number, threshold = TELEMETRY_GAP_SECONDS): boolean { + return currentTime - previousTime > threshold + TELEMETRY_GAP_TOLERANCE_SECONDS; +} + +/** Sparse recordings have a lower regular cadence, not a gap at every sample. */ +export function telemetryGapThreshold(times: readonly number[]): number { + const intervals: number[] = []; + for (let index = 1; index < times.length; index++) { + const delta = times[index] - times[index - 1]; + if (Number.isFinite(delta) && delta > 0) intervals.push(delta); + } + if (intervals.length < 3) return TELEMETRY_GAP_SECONDS; + intervals.sort((a, b) => a - b); + return Math.max(TELEMETRY_GAP_SECONDS, intervals[Math.floor(intervals.length / 2)] * 1.5); +} + +export interface ChartSeries { + data: number[]; + color: string; + label: string; +} +function axisDecimals(range: number): number { + if (range >= 10) return 0; + if (range >= 1) return 1; + if (range >= 0.1) return 2; + return 3; +} + +function axisValue(value: number, range: number): string { + return value.toFixed(axisDecimals(range)); +} + +function chartDomain(series: ChartSeries[]): [number, number] { + let min = Infinity; + let max = -Infinity; + for (const item of series) { + for (const value of item.data) { + if (!Number.isFinite(value)) continue; + min = Math.min(min, value); + max = Math.max(max, value); + } + } + if (min === Infinity) return [-1, 1]; + const observedRange = max - min; + // Scale padding to observed values so small telemetry changes remain visible. + const pad = observedRange > 0 ? observedRange * 0.08 : Math.max(Math.abs(min) * 0.05, 1); + let domainMin = min - pad; + let domainMax = max + pad; + // Keep zero visible when data crosses it; don't force zero into one-sided charts. + if (min <= 0 && max >= 0) { + domainMin = Math.min(domainMin, 0); + domainMax = Math.max(domainMax, 0); + } + return [domainMin, domainMax]; +} + +export function TelemetryChart({ + series, + totalPackets, + onClickIndex, + onScrubStart, + height = 100, + timeFracs, + times, + visualTimeFrac: _visualTimeFrac, + onVisualFracChange, +}: { + series: ChartSeries[]; + totalPackets: number; + onClickIndex: (idx: number) => void; + onScrubStart?: () => void; + height?: number; + timeFracs?: number[]; + times?: number[]; + visualTimeFrac?: number | null; + onVisualFracChange?: (frac: number | null) => void; +}) { + const [containerWidth, setContainerWidth] = useState(0); + const canvasRef = useRef(null); + const containerRef = useRef(null); + const scrubCleanupRef = useRef<(() => void) | null>(null); + const lastScrubIndexRef = useRef(null); + const visualFracRafRef = useRef(null); + const pendingVisualFracRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const observer = new ResizeObserver(() => setContainerWidth(container.clientWidth)); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + const ctx = getSemanticCanvasContext(canvas); + if (!ctx) return; + + const w = container.clientWidth; + const h = height; + syncCanvasSize(canvas, w, h, window.devicePixelRatio || 1, false); + const scaleX = canvas.width / w; + const scaleY = canvas.height / h; + ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0); + ctx.clearRect(0, 0, w, h); + + const leftPad = 40; + const rightPad = 8; + const topPad = 16; + const botPad = 4; + const chartW = w - leftPad - rightPad; + const chartH = h - topPad - botPad; + + if (totalPackets < 2) return; + + const [gMin, gMax] = chartDomain(series); + const range = gMax - gMin; + + // Y axis ticks (3) + ctx.font = "var(--text-app-micro) var(--font-mono)"; + ctx.fillStyle = "var(--app-border)"; + ctx.textAlign = "right"; + for (let i = 0; i <= 2; i++) { + const val = gMin + (range * i) / 2; + const y = topPad + chartH - (i / 2) * chartH; + ctx.fillText(axisValue(val, range), leftPad - 4, y + 3); + ctx.strokeStyle = "color-mix(in srgb, var(--app-text-dim) 8%, transparent)"; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(leftPad, y); + ctx.lineTo(w - rightPad, y); + ctx.stroke(); + } + + const gapThreshold = times ? telemetryGapThreshold(times) : TELEMETRY_GAP_SECONDS; + // Draw gap highlights first (behind data lines) + if (times && timeFracs) { + ctx.fillStyle = "color-mix(in srgb, var(--status-danger) 8%, transparent)"; + for (let i = 1; i < times.length; i++) { + if (hasTelemetryGap(times[i - 1], times[i], gapThreshold)) { + const x1 = leftPad + timeFracs[i - 1] * chartW; + const x2 = leftPad + timeFracs[i] * chartW; + ctx.fillRect(x1, topPad, x2 - x1, chartH); + } + } + } + + // Break lines only across gaps beyond the recording's regular cadence. + for (const s of series) { + ctx.strokeStyle = s.color; + ctx.lineWidth = 1.2; + const n = s.data.length; + let drawing = false; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + if (i > 0 && times && hasTelemetryGap(times[i - 1], times[i], gapThreshold)) { + drawing = false; + } + const xFrac = timeFracs ? timeFracs[i] : i / (n - 1); + const x = leftPad + xFrac * chartW; + const y = topPad + chartH - ((s.data[i] - gMin) / range) * chartH; + if (!drawing) { + ctx.moveTo(x, y); + drawing = true; + } else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + // Labels + ctx.font = "var(--font-weight-bold) var(--text-app-micro) var(--font-sans)"; + ctx.textAlign = "left"; + let ly = 10; + for (const s of series) { + ctx.fillStyle = s.color; + ctx.fillText(s.label, leftPad + 4, ly); + ly += 11; + } + }, [series, totalPackets, height, containerWidth, timeFracs, times]); + + const idxFromEvent = useCallback( + (clientX: number): number | null => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container || totalPackets < 2) return null; + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const w = container.clientWidth; + const leftPad = 40; + const rightPad = 8; + const chartW = w - leftPad - rightPad; + const clickFrac = (x - leftPad) / chartW; + if (!timeFracs || timeFracs.length === 0) { + const idx = Math.round(clickFrac * (totalPackets - 1)); + return idx >= 0 && idx < totalPackets ? idx : null; + } + let lo = 0, + hi = timeFracs.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (timeFracs[mid] < clickFrac) lo = mid + 1; + else hi = mid; + } + if (lo > 0 && Math.abs(timeFracs[lo - 1] - clickFrac) < Math.abs(timeFracs[lo] - clickFrac)) lo--; + return lo >= 0 && lo < totalPackets ? lo : null; + }, + [totalPackets, timeFracs], + ); + + const fracFromEvent = useCallback((clientX: number): number => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return 0; + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const w = container.clientWidth; + return Math.max(0, Math.min(1, (x - 40) / (w - 40 - 8))); + }, []); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + scrubCleanupRef.current?.(); + onScrubStart?.(); + lastScrubIndexRef.current = null; + const emitIndex = (clientX: number) => { + const idx = idxFromEvent(clientX); + if (idx !== null && idx !== lastScrubIndexRef.current) { + lastScrubIndexRef.current = idx; + onClickIndex(idx); + } + }; + const emitVisualFrac = (frac: number | null) => { + pendingVisualFracRef.current = frac; + if (visualFracRafRef.current == null) { + visualFracRafRef.current = requestAnimationFrame(() => { + visualFracRafRef.current = null; + onVisualFracChange?.(pendingVisualFracRef.current); + }); + } + }; + emitIndex(e.clientX); + emitVisualFrac(fracFromEvent(e.clientX)); + + const handleMouseMove = (ev: MouseEvent) => { + emitIndex(ev.clientX); + emitVisualFrac(fracFromEvent(ev.clientX)); + }; + const handleMouseUp = () => { + if (visualFracRafRef.current != null) { + cancelAnimationFrame(visualFracRafRef.current); + visualFracRafRef.current = null; + } + pendingVisualFracRef.current = null; + onVisualFracChange?.(null); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + scrubCleanupRef.current = null; + }; + const cleanup = () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + scrubCleanupRef.current = cleanup; + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }, + [idxFromEvent, fracFromEvent, onClickIndex, onScrubStart, onVisualFracChange], + ); + + return ( + // oxlint-disable-next-line a11y/noStaticElementInteractions: canvas scrubbing uses pointer drag; keyboard navigation is provided by the timeline controls +
+ +
+ ); +} diff --git a/client/src/components/analyse/AnalyseTimelineScrubber.tsx b/client/src/components/analyse/AnalyseTimelineScrubber.tsx new file mode 100644 index 000000000..a052c870c --- /dev/null +++ b/client/src/components/analyse/AnalyseTimelineScrubber.tsx @@ -0,0 +1,301 @@ +import { memo, type RefObject, useEffect, useMemo, useRef } from "react"; +import { SECTOR_COLOR_VARS } from "@raceiq/scene-3d/colors"; +import { formatLapTime } from "@/lib/format"; +import { Button } from "../ui/button"; +import { type SemanticAnalysisFrame, semanticNumber } from "@raceiq/scene-3d/semantic-frame"; +import { hasTelemetryGap, telemetryGapThreshold } from "./AnalyseTelemetryChart"; +import { m } from "../../paraglide/messages"; +import type { IRacingIncidentEvent } from "@raceiq/shared/racing/sessions/iracing-incidents"; +import { incidentEventLabel } from "../iracing/incident-format"; +import type { GameId } from "@raceiq/shared/games/ids"; + +const currentLap = (frame: SemanticAnalysisFrame): number => semanticNumber(frame, "timing.current-lap") ?? 0; + +interface SectorTimesData { + times: number[]; + sectorCount: number; + cursorSector: number; +} +interface TimelineScrubberProps { + gameId?: GameId; + displayTelemetry: SemanticAnalysisFrame[]; + cursorIdx: number; + totalPackets: number; + currentTime: number; + totalTime: number; + lapNumber: number | string; + sectorTimes: SectorTimesData | null; + playing: boolean; + playbackSpeed: number; + visualTimeFrac: number | null; + progressRef: RefObject; + thumbRef: RefObject; + onTogglePlay: () => void; + onSpeedChange: (speed: number) => void; + onSeek: (idx: number) => void; + onVisualFracChange: (frac: number | null) => void; + incidentMarkers?: readonly IRacingIncidentEvent[]; + incidentReportStatus?: "loading" | "error" | "unavailable" | "partial" | "ready"; +} +interface TimelineData { + timeFracs: number[]; + times: number[]; + gapThreshold: number; +} + +const PlaybackControls = memo(function PlaybackControls({ + playing, + playbackSpeed, + onTogglePlay, + onSpeedChange, +}: Pick) { + return ( + <> + +
+ {[0.1, 0.25, 0.5, 1, 1.5, 2, 2.5].map((speed) => ( + + ))} +
+ + ); +}); + +const TimelineGapHighlights = memo(function TimelineGapHighlights({ timelineData }: { timelineData: TimelineData }) { + return timelineData.times.map((time, index) => { + if (index === 0) return null; + const delta = time - timelineData.times[index - 1]; + if (!hasTelemetryGap(timelineData.times[index - 1], time, timelineData.gapThreshold)) return null; + const left = timelineData.timeFracs[index - 1] * 100; + const right = timelineData.timeFracs[index] * 100; + return ( +
+ ); + }); +}); + +export const AnalyseTimelineScrubber = memo(function AnalyseTimelineScrubber({ + gameId, + displayTelemetry, + cursorIdx, + totalPackets, + currentTime, + totalTime, + lapNumber, + sectorTimes, + playing, + playbackSpeed, + visualTimeFrac, + progressRef, + thumbRef, + onTogglePlay, + onSpeedChange, + onSeek, + onVisualFracChange, + incidentMarkers = [], + incidentReportStatus, +}: TimelineScrubberProps) { + const scrubCleanupRef = useRef<(() => void) | null>(null); + + useEffect( + () => () => { + scrubCleanupRef.current?.(); + scrubCleanupRef.current = null; + }, + [], + ); + const timelineData = useMemo(() => { + if (displayTelemetry.length === 0) return null; + const startTime = currentLap(displayTelemetry[0]); + // Use max current-lap as end time — last frame may have reset to next lap + let maxTime = startTime; + for (const frame of displayTelemetry) { + const time = currentLap(frame); + if (time > maxTime) maxTime = time; + } + const lapDuration = maxTime - startTime || 1; + let prevFrac = 0; + const timeFracs = displayTelemetry.map((frame) => { + const frac = Math.max(prevFrac, (currentLap(frame) - startTime) / lapDuration); + prevFrac = frac; + return frac; + }); + const times = displayTelemetry.map(currentLap); + return { timeFracs, times, gapThreshold: telemetryGapThreshold(times) }; + }, [displayTelemetry]); + + const cursorFrac = visualTimeFrac ?? timelineData?.timeFracs?.[cursorIdx] ?? cursorIdx / (totalPackets - 1); + const cursorPct = cursorFrac * 100; + + return ( +
+
+ Lap {lapNumber} + {gameId === "iracing" && currentTime === 0 && semanticNumber(displayTelemetry[cursorIdx], "timing.current-lap") === 0 ? "0:00.000" : formatLapTime(currentTime)} + / {formatLapTime(totalTime)} + {sectorTimes && + Array.from({ length: sectorTimes.sectorCount }, (_, index) => `S${index + 1}`).map((name, i) => { + const isActive = sectorTimes.cursorSector === i; + return ( +
+
+ {name} + {formatLapTime(sectorTimes.times[i])} +
+ ); + })} + + Packet {cursorIdx + 1}/{totalPackets} + +
+
+ +
{ + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + const direction = event.key === "ArrowLeft" ? -1 : 1; + onSeek(Math.max(0, Math.min(totalPackets - 1, cursorIdx + direction))); + } + }} + onMouseDown={(e) => { + scrubCleanupRef.current?.(); + const bar = e.currentTarget; + const seek = (clientX: number) => { + const rect = bar.getBoundingClientRect(); + const clickFrac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + onVisualFracChange(clickFrac); + const tf = timelineData?.timeFracs; + if (tf && tf.length > 0) { + let lo = 0, + hi = tf.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (tf[mid] < clickFrac) lo = mid + 1; + else hi = mid; + } + if (lo > 0 && Math.abs(tf[lo - 1] - clickFrac) < Math.abs(tf[lo] - clickFrac)) lo--; + onSeek(lo); + } else { + onSeek(Math.round(clickFrac * (totalPackets - 1))); + } + }; + seek(e.clientX); + const onMove = (ev: MouseEvent) => seek(ev.clientX); + const onUp = () => { + onVisualFracChange(null); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + scrubCleanupRef.current = null; + }; + const cleanup = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + scrubCleanupRef.current = cleanup; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }} + > + {/* Track background */} +
+ {/* Gap highlights */} + {timelineData && } + {/* Progress fill */} +
+
+ {/* Thumb */} +
+
+
+ {incidentReportStatus !== undefined && } +
+ ); +}); + +const IncidentMarkerLane = memo(function IncidentMarkerLane({ markers, status, timelineData, onSeek }: { + markers: readonly IRacingIncidentEvent[]; + status: NonNullable; + timelineData: TimelineData | null; + onSeek: (index: number) => void; +}) { + const groups = useMemo(() => { + const byFrame = new Map(); + for (const event of markers) { + const frame = event.replay?.frameIndex; + if (frame === undefined || !Number.isInteger(frame) || timelineData?.timeFracs[frame] === undefined) continue; + const group = byFrame.get(frame); + if (group) group.push(event); + else byFrame.set(frame, [event]); + } + return [...byFrame]; + }, [markers, timelineData]); + const message = status === "loading" ? m.iracing_incident_report_marker_loading() + : status === "error" ? m.iracing_incident_report_marker_error() + : status === "unavailable" ? m.iracing_incident_report_marker_unavailable() + : status === "partial" ? m.iracing_incident_report_marker_partial() + : groups.length === 0 ? m.iracing_incident_report_marker_empty() : null; + return ( +
+
+ {m.iracing_incident_report_markers()} + {message && {message}} +
+ {groups.length > 0 &&
+ {groups.map(([frameIndex, events]) => { + const label = events.map(incidentEventLabel).join("; "); + const points = events.some(event => event.kind === "incident-points"); + const surface = events.some(event => event.kind === "off-track"); + return ; + })} +
} +
+ ); +}); diff --git a/client/src/components/analyse/AnalyseTopSection.tsx b/client/src/components/analyse/AnalyseTopSection.tsx new file mode 100644 index 000000000..9eaf261bd --- /dev/null +++ b/client/src/components/analyse/AnalyseTopSection.tsx @@ -0,0 +1,254 @@ +import type { TrackProjection } from "@raceiq/scene-3d/wireframe/ScenePlacement"; +import type { SceneRuntime, SceneSource } from "@raceiq/scene-3d/wireframe/SceneRuntime"; +import { type CSSProperties, type RefObject } from "react"; +import type { GameId } from "@raceiq/shared/games/ids"; +import type { AnalysisHighlight } from "@/components/ai/analysis-types"; +import type { PitLine } from "@/lib/canvas/draw-track"; +import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; +import type { Point, SectorBoundaries, TrackMapBoundaries, TrackMapHandle, TrackMapLabel, TrackOverlayKey, TrackOverlays, TrackZoomBehavior } from "./track-map/types"; +import type { useUnits } from "../../hooks/useUnits"; +import { m } from "../../paraglide/messages"; +import { AnalyseSegmentList } from "@raceiq/lap-replay/ui"; +import { AnalyseTrackPanel } from "./AnalyseTrackPanel"; +import { useEffect, useRef } from "react"; +import { AnalyseVizPanel } from "./AnalyseVizPanel"; + +interface AnalyseTopSectionProps { + lmuCarClass?: string; + gameId?: GameId; + // Layout + topHeight: number; + leftColWidth: number; + rightColWidth: number; + onLeftResize: (width: number) => void; + onRightResize: (width: number) => void; + + // Data + semanticFrames: SemanticAnalysisFrame[]; + cursorIdx: number; + outline: Point[] | null; + trackProjection?: TrackProjection | null; + trackGeometryLoading?: boolean; + trackGeometryError?: boolean; + closed?: boolean; + mapLabels?: TrackMapLabel[] | null; + pitLines?: PitLine[] | null; + boundaries: TrackMapBoundaries | null; + sectors: SectorBoundaries | null; + segments: { type: string; name: string; startFrac: number; endFrac: number }[] | null; + lapLine: Point[] | null; + units: ReturnType; + + // AI highlights + aiPanelOpen: boolean; + aiHighlights: AnalysisHighlight[] | null; + + // View toggles + rotateWithCar: boolean; + trackOverlays: TrackOverlays; + mapZoom: number; + zoomBehavior: TrackZoomBehavior; + onRotateWithCarToggle: () => void; + onTrackOverlayChange: (overlay: TrackOverlayKey, checked: boolean) => void; + onMapZoomChange: (updater: (z: number) => number) => void; + onZoomBehaviorChange: () => void; + + // Viz + vizMode: "2d" | "3d"; + onVizModeChange: (mode: "2d" | "3d") => void; + + // Refs + trackMapRef: RefObject; + cursorRef: RefObject; + displayTelemetryRef: RefObject; + sceneSource: SceneSource; + onRuntime: (runtime: SceneRuntime | null) => void; +} + +export function AnalyseTopSection({ + lmuCarClass, + gameId, + topHeight, + leftColWidth, + rightColWidth, + onLeftResize, + onRightResize, + semanticFrames, + cursorIdx, + outline, + trackProjection, + trackGeometryLoading, + trackGeometryError, + closed = true, + mapLabels, + pitLines, + boundaries, + sectors, + segments, + lapLine, + units, + aiPanelOpen, + aiHighlights, + rotateWithCar, + trackOverlays, + mapZoom, + zoomBehavior, + onRotateWithCarToggle, + onTrackOverlayChange, + onMapZoomChange, + onZoomBehaviorChange, + vizMode, + onVizModeChange, + trackMapRef, + cursorRef, + displayTelemetryRef, + sceneSource, + onRuntime, +}: AnalyseTopSectionProps) { + const resizeCleanupRef = useRef<(() => void) | null>(null); + useEffect( + () => () => { + resizeCleanupRef.current?.(); + resizeCleanupRef.current = null; + }, + [], + ); + const responsiveSizeVars = { + "--analyse-top-height": `min(${topHeight}px, max(15.625rem, calc(100dvh - 18rem)))`, + "--analyse-left-width": `clamp(3.75rem, ${leftColWidth}px, min(12rem, calc(100cqw - 29.25rem)))`, + "--analyse-right-width": `clamp(12.5rem, ${rightColWidth}px, calc(100cqw - var(--analyse-left-width) - 16.75rem))`, + } as CSSProperties; + + return ( +
+ {/* Segment table + legend */} +
+ {/* Legend */} +
+
+
+ {m.label_corner()} +
+
+
+ {m.analyse_straight()} +
+
+ {/* Segment list */} + +
+ +
+ ); +} diff --git a/client/src/components/analyse/AnalyseTrackMap.tsx b/client/src/components/analyse/AnalyseTrackMap.tsx index f4c6cbbeb..7d61516f2 100644 --- a/client/src/components/analyse/AnalyseTrackMap.tsx +++ b/client/src/components/analyse/AnalyseTrackMap.tsx @@ -3,7 +3,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffec import { syncCanvasSize } from "@/lib/rendering/canvas-size"; import { getSemanticCanvasContext } from "@raceiq/scene-3d/rendering/css-canvas"; import { compositeTrack, drawCarOverlay } from "./track-map/overlay-drawing"; -import { pathForwardOffsets, resolveTrackPositions } from "./track-map/path"; +import { alignOfficialReferenceToPositions, alignTrackBoundariesToPositions, pathForwardOffsets, resolveTrackPositions } from "./track-map/path"; import { drawStaticTrack } from "./track-map/static-drawing"; import type { TrackMapHandle, TrackMapProps, TrackTransform } from "./track-map/types"; @@ -33,8 +33,24 @@ export const AnalyseTrackMap = forwardRef(functio const carPosRef = useRef<{ x: number; y: number; w: number; h: number; angle?: number } | null>(null); const transformRef = useRef(null); const sceneRef = useRef(null); - const resolvedPositions = useMemo(() => resolveTrackPositions(telemetry, outline), [telemetry, outline]); + const resolvedPositions = useMemo(() => resolveTrackPositions(telemetry, outline, gameId), [telemetry, outline, gameId]); const resolvedDirections = useMemo(() => pathForwardOffsets(resolvedPositions), [resolvedPositions]); + const measuredGeo = gameId === "iracing" && telemetry.some((frame) => { + const latitude = frame.values["motion.latitude"], longitude = frame.values["motion.longitude"]; + return typeof latitude === "number" && typeof longitude === "number" && Number.isFinite(latitude) && Number.isFinite(longitude) && !(latitude === 0 && longitude === 0) + && (frame.states["motion.latitude"] === undefined || frame.states["motion.latitude"] === "ok") + && (frame.freshness["motion.latitude"] === undefined || frame.freshness["motion.latitude"] === "fresh") + && (frame.states["motion.longitude"] === undefined || frame.states["motion.longitude"] === "ok") + && (frame.freshness["motion.longitude"] === undefined || frame.freshness["motion.longitude"] === "fresh"); + }); + const reference = useMemo(() => { + const layers = { outline, mapLabels, pitLines, boundaries }; + return measuredGeo ? alignOfficialReferenceToPositions(layers, resolvedPositions) : layers; + }, [measuredGeo, outline, mapLabels, pitLines, boundaries, resolvedPositions]); + const displayBoundaries = useMemo( + () => gameId === "lmu" ? alignTrackBoundariesToPositions(boundaries, resolvedPositions) : reference.boundaries, + [boundaries, gameId, reference, resolvedPositions], + ); const drawStatic = useCallback(() => { const canvas = canvasRef.current; @@ -46,10 +62,10 @@ export const AnalyseTrackMap = forwardRef(functio gameId, resolvedPositions, closed, - outline, - mapLabels, - boundaries, - pitLines, + outline: reference.outline, + mapLabels: reference.mapLabels, + boundaries: displayBoundaries, + pitLines: reference.pitLines, sectors, segments, highlights, @@ -79,7 +95,7 @@ export const AnalyseTrackMap = forwardRef(functio const ctx = getSemanticCanvasContext(carCanvasRef.current); ctx?.clearRect(0, 0, carCanvasRef.current.width, carCanvasRef.current.height); } - }, [gameId, telemetry, resolvedPositions, outline, closed, mapLabels, pitLines, boundaries, sectors, segments, highlights, showInputs, showRaceLine, showTrace, rotateWithCar, zoom, zoomBehavior]); + }, [gameId, telemetry, resolvedPositions, reference, closed, displayBoundaries, sectors, segments, highlights, showInputs, showRaceLine, showTrace, rotateWithCar, zoom, zoomBehavior]); const renderOverlayOptions = useCallback( () => ({ diff --git a/client/src/components/analyse/AnalyseTrackPanel.tsx b/client/src/components/analyse/AnalyseTrackPanel.tsx index 1014e2f8b..91de1388d 100644 --- a/client/src/components/analyse/AnalyseTrackPanel.tsx +++ b/client/src/components/analyse/AnalyseTrackPanel.tsx @@ -141,7 +141,7 @@ export function AnalyseTrackPanel({ zoomBehavior={zoomBehavior} /> {/* Weather widget (updates at cursor position) — bottom left by default, bottom right for the live dashboard */} - {hasAirTemperature && } + {gameId !== "iracing" && hasAirTemperature && }
diff --git a/client/src/components/analyse/AnalyseVizPanel.tsx b/client/src/components/analyse/AnalyseVizPanel.tsx index b64571c69..48ff46b4e 100644 --- a/client/src/components/analyse/AnalyseVizPanel.tsx +++ b/client/src/components/analyse/AnalyseVizPanel.tsx @@ -1,6 +1,7 @@ -import type { SceneRuntime, SceneSource } from "@raceiq/scene-3d/wireframe/SceneRuntime" +import type { TrackProjection } from "@raceiq/scene-3d/wireframe/ScenePlacement"; +import type { SceneRuntime, SceneSource } from "@raceiq/scene-3d/wireframe/SceneRuntime"; import type { GameId } from "@raceiq/shared/games/ids"; -import { memo, type RefObject, useEffect, useRef, useState } from "react"; +import { memo, type RefObject, useEffect, useMemo, useRef, useState } from "react"; import type { useUnits } from "../../hooks/useUnits"; import { BodyAttitude } from "../BodyAttitude"; import { CarWireframe } from "../CarWireframe"; @@ -10,6 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; import type { Point, TrackMapBoundaries } from "./track-map/types"; +import { alignTrackBoundariesToPositions } from "./track-map/path"; interface Props { onVizModeChange: (mode: "2d" | "3d") => void; vizMode: "2d" | "3d"; @@ -19,6 +21,9 @@ interface Props { displayTelemetryRef: RefObject; cursorIdx: number; lapLine: Point[] | null; + trackProjection?: TrackProjection | null; + trackGeometryLoading?: boolean; + trackGeometryError?: boolean; boundaries: TrackMapBoundaries | null; units: ReturnType; gameId?: GameId; @@ -34,6 +39,9 @@ function areAnalyseVizPropsEqual(previous: Props, next: Props): boolean { previous.cursorRef === next.cursorRef && previous.displayTelemetryRef === next.displayTelemetryRef && previous.lapLine === next.lapLine && + previous.trackProjection === next.trackProjection && + previous.trackGeometryLoading === next.trackGeometryLoading && + previous.trackGeometryError === next.trackGeometryError && previous.boundaries === next.boundaries && previous.units === next.units && previous.lmuCarClass === next.lmuCarClass && @@ -52,6 +60,9 @@ export const AnalyseVizPanel = memo(function AnalyseVizPanel({ displayTelemetryRef, cursorIdx, lapLine, + trackProjection, + trackGeometryLoading, + trackGeometryError, boundaries, units, gameId, @@ -73,6 +84,11 @@ export const AnalyseVizPanel = memo(function AnalyseVizPanel({ }, [cursorRef]); const visualFrame = displayTelemetryRef.current[visualCursorIdx] ?? semanticFrames[visualCursorIdx] ?? currentFrame; + const sceneBoundaries = useMemo( + () => gameId === "lmu" && lapLine ? alignTrackBoundariesToPositions(boundaries, lapLine) : boundaries, + [boundaries, gameId, lapLine], + ); + return ( )} diff --git a/client/src/components/analyse/IbtImportPreviewModal.tsx b/client/src/components/analyse/IbtImportPreviewModal.tsx index da869dbf6..8a7f641eb 100644 --- a/client/src/components/analyse/IbtImportPreviewModal.tsx +++ b/client/src/components/analyse/IbtImportPreviewModal.tsx @@ -1,46 +1,10 @@ -import type { SessionOwnership } from "@raceiq/shared/racing/sessions/types"; import { OwnershipChoice } from "../import/OwnershipChoice"; import { Button } from "../ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; import { IbtTelemetryWarning } from "./IbtTelemetryWarning"; import { getLocale } from "@/paraglide/runtime"; - import { m } from "../../paraglide/messages"; -export interface IbtImportPreview { - gameId: "iracing"; - fileName: string; - fileSize: number; - tickRate: number; - recordCount: number; - durationSeconds: number; - sessionStartDate: string; - trackId: string; - trackName: string; - carId: string; - carName: string; - carClassName: string; - missingRaceIQVariables: string[]; - missingRequiredVariables: string[]; - drivingFrames: number; - pitRoadFrames: number; - lapTransitions: number; - candidateLapCount: number; - maxSpeedMph: number; - firstDrivingRecord: number | null; - lastDrivingRecord: number | null; - canImport: boolean; - reason: string | null; -} - -interface Props { - token: string | null; - preview: IbtImportPreview; - importing: boolean; - ownership: SessionOwnership; - onOwnershipChange: (value: SessionOwnership) => void; - onImport: () => void; - onClose: () => void; -} +import type { IbtImportPreviewModalProps } from "@raceiq/sessions-page/contracts"; function formatDuration(totalSeconds: number): string { const seconds = Math.max(0, Math.round(totalSeconds)); @@ -56,9 +20,9 @@ function formatSize(bytes: number): string { return `${(bytes / 1024).toFixed(1)} KiB`; } -export function IbtImportPreviewModal({ token, preview, importing, ownership, onOwnershipChange, onImport, onClose }: Props) { +export function IbtImportPreviewModal({ token, preview, importing, ownership, onOwnershipChange, onImport, onClose }: IbtImportPreviewModalProps) { return ( - !open && onClose()}> + !open && !importing && onClose()}> {m.analyse_import_preview_title()} diff --git a/client/src/components/analyse/LapAnalyse.tsx b/client/src/components/analyse/LapAnalyse.tsx index 4edbd45ba..279fa2d7d 100644 --- a/client/src/components/analyse/LapAnalyse.tsx +++ b/client/src/components/analyse/LapAnalyse.tsx @@ -50,6 +50,8 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init setSelectedLapId, handleLapChange, outline, + trackProjection, + segments, initialCursor, trackNames, @@ -268,6 +270,7 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init lap={selectedLap} replay={semanticReplay} outline={outline} + trackProjection={trackProjection} segments={segments ?? []} initialFrame={initialCursor} onController={handleController} diff --git a/client/src/components/analyse/chart-data.ts b/client/src/components/analyse/chart-data.ts index 5fc643987..3317c2902 100644 --- a/client/src/components/analyse/chart-data.ts +++ b/client/src/components/analyse/chart-data.ts @@ -4,6 +4,7 @@ export interface ChartData { speed: number[]; throttle: number[]; brake: number[]; + clutch: number[]; rpm: number[]; steering: number[]; timeFracs: number[]; @@ -44,7 +45,7 @@ export function buildChartData( temperatureConverter: (celsius: number) => number = (celsius) => celsius, ): ChartData | null { if (semanticFrames.length === 0) return null; - const speed: number[] = [], throttle: number[] = [], brake: number[] = [], rpm: number[] = [], steering: number[] = []; + const speed: number[] = [], throttle: number[] = [], brake: number[] = [], clutch: number[] = [], rpm: number[] = [], steering: number[] = []; const tireTempFL: number[] = [], tireTempFR: number[] = [], tireTempRL: number[] = [], tireTempRR: number[] = []; const tireCoreTempFL: number[] = [], tireCoreTempFR: number[] = [], tireCoreTempRL: number[] = [], tireCoreTempRR: number[] = []; const tireCarcassTempFL: number[] = [], tireCarcassTempFR: number[] = [], tireCarcassTempRL: number[] = [], tireCarcassTempRR: number[] = []; @@ -69,6 +70,7 @@ export function buildChartData( speed.push(semanticNumber(frame, "motion.speed") ?? NaN); throttle.push((semanticNumber(frame, "inputs.accel") ?? NaN) / 255 * 100); brake.push((semanticNumber(frame, "inputs.brake") ?? NaN) / 255 * 100); + clutch.push(semanticNumber(frame, "inputs.clutch") ?? NaN); rpm.push(semanticNumber(frame, "engine.current-engine-rpm") ?? NaN); steering.push(semanticNumber(frame, "inputs.steer") ?? NaN); const rotation = semanticWheelNumbers(frame, "tires.wheel-rotation-speed"); @@ -115,7 +117,7 @@ export function buildChartData( if (brakes.some((value) => value != null)) hasBrakeTemp = true; } return { - speed, throttle, brake, rpm, steering, timeFracs, times, tireTempFL, tireTempFR, tireTempRL, tireTempRR, + speed, throttle, brake, clutch, rpm, steering, timeFracs, times, tireTempFL, tireTempFR, tireTempRL, tireTempRR, ...(hasWheelRotation ? { wheelRotationFL, wheelRotationFR, wheelRotationRL, wheelRotationRR } : {}), ...(hasTireTemp && hasCoreTemp && tireTemperatureSemanticId === "tire.temperature.surface.representative" ? { tireCoreTempFL, tireCoreTempFR, tireCoreTempRL, tireCoreTempRR } : {}), ...(hasTireTemp && hasCarcassTemp && tireTemperatureSemanticId === "tire.temperature.surface.representative" ? { tireCarcassTempFL, tireCarcassTempFR, tireCarcassTempRL, tireCarcassTempRR } : {}), diff --git a/client/src/components/analyse/track-map/overlay-drawing.ts b/client/src/components/analyse/track-map/overlay-drawing.ts index e9ed3d8d6..961352dc5 100644 --- a/client/src/components/analyse/track-map/overlay-drawing.ts +++ b/client/src/components/analyse/track-map/overlay-drawing.ts @@ -1,7 +1,7 @@ import type { SceneBuilder } from "./scene"; import { syncCanvasSize } from "@/lib/rendering/canvas-size"; import { getSemanticCanvasContext } from "@raceiq/scene-3d/rendering/css-canvas"; -import { resolveFrameDirection } from "./path"; +import { interpolateReplayPosition, resolveFrameDirection } from "./path"; import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; import type { Point, TrackTransform } from "./types"; @@ -18,20 +18,49 @@ export interface OverlayOptions { const direction = (frame: SemanticAnalysisFrame, path: [number, number] | null): [number, number] | null => resolveFrameDirection(frame, path); -export function compositeTrack(options: OverlayOptions, idx: number): void { - const { canvas, telemetry, resolvedPositions, resolvedDirections, transform: t, scene } = options; +function interpolatedDirection(options: OverlayOptions, cursorIndex: number): [number, number] | null { + const { telemetry, resolvedDirections } = options; + if (telemetry.length === 0 || !Number.isFinite(cursorIndex)) return null; + const index = Math.max(0, Math.min(telemetry.length - 1, cursorIndex)); + const lower = Math.floor(index); + const fraction = index - lower; + const first = direction(telemetry[lower]!, resolvedDirections[lower] ?? null); + if (fraction === 0 || lower + 1 >= telemetry.length) return first; + const second = direction(telemetry[lower + 1]!, resolvedDirections[lower + 1] ?? null); + if (!first || !second) return first ?? second; + const firstAngle = Math.atan2(first[0], first[1]); + const secondAngle = Math.atan2(second[0], second[1]); + let angleDelta = secondAngle - firstAngle; + if (angleDelta > Math.PI) angleDelta -= Math.PI * 2; + else if (angleDelta < -Math.PI) angleDelta += Math.PI * 2; + const angle = firstAngle + angleDelta * fraction; + return [Math.sin(angle), Math.cos(angle)]; +} + +function cursorState(options: OverlayOptions, cursorIndex: number) { + const { telemetry, resolvedPositions } = options; + if (telemetry.length === 0 || !Number.isFinite(cursorIndex)) return null; + const index = Math.max(0, Math.min(telemetry.length - 1, cursorIndex)); + const frame = telemetry[Math.floor(index)]; + const position = interpolateReplayPosition(resolvedPositions, index); + if (!frame || !position) return null; + return { frame, position, path: interpolatedDirection(options, index) }; +} + +export function compositeTrack(options: OverlayOptions, cursorIndex: number): void { + const { canvas, transform: t, scene } = options; if (!scene || !t) return; const ctx = getSemanticCanvasContext(canvas); if (!ctx) return; ctx.save(); ctx.setTransform(canvas.width / t.w, 0, 0, canvas.height / t.h, 0, 0); ctx.clearRect(0, 0, t.w, t.h); - const frame = telemetry[idx], - position = resolvedPositions[idx], - path = resolvedDirections[idx]; + const cursor = cursorState(options, cursorIndex); + const { frame, position, path } = cursor ?? {}; + const validPosition = !!frame && !!position; let cx = 0, cy = 0, heading = 0; if (frame && position) { - const resolvedDirection = direction(frame, path); + const resolvedDirection = direction(frame, path ?? null); heading = resolvedDirection ? Math.atan2(resolvedDirection[0], resolvedDirection[1]) : 0; cx = t.offsetX + (t.maxX - position.x) * t.scale; cy = t.offsetZ + (position.z - t.minZ) * t.scale; @@ -44,13 +73,13 @@ export function compositeTrack(options: OverlayOptions, idx: number): void { const extentY = (sin * t.w + cos * t.h) / 2; scene.renderView( ctx, - frame && position ? cx - extentX : 0, - frame && position ? cy - extentY : 0, - frame && position ? cx + extentX : t.w, - frame && position ? cy + extentY : t.h, + validPosition ? cx - extentX : 0, + validPosition ? cy - extentY : 0, + validPosition ? cx + extentX : t.w, + validPosition ? cy + extentY : t.h, ); - if (frame && position) { - const resolvedDirection = direction(frame, path); + if (validPosition && frame && position) { + const resolvedDirection = direction(frame, path ?? null); if (resolvedDirection) { const [dx, dz] = resolvedDirection, fx = t.offsetX + (t.maxX - (position.x + dx)) * t.scale, @@ -74,21 +103,21 @@ export function compositeTrack(options: OverlayOptions, idx: number): void { ctx.restore(); } -export function drawCarOverlay(options: OverlayOptions, idx: number): { x: number; y: number; w: number; h: number; angle?: number } | null { - const { carCanvas, telemetry, resolvedPositions, resolvedDirections, transform: t } = options; +export function drawCarOverlay(options: OverlayOptions, cursorIndex: number): { x: number; y: number; w: number; h: number; angle?: number } | null { + const { carCanvas, transform: t } = options; if (!t) return null; syncCanvasSize(carCanvas, t.w, t.h, window.devicePixelRatio || 1, false); const ctx = getSemanticCanvasContext(carCanvas); if (!ctx) return null; ctx.setTransform(carCanvas.width / t.w, 0, 0, carCanvas.height / t.h, 0, 0); ctx.clearRect(0, 0, t.w, t.h); - const frame = telemetry[idx], - position = resolvedPositions[idx]; - if (!frame || !position) return null; + const cursor = cursorState(options, cursorIndex); + if (!cursor) return null; + const { position, path } = cursor; const sx = t.w / t.offW, sy = t.h / t.offH, toCanvas = (x: number, z: number): [number, number] => [(t.offsetX + (t.maxX - x) * t.scale) * sx, (t.offsetZ + (z - t.minZ) * t.scale) * sy]; - const resolvedDirection = direction(frame, resolvedDirections[idx]); + const resolvedDirection = direction(cursor.frame, path); if (!resolvedDirection) return null; const [cx, cy] = toCanvas(position.x, position.z), [dx, dz] = resolvedDirection, diff --git a/client/src/components/analyse/track-map/path.ts b/client/src/components/analyse/track-map/path.ts index e02fb507d..ecf119a42 100644 --- a/client/src/components/analyse/track-map/path.ts +++ b/client/src/components/analyse/track-map/path.ts @@ -1,44 +1,125 @@ +import { applyAlignment, computeAlignment } from "@raceiq/shared/racing/tracks/geometry/points"; +import { createLapFractionProjector } from "@raceiq/shared/racing/tracks/path"; +import { geographicPosition } from "@raceiq/scene-3d/wireframe/ScenePlacement"; import { semanticNumber, type SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; -import type { Point } from "./types"; +import type { Point, TrackMapBoundaries, TrackMapProps } from "./types"; -const worldPosition = (frame: SemanticAnalysisFrame): Point => ({ - x: semanticNumber(frame, "motion.position-x") ?? 0, - z: semanticNumber(frame, "motion.position-z") ?? 0, -}); +const fresh = (frame: SemanticAnalysisFrame, id: string) => { + const state = frame.states[id]; + const freshness = frame.freshness[id]; + return (state === undefined || state === "ok") && (freshness === undefined || freshness === "fresh") + ? semanticNumber(frame, id as never) + : null; +}; -export function resolveTrackPositions(telemetry: SemanticAnalysisFrame[], outline: Point[] | null): Point[] { - const worldPositions = telemetry.map(worldPosition); +export function resolveTrackPositions(telemetry: SemanticAnalysisFrame[], outline: Point[] | null, gameId?: string): Point[] { + if (gameId === "iracing") { + const valid = telemetry.map((frame) => { + const latitude = fresh(frame, "motion.latitude"); + const longitude = fresh(frame, "motion.longitude"); + return latitude !== null && longitude !== null && Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180 && !(latitude === 0 && longitude === 0) + ? { latitude, longitude } + : null; + }); + const originSample = telemetry.find((frame) => { + const latitude = fresh(frame, "identity.track.latitude"); + const longitude = fresh(frame, "identity.track.longitude"); + return latitude !== null && longitude !== null && Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180 && !(latitude === 0 && longitude === 0); + }); + const origin = originSample + ? { latitude: fresh(originSample, "identity.track.latitude")!, longitude: fresh(originSample, "identity.track.longitude")! } + : valid.find((sample) => sample !== null); + if (origin && valid.some((sample) => sample !== null)) return valid.map((sample) => sample ? geographicPosition(sample.latitude, sample.longitude, origin.latitude, origin.longitude) : { x: NaN, z: NaN }); + } + const worldPositions = telemetry.map((frame) => ({ + x: semanticNumber(frame, "motion.position-x") ?? 0, + z: semanticNumber(frame, "motion.position-z") ?? 0, + })); if (worldPositions.some((point) => point.x !== 0 || point.z !== 0) || !outline || outline.length < 2) return worldPositions; const fractions = telemetry.map((frame) => semanticNumber(frame, "timing.lap-fraction")); if (fractions.some((fraction) => fraction === null)) return worldPositions; - const cumulative = [0]; - for (let index = 1; index < outline.length; index++) { - cumulative.push(cumulative[index - 1] + Math.hypot(outline[index].x - outline[index - 1].x, outline[index].z - outline[index - 1].z)); - } - const total = cumulative.at(-1) ?? 0; - if (total <= 0) return worldPositions; - - return fractions.map((fraction) => { - const target = Math.max(0, Math.min(1, fraction!)) * total; - let low = 1; - let high = cumulative.length - 1; - while (low < high) { - const middle = (low + high) >> 1; - if (cumulative[middle] < target) low = middle + 1; - else high = middle; - } - const start = Math.max(0, low - 1); - const segmentLength = cumulative[low] - cumulative[start]; - const amount = segmentLength > 0 ? (target - cumulative[start]) / segmentLength : 0; - return { - x: outline[start].x + (outline[low].x - outline[start].x) * amount, - z: outline[start].z + (outline[low].z - outline[start].z) * amount, - }; + const project = createLapFractionProjector(outline); + if (!project) return worldPositions; + return fractions.map((fraction, index) => { + const pose = project(fraction!); + return pose ? { x: pose.x, z: pose.z } : worldPositions[index]; }); } +export function interpolateReplayPosition( + positions: readonly Point[], + cursorIndex: number, +): Point | null { + if (positions.length === 0 || !Number.isFinite(cursorIndex)) return null; + const index = Math.max(0, Math.min(positions.length - 1, cursorIndex)); + const startIndex = Math.floor(index); + const start = positions[startIndex]; + if (!start || !Number.isFinite(start.x) || !Number.isFinite(start.z)) return null; + const end = positions[startIndex + 1]; + if (!end || !Number.isFinite(end.x) || !Number.isFinite(end.z)) return start; + const fraction = index - startIndex; + return { + x: start.x + (end.x - start.x) * fraction, + z: start.z + (end.z - start.z) * fraction, + }; +} +export function alignOfficialReferenceToPositions( + reference: Pick, + positions: Point[], +): typeof reference { + const points = reference.outline ?? reference.boundaries?.centerLine; + const target = positions.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.z)); + const alignment = points && points.length >= 5 && target.length >= 5 ? computeAlignment(points, target) : null; + if (!alignment) return { outline: null, mapLabels: null, pitLines: null, boundaries: null }; + const align = (points: Point[]) => points.map((point) => applyAlignment(point, alignment)); + const boundaries = reference.boundaries; + return { + outline: reference.outline ? align(reference.outline) : null, + mapLabels: reference.mapLabels?.map((label) => ({ ...label, ...applyAlignment(label, alignment) })), + pitLines: reference.pitLines?.map((line) => ({ ...line, points: align(line.points) })), + boundaries: boundaries ? { + ...boundaries, + leftEdge: align(boundaries.leftEdge), + rightEdge: align(boundaries.rightEdge), + centerLine: align(boundaries.centerLine), + raceLine: boundaries.raceLine ? align(boundaries.raceLine) : boundaries.raceLine, + pitLane: boundaries.pitLane ? align(boundaries.pitLane) : null, + additionalSections: boundaries.additionalSections?.map((section) => ({ + leftEdge: align(section.leftEdge), rightEdge: align(section.rightEdge), + })), + } : null, + }; +} + +export function alignTrackBoundariesToPositions( + boundaries: TrackMapBoundaries | null, + positions: Point[], +): TrackMapBoundaries | null { + if (!boundaries || boundaries.centerLine.length < 20) return boundaries; + const target = positions.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.z)); + if ( + target.length < 20 + || !target.some((point) => point.x !== target[0].x || point.z !== target[0].z) + ) { + return boundaries; + } + const alignment = computeAlignment(boundaries.centerLine, target); + if (!alignment) return boundaries; + const align = (points: Point[]) => points.map((point) => applyAlignment(point, alignment)); + return { + ...boundaries, + leftEdge: align(boundaries.leftEdge), + rightEdge: align(boundaries.rightEdge), + centerLine: align(boundaries.centerLine), + raceLine: boundaries.raceLine ? align(boundaries.raceLine) : boundaries.raceLine, + pitLane: boundaries.pitLane ? align(boundaries.pitLane) : null, + }; +} + + + export function projectPointOntoPath(point: Point, path: readonly Point[]): Point | null { if (path.length < 2) return null; let bestDistance = Number.POSITIVE_INFINITY; @@ -98,9 +179,15 @@ export function pathForwardOffsets(points: readonly Point[]): ([number, number] } export function resolveFrameDirection(frame: SemanticAnalysisFrame, pathDirection: [number, number] | null): [number, number] | null { - const yaw = semanticNumber(frame, "motion.yaw"); - const state = frame.states["motion.yaw"]; - const freshness = frame.freshness["motion.yaw"]; + const nativeNorthYaw = semanticNumber(frame, "motion.yaw-north"); + // iRacing replay positions are measured or projected onto the track; use that map-space tangent + // instead of simulator yaw, whose reference frame does not match projected replay positions. + if (nativeNorthYaw !== null && pathDirection) return pathDirection; + const hasGeographicPose = fresh(frame, "motion.latitude") !== null && fresh(frame, "motion.longitude") !== null; + const yawId = hasGeographicPose ? "motion.yaw-north" : "motion.yaw"; + const yaw = semanticNumber(frame, yawId as never); + const state = frame.states[yawId]; + const freshness = frame.freshness[yawId]; if (yaw !== null && (state === undefined || state === "ok") && (freshness === undefined || freshness === "fresh")) { return [Math.sin(yaw), Math.cos(yaw)]; } diff --git a/client/src/components/analyse/track-map/static-drawing.ts b/client/src/components/analyse/track-map/static-drawing.ts index c1d9b9b7b..649e25aa2 100644 --- a/client/src/components/analyse/track-map/static-drawing.ts +++ b/client/src/components/analyse/track-map/static-drawing.ts @@ -119,6 +119,7 @@ export function drawStaticTrack(options: StaticTrackOptions): { scene: SceneBuil if (displayMapLabels?.length) allBoundsPts.push(displayMapLabels); for (const pts of allBoundsPts) for (const p of pts) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.z)) continue; minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minZ = Math.min(minZ, p.z); @@ -172,11 +173,29 @@ export function drawStaticTrack(options: StaticTrackOptions): { scene: SceneBuil ctx.lineWidth = showInputs ? 0.75 : 4; ctx.lineCap = "round"; ctx.lineJoin = "round"; - const [sx, sy] = toCanvas(displayOutline[0].x, displayOutline[0].z); - ctx.moveTo(sx, sy); - for (let i = 1; i < displayOutline.length; i++) ctx.lineTo(...toCanvas(displayOutline[i].x, displayOutline[i].z)); - if (drawingReferenceOutline && closed) ctx.lineTo(sx, sy); + let penDown = false; + for (const point of displayOutline) { + if (!Number.isFinite(point.x) || !Number.isFinite(point.z)) { + penDown = false; + continue; + } + const [x, y] = toCanvas(point.x, point.z); + if (penDown) ctx.lineTo(x, y); + else { ctx.moveTo(x, y); penDown = true; } + } + if (drawingReferenceOutline) ctx.closePath(); ctx.stroke(); + if (gameId === "iracing" && hasReferenceOutline && displayOutline === telemetryPoints) { + ctx.beginPath(); + ctx.strokeStyle = "var(--track-outline)"; + ctx.globalAlpha = 0.45; + ctx.lineWidth = 2; + ctx.moveTo(...toCanvas(displayTrackOutline![0].x, displayTrackOutline![0].z)); + for (let i = 1; i < displayTrackOutline!.length; i++) ctx.lineTo(...toCanvas(displayTrackOutline![i].x, displayTrackOutline![i].z)); + if (closed) ctx.closePath(); + ctx.stroke(); + ctx.globalAlpha = 1; + } const displayPitLines = flip && pitLines ? pitLines.map((line) => ({ ...line, points: flipPoints(line.points) })) : pitLines; drawPitLines(ctx, displayPitLines, toCanvas); @@ -273,9 +292,14 @@ export function drawStaticTrack(options: StaticTrackOptions): { scene: SceneBuil ctx.beginPath(); ctx.strokeStyle = "var(--track-edge)"; ctx.lineWidth = 2; - ctx.moveTo(sx, sy); - for (let i = 1; i < displayOutline.length; i++) ctx.lineTo(...toCanvas(displayOutline[i].x, displayOutline[i].z)); - if (drawingReferenceOutline && closed) ctx.lineTo(sx, sy); + penDown = false; + for (const point of displayOutline) { + if (!Number.isFinite(point.x) || !Number.isFinite(point.z)) { penDown = false; continue; } + const [x, y] = toCanvas(point.x, point.z); + if (penDown) ctx.lineTo(x, y); + else { ctx.moveTo(x, y); penDown = true; } + } + if (drawingReferenceOutline && closed) ctx.closePath(); ctx.stroke(); } diff --git a/client/src/components/analyse/useAnalyseImports.ts b/client/src/components/analyse/useAnalyseImports.ts index a047aa5eb..83e93aac5 100644 --- a/client/src/components/analyse/useAnalyseImports.ts +++ b/client/src/components/analyse/useAnalyseImports.ts @@ -4,7 +4,7 @@ import { invalidateDashboardQueries } from "../../hooks/dashboard"; import { useCallback, useState } from "react"; import type { SessionOwnership } from "@raceiq/shared/racing/sessions/types"; import { authenticatedFetch, client } from "../../lib/rpc"; -import type { IbtImportPreview } from "./IbtImportPreviewModal"; +import type { IbtImportPreview } from "@raceiq/sessions-page/contracts"; export interface ImportedLap { lapId: number; @@ -131,7 +131,9 @@ export function useAnalyseImports(args: { } setIbtPreview(null); const laps = data?.laps ?? []; - finalizeImport({ fileName: staged.preview.fileName, packetCount: data?.packetCount ?? 0, laps, gameId: data?.gameId ?? "", routePrefix: data?.routePrefix ?? "" }); + const result = { fileName: staged.preview.fileName, packetCount: data?.packetCount ?? 0, laps, gameId: data?.gameId ?? "", routePrefix: data?.routePrefix ?? "" }; + finalizeImport(result); + return result; } catch (e) { setIbtPreview(null); window.alert(`IBT import failed: ${e instanceof Error ? e.message : String(e)}`); diff --git a/client/src/components/analyse/useAnalyseSelections.ts b/client/src/components/analyse/useAnalyseSelections.ts index e1c5733cb..9f7b9e732 100644 --- a/client/src/components/analyse/useAnalyseSelections.ts +++ b/client/src/components/analyse/useAnalyseSelections.ts @@ -1,4 +1,5 @@ import { getGame } from "@raceiq/shared/games/registry" +import type { GameId } from "@raceiq/shared/games/ids"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LapMeta } from "@raceiq/shared/racing/sessions/types"; @@ -27,7 +28,7 @@ interface AnalyseSemanticFrame { values: SemanticAnalysisFrame["values"]; states: Readonly>; freshness: Readonly>; - source?: "motec" | null; + source?: SemanticAnalysisFrame["source"]; } const emptyLaps: LapMeta[] = []; @@ -35,7 +36,7 @@ type IdentityKey = string; const lapTrackKey = (lap: LapMeta): IdentityKey | null => lap.trackId ?? null; const lapCarKey = (lap: LapMeta): IdentityKey | null => lap.carId ?? null; -export function useAnalyseSelections(search: AnalyseSearch, gameId: Parameters[0], sessionId?: number) { +export function useAnalyseSelections(search: AnalyseSearch, gameId: GameId, sessionId?: number) { const navigate = useNavigate(); const [laps, setLaps] = useState([]); const [selectedTrack, setSelectedTrack] = useState(search.track ?? null); @@ -82,7 +83,7 @@ export function useAnalyseSelections(search: AnalyseSearch, gameId: Parameters { if (!outlineRaw) return null; @@ -91,6 +92,7 @@ export function useAnalyseSelections(search: AnalyseSearch, gameId: Parameters { if (!outlineRaw || Array.isArray(outlineRaw) || typeof outlineRaw !== "object") return null; if (!("pitLines" in outlineRaw)) return null; @@ -102,8 +104,18 @@ export function useAnalyseSelections(search: AnalyseSearch, gameId: Parameters { + if (boundariesRaw && typeof boundariesRaw === "object") return boundariesRaw as TrackMapBoundaries; + if (gameId !== "iracing" || !outlineRaw || Array.isArray(outlineRaw)) return null; + const edges = outlineRaw.ribbonEdges; + if (!edges || !Array.isArray(edges.leftEdge) || !Array.isArray(edges.rightEdge) || edges.leftEdge.length < 2 || edges.leftEdge.length !== edges.rightEdge.length) return null; + const centerLine = edges.leftEdge.map((left, index) => ({ + x: (left.x + edges.rightEdge[index]!.x) / 2, + z: (left.z + edges.rightEdge[index]!.z) / 2, + })); + return { ...edges, centerLine, pitLane: null, coordSystem: "official-map" } as TrackMapBoundaries; + }, [boundariesRaw, gameId, outlineRaw]); const { data: sectorsRaw } = useTrackSectorBoundaries(trackId ?? undefined); const sectorData = useMemo<{ sectorStarts: number[]; sectorCount: number; firstDist: number; lapDist: number; times: number[] } | null>(() => { if (!semanticReplay || semanticFrames.length < 2) return null; @@ -259,6 +271,9 @@ export function useAnalyseSelections(search: AnalyseSearch, gameId: Parameters(null); const zoomCanvasRef = useRef(null); const overviewContainerRef = useRef(null); @@ -46,6 +64,9 @@ export function CompareTrackMap({ outline, traces, segments, hoveredDistanceRef, useEffect(() => { followCarRef.current = followCar; }, [followCar]); + const { data: lapAMapTelemetry } = useIRacingTrackMapTelemetry(gameId === "iracing" ? lapAId : null); + const { data: lapBMapTelemetry } = useIRacingTrackMapTelemetry(gameId === "iracing" ? lapBId : null); + // Fetch track boundaries useEffect(() => { @@ -68,10 +89,34 @@ export function CompareTrackMap({ outline, traces, segments, hoveredDistanceRef, // Pre-flip outline/boundary X so they render correctly against telemetry. const flip = needsTrackFlip(gameId); const displayOutline = useMemo(() => (flip ? flipPoints(outline) : outline), [outline, flip]); + const sourceBoundaries = boundaries ?? outlineBounds; const displayBoundaries = useMemo(() => { - if (!flip || !boundaries) return boundaries; - return flipBoundaries(boundaries); - }, [boundaries, flip]); + if (!flip || !sourceBoundaries) return sourceBoundaries; + return flipBoundaries(sourceBoundaries); + }, [sourceBoundaries, flip]); + const measuredMapPaths = useMemo( + () => gameId === "iracing" + ? buildIRacingMeasuredMapPaths( + [lapAMapTelemetry, lapBMapTelemetry], + traces.distance, + ) + : [], + [gameId, lapAMapTelemetry, lapBMapTelemetry, traces.distance], + ); + const iracingReference = useMemo(() => { + if (gameId !== "iracing") return null; + const target = measuredMapPaths.find((path) => path !== null); + if (!target) return null; + return alignOfficialReferenceToPositions( + { + outline: displayOutline, + mapLabels: null, + pitLines: null, + boundaries: displayBoundaries as TrackMapBoundaries | null, + }, + target.filter((point): point is Point => point !== null), + ); + }, [gameId, measuredMapPaths, displayOutline, displayBoundaries]); const { alignedOutline, alignedBoundaries, telXFn, trackRange } = useMemo(() => { const outline = displayOutline; @@ -91,6 +136,14 @@ export function CompareTrackMap({ outline, traces, segments, hoveredDistanceRef, } return Math.max(maxX - minX || 1, maxZ - minZ || 1); }; + if (gameId === "iracing" && iracingReference?.outline) { + return { + alignedOutline: iracingReference.outline, + alignedBoundaries: iracingReference.boundaries, + telXFn: identity, + trackRange: computeRange(iracingReference.outline), + }; + } // Extract telemetry positions from lap A const telPts: Point[] = []; @@ -258,7 +311,24 @@ export function CompareTrackMap({ outline, traces, segments, hoveredDistanceRef, } return { alignedOutline: newOutline, alignedBoundaries: newBoundaries, telXFn: identity, trackRange: computeRange(newOutline) }; - }, [displayOutline, traces, displayBoundaries]); + }, [displayOutline, traces, displayBoundaries, gameId, iracingReference]); + const mapTraces = useMemo(() => { + if (gameId !== "iracing") return traces; + const measured = iracingReference?.outline ? measuredMapPaths : []; + const projected = projectIRacingDistanceToOutline(traces.distance, alignedOutline); + const pathA = measured[0] ?? projected; + const pathB = measured[1] ?? projected; + const xs = (path: typeof pathA, fallback: number[]) => path ? path.map((point) => point?.x ?? Number.NaN) : fallback; + const zs = (path: typeof pathA, fallback: number[]) => path ? path.map((point) => point?.z ?? Number.NaN) : fallback; + return { + ...traces, + positionXA: xs(pathA, traces.positionXA), + positionZA: zs(pathA, traces.positionZA), + positionXB: xs(pathB, traces.positionXB), + positionZB: zs(pathB, traces.positionZB), + }; + }, [gameId, iracingReference, measuredMapPaths, traces, alignedOutline]); + const drawBoth = useCallback(() => { const hd = hoveredDistanceRef.current; @@ -277,15 +347,15 @@ export function CompareTrackMap({ outline, traces, segments, hoveredDistanceRef, if (ctx) { ctx.scale(dpr, dpr); const segPts = - segments.length > 0 && traces.distance.length >= 2 + segments.length > 0 && mapTraces.distance.length >= 2 ? segments .map((s) => { - const idx = Math.round(s.startFrac * (traces.distance.length - 1)); - return { x: telXFn(traces.positionXA[idx] ?? 0), z: traces.positionZA[idx] ?? 0, type: s.type, label: s.name }; + const idx = Math.round(s.startFrac * (mapTraces.distance.length - 1)); + return { x: telXFn(mapTraces.positionXA[idx] ?? 0), z: mapTraces.positionZA[idx] ?? 0, type: s.type, label: s.name }; }) - .filter((sp) => sp.x !== 0 || sp.z !== 0) + .filter((sp) => Number.isFinite(sp.x) && Number.isFinite(sp.z) && (sp.x !== 0 || sp.z !== 0)) : undefined; - drawTrackCanvas(ctx, rect.width, rect.height, alignedOutline, traces, hd, null, segPts, false, alignedBoundaries, telXFn, false, false); + drawTrackCanvas(ctx, rect.width, rect.height, alignedOutline, mapTraces, hd, null, segPts, false, alignedBoundaries, telXFn, false, false, gameId === "iracing"); } } @@ -302,12 +372,12 @@ export function CompareTrackMap({ outline, traces, segments, hoveredDistanceRef, const ctx = getSemanticCanvasContext(zc); if (ctx) { ctx.scale(dpr, dpr); - const zoom = hd != null ? computeZoom(traces, hd, trackRange, telXFn, alignedOutline) : null; - drawTrackCanvas(ctx, rect.width, rect.height, alignedOutline, traces, hd, zoom, undefined, followCarRef.current, alignedBoundaries, telXFn, true); + const zoom = hd != null ? computeZoom(mapTraces, hd, trackRange, telXFn, alignedOutline) : null; + drawTrackCanvas(ctx, rect.width, rect.height, alignedOutline, mapTraces, hd, zoom, undefined, followCarRef.current, alignedBoundaries, telXFn, true, true, gameId === "iracing", gameId === "iracing"); if (hd != null) { - const index = findTraceIndexAtDistance(traces.distance, hd); - drawInputsHUD(ctx, rect.width, rect.height, traces, index); + const index = findTraceIndexAtDistance(mapTraces.distance, hd); + drawInputsHUD(ctx, rect.width, rect.height, mapTraces, index); } } } @@ -333,7 +403,7 @@ export function CompareTrackMap({ outline, traces, segments, hoveredDistanceRef, prevActiveSegRef.current = activeIdx; } } - }, [alignedOutline, traces, hoveredDistanceRef, segments, alignedBoundaries, telXFn, trackRange]); + }, [alignedOutline, mapTraces, hoveredDistanceRef, segments, alignedBoundaries, telXFn, trackRange, gameId]); const redrawFrameRef = useRef(null); const scheduleDraw = useCallback(() => { if (redrawFrameRef.current != null) return; @@ -365,7 +435,7 @@ export function CompareTrackMap({ outline, traces, segments, hoveredDistanceRef,
{/* Overview — full track, static */}
- {m.compare_overview()} + {m.compare_overview()} {alignedOutline.length < 2 ? (
{m.compare_no_outline()}
) : ( diff --git a/client/src/components/comparison/LapComparison.tsx b/client/src/components/comparison/LapComparison.tsx index 24f0d42eb..ddb73495e 100644 --- a/client/src/components/comparison/LapComparison.tsx +++ b/client/src/components/comparison/LapComparison.tsx @@ -212,6 +212,22 @@ function LapComparisonInner({ initialSearch }: { initialSearch?: CompareSearch } if (Array.isArray(d)) return d; return null; }, [outlineData]); + const trackOutlineBounds = useMemo(() => { + if (gameId !== "iracing" || !outlineData || Array.isArray(outlineData) || !("ribbonEdges" in outlineData)) return null; + const rawEdges = outlineData.ribbonEdges; + if (!rawEdges || typeof rawEdges !== "object" || !("leftEdge" in rawEdges) || !("rightEdge" in rawEdges)) return null; + const isPointList = (value: unknown): value is Point[] => + Array.isArray(value) && + value.every((point) => point && typeof point === "object" && "x" in point && typeof point.x === "number" && Number.isFinite(point.x) && "z" in point && typeof point.z === "number" && Number.isFinite(point.z)); + const leftEdge = rawEdges.leftEdge; + const rightEdge = rawEdges.rightEdge; + if (!isPointList(leftEdge) || !isPointList(rightEdge) || leftEdge.length < 2 || leftEdge.length !== rightEdge.length) return null; + const centerLine = leftEdge.map((left, index) => { + const right = rightEdge[index]!; + return { x: (left.x + right.x) / 2, z: (left.z + right.z) / 2 }; + }); + return { leftEdge, rightEdge, centerLine, pitLane: null, coordSystem: "official-map" }; + }, [outlineData, gameId]); const { data: sectorsData } = useTrackSectors(selectedTrack ?? undefined); const trackSegments = useMemo((): { type: string; name: string; startFrac: number; endFrac: number }[] | null => { const s = sectorsData as { segments?: { type: string; name: string; startFrac: number; endFrac: number }[] } | undefined; @@ -714,7 +730,11 @@ function LapComparisonInner({ initialSearch }: { initialSearch?: CompareSearch } > 1) continue; + if (previousRaw !== null && rawFraction + offset < previousProgress - 0.5) offset += 1; + const progress = Math.max(previousProgress, rawFraction + offset); + previousRaw = rawFraction; + previousProgress = progress; + result.push({ progress, point }); + } + if (measuredCount < 5 || result.length < 2 || !(result.at(-1)!.progress > result[0]!.progress)) return null; + const first = result[0]!.progress; + const span = result.at(-1)!.progress - first; + return result.map((item) => ({ ...item, progress: (item.progress - first) / span })); +} + +function sampleProgressPath(source: ProgressPoint[], distances: readonly number[]): (Point | null)[] | null { + const start = distances[0]; + const end = distances.at(-1); + if (start === undefined || end === undefined || !Number.isFinite(start) || !Number.isFinite(end) || !(end > start)) return null; + const path: (Point | null)[] = []; + let upper = 0; + for (const distance of distances) { + if (!Number.isFinite(distance)) return null; + const target = Math.max(0, Math.min(1, (distance - start) / (end - start))); + while (upper < source.length && source[upper]!.progress < target) upper++; + if (upper === 0) { + path.push(source[0]!.point); + continue; + } + if (upper >= source.length) { + path.push(source.at(-1)!.point); + continue; + } + const right = source[upper]!; + const left = source[upper - 1]!; + if (right.progress === target) { + path.push(right.point); + continue; + } + if (!left.point || !right.point) { + path.push(null); + continue; + } + const amount = (target - left.progress) / (right.progress - left.progress); + path.push({ + x: left.point.x + (right.point.x - left.point.x) * amount, + z: left.point.z + (right.point.z - left.point.z) * amount, + }); + } + return path.filter((point) => point !== null).length >= 5 ? path : null; +} + +/** Project iRacing GPS replay positions onto the shared comparison distance grid. */ +export function buildIRacingMeasuredMapPaths( + replays: readonly Replay[], + distances: readonly number[], +): ((Point | null)[] | null)[] { + const origin = geographicOrigin(replays); + if (!origin) return replays.map(() => null); + return replays.map((replay) => { + if (!replay) return null; + const source = progressPoints(replay, origin); + return source ? sampleProgressPath(source, distances) : null; + }); +} + +/** iRacing's live SDK supplies lap progress, not world positions; map it onto official track geometry. */ +export function projectIRacingDistanceToOutline(distances: readonly number[], outline: readonly Point[]): (Point | null)[] | null { + const start = distances[0]; + const end = distances.at(-1); + const project = createLapFractionProjector(outline); + if (!project || start === undefined || end === undefined || !Number.isFinite(start) || !Number.isFinite(end) || !(end > start)) return null; + return distances.map((distance) => { + if (!Number.isFinite(distance)) return null; + const fraction = Math.max(0, Math.min(1, (distance - start) / (end - start))); + const pose = project(fraction); + return pose ? { x: pose.x, z: pose.z } : null; + }); +} diff --git a/client/src/components/home/HomePageView.tsx b/client/src/components/home/HomePageView.tsx index 057d69aae..f62eb6231 100644 --- a/client/src/components/home/HomePageView.tsx +++ b/client/src/components/home/HomePageView.tsx @@ -16,6 +16,7 @@ import { PeriodSelector } from "./Stats"; import type { HomePageViewProps } from "./types"; import { Link } from "@tanstack/react-router"; import { getGameRoute } from "@/stores/game"; +import { sessionCleanDrivingScore } from "./dashboard-insights"; export function HomePageView({ gameId, @@ -58,13 +59,14 @@ export function HomePageView({ : null; const latestSessionLoading = latestRecapLoading || sessionsLoading || latestProcessingLoading; const showFinishPlaceholder = !latestSession || /^(race|sprint)/i.test(latestSession.sessionType?.trim() ?? ""); + const cleanDriving = latestRecap?.gameId === "iracing" ? sessionCleanDrivingScore(latestRecap.sparkline) : null; const latestSessionCard = (

{m.recap_latest_session()}

{latestRecap?.personalBest?.isNew && latestRecap.bestLapSec != null && {m.recap_new_pb()}}
- {latestRecap && latestSession && !latestRecapLoading && !latestRecapError ? ( + {latestRecap && latestSession && !latestRecapLoading && !latestRecapError ? (<> + {cleanDriving &&
+
+

{m.home_clean_driving_score()}

+
{cleanDriving.score === null ? "—" : <>{cleanDriving.score}/ 100}
+
+

{m.home_clean_driving_score_description({ clean: cleanDriving.clean, total: cleanDriving.completed })}

+
} + ) : (
{latestSession && } diff --git a/client/src/components/home/dashboard-insights.ts b/client/src/components/home/dashboard-insights.ts index 40f45c5f4..aa5e1bb59 100644 --- a/client/src/components/home/dashboard-insights.ts +++ b/client/src/components/home/dashboard-insights.ts @@ -13,6 +13,18 @@ export interface DashboardInsights { favouriteCar: FavouriteInsight | null; } +/** Recap sparkline excludes pit-cycle laps; untimed rows are not scored. */ +export function sessionCleanDrivingScore(laps: readonly { lapTimeSec: number; isValid: boolean }[]) { + let clean = 0; + let completed = 0; + for (const lap of laps) { + if (!Number.isFinite(lap.lapTimeSec) || lap.lapTimeSec <= 0) continue; + completed++; + if (lap.isValid) clean++; + } + return { clean, completed, score: completed > 0 ? Math.round(clean * 100 / completed) : null }; +} + export function dashboardInsights(response: DashboardResponse): DashboardInsights { let valid = 0; let total = 0; diff --git a/client/src/components/iracing/IRacingIncidentReport.tsx b/client/src/components/iracing/IRacingIncidentReport.tsx new file mode 100644 index 000000000..fe57ce9f3 --- /dev/null +++ b/client/src/components/iracing/IRacingIncidentReport.tsx @@ -0,0 +1,52 @@ +import { Link } from "@tanstack/react-router"; +import { useIRacingIncidentReport } from "@/hooks/session-queries"; +import { m } from "@/paraglide/messages"; +import { Button } from "@/components/ui/button"; +import { formatIncidentTime, incidentEventLabel, incidentEvidenceLabel } from "./incident-format"; + +export function IRacingIncidentReport({ sessionId }: { sessionId: number }) { + const query = useIRacingIncidentReport(sessionId); + const report = query.data; + return ( +
+

{m.iracing_incident_report_heading()}

+ {query.isLoading ?

{m.iracing_incident_report_loading()}

: query.isError ? ( +
+

{m.iracing_incident_report_error()}

+ +
+ ) : report ? <> +
+ {[ + [m.iracing_incident_report_points(), report.observedIncidentPoints === null ? "—" : String(report.observedIncidentPoints)], + [m.iracing_incident_report_spans(), report.offTrackSpans === null ? "—" : String(report.offTrackSpans)], + [m.iracing_incident_report_duration(), formatIncidentTime(report.observedOffTrackSeconds)], + [m.iracing_incident_report_streak(), report.longestCleanLapStreak === null ? "—" : String(report.longestCleanLapStreak)], + ].map(([label, value]) =>
+
{label}
+
{value}
+
)} +
+
+

{m.iracing_incident_report_windows({ included: report.coverage.lapsWithCapture, total: report.coverage.totalLaps })}

+

{m.iracing_incident_report_counter({ status: incidentEvidenceLabel(report.coverage.incidentStatus) })} · {m.iracing_incident_report_surface({ status: incidentEvidenceLabel(report.coverage.surfaceStatus) })}

+

{m.iracing_incident_report_scope()}

+

{m.iracing_incident_report_explanation()}

+
+ {report.coverage.incidentStatus === "unavailable" && report.coverage.surfaceStatus === "unavailable" ?

{m.iracing_incident_report_no_evidence()}

: report.events.length === 0 ?

{m.iracing_incident_report_empty()}

: null} + {report.events.length > 0 &&
    + {report.events.map(event =>
  1. +
    +

    {incidentEventLabel(event)}

    +

    {m.iracing_incident_report_lap_time({ lap: event.lapNumber, time: formatIncidentTime(event.sessionTimeSec) })}

    + {event.kind === "incident-points" &&

    {m.iracing_incident_report_bounds({ previous: formatIncidentTime(event.previousSessionTimeSec), current: formatIncidentTime(event.sessionTimeSec) })}

    } +
    + {event.replay !== null ? + {event.replay.lapTimeSec === null ? m.iracing_incident_report_replay_link() : m.iracing_incident_report_replay({ time: formatIncidentTime(event.replay.lapTimeSec) })} + : {m.iracing_incident_report_no_replay()}} +
  2. )} +
} + : null} +
+ ); +} diff --git a/client/src/components/iracing/IRacingLiveDashboard.tsx b/client/src/components/iracing/IRacingLiveDashboard.tsx new file mode 100644 index 000000000..404e66f1f --- /dev/null +++ b/client/src/components/iracing/IRacingLiveDashboard.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; + +import { m } from "@/paraglide/messages"; +import { useCarName } from "../../hooks/catalog-queries"; +import { useTrackName } from "../../hooks/track-queries"; +import { formatLapTime } from "../../lib/format"; +import { controlInputPercent } from "@raceiq/scene-3d/vehicle-dynamics"; +import { useUnits } from "../../hooks/useUnits"; +import { useTelemetryStore } from "../../stores/telemetry"; +import { selectFreshIRacingLiveView } from "./live-state"; + +import { SectorTimes } from "../SectorTimes"; +import { getGame } from "@raceiq/shared/games/registry"; +import { getFuelAmount } from "@raceiq/shared/games/telemetry"; + +function Metric({ label, value, unit, prominent = false }: { label: string; value: string; unit?: string; prominent?: boolean }) { + return
+
{label}
+
+ {value}{unit && {unit}} +
+
; +} + +function InputMeter({ label, value, color }: { label: string; value: number | undefined; color: string }) { + const pct = value === undefined ? undefined : controlInputPercent(value); + return
+
{label}{pct === undefined ? "—" : `${pct.toFixed(0)}%`}
+
+
; +} + +export function IRacingLiveDashboard() { + const connected = useTelemetryStore((s) => s.connected); + const rawView = useTelemetryStore((s) => s.telemetryView); + const frame = useTelemetryStore((s) => s.telemetryFrame); + const sectors = useTelemetryStore((s) => s.sectors); + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + if (!connected || rawView?.simulator !== "iracing") return; + const timer = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [connected, rawView?.simulator]); + const view = selectFreshIRacingLiveView(connected, rawView, frame?.receivedAtMs ?? null, nowMs); + const units = useUnits("iracing"); + const { data: carResolved } = useCarName(view?.identity.carId); + const { data: trackResolved } = useTrackName(view?.identity.trackId); + const car = carResolved || view?.identity.carId; + const track = trackResolved || view?.identity.trackId; + const fuel = view?.fuel.amount === undefined ? undefined : getFuelAmount(view.fuel.amount, getGame("iracing").telemetry.fuel); + + return
+
+
+

{m.iracing_live_title()}

{view ? m.iracing_live_receiving() : connected ? m.iracing_live_waiting_title() : m.iracing_live_disconnected()}
+

{m.iracing_live_subtitle()}

+
+ {view &&
{car &&
{car}
}{track &&
{track}
}
} +
+ + {!view ?
+

{m.iracing_live_waiting_title()}

+

{m.iracing_live_waiting_description()}

+

{m.iracing_live_position_note()}

+
: <> +
+
+
{m.label_speed()}
{view.motion.speedMps === undefined ? "—" : units.speed(view.motion.speedMps).toFixed(0)}{units.speedLabel}
+
{m.dataguide_gear()}
{view.inputs.gear === undefined ? "—" : view.inputs.gear === 0 ? "R" : view.inputs.gear === 11 ? "N" : view.inputs.gear}
+
+ +
+

{m.iracing_live_inputs()}

+ + + +
{m.analyse_chart_steering()}{view.inputs.steer === undefined ? "—" : `${(view.inputs.steer / 127 * 100).toFixed(0)}%`}
+
+
+ +
+ 0 ? `P${view.timing.racePosition}` : "—"} /> + + + + +
+ {view.timing.lapFraction !== undefined &&
+

{m.iracing_live_lap_progress()}

{(view.timing.lapFraction * 100).toFixed(0)}%
+
+
} + {sectors &&

{m.label_race()}

} + + {fuel &&
} + {(view.motion.acceleration || view.motion.attitude) &&
+

{m.iracing_live_motion()}

+
+ {view.motion.acceleration && <>} + {view.motion.attitude && <>} +
+
} + {view.tires.suspensionTravelM &&
+

{m.iracing_live_suspension()}

+
{(["fl", "fr", "rl", "rr"] as const).map((wheel) => )}
+
} + {(view.weather.airTemperatureC !== undefined || view.weather.trackTemperatureC !== undefined) &&
+ {view.weather.airTemperatureC !== undefined && } + {view.weather.trackTemperatureC !== undefined && } +
} +
+

{m.iracing_live_snapshot_note()}

+

{m.iracing_live_position_note()}

+
+ } +
; +} diff --git a/client/src/components/iracing/incident-format.ts b/client/src/components/iracing/incident-format.ts new file mode 100644 index 000000000..bedd50e2e --- /dev/null +++ b/client/src/components/iracing/incident-format.ts @@ -0,0 +1,15 @@ +import type { IRacingEvidenceStatus, IRacingIncidentEvent } from "@raceiq/shared/racing/sessions/iracing-incidents"; +import { formatLapTime } from "@/lib/format"; +import { m } from "@/paraglide/messages"; + +/** Simulator zero is a real time, not the lap formatter's missing-time sentinel. */ +export function formatIncidentTime(seconds: number | null): string { + return seconds === null ? "—" : seconds === 0 ? "0:00.000" : formatLapTime(seconds); +} +export function incidentEvidenceLabel(status: IRacingEvidenceStatus): string { + return status === "available" ? m.iracing_incident_report_available() : status === "partial" ? m.iracing_incident_report_partial() : m.iracing_incident_report_unavailable(); +} +export function incidentEventLabel(event: IRacingIncidentEvent): string { + if (event.kind === "incident-points") return m.iracing_incident_report_point_event({ points: event.points }); + return [m.iracing_incident_report_span_event(), m.iracing_incident_report_sampled_duration({ duration: formatIncidentTime(event.observedSeconds) }), !event.startObserved ? m.iracing_incident_report_clipped_start() : null, !event.endObserved ? m.iracing_incident_report_clipped_end() : null].filter(Boolean).join(" · "); +} diff --git a/client/src/components/iracing/live-state.ts b/client/src/components/iracing/live-state.ts new file mode 100644 index 000000000..dffa3c217 --- /dev/null +++ b/client/src/components/iracing/live-state.ts @@ -0,0 +1,14 @@ +import type { LiveTelemetryView } from "../../lib/live-telemetry-view"; + +export const IRACING_LIVE_FRAME_MAX_AGE_MS = 2_500; + +export function selectFreshIRacingLiveView( + connected: boolean, + view: LiveTelemetryView | null, + frameReceivedAtMs: number | null, + nowMs: number, +): LiveTelemetryView | null { + if (!connected || view?.simulator !== "iracing" || frameReceivedAtMs === null) return null; + const ageMs = nowMs - frameReceivedAtMs; + return Math.abs(ageMs) <= IRACING_LIVE_FRAME_MAX_AGE_MS ? view : null; +} diff --git a/client/src/components/settings/connection/ConnectionSection.tsx b/client/src/components/settings/connection/ConnectionSection.tsx index 19a1f09ae..de963c6a1 100644 --- a/client/src/components/settings/connection/ConnectionSection.tsx +++ b/client/src/components/settings/connection/ConnectionSection.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { IRacingConnection } from "./IRacingConnection"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useSaveSettings, useSettings } from "@/hooks/settings"; @@ -184,6 +185,7 @@ export function ConnectionSection() {
)}
+ ); } diff --git a/client/src/components/settings/connection/IRacingConnection.tsx b/client/src/components/settings/connection/IRacingConnection.tsx new file mode 100644 index 000000000..02f6ff249 --- /dev/null +++ b/client/src/components/settings/connection/IRacingConnection.tsx @@ -0,0 +1,100 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getAllGames } from "@raceiq/shared/games/registry"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { client } from "@/lib/rpc"; +import { clientReleaseFeatures } from "@/lib/release-features"; +import { m } from "@/paraglide/messages"; +import type { IRacingCaptureStatus } from "../../../../../apps/backend/src/runtime/iracing-capture"; + + +const queryKey = ["iracing-capture-status"]; + +function stateLabel(state: IRacingCaptureStatus["state"]): string { + switch (state) { + case "unavailable": return m.settings_iracing_state_unavailable(); + case "disconnected": return m.settings_iracing_state_disconnected(); + case "waiting-for-iracing": return m.settings_iracing_state_waiting_iracing(); + case "waiting-for-recording": return m.settings_iracing_state_waiting_recording(); + case "recording": return m.settings_iracing_state_recording(); + case "saving": return m.settings_iracing_state_saving(); + case "error": return m.settings_iracing_state_error(); + } +} + +function reasonLabel(reason: NonNullable): string { + switch (reason) { + case "unsupported-platform": return m.settings_iracing_reason_unsupported(); + case "feature-disabled": return m.settings_iracing_reason_feature_disabled(); + case "documents-unavailable": return m.settings_iracing_reason_documents(); + case "sdk-command-failed": return m.settings_iracing_reason_sdk(); + case "archive-failed": return m.settings_iracing_reason_archive(); + } +} + +export function IRacingConnection() { + const queryClient = useQueryClient(); + const enabled = clientReleaseFeatures.iracingAdapter && getAllGames().some((game) => game.id === "iracing"); + const query = useQuery({ + queryKey, + enabled, + queryFn: async () => { + const response = await client.api.iracing.capture.$get(); + if (!response.ok) throw new Error(String(response.status)); + return await response.json(); + }, + refetchInterval: 2000, + }); + const mutation = useMutation({ + mutationFn: async (connected: boolean) => { + const response = await client.api.iracing.capture.$put({ json: { connected } }); + if (!response.ok) throw new Error(String(response.status)); + return await response.json(); + }, + onSuccess: async (status) => { + queryClient.setQueryData(queryKey, status); + await queryClient.invalidateQueries({ queryKey }); + }, + onError: async () => { await queryClient.invalidateQueries({ queryKey }); }, + }); + + if (!enabled) return null; + + const status = query.data; + return ( + + + {m.settings_iracing_title()} + {m.settings_iracing_description()} + + + + {m.settings_iracing_opt_in_title()} + {m.settings_iracing_opt_in_description()} + +
+ {status && {stateLabel(status.state)}} + {status?.connected && {m.settings_iracing_armed()}} +
+ {query.isError && {m.settings_iracing_query_error_title()}{m.settings_iracing_query_error()}} + {mutation.isError && {m.settings_iracing_mutation_error_title()}{m.settings_iracing_mutation_error()}} + {status && !status.supported && {m.settings_iracing_unavailable_title()}{status.reason ? reasonLabel(status.reason) : m.settings_iracing_reason_unsupported()}} + {status?.state === "error" && status.reason && {m.settings_iracing_state_error()}{reasonLabel(status.reason)}} + {status &&
+
{m.settings_iracing_source_directory()}
{status.sourceDirectory ?? m.settings_iracing_not_available()}
+
{m.settings_iracing_archive_directory()}
{status.archiveDirectory}
+
{m.settings_iracing_saved_count()}
{status.savedFiles}
+
{m.settings_iracing_last_file()}
{status.lastSavedFile ?? m.settings_iracing_none()}
+
} +

{m.settings_iracing_local_files_note()}

+
+ + + +
+ ); +} diff --git a/client/src/components/telemetry/GForceCircle.tsx b/client/src/components/telemetry/GForceCircle.tsx index 1dec860cd..f5fb4ff13 100644 --- a/client/src/components/telemetry/GForceCircle.tsx +++ b/client/src/components/telemetry/GForceCircle.tsx @@ -18,8 +18,9 @@ export function GForceCircle({ frame, view }: { frame?: SemanticAnalysisFrame; v const maxG = 2.5; function number(current: SemanticAnalysisFrame | undefined, id: keyof SemanticAnalysisFrame["values"]): number | null { - const value = current?.values[id]; - return typeof value === "number" && Number.isFinite(value) ? value : null; + const value = current?.values[id], state = current?.states[id], freshness = current?.freshness[id]; + return typeof value === "number" && Number.isFinite(value) && (state === undefined || state === "ok") && + (freshness === undefined || freshness === "fresh") ? value : null; } useEffect(() => { diff --git a/client/src/components/telemetry/WheelCard.tsx b/client/src/components/telemetry/WheelCard.tsx index 9a212a096..9ff3ec5fc 100644 --- a/client/src/components/telemetry/WheelCard.tsx +++ b/client/src/components/telemetry/WheelCard.tsx @@ -57,6 +57,7 @@ export function WheelCard({ const coreTemp = temperatureReadings.find(({ kind }) => kind === "core")?.value ?? null; const surfaceBands = temperatureReadings.filter(({ kind }) => kind === "inner" || kind === "middle" || kind === "outer"); const hasCarcassBands = carcassBands.every((value) => value != null); + const hasTemperature = hasCarcassBands || temperatureReadings.some(({ value }) => value != null); const hasProfile = surfaceBands.length > 0; const singleSurface = !hasProfile && surfaceTemp != null && coreTemp == null && carcassTemp == null && !hasCarcassBands; const singleTemperature = singleSurface ? surfaceTemp : !hasProfile && surfaceTemp == null ? carcassTemp : null; @@ -98,7 +99,7 @@ export function WheelCard({ )} - + {(hasProfile || (surfaceTemp != null && !singleSurface)) && [cy - tH / 2, cy + tH / 2 - 6].map((y) => [0, 1, 2].map((index) => { const value = hasProfile ? surfaceBands[index]?.value : surfaceTemp; const x = cx - tW / 2 + index * (tW / 3); diff --git a/client/src/features/session-analyse-host.tsx b/client/src/features/session-analyse-host.tsx index f874e678f..cfd6f85a9 100644 --- a/client/src/features/session-analyse-host.tsx +++ b/client/src/features/session-analyse-host.tsx @@ -14,6 +14,7 @@ import { SectorMap } from "@/components/tunes/SectorMap"; import { bandColor, buildSectorRanges, CORNERS, CornerBars, METRICS, metricDisplayValue, sectorIndexForTelemetryIndex, tuneMetricValue, tuneMetricsFor } from "@/components/tunes/SectorRangeBreakdown"; import { TrackFocusView } from "@/components/tunes/track-focus/TrackFocusView"; import { IssuePill } from "@/components/tunes/review/ReviewIssues"; +import { IRacingIncidentReport } from "@/components/iracing/IRacingIncidentReport"; import { useSessions } from "@/hooks/session-queries"; import { useSessionLaps } from "@/hooks/laps"; import { useAlignedTelemetry } from "@/hooks/aligned-telemetry"; @@ -29,7 +30,7 @@ import { m } from "@/paraglide/messages"; const sessionAnalyseHost: SessionAnalyseHost = { m, Button, Badge, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, - SearchSelect, LapAnalyse, TireGrid, SectorDetailView, SectorMap, TrackFocusView, IssuePill, CornerBars, + SearchSelect, LapAnalyse, IRacingIncidentReport, TireGrid, SectorDetailView, SectorMap, TrackFocusView, IssuePill, CornerBars, CORNERS, METRICS, bandColor, buildSectorRanges, sectorIndexForTelemetryIndex, metricDisplayValue, tuneMetricValue, tuneMetricsFor, useGameId, useSessions, useSessionLaps, useAlignedTelemetry, useLapIssues, useTirePressureOptimal, useUnits, useCarName, useTrackName, useResolveNames, getGameRoute, formatLapTime, diff --git a/client/src/features/sessions-page-host.tsx b/client/src/features/sessions-page-host.tsx index 18b177f55..852027e75 100644 --- a/client/src/features/sessions-page-host.tsx +++ b/client/src/features/sessions-page-host.tsx @@ -1,4 +1,7 @@ import type { ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { IbtImportPreviewModal } from "@/components/analyse/IbtImportPreviewModal"; +import { useAnalyseImports } from "@/components/analyse/useAnalyseImports"; import { SessionsPageProvider } from "@raceiq/sessions-page/ui"; import type { SessionsPageHost } from "@raceiq/sessions-page/contracts"; import { Button } from "@/components/ui/button"; @@ -35,11 +38,15 @@ import { useGameId, useGameRoute } from "@/stores/game"; import { useOpponentLapCatalog } from "@/hooks/opponents"; import { useTelemetryStore } from "@/stores/telemetry"; +function useIbtImport() { + const queryClient = useQueryClient(); + return useAnalyseImports({ queryClient }); +} export const sessionsPageHost: SessionsPageHost = { m, Button, Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, SortableTableHead, AppInput, Toggle, ToggleGroup03, Dialog, DialogContent, DialogHeader, DialogTitle, NoteModal, FavoriteToggleButton, SessionTypeBadge, SessionCleanupDialog, RaceResultLedger, - SessionIdentityDialog, MotecImportModal, OwnershipChoice, ImportSetupFile, useAcEvoCars, + SessionIdentityDialog, MotecImportModal, OwnershipChoice, ImportSetupFile, useAcEvoCars, IbtImportPreviewModal, useIbtImport, useGameId, useGameRoute, useSessions, useLaps, useDeleteLap, useResolveNames, useOpponentLapCatalog, useLiveCaptureLifecycle: (sessionId) => useTelemetryStore((state) => state.liveCaptureLifecycles[sessionId ?? -1] ?? null), diff --git a/client/src/hooks/laps.ts b/client/src/hooks/laps.ts index 33a25ac94..9f4061c7a 100644 --- a/client/src/hooks/laps.ts +++ b/client/src/hooks/laps.ts @@ -243,6 +243,40 @@ export function useLapSemanticTelemetry(lapId: number | null) { }; } +export interface IRacingTrackMapTelemetry { + lapId: number; + trackOrigin: { latitude: number; longitude: number } | null; + samples: Array<{ + latitude: number | null; + longitude: number | null; + fraction: number | null; + }>; + parseError?: string | null; +} + +/** Compact iRacing-only replay view for map overlays; avoids full analysis payload. */ +export function useIRacingTrackMapTelemetry(lapId: number | null) { + const gameId = useGameId(); + return useQuery({ + queryKey: ["iracing-track-map-telemetry", lapId, gameId ?? null], + queryFn: async () => { + if (lapId == null) throw new Error("Missing lap ID"); + const res = await fetch(`/api/laps/${lapId}/semantic-telemetry?view=track-map`, { headers: { "X-Game-Id": "iracing" } }); + const body = (await res.json().catch(() => null)) as (IRacingTrackMapTelemetry & { error?: string }) | null; + if (!res.ok || body?.parseError) { + throw new Error(body?.parseError ?? body?.error ?? res.statusText); + } + if (!body) throw new Error("Unable to replay iRacing map telemetry"); + return body; + }, + enabled: lapId != null && gameId === "iracing", + gcTime: 30 * 60 * 1000, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + }); +} + + /** Findings only; avoids loading full semantic replay in session Analyse. */ export function useLapInsights(lapId: number | null) { const gameId = useGameId(); diff --git a/client/src/hooks/query-keys.ts b/client/src/hooks/query-keys.ts index eab829489..140e001f3 100644 --- a/client/src/hooks/query-keys.ts +++ b/client/src/hooks/query-keys.ts @@ -2,6 +2,7 @@ import type { GameId } from "@raceiq/shared/games/ids"; export const queryKeys = { laps: ["laps"] as const, + iracingIncidentReport: (sessionId: number | null) => ["laps", "iracing-incidents", sessionId] as const, settings: ["settings"] as const, storageSessions: ["storage", "sessions"] as const, recordingDirectoryStatus: ["storage", "recording-directory", "status"] as const, diff --git a/client/src/hooks/session-queries.ts b/client/src/hooks/session-queries.ts index 1c31e0563..19e937323 100644 --- a/client/src/hooks/session-queries.ts +++ b/client/src/hooks/session-queries.ts @@ -2,6 +2,7 @@ import type { RaceResult } from "@raceiq/shared/racing/results/types"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useSyncExternalStore } from "react"; import type { GameId } from "@raceiq/shared/games/ids"; +import type { IRacingIncidentReport } from "@raceiq/shared/racing/sessions/iracing-incidents"; import type { ProcessingMetadata, SessionMeta, SessionRecap } from "@raceiq/shared/racing/sessions/types"; import { client } from "../lib/rpc"; import { rpcJson } from "../lib/rpc-json"; @@ -75,3 +76,14 @@ export function useSessionResult(sessionId: number | null | undefined, gameId: G enabled: enabled && sessionId != null && !!gameId, }); } + +export function useIRacingIncidentReport(sessionId: number | null | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.iracingIncidentReport(sessionId ?? null), + queryFn: async () => { + if (sessionId == null) throw new Error("useIRacingIncidentReport: sessionId is required"); + return rpcJson(await client.api.iracing.sessions[":id"].incidents.$get({ param: { id: String(sessionId) } })); + }, + enabled: enabled && sessionId != null, + }); +} diff --git a/client/src/hooks/track-queries.ts b/client/src/hooks/track-queries.ts index d493dd2db..6a940c8d6 100644 --- a/client/src/hooks/track-queries.ts +++ b/client/src/hooks/track-queries.ts @@ -1,3 +1,4 @@ +import type { TrackProjection } from "@raceiq/scene-3d/wireframe/ScenePlacement"; import { useQuery } from "@tanstack/react-query"; import { resolveLMUTrack } from "@raceiq/game-lmu-metadata/catalog"; import type { GameId } from "@raceiq/shared/games/ids"; @@ -66,9 +67,10 @@ export function useTrackOutline(trackId: string | undefined, gameIdOverride?: Ga return useQuery({ queryKey: [...queryKeys.trackOutline(trackId!), gameId ?? null], queryFn: async () => { - if (!resolvedTrackId || !gameId) return []; + if (!resolvedTrackId || !gameId) return null; const res = await trackIdRoutes["track-outline"][":trackId"].$get({ param: { trackId: encodeURIComponent(resolvedTrackId) }, query: { gameId } }); - return rpcJson<{ points?: { x: number; z: number }[]; labels?: { text: string; x: number; z: number }[]; flipX?: boolean; recorded?: boolean; source?: string; pitLines?: PitLine[]; closed?: boolean } | { x: number; z: number }[]>( + if (res.status === 404) return null; + return rpcJson<{ points?: { x: number; z: number }[]; projection?: TrackProjection | null; ribbonEdges?: { leftEdge: { x: number; z: number }[]; rightEdge: { x: number; z: number }[] } | null; labels?: { text: string; x: number; z: number }[]; flipX?: boolean; recorded?: boolean; source?: string; pitLines?: PitLine[]; closed?: boolean } | { x: number; z: number }[]>( res, ); }, diff --git a/client/src/hooks/useLapPlayback.ts b/client/src/hooks/useLapPlayback.ts new file mode 100644 index 000000000..9d0ca3f5a --- /dev/null +++ b/client/src/hooks/useLapPlayback.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect } from "react"; +import type { ChartsPanelHandle } from "../components/analyse/AnalyseChartsPanel"; +import { type SemanticAnalysisFrame, semanticNumber } from "@raceiq/scene-3d/semantic-frame"; +import type { TrackMapHandle } from "../components/analyse/track-map/types"; +// React-owned gauges publish at display cadence while canvas animation stays smooth. +export const REACT_STATE_INTERVAL_MS = 1000 / 60; + +export function interpolateReplayCursorIndex( + lowerIndex: number, + lowerTime: number, + upperTime: number, + targetTime: number, +): number { + if ( + !Number.isFinite(lowerIndex) || + !Number.isFinite(lowerTime) || + !Number.isFinite(upperTime) || + !Number.isFinite(targetTime) || + upperTime <= lowerTime + ) { + return lowerIndex; + } + return lowerIndex + Math.max(0, Math.min(1, (targetTime - lowerTime) / (upperTime - lowerTime))); +} + +interface UseLapPlaybackOptions { + playing: boolean; + smoothCursor: boolean; + playRef: React.MutableRefObject; + telemetry: SemanticAnalysisFrame[]; + speedRef: React.MutableRefObject; + cursorRef: React.MutableRefObject; + seekRef: React.MutableRefObject; + speedChangeRef: React.MutableRefObject; + lastStateUpdateRef: React.MutableRefObject; + interpolatedTimeRef: React.MutableRefObject; + trackMapRef: React.RefObject; + chartsPanelRef: React.RefObject; + thumbRef: React.RefObject; + progressRef: React.RefObject; + setCursorIdx: (idx: number) => void; + setPlaying: (updater: (p: boolean) => boolean) => void; +} + +export function useLapPlayback({ + playing, + smoothCursor, + telemetry, + playRef, + speedRef, + cursorRef, + seekRef, + speedChangeRef, + lastStateUpdateRef, + interpolatedTimeRef, + trackMapRef, + chartsPanelRef, + thumbRef, + progressRef, + setCursorIdx, + setPlaying, +}: UseLapPlaybackOptions) { + const updateOverlays = useCallback( + (idx: number) => { + trackMapRef.current?.updateCursor(idx); + chartsPanelRef.current?.updateCursor(idx); + const timeFracs = chartsPanelRef.current?.timeFracs; + const maxIndex = Math.max(0, telemetry.length - 1); + const index = Math.max(0, Math.min(maxIndex, idx)); + const lowerIndex = Math.floor(index); + const upperIndex = Math.min(maxIndex, lowerIndex + 1); + const fraction = index - lowerIndex; + const lowerFrac = timeFracs?.[lowerIndex] ?? lowerIndex / Math.max(1, maxIndex); + const upperFrac = timeFracs?.[upperIndex] ?? upperIndex / Math.max(1, maxIndex); + const pct = `${(lowerFrac + (upperFrac - lowerFrac) * fraction) * 100}%`; + if (thumbRef.current) thumbRef.current.style.left = pct; + if (progressRef.current) progressRef.current.style.width = pct; + }, + [telemetry.length, trackMapRef, chartsPanelRef, thumbRef, progressRef], + ); + + // Play/pause animation — uses CurrentLap timer for accurate real-time playback + useEffect(() => { + playRef.current = playing; + if (!playing || telemetry.length < 2) return; + + let rafId: number; + let wallStart = performance.now(); + let gameStart = semanticNumber(telemetry[cursorRef.current], "timing.current-lap") ?? 0; + let lastSpeedChange = speedChangeRef.current; + let lastSeek = seekRef.current; + function step(now: number) { + if (!playRef.current) return; + const idx = cursorRef.current; + if (idx >= telemetry.length - 1) { + const lastIdx = telemetry.length - 1; + cursorRef.current = lastIdx; + updateOverlays(lastIdx); + setCursorIdx(lastIdx); + interpolatedTimeRef.current = semanticNumber(telemetry[lastIdx], "timing.current-lap") ?? 0; + playRef.current = false; + setPlaying(() => false); + return; + } + + if (seekRef.current !== lastSeek) { + lastSeek = seekRef.current; + wallStart = now; + gameStart = semanticNumber(telemetry[idx], "timing.current-lap") ?? 0; + } + if (speedChangeRef.current !== lastSpeedChange) { + lastSpeedChange = speedChangeRef.current; + wallStart = now; + gameStart = semanticNumber(telemetry[idx], "timing.current-lap") ?? 0; + } + + let nextIdx = idx; + const wallElapsed = (now - wallStart) / 1000; + const gameTarget = gameStart + wallElapsed * speedRef.current; + interpolatedTimeRef.current = gameTarget; + while (nextIdx < telemetry.length - 1 && (semanticNumber(telemetry[nextIdx + 1], "timing.current-lap") ?? 0) <= gameTarget) { + nextIdx++; + } + + if (smoothCursor) { + const lowerTime = semanticNumber(telemetry[nextIdx], "timing.current-lap") ?? gameTarget; + const upperTime = semanticNumber(telemetry[nextIdx + 1], "timing.current-lap") ?? lowerTime; + updateOverlays(interpolateReplayCursorIndex(nextIdx, lowerTime, upperTime, gameTarget)); + } + + if (nextIdx !== idx) { + cursorRef.current = nextIdx; + if (!smoothCursor) updateOverlays(nextIdx); + if (now - lastStateUpdateRef.current > REACT_STATE_INTERVAL_MS) { + lastStateUpdateRef.current = now; + setCursorIdx(nextIdx); + } + } + + rafId = requestAnimationFrame(step); + } + rafId = requestAnimationFrame(step); + + return () => cancelAnimationFrame(rafId); + }, [playing, telemetry, updateOverlays, smoothCursor]); + + // Keyboard controls + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (telemetry.length === 0) return; + if (e.key === "ArrowLeft") { + e.preventDefault(); + const next = Math.max(0, cursorRef.current - 1); + cursorRef.current = next; + setCursorIdx(next); + updateOverlays(next); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + const next = Math.min(telemetry.length - 1, cursorRef.current + 1); + cursorRef.current = next; + setCursorIdx(next); + updateOverlays(next); + } else if (e.key === " ") { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + e.preventDefault(); + setPlaying((p) => !p); + } + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [telemetry, cursorRef, setCursorIdx, setPlaying, updateOverlays]); + + return { updateOverlays }; +} diff --git a/client/src/lib/comparison-utils.ts b/client/src/lib/comparison-utils.ts index 7b6008a53..1f0988ad7 100644 --- a/client/src/lib/comparison-utils.ts +++ b/client/src/lib/comparison-utils.ts @@ -52,6 +52,27 @@ function mapPosition(traces: AlignedTrace, index: number, outline: Point[], telX return outline[Math.round(fraction * (outline.length - 1))] ?? null; } +/** Screen-space travel direction; never infer a heading across missing positions. */ +function pathDirection(xs: number[], zs: number[], index: number, telX: (x: number) => number): number | null { + const valid = (i: number) => Number.isFinite(xs[i]) && Number.isFinite(zs[i]) && (xs[i] !== 0 || zs[i] !== 0); + if (!valid(index)) return null; + let before = index; + let after = index; + for (let step = -1; step <= 1; step += 2) { + for (let offset = 1; offset <= 5; offset++) { + const next = index + step * offset; + if (!valid(next)) break; + if (Math.hypot(xs[next] - xs[index], zs[next] - zs[index]) < 0.1) continue; + if (step < 0) before = next; + else after = next; + break; + } + } + const dx = telX(xs[before]) - telX(xs[after]); + const dy = zs[after] - zs[before]; + return Math.hypot(dx, dy) >= 0.1 ? Math.atan2(dy, dx) : null; +} + export function drawTrackCanvas( ctx: CanvasRenderingContext2D, w: number, @@ -66,6 +87,8 @@ export function drawTrackCanvas( telX: (x: number) => number = (x) => x, hideOutline = false, showSegmentMarkers = true, + breakAtInvalidPositions = false, + usePathDirection = false, ) { ctx.clearRect(0, 0, w, h); const bounds = [...outline, ...(boundaries ? [boundaries.leftEdge, boundaries.rightEdge].flat() : [])]; @@ -90,13 +113,15 @@ export function drawTrackCanvas( if (followCar && zoom && hoveredDistance != null) { const index = findTraceIndexAtDistance(traces.distance, hoveredDistance); const x = traces.positionXA[index], - z = traces.positionZA[index], - yaw = traces.yawA[index]; - if (Number.isFinite(x) && Number.isFinite(z) && Number.isFinite(yaw) && (x !== 0 || z !== 0)) { + z = traces.positionZA[index]; + const direction = usePathDirection + ? pathDirection(traces.positionXA, traces.positionZA, index, telX) + : Number.isFinite(traces.yawA[index]) ? Math.PI / 2 - traces.yawA[index] : null; + if (Number.isFinite(x) && Number.isFinite(z) && direction != null && (x !== 0 || z !== 0)) { const [cx, cy] = toCanvas(telX(x), z); ctx.save(); ctx.translate(w / 2, h / 2); - ctx.rotate(Math.PI - yaw); + ctx.rotate(usePathDirection ? -Math.PI / 2 - direction : Math.PI - traces.yawA[index]); ctx.translate(-cx, -cy); restore = true; } @@ -146,7 +171,10 @@ export function drawTrackCanvas( ctx.beginPath(); let moved = false; for (let i = 0; i < xs.length; i++) { - if (!Number.isFinite(xs[i]) || !Number.isFinite(zs[i]) || (xs[i] === 0 && zs[i] === 0)) continue; + if (!Number.isFinite(xs[i]) || !Number.isFinite(zs[i]) || (xs[i] === 0 && zs[i] === 0)) { + if (breakAtInvalidPositions) moved = false; + continue; + } const [x, y] = toCanvas(telX(xs[i]), zs[i]); if (!moved) { ctx.moveTo(x, y); @@ -165,14 +193,26 @@ export function drawTrackCanvas( const index = findTraceIndexAtDistance(traces.distance, hoveredDistance); const a = mapPosition(traces, index, outline, telX); const b = mapPosition({ ...traces, positionXA: traces.positionXB, positionZA: traces.positionZB }, index, outline, telX); - const dot = (p: Point | null, color: string, yaw?: number) => { + const dot = (p: Point | null, color: string, yaw?: number, direction: number | null = null) => { if (!p) return; const [x, y] = toCanvas(p.x, p.z); + if (zoom && usePathDirection && direction != null) { + const dx = Math.cos(direction); + const dy = Math.sin(direction); + ctx.beginPath(); + ctx.moveTo(x - dx * 7 - dy * 6, y - dy * 7 + dx * 6); + ctx.lineTo(x + dx * 10, y + dy * 10); + ctx.lineTo(x - dx * 7 + dy * 6, y - dy * 7 - dx * 6); + ctx.strokeStyle = color; + ctx.lineWidth = 3; + ctx.stroke(); + return; + } ctx.beginPath(); ctx.arc(x, y, zoom ? 7 : 5, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); - if (zoom && yaw != null) { + if (zoom && !usePathDirection && yaw != null) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - Math.sin(yaw) * 22, y + Math.cos(yaw) * 22); @@ -182,8 +222,8 @@ export function drawTrackCanvas( } }; if (zoom) { - dot(a, COLOR_A, traces.yawA[index]); - dot(b, COLOR_B, traces.yawB[index]); + dot(a, COLOR_A, traces.yawA[index], usePathDirection ? pathDirection(traces.positionXA, traces.positionZA, index, telX) : null); + dot(b, COLOR_B, traces.yawB[index], usePathDirection ? pathDirection(traces.positionXB, traces.positionZB, index, telX) : null); } else { dot(a, "var(--app-text)"); } diff --git a/client/src/lib/game-routes.ts b/client/src/lib/game-routes.ts index 926dd41e6..678985d1d 100644 --- a/client/src/lib/game-routes.ts +++ b/client/src/lib/game-routes.ts @@ -55,7 +55,7 @@ export type TuneReviewSearch = { export type GameRouteFeature = "driver" | "experiments" | "raw" | "setups"; -export type LiveDashboard = "forza" | "f1" | "acc" | "lmu"; +export type LiveDashboard = "forza" | "f1" | "acc" | "iracing" | "lmu"; const ROUTE_FEATURES: Record = { driver: ["fm23", "f125", "acc", "ac-evo", "iracing", "ams2"], experiments: ["f125", "acc", "ac-evo"], @@ -82,6 +82,7 @@ export function liveDashboardForGame(gameId: GameId): LiveDashboard { case "ac-evo": return "acc"; case "iracing": + return "iracing"; case "ams2": return "forza"; case "lmu": diff --git a/client/src/routeTree.gen.ts b/client/src/routeTree.gen.ts index d595eda03..6e4664190 100644 --- a/client/src/routeTree.gen.ts +++ b/client/src/routeTree.gen.ts @@ -42,6 +42,7 @@ import { Route as F125TunesRouteImport } from './routes/f125/tunes' import { Route as Fm23IndexRouteImport } from './routes/fm23/index' import { Route as Fm23SetupsRouteImport } from './routes/fm23/setups' import { Route as IracingIndexRouteImport } from './routes/iracing/index' +import { Route as IracingLiveRouteImport } from './routes/iracing/live' import { Route as LiveIndexRouteImport } from './routes/live.index' import { Route as LivePitRouteImport } from './routes/live.pit' import { Route as LmuIndexRouteImport } from './routes/lmu/index' @@ -239,6 +240,11 @@ const IracingIndexRoute = IracingIndexRouteImport.update({ path: '/', getParentRoute: () => IracingRoute, } as any) +const IracingLiveRoute = IracingLiveRouteImport.update({ + id: '/live', + path: '/live', + getParentRoute: () => IracingRoute, +} as any) const LiveIndexRoute = LiveIndexRouteImport.update({ id: '/', path: '/', @@ -428,6 +434,7 @@ export interface FileRoutesByFullPath { '/f125/setups': typeof F125SetupsRouteWithChildren '/f125/tunes': typeof F125TunesRouteWithChildren '/fm23/setups': typeof Fm23SetupsRouteWithChildren + '/iracing/live': typeof IracingLiveRoute '/live/pit': typeof LivePitRoute '/portable/combo-1': typeof PortableCombo1Route '/portable/combo-2': typeof PortableCombo2Route @@ -478,6 +485,7 @@ export interface FileRoutesByTo { '/$gameid/compare': typeof GameidCompareRoute '/$gameid/driver': typeof GameidDriverRoute '/$gameid/raw': typeof GameidRawRoute + '/iracing/live': typeof IracingLiveRoute '/live/pit': typeof LivePitRoute '/portable/combo-1': typeof PortableCombo1Route '/portable/combo-2': typeof PortableCombo2Route @@ -545,6 +553,7 @@ export interface FileRoutesById { '/f125/setups': typeof F125SetupsRouteWithChildren '/f125/tunes': typeof F125TunesRouteWithChildren '/fm23/setups': typeof Fm23SetupsRouteWithChildren + '/iracing/live': typeof IracingLiveRoute '/live/pit': typeof LivePitRoute '/portable/combo-1': typeof PortableCombo1Route '/portable/combo-2': typeof PortableCombo2Route @@ -613,6 +622,7 @@ export interface FileRouteTypes { | '/f125/setups' | '/f125/tunes' | '/fm23/setups' + | '/iracing/live' | '/live/pit' | '/portable/combo-1' | '/portable/combo-2' @@ -663,6 +673,7 @@ export interface FileRouteTypes { | '/$gameid/compare' | '/$gameid/driver' | '/$gameid/raw' + | '/iracing/live' | '/live/pit' | '/portable/combo-1' | '/portable/combo-2' @@ -729,6 +740,7 @@ export interface FileRouteTypes { | '/f125/setups' | '/f125/tunes' | '/fm23/setups' + | '/iracing/live' | '/live/pit' | '/portable/combo-1' | '/portable/combo-2' @@ -1021,6 +1033,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IracingIndexRouteImport parentRoute: typeof IracingRoute } + '/iracing/live': { + id: '/iracing/live' + path: '/live' + fullPath: '/iracing/live' + preLoaderRoute: typeof IracingLiveRouteImport + parentRoute: typeof IracingRoute + } '/live/': { id: '/live/' path: '/' @@ -1458,10 +1477,12 @@ const Fm23RouteChildren: Fm23RouteChildren = { const Fm23RouteWithChildren = Fm23Route._addFileChildren(Fm23RouteChildren) interface IracingRouteChildren { + IracingLiveRoute: typeof IracingLiveRoute IracingIndexRoute: typeof IracingIndexRoute } const IracingRouteChildren: IracingRouteChildren = { + IracingLiveRoute: IracingLiveRoute, IracingIndexRoute: IracingIndexRoute, } diff --git a/client/src/routes/fm23/cars_.$carId.tsx b/client/src/routes/fm23/cars_.$carId.tsx index 795f49198..2cd2ce396 100644 --- a/client/src/routes/fm23/cars_.$carId.tsx +++ b/client/src/routes/fm23/cars_.$carId.tsx @@ -83,7 +83,7 @@ function CarModelPage() {
- +
); diff --git a/client/src/routes/iracing/live.tsx b/client/src/routes/iracing/live.tsx new file mode 100644 index 000000000..bd676becd --- /dev/null +++ b/client/src/routes/iracing/live.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { IRacingLiveDashboard } from "@/components/iracing/IRacingLiveDashboard"; + +export const Route = createFileRoute("/iracing/live")({ + component: IRacingLiveDashboard, +}); diff --git a/client/test/comparison-map-drawing.test.ts b/client/test/comparison-map-drawing.test.ts index 664f5f84d..5fe13ec6a 100644 --- a/client/test/comparison-map-drawing.test.ts +++ b/client/test/comparison-map-drawing.test.ts @@ -5,30 +5,38 @@ import type { AlignedTrace } from "@raceiq/shared/racing/comparison/types"; function makeContext() { const fills: string[] = []; const arcs: Array<[number, number, number]> = []; + const strokes: Array<{ color: string; lineWidth: number; moveCount: number; points: [number, number][] }> = []; + const rotations: number[] = []; + let path: [number, number][] = []; + let moveCount = 0; return { fills, arcs, + strokes, + rotations, ctx: { fillStyle: "", strokeStyle: "", lineWidth: 1, globalAlpha: 1, clearRect() {}, - beginPath() {}, - moveTo() {}, - lineTo() {}, + beginPath() { path = []; moveCount = 0; }, + moveTo(x: number, y: number) { moveCount++; path.push([x, y]); }, + lineTo(x: number, y: number) { path.push([x, y]); }, closePath() {}, arc(x: number, y: number, radius: number) { arcs.push([x, y, radius]); }, - stroke() {}, + stroke(this: { strokeStyle: string; lineWidth: number }) { + strokes.push({ color: this.strokeStyle, lineWidth: this.lineWidth, moveCount, points: [...path] }); + }, fill(this: { fillStyle: string }) { fills.push(this.fillStyle); }, save() {}, restore() {}, translate() {}, - rotate() {}, + rotate(angle: number) { rotations.push(angle); }, } as unknown as CanvasRenderingContext2D, }; } @@ -60,6 +68,26 @@ function makeTraces(): AlignedTrace { elapsedTimeB: [0, 1], }; } + +function drawLapAMoveCount(breakAtInvalidPositions: boolean): number { + const traces = makeTraces(); + traces.distance = [0, 1, 2, 3]; + traces.positionXA = [1, 2, Number.NaN, 4]; + traces.positionZA = [1, 2, Number.NaN, 4]; + traces.positionXB = [1, 2, 3, 4]; + traces.positionZB = [1, 2, 3, 4]; + const { ctx, strokes } = makeContext(); + drawTrackCanvas(ctx, 400, 200, [], traces, null, null, undefined, false, null, undefined, true, false, breakAtInvalidPositions); + return strokes.find(({ color }) => color === "var(--comparison-lap-a)")!.moveCount; +} + +test("iRacing map starts a new path across missing GPS samples", () => { + expect(drawLapAMoveCount(true)).toBe(2); +}); + +test("other game maps keep existing path behavior", () => { + expect(drawLapAMoveCount(false)).toBe(1); +}); test("overview can suppress static segment markers while keeping start marker", () => { const { ctx, fills } = makeContext(); @@ -165,3 +193,63 @@ test("zoomed overlapping lap hover dots share one position", () => { expect(dots).toHaveLength(2); expect(dots[0]?.slice(0, 2)).toEqual(dots[1]?.slice(0, 2)); }); + +function drawDirections(traces: AlignedTrace, follow = false, telX = (x: number) => x, distance = 10) { + const result = makeContext(); + drawTrackCanvas(result.ctx, 400, 300, [], traces, distance, + { centerX: 2, centerZ: 1, range: 20 }, undefined, follow, null, telX, true, false, true, true); + return result; +} + +test("iRacing chevrons follow each lap's path instead of opposing simulator yaw", () => { + const traces = makeTraces(); + traces.distance = [0, 10, 20]; + traces.positionXA = [3, 2, 1]; + traces.positionZA = [1, 1, 1]; + traces.positionXB = [2, 2, 2]; + traces.positionZB = [1, 2, 3]; + traces.yawA = traces.yawB = [Number.NaN, Number.NaN, Number.NaN]; + const { strokes, arcs } = drawDirections(traces); + const a = strokes.filter((s) => s.color === "var(--comparison-lap-a)").at(-1)!; + const b = strokes.filter((s) => s.color === "var(--comparison-lap-b)").at(-1)!; + expect(a.points[1][0]).toBeGreaterThan(a.points[0][0]); + expect(a.points[1][1]).toBeCloseTo((a.points[0][1] + a.points[2][1]) / 2); + expect(b.points[1][1]).toBeGreaterThan(b.points[0][1]); + expect(b.points[1][0]).toBeCloseTo((b.points[0][0] + b.points[2][0]) / 2); + expect(strokes.some((s) => s.color === "var(--app-text)")).toBe(false); + expect(arcs).toEqual([]); +}); + +test("iRacing follow camera points path direction up, including mirrored coordinates", () => { + const traces = makeTraces(); + traces.distance = [0, 10, 20]; + traces.positionXA = [3, 2, 1]; + traces.positionZA = [1, 1, 1]; + expect(drawDirections(traces, true).rotations[0]).toBeCloseTo(-Math.PI / 2); + expect(drawDirections(traces, true, (x) => -x).rotations[0]).toBeCloseTo(-3 * Math.PI / 2); +}); + +test("iRacing stationary or isolated positions retain dots without invented directions", () => { + const traces = makeTraces(); + traces.distance = [0, 10, 20]; + traces.positionXA = [2, 2, 2]; + traces.positionZA = [1, 1, 1]; + traces.positionXB = [Number.NaN, 2, Number.NaN]; + traces.positionZB = [Number.NaN, 1, Number.NaN]; + const { strokes, arcs, rotations } = drawDirections(traces, true); + expect(arcs).toHaveLength(2); + expect(rotations).toEqual([]); + expect(strokes.some((s) => s.color === "var(--app-text)")).toBe(false); +}); + +test("iRacing direction handles repeated samples and one-sided lap endpoints", () => { + const traces = makeTraces(); + traces.distance = [0, 10, 20]; + traces.positionXA = [3, 2, 2]; + traces.positionZA = [1, 1, 1]; + for (const distance of [0, 10, 20]) { + const { strokes } = drawDirections(traces, false, (x) => x, distance); + const a = strokes.filter((s) => s.color === "var(--comparison-lap-a)").at(-1)!; + expect(a.points[1][0]).toBeGreaterThan(a.points[0][0]); + } +}); diff --git a/client/test/dashboard-insights.test.ts b/client/test/dashboard-insights.test.ts index d1ed9fccb..dbd11904e 100644 --- a/client/test/dashboard-insights.test.ts +++ b/client/test/dashboard-insights.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { DashboardResponse } from "@raceiq/shared/racing/sessions/dashboard"; -import { dashboardInsights } from "../src/components/home/dashboard-insights"; +import { dashboardInsights, sessionCleanDrivingScore } from "../src/components/home/dashboard-insights"; import { dashboardPeriodStart } from "../src/hooks/dashboard"; test("dashboard periods use UTC boundaries independent of DST dates", () => { @@ -11,6 +11,26 @@ test("dashboard periods use UTC boundaries independent of DST dates", () => { expect(dashboardPeriodStart(now, "year").toISOString()).toBe("2025-11-01T07:30:00.000Z"); }); +describe("session clean driving score", () => { + test("scores valid timed laps and excludes untimed rows", () => { + expect(sessionCleanDrivingScore([ + { lapTimeSec: 90, isValid: true }, + { lapTimeSec: 91, isValid: true }, + { lapTimeSec: 95, isValid: false }, + { lapTimeSec: 0, isValid: false }, + { lapTimeSec: -1, isValid: true }, + { lapTimeSec: Number.NaN, isValid: false }, + ])).toEqual({ clean: 2, completed: 3, score: 67 }); + }); + + test("distinguishes no timed laps from a fully invalid session", () => { + expect(sessionCleanDrivingScore([])).toEqual({ clean: 0, completed: 0, score: null }); + expect(sessionCleanDrivingScore([{ lapTimeSec: 0, isValid: true }])).toEqual({ clean: 0, completed: 0, score: null }); + expect(sessionCleanDrivingScore([{ lapTimeSec: 90, isValid: false }])).toEqual({ clean: 0, completed: 1, score: 0 }); + expect(sessionCleanDrivingScore([{ lapTimeSec: 90, isValid: true }])).toEqual({ clean: 1, completed: 1, score: 100 }); + }); +}); + const response = { request: { from: "2026-10-01T12:00:00.000Z", to: "2026-10-03T12:00:00.000Z" }, revision: 1, diff --git a/client/test/input-overlay.test.ts b/client/test/input-overlay.test.ts index 9e2a75ffa..414bf20c9 100644 --- a/client/test/input-overlay.test.ts +++ b/client/test/input-overlay.test.ts @@ -2,13 +2,14 @@ import { expect, test } from "bun:test"; import { buildInputOverlayRuns } from "@raceiq/scene-3d/wireframe/InputOverlay" import { buildTrackIndex } from "@raceiq/scene-3d/wireframe-utils" import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; +import { resolveScenePoses, type TrackProjection } from "@raceiq/scene-3d/wireframe/ScenePlacement"; function frame(x: number, z: number, throttle = 0, brake = 0): SemanticAnalysisFrame { - return { values: { "motion.position-x": x, "motion.position-z": z, "inputs.accel": throttle, "inputs.brake": brake } } as SemanticAnalysisFrame; + return { values: { "motion.position-x": x, "motion.position-z": z, "inputs.accel": throttle, "inputs.brake": brake }, states: {}, freshness: {} }; } function build(telemetry: SemanticAnalysisFrame[], yaw = 0): SemanticAnalysisFrame { - return { values: { "motion.position-x": 0, "motion.position-z": 0, "motion.yaw": yaw } } as SemanticAnalysisFrame; + return { values: { "motion.position-x": 0, "motion.position-z": 0, "motion.yaw": yaw }, states: {}, freshness: {} }; } test("indexes nearby input runs and preserves source ids and intensities", () => { @@ -51,3 +52,22 @@ test("splits pedal-off gaps and drops runs shorter than five points", () => { expect(result.throttleRuns[0]?.id).toBe(3); expect(result.throttleRuns[0]?.values).toHaveLength(6); }); + +test("places supported pedal evidence on derived geometry without writing native positions", () => { + const projection: TrackProjection = { + points: [{ x: 100, z: 100 }, { x: 100, z: 200 }, { x: 200, z: 200 }, { x: 200, z: 100 }, { x: 100, z: 100 }], + source: "official-svg", units: "m", start: "start-finish", direction: "forward", + }; + const samples: SemanticAnalysisFrame[] = Array.from({ length: 8 }, (_, index) => ({ + values: { "timing.lap-fraction": index / 200, "inputs.accel": 128 }, + states: { "inputs.accel": "ok" }, freshness: { "inputs.accel": "fresh" }, + })); + const poses = resolveScenePoses("iracing", samples, projection); + const index = buildTrackIndex(poses.map((pose) => ({ x: pose!.x, z: pose!.z }))); + const result = buildInputOverlayRuns(samples, index, samples[0], poses[0]!, poses); + expect(result.throttleRuns[0]?.values).toEqual(Array(8).fill(128)); + expect(result.throttleRuns[0]?.pts[0]).toEqual([0, -0.44, 0.1]); + expect(samples[0].values["motion.position-x"]).toBeUndefined(); + const stale = samples.map((sample) => ({ ...sample, freshness: { "inputs.accel": "stale" } })); + expect(buildInputOverlayRuns(stale, index, stale[0], poses[0]!, poses).throttleRuns).toEqual([]); +}); diff --git a/client/test/iracing-analysis-ui.test.ts b/client/test/iracing-analysis-ui.test.ts index 9ae2f730d..f07ce2d11 100644 --- a/client/test/iracing-analysis-ui.test.ts +++ b/client/test/iracing-analysis-ui.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { initGameAdapters } from "@raceiq/game-catalogs/games/init"; @@ -16,57 +15,6 @@ initGameAdapters({ iracingAdapter: true, f1Experiments: true, ams2Adapter: true const units = { temp: (value: number) => value, tempLabel: "°C", thresholds: { cold: 75, warm: 115, hot: 150 }, toTempC: (value: number) => value } as ReturnType; const frame = (values: Record): SemanticAnalysisFrame => ({ values, states: {}, freshness: {} }); -const iracingFrame = frame({ - "motion.speed": 30, - "motion.acceleration-x": 11.38, - "motion.acceleration-z": -0.1, - "motion.angular-velocity-y": 0.1, - "identity.player-track-surface": 1, - "tires.tire-pressure": [23.5, 23.5, 24.5, 24.5], - "tires.tire-wear": [0.012, 0.012, 0.01, 0.01], - "tire.temperature.surface.representative": [63, 60, 65, 63], - "suspension.suspension-travel-m": [0.039, 0.059, 0.018, 0.046], -}); - -describe("iRacing analysis panels", () => { - test("keeps main rows visible without fabricated unsupported values", () => { - const markup = renderToStaticMarkup(createElement(AnalyseDynamicsPanel, { frame: iracingFrame, gameId: "iracing", units })); - expect(markup).toContain("Understeer"); - expect(markup).toContain("Balance"); - expect(markup).toContain("Grip Ask"); - expect(markup).toContain("Traction"); - expect(markup).toContain("Ratio"); - expect(markup).toContain("Angle"); - expect(markup).toContain("Pit stall"); - expect(markup).not.toContain("200%"); - expect(markup).not.toContain("LOCK"); - }); - test("keeps balance decision render expanded inside tooltip", () => { - const markup = renderToStaticMarkup(createElement(AnalyseDynamicsPanel, { frame: iracingFrame, gameId: "iracing", units })); - expect(markup).toMatch(/ { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - createElement(QueryClientProvider, { client: queryClient }, createElement(AnalyseTireWheelsPanel, { frame: iracingFrame, gameId: "iracing", units, wearRate: { FL: 0, FR: 0, RL: 0, RR: 0 } })), - ); - expect(markup).toContain("Last pit temp"); - expect(markup).toContain("Last pit health"); - expect(markup).toContain("Cold pressure"); - expect(markup).not.toContain("Wear /s"); - }); - - test("keeps compression bias unavailable when normalized source is a placeholder", () => { - const markup = renderToStaticMarkup(createElement(AnalyseSuspensionPanel, { frame: iracingFrame, gameId: "iracing" })); - expect(markup).toContain("39mm"); - expect(markup).toContain("59mm"); - expect(markup).toContain("Compression bias"); - expect(markup.match(/>— { test("keeps projected-path direction through repeated positions and corners", () => { @@ -110,6 +58,15 @@ describe("iRacing analysis semantic direction", () => { expect(result?.[1]).toBeCloseTo(0); }); + test("uses replay path direction when iRacing compass yaw is present without geographic position", () => { + const result = resolveFrameDirection({ + values: { "motion.yaw": 0, "motion.yaw-north": Math.PI / 2 }, + states: { "motion.yaw": "ok", "motion.yaw-north": "ok" }, + freshness: { "motion.yaw": "fresh", "motion.yaw-north": "fresh" }, + }, pathDirection); + expect(result).toEqual(pathDirection); + }); + test("falls back to path tangent for missing, invalid, or stale yaw", () => { for (const frame of [ { values: {}, states: {}, freshness: {} }, diff --git a/client/test/iracing-comparison-map-path.test.ts b/client/test/iracing-comparison-map-path.test.ts new file mode 100644 index 000000000..ebaaad75b --- /dev/null +++ b/client/test/iracing-comparison-map-path.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import type { IRacingTrackMapTelemetry } from "../src/hooks/laps"; +import { buildIRacingMeasuredMapPaths, projectIRacingDistanceToOutline } from "../src/components/comparison/iracing-map-path"; + +function replay(latitudeOffset: number): IRacingTrackMapTelemetry { + return { + lapId: latitudeOffset === 0 ? 1 : 2, + trackOrigin: { latitude: 53, longitude: -2 }, + samples: [0, 0.25, 0.5, 0.75, 1].map((fraction) => ({ + latitude: 53 + latitudeOffset + fraction * 0.001, + longitude: -2 + fraction * 0.001, + fraction, + })), + }; +} + +test("iRacing IBT map paths preserve measured offsets between laps", () => { + const [lapA, lapB] = buildIRacingMeasuredMapPaths([replay(0), replay(0.00001)], [0, 25, 50, 75, 100]); + expect(lapA).not.toBeNull(); + expect(lapB).not.toBeNull(); + expect(lapA).toHaveLength(5); + expect(lapB).toHaveLength(5); + expect(Math.abs(lapA![2]!.z - lapB![2]!.z)).toBeGreaterThan(0.5); +}); + +test("track origin metadata alone does not create a measured iRacing path", () => { + const replay: IRacingTrackMapTelemetry = { + lapId: 1, + trackOrigin: { latitude: 53, longitude: -2 }, + samples: [0, 0.25, 0.5, 0.75, 1].map((fraction) => ({ latitude: null, longitude: null, fraction })), + }; + expect(buildIRacingMeasuredMapPaths([replay], [0, 25, 50, 75, 100])[0]).toBeNull(); +}); + +test("iRacing lap progress projects onto official outline when GPS positions are unavailable", () => { + const outline = [{ x: 0, z: 0 }, { x: 100, z: 0 }, { x: 100, z: 100 }]; + expect(projectIRacingDistanceToOutline([0, 5, 10], outline)).toEqual([ + { x: 0, z: 0 }, + { x: 100, z: 0 }, + { x: 100, z: 100 }, + ]); +}); diff --git a/client/test/iracing-live-state.test.ts b/client/test/iracing-live-state.test.ts new file mode 100644 index 000000000..d687bb26f --- /dev/null +++ b/client/test/iracing-live-state.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { IRACING_LIVE_FRAME_MAX_AGE_MS, selectFreshIRacingLiveView } from "../src/components/iracing/live-state"; +import type { LiveTelemetryView } from "../src/lib/live-telemetry-view"; + +const iracingView = { simulator: "iracing" } as LiveTelemetryView; +const accView = { simulator: "acc" } as LiveTelemetryView; + +describe("iRacing live frame readiness", () => { + test("shows fresh iRacing telemetry without requiring server status", () => { + expect(selectFreshIRacingLiveView(true, iracingView, 10_000, 10_000)).toBe(iracingView); + }); + + test("hides the view when disconnected, from another simulator, or without a frame", () => { + expect(selectFreshIRacingLiveView(false, iracingView, 10_000, 10_000)).toBeNull(); + expect(selectFreshIRacingLiveView(true, accView, 10_000, 10_000)).toBeNull(); + expect(selectFreshIRacingLiveView(true, iracingView, null, 10_000)).toBeNull(); + }); + + test("expires stopped telemetry after the freshness window", () => { + expect(selectFreshIRacingLiveView(true, iracingView, 10_000 + 500, 10_000)).toBe(iracingView); + expect(selectFreshIRacingLiveView(true, iracingView, 10_000, 10_000 + IRACING_LIVE_FRAME_MAX_AGE_MS)).toBe(iracingView); + expect(selectFreshIRacingLiveView(true, iracingView, 10_000, 10_001 + IRACING_LIVE_FRAME_MAX_AGE_MS)).toBeNull(); + }); +}); diff --git a/client/test/lap-playback-timing.test.ts b/client/test/lap-playback-timing.test.ts index 8768f368b..78024b969 100644 --- a/client/test/lap-playback-timing.test.ts +++ b/client/test/lap-playback-timing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { hasTelemetryGap } from "@raceiq/lap-replay/ui" +import { hasTelemetryGap } from "@raceiq/lap-replay/ui"; describe("Replay telemetry gaps", () => { test("does not mark floating-point 10 Hz samples as gaps", () => { @@ -8,4 +8,19 @@ describe("Replay telemetry gaps", () => { ).toBe(false); expect(hasTelemetryGap(0.055, 0.157)).toBe(true); }); + + test("accepts regular sparse samples while preserving missing-sample gaps", () => { + const times = [0, 0.25, 0.5, 0.75, 1.25, 1.5]; + const threshold = telemetryGapThreshold(times); + expect(hasTelemetryGap(times[0], times[1], threshold)).toBe(false); + expect(hasTelemetryGap(times[3], times[4], threshold)).toBe(true); + }); + + test("ignores duplicate and invalid intervals when measuring cadence", () => { + const threshold = telemetryGapThreshold([0, 0, 0.25, 0.5, 0.75, NaN]); + expect(hasTelemetryGap(0, 0.25, threshold)).toBe(false); + expect(hasTelemetryGap(0, 1, threshold)).toBe(true); + expect(telemetryGapThreshold([])).toBe(0.1); + expect(telemetryGapThreshold([0, 1])).toBe(0.1); + }); }); diff --git a/client/test/replay-scene-placement.test.ts b/client/test/replay-scene-placement.test.ts new file mode 100644 index 000000000..0c0388c29 --- /dev/null +++ b/client/test/replay-scene-placement.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { alignProjectionToMeasured, resolveScenePoses, type TrackProjection } from "@raceiq/scene-3d/wireframe/ScenePlacement"; +import { alignOfficialReferenceToPositions, resolveTrackPositions } from "../src/components/analyse/track-map/path"; +import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; +import { buildTrackIndex, filterByDistanceIndexed } from "@raceiq/scene-3d/wireframe-utils"; + +const projection: TrackProjection = { + points: [[100, 100, 0, 1], [100, 200, 1, 0], [200, 200, 0, -1], [200, 100, -1, 0]] + .flatMap(([x, z, dx, dz]) => Array.from({ length: 20 }, (_, index) => ({ x: x + dx * index * 5, z: z + dz * index * 5 }))) + .concat({ x: 100, z: 100 }), + source: "official-svg", units: "m", start: "start-finish", direction: "forward", +}; +const frame = (values: Record, states: Record = {}, freshness: Record = {}): SemanticAnalysisFrame => ({ values, states, freshness }); + +describe("replay scene placement", () => { + test("projects iRacing lap distance separately from unsupported native coordinates", () => { + const sample = frame({ "timing.lap-fraction": 0.125, "motion.yaw": -2 }); + const pose = resolveScenePoses("iracing", [sample], projection)[0]!; + expect(pose).toEqual({ x: 100, z: 150, yaw: 0, source: "lap-fraction" }); + expect(sample.values["motion.position-x"]).toBeUndefined(); + expect(sample.values["motion.yaw"]).toBe(-2); + const geometry = filterByDistanceIndexed(buildTrackIndex(projection.points), pose.x, pose.z, pose.yaw, -0.44); + expect(geometry.some(({ points }) => points.some(([x, , z]) => Math.abs(x) < 1 && Math.abs(z) < 1))).toBe(true); + expect(geometry.flatMap(({ points }) => points.flat()).every(Number.isFinite)).toBe(true); + }); + + test("start/finish is continuous across lap wrap without reversed heading", () => { + const poses = resolveScenePoses("iracing", [0.999, 1, 0, 0.001].map((fraction) => frame({ "timing.lap-fraction": fraction })), projection); + expect(poses[1]!.x).toBe(poses[2]!.x); + expect(poses[1]!.z).toBe(poses[2]!.z); + expect(Math.hypot(poses[0]!.x - poses[3]!.x, poses[0]!.z - poses[3]!.z)).toBeLessThan(1); + expect(poses[3]!.yaw).toBe(0); + }); + + test("missing, invalid, stale distance and incomplete native position remain unavailable", () => { + const samples = [frame({}), ...[-1, 1.1, Number.NaN, Number.POSITIVE_INFINITY].map((fraction) => frame({ "timing.lap-fraction": fraction })), + frame({ "timing.lap-fraction": 0.5 }, { "timing.lap-fraction": "unavailable" }), + frame({ "timing.lap-fraction": 0.5 }, {}, { "timing.lap-fraction": "stale" }), + frame({ "motion.position-x": 20, "motion.yaw": 0 })]; + expect(resolveScenePoses("iracing", samples, projection)).toEqual(samples.map(() => null)); + expect(resolveScenePoses("iracing", [frame({ "timing.lap-fraction": 0.5 })], null)).toEqual([null]); + expect(resolveScenePoses("iracing", [frame({ "timing.lap-fraction": 0.5 })], { ...projection, points: [{ x: NaN, z: 1 }, { x: 2, z: 3 }] })).toEqual([null]); + }); + + test("native AC EVO positions and heading win without changing coordinate conventions", () => { + const sample = frame({ "motion.position-x": -140, "motion.position-z": 50, "motion.yaw": 0.4, "timing.lap-fraction": 0.5 }); + expect(resolveScenePoses("ac-evo", [sample], projection)[0]).toEqual({ x: -140, z: 50, yaw: 0.4, source: "native" }); + expect(resolveScenePoses("iracing", [sample], projection)[0]!.source).toBe("native"); + expect(resolveScenePoses("ac-evo", [frame({ "timing.lap-fraction": 0.5 })], projection)).toEqual([null]); + }); + test("uses fresh geographic samples, compass heading, and measured altitude", () => { + const poses = resolveScenePoses("iracing", [ + frame({ "motion.latitude": 0, "motion.longitude": 0, "motion.yaw-north": 0 }), + frame({ "motion.latitude": 53, "motion.longitude": -2, "motion.altitude": 100, "motion.yaw-north": Math.PI / 2 }), + frame({ "motion.latitude": 53.0001, "motion.longitude": -2, "motion.altitude": 104, "motion.yaw-north": 0 }, {}, { "motion.latitude": "stale" }), + frame({ "motion.latitude": 53.0001, "motion.longitude": -2, "motion.altitude": 104, "motion.yaw-north": 0 }), + ], projection); + expect(poses[0]).toBeNull(); + expect(poses[1]).toMatchObject({ x: 0, z: 0, y: 0, yaw: Math.PI / 2, source: "measured" }); + expect(poses[2]).toBeNull(); + expect(poses[3]).toMatchObject({ x: 0, z: expect.closeTo(11.1, 0), y: 4, yaw: 0, source: "measured" }); + }); + + test("keeps measured iRacing positions when compass heading is absent", () => { + const poses = resolveScenePoses("iracing", [ + frame({ "motion.latitude": 53, "motion.longitude": -2 }), + frame({ "motion.latitude": 53.0001, "motion.longitude": -2 }), + frame({ "motion.latitude": 53.0001, "motion.longitude": -1.9999 }), + ], projection); + expect(poses[0]).toMatchObject({ x: 0, z: 0, yaw: 0, source: "measured" }); + expect(poses[1]).toMatchObject({ z: expect.closeTo(11.1, 0), yaw: expect.closeTo(Math.PI / 2, 3), source: "measured" }); + expect(poses[2]).toMatchObject({ x: expect.closeTo(6.7, 0), yaw: expect.closeTo(Math.PI / 2, 3), source: "measured" }); + }); + test("2D geographic path uses track origin and preserves missing-sample gaps", () => { + const positions = resolveTrackPositions([ + frame({ "identity.track.latitude": 53, "identity.track.longitude": -2, "motion.latitude": 53, "motion.longitude": -2 }), + frame({ "identity.track.latitude": 53, "identity.track.longitude": -2 }), + frame({ "identity.track.latitude": 53, "identity.track.longitude": -2, "motion.latitude": 53.0001, "motion.longitude": -2 }), + ], projection.points, "iracing"); + expect(positions[0].x).toBe(0); + expect(positions[0].z).toBe(0); + expect(Number.isNaN(positions[1].x)).toBe(true); + expect(positions[2].z).toBeCloseTo(11.1, 0); + const elevationPath = buildTrackIndex([{ x: 0, z: 0, y: 0 }, { x: 0, z: 10, y: 4 }]); + const ribbon = filterByDistanceIndexed(elevationPath, 0, 10, 0, -0.42, 60, undefined, undefined, 4); + expect(ribbon[0].points[0][1]).toBeCloseTo(-4.42); + expect(ribbon[0].points[1][1]).toBeCloseTo(-0.42); + }); + test("aligns only official reference geometry into measured frame", () => { + const measured = projection.points.map((point) => ({ ...point, x: point.x + 500, z: point.z - 200, yaw: 0, source: "measured" as const })); + const aligned = alignProjectionToMeasured(projection, measured)!; + expect(aligned.points[0].x).toBeCloseTo(projection.points[0].x + 500); + expect(aligned.points[0].z).toBeCloseTo(projection.points[0].z - 200); + expect(measured[0].x).toBe(projection.points[0].x + 500); + }); + test("track geographic metadata alone does not replace live-only lap-fraction placement", () => { + const sample = frame({ + "identity.track.latitude": 53.180009, + "identity.track.longitude": -2.612779, + "identity.track.altitude": 69.47, + "timing.lap-fraction": 0.125, + }); + expect(resolveTrackPositions([sample], projection.points, "iracing")).toEqual([{ x: 100, z: 150 }]); + expect(resolveScenePoses("iracing", [sample], projection)[0]).toMatchObject({ + x: 100, z: 150, source: "lap-fraction", + }); + }); + test("keeps pit lanes and map labels in the same measured frame as the official outline", () => { + const positions = projection.points.map((point) => ({ x: point.x + 500, z: point.z - 200 })); + const reference = { + outline: projection.points, + boundaries: null, + mapLabels: [{ x: 105, z: 105, text: "Pit entry" }], + pitLines: [{ kind: "pit-road" as const, points: [{ x: 105, z: 105 }, { x: 105, z: 130 }] }], + }; + const aligned = alignOfficialReferenceToPositions(reference, positions); + expect(aligned.mapLabels![0].x).toBeCloseTo(605); + expect(aligned.mapLabels![0].z).toBeCloseTo(-95); + expect(aligned.pitLines![0].points[1].x).toBeCloseTo(605); + expect(aligned.pitLines![0].points[1].z).toBeCloseTo(-70); + expect(reference.pitLines[0].points[0]).toEqual({ x: 105, z: 105 }); + const unavailable = alignOfficialReferenceToPositions(reference, [{ x: NaN, z: NaN }]); + expect(unavailable.outline).toBeNull(); + expect(unavailable.pitLines).toBeNull(); + }); +}); diff --git a/client/test/scene-track-boundaries.test.ts b/client/test/scene-track-boundaries.test.ts new file mode 100644 index 000000000..ff3caf8ea --- /dev/null +++ b/client/test/scene-track-boundaries.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import * as THREE from "three/webgpu"; +import { initGameAdapters } from "@raceiq/game-catalogs/games/init"; +import { SceneOverlays } from "../src/components/wireframe/SceneOverlays"; +import type { SceneRuntimeConfig } from "../src/components/wireframe/SceneRuntime"; +import { DEFAULT_TOGGLES } from "../src/lib/wireframe-data"; + +initGameAdapters({ f1Experiments: false, iracingAdapter: true }); + +const sample = { values: { "suspension.norm-suspension-travel": [0.5, 0.5, 0.5, 0.5] }, states: {}, freshness: {} }; +const leftEdge = Array.from({ length: 61 }, (_, index) => ({ x: 95, z: 80 + index })); +const rightEdge = leftEdge.map(({ z }) => ({ x: 105, z })); +const config: SceneRuntimeConfig = { + gameId: "iracing", frame: sample, telemetry: [sample], cursorIdx: 0, outline: null, + poses: [{ x: 100, z: 100, yaw: 0, source: "lap-fraction" }], + boundaries: { leftEdge, rightEdge }, boundarySpace: "lap-fraction", + toggles: { ...DEFAULT_TOGGLES, track: true, grid: false, inputs: false, trails: false, wheelInfo: false, springs: false, drivetrain: false, dimensions: false }, + viewPreset: "3/4", carModel: { modelPath: "", halfWheelbase: 1.35, halfFrontTrack: 0.93, halfRearTrack: 0.91, bodyLength: 4.5, tireRadius: 0.33, hasModel: false }, + modelOffsetX: 0, hideModelWheels: false, autoOrbit: false, fpsCap: 60, fmtTemp: String, + suspThresholds: [25, 65, 85], temperatureThresholds: { cold: 70, warm: 100, hot: 120 }, temperatureSemanticId: "tire.temperature.carcass.middle", +}; + +function renderedWalls(scene: THREE.Scene): THREE.Mesh[] { + return scene.children.filter((object): object is THREE.Mesh => object instanceof THREE.Mesh && object.visible && Number.isFinite(object.geometry.drawRange.count) && object.geometry.drawRange.count > 0); +} + +test("draws both iRacing edges around a derived pose without native position telemetry", () => { + const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(); + const overlays = new SceneOverlays(scene, camera); + try { + overlays.update(sample, [sample], 0, config, null, 0, 0, 0, 600); + const walls = renderedWalls(scene); + expect(walls).toHaveLength(2); + const sides = walls.map((wall) => { + const index = wall.geometry.index!; + const positions = wall.geometry.getAttribute("position"); + const used = new Set(Array.from(index.array).slice(0, wall.geometry.drawRange.count)); + for (const vertex of used) { + expect(Number.isFinite(positions.getX(vertex))).toBe(true); + expect(Number.isFinite(positions.getY(vertex))).toBe(true); + expect(Number.isFinite(positions.getZ(vertex))).toBe(true); + } + return positions.getZ(index.getX(0)); + }); + expect(sides.sort((a, b) => a - b)).toEqual([-5, 5]); + overlays.update(sample, [sample], 0, { ...config, boundarySpace: "native" }, null, 1, 0, 0, 600); + expect(renderedWalls(scene)).toEqual([]); + overlays.update(sample, [sample], 0, { ...config, poses: [null] }, null, 2, 0, 0, 600); + expect(renderedWalls(scene)).toEqual([]); + } finally { overlays.dispose(); } +}); + +test("keeps AC EVO native positioning and boundary rendering", () => { + const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(); + const overlays = new SceneOverlays(scene, camera); + try { + overlays.update(sample, [sample], 0, { ...config, gameId: "ac-evo", boundarySpace: "native", poses: [{ x: 100, z: 100, yaw: 0, source: "native" }] }, null, 0, 0, 0, 600); + expect(renderedWalls(scene)).toHaveLength(2); + } finally { overlays.dispose(); } +}); diff --git a/client/test/static-track-drawing.test.ts b/client/test/static-track-drawing.test.ts index de0ca93c6..3247e302c 100644 --- a/client/test/static-track-drawing.test.ts +++ b/client/test/static-track-drawing.test.ts @@ -1,9 +1,19 @@ -import { expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { drawStaticTrack } from "../src/components/analyse/track-map/static-drawing"; import { needsTrackFlip } from "@raceiq/shared/racing/tracks/coords"; import { initGameAdapters } from "@raceiq/game-catalogs/games/init"; import type { Point, TrackMapBoundaries, TrackTransform } from "../src/components/analyse/track-map/types"; -import { resolveTrackPositions } from "../src/components/analyse/track-map/path"; +import { interpolateReplayPosition, resolveTrackPositions } from "../src/components/analyse/track-map/path"; + +let previousPath2D: PropertyDescriptor | undefined; +beforeEach(() => { + previousPath2D = Object.getOwnPropertyDescriptor(globalThis, "Path2D"); + Object.defineProperty(globalThis, "Path2D", { configurable: true, value: RecordedPath }); +}); +afterEach(() => { + if (previousPath2D) Object.defineProperty(globalThis, "Path2D", previousPath2D); + else Reflect.deleteProperty(globalThis, "Path2D"); +}); initGameAdapters(); test("returns no transform when replay has no drawable track points", () => { @@ -15,12 +25,12 @@ test("returns no transform when replay has no drawable track points", () => { height: 0, style: {}, getBoundingClientRect: () => ({ width: 800, height: 600 }), + getContext: () => null, } as unknown as HTMLCanvasElement; expect( drawStaticTrack({ canvas, - bufferCanvas: null, telemetry: [], resolvedPositions: [], outline: null, @@ -30,8 +40,8 @@ test("returns no transform when replay has no drawable track points", () => { showTrace: false, rotateWithCar: false, zoom: 1, - }), - ).toEqual({ bufferCanvas: null, transform: null }); + }).transform, + ).toBeNull(); } finally { Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); } @@ -77,67 +87,30 @@ test("prefers recorded world coordinates over lap-fraction projection", () => { ]); }); -test("draws throttle input traces in the throttle channel color", () => { - const previousWindow = globalThis.window; - Object.defineProperty(globalThis, "window", { configurable: true, value: { devicePixelRatio: 1 } }); - try { - const strokes: Array<{ color: string; alpha: number; width: number }> = []; - const context = { - strokeStyle: "", - globalAlpha: 1, - lineWidth: 1, - lineCap: "butt", - lineJoin: "miter", - setTransform() {}, - clearRect() {}, - beginPath() {}, - moveTo() {}, - lineTo() {}, - stroke() { - strokes.push({ color: this.strokeStyle, alpha: this.globalAlpha, width: this.lineWidth }); - }, - save() {}, - restore() {}, - drawImage() {}, - } as unknown as CanvasRenderingContext2D; - const canvas = { - width: 0, - height: 0, - style: {}, - getBoundingClientRect: () => ({ width: 800, height: 600 }), - getContext: () => context, - } as unknown as HTMLCanvasElement; - const frame = (accel: number) => ({ values: { "inputs.accel": accel, "inputs.brake": 0 }, states: {}, freshness: {} }); - - drawStaticTrack({ - canvas, - bufferCanvas: canvas, - telemetry: [frame(64), frame(128), frame(255)], - resolvedPositions: [ - { x: 1, z: 1 }, - { x: 2, z: 2 }, - { x: 3, z: 3 }, - ], - outline: null, - boundaries: null, - sectors: null, - segments: null, - showInputs: true, - showTrace: true, - rotateWithCar: false, - zoom: 1, - }); +test("interpolates replay map position between telemetry samples", () => { + const positions = [{ x: 0, z: 0 }, { x: 10, z: 20 }, { x: 20, z: 30 }]; + expect(interpolateReplayPosition(positions, 0.25)).toEqual({ x: 2.5, z: 5 }); + expect(interpolateReplayPosition(positions, -1)).toEqual(positions[0]); + expect(interpolateReplayPosition(positions, 10)).toEqual(positions[2]); +}); - expect(strokes.filter((stroke) => stroke.color === "var(--ch-throttle)")).toEqual([ - { color: "var(--ch-throttle)", alpha: 0.35 + (128 / 255) * 0.65, width: 2 }, - { color: "var(--ch-throttle)", alpha: 1, width: 2 }, - ]); - } finally { - Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); - } +test("holds last valid map position rather than interpolating across missing coordinates", () => { + expect(interpolateReplayPosition([{ x: 2, z: 4 }, { x: Number.NaN, z: 1 }], 0.5)).toEqual({ x: 2, z: 4 }); + expect(interpolateReplayPosition([{ x: Number.NaN, z: 0 }], 0)).toBeNull(); }); -type PathCommand = { kind: "moveTo" | "lineTo" | "arc"; values: number[] } | { kind: "closePath"; values: [] }; + +type PathCommand = { kind: "moveTo" | "lineTo" | "arc" | "roundRect"; values: number[] } | { kind: "closePath"; values: [] }; + +/** Test-only path sink: inspect computed geometry, not native browser rasterization. */ +class RecordedPath { + readonly commands: PathCommand[] = []; + moveTo(x: number, y: number) { this.commands.push({ kind: "moveTo", values: [x, y] }); } + lineTo(x: number, y: number) { this.commands.push({ kind: "lineTo", values: [x, y] }); } + closePath() { this.commands.push({ kind: "closePath", values: [] }); } + arc(x: number, y: number, radius: number, start: number, end: number) { this.commands.push({ kind: "arc", values: [x, y, radius, start, end] }); } + roundRect(x: number, y: number, width: number, height: number) { this.commands.push({ kind: "roundRect", values: [x, y, width, height] }); } +} interface StrokeRecord { color: string; @@ -190,6 +163,9 @@ function createDrawingHarness(): { canvas: HTMLCanvasElement; strokes: StrokeRec lineJoin: "miter" as CanvasLineJoin, setTransform() {}, clearRect() {}, + scale() {}, + setLineDash() {}, + measureText(text: string) { return { width: text.length * 8 }; }, beginPath() { commands = []; }, @@ -205,17 +181,17 @@ function createDrawingHarness(): { canvas: HTMLCanvasElement; strokes: StrokeRec arc(x: number, y: number, radius: number, startAngle: number, endAngle: number) { commands.push({ kind: "arc", values: [x, y, radius, startAngle, endAngle] }); }, - fill() { - fills.push({ color: String(this.fillStyle), commands: commands.map((command) => ({ ...command, values: [...command.values] }) as PathCommand) }); + fill(path?: RecordedPath) { + fills.push({ color: String(this.fillStyle), commands: [...(path?.commands ?? commands)] }); }, - stroke() { + stroke(path?: RecordedPath) { strokes.push({ color: String(this.strokeStyle), alpha: this.globalAlpha, width: this.lineWidth, lineCap: this.lineCap, lineJoin: this.lineJoin, - commands: commands.map((command) => ({ ...command, values: [...command.values] }) as PathCommand), + commands: [...(path?.commands ?? commands)], }); }, save() {}, @@ -239,7 +215,6 @@ test("marks open route endpoints with Start and Finish", () => { const { canvas, fills, texts } = createDrawingHarness(); const result = drawStaticTrack({ canvas, - bufferCanvas: canvas, telemetry: [], gameId: "ac-evo", resolvedPositions: [], @@ -256,7 +231,6 @@ test("marks open route endpoints with Start and Finish", () => { expect(markers).toHaveLength(2); expect(markers[0]!.color).not.toBe(markers[1]!.color); expect(markers[0]!.commands[0]!.values.slice(0, 2)).not.toEqual(markers[1]!.commands[0]!.values.slice(0, 2)); - expect(texts).toEqual(["Start", "Finish"]); expect(result.transform).not.toBeNull(); } finally { Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); @@ -267,7 +241,6 @@ function drawRaceLineCase(boundaries: TrackMapBoundaries | null, showRaceLine: b const harness = createDrawingHarness(); const result = drawStaticTrack({ canvas: harness.canvas, - bufferCanvas: harness.canvas, telemetry: [], gameId, resolvedPositions: [], @@ -288,6 +261,13 @@ test("keeps telemetry traces open while closing reference outlines", () => { const previousWindow = globalThis.window; Object.defineProperty(globalThis, "window", { configurable: true, value: { devicePixelRatio: 1 } }); try { + const closesPath = (stroke: StrokeRecord) => { + const first = stroke.commands.find((command) => command.kind === "moveTo"); + const last = stroke.commands.at(-1); + return last?.kind === "closePath" || ( + last?.kind === "lineTo" && first?.values[0] === last.values[0] && first?.values[1] === last.values[1] + ); + }; const resolvedPositions = [ { x: 1, z: 1 }, { x: 2, z: 2 }, @@ -297,7 +277,6 @@ test("keeps telemetry traces open while closing reference outlines", () => { const trace = createDrawingHarness(); drawStaticTrack({ canvas: trace.canvas, - bufferCanvas: trace.canvas, telemetry: [], gameId: "acc", resolvedPositions, @@ -310,13 +289,11 @@ test("keeps telemetry traces open while closing reference outlines", () => { zoom: 1, }); const traceStroke = trace.strokes.find((stroke) => stroke.color === "var(--track-outline)")!; - expect(traceStroke.commands.filter((command) => command.kind === "moveTo")).toHaveLength(1); - expect(traceStroke.commands.filter((command) => command.kind === "lineTo")).toHaveLength(resolvedPositions.length - 1); + expect(closesPath(traceStroke)).toBe(false); const reference = createDrawingHarness(); drawStaticTrack({ canvas: reference.canvas, - bufferCanvas: reference.canvas, telemetry: [], gameId: "acc", resolvedPositions: [], @@ -329,7 +306,7 @@ test("keeps telemetry traces open while closing reference outlines", () => { zoom: 1, }); const referenceStroke = reference.strokes.find((stroke) => stroke.color === "var(--track-outline)")!; - expect(referenceStroke.commands.filter((command) => command.kind === "lineTo")).toHaveLength(outlineFixture.length); + expect(closesPath(referenceStroke)).toBe(true); } finally { Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); } @@ -343,7 +320,6 @@ test("draws input, segment, and racing-line overlays together", () => { const frame = (accel: number) => ({ values: { "inputs.accel": accel, "inputs.brake": 0 }, states: {}, freshness: {} }); drawStaticTrack({ canvas, - bufferCanvas: canvas, telemetry: [frame(64), frame(128), frame(255)], resolvedPositions: [ { x: 1, z: 1 }, @@ -386,7 +362,6 @@ test("places segment overlays on canonical centerline instead of replay trace", ]; const result = drawStaticTrack({ canvas, - bufferCanvas: canvas, telemetry: [], resolvedPositions: [], outline: [ @@ -420,7 +395,6 @@ test("places sector boundary markers on canonical centerline instead of replay t const centerLine = Array.from({ length: 11 }, (_, z) => ({ x: 0, z })); const result = drawStaticTrack({ canvas, - bufferCanvas: canvas, telemetry: [], resolvedPositions: [], outline: Array.from({ length: 11 }, (_, x) => ({ x, z: 10 })), @@ -500,18 +474,11 @@ for (const gameId of ["acc", "ac-evo"] as const) { const { strokes, transform } = drawRaceLineCase(boundaryFixture(raceLine), true, gameId); expect(transform).not.toBeNull(); const racingLineStroke = strokes.find((stroke) => stroke.color === "var(--track-racing-line)"); - expect(racingLineStroke).toEqual({ - color: "var(--track-racing-line)", - alpha: 1, - width: 2.5, - lineCap: "round", - lineJoin: "round", - commands: [ - { kind: "moveTo", values: projectRaceLinePoint(raceLine[0], transform!, gameId) }, - ...raceLine.slice(1).map((point) => ({ kind: "lineTo" as const, values: projectRaceLinePoint(point, transform!, gameId) })), - { kind: "closePath", values: [] }, - ], - }); + expect(racingLineStroke?.commands).toEqual([ + { kind: "moveTo", values: projectRaceLinePoint(raceLine[0], transform!, gameId) }, + ...raceLine.slice(1).map((point) => ({ kind: "lineTo" as const, values: projectRaceLinePoint(point, transform!, gameId) })), + { kind: "closePath", values: [] }, + ]); } finally { Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); } diff --git a/client/test/telemetry-overlay-evidence.test.tsx b/client/test/telemetry-overlay-evidence.test.tsx new file mode 100644 index 000000000..c51a3138a --- /dev/null +++ b/client/test/telemetry-overlay-evidence.test.tsx @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { BodyAttitude } from "../src/components/BodyAttitude"; +import { GForceCircle } from "../src/components/telemetry/GForceCircle"; +import type { SemanticAnalysisFrame } from "../src/components/analyse/track-map/types"; + +const frame = (values: Record, states: Record = {}, freshness: Record = {}): SemanticAnalysisFrame => ({ values, states, freshness }); +const spanValues = (markup: string) => [...markup.matchAll(/]*>(.*?)<\/span>/g)].map((match) => match[1].trim()); + +test("G-force labels show supported acceleration and do not turn invalid channels into zero", () => { + const markup = renderToStaticMarkup(); + expect(spanValues(markup)).toEqual(["-1.0", "—"]); +}); + +test("stale and unavailable acceleration evidence is not displayed as measured G", () => { + const sample = frame({ "motion.acceleration-x": 9.81, "motion.acceleration-z": 9.81 }, { "motion.acceleration-z": "missing" }, { "motion.acceleration-x": "stale" }); + expect(spanValues(renderToStaticMarkup())).toEqual(["—", "—"]); +}); + +test("attitude labels keep missing or stale channels unavailable without invalid geometry", () => { + const sample = frame({ "motion.roll": NaN, "motion.pitch": Infinity, "motion.yaw": 1 }, {}, { "motion.yaw": "stale" }); + const markup = renderToStaticMarkup(); + const labels = [...markup.matchAll(/]*>(.*?)<\/text>/g)].map((match) => match[1]); + expect(labels[0]).toContain("—"); + expect(labels[1]).toContain("—"); + expect(labels.join(" ")).not.toContain("0°"); + expect(markup).not.toMatch(/NaN|Infinity/); +}); diff --git a/client/test/wireframe-telemetry.test.ts b/client/test/wireframe-telemetry.test.ts new file mode 100644 index 000000000..75c4a8eec --- /dev/null +++ b/client/test/wireframe-telemetry.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { wheelLabelRows, type WheelLabelConfig } from "@raceiq/scene-3d/wireframe/WheelLabels"; +import { readSceneWheelValue, sceneTemperatureReadings } from "@raceiq/scene-3d/wireframe/SceneOverlays"; +const config = (overrides: Partial = {}): WheelLabelConfig => ({ + temperatureReadings: [], carcassBands: [null, null, null], fmtTemp: (value) => `${value}°`, + temperatureThresholds: { cold: 60, warm: 85, hot: 100 }, + temperatureLabels: { inner: "Inner", middle: "Middle", outer: "Outer", core: "Core", carcass: "Carcass", surface: "Surface" }, + pitTemperatureLabel: "Last pit temp", carcassLabel: "Carcass", pitHealthLabel: "Last pit health", coldPressureLabel: "Cold pressure", + pressureLabel: "Pressure", brakeLabel: "Brake", wearRateLabel: "Wear /s", snapshotStaleLabel: "Snapshot stale", + wear: null, wearRate: 0, brakeTemp: 0, pressurePsi: null, coldPressure: false, side: "left", isRear: false, + ...overrides, +}); + +describe("wireframe wheel telemetry details", () => { + test("shows supported wear and cold setup pressure without requiring temperatures", () => { + const rows = wheelLabelRows(config({ wear: 0.2, pressurePsi: 25.4, coldPressure: true })); + expect(rows[0]?.values[0]?.text).toBe("80%"); + expect(rows.some((row) => row.values[0]?.text === "25.4 psi")).toBe(true); + expect(rows.some((row) => row.kind === "profile")).toBe(false); + }); + + test("keeps missing or invalid pit wear unavailable instead of displaying full health", () => { + expect(wheelLabelRows(config())[0]?.values[0]?.text).toBe("—"); + expect(wheelLabelRows(config({ wear: Number.NaN }))[0]?.values[0]?.text).toBe("—"); + }); + + test("does not fabricate pressure when channel absent and keeps live pressure distinct", () => { + const rows = wheelLabelRows(config({ pressurePsi: 28, coldPressure: false })); + expect(rows.some((row) => row.values[0]?.text === "28.0 psi")).toBe(true); + expect(wheelLabelRows(config()).some((row) => row.values.some(({ text }) => text.includes("psi")))).toBe(false); + }); + test("invalid temperature readings stay unavailable in rendered label data", () => { + const rows = wheelLabelRows(config({ + temperatureReadings: [{ kind: "surface", value: Number.NaN }], + carcassBands: [Number.POSITIVE_INFINITY, null, null], + })); + expect(rows.find((row) => row.kind === "profile")?.values[0]?.text).toBe("—"); + expect(rows.filter((row) => row.kind === "profile").flatMap((row) => row.values.map(({ text }) => text))).toEqual(["—"]); + }); + + + test("non-finite wear rates never render", () => { + expect(wheelLabelRows(config({ wearRate: Number.NaN })).some((row) => row.values.some(({ text }) => text.includes("%/s")))).toBe(false); + }); + + test("rejects unsupported and stale evidence before producing label values", () => { + const id = "tires.tire-wear"; + const values = { [id]: [0, 0.2, NaN, 0.4] }; + expect(readSceneWheelValue({ values, states: {}, freshness: {} }, id, 1)).toBe(0.2); + expect(readSceneWheelValue({ values, states: { [id]: "not-applicable" }, freshness: {} }, id, 0)).toBeNull(); + expect(readSceneWheelValue({ values, states: { [id]: "ok" }, freshness: { [id]: "stale" } }, id, 0)).toBeNull(); + expect(readSceneWheelValue({ values, states: {}, freshness: {} }, id, 2)).toBeNull(); + }); + + test("valid carcass middle survives an unavailable representative channel", () => { + const primary = "tire.temperature.carcass.middle"; + const frame = { values: { [primary]: [91, 92, 93, 94] }, states: { [primary]: "ok", "tire.temperature.carcass.representative": "missing" }, freshness: { [primary]: "fresh", "tire.temperature.carcass.representative": "stale" } }; + expect(sceneTemperatureReadings(frame, 0, "left", primary, "pit-snapshot")).toEqual([{ kind: "carcass", value: 91 }]); + expect(sceneTemperatureReadings({ ...frame, states: { [primary]: "missing" } }, 0, "left", primary, "pit-snapshot")).toEqual([{ kind: "carcass", value: null }]); + }); + + test("retains aged pit observations and cold setup pressure only as historical values", () => { + const ids = ["tire.temperature.carcass.middle", "tires.tire-wear", "tires.tire-pressure"]; + const values = { [ids[0]]: [42, 43, 44, 45], [ids[1]]: [0.1, 0.2, 0.3, 0.4], [ids[2]]: [28, 28, 28, 28] }; + const frame = { values, states: Object.fromEntries(ids.map((id) => [id, "stale"])), freshness: Object.fromEntries(ids.map((id) => [id, "stale"])) }; + expect(readSceneWheelValue(frame, ids[0], 0)).toBeNull(); + const temperature = readSceneWheelValue(frame, ids[0], 0, "pit-snapshot"); + const wear = readSceneWheelValue(frame, ids[1], 0, "pit-snapshot"); + const pressure = readSceneWheelValue(frame, ids[2], 0, "static"); + const rows = wheelLabelRows(config({ carcassBands: [null, temperature, null], wear, pressurePsi: pressure, coldPressure: true, pitTemperature: true, pitHealth: true, historicalSnapshot: true })); + expect(rows[0]?.values[0]?.text).toBe("90%"); + expect(rows.some((row) => row.values.some(({ text }) => text === "42°"))).toBe(true); + expect(rows.some((row) => row.values.some(({ text }) => text === "28.0 psi"))).toBe(true); + expect(rows.some((row) => row.kind === "note")).toBe(true); + expect(readSceneWheelValue({ ...frame, states: { [ids[0]]: "invalid" } }, ids[0], 0, "pit-snapshot")).toBeNull(); + expect(readSceneWheelValue({ ...frame, values: {} }, ids[2], 0, "static")).toBeNull(); + }); +}); diff --git a/docs/adr/0015-automatic-iracing-ibt-reconciliation.md b/docs/adr/0015-automatic-iracing-ibt-reconciliation.md new file mode 100644 index 000000000..1270a7de2 --- /dev/null +++ b/docs/adr/0015-automatic-iracing-ibt-reconciliation.md @@ -0,0 +1,34 @@ +--- +status: proposed +--- +# Automatically reconcile local iRacing IBTs with live captures + +## Context + +RaceIQ retains live iRacing SDK capture data, including changing session context and competitor channels. Native IBTs contain additional disk-only geographic channels. The existing opted-in Windows collector already enables native logging, resolves redirected Known Documents, and archives closed IBTs without modifying originals; archiving currently does not attach evidence to existing live sessions. Standalone import would create separate sessions rather than enrich the original capture. + +## Decision + +Extend the existing collector with unattended historical/new-file inventory and durable reconciliation for existing owned live sessions. Match native segment/player/car/layout identity and exact tick/session-time overlap, never filenames, lap numbers or nearest wall-clock times. Preserve both original sources and stable lap/session identities. Publish supplementary channels only where absent from the live segment dictionary and exact row identity is unambiguous, with explicit IBT provenance and revision-aware replay. + +Treat reconciliation revision as a replay-evidence epoch: attachment changes and verified source loss/corruption/restoration each invalidate dependent replay through idempotent durable transitions, even when attachment IDs are unchanged. Full iRacing semantic HTTP replay explicitly serializes supplemental attribution per value/dependency/component; preserve primary provenance and never expose source paths. The shared contract owns wire shapes and null/empty semantics. + +Reuse existing capture opt-in, closed-file checks, immutable archival, maintenance ownership and short DB write admission. Do not scan the whole system or import unrelated history. Ambiguous/insufficient evidence stays visibly unresolved. Changed evidence and interrupted work recover automatically; unchanged failures do not spin. Retention and transfer treat both sources as referenced evidence. + +Use installed Effect 4.0.4 durable Workflow/Activity APIs with SQL-backed SingleRunner/ClusterWorkflowEngine for reconciliation execution, stage tracking, durable waits and bounded retries. Persist the workflow journal in a dedicated owned SQLite file; validate the compatible SqlClient adapter on Bun/Windows before integration. Do not substitute an in-memory engine, unrelated Cloudflare Durable Objects package or custom persisted queue. Existing recording maintenance is Effect-owned but explicitly non-durable; retain its locking/lifecycle contracts without migrating unrelated jobs. + +Execution IDs derive from immutable file generation or session/source/candidate-evidence/baseline/version identity. Record completed activity results and resume the same execution after restart. Domain status is a revision-fenced workflow projection, not independent scheduling state. Durable submission/refresh outboxes and execution-keyed publication receipts bridge journal and application database transactions; replayed side effects must be idempotent. No blanket exactly-once guarantee is claimed. Own the runner in backend scope and await non-cancellable I/O settlement before lease release/shutdown. + +Run the reconciler, durable Effect engine and all archive/hash/index/match/fit activities inside one dedicated Bun-hosted `node:worker_threads` Worker, following the existing startup worker's dev/compiled entry pattern. Main backend owns supervision, capture SDK control, admission and native SQL transaction handles; a bounded typed generation-fenced bridge connects worker activities to parent-owned domain/journal SQL hosts. Do not share the startup/dashboard worker, create a worker per recording, or fall back to reconciliation on the main thread. Worker exit/channel loss must settle parent SQL and release leases before one replacement resumes the same durable journal. Verify worker isolation, crash recovery and compiled Windows entry resolution through RE-10. + +Measured positions remain geographic-derived scene poses, not native live XYZ. Fit only reference geometry and reject poor/incomplete fits without altering the measured path. Reconciliation does not promise exact live GPS or automatically surveyed track geometry. + +Geometry acceptance belongs to a lap/evidence/reference/policy key, not a session-wide flag. Full semantic replay returns the selected lap's result; selection and comparisons cannot inherit another lap's accepted fit. Shared E2E RE-07a and RE-08a verify mixed-lap/mixed-source HTTP presentation and hot-cache loss/restoration respectively. + +## Rationale and consequences + +A supplementary evidence model preserves live-only data that replacing the capture with IBT would lose. Automatic local discovery meets unattended operation without duplicate sessions or an upload flow. Exact matching sacrifices coverage rather than attach coordinates to the wrong frame. Retaining original IBTs preserves extra native rows even though historical replay remains on the live capture's frame grid. Durable indexes, provenance and source lifecycle changes are required; file archiving alone is insufficient. + +Alternative rejected: replace or reimport the live session from IBT, which changes identities and loses live-only context. Nearest-sample merging and reconstructed live GPS are also rejected because they hide uncertainty. + +Plans: [Backend implementation](../plans/iracing-ibt-reconciliation-backend-plan.md), [UI implementation](../plans/iracing-ibt-reconciliation-ui-plan.md). Shared policy, acceptance and E2E: [Reconciliation contract](../plans/iracing-ibt-reconciliation-contract.md). diff --git a/docs/plans/iracing-ibt-reconciliation-backend-plan.md b/docs/plans/iracing-ibt-reconciliation-backend-plan.md new file mode 100644 index 000000000..08bf5bcc0 --- /dev/null +++ b/docs/plans/iracing-ibt-reconciliation-backend-plan.md @@ -0,0 +1,59 @@ +# Automatic iRacing IBT reconciliation: backend implementation plan + +Status: proposed; not implemented or verified. + +Shared boundary, policy, acceptance and E2E: [Reconciliation contract](iracing-ibt-reconciliation-contract.md). Decision: [ADR 0015](../adr/0015-automatic-iracing-ibt-reconciliation.md). Parallel implementation: [UI plan](iracing-ibt-reconciliation-ui-plan.md). + +## Scope and ownership + +Implement automatic local discovery and safe attachment of IBT evidence to existing live-recorded sessions. Preserve live-only data and original bytes; enrich semantic replay without changing primary recording format or creating imported duplicate laps. Backend owner also owns shared schemas, contract updates and final cross-layer E2E execution. Do not invoke destructive session reprocessing or race-result reconciliation. + +Owned existing integration points: +- `apps/backend/src/runtime/iracing-capture.ts`: collection events, enabled-state restoration and historical inventory. +- `packages/game-iracing/src/disk-logging.ts`: existing Known Folder/closed-file/atomic archive primitives; preserve native logging ownership semantics. +- `packages/game-iracing/src/ibt-reader.ts`, `ibt-source.ts`: stream complete variable table, native segment identities and row offsets; do not reuse standalone import's complete-lap eligibility for reconciliation. +- `packages/capture-formats/src/iracing/{source-frame,session-info}.ts`: existing native identity and metadata definitions. +- New dedicated Bun reconciliation worker entry point, parent supervisor, typed bridge and worker-owned durable Effect runtime beside `apps/backend/src/runtime/startup-worker.ts`; appended domain migrations/queries and a separate owned workflow journal. Reuse `server/db/worker-sql.ts` and parent write/maintenance authorities; do not create another hand-built scheduler or application DB writer. +- `server/telemetry/replay.ts`, `server/db/telemetry-replay-storage.ts`, source loader and attachment-aware export/retention paths in `apps/backend/src/laps/archive.ts` and `server/session-capture/`. +- `apps/backend/src/routes/games/iracing.ts`: shared summary/session status APIs. +- `apps/backend/src/routes/laps/resource-routes.ts` and shared/client semantic replay wire types: explicit full iRacing HTTP provenance and lap geometry projection; preserve other games and compact track-map shape. +- `packages/scene-3d/src/wireframe/ScenePlacement.ts`: reference-only fit validation and truthful geometry status; shared/server policy must not drift from scene behavior. +- Existing native capture tests and seeded Playwright session/analysis/compare tests; backend owner adds paired fixtures and integration scenarios. + +Do not change Connection UI or translations. UI owner can implement immediately against contract-conforming fixtures once boundary is accepted. Backend publication and real UI integration are the genuine shared completion dependency, not prerequisites for UI implementation. + +## Tasks and acceptance mapping + +0. **Bun worker and durable Effect runtime prerequisite — RC-05/07/09/10.** Run pinned Effect 4.0.4 Workflow/Activity and SQL-backed SingleRunner/ClusterWorkflowEngine inside one dedicated Bun-hosted `node:worker_threads` Worker. Parent supervises lifecycle and native SQL hosts; worker owns workflow/activity execution and the Effect journal SqlClient facade through the typed parent bridge. Follow existing startup-worker dev/compiled entry resolution, not a worker per job. Validate adapter compatibility on Bun/Windows, journal migrations, restart and shutdown before integration; no core Effect upgrade, in-memory engine or main-thread fallback. Define worker-generation/request IDs, bounded controls and durable intent acknowledgements against the shared contract. UI implementation remains independent. + + +1. **Durable attachment model and shared API types — RC-02/03/05/07/08/09.** Append domain migration for file inventory generations, immutable owned source identities, segment/range attachments, revisions, counters, processing version, durable submission/refresh outboxes and execution-keyed publication receipts. Workflow engine journal owns execution history; domain state/reason/stage is its fenced read projection, not a second scheduling authority. Multiple attachments may serve one session; one IBT may serve multiple sessions. Add unique constraints preventing duplicate source/range publication and duplicate revision increments after activity replay. Preserve primary session source/rawFile, IDs and metadata. Shared API types include nullable workflow stage/wake metadata and supplemental provenance; preserve route authentication/errors. + Persist availability generations and idempotent transition receipts as part of the replay-evidence epoch defined in the contract; do not equate unchanged attachment IDs with unchanged replay. Source loss/corruption/restoration updates each referencing session's epoch, coverage, fit validity and refresh intent atomically. Repeated probes do not increment revisions. Restoration requires digest verification and a new availability-generation execution identity. RC-03/05/06/08/09; RE-08a. +2. **Automatic discovery and catch-up — RC-01/05/07/09.** Extend existing capture manager rather than create another independent native collector. On enabled startup/file changes inventory owned archives and resolved telemetry directory, including pre-existing files. Header-index historical candidates against live-session identities before copying unrelated history. Persist changed-file state in bounded batches, with a durable workflow submission outbox before event acknowledgement. Deliver archive-publication/live-finalization intents idempotently; recover missed notifications by inventory scan. Reuse settled-metadata/exclusive-close checks and immutable publication. No whole-disk search, symlink traversal, native mutation, unrelated bulk import or extra confirmation. Preserve opt-in, disconnect and logging ownership. +3. **Segment matching and range index — RC-02/03/04.** Implement exact native identity and overlap proof from contract, including placeholder-ID handling and driver identity metadata absent from the normalized snapshot. Partition restarts/session changes/car entries before indexing. Stream each candidate once per attempt; keep bounded matching state and persist selected-lap row/range indexes. Deduplicate same bytes; combine disjoint proven ranges; reject ambiguous/conflicting overlap rather than overwrite a prior result. No filename/lap-number/time-nearest matching. Retain all IBT bytes even where rows do not map to live frame grid. +4. **Worker activities, fenced publication and recovery — RC-03/05/08/09/10.** Execute archive/index/match/publish/refresh and fit preparation on the dedicated Bun worker's durable Effect runtime. Main thread performs only supervision, bounded message routing and admitted native SQL work. Preserve activity results, durable waits/timers and typed retries. Use parent-owned application leases plus isolated journal SQL host; never rely on a worker-local application gate or share native handles. Publication atomically records execution receipt, attachments, revision and refresh intent. Fence worker generation and source/baseline. On exit/channel loss drain parent SQL and roll back/close native transactions before lease release or replacement; wait for actual worker exit. Stop gracefully after I/O settlement; crashes resume same journal without full-pipeline replay or inline fallback. Keep referenced sources/native originals safe. +5. **Semantic replay and provenance — RC-03/04/06.** Resolve base live data first; supplement only segment-dictionary-absent channels on exact unique native keys. Do not attach IBT packets by array index or overwrite invalid live samples. Implement the contract's [semantic replay wire extension](iracing-ibt-reconciliation-contract.md#semantic-replay-wire-extension), including the explicit route projection, required empty provenance arrays for primary-only values, consumed dependency/component attribution and consistent nested epochs. Preserve internal primary provenance. Use selected-lap index reads; invalidate/cache-key all affected semantic replay, compare and pose artifacts by the replay-evidence epoch, including source loss/restoration. Preserve indexed/live-member bootstrap and raw frame-window invariants. Integrity monitoring of existing attachments remains active independently of external discovery; read-detected loss uses the same admitted transition path and never serves unavailable cached evidence as current. Missing/corrupt attachments preserve primary replay. Execute RE-07a/08a through HTTP, not only the internal resolver. + Implement against the shared contract's [non-GPS inventory](iracing-ibt-reconciliation-contract.md#non-gps-enrichment-inventory), not a GPS-only allowlist: candidate additions include wheel speeds, tyre pressures/surface temperatures, ride heights and ABS reduction; suspension, brake-line pressure, steering torque/subsamples and motion supplement only when absent live. Preserve units, scalar/array shape and catalog freshness/support; original-channel retention does not authorize invented mappings, timestamps or analyses. Execute RE-04b for RC-03/04/07/08, including cold replay and transfer. +6. **Measured scene and fit guard — RC-04/06.** Feed reconciled Lat/Lon/Alt/YawNorth through existing semantic pose resolver; keep native XYZ capabilities disabled for iRacing. Apply the shared [geometry safety policy](iracing-ibt-reconciliation-contract.md#geometry-safety) to official reference only. Publish lap-scoped results in full semantic replay, not session-wide acceptance. Key results by lap/live source/evidence/reference/policy; reuse one deterministic fit implementation across worker and scene consumer. Partial/gapped laps and poor/reversed fits cannot inherit another lap's accepted transform, including comparisons. Reject overlay while retaining measured path. Never persist global track calibration or invent optimal line/tire-trail support. RE-07a covers a mixed accepted/rejected session and reference/policy changes. +7. **Status, transfer and documentation — RC-05/07/08.** Implement contract capture-summary extension and session status endpoint; preserve access checks, feature gating and existing PUT body. Persist counters/outcomes rather than use runtime savedFiles as reconciliation success. Extend archive manifest/readers to carry both original sources and attachment indexes/provenance, with backwards reading of single-source exports. Update adapter, game collection, track replay docs and Unreleased changelog once the coherent implementation is verified. + +## Edge handling and verification ownership + +The shared contract is authoritative for every edge and expected result; do not redefine thresholds or precedence here. + +| Backend boundary | Strategy and required shared E2E | +| --- | --- | +| Startup history, redirection, unsupported/disabled host, unrelated paths | Reuse opt-in/Windows resolver; bounded matched-only catch-up; explicit status. RE-01/02; RC-01/05/07/08. | +| Zero IDs, wrong driver/layout, restart ticks, multi-file/multi-session and overlap conflict | Partition native identity; exact-key overlap proof; limit ambiguous inputs; deduplicate equivalents and reject new conflicts. RE-03; RC-02/03/04/05. | +| Different cadence, missing heading/GPS, invalid live channel, duplicate identity, partial IBT | Add only missing channels at exact keys, never fill gaps or overwrite live states; partial source can enrich without geometry acceptance. RE-04; RC-03/04/06. | +| Open writer, changing/corrupt/missing source, process crash | Closed-file/generation rechecks, durable work state, immutable copy, fenced atomic publication and visible limitation/recovery. RE-05/08; RC-01/05/08. | +| Active source, disconnect, stale revision, delete/retention/relocation | Defer final publication, preserve accepted work, fence current existence/revision, protect owned references. RE-06; RC-03/05/08. | +| Hot/cold replay cache, comparison, fit rejection and export/import | Revision-aware readers and provenance; reference-only policy; preserve both sources during transfer. RE-07/08; RC-03/04/06/07/08. | +| Availability changes with unchanged attachment IDs/digests | Commit deduplicated loss/corruption/restoration epochs and refresh outbox; monitor existing sources while discovery is disabled; verify open and cold consumers agree after crash/reconnect. RE-08a; RC-03/05/06/07/08/09. | +| Mixed-lap geometry and mixed-source HTTP values | Return each lap's own keyed fit result and required per-value provenance arrays; preserve primary attribution, reject stale response epochs and never reuse another lap's acceptance. RE-07a; RC-03/04/06/07. | +| Durable execution, retries, stage projection and journal/domain transaction gap | SQL-backed Effect engine, evidence-keyed identities, named activity results, durable waits/wakes, submission/refresh outboxes and idempotent domain receipts; no second queue or blanket exactly-once claim. RE-09; RC-03/05/06/07/09. | +| Bun worker boundaries, compiled entry, crash/channel loss and backpressure | Dedicated worker engine/activities, bounded versioned control bridge, parent-owned native SQL and generation fencing; await actual exit plus SQL drain before replacement, no inline fallback. RE-10; RC-03/05/06/07/09/10. | + +Execute real production-entry scenarios RE-01–10 after both implementations integrate. Extend existing native capture harness/seeded Playwright suite with isolated domain DB/data dirs, persisted journal and actual Bun reconciliation worker. RE-09 verifies process-restart durability; RE-10 additionally proves non-main thread identity, live/HTTP progress during worker activity, crash/channel-loss cleanup, single replacement, bounded controls and dev/compiled worker resolution. Test barriers may control sequencing but cannot replace worker/SQL/engine integration. Run applicable regression/project gates once edits settle and record outcomes. Real compiled Windows simulator/locking/redirected-folder evidence remains mandatory. + +Completion evidence must map RC-01–10 and all edges to passing E2E, including exact source/provenance conservation, preserved IDs/live-only data, durable Effect recovery and actual Bun worker isolation/parent SQL cleanup. In-memory engines, Promise-only offloading and planned smoke are not proof. Missing environment/dependencies remain explicit blockers. Document this durable worker runtime without claiming unrelated maintenance jobs became durable. No implementation, tests, commits or checkpoints are authorized by this planning-only request. diff --git a/docs/plans/iracing-ibt-reconciliation-contract.md b/docs/plans/iracing-ibt-reconciliation-contract.md new file mode 100644 index 000000000..ee22a70cb --- /dev/null +++ b/docs/plans/iracing-ibt-reconciliation-contract.md @@ -0,0 +1,232 @@ +# Automatic iRacing IBT reconciliation contract + +Status: proposed; planning only. No implementation or runtime verification is claimed. + +Plans: [Backend](iracing-ibt-reconciliation-backend-plan.md), [UI](iracing-ibt-reconciliation-ui-plan.md). Decision: [ADR 0015](../adr/0015-automatic-iracing-ibt-reconciliation.md). + +## Outcome and existing contracts + +Automatically find the correct local IBT recordings for existing live-recorded iRacing sessions, retain both sources, and enrich historical replay with measured geographic positions without importing duplicate sessions or losing live-only competitor/context data. Discovery and matching need no upload, file picker, or per-file confirmation. Reconciliation becomes available after iRacing closes its disk recording, not as exact live GPS. + +Authoritative existing boundaries: +- [Adapter](../reference/adapters/iracing.md): live source frames retain native values and changing SessionInfo; normal IBT import retains original bytes. +- [Native collection](../../server/games/README.md): existing persisted Connect opt-in, Known Documents resolution, logging ownership, exclusive closed-file probe, immutable hash-named archives. Extend its historical-discovery limitation explicitly; do not silently bypass opt-in. +- [Track geometry](../../shared/racing/tracks/README.md): measured poses are distinct from native XYZ, reference geometry alone is fitted, missing/stale GPS remains unavailable. +- `apps/backend/src/runtime/iracing-capture.ts`, `packages/game-iracing/src/disk-logging.ts`: current collector and platform integration. +- `server/telemetry/replay.ts:46–113,152–241`: existing identity is native session UID + SessionTick + rounded SessionTime milliseconds. Do not align by packet index. +- `server/db/telemetry-replay-storage.ts`: existing lap/member identities and replay caches must remain valid and revision-aware. + +Scope excludes fabricated live coordinates, moving/deleting native originals, whole-disk searches, auto-importing unrelated IBTs, modifying lap timing/validity/classification, racing-result reconciliation, and publishing derived poses as native telemetry. Existing standalone IBT import remains available and unchanged. + +## Discovery, matching, and source precedence + +1. Reuse the existing persisted iRacing capture opt-in. While enabled, resolve current user's Windows Known Documents `iRacing/telemetry` (including redirection), scan regular `.ibt` files there and owned archive files. Do not traverse unrelated directories, symlinks/reparse-point targets, or arbitrary client paths. No new permission setting is enabled automatically. Unsupported hosts report unavailable; the Windows backend must have access to the simulator user's files. +2. Extend startup discovery to historical files, but only archive/reconcile files positively matched to an existing owned live capture. Inventory headers incrementally, persist file generation/signature and outcomes, and rescan changed files rather than rehashing every file on every tick. Existing in-window archival continues. Native originals remain untouched. Recheck file generation and exclusive closed-file status before/after copy, hash owned staged bytes, then publish atomically without replacement. A paused writer is not complete. +3. Match each IBT session segment, not its filename: non-placeholder native SessionID, SubSessionID, SessionNum, exact native track/layout ID, car ID and player driver/car identity from retained source metadata must agree. Resolve player identity from SessionInfo driver metadata, including UserID when available; driverCarIdx alone is not globally unique. Offline/test sessions with zero IDs require the same car/driver/layout plus a unique exact overlapping native tick/time sequence; otherwise report insufficient identity, never guess from wall clock or lap number. +4. For every segment, require at least 60 distinct exact common `(session UID, SessionTick, round(SessionTime * 1000))` keys spanning at least one second with consistent native lap progress (absolute LapDistPct difference <= 0.001 for each compared valid pair). These are matching thresholds, not interpolation permissions. Duplicate or reset tick/time identities cannot cross segment boundaries. Missing identity, incompatible progress, multiple non-equivalent candidate captures, or driver swap identity uncertainty yields an explicit unresolved outcome. Do not choose the nearest filename/time or first candidate. +5. One IBT may cover several live sessions; one live session may need several IBTs from separate car entries. Build per-segment/per-range attachments. Byte-identical duplicate files share one archive identity. Overlapping attachments must have identical supplementary values for the same exact identity/channel or the new conflicting attachment is rejected; previously committed data remains intact. Disjoint attachments extend coverage atomically. +6. Keep live capture bytes, frame grid, native values, metadata timeline, lap/session IDs, offsets, notes, ownership and all user decisions unchanged. IBT supplements channels absent from the live source's variable dictionary for that segment. Never replace an existing live channel, including invalid/stale samples, with an IBT value. Retain the complete IBT for provenance and original-cadence access; do not discard its extra rows merely because the live grid has gaps. +7. Publish supplementary values only on exact unambiguous common keys. No nearest-row matching, resampling, interpolation, extrapolation, or carrying GPS through gaps. Missing/duplicate/conflicting rows remain unavailable. Position validity/freshness remains governed by the semantic resolver and ScenePlacement; retain `(0,0)`, invalid values, and missing heading as gaps, not fabricated zero coordinates. Report both matched-row coverage and usable-position coverage. +8. Store immutable owned IBT references, digest, source generation, matching version, range index and source/result revisions in durable attachment metadata. Expose per-value IBT provenance without replacing the primary live raw reference or falsely attributing GPS to live SDK. Reconciliation completion means source reconciliation completed; geometry acceptance is a separate state. + +### Non-GPS enrichment inventory + +The channel headers in [`iracing-navarra-mx5-2026-10-19.ibt`](../../test/artifacts/sessions/iracing-navarra-mx5-2026-10-19.ibt) contain the following additional data. Comparison with [`iracing-all-vars-2026-07-29T02-06-39-162Z.json`](../../data/diagnostics/iracing-all-vars-2026-07-29T02-06-39-162Z.json) identifies candidate additions, not universal disk-only guarantees: these sources are different dates/cars, not a paired recording. Actual per-segment dictionaries remain authoritative. + +| Data family | Observed native channels | Reconciliation treatment | +| --- | --- | --- | +| Wheel speeds | `LFspeed`, `RFspeed`, `LRspeed`, `RRspeed` | Present in this IBT, absent from the captured live catalog; retain missing channels as measured wheel speed, not derived slip or angular rotation. | +| Tyre pressures | `LFpressure`, `RFpressure`, `LRpressure`, `RRpressure` | Candidate missing channels; preserve recorded units and actual observation/freshness semantics. | +| Tyre surface temperatures | `LFtempL/M/R`, `RFtempL/M/R`, `LRtempL/M/R`, `RRtempL/M/R` (notation expands to three native names per wheel) | Candidate missing channels; retain three separate surface readings per tyre, distinct from carcass/pit snapshots. | +| Ride heights | `LFrideHeight`, `RFrideHeight`, `LRrideHeight`, `RRrideHeight` | Candidate missing channels; retain per-corner measurements, not an inferred setup or suspension geometry. | +| ABS brake-force reduction | `BrakeABScutPct` | Present here and absent from the captured live catalog; do not confuse the scalar reduction amount with the existing ABS-active boolean. | +| Suspension deflection/velocity | `LFshockDefl`, `LFshockVel`, and corresponding RF/LR/RR channels | Present in this IBT; supplement only if absent from the matched live segment. | +| Brake-line pressure | `LFbrakeLinePress`, `RFbrakeLinePress`, `LRbrakeLinePress`, `RRbrakeLinePress` | Present in this IBT; preserve any existing live channel unchanged. | +| Steering torque/subsamples | `SteeringWheelTorque`, `SteeringWheelTorque_ST` | Preserve scalar versus array shape; this IBT declares six subsamples per `_ST` row. Never flatten them into invented independently timestamped live frames. | +| Vehicle motion | `VelocityX/Y/Z`, `VertAccel`, `LatAccel`, `LongAccel`, `YawRate`, `PitchRate`, `RollRate` (expand slash notation to native names) | Present here but may already exist live; retain live precedence, actual units and native coordinate semantics. | + +Channel presence alone proves neither continuously updated values nor usable measurement. Existing semantic catalog support/freshness remains authoritative; repeated values alone do not establish stale data. Never relabel a pit snapshot, static setup value or unsupported channel as continuous measured telemetry. Preserve unsupported native channels and array data in the original attachment for native inspection, without inventing semantic mappings or new analysis/UI capabilities. `addedChannels` reports actual supplemental channel names, not every column in this inventory. Reconciliation is data/provenance enrichment; new wheel-slip, suspension, tyre or braking analyses are outside this plan. This inventory elaborates RC-03/04/07 and RE-04 below without changing source precedence. + + +## Publication and state transitions + +Use Effect 4.0.4 durable workflows, not an in-memory queue or a hand-built job-state machine, to own reconciliation execution and stage tracking. Run the reconciler and its durable Effect runtime inside one dedicated Bun-hosted `Worker`, supervised by the backend; never run reconciliation activities on the main event loop. Reuse existing parent-owned maintenance/write admission for domain side effects. Parse each changed IBT once per durable indexing activity; persist compact row/range indexes for selected-lap reads, not a JSON copy of every row. + +States per existing live session: `waiting-for-file`, `waiting-for-close`, `pending`, `running`, `completed`, `limited`, `failed`; these are persisted projections of durable workflow progress and domain outcomes, not a second scheduler. Active live captures defer publication until their relevant source windows are finalized. No matching file stays waiting; absent disk logging explicitly explains that no coordinates can be recovered. Missing identity/ambiguity/no usable supplementary evidence is limited; corrupt bytes/I/O failure is failed. Changed evidence or processor version starts a new evidence-keyed execution automatically; unchanged limited/failed input does not spin. Interrupted work resumes the same durable execution from recorded activity results, rather than resetting every running session to pending and replaying the whole pipeline. Disconnect ends new external discovery under existing ownership rules; already accepted durable work can finish. Re-enabling resumes catch-up without importing unrelated sessions. + +Archive publication precedes a short transaction that checks capture existence, finalization, ownership, source revision and attachment baseline, and commits all range attachments, reconciliation revision/status and derived invalidation together. Stale attempts discard computation and requeue current evidence; deleted sessions cannot be recreated. Crash after archive publication but before DB commit leaves recoverable unreferenced owned bytes, not visible partial reconciliation. Retention/relocation/deletion/export protect referenced attachments and never unlink native originals. Losing an attachment later reports unavailable supplementary data, preserves live replay, and schedules recovery if a matching original remains. + +`reconciliationRevision` is the session's durable replay-evidence epoch, not just an attachment-set counter. Increment it when attachments change, when a referenced source becomes unavailable/corrupt, and when verified bytes restore availability, even if attachment IDs and digests are unchanged. Each committed transition atomically updates availability, coverage/status, dependent geometry validity and a refresh outbox intent for every referencing session. Fence transitions by prior availability generation and deduplicate repeated observations/activity delivery; repeated failure probes do not increment the epoch. Restoration requires digest verification, not merely path existence. Status-only workflow progress does not increment this epoch. + +Owned-source integrity monitoring continues for existing attachments even when external discovery is disabled. A replay read that encounters loss must exclude the affected supplement immediately and durably report the observation through the same admitted transition path; it must not serve a previously cached supplement as current. Restoration resumes from a new availability generation rather than reusing an old successful activity result. Hot caches and browser queries invalidate on the committed epoch in both directions; cold reads and exports use the same committed availability snapshot. Primary live replay remains usable throughout. + +Replay caches, prepared comparisons, scene pose/geometry caches, and persistent derived artifacts that consume enriched semantic channels include `reconciliationRevision`. Unaffected primary timing/classification/results do not rebuild merely because GPS was attached. Per-value attachment attribution crosses HTTP through the [semantic replay wire extension](#semantic-replay-wire-extension); internal resolver provenance and the primary live raw reference remain intact. Disk paths are never exposed. + +## Durable Effect execution contract + +Use the installed Effect 4.0.4 `effect/workflow` APIs (`Workflow`, `Activity`, `DurableDeferred`, `DurableClock`) with `effect/cluster` `ClusterWorkflowEngine` and `SingleRunner` SQL-backed message/runner storage. Their installed source exports were inspected during planning; these APIs are unstable. Do not use `WorkflowEngine`'s in-memory layer in production or substitute the unrelated Cloudflare-only `@durable-effect/workflow` package. Validate the exact SQL driver/adapter against Effect 4.0.4 and Bun/Windows in the first backend implementation task; do not silently upgrade core Effect or downgrade to an in-memory engine if compatibility fails. + +Store the durable engine journal in an owned SQLite file at `DATA_DIR/iracing-reconciliation-workflows.sqlite`, separate from the application domain database. Provision its compatible Effect `SqlClient`, migrations, SQL-backed runner storage and single-runner layer before discovery admits work. Keep one runner per backend/data directory; do not enable distributed runners or introduce a remote service. Journal writes use the journal connection; all application-domain mutations still use existing application write admission. Startup/readiness failure of the durable store prevents new reconciliation without disabling primary live capture or capture connect/disconnect. Capture responses report journal unavailability explicitly; reconciliation-only detail reads return the service error defined below. + +The existing [recording maintenance guide](../contributing/scheduled-recording-maintenance.md) explicitly describes non-durable in-memory jobs. Reuse its reservation → maintenance lock → write permit → transaction order, Promise/Effect facade rules, protected non-cancellable I/O settlement and shutdown discipline; do not claim its scheduler already provides durable execution or migrate unrelated storage jobs. The durable Effect runtime lives inside the dedicated Bun worker and is scoped to that worker's backend-owned lifecycle, not a request, browser or detached fiber. All reconciliation archive/hash/index/match/fit preparation and workflow execution occurs there; the main thread only supervises, routes bounded control/status messages and performs admitted native SQL operations. + +### Execution identity and activities + +- Discovery inventories file generations durably, and records an outbox intent before acknowledging acceptance. Archive-publication and live-finalization events also create durable intents. An Effect dispatcher redelivers unacknowledged intents idempotently; losing an in-memory notification cannot strand work. +- A file-processing workflow key includes canonical inventory identity, file generation and processing version. It waits for closure, publishes/verifies immutable archived bytes, and produces an immutable native segment/range index. Deduplicated content can reuse a verified index; retries must recheck file generation and integrity, not trust a stale successful probe. +- A session-reconciliation workflow key includes owned session/capture identity, live source revision, a digest of the sorted candidate evidence set, referenced-source availability generations, prior attachment baseline and matching/policy version. Duplicate events execute/poll the same execution ID. Changed inputs create a new execution; obsolete executions cannot publish through current revision fences. Availability restoration cannot reuse a terminal execution from before source loss. +- Named, schema-serialized activities cover `archive`, `index`, `match`, `publish`, and `refresh`. Store compact identities, owned artifact references and result manifests in activity results, not entire telemetry, file handles, DB connections or mutable service objects. Completed deterministic indexing/matching results survive process restart; source-dependent commit checks always run in the publication transaction. +- `DurableDeferred` completions and durable clock wakeups handle file-close/finalization waiting and delayed retry without retaining an active loop or using process-local `setTimeout` as the durable record. Discovery rescans repair missed external notifications and complete the same durable wait idempotently. Persist waiting reason and next wake time for status projection. +- Retry only typed transient I/O/temporary availability failures with at most three retries at 1 s, 5 s and 30 s, using durable wakeups. Writer-open/no-file/active-capture are suspended waiting conditions, not failed retry attempts. Invalid identity, ambiguity, corrupt bytes or incompatible source is a terminal limited/failed result for that input; source change/version change creates new evidence-keyed work. Defects and interruption are never converted into successful empty attachments. + +### Side effects, recovery, and tracking + +Durable activity replay is at-least-once around external side effects, not a blanket exactly-once guarantee. Archive publication uses immutable digest identities and verification; index outputs use owned immutable references. The domain publication transaction records the execution/activity idempotency key with its attachment revision and durable refresh outbox. A retry after DB commit but before workflow-result persistence returns the existing committed result, without inserting duplicate attachments or incrementing reconciliationRevision again. Refresh delivery is idempotent and recoverable after a missed response or process death. Engine journal and domain DB are separate transactions; this outbox and idempotent publication protocol bridges them explicitly. + +Workflow progress updates its domain read projection through revision-fenced activities; reads do not decode captures or advance workflows. A crash between engine progress and projection update is repaired from the engine's recorded result before status can claim completed. `completed` requires a committed attachment result and durable refresh intent; cache refresh may be retried safely, and reconnect/cold reads use reconciliationRevision. Retain terminal journal results at least until all domain/outbox references are settled; cleanup cannot remove a live dependency. Domain deletion tombstones and revision checks prevent replayed activities from resurrecting deleted sessions. Back up/relocate the journal with the owned data directory; exported sessions carry attachment provenance, not active runner messages. + +Shutdown stops discovery/admission, drains or suspends owned executions at safe activity boundaries, awaits already-started Promise/worker I/O and domain transactions, then disposes the Effect runner and journal connection before database shutdown. Never release a maintenance/write lease while underlying I/O is still executing. Restart reopens the same journal, restores durable waits/timers and resumes executions without rerunning successful activities; authoritative domain checks still reject obsolete work. + +### Bun worker ownership and bridge + +Use the repository's Bun-hosted `node:worker_threads` `Worker` pattern in `apps/backend/src/runtime/startup-worker.ts`, including development and compiled entry-point resolution. Add a dedicated reconciliation supervisor, worker entry point and shared message schema beside that runtime. Do not reuse the startup/dashboard worker or create one worker per file/session. Keep the existing capture manager's SDK command/observation ownership; it sends durable discovery/finalization intents, not telemetry arrays, to the reconciliation worker. + +| Owner | Responsibilities | +| --- | --- | +| Main backend | Spawn/supervise exactly one reconciliation worker; capture opt-in/SDK control and notifications; durable domain outbox acceptance; maintain parent-owned storage/maintenance/write authorities and native SQL transaction handles; expose status projections; deliver cache invalidations after committed publication. | +| Dedicated Bun worker | Own the scoped durable Effect engine/runner, durable dispatch/waits/retries and all reconciliation activities; scan/index candidate evidence in bounded batches, hash/archive, decode/match and prepare reference-fit results; read committed domain snapshots; request admitted domain publication and own journal SQL operations through the parent bridge. | +| Parent SQL bridge | Reuse `server/db/worker-sql.ts` and write-coordination lease semantics for application writes; maintain an isolated journal SQL host/connection and journal admission authority for engine storage. Parent owns native transaction handles for both databases; worker-side Effect SqlClient uses the journal bridge, not an independent application writer. Journal admission never acquires the application write permit. | + +Use versioned structured-cloneable control envelopes with worker-generation and request IDs. Parent controls are `initialize`, `wake` and `stop`; worker replies are `ready`, `accepted`, `publication`, `error` and `stopped`, alongside the existing lease/SQL request-response protocol. Wake carries durable intent IDs; accepted means engine submission was durably recorded, not merely received. Stop acknowledges only after activity I/O/finalizers and runtime disposal settle. Publication carries session ID, execution ID and committed reconciliationRevision; parent ignores obsolete-generation messages and verifies current committed revision before invalidation. Persisted outboxes, not worker messages, are recovery authority. Bound in-flight controls; leave excess work durable in the outbox. Never structured-clone full telemetry or share native connection/transaction objects. + +On worker error/channel loss, stop admitting messages for that generation, await actual worker exit, drain submitted parent SQL, and roll back/close its uncommitted domain and journal transactions before releasing leases or opening a replacement runner. A single supervisor may restart only after cleanup settles; resume the same journal/execution identities. Startup failure or repeated worker failure remains an explicit reconciliation service failure, never an inline fallback. Graceful shutdown sends stop and awaits stopped plus exit/bridge drain; forced termination is failure-test coverage, not normal shutdown. Include the worker entry point in compiled Bun artifacts and verify it runs in both dev and compiled Windows application paths. + + + +## UI/backend boundary + +Extend existing authenticated, feature-gated `GET /api/iracing/capture` and `PUT /api/iracing/capture` responses with required `reconciliation: ReconciliationSummary`; preserve existing top-level fields and PUT body `{ connected: boolean }`. No new action is required to reconcile. Add authenticated `GET /api/iracing/reconciliation/sessions/:sessionId` for per-session details using existing session ownership/access rules. Invalid IDs return 400; inaccessible/missing/non-iRacing sessions return existing not-found behavior. Unsupported discovery is a successful status response, not a false completed result. Unexpected server failures retain the existing error boundary. + +If the workflow journal is unavailable, capture GET/PUT retain their existing capture behavior and include `reconciliation.store = "unavailable"`; summary counters remain the last durable domain projection, not proof of a healthy workflow. The session reconciliation endpoint returns HTTP 503 with `{ code: "reconciliation-store-unavailable", error: "Reconciliation workflow storage is unavailable" }`. This error must not disable capture disconnect or erase cached live telemetry. Unsupported platforms are distinguished by `supported = false`, not journal corruption. + +Report worker lifecycle independently through `reconciliation.worker`: `starting`, `ready`, `unavailable` or `stopping`. Unsupported execution reports `unavailable`. Disabled external discovery sets `enabled = false` but does not mislabel a ready worker that is finishing accepted work or maintaining existing attachment integrity; stop an idle worker only when no such responsibility remains. No ready acknowledgement means no claim that execution is running. If journal readiness is established but worker bootstrap/channel fails, the session endpoint returns HTTP 503 with `{ code: "reconciliation-worker-unavailable", error: "Reconciliation worker is unavailable" }`; capture GET/PUT still work and show the worker state. During starting/stopping, detail reads may serve the last durable projection but cannot claim an active worker. The UI uses these states without exposing worker handles or creating a new start/restart control. + +```ts +type ReconciliationState = "waiting-for-file" | "waiting-for-close" | "pending" | "running" | "completed" | "limited" | "failed"; +type ReconciliationReason = "no-file" | "writer-open" | "active-capture" | "insufficient-identity" | "ambiguous-match" | "conflicting-supplement" | "missing-channel" | "no-common-frames" | "invalid-position" | "source-missing" | "corrupt-file" | "io-error"; +interface ReconciliationSummary { + supported: boolean; + enabled: boolean; // existing capture opt-in, not a separate preference + store: "ready" | "unavailable"; // durable journal readiness, independent of capture connection + worker: "starting" | "ready" | "unavailable" | "stopping"; // parent-supervised executor readiness + revision: number; // durable monotonically increasing status revision + counts: Record; // owned live iRacing sessions; each counted once +} +interface SessionReconciliation { + sessionId: number; + revision: number; // status revision, distinct from publication identity + reconciliationRevision: number; // replay-evidence epoch: attachments and verified availability transitions + state: ReconciliationState; + reason: ReconciliationReason | null; + workflow: { executionId: string; stage: "archive" | "index" | "match" | "publish" | "refresh"; nextWakeAt: number | null } | null; + attachmentIds: string[]; // stable opaque IDs, ordered lexicographically + liveFrameCount: number; + matchedFrameCount: number; // distinct live frames with an attached exact match + usablePositionFrameCount: number; // valid Lat/Lon and YawNorth, <= matchedFrameCount + addedChannels: string[]; // native names, unique, lexicographically ordered +} +``` + +All numeric counts/revisions are finite nonnegative safe integers; `usablePositionFrameCount <= matchedFrameCount <= liveFrameCount`. Required nullable fields serialize as null, not omitted. Empty attachment/channel arrays mean none, not pending success. No pagination is needed for fixed summary/session endpoints. Existing capture polling remains authoritative; no new socket envelope. UI refetches per-session details while open, on focus/reconnect, and invalidates affected replay/compare queries when reconciliationRevision changes. A status revision alone does not imply new telemetry. + +Representative session response: `{ "sessionId": 42, "revision": 8, "reconciliationRevision": 1, "state": "completed", "reason": null, "workflow": { "executionId": "opaque-durable-execution-id", "stage": "refresh", "nextWakeAt": null }, "attachmentIds": ["opaque-ibt-range-id"], "liveFrameCount": 6000, "matchedFrameCount": 5400, "usablePositionFrameCount": 5350, "addedChannels": ["Alt", "Lat", "Lon"] }`. Numbers are illustrative, not measured fixture results. Partial coverage is explicitly displayed; completed does not mean every frame has GPS. `workflow` is null before admission; otherwise it identifies the current or latest execution and its last entered stage. `nextWakeAt` is nullable Unix epoch milliseconds for a persisted scheduled wake, never a client countdown authority. Geometry is deliberately absent here: it belongs to the selected lap's replay response, not the whole session. + +### Semantic replay wire extension + +Extend the existing full `GET /api/laps/:id/semantic-telemetry` response for `X-Game-Id: iracing`. Preserve its authentication, header validation, lap access checks, error statuses, existing envelope/value fields and primary-source semantics. `apps/backend/src/routes/laps/resource-routes.ts` currently strips resolver provenance when serializing values; explicitly extend that projection and its shared/client wire types. Other games and the compact `?view=track-map` response retain their current shape; compact readers still use the replay-evidence epoch for cache invalidation. + +For every successful full iRacing replay response, add required top-level `reconciliation` and required `supplementalProvenance` on every `envelopes[].values[]` entry: + +```ts +interface ReplayReconciliation { + sessionId: number; + reconciliationRevision: number; + geometry: LapGeometryResult; +} +interface LapGeometryResult { + lapId: number; + reconciliationRevision: number; + referenceDigest: string | null; + policyVersion: string; + state: "unavailable" | "approximate" | "rejected"; + reason: "no-position" | "insufficient-coverage" | "poor-fit" | "missing-reference" | null; +} +interface SupplementalProvenance { + sourceKind: "ibt-supplement"; + attachmentId: string; + sourceDigest: string; + sourceRow: number; + sourceChannel: string; + valuePath: string; + reconciliationRevision: number; +} +// Each serialized semantic value adds: +// supplementalProvenance: SupplementalProvenance[]; +``` + +Session/lap IDs are positive safe integers; epochs and zero-based native `sourceRow` indexes are nonnegative safe integers. Source/reference digests are lowercase SHA-256 hex of the immutable source bytes/canonical reference geometry respectively; `policyVersion` is a nonempty versioned fit-policy identity. The response and all nested epochs must come from one replay snapshot. A publication racing response assembly must not mix epochs; discard/retry stale assembly. A response older than an already observed session epoch must not replace newer UI data. + +`supplementalProvenance` is always an array, never null or omitted. Primary-only and wholly missing values use `[]`; this does not reclassify setup/derived/standalone-IBT values as live measurements. Each entry identifies an IBT observation actually consumed in resolving that value, including an invalid native observation yielding null/non-ok. An unavailable file contributes no observation or provenance entry. `sourceChannel` is the exact `iRacing.` dependency; `valuePath` is an RFC 6901 JSON Pointer relative to the semantic value (`""` for a scalar/whole-value dependency). Per-wheel/array and mixed-source values carry entries only for supplemented components; multiple native dependencies for one component have separate entries. Preserve native row/subsample shape without invented timestamps. Deduplicate entries and sort by valuePath, sourceChannel, attachmentId and sourceRow. + +Illustrative mixed-source value excerpt at epoch 3: `[{"semanticId":"engine.current-engine-rpm","value":6000,"unit":"rpm","state":"ok","freshness":"fresh","supplementalProvenance":[]},{"semanticId":"motion.latitude","value":42.56,"unit":"deg","state":"ok","freshness":"fresh","supplementalProvenance":[{"sourceKind":"ibt-supplement","attachmentId":"attachment-1","sourceDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","sourceRow":120,"sourceChannel":"iRacing.Lat","valuePath":"","reconciliationRevision":3}]}]`. The corresponding top-level extension is `{"sessionId":42,"reconciliationRevision":3,"geometry":{"lapId":101,"reconciliationRevision":3,"referenceDigest":null,"policyVersion":"iracing-reference-fit-v1","state":"unavailable","reason":"missing-reference"}}`. These are wire examples, not fixture evidence. + +Full iRacing parse-error responses retain their existing `parseError` and empty envelopes, and still include the extension with `geometry.state = "unavailable"` and `reason = "no-position"`. A journal/worker outage must not make retained primary replay fail: replay reads use the committed domain projection; service-specific 503 responses remain confined to reconciliation status endpoints as specified above. + +## Geometry safety + +Keep measured east/north/altitude positions unchanged. Reuse ScenePlacement's reference-only fitting; never snap or scale the measured path. For display acceptance, validate using lap-fraction-corresponding samples from a complete lap: at least 90 of 100 equal lap-fraction bins represented, median residual <= 5 m and p95 <= 15 m after fitting. These conservative proposed limits belong here as versioned policy, not duplicated in clients. Insufficient coverage or a failed fit hides the reference overlay with an explicit reason while retaining measured trajectory. Validate transform orientation against forward lap progress; applying one transform to outline, edges, labels, pit lanes and sections is mandatory. Never promote an SVG fit to surveyed/native geometry or reuse it as exact live GPS. Persisted global track calibration is outside this change. + +Evaluate and expose geometry per selected lap. Cache identity includes lap ID, live source revision, reconciliationRevision, referenceDigest and policyVersion; never borrow acceptance or a transform from another lap, including another lap of the same session. `approximate` requires a non-null reference digest and null reason. No reference yields `unavailable/missing-reference`; no usable positions yields `unavailable/no-position`; incomplete/under-covered laps yield `rejected/insufficient-coverage`; failed residual/orientation checks yield `rejected/poor-fit`. If both reference and positions are absent, report no-position first. A changed reference/policy invalidates fit results independently of attachment identity. + +The worker and scene consumer reuse one shared deterministic fit policy over the same lap frames/reference; UI renders an accepted overlay only when lap, evidence epoch, reference digest and policy version match. Otherwise hide the reference pending refetch/revalidation while preserving measured trajectory. Comparisons retain each lap's own result and must not promote a rejected lap through another lap's accepted fit. + +## Observable acceptance criteria + +| ID | Required result | +| --- | --- | +| RC-01 | With existing capture opt-in enabled, correct new and historical closed IBTs are found in resolved local locations and automatically associated without upload/confirmation; unrelated files produce no sessions or attachments. | +| RC-02 | Identity, segment and exact-key rules select correct source ranges across files/sessions; ambiguity fails closed and identical duplicates are idempotent. | +| RC-03 | Reconciliation retains both byte-identical sources and existing IDs/data, adds only missing channels with truthful provenance, and creates no duplicate laps/sessions. Full semantic HTTP responses expose the required mixed-source provenance schema, including primary-only empty arrays, component attribution and no private paths; browser source presentation consumes that real response. | +| RC-04 | Only exact matched frames receive valid supplementary data; gaps, duplicates, invalid GPS and missing heading remain gaps; coverage counters match actual replay. | +| RC-05 | Closed-file detection, bounded work, restart/retry, revision fencing, atomic commit and source lifecycle prevent partial/stale publication or lost live data. | +| RC-06 | Actual replay and affected comparisons refresh after publication and cold restart; geometry is keyed to each lap/evidence/reference/policy, never inherited from a different lap. Accepted geometry is approximate, reference-only, forward-aligned and within policy; rejected geometry leaves measured path intact. Mixed accepted/rejected laps remain distinct through selection and comparison. | +| RC-07 | Existing Connection UI and per-session replay status show automatic progress, partial coverage and actionable limitations; reconnect recovers updates; disabled/unsupported hosts never claim discovery. | +| RC-08 | Export/import and retention/deletion preserve both source identities, attachment provenance and replay behavior without exposing arbitrary filesystem access or deleting native files. Loss/corruption and verified restoration each advance the replay-evidence epoch once without changing attachment IDs; hot/cold replay and open comparisons converge after each transition, with primary data preserved. | +| RC-09 | Effect SQL-backed durable workflows track stages, waits and bounded retries across backend restart; duplicate delivery or crash between side effect and activity acknowledgement creates neither duplicate attachments nor lost completion. Status is a truthful revision-fenced workflow projection, not an independent in-memory tracker. | +| RC-10 | Reconciler and durable Effect engine execute in a dedicated Bun worker in development and compiled builds; main-thread live ingestion/HTTP remain responsive. Parent-owned SQL leases and generation fencing prevent overlapping runners, leaked transactions, stale publications or inline fallback after worker failure. | + +## Shared E2E scenarios and edge-case handling + +Integration owner: backend implementer owns cross-layer execution/evidence after both implementations; UI owner supplies browser assertions. Extend `playwright/tests/seeded/sessions/import.spec.ts`, seeded analyse/compare coverage and existing backend capture/runtime harness. Use isolated data dirs/DBs, real HTTP backend, actual source readers/worker/storage and browser. Generate deterministic IBT/live pairs through existing native fixture builders; do not mock reconciliation, DB, filesystem scanning, semantic resolver or successful API responses. The Navarra real IBT is an additional sanity case, not a substitute for a known paired live capture. Harness may inject discovery root/clock and lifecycle barriers only at existing runtime test seams. Windows acceptance additionally requires native file-lock, Known Folder and real simulator capture evidence; simulated flags alone cannot close that criterion. + +| Scenario | Setup → action → observable outcome; affected criteria | +| --- | --- | +| RE-01 automatic happy path | Seed a finalized live session with a live-only competitor sentinel; enable existing connection; create matching IBT in resolved test directory → collector archives, matches, commits without import/upload UI → unchanged IDs/counts/source digests; competitor sentinel retained, GPS added with IBT provenance; open replay shows measured path and corresponding status. RC-01/02/03/04/06/07. | +| RE-02 historical and directory discovery | Before startup place matching old IBT, unrelated IBT, already archived duplicate and files outside root; exercise redirected Known Documents on Windows → enable/restart → only matched historical file attaches once; existing in-window collection still works; no traversal or unrelated imports. Repeat disabled/feature-disabled/non-Windows states → explicit unavailable/disabled discovery. RC-01/02/05/07/08. | +| RE-03 identity boundaries | Pair same names/lap numbers with wrong layout/car/player/session; zero-ID test sessions; reused/reset ticks; multi-session IBT, two car entries, conflicting candidates and identical duplicates → automatic discovery → only fully proven segments attach; insufficient/ambiguous cases remain limited, disjoint ranges combine, conflicting overlap preserves prior attachment. RC-02/03/04/05. | +| RE-04 cadence and invalid data | Seed different live/IBT cadences, dropped rows, duplicate keys, GPS (0,0)/out-of-range/missing, missing YawNorth, and existing invalid live channels → reconcile via production worker → exact frame-level values/provenance and coverage counters; no nearest-row fill, stale carry or live-channel overwrite. Partial IBT without complete lap still enriches proven rows but cannot accept geometry. RC-02/03/04/06/07. | +| RE-04b non-GPS enrichment | Seed exact paired live/IBT sources with differing wheel-speed, pressure, surface-temperature, ride-height and ABS channel dictionaries; include pre-existing live suspension/brake/motion channels, unavailable/pit-snapshot semantics and a six-element torque subsample array → reconcile through real Bun worker and inspect native/semantic replay plus status → only absent channels appear in addedChannels, units/shapes/provenance are preserved, existing live values/states remain unchanged, unsupported/pit-only channels do not become continuous semantic telemetry and no subsample becomes a fabricated frame. Repeat cold replay/export-import for retained supplemental bytes; existing UI reports actual channels without claiming unimplemented analyses. RC-03/04/07/08. | +| RE-05 writer and recovery | Hold actual Windows IBT writer open with stable size; close it; change file during copy; deny read; supply truncated file; crash before/after archive publication and DB commit → no open/partial publish; explicit failure; restart resumes accepted work once; repaired evidence automatically requeues, unchanged failure does not spin. RC-01/05/07/08. | +| RE-06 concurrency and lifecycle | Hold decode/commit barriers, finalize active live capture, disconnect/reconnect, replace source revision, delete session and run retention/relocation → old attempts cannot publish; no resurrection; referenced attachments stay readable; pending work recovers; native originals unchanged. Health request and live ingestion complete while background decoding is paused. RC-03/05/08. | +| RE-07 replay, fit and refresh | Open real analyse/compare before attachment; publish and focus/reconnect, then restart backend → missing channels become measured without navigation or changed lap IDs; both cold/hot reads agree. Known transformed closed-loop fixture meets 5m/15m policy; wrong/reversed reference or partial lap is rejected; measured coordinates unchanged, all accepted reference layers share transform. Inspect 1440px/390px and keyboard/status announcements. RC-04/06/07. | +| RE-07a lap-scoped geometry and HTTP provenance | Seed one complete accepted lap and one partial/rejected lap in the same session, with primary-only RPM, supplemented GPS, a mixed per-wheel channel and an invalid native datum → reconcile and fetch both real semantic HTTP responses → required provenance arrays match consumed rows/components, unavailable sources have no fabricated entries, nested epochs agree and no paths leak. Open both laps and compare them → each shows its own geometry outcome; switching laps cannot reuse acceptance. Race publication with response delivery and change reference/policy → obsolete responses/fit keys cannot replace newer results; measured trajectories remain unchanged. RC-03/04/06/07. | +| RE-08 transfer and loss | Export reconciled session through real export API, import into isolated second DB → both original byte identities, attachments and semantic replay retained once; old single-source archives still work. Remove owned supplementary file → live replay survives with source-missing status; restore original → automatic recovery. Delete last reference → only owned unreferenced bytes eligible for removal, native originals untouched. RC-03/05/06/08. | +| RE-08a hot availability transitions | Open enriched analyse/compare and warm server/browser caches; retain attachment IDs/digests → remove or corrupt the owned IBT, then let integrity monitoring or replay report loss → exactly one epoch increment and durable refresh intent; same open views lose unavailable supplement/provenance/accepted fit, retain primary telemetry and match cold reads. Repeat probes and duplicate event delivery → no extra increment. Restore wrong bytes → no recovery; restore byte-identical original → digest-verified recovery advances epoch once, same IDs regain supplementation in open and cold views without navigation. Crash after each transition commit before notification, restart and reconnect → durable refresh converges without duplicate epochs. Repeat with discovery disabled to prove existing-source integrity still updates. RC-03/05/06/07/08/09. | +| RE-09 durable Effect execution | Through real discovery/runtime entry points, suspend an execution at each named activity, durable file/finalization wait and retry clock → kill/restart backend against the same journal/domain DB → same execution ID resumes, recorded successful activities are reused, scheduled waits/retry budget survive, and UI shows restored stage. Kill after domain commit before activity acknowledgement and after refresh intent before delivery → retry returns the same publication/revision and eventually refreshes real replay. Duplicate discovery/outbox delivery creates one execution; changed evidence creates a fenced new one. Fail journal initialization, interrupt non-cancellable I/O, delete session before resumption, and clean terminal journals with pending outbox references → explicit service failure/preserved live capture, no released-active lease, no resurrection or premature journal deletion. RC-03/05/06/07/09. | +| RE-10 Bun worker isolation and lifecycle | Start the real application in dev and compiled Bun modes with paired sources; observe worker thread identity and run actual indexing/matching under a test-only worker activity barrier → main-thread health/status and live ingestion complete before release, with no inline reconciler activity. Crash worker during decode, journal write, domain transaction, and after commit before acknowledgement; deliver stale-generation/duplicate notifications → parent observes exit, drains/rolls back native handles and only then starts one replacement, same durable execution resumes, committed revision publishes once, primary capture stays available. Test graceful stop during outstanding I/O, worker bootstrap failure, bounded message backlog, and compiled Windows worker resolution → no active lease released early, no duplicate runner, no main-thread fallback, truthful status and restored UI. RC-03/05/06/07/09/10. | + +After implementation execute RE-01–10, including RE-04b, RE-07a and RE-08a, and record commands, environment, fixture identities, pass/fail and evidence paths mapped to RC-01–10. RE-09 uses the actual Effect SQL-backed engine/journal across backend processes; RE-10 additionally proves dedicated Bun worker execution, worker-only crash recovery, parent SQL cleanup and compiled entry-point resolution. In-memory engines, Promise-only dispatch and simulated state resets are not substitutes. Execute real Windows native collection → archive → worker reconciliation → UI replay with at least one genuinely paired live/IBT run. Missing Windows/simulator access is an explicit acceptance blocker. Unit/build/type/lint checks supplement E2E; this planning task runs no implementation tests. + +## Ownership and sequencing + +Backend owner owns this contract, migrations, attachment lifecycle, source matching/replay, shared schemas and integration evidence. UI owner owns Connection/status presentation, query invalidation and translations. Both implement independently against this proposed boundary after plan acceptance; UI may use typed development fixtures, never production fallbacks. Contract changes update both linked plans before dependent work continues. Implementation completion requires all shared criteria and edge scenarios passing, including real Windows evidence. diff --git a/docs/plans/iracing-ibt-reconciliation-ui-plan.md b/docs/plans/iracing-ibt-reconciliation-ui-plan.md new file mode 100644 index 000000000..db17c6183 --- /dev/null +++ b/docs/plans/iracing-ibt-reconciliation-ui-plan.md @@ -0,0 +1,54 @@ +# Automatic iRacing IBT reconciliation: UI implementation plan + +Status: proposed; not implemented or verified. + +Shared boundary, policy, acceptance and E2E: [Reconciliation contract](iracing-ibt-reconciliation-contract.md). Decision: [ADR 0015](../adr/0015-automatic-iracing-ibt-reconciliation.md). Parallel implementation: [Backend plan](iracing-ibt-reconciliation-backend-plan.md). + +## Scope and ownership + +Expose unattended reconciliation honestly in the existing iRacing Connection card and affected session/replay surfaces. No upload workflow, file chooser, new enable preference, per-file confirmation or manual matching is required. Preserve incumbent layout, native capture connect/disconnect behavior and existing standalone IBT import. Do not redesign the application or make clients perform filesystem matching. + +Owned files/components: +- `client/src/components/settings/connection/IRacingConnection.tsx`: existing capture query, connection state and local archive status. +- Existing Analyse/session replay source-status presentation and associated query consumers; extend the current source/provenance presentation, rather than render a separate duplicate track scene. Resolve the narrow presentation component before editing. +- Existing replay/compare query invalidations, shared API consumption and locale messages. +- Seeded sessions/analysis/compare Playwright assertions and narrowly scoped development fixtures. + +Backend owner publishes shared schemas and owns production source matching, persistence, scene geometry policy, API operations and integrated E2E execution. UI may proceed in parallel with typed contract fixtures after boundary acceptance; development fixtures must not become production fallback data. Shared contract changes update both plans before dependent changes continue. + +## Tasks and observable acceptance + +1. **Automatic status in Connection — UI-01; RC-01/07.** Extend existing capture response consumption with contract reconciliation summary. Keep capture state and reconciliation state separate: savedFiles is archive activity, not proof a session was reconciled. Show durable counts for waiting, processing, reconciled and attention outcomes, with unambiguous labels. Existing Connect remains the opt-in; clearly explain that correct IBTs are discovered locally and processed after disk-file closure. Show disabled/unsupported discovery accurately without a new confirmation flow. +2. **Per-session coverage and limitations — UI-02; RC-03/04/06/07.** Consume per-session status for an open iRacing session/replay. Show measured coordinates from IBT versus lap-progress estimate, matched and usable-position counts against live frame count, and concise state/reason. Completed with partial coverage must say partial; no-file/writer-open/active-capture are waiting, not success or corruption. Ambiguous identity/conflicting supplement/missing source/corrupt file have distinct actionable explanations. Do not expose private paths or invent a manual matching action; repaired/new evidence retries automatically. Preserve existing live telemetry while supplementary data is unavailable. + Consume the contract's [semantic replay wire extension](iracing-ibt-reconciliation-contract.md#semantic-replay-wire-extension) for source attribution, not guessed attribution from addedChannels. Required `supplementalProvenance` entries identify consumed IBT rows/components; empty arrays mean no supplemental attribution, not proof of live measurement. Preserve primary-source labels and show mixed per-wheel/array attribution honestly. RE-07a covers real HTTP-to-browser provenance, null/invalid observations and absence of private paths; UI-02/04, RC-03/04/07. + Reference the contract's [non-GPS inventory](iracing-ibt-reconciliation-contract.md#non-gps-enrichment-inventory) when explaining addedChannels; distinguish measured source enrichment from new analysis availability. Do not advertise continuous tyre data from channel presence, conflate surface/carcass readings, or claim slip/suspension/braking analyses were added. RE-04b verifies actual additions and truthful existing source-status presentation; RC-03/04/07/08, UI-02/04. +3. **Reactive replay refresh — UI-03; RC-05/06/07/08.** Keep existing capture polling; poll per-session status while its relevant surface is open, including every session represented in comparison. Refetch on focus/reconnect and invalidate actual replay/compare/scene queries when the contract's replay-evidence epoch changes, including loss/corruption/restoration with unchanged attachment IDs. A status-only revision change must not rebuild telemetry. Reject replay responses older than an already observed epoch; do not present old supplemental values as current while refresh is pending or failed. Preserve usable primary telemetry, selected lap, playback position, camera/focus and session expansion. Never add a new WebSocket event contract or client matching loop. RE-08a covers open hot-cache transitions, duplicate notifications, disabled discovery and restart/reconnect recovery. +4. **Geometry truth and access — UI-04; RC-04/06/07.** Consume the selected lap's `reconciliation.geometry` from its semantic replay response under the shared [geometry safety policy](iracing-ibt-reconciliation-contract.md#geometry-safety). Match lap/evidence/reference/policy keys before showing an accepted overlay; never use a session-wide result or another lap's acceptance. Comparisons preserve each lap's own result. Render approximate accepted reference, unavailable reference or rejected fit; measured path survives reference rejection or pending revalidation. Do not imply geographic telemetry automatically georeferences SVG or enables measured live GPS/native tire trails. Preserve global auth/not-found handling. Make status/coverage available as text, not color/hover alone, with polite progress announcements and alert semantics for failures. Update all existing locales; inspect 390px/1440px and long translated reasons without overflow. RE-07a proves mixed-lap selection/comparison and stale response/reference/policy rejection. +5. **Production integration — UI-01–04; RC-01/03/04/05/06/07/08.** Integrate real capture/status/replay APIs, remove development fixtures from runtime paths, and contribute UI assertions to shared E2E scenarios. Coordinate one documentation/changelog update with backend owner, not a separate cosmetic ADR or duplicate release note. +6. **Durable workflow tracking — UI-05; RC-05/07/09.** Display server-projected workflow stage and persisted next-wake information using the shared nullable workflow field. Distinguish waiting/retrying from active processing without a browser-owned retry engine or fabricated progress percentage. Backend restart/reconnect restores the same execution and recorded stage; a new evidence-keyed execution is an update, not a second session. Null workflow metadata means not yet admitted, not completed. A durable-store service failure is an explicit status error while cached primary replay stays usable. + Consume `reconciliation.store` independently of capture status: journal unavailability shows an explicit reconciliation error but leaves existing capture connect/disconnect usable. Handle the session endpoint's typed HTTP 503 without erasing cached primary replay. RE-09 covers this boundary. + Worker execution is backend-only and requires no new browser operation. Worker restart preserves execution/stage display; bootstrap/channel failure uses the shared service-error boundary while capture controls and cached primary replay remain available. UI assertions in RE-10 cover recovery and no false completion; UI-03/05, RC-07/09/10. + Consume the summary's worker lifecycle independently of journal readiness, and handle `reconciliation-worker-unavailable` HTTP 503 explicitly; absent ready acknowledgement must never be presented as running reconciliation. No browser worker controls are added. + +## Edge cases and E2E mapping + +| Case | Expected handling and proof | +| --- | --- | +| Enabled startup finds historical matching IBT; normal live run closes new IBT | No upload/confirmation; automatic counts and session provenance update. RE-01/02; UI-01/02. | +| Disabled capture or unsupported host | Existing opt-in/availability remains authoritative; no claim that discovery is running or completed. Previously published replay remains usable. RE-02; UI-01/02. | +| No file, writer still open, active capture | Waiting reason and unchanged primary replay; do not ask user to import or claim coordinates available. RE-04/05/06; UI-01/02. | +| Wrong/ambiguous identity, corrupt source, changed evidence | Display specific attention state; no guessed selection or successful attachment; automatic recovery reflected after input changes. RE-03/05; UI-01/02/03. | +| Partial coverage, missing heading, invalid GPS, bad fit | Display actual coverage and unavailable/rejected geometry; preserve measured path where valid and primary data everywhere. RE-04/07; UI-02/04. | +| Complete accepted lap and partial/rejected lap in one session; reference/policy changes | Read each selected lap's keyed HTTP geometry; invalidate obsolete fit keys, never promote rejection through another lap, retain measured path. RE-07a; UI-02/04. | +| Primary-only, supplemented, invalid and mixed-component values | Render attribution from required HTTP provenance arrays without relabeling primary values or exposing source paths; empty/missing observations do not fabricate provenance. RE-07a; UI-02/04. | +| Lost status/replay response, reconnect, publication while viewer is open | Existing cached primary data stays usable with warning; real refetch/revision invalidation restores enriched view without selection reset. RE-07; UI-03. | +| Attachment loss/corruption/restoration and export/import | Unchanged attachment IDs do not prevent epoch-driven refresh; open analyse/compare removes then restores only supplemental evidence, agrees with cold reads and survives reconnect. Primary replay remains usable; imported provenance is preserved. RE-08/08a; UI-02/03/04. | +| Mobile, keyboard, long locale, inaccessible session | Readable bounded UI and semantic status; existing access boundary applies, no private source leak. RE-02/07/08; UI-04. | +| Backend restart during activity, durable wait or retry; journal unavailable | Restore server-owned stage/wake state and execution identity without client restart commands or reset-to-zero assumptions; distinguish explicit service failure from successful completion. RE-09; UI-03/05. | +| Dedicated reconciliation worker crash/replacement or compiled bootstrap failure | Preserve primary replay/capture controls; show explicit reconciliation unavailability, then restore durable stage after worker recovery without creating a new session or claiming completion from a stale message. RE-10; UI-03/05. | + +## Verification and completion dependency + +Extend existing seeded Playwright sessions/analysis/compare suite against real HTTP backend, dedicated Bun reconciliation worker and SQL-backed durable Effect journal. UI owner supplies assertions for RE-01–10; backend integration owner executes shared matrix and records RC-01–10/UI-01–05 evidence. RE-09 proves backend-process recovery; RE-10 proves worker-only recovery and truthful status/capture behavior after worker bootstrap/channel failure. Verify actual measured scene, not only JSON/status screenshots. Development mocks never satisfy integration acceptance. + +After implementation run changed-path browser scenarios, accessibility/responsive checks and applicable existing project gates. Include real Windows native collection → automatic matching → browser replay evidence before acceptance; unavailable Windows/simulator access remains a blocker. Record unexecuted checks explicitly. No tests, implementation, commits or checkpoints are performed for this planning-only request. diff --git a/docs/reference/adapters/iracing.md b/docs/reference/adapters/iracing.md index 112da4601..296dc4b31 100644 --- a/docs/reference/adapters/iracing.md +++ b/docs/reference/adapters/iracing.md @@ -31,15 +31,19 @@ The Analyse page accepts `.ibt` recordings: 1. Upload streams to a temporary staging file. 2. Preview scans telemetry rows and reports identity, duration, channel coverage, driving rows, and complete-lap candidates without creating a session. 3. A recording without a complete lap is rejected. -4. Confirmation replays the useful driving window through an isolated iRacing parser and the common `Pipeline`. -5. RaceIQ creates normal session/lap rows plus a canonical `.bin` raw capture. The source `.ibt` is not retained as `sessions.rawFile`. +4. Confirmation decodes the useful driving window directly into telemetry packets and feeds the common import pipeline at the recording's native cadence. +5. RaceIQ retains a byte-identical `.ibt` as `sessions.rawFile`, marks the session source as `ibt`, and stores lap windows as packet indices. It does not create a derived `.bin` capture. -Cancel, commit, and preview expiry remove the staged file. Confirmed previews expire after 30 minutes. +Cancel, commit, and preview expiry remove the staged file. Confirmed previews expire after 30 minutes. A successful import keeps its owned original-format recording; failed imports roll back their sessions and owned recording. Replay and reprocessing decode that retained IBT, including original native channels, rather than converting it into BIN. + +Exports of imported IBT telemetry retain the complete original recording, even when initiated from a single lap. ZIP archives contain the original `.ibt` rather than a converted lap capture; re-import uses the same production IBT preview and commit service. ## Analysis boundaries - The transport is Windows-local. - Live SDK telemetry provides lap distance and native sector starts, but no stable world-space racing line. The adapter uses `lap-distance` coordinates rather than fabricating world positions. +- Single-lap replay resolves scene positions before creating its playback controller: native IBT GPS positions take precedence; live-only lap fractions use the official track projection when available. Missing GPS samples remain gaps. When compass heading is absent, adjacent GPS positions supply visualization-only direction. +- Player iRacing replay interpolates capture stalls up to 250 ms for continuous playback. Longer outages and missing position channels remain unavailable; interpolation does not modify recorded telemetry or other simulators' gap policies. - The live SDK does not provide per-wheel rotation, slip ratio, slip angle, or tyre-force channels suitable for continuous analysis. - Tyre temperatures and tyre condition are pit snapshots; cold tyre pressures are static setup values, not live hot pressure. - RaceIQ reads telemetry and session identity. It does not write garage setup values back to iRacing. @@ -53,4 +57,5 @@ Cancel, commit, and preview expiry remove the staged file. Confirmed previews ex - `packages/game-iracing/src/normalizer.ts` — canonical telemetry mapping - `packages/game-iracing/src/index.ts` — server adapter - `packages/game-iracing/src/ibt-reader.ts` — streaming IBT decoder -- `server/games/iracing/import-ibt.ts` — preview, staging, commit, and cleanup +- `packages/game-iracing/src/ibt-source.ts` — original-format packet and native-channel decoding +- `apps/backend/src/imports/iracing-ibt.ts` — preview, original-file publication, commit, and cleanup diff --git a/package.json b/package.json index 82124301c..2c1974400 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,6 @@ "iracing:track-maps:seed": "bun packages/tooling-data/src/iracing/seed-track-maps.ts", "gzip:recording": "bun packages/tooling-data/src/telemetry/recordings/gzip-recording.ts", "fixtures:iracing": "bun packages/tooling-data/src/iracing/generate-recording-fixture.ts", - "fixtures:iracing:seed": "bun packages/tooling-data/src/iracing/generate-seed-fixture.ts", "build:installer": "bun packages/tooling-build/src/build/build-installer.ts", "mastra:studio": "portless studio bun scripts/dev/mastra-studio.ts", "mastra:migrate": "DATA_DIR=\"$PWD/data\" bunx --bun mastra migrate --dir apps/backend/src/mastra", diff --git a/packages/capture-formats/src/iracing/session-info.ts b/packages/capture-formats/src/iracing/session-info.ts index 5d858fc84..76a57975e 100644 --- a/packages/capture-formats/src/iracing/session-info.ts +++ b/packages/capture-formats/src/iracing/session-info.ts @@ -163,3 +163,25 @@ export function parseIRacingSessionInfo( engineCylinderCount: toNumber(findScalar(lines, "DriverCarEngCylinderCount"), 0), }; } + +/** Extract numeric geographic track origin values retained verbatim in v3 frames. */ +export function parseIRacingTrackGeography( + yaml: string, +): Readonly> { + const lines = yaml.split(/\r?\n/); + const fields = [ + ["TrackLatitude", "SessionInfo.WeekendInfo.TrackLatitude"], + ["TrackLongitude", "SessionInfo.WeekendInfo.TrackLongitude"], + ["TrackAltitude", "SessionInfo.WeekendInfo.TrackAltitude"], + ] as const; + const result: Record = {}; + for (const [field, sourcePath] of fields) { + // iRacing writes track latitude/longitude in degrees with a misleading "m" suffix. + const raw = findScalar(lines, field); + const match = raw?.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+))(?:\s+(?:m|deg))?$/i); + if (!match) continue; + const value = Number(match[1]); + if (Number.isFinite(value)) result[sourcePath] = value; + } + return result; +} diff --git a/packages/capture-splitting/src/client.ts b/packages/capture-splitting/src/client.ts index 9e9f7dc03..8eb45c9e9 100644 --- a/packages/capture-splitting/src/client.ts +++ b/packages/capture-splitting/src/client.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { Schema } from "effect"; import { SplitRequestSchema, WorkerMessageSchema, type SplitRequest, type WorkerMessage } from "./protocol"; import { SplitFailure } from "./index"; diff --git a/packages/game-iracing-metadata/src/index.ts b/packages/game-iracing-metadata/src/index.ts index 83885253e..1e2ceb99a 100644 --- a/packages/game-iracing-metadata/src/index.ts +++ b/packages/game-iracing-metadata/src/index.ts @@ -41,16 +41,15 @@ export const iracingAdapter: GameAdapter = { routePrefix: "iracing", telemetry: { fuel: { packetUnit: "litre", binding: { kind: "value", semanticId: "fuel.fuel" } }, - tireTemperature: { packetUnit: "celsius", binding: { kind: "value", semanticId: "tire.temperature.carcass.middle" } }, - tireCarcassProfile: { source: "direct", freshness: "pit-snapshot", binding: { kind: "group", required: ["tire.temperature.carcass.left", "tire.temperature.carcass.middle", "tire.temperature.carcass.right"] } }, - tirePressure: { packetUnit: "psi", binding: { kind: "value", semanticId: "tires.tire-pressure" } }, + // Unit metadata is retained; pit snapshots are not live temperature channels. + tireTemperature: { packetUnit: "celsius" }, clutch: { source: "direct", freshness: "continuous", binding: { kind: "value", semanticId: "inputs.clutch" } }, pitStatus: { source: "direct", freshness: "continuous", binding: { kind: "value", semanticId: "race.on-pit-road" } }, analysis: { balance: { source: "derived", confidence: "high", binding: { kind: "derived", derivation: "physical-balance-v1", requires: ["motion.speed", "motion.acceleration-x", "motion.angular-velocity-y"] } }, gForce: { source: "derived", confidence: "exact", binding: { kind: "derived", derivation: "g-force-v1", requires: ["motion.acceleration-x", "motion.acceleration-z"] } }, gripDemand: { source: "unavailable", reason: "source-limitation" }, - tireTemperature: { source: "direct", freshness: "pit-snapshot", display: "per-wheel", binding: { kind: "value", semanticId: "tire.temperature.carcass.middle" } }, + tireTemperature: { source: "unavailable", reason: "source-limitation" }, surface: { source: "direct", freshness: "continuous", display: "vehicle", binding: { kind: "value", semanticId: "identity.player-track-surface" } }, slipRatio: { source: "unavailable", reason: "source-limitation" }, slipAngle: { source: "unavailable", reason: "source-limitation" }, @@ -58,7 +57,7 @@ export const iracingAdapter: GameAdapter = { wheelRotation: { source: "unavailable", reason: "source-limitation" }, tireHealth: { source: "direct", freshness: "pit-snapshot", display: "per-wheel", binding: { kind: "value", semanticId: "tires.tire-wear" } }, tireWearRate: { source: "unavailable", reason: "source-limitation" }, - tirePressure: { source: "direct", freshness: "static", display: "cold-pressure", binding: { kind: "value", semanticId: "tires.tire-pressure" } }, + tirePressure: { source: "unavailable", reason: "source-limitation" }, suspensionTravel: { source: "direct", freshness: "continuous", display: "millimeters", binding: { kind: "value", semanticId: "suspension.suspension-travel-m" } }, suspensionCompressionBias: { source: "unavailable", reason: "source-limitation" }, }, diff --git a/packages/game-iracing/package.json b/packages/game-iracing/package.json index de07c113e..d81a488fd 100644 --- a/packages/game-iracing/package.json +++ b/packages/game-iracing/package.json @@ -14,6 +14,9 @@ "./source": "./src/source.ts", "./track-map-svg": "./src/track-map-svg.ts", "./track-map": "./src/track-map.ts", + "./incident-report": "./src/incident-report.ts", + "./ibt-source": "./src/ibt-source.ts", + "./disk-logging": "./src/disk-logging.ts", "./test-support/games/iracing-ibt": "./test/support/games/iracing-ibt.ts", "./test-support/recordings": "./test/support/recordings.ts", "./game-parser": "./src/game-parser.ts" diff --git a/packages/game-iracing/src/disk-logging.ts b/packages/game-iracing/src/disk-logging.ts new file mode 100644 index 000000000..770d82b48 --- /dev/null +++ b/packages/game-iracing/src/disk-logging.ts @@ -0,0 +1,113 @@ +import { dlopen, FFIType, ptr, toArrayBuffer, type Library } from "bun:ffi"; +import { join, resolve } from "node:path"; + +const HWND_BROADCAST = 0xffffn; +const TELEMETRY_COMMAND = 10; +const INVALID_HANDLE_VALUE = 0xffffffffffffffffn; +const KF_FLAG_DONT_VERIFY = 0x4000; + +const user32Symbols = { + RegisterWindowMessageW: { args: [FFIType.ptr], returns: FFIType.u32 }, + SendNotifyMessageW: { args: [FFIType.u64, FFIType.u32, FFIType.u64, FFIType.i64], returns: FFIType.bool }, +} as const; +const kernel32Symbols = { + CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.bool }, + MoveFileW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool }, + GetLastError: { args: [], returns: FFIType.u32 }, + lstrlenW: { args: [FFIType.u64], returns: FFIType.i32 }, +} as const; +const shell32Symbols = { + SHGetKnownFolderPath: { args: [FFIType.ptr, FFIType.u32, FFIType.u64, FFIType.ptr], returns: FFIType.i32 }, +} as const; +const ole32Symbols = { + CoTaskMemFree: { args: [FFIType.u64], returns: FFIType.void }, +} as const; + +type User32Library = Library; +type Kernel32Library = Library; +type Shell32Library = Library; +type Ole32Library = Library; +let user32: User32Library | null = null; +let kernel32: Kernel32Library | null = null; +let shell32: Shell32Library | null = null; +let ole32: Ole32Library | null = null; +let broadcastMessage = 0; + +function requireWindows(): void { + if (process.platform !== "win32") throw new Error("Native iRacing disk capture requires Windows"); +} + +/** Uses the current user's Known Documents folder, including Windows redirection. */ +export function resolveIRacingTelemetryDirectory(): string { + requireWindows(); + const shell = shell32 ??= dlopen("shell32.dll", shell32Symbols); + const kernel = kernel32 ??= dlopen("kernel32.dll", kernel32Symbols); + const ole = ole32 ??= dlopen("ole32.dll", ole32Symbols); + // FOLDERID_Documents: FDD39AD0-238F-46AF-ADB4-6C85480369C7, GUID memory order. + const folderId = Buffer.from([0xd0, 0x9a, 0xd3, 0xfd, 0x8f, 0x23, 0xaf, 0x46, 0xad, 0xb4, 0x6c, 0x85, 0x48, 0x03, 0x69, 0xc7]); + const output = Buffer.alloc(8); + const result = shell.symbols.SHGetKnownFolderPath(ptr(folderId), KF_FLAG_DONT_VERIFY, 0n, ptr(output)); + const address = output.readBigUInt64LE(0); + try { + if (result < 0 || !address) throw new Error(`Windows Documents folder lookup failed (${result})`); + const length = kernel.symbols.lstrlenW(address); + if (length <= 0 || length > 32767) throw new Error("Windows Documents folder has an invalid path length"); + const documents = Buffer.from(toArrayBuffer(Number(address), 0, length * 2)).toString("utf16le"); + return join(documents, "iRacing", "telemetry"); + } finally { + if (address) ole.symbols.CoTaskMemFree(address); + } +} + +/** Queues the SDK telemetry command; only SDK flags can confirm enabled/active logging. */ +export function setIRacingDiskLogging(enabled: boolean): void { + requireWindows(); + const user = user32 ??= dlopen("user32.dll", user32Symbols); + if (!broadcastMessage) { + const name = Buffer.from("IRSDK_BROADCASTMSG\0", "utf16le"); + broadcastMessage = user.symbols.RegisterWindowMessageW(ptr(name)); + if (!broadcastMessage) throw new Error("Unable to register the iRacing SDK broadcast message"); + } + // SDK docs-linked pyirsdk: command in low word, stop=0/start=1 in high word. + const command = BigInt(TELEMETRY_COMMAND | (enabled ? 1 << 16 : 0)); + if (!user.symbols.SendNotifyMessageW(HWND_BROADCAST, broadcastMessage, command, 0n)) { + const kernel = kernel32 ??= dlopen("kernel32.dll", kernel32Symbols); + throw new Error(`iRacing telemetry command failed (Windows error ${kernel.symbols.GetLastError()})`); + } +} + +function windowsFileName(path: string) { + const absolute = resolve(path); + const extended = absolute.startsWith("\\\\?\\") ? absolute + : absolute.startsWith("\\\\") ? `\\\\?\\UNC\\${absolute.slice(2)}` : `\\\\?\\${absolute}`; + return Buffer.from(`${extended}\0`, "utf16le"); +} + +/** A paused writer still owns its file; stable size alone is not evidence of completion. */ +export function isIRacingTelemetryFileClosed(path: string): boolean { + requireWindows(); + const kernel = kernel32 ??= dlopen("kernel32.dll", kernel32Symbols); + const name = windowsFileName(path); + const handle = BigInt(kernel.symbols.CreateFileW(ptr(name), 0x80000000, 0, null, 3, 0x80, 0n)); + if (!handle || handle === INVALID_HANDLE_VALUE) { + const error = kernel.symbols.GetLastError(); + // Disappearance between scans and active writers are normal collection races. + if (error === 2 || error === 3 || error === 32 || error === 33) return false; + throw new Error(`Unable to probe iRacing telemetry file (Windows error ${error})`); + } + if (!kernel.symbols.CloseHandle(handle)) throw new Error("Unable to close the iRacing telemetry probe handle"); + return true; +} + +/** Same-volume atomic publication without replacing any existing archive file. */ +export function publishIRacingTelemetryFile(stage: string, destination: string): boolean { + requireWindows(); + const kernel = kernel32 ??= dlopen("kernel32.dll", kernel32Symbols); + const from = windowsFileName(stage); + const to = windowsFileName(destination); + if (kernel.symbols.MoveFileW(ptr(from), ptr(to))) return true; + const error = kernel.symbols.GetLastError(); + if (error === 80 || error === 183) return false; + throw new Error(`Unable to publish iRacing telemetry file (Windows error ${error})`); +} diff --git a/packages/game-iracing/src/ibt-preview.ts b/packages/game-iracing/src/ibt-preview.ts index f553a244c..3f5e12aa8 100644 --- a/packages/game-iracing/src/ibt-preview.ts +++ b/packages/game-iracing/src/ibt-preview.ts @@ -1,13 +1,9 @@ import { basename } from "node:path"; import { IRacingIbtReader } from "./ibt-reader"; import { parseIRacingSessionInfo } from "@raceiq/capture-formats/iracing/session-info"; -import { - IRacingSourceFrameEncoder, - type IRacingSessionSnapshot, - type IRacingValue, -} from "@raceiq/capture-formats/iracing/source-frame"; +import type { IRacingSessionSnapshot, IRacingValue } from "@raceiq/capture-formats/iracing/source-frame"; const DRIVING_SPEED_MPS = 1; -const TIMING_ROLLOVER_SECONDS = 5; +export const TIMING_ROLLOVER_SECONDS = 5; const REQUIRED_IMPORT_VARIABLES = [ "SessionTime", "SessionNum", "IsOnTrack", "Speed", "Lap", "LapLastLapTime", "LapCurrentLapTime", @@ -217,54 +213,3 @@ export async function previewIbtFile( await reader.stop(); } } - -export async function* ibtFrames( - path: string, - preview: IbtImportPreview, -): AsyncGenerator { - const reader = new IRacingIbtReader(path); - const frameEncoder = new IRacingSourceFrameEncoder(); - const sessionCache = new Map(); - const lastRecord = Math.min( - preview.recordCount - 1, - (preview.lastDrivingRecord ?? 0) + - Math.ceil(preview.tickRate * TIMING_ROLLOVER_SECONDS), - ); - reader.start(); - try { - for (;;) { - const snapshot = reader.readLatest(); - if (!snapshot) break; - const recordIndex = reader.recordsRead - 1; - if ( - preview.firstDrivingRecord === null || - recordIndex < preview.firstDrivingRecord - ) { - continue; - } - if (recordIndex > lastRecord) break; - - const sessionNum = Math.trunc( - numeric(snapshot.values, "SessionNum"), - ); - let session = sessionCache.get(sessionNum); - if (!session) { - session = parseIRacingSessionInfo( - snapshot.sessionInfo, - sessionNum, - ); - sessionCache.set(sessionNum, session); - } - yield frameEncoder.encode({ - schemaVersion: 3, - session, - values: snapshot.values, - sessionInfo: snapshot.sessionInfo, - sessionInfoUpdate: snapshot.sessionInfoUpdate, - }); - } - } finally { - await reader.stop(); - } -} - diff --git a/packages/game-iracing/src/ibt-source.ts b/packages/game-iracing/src/ibt-source.ts new file mode 100644 index 000000000..7027e743a --- /dev/null +++ b/packages/game-iracing/src/ibt-source.ts @@ -0,0 +1,59 @@ +import { parseIRacingSessionInfo, parseIRacingTrackGeography } from "@raceiq/capture-formats/iracing/session-info"; +import type { IRacingSessionSnapshot, IRacingSourceFrame, IRacingValue } from "@raceiq/capture-formats/iracing/source-frame"; +import type { ImportedTelemetrySource } from "@raceiq/backend-core/games/types"; +import { IRacingIbtReader } from "./ibt-reader"; +import { createIRacingParserState, normalizeIRacingFrame } from "./normalizer"; +import { IbtImportError, previewIbtFile, TIMING_ROLLOVER_SECONDS, type IbtImportPreview } from "./ibt-preview"; +export async function readIbtSource( + path: string, + suppliedPreview?: IbtImportPreview, +): Promise { + const preview = suppliedPreview ?? await previewIbtFile(path); + if (!preview.canImport) throw new IbtImportError(preview.reason ?? "IBT recording cannot be imported"); + if (preview.firstDrivingRecord === null) throw new IbtImportError("IBT recording contains no importable driving window"); + const lastRecord = Math.min( + preview.recordCount - 1, + preview.lastDrivingRecord! + Math.ceil(preview.tickRate * TIMING_ROLLOVER_SECONDS), + ); + const reader = new IRacingIbtReader(path); + const parserState = createIRacingParserState(); + const sessions = new Map(); + const packets: ImportedTelemetrySource["packets"] = []; + const nativeValues: Readonly>[] = []; + let trackGeography: Readonly> | undefined; + reader.start(); + try { + while (true) { + const snapshot = reader.readLatest(); + if (!snapshot) break; + const recordIndex = reader.recordsRead - 1; + if (recordIndex < preview.firstDrivingRecord) continue; + if (recordIndex > lastRecord) break; + const rawValues = snapshot.values as Record; + const sessionNumValue = rawValues.SessionNum; + const sessionNum = typeof sessionNumValue === "number" && Number.isFinite(sessionNumValue) + ? Math.trunc(sessionNumValue) + : 0; + let session = sessions.get(sessionNum); + if (!session) { + session = parseIRacingSessionInfo(snapshot.sessionInfo, sessionNum); + sessions.set(sessionNum, session); + } + const frame: IRacingSourceFrame = { + schemaVersion: 3, + session, + values: rawValues, + sessionInfo: snapshot.sessionInfo, + sessionInfoUpdate: snapshot.sessionInfoUpdate, + }; + packets.push(normalizeIRacingFrame(frame, parserState)); + trackGeography ??= parseIRacingTrackGeography(snapshot.sessionInfo); + Object.assign(rawValues, trackGeography); + // IRacingIbtReader allocates a fresh values record per row; enrich and retain it directly. + nativeValues.push(rawValues); + } + } finally { + await reader.stop(); + } + return { packets, nativeValues }; +} diff --git a/packages/game-iracing/src/incident-report.ts b/packages/game-iracing/src/incident-report.ts new file mode 100644 index 000000000..f483b5b4d --- /dev/null +++ b/packages/game-iracing/src/incident-report.ts @@ -0,0 +1,128 @@ +import type { IRacingEvidenceStatus, IRacingIncidentCoverageReason, IRacingIncidentEvent, IRacingIncidentReport } from "@raceiq/shared/racing/sessions/iracing-incidents"; + +export interface IRacingIncidentLapInput { lapId: number; lapNumber: number; eligibleForCleanStreak: boolean } +export interface IRacingIncidentSample { + kind: "sample"; lapId: number; rawOffset: number; nativeSessionUid: string; driverCarIdx: number; + sessionTick: number | null; sessionTimeSec: number | null; incidentPoints: number | null; surface: number | null; +} +export type IRacingIncidentInput = IRacingIncidentSample | { kind: "break"; reason: "capture-gap" | "segment-boundary" | "decode-failure"; lapId?: number }; +export type IRacingIncidentEventEvidence = IRacingIncidentEvent & { seekReference: { nativeSessionUid: string; sessionTick: number | null; sessionTimeSec: number } }; +export interface IRacingIncidentEvidence extends Omit { + incidentStatus: IRacingEvidenceStatus; surfaceStatus: IRacingEvidenceStatus; reasons: IRacingIncidentCoverageReason[]; events: IRacingIncidentEventEvidence[]; +} +interface LapState { + countSamples: number; countPairs: number; countBroken: boolean; points: number; + surfaceSamples: number; surfacePairs: number; surfaceBroken: boolean; spans: number; seconds: number; + incomingLapId: number | null; firstEpoch: number | null; lastEpoch: number | null; +} +const timeValid = (value: number | null): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; +const countValid = (value: number | null): value is number => timeValid(value) && Number.isInteger(value); +const surfaceValid = (value: number | null): value is number => value === 0 || value === 1 || value === 2 || value === 3; +type OffTrackEvent = Extract; + +/** Reduce only owned observations, in capture order; breaks never invent missing evidence. */ +export async function reduceIRacingIncidents(laps: readonly IRacingIncidentLapInput[], input: Iterable | AsyncIterable): Promise { + const states = new Map(laps.map(lap => [lap.lapId, { countSamples: 0, countPairs: 0, countBroken: false, points: 0, surfaceSamples: 0, surfacePairs: 0, surfaceBroken: false, spans: 0, seconds: 0, incomingLapId: null, firstEpoch: null, lastEpoch: null }])); + const lapNumbers = new Map(laps.map(lap => [lap.lapId, lap.lapNumber])); + const reasons = new Set(); + const events: IRacingIncidentEventEvidence[] = []; + let previous: IRacingIncidentSample | null = null; + let counter: IRacingIncidentSample | null = null; + let location: IRacingIncidentSample | null = null; + let span: OffTrackEvent | null = null; + let epoch = 0; + let interrupted = false; + let pendingBreakLapId: number | null = null; + const closeSpan = (endObserved: boolean) => { if (span) span.endObserved = endObserved; span = null; }; + const breakBoth = (reason: IRacingIncidentCoverageReason, lapId?: number) => { + reasons.add(reason); epoch++; + if (lapId === undefined || lapId === previous?.lapId) pendingBreakLapId ??= previous?.lapId ?? null; + if (lapId !== undefined) { const state = states.get(lapId); if (state) { state.countBroken = true; state.surfaceBroken = true; } } + closeSpan(false); previous = null; counter = null; location = null; + }; + for await (const sample of input) { + if (sample.kind === "break") { breakBoth(sample.reason, sample.lapId); continue; } + const state = states.get(sample.lapId); + if (!state) { breakBoth("capture-gap"); continue; } + if (!timeValid(sample.sessionTimeSec)) { breakBoth("missing-evidence", sample.lapId); continue; } + if (previous) { + const insideLap = previous.lapId === sample.lapId ? sample.lapId : undefined; + if (previous.nativeSessionUid !== sample.nativeSessionUid || previous.driverCarIdx !== sample.driverCarIdx) breakBoth("segment-boundary", insideLap); + else if (sample.sessionTimeSec < previous.sessionTimeSec!) breakBoth("time-reset", insideLap); + else if (sample.sessionTimeSec - previous.sessionTimeSec! > 0.1 + 0.001) breakBoth("capture-gap", insideLap); + else if (sample.sessionTimeSec === previous.sessionTimeSec && sample.incidentPoints === previous.incidentPoints && sample.surface === previous.surface && sample.lapId === previous.lapId) continue; + } + if (pendingBreakLapId !== null) { + interrupted = true; + if (pendingBreakLapId === sample.lapId) { state.countBroken = true; state.surfaceBroken = true; } + pendingBreakLapId = null; + } + if (previous && previous.lapId !== sample.lapId) closeSpan(false); + if (countValid(sample.incidentPoints)) { + state.countSamples++; + if (state.firstEpoch === null) { + state.firstEpoch = epoch; + state.incomingLapId = counter?.lapId ?? null; + } + if (counter) { + if (sample.incidentPoints < counter.incidentPoints!) { + reasons.add("counter-reset"); state.countBroken = true; epoch++; + } else { + state.countPairs++; + const points = sample.incidentPoints - counter.incidentPoints!; + state.points += points; + if (points > 0) events.push({ kind: "incident-points", id: `incident-points:${sample.lapId}:${sample.rawOffset}`, lapId: sample.lapId, lapNumber: lapNumbers.get(sample.lapId)!, sessionTimeSec: sample.sessionTimeSec, previousSessionTimeSec: counter.sessionTimeSec!, points, replay: null, seekReference: { nativeSessionUid: sample.nativeSessionUid, sessionTick: sample.sessionTick, sessionTimeSec: sample.sessionTimeSec } }); + } + } + state.lastEpoch = epoch; + counter = sample; + } else { + reasons.add("missing-evidence"); state.countBroken = true; counter = null; epoch++; + } + if (surfaceValid(sample.surface)) { + state.surfaceSamples++; + const contiguous = location !== null && location.lapId === sample.lapId; + if (contiguous) state.surfacePairs++; + if (sample.surface === 0) { + if (!span) { + span = { kind: "off-track", id: `off-track:${sample.lapId}:${sample.rawOffset}`, lapId: sample.lapId, lapNumber: lapNumbers.get(sample.lapId)!, sessionTimeSec: sample.sessionTimeSec, endSessionTimeSec: sample.sessionTimeSec, observedSeconds: 0, startObserved: contiguous && location!.surface !== 0, endObserved: false, replay: null, seekReference: { nativeSessionUid: sample.nativeSessionUid, sessionTick: sample.sessionTick, sessionTimeSec: sample.sessionTimeSec } }; + state.spans++; events.push(span); + } else { + const elapsed = sample.sessionTimeSec - span.endSessionTimeSec; + span.endSessionTimeSec = sample.sessionTimeSec; span.observedSeconds += elapsed; state.seconds += elapsed; + } + } else closeSpan(contiguous); + location = sample; + } else { + reasons.add("missing-evidence"); state.surfaceBroken = true; closeSpan(false); location = null; + } + previous = sample; + } + closeSpan(false); + const summaries = laps.map(lap => { + const state = states.get(lap.lapId)!; + return { lapId: lap.lapId, lapNumber: lap.lapNumber, + incidentStatus: (state.countSamples === 0 ? "unavailable" : state.countSamples >= 2 && state.countPairs > 0 && !state.countBroken ? "available" : "partial") as IRacingEvidenceStatus, + surfaceStatus: (state.surfaceSamples === 0 ? "unavailable" : state.surfaceSamples >= 2 && state.surfacePairs > 0 && !state.surfaceBroken ? "available" : "partial") as IRacingEvidenceStatus, + observedIncidentPoints: state.countPairs > 0 ? state.points : null, + offTrackSpans: state.surfaceSamples > 0 ? state.spans : null, + observedOffTrackSeconds: state.surfacePairs > 0 ? state.seconds : null }; + }); + const aggregate = (key: "incidentStatus" | "surfaceStatus"): IRacingEvidenceStatus => summaries.every(lap => lap[key] === "unavailable") ? "unavailable" : !interrupted && summaries.every(lap => lap[key] === "available") ? "available" : "partial"; + let longest: number | null = null; + let streak = 0; + for (let index = 0; index < laps.length; index++) { + const lap = laps[index], summary = summaries[index], state = states.get(lap.lapId)!; + const prior = laps[index - 1]; + const priorState = prior ? states.get(prior.lapId)! : null; + if (lap.eligibleForCleanStreak && summary.incidentStatus === "available") { + if (summary.observedIncidentPoints === 0) { + const joins = prior && lap.lapNumber === prior.lapNumber + 1 && state.incomingLapId === prior.lapId && state.firstEpoch === priorState!.lastEpoch; + streak = joins ? streak + 1 : 1; + } else streak = 0; + longest = Math.max(longest ?? 0, streak); + } else streak = 0; + } + const sum = (key: "observedIncidentPoints" | "offTrackSpans" | "observedOffTrackSeconds") => summaries.some(lap => lap[key] !== null) ? summaries.reduce((total, lap) => total + (lap[key] ?? 0), 0) : null; + return { incidentStatus: aggregate("incidentStatus"), surfaceStatus: aggregate("surfaceStatus"), reasons: [...reasons], observedIncidentPoints: sum("observedIncidentPoints"), offTrackSpans: sum("offTrackSpans"), observedOffTrackSeconds: sum("observedOffTrackSeconds"), longestCleanLapStreak: longest, laps: summaries, events }; +} diff --git a/packages/game-iracing/src/index.ts b/packages/game-iracing/src/index.ts index c9e00c2ca..876e63d0d 100644 --- a/packages/game-iracing/src/index.ts +++ b/packages/game-iracing/src/index.ts @@ -13,6 +13,7 @@ import { type IRacingParserState, } from "./normalizer"; +import { readIbtSource } from "./ibt-source"; const IRACING_SYSTEM_PROMPT = `You are an expert iRacing driver coach and race engineer. Analyze the supplied lap telemetry and give specific, data-grounded advice. @@ -68,6 +69,9 @@ export const iracingServerAdapter: ServerGameAdapter = { iracingParser.primeParserState(buf, state as never); }, createParserState: iracingParser.createParserState, + async loadImportedSource(path) { + return readIbtSource(path); + }, captureParserCheckpoint(state) { return captureIRacingParserCheckpoint(state as IRacingParserState); }, restoreParserCheckpoint(_state, payload) { return restoreIRacingParserCheckpoint(payload); }, diff --git a/packages/game-iracing/src/normalizer.ts b/packages/game-iracing/src/normalizer.ts index 8450be376..14bff03a7 100644 --- a/packages/game-iracing/src/normalizer.ts +++ b/packages/game-iracing/src/normalizer.ts @@ -205,9 +205,9 @@ function tireWear( scalar(values, `${corner}wearL`, Number.NaN), scalar(values, `${corner}wearM`, Number.NaN), scalar(values, `${corner}wearR`, Number.NaN), - ].filter(Number.isFinite); - if (remaining.length === 0) return 0; - return 1 - clamp(Math.min(...remaining), 0, 1); + ]; + if (remaining.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) return Number.NaN; + return 1 - Math.min(...remaining); } function coldPressurePsi( @@ -276,8 +276,10 @@ export function normalizeIRacingFrame( const { session, values } = frame; const rawLap = Math.max(0, Math.trunc(scalar(values, "Lap", 0))); - const lapDistanceM = Math.max(0, scalar(values, "LapDist", 0)); - const lapDistancePct = clamp(scalar(values, "LapDistPct", 0), 0, 1); + const nativeLapDistance = scalar(values, "LapDist", Number.NaN); + const lapDistanceM = nativeLapDistance >= 0 ? nativeLapDistance : Number.NaN; + const nativeLapFraction = scalar(values, "LapDistPct", Number.NaN); + const lapDistancePct = nativeLapFraction >= 0 && nativeLapFraction <= 1 ? nativeLapFraction : Number.NaN; const sessionTime = Math.max(0, scalar(values, "SessionTime", 0)); const sdkCurrentLapTime = Math.max( 0, @@ -359,9 +361,9 @@ export function normalizeIRacingFrame( // iRacing LatAccel is left-positive; RaceIQ's canonical lateral axis is // right-positive so felt G renders opposite the direction of the turn. - AccelerationX: -scalar(values, "LatAccel", 0), - AccelerationY: scalar(values, "VertAccel", 0), - AccelerationZ: scalar(values, "LongAccel", 0), + AccelerationX: -scalar(values, "LatAccel", Number.NaN), + AccelerationY: scalar(values, "VertAccel", Number.NaN), + AccelerationZ: scalar(values, "LongAccel", Number.NaN), VelocityX: scalar(values, "VelocityX", 0), VelocityY: scalar(values, "VelocityY", 0), @@ -372,9 +374,9 @@ export function normalizeIRacingFrame( AngularVelocityY: -scalar(values, "YawRate", 0), AngularVelocityZ: scalar(values, "RollRate", 0), - Yaw: -scalar(values, "Yaw", 0), - Pitch: scalar(values, "Pitch", 0), - Roll: scalar(values, "Roll", 0), + Yaw: -scalar(values, "Yaw", Number.NaN), + Pitch: scalar(values, "Pitch", Number.NaN), + Roll: scalar(values, "Roll", Number.NaN), NormSuspensionTravelFL: 0, NormSuspensionTravelFR: 0, @@ -438,7 +440,8 @@ export function normalizeIRacingFrame( Accel: input255(scalar(values, "Throttle", 0)), Brake: input255(scalar(values, "Brake", 0)), - Clutch: input255(scalar(values, "Clutch", 0)), + // SDK clutch is 1 when released; canonical inputs measure pedal depression. + Clutch: input255(1 - scalar(values, "Clutch", 1)), HandBrake: 0, Gear: canonicalGear(scalar(values, "Gear", 0)), Steer: steer, diff --git a/packages/game-iracing/src/sdk-reader.ts b/packages/game-iracing/src/sdk-reader.ts index eaed2f5fe..e514bd2aa 100644 --- a/packages/game-iracing/src/sdk-reader.ts +++ b/packages/game-iracing/src/sdk-reader.ts @@ -91,6 +91,8 @@ export const IRACING_TELEMETRY_VARIABLES = [ "RFcoldPressure", "LRcoldPressure", "RRcoldPressure", + "IsDiskLoggingEnabled", + "IsDiskLoggingActive", ] as const; interface IRacingDataBufferHeader { diff --git a/packages/game-iracing/src/source.ts b/packages/game-iracing/src/source.ts index 54b82cdbd..24ef5c2f7 100644 --- a/packages/game-iracing/src/source.ts +++ b/packages/game-iracing/src/source.ts @@ -28,6 +28,7 @@ export interface IRacingTelemetrySourceOptions { reader?: IRacingFrameReader; dispatchRawFrame?: (rawFrame: Buffer, frameTimeMs?: number) => Promise; registerIdentity?: (session: IRacingSessionSnapshot) => Promise; + onSnapshot?: (snapshot: IRacingSdkSnapshot, frameTimeMs: number) => void; pollIntervalMs?: number; recordingEnabled?: boolean; recordingDir?: string; @@ -59,6 +60,9 @@ function numeric(values: Record, name: string, fallback = */ export class IRacingTelemetrySource { private readonly reader: IRacingFrameReader; + private readonly onSnapshot: + | ((snapshot: IRacingSdkSnapshot, frameTimeMs: number) => void) + | undefined; private readonly dispatchRawFrame: (rawFrame: Buffer, frameTimeMs?: number) => Promise; private readonly registerIdentity: | ((session: IRacingSessionSnapshot) => Promise) @@ -86,8 +90,9 @@ export class IRacingTelemetrySource { private drainPromise: Promise | null = null; constructor(options: IRacingTelemetrySourceOptions = {}) { this.reader = options.reader ?? new IRacingSdkReader(); - this.dispatchRawFrame = options.dispatchRawFrame ?? dispatchThroughParser; this.registerIdentity = options.registerIdentity; + this.onSnapshot = options.onSnapshot; + this.dispatchRawFrame = options.dispatchRawFrame ?? dispatchThroughParser; // Poll above the SDK's 60Hz rate so timer phase and brief processing overlap cannot miss ticks. // IRacingSdkReader deduplicates unchanged tick counts. this.pollIntervalMs = options.pollIntervalMs ?? 1000 / 240; @@ -161,7 +166,11 @@ export class IRacingTelemetrySource { } this.pendingSnapshot = null; const frameTimeMs = Date.now(); - + try { + this.onSnapshot?.(snapshot, frameTimeMs); + } catch (error) { + this.logSourceError(error); + } const sessionNum = Math.trunc(numeric(snapshot.values, "SessionNum", 0)); let identity: IRacingSessionSnapshot | undefined; if ( diff --git a/packages/game-iracing/src/track-map-svg.ts b/packages/game-iracing/src/track-map-svg.ts index 12fdb8cd8..6a7a9d55e 100644 --- a/packages/game-iracing/src/track-map-svg.ts +++ b/packages/game-iracing/src/track-map-svg.ts @@ -14,11 +14,19 @@ export interface IRacingPitLine { kind: IRacingPitLineKind; points: IRacingMapPoint[]; } +export interface IRacingMapEdges { + leftEdge: IRacingMapPoint[]; + rightEdge: IRacingMapPoint[]; +} export interface IRacingSvgTrackMap { points: IRacingMapPoint[]; labels: IRacingMapLabel[]; pitLines: IRacingPitLine[]; + startFinishAligned: boolean; + forwardDirectionKnown: boolean; + /** Published map ribbon edges, not surveyed track-width telemetry. */ + ribbonEdges: IRacingMapEdges | null; } type PitPathHint = IRacingPitLineKind | "ignore" | null; @@ -622,6 +630,49 @@ function allContours(svg: string): SvgPoint[][] { (pathData) => parsePathData(pathData).contours, ); } +function startFinishMarkerContours(svg: string): SvgPoint[] { + const symbols = new Map(); + for (const match of svg.matchAll(/]*)>([\s\S]*?)<\/symbol>/gi)) { + const id = svgAttribute(match[1]!, "id"); + const viewBox = svgAttribute(match[1]!, "viewBox"); + if (!id || !/start[_-]?finish/i.test(id) || !viewBox) continue; + const values = numberTokens(viewBox); + if (values.length >= 4) symbols.set(id, { x: values[0]! + values[2]! / 2, y: values[1]! + values[3]! / 2 }); + } + const markers: SvgPoint[] = []; + for (const match of svg.matchAll(/]*>/gi)) { + const tag = match[0]; + const href = svgAttribute(tag, "href") ?? svgAttribute(tag, "xlink:href"); + if (!href || !/start[_-]?finish/i.test(href)) continue; + const id = href.replace(/^#/, ""); + const point = symbols.get(id); + if (!point) continue; + let { x, y } = point; + const transforms = [...(svgAttribute(tag, "transform") ?? "").matchAll(/(translate|rotate|scale)\(([^)]*)\)/gi)].reverse(); + for (const transform of transforms) { + const values = numberTokens(transform[2]!); + switch (transform[1]!.toLowerCase()) { + case "translate": + x += values[0] ?? 0; y += values[1] ?? 0; + break; + case "scale": + x *= values[0] ?? 1; y *= values[1] ?? values[0] ?? 1; + break; + case "rotate": { + const angle = (values[0] ?? 0) * Math.PI / 180; + const cx = values[1] ?? 0, cy = values[2] ?? 0; + const dx = x - cx, dy = y - cy; + x = cx + dx * Math.cos(angle) - dy * Math.sin(angle); + y = cy + dx * Math.sin(angle) + dy * Math.cos(angle); + break; + } + } + } + if (Number.isFinite(x) && Number.isFinite(y)) markers.push({ x, y }); + } + return markers; +} + function closedPerimeter(points: readonly SvgPoint[]): number { let total = 0; @@ -803,10 +854,14 @@ export function parseIRacingActiveSvg( const first = resampleClosed(contours[0], SAMPLE_COUNT); if (first.length !== SAMPLE_COUNT) return null; let centerline = first; + let firstEdge: SvgPoint[] | null = null; + let secondEdge: SvgPoint[] | null = null; if (contours.length >= 2) { const second = resampleClosed(contours[1], SAMPLE_COUNT); if (second.length === SAMPLE_COUNT) { const aligned = alignBoundary(first, second); + firstEdge = first; + secondEdge = aligned; centerline = first.map((point, index) => ({ x: (point.x + aligned[index].x) / 2, y: (point.y + aligned[index].y) / 2, @@ -814,13 +869,17 @@ export function parseIRacingActiveSvg( } } + let startFinishAligned = false; if (startFinishSvg) { - const markerPoints = allContours(startFinishSvg).flat(); + const markerPoints = [...allContours(startFinishSvg).flat(), ...startFinishMarkerContours(startFinishSvg)]; if (markerPoints.length > 0) { - centerline = rotateToIndex( - centerline, - nearestPointIndex(centerline, markerPoints), - ); + const startIndex = nearestPointIndex(centerline, markerPoints); + centerline = rotateToIndex(centerline, startIndex); + if (firstEdge && secondEdge) { + firstEdge = rotateToIndex(firstEdge, startIndex); + secondEdge = rotateToIndex(secondEdge, startIndex); + } + startFinishAligned = true; } } @@ -830,12 +889,37 @@ export function parseIRacingActiveSvg( .sort((a, b) => Number(a.text) - Number(b.text)) .slice(0, 2) .map((label) => ({ x: -label.x, y: label.z })); + const turn1Index = numericLabels.length === 2 ? nearestPointIndex(centerline, [numericLabels[0]]) : -1; + const turn2Index = numericLabels.length === 2 ? nearestPointIndex(centerline, [numericLabels[1]]) : -1; + const forwardDirectionKnown = turn1Index >= 0 && turn2Index >= 0 && turn1Index !== turn2Index; if ( - numericLabels.length === 2 && - nearestPointIndex(centerline, [numericLabels[0]]) > - nearestPointIndex(centerline, [numericLabels[1]]) + forwardDirectionKnown && + turn1Index > turn2Index ) { centerline = [centerline[0], ...centerline.slice(1).reverse()]; + if (firstEdge && secondEdge) { + firstEdge = [firstEdge[0], ...firstEdge.slice(1).reverse()]; + secondEdge = [secondEdge[0], ...secondEdge.slice(1).reverse()]; + } + } + + let ribbonEdges: IRacingMapEdges | null = null; + if (firstEdge && secondEdge) { + let firstSide = 0; + for (let index = 0; index < centerline.length; index++) { + const previous = centerline[(index + centerline.length - 1) % centerline.length]; + const next = centerline[(index + 1) % centerline.length]; + // X is mirrored below; positive means the first edge is on the driver's left. + firstSide += (firstEdge[index].x - centerline[index].x) * (next.y - previous.y) + - (firstEdge[index].y - centerline[index].y) * (next.x - previous.x); + } + if (Number.isFinite(firstSide) && firstSide !== 0) { + const mapPoint = (point: SvgPoint): IRacingMapPoint => ({ x: -point.x, z: point.y }); + ribbonEdges = { + leftEdge: (firstSide > 0 ? firstEdge : secondEdge).map(mapPoint), + rightEdge: (firstSide > 0 ? secondEdge : firstEdge).map(mapPoint), + }; + } } return { @@ -844,5 +928,8 @@ export function parseIRacingActiveSvg( points: centerline.map((point) => ({ x: -point.x, z: point.y })), labels: rawLabels, pitLines: pitRoadSvg ? parseIRacingPitRoadSvg(pitRoadSvg) : [], + startFinishAligned, + forwardDirectionKnown, + ribbonEdges, }; } diff --git a/packages/game-iracing/src/track-map.ts b/packages/game-iracing/src/track-map.ts index f930157ea..ac3e01fe4 100644 --- a/packages/game-iracing/src/track-map.ts +++ b/packages/game-iracing/src/track-map.ts @@ -4,15 +4,19 @@ import { getIRacingOvalDirection, getIRacingTrack } from "@raceiq/game-iracing-m import { gameCatalogDir, USER_TRACKS_DIR } from "@raceiq/shared/platform/runtime/data-paths"; import { parseIRacingActiveSvg, parseIRacingPitRoadSvg, type IRacingSvgTrackMap } from "./track-map-svg"; -interface CachedMapFile extends Omit { +interface CachedMapFile extends Omit { version: 4; mapUrl: string; pitLines?: IRacingSvgTrackMap["pitLines"]; + startFinishAligned?: boolean; + forwardDirectionKnown?: boolean; + ribbonEdges?: IRacingSvgTrackMap["ribbonEdges"]; } interface CachedMapResult { map: IRacingSvgTrackMap; hasPitLineLayer: boolean; + hasRibbonLayer: boolean; } export const IRACING_MAP_CACHE_VERSION = 4; @@ -43,15 +47,26 @@ function readCachedMap(ordinal: number, mapUrl: string): CachedMapResult | null const valid = parsed.version === IRACING_MAP_CACHE_VERSION && parsed.mapUrl === mapUrl && Array.isArray(parsed.points) && parsed.points.length >= 20 && Array.isArray(parsed.labels); if (!valid) continue; const hasPitLineLayer = Array.isArray(parsed.pitLines); + const hasRibbonLayer = parsed.ribbonEdges === null || ( + Array.isArray(parsed.ribbonEdges?.leftEdge) && Array.isArray(parsed.ribbonEdges?.rightEdge) + && parsed.ribbonEdges.leftEdge.length === parsed.points.length + && parsed.ribbonEdges.rightEdge.length === parsed.points.length + && parsed.ribbonEdges.leftEdge.every(({ x, z }) => Number.isFinite(x) && Number.isFinite(z)) + && parsed.ribbonEdges.rightEdge.every(({ x, z }) => Number.isFinite(x) && Number.isFinite(z)) + ); const cached: CachedMapResult = { map: { points: parsed.points, labels: parsed.labels, pitLines: hasPitLineLayer ? parsed.pitLines! : [], + startFinishAligned: parsed.startFinishAligned === true, + forwardDirectionKnown: parsed.forwardDirectionKnown === true, + ribbonEdges: hasRibbonLayer ? parsed.ribbonEdges ?? null : null, }, hasPitLineLayer, + hasRibbonLayer, }; - if (hasPitLineLayer) return cached; + if (hasPitLineLayer && hasRibbonLayer) return cached; incomplete ??= cached; } catch { continue; @@ -72,6 +87,9 @@ function writeCachedMap(ordinal: number, mapUrl: string, map: IRacingSvgTrackMap points: map.points, labels: map.labels, ...(includePitLines && { pitLines: map.pitLines }), + startFinishAligned: map.startFinishAligned, + forwardDirectionKnown: map.forwardDirectionKnown, + ribbonEdges: map.ribbonEdges, } satisfies CachedMapFile), ); } catch (error) { @@ -102,8 +120,12 @@ export function orientIRacingOvalMap(map: IRacingSvgTrackMap, direction: "left" const next = map.points[(index + 1) % map.points.length]; signedArea += point.x * next.z - next.x * point.z; } - if ((signedArea > 0) === (direction === "left")) return map; - return { ...map, points: [map.points[0], ...map.points.slice(1).reverse()] }; + if ((signedArea > 0) === (direction === "left")) return map.forwardDirectionKnown ? map : { ...map, forwardDirectionKnown: true }; + const reverse = (points: T[]) => [points[0], ...points.slice(1).reverse()]; + return { + ...map, points: reverse(map.points), forwardDirectionKnown: true, + ribbonEdges: map.ribbonEdges ? { leftEdge: reverse(map.ribbonEdges.rightEdge), rightEdge: reverse(map.ribbonEdges.leftEdge) } : null, + }; } async function loadMap(ordinal: number): Promise { @@ -112,10 +134,12 @@ async function loadMap(ordinal: number): Promise { const mapUrl = track.mapUrl; if (!mapUrl.startsWith(PUBLIC_MAP_PREFIX)) return null; const direction = getIRacingOvalDirection(ordinal); - const orient = (map: IRacingSvgTrackMap) => direction ? orientIRacingOvalMap(map, direction) : map; + const orient = (map: IRacingSvgTrackMap) => direction + ? orientIRacingOvalMap(map, direction) + : map; const cached = readCachedMap(ordinal, mapUrl); - if (cached?.hasPitLineLayer) return orient(cached.map); - if (cached) { + if (cached?.hasPitLineLayer && cached.hasRibbonLayer && cached.map.startFinishAligned && (cached.map.forwardDirectionKnown || direction)) return orient(cached.map); + if (cached?.hasRibbonLayer && cached.map.startFinishAligned && (cached.map.forwardDirectionKnown || direction)) { const pitRoadSvg = track.pitMapUrl ? await fetchSvg(track.pitMapUrl) : null; const upgraded = { ...cached.map, pitLines: pitRoadSvg ? parseIRacingPitRoadSvg(pitRoadSvg) : [] }; if (pitRoadSvg || !track.pitMapUrl) writeCachedMap(ordinal, mapUrl, upgraded); @@ -129,11 +153,14 @@ async function loadMap(ordinal: number): Promise { fetchSvg(track.turnsMapUrl || layerUrl("turns.svg")), track.pitMapUrl ? fetchSvg(track.pitMapUrl) : null, ]); - if (!activeSvg) return null; + if (!activeSvg) { + memoryCache.delete(ordinal); + return cached ? orient(cached.map) : null; + } const map = parseIRacingActiveSvg(activeSvg, startFinishSvg, turnsSvg, pitRoadSvg); const oriented = map ? orient(map) : null; if (oriented) writeCachedMap(ordinal, mapUrl, oriented, !!pitRoadSvg || !track.pitMapUrl); - if (!pitRoadSvg && track.pitMapUrl) memoryCache.delete(ordinal); + if ((!pitRoadSvg && track.pitMapUrl) || !oriented?.startFinishAligned || !oriented.forwardDirectionKnown) memoryCache.delete(ordinal); return oriented; } diff --git a/packages/game-iracing/test/catalog/iracing-tire-availability.test.ts b/packages/game-iracing/test/catalog/iracing-tire-availability.test.ts new file mode 100644 index 000000000..5052eee91 --- /dev/null +++ b/packages/game-iracing/test/catalog/iracing-tire-availability.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { IRacingSourceFrameEncoder } from "@raceiq/capture-formats/iracing/source-frame"; +import { compileTelemetryResolver } from "@raceiq/telemetry-core/telemetry/resolver/compile"; +import { iracingParser } from "../../src/game-parser"; +import { sampleFrame } from "../support/games/iracing-sdk"; + +const channels = [ + "tire.temperature.surface.representative", + "tire.temperature.surface.inner", + "tire.temperature.surface.middle", + "tire.temperature.surface.outer", + "tire.temperature.core", + "tire.temperature.carcass.representative", + "tire.temperature.carcass.left", + "tire.temperature.carcass.middle", + "tire.temperature.carcass.right", + "tires.tire-pressure", +] as const; + +test("iRacing pit snapshots never become canonical live tire temperatures or pressures", () => { + const encoder = new IRacingSourceFrameEncoder(); + const state = iracingParser.createParserState!(); + const resolver = compileTelemetryResolver({ + simulator: "iracing", + requested: channels.map((semanticId) => ({ semanticId })), + }); + for (const [index, onPitRoad] of [false, true, false].entries()) { + const source = sampleFrame(); + source.values.OnPitRoad = onPitRoad; + source.values.SessionTime = 125.5 + index; + for (const corner of ["LF", "RF", "LR", "RR"]) { + for (const band of ["L", "M", "R"]) source.values[`${corner}tempC${band}`] = 80 + index; + source.values[`${corner}coldPressure`] = 180; + } + const packet = iracingParser.tryParse(encoder.encode(source), state)!; + // Keep the native pit snapshot available for source inspection, not live readings. + expect(packet.TireCarcassTempMiddleFL).toBe(80 + index); + expect(packet.TirePressureFrontLeft).toBeGreaterThan(0); + const frame = resolver.createFrameView(packet, { + timestamp: { domain: "session", milliseconds: index * 1_000 }, + updateSequence: BigInt(index), + }); + for (const semanticId of channels) { + const slot = resolver.slot(semanticId); + expect(frame.resolveValue(slot)).toMatchObject({ state: "missing", mappingStatus: "unavailable" }); + expect(frame.readValue(slot)).toBeUndefined(); + } + } +}); diff --git a/packages/game-iracing/test/catalog/telemetry-catalog-iracing-e2e.test.ts b/packages/game-iracing/test/catalog/telemetry-catalog-iracing-e2e.test.ts index 19336cae1..09bc9428e 100644 --- a/packages/game-iracing/test/catalog/telemetry-catalog-iracing-e2e.test.ts +++ b/packages/game-iracing/test/catalog/telemetry-catalog-iracing-e2e.test.ts @@ -110,12 +110,6 @@ describe("iRacing telemetry catalog coverage", () => { unit: "m", accepts: (value) => isFiniteNumberArrayOfLength(value, 4) && value.every((x) => x >= 0), }, - { - semanticId: "tire.temperature.carcass.left", - mappingStatus: "direct", - unit: "°C", - accepts: (value) => isFiniteNumberArrayOfLength(value, 4) && value.every((x) => x > -273.15 && x < 200), - }, ], }); }, diff --git a/packages/game-iracing/test/integration-files.txt b/packages/game-iracing/test/integration-files.txt index 4b88411ff..d3977c5c7 100644 --- a/packages/game-iracing/test/integration-files.txt +++ b/packages/game-iracing/test/integration-files.txt @@ -1,5 +1,9 @@ +packages/game-iracing/test/catalog/iracing-tire-availability.test.ts +packages/game-iracing/test/iracing-incident-report.test.ts + packages/game-iracing/test/iracing-ibt-reader.test.ts packages/game-iracing/test/iracing-recorder.test.ts +packages/game-iracing/test/iracing-replay-evidence.test.ts packages/game-iracing/test/iracing-sdk-decoding.test.ts packages/game-iracing/test/iracing-sdk-lap-sectors.test.ts packages/game-iracing/test/iracing-sdk-source-frame.test.ts diff --git a/packages/game-iracing/test/iracing-ibt-reader.test.ts b/packages/game-iracing/test/iracing-ibt-reader.test.ts index b1fa8c561..42b882d0b 100644 --- a/packages/game-iracing/test/iracing-ibt-reader.test.ts +++ b/packages/game-iracing/test/iracing-ibt-reader.test.ts @@ -12,6 +12,7 @@ import { } from "node:fs"; import { IRacingIbtReader } from "../src/ibt-reader"; import { previewIbtFile } from "../src/ibt-preview"; +import { readIbtSource } from "../src/ibt-source"; import { normalizeIRacingFrame } from "../src/normalizer"; import { IRacingTelemetrySource } from "../src/source"; import { @@ -27,6 +28,7 @@ import { DISK_HEADER_SIZE, ROW_LENGTH, createRecording, + drivenRows, syntheticSessionInfo, writeCString, } from "./support/games/iracing-ibt"; @@ -185,6 +187,25 @@ describe("IRacingIbtReader", () => { recording.cleanup(); } }); + test("reads native-cadence packets and channels without modifying the IBT", async () => { + const recording = createRecording("source.ibt", drivenRows()); + try { + const before = readFileSync(recording.path); + const preview = await previewIbtFile(recording.path); + const imported = await readIbtSource(recording.path, preview); + expect(preview.canImport).toBe(true); + expect(imported.packets).toHaveLength(preview.recordCount); + expect(imported.nativeValues).toHaveLength(imported.packets.length); + expect(imported.nativeValues?.map((values) => values.SessionTick)).toEqual([0, 2700, 3000, 6300, 6600, 6720]); + expect(imported.nativeValues?.[0]?.Speed).toBe(45); + expect(imported.packets[0]?.gameId).toBe("iracing"); + expect(imported.packets[0]?.trackId).toBe("99"); + expect(readFileSync(recording.path)).toEqual(before); + } finally { + recording.cleanup(); + } + }); + test("rejects a recording whose declared rows are truncated", () => { const recording = createRecording(); diff --git a/packages/game-iracing/test/iracing-incident-report.test.ts b/packages/game-iracing/test/iracing-incident-report.test.ts new file mode 100644 index 000000000..d5d749196 --- /dev/null +++ b/packages/game-iracing/test/iracing-incident-report.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, test } from "bun:test"; +import { reduceIRacingIncidents, type IRacingIncidentInput, type IRacingIncidentLapInput, type IRacingIncidentSample } from "../src/incident-report"; +import type { IRacingIncidentCoverageReason } from "@raceiq/shared/racing/sessions/iracing-incidents"; + +const laps: IRacingIncidentLapInput[] = [ + { lapId: 1, lapNumber: 1, eligibleForCleanStreak: true }, + { lapId: 2, lapNumber: 2, eligibleForCleanStreak: true }, + { lapId: 3, lapNumber: 3, eligibleForCleanStreak: false }, +]; +function sample(lapId: number, rawOffset: number, time: number | null, count: number | null, surface: number | null, extra: Partial = {}): IRacingIncidentSample { + return { kind: "sample", lapId, rawOffset, nativeSessionUid: "sub:session:1", driverCarIdx: 4, sessionTick: rawOffset, sessionTimeSec: time, incidentPoints: count, surface, ...extra }; +} +const reduce = (items: IRacingIncidentInput[], selected = laps) => reduceIRacingIncidents(selected, items); + + describe("reduceIRacingIncidents", () => { + test("uses first valid count as baseline and emits later positive differences in capture order", async () => { + const result = await reduce([ + sample(1, 10, 1, 2, 3), sample(1, 11, 1.05, 2, 3), sample(1, 12, 1.1, 3, 3), + sample(2, 20, 1.15, 3, 3), sample(2, 21, 1.2, 5, 3), + ]); + expect(result.events.filter(e => e.kind === "incident-points").map(e => [e.id, e.points, e.previousSessionTimeSec, e.lapId])).toEqual([ + ["incident-points:1:12", 1, 1.05, 1], ["incident-points:2:21", 2, 1.15, 2], + ]); + expect(result.observedIncidentPoints).toBe(3); + expect(result.events.every(e => e.replay === null)).toBe(true); + expect(result.events.map(e => e.seekReference.sessionTimeSec)).toEqual([1.1, 1.2]); + }); + + test("does not substitute flags/team-like data; invalid counts interrupt comparability and surfaces remain independent", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, null, 0), sample(1, 3, .1, 2, 0), sample(1, 4, .15, 3, 0), + ]); + expect(result.observedIncidentPoints).toBe(1); + expect(result.events.filter(e => e.kind === "incident-points").map(e => e.points)).toEqual([1]); + expect(result.laps[0]).toMatchObject({ incidentStatus: "partial", surfaceStatus: "available" }); + expect(result.laps[0].observedOffTrackSeconds).toBeCloseTo(.1); + for (const invalid of [-1, 1.5, NaN, Infinity, -Infinity]) { + const isolated = await reduce([sample(1, 1, 0, invalid, 3), sample(1, 2, .05, 4, 3)]); + expect(isolated.observedIncidentPoints).toBeNull(); + } + }); + + test("counter decrease rebaselines without negative events and preserves later comparable increases", async () => { + const result = await reduce([sample(1, 1, 0, 4, 3), sample(1, 2, .05, 2, 3), sample(1, 3, .1, 3, 3)]); + expect(result.reasons).toContain("counter-reset"); + expect(result.events.filter(e => e.kind === "incident-points").map(e => [e.points, e.id])).toEqual([[1, "incident-points:1:3"]]); + expect(result.observedIncidentPoints).toBe(1); + expect(result.laps[0].incidentStatus).toBe("partial"); + }); + + test("creates lap-clipped sampled spans with known boundary flags and zero duration at equal time", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 0), sample(1, 3, .1, 0, 0), sample(1, 4, .1, 0, 3), + sample(2, 5, .15, 0, 0), sample(2, 6, .2, 0, 3), sample(3, 7, .25, 0, 0), + ]); + const spans = result.events.filter(e => e.kind === "off-track"); + expect(spans.map(e => e.kind === "off-track" ? [e.id, e.lapId, e.observedSeconds, e.startObserved, e.endObserved] : [])).toEqual([ + ["off-track:1:2", 1, .05, true, true], ["off-track:2:5", 2, 0, false, true], ["off-track:3:7", 3, 0, false, false], + ]); + expect(result.offTrackSpans).toBe(3); + expect(result.observedOffTrackSeconds).toBeCloseTo(.05); + }); + + test("treats pits as known non-off-track, unknown and not-in-world as continuity breaks", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 1), sample(1, 2, .05, 0, 0), sample(1, 3, .1, 0, 2), + sample(1, 4, .15, 0, 0), sample(1, 5, .2, 0, -1), sample(1, 6, .25, 0, 0), sample(1, 7, .3, 0, null), + ]); + const spans = result.events.filter(e => e.kind === "off-track"); + expect(spans).toHaveLength(3); + expect(spans.map(e => e.kind === "off-track" ? [e.startObserved, e.endObserved] : [])).toEqual([[true, true], [true, false], [false, false]]); + expect(result.laps[0].surfaceStatus).toBe("partial"); + }); + + test("breaks both channels on time reset, identity change, excessive gap, explicit break, and missing time", async () => { + const cases: Array<{ items: IRacingIncidentInput[]; reason: IRacingIncidentCoverageReason }> = [ + { items: [sample(1, 1, .1, 0, 3), sample(1, 2, .05, 1, 3)], reason: "time-reset" }, + { items: [sample(1, 1, 0, 0, 3), sample(1, 2, .05, 1, 3, { nativeSessionUid: "other" })], reason: "segment-boundary" }, + { items: [sample(1, 1, 0, 0, 3), sample(1, 2, .10101, 1, 3)], reason: "capture-gap" }, + { items: [sample(1, 1, 0, 0, 3), { kind: "break", reason: "decode-failure" }, sample(1, 2, .05, 1, 3)], reason: "decode-failure" }, + { items: [sample(1, 1, 0, 0, 3), sample(1, 2, null, 1, 3)], reason: "missing-evidence" }, + ]; + for (const { items, reason } of cases) { + const result = await reduce(items); + expect(result.reasons).toContain(reason); + expect(result.events.filter(e => e.kind === "incident-points")).toHaveLength(0); + expect(result.laps[0].incidentStatus).toBe("partial"); + expect(result.laps[0].surfaceStatus).toBe("partial"); + } + const allowed = await reduce([sample(1, 1, 0, 0, 3), sample(1, 2, .101, 1, 3)]); + expect(allowed.events.filter(e => e.kind === "incident-points")).toHaveLength(1); + }); + + test("driver identity and unowned records interrupt comparisons; unowned interval cannot earn point attribution", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 1, 3, { driverCarIdx: 5 }), + sample(99, 3, .1, 1, 3), sample(1, 4, .15, 2, 3), + ]); + expect(result.reasons).toContain("segment-boundary"); + expect(result.events.filter(e => e.kind === "incident-points")).toHaveLength(0); + expect(result.observedIncidentPoints).toBeNull(); + }); + + test("lap breaks invalidate named lap, unnamed breaks separate continuity, and async input is accepted", async () => { + async function* items() { + yield sample(1, 1, 0, 0, 3); + yield sample(1, 2, .05, 0, 3); + yield { kind: "break", reason: "capture-gap" } as const; + yield sample(2, 3, .1, 0, 3); + yield sample(2, 4, .15, 0, 3); + yield { kind: "break", reason: "decode-failure", lapId: 2 } as const; + } + const result = await reduceIRacingIncidents(laps, items()); + expect(result.laps[0].incidentStatus).toBe("available"); + expect(result.laps[1].incidentStatus).toBe("partial"); + expect(result.laps[1].surfaceStatus).toBe("partial"); + expect(result.reasons).toEqual(expect.arrayContaining(["capture-gap", "decode-failure"])); + }); + + test("summarizes null versus observed-zero metrics and evidence availability independently", async () => { + const noEvidence = await reduce([], laps); + expect(noEvidence).toMatchObject({ incidentStatus: "unavailable", surfaceStatus: "unavailable", observedIncidentPoints: null, offTrackSpans: null, observedOffTrackSeconds: null, longestCleanLapStreak: null }); + const single = await reduce([sample(1, 1, 0, 0, 3)], laps.slice(0, 1)); + expect(single).toMatchObject({ observedIncidentPoints: null, offTrackSpans: 0, observedOffTrackSeconds: null, longestCleanLapStreak: null }); + const zero = await reduce([sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3)], laps.slice(0, 1)); + expect(zero).toMatchObject({ incidentStatus: "available", surfaceStatus: "available", observedIncidentPoints: 0, offTrackSpans: 0, observedOffTrackSeconds: 0, longestCleanLapStreak: 1 }); + }); + + test("clean streak respects eligibility, lap-number continuity, point increases, and absent eligible evidence", async () => { + const cleanPair = await reduce([sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), sample(2, 3, .1, 0, 3), sample(2, 4, .15, 0, 3)], laps.slice(0, 2)); + expect(cleanPair.longestCleanLapStreak).toBe(2); + const ineligible = await reduce([sample(3, 1, 0, 0, 3), sample(3, 2, .05, 0, 3)], [laps[2]]); + expect(ineligible.longestCleanLapStreak).toBeNull(); + const consecutiveRule = await reduce([sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), sample(2, 3, .1, 0, 3), sample(2, 4, .15, 0, 3)], [laps[0], { ...laps[1], lapNumber: 4 }]); + expect(consecutiveRule.longestCleanLapStreak).toBe(1); + const point = await reduce([sample(1, 1, 0, 0, 3), sample(1, 2, .05, 1, 3), sample(2, 3, .1, 1, 3), sample(2, 4, .15, 1, 3)], laps.slice(0, 2)); + expect(point.longestCleanLapStreak).toBe(1); + }); + test("keeps equal-time changed counts but adds no elapsed surface duration", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 0), sample(1, 2, 0, 1, 3), + sample(1, 3, .05, 1, 3), + ], laps.slice(0, 1)); + expect(result.events.filter(event => event.kind === "incident-points").map(event => event.points)).toEqual([1]); + expect(result.events.find(event => event.kind === "off-track")).toMatchObject({ observedSeconds: 0, startObserved: false, endObserved: true }); + }); + + test("does not bridge clean streak across an unnamed continuity break", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), + { kind: "break", reason: "segment-boundary" }, + sample(2, 3, .1, 0, 3), sample(2, 4, .15, 0, 3), + ], laps.slice(0, 2)); + expect(result.longestCleanLapStreak).toBe(1); + expect(result.reasons).toContain("segment-boundary"); + }); + + test("captures isolated off-track sample at lap edge as zero-duration clipped span", async () => { + const result = await reduce([sample(1, 1, 0, 0, 0)], laps.slice(0, 1)); + expect(result.events).toContainEqual(expect.objectContaining({ + kind: "off-track", id: "off-track:1:1", observedSeconds: 0, startObserved: false, endObserved: false, + })); + expect(result.laps[0]).toMatchObject({ surfaceStatus: "partial", offTrackSpans: 1, observedOffTrackSeconds: null }); + }); + + test("does not infer off-track duration across unknown surface observations", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 0), sample(1, 3, .1, 0, null), + sample(1, 4, .15, 0, 0), sample(1, 5, .2, 0, 3), + ], laps.slice(0, 1)); + const spans = result.events.filter(event => event.kind === "off-track"); + expect(spans.map(event => event.kind === "off-track" ? [event.observedSeconds, event.startObserved, event.endObserved] : [])).toEqual([[0, true, false], [0, false, true]]); + }); + + test("does not count missing or invalid separated observations as comparable pairs", async () => { + for (const missing of [null, -1, 2.5, NaN, Infinity]) { + const result = await reduce([ + sample(1, 1, 0, 0, 3), + sample(1, 2, .05, missing, 3), + sample(1, 3, .1, 1, 3), + ], laps.slice(0, 1)); + expect(result.observedIncidentPoints).toBeNull(); + expect(result.laps[0].incidentStatus).toBe("partial"); + expect(result.events.filter(event => event.kind === "incident-points")).toHaveLength(0); + } + }); + + test("named breaks mark a noncurrent lap partial and unnamed breaks partially cover aggregates", async () => { + const named = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), + sample(2, 3, .1, 0, 3), + { kind: "break", reason: "decode-failure", lapId: 1 }, + sample(2, 4, .15, 0, 3), + ], laps.slice(0, 2)); + expect(named.laps[0].incidentStatus).toBe("partial"); + expect(named.laps[1].incidentStatus).toBe("available"); + + const separated = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), + { kind: "break", reason: "segment-boundary" }, + sample(2, 3, .1, 0, 3), sample(2, 4, .15, 0, 3), + ], laps.slice(0, 2)); + expect(separated.incidentStatus).toBe("partial"); + expect(separated.surfaceStatus).toBe("partial"); + }); + + test("ignores identical equal-time duplicates and keeps surface evidence independent of counter reset", async () => { + const first = sample(1, 1, 0, 4, 3); + const result = await reduce([ + first, { ...first, rawOffset: 2 }, sample(1, 3, .05, 2, 0), + sample(1, 4, .1, 3, 3), + ], laps.slice(0, 1)); + expect(result.reasons).toContain("counter-reset"); + expect(result.events.filter(event => event.kind === "incident-points").map(event => event.points)).toEqual([1]); + expect(result.laps[0]).toMatchObject({ incidentStatus: "partial", surfaceStatus: "available" }); + expect(result.events.filter(event => event.kind === "off-track")).toHaveLength(1); + }); + + test("missing surface preserves comparable count changes", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 1, null), sample(1, 3, .1, 2, 3), + ], laps.slice(0, 1)); + expect(result.observedIncidentPoints).toBe(2); + expect(result.events.filter(event => event.kind === "incident-points").map(event => event.points)).toEqual([1, 1]); + expect(result.laps[0].incidentStatus).toBe("available"); + expect(result.laps[0].surfaceStatus).toBe("partial"); + }); + + test("clips continuing off-track state at lap boundary without bridging duration", async () => { + const result = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 0), + sample(2, 3, .1, 0, 0), sample(2, 4, .15, 0, 3), + ], laps.slice(0, 2)); + expect(result.events.filter(event => event.kind === "off-track").map(event => event.kind === "off-track" + ? [event.lapId, event.observedSeconds, event.startObserved, event.endObserved] + : [])).toEqual([[1, 0, true, false], [2, 0, false, true]]); + expect(result.observedOffTrackSeconds).toBe(0); + }); + + test("retains capture event order when counter events follow clock reset", async () => { + const result = await reduce([ + sample(1, 1, 10, 0, 3), sample(1, 2, 10.05, 1, 3), + sample(1, 3, 1, 0, 3), sample(1, 4, 1.05, 2, 3), + ], laps.slice(0, 1)); + expect(result.events.filter(event => event.kind === "incident-points").map(event => event.id)).toEqual([ + "incident-points:1:2", "incident-points:1:4", + ]); + expect(result.observedIncidentPoints).toBe(3); + expect(result.reasons).toContain("time-reset"); + }); + + test("clean streak breaks on reset, gap, missing evidence, ineligible and number discontinuity", async () => { + const two = laps.slice(0, 2); + const pairs = [ + [sample(1, 1, 0, 0, 3), sample(1, 2, .05, 1, 3), sample(2, 3, .1, 1, 3), sample(2, 4, .15, 1, 3)], + [sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), sample(2, 3, .2, 0, 3), sample(2, 4, .25, 0, 3)], + [sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), sample(2, 3, .1, null, 3), sample(2, 4, .15, 0, 3)], + ]; + for (const items of pairs) { + const result = await reduce(items, two); + expect(result.longestCleanLapStreak).toBe(1); + } + const ineligible = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), + sample(2, 3, .1, 0, 3), sample(2, 4, .15, 0, 3), + ], [two[0], { ...two[1], eligibleForCleanStreak: false }]); + expect(ineligible.longestCleanLapStreak).toBe(1); + const discontinuous = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), + sample(2, 3, .1, 0, 3), sample(2, 4, .15, 0, 3), + ], [two[0], { ...two[1], lapNumber: 4 }]); + expect(discontinuous.longestCleanLapStreak).toBe(1); + }); + + test("outside-window breaks do not invalidate full coverage, but resumed same-lap breaks do", async () => { + const selected = laps.slice(0, 1); + const outside = await reduce([ + { kind: "break", reason: "segment-boundary" }, + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), + { kind: "break", reason: "capture-gap" }, + ], selected); + expect(outside.incidentStatus).toBe("available"); + expect(outside.surfaceStatus).toBe("available"); + const inside = await reduce([ + sample(1, 1, 0, 0, 3), sample(1, 2, .05, 0, 3), + { kind: "break", reason: "capture-gap" }, + sample(1, 3, .1, 0, 3), sample(1, 4, .15, 0, 3), + ], selected); + expect(inside.laps[0].incidentStatus).toBe("partial"); + expect(inside.laps[0].surfaceStatus).toBe("partial"); + expect(inside.longestCleanLapStreak).toBeNull(); + }); + +}); diff --git a/packages/game-iracing/test/iracing-replay-evidence.test.ts b/packages/game-iracing/test/iracing-replay-evidence.test.ts new file mode 100644 index 000000000..d2a79f331 --- /dev/null +++ b/packages/game-iracing/test/iracing-replay-evidence.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeIRacingFrame } from "../src/normalizer"; +import { sampleFrame } from "./support/games/iracing-sdk"; +import { compileTelemetryResolver } from "@raceiq/telemetry-core/telemetry/resolver/compile"; +import { TELEMETRY_CATALOG } from "@raceiq/shared/telemetry/catalog/data"; +import type { TelemetryPacket } from "@raceiq/shared/telemetry/types"; + +describe("iRacing replay source evidence", () => { + test("missing or invalid lap distance cannot place replay at start/finish", () => { + for (const fraction of [undefined, -1, 1.5, NaN, Infinity]) { + const source = sampleFrame(); + const values = { ...source.values }; + if (fraction === undefined) delete values.LapDistPct; + else values.LapDistPct = fraction; + delete values.LapDist; + const packet = normalizeIRacingFrame({ ...source, values }); + expect(Number.isFinite(packet.iracing!.lapDistancePct)).toBe(false); + expect(Number.isFinite(packet.DistanceTraveled)).toBe(false); + const resolver = compileTelemetryResolver<{ packet: TelemetryPacket; nativeValues: typeof values }>(TELEMETRY_CATALOG, { simulator: "iracing", requested: [{ semanticId: "timing.lap-fraction" }] }); + const view = resolver.createFrameView({ packet, nativeValues: values }, { timestamp: { domain: "session", milliseconds: 1000 }, updateSequence: 1n }); + const resolved = view.resolveNumber(resolver.slot("timing.lap-fraction")); + expect(resolved.state).not.toBe("ok"); + } + }); + + test("partial or absent pit wear never becomes fresh tyre health", () => { + const source = sampleFrame(); + const values = { ...source.values }; + delete values.LFwearL; + expect(Number.isFinite(normalizeIRacingFrame({ ...source, values }).TireWearFL)).toBe(false); + values.LFwearL = 1.2; + expect(Number.isFinite(normalizeIRacingFrame({ ...source, values }).TireWearFL)).toBe(false); + values.LFwearL = 1; + values.LFwearM = 0.9; + values.LFwearR = 0.8; + expect(normalizeIRacingFrame({ ...source, values }).TireWearFL).toBeCloseTo(0.2); + }); + + test("missing attitude and acceleration stay unavailable rather than level and zero G", () => { + const source = sampleFrame(); + const values = { ...source.values }; + for (const key of ["Yaw", "Pitch", "Roll", "LatAccel", "LongAccel"]) delete values[key]; + const packet = normalizeIRacingFrame({ ...source, values }); + for (const value of [packet.Yaw, packet.Pitch, packet.Roll, packet.AccelerationX, packet.AccelerationZ]) expect(Number.isFinite(value)).toBe(false); + }); +}); diff --git a/packages/game-iracing/test/iracing-sdk-source-frame.test.ts b/packages/game-iracing/test/iracing-sdk-source-frame.test.ts index f04f7be8c..fdc6e0271 100644 --- a/packages/game-iracing/test/iracing-sdk-source-frame.test.ts +++ b/packages/game-iracing/test/iracing-sdk-source-frame.test.ts @@ -162,6 +162,26 @@ describe("iRacing raw source frame parser integration", () => { }); }); + test.each([ + [1, 0], + [0.75, 64], + [0.5, 128], + [0, 255], + [2, 0], + [-1, 255], + [Number.NaN, 0], + ])("converts SDK clutch %s into pedal depression %s", (nativeClutch, expected) => { + const frame = sampleFrame(); + frame.values.Clutch = nativeClutch; + expect(normalizeIRacingFrame(frame).Clutch).toBe(expected); + }); + + test("does not report a pressed clutch when the SDK channel is absent", () => { + const frame = sampleFrame(); + delete frame.values.Clutch; + expect(normalizeIRacingFrame(frame).Clutch).toBe(0); + }); + test("keeps historical v2 frames compatible without inventing raw YAML", () => { const frame = sampleFrame(); const raw = new IRacingSourceFrameEncoder().encode(frame); diff --git a/packages/game-iracing/test/support/games/iracing-ibt.ts b/packages/game-iracing/test/support/games/iracing-ibt.ts index dd62874be..3ac128d52 100644 --- a/packages/game-iracing/test/support/games/iracing-ibt.ts +++ b/packages/game-iracing/test/support/games/iracing-ibt.ts @@ -22,6 +22,7 @@ export interface SyntheticRow { lastLapTime?: number; currentLapTime?: number; brakeLinePressure?: number; + brakeBias?: number; } export interface SyntheticIdentity { @@ -67,8 +68,8 @@ function descriptor( return buffer; } -function telemetryRow(row: SyntheticRow): Buffer { - const buffer = Buffer.alloc(ROW_LENGTH); +function telemetryRow(row: SyntheticRow, includeBrakeBias: boolean): Buffer { + const buffer = Buffer.alloc(ROW_LENGTH + (includeBrakeBias ? 4 : 0)); buffer.writeDoubleLE(row.sessionTime, 0); buffer.writeInt32LE(row.sessionTick, 8); buffer.writeInt32LE(2, 12); @@ -80,6 +81,7 @@ function telemetryRow(row: SyntheticRow): Buffer { buffer.writeFloatLE(row.lastLapTime ?? 0, 32); buffer.writeFloatLE(row.currentLapTime ?? row.sessionTime, 36); buffer.writeFloatLE(row.brakeLinePressure ?? 1200.25, 40); + if (includeBrakeBias) buffer.writeFloatLE(row.brakeBias ?? NaN, ROW_LENGTH); return buffer; } @@ -118,6 +120,23 @@ export function writeSyntheticIbt( suppliedRows?: SyntheticRow[], identity: SyntheticIdentity = DEFAULT_IDENTITY, ): void { + const sourceRows: SyntheticRow[] = suppliedRows ?? [ + { + sessionTime: 10, + sessionTick: 600, + speed: 50.5, + lapDistancePct: 0.25, + brakeLinePressure: 1200.25, + }, + { + sessionTime: 10 + 1 / 60, + sessionTick: 601, + speed: 51.5, + lapDistancePct: 0.26, + brakeLinePressure: 1201.5, + }, + ]; + const includeBrakeBias = sourceRows.some((row) => row.brakeBias !== undefined); const variableHeaders = Buffer.concat([ descriptor(IRSDKVariableType.Double, 0, "SessionTime"), descriptor(IRSDKVariableType.Int, 8, "SessionTick"), @@ -130,28 +149,13 @@ export function writeSyntheticIbt( descriptor(IRSDKVariableType.Float, 32, "LapLastLapTime"), descriptor(IRSDKVariableType.Float, 36, "LapCurrentLapTime"), descriptor(IRSDKVariableType.Float, 40, "LFbrakeLinePress"), + ...(includeBrakeBias ? [descriptor(IRSDKVariableType.Float, ROW_LENGTH, "dcBrakeBias")] : []), ]); const sessionInfo = Buffer.from( `${syntheticSessionInfo(identity)}\0`, "utf8", ); - const sourceRows: SyntheticRow[] = suppliedRows ?? [ - { - sessionTime: 10, - sessionTick: 600, - speed: 50.5, - lapDistancePct: 0.25, - brakeLinePressure: 1200.25, - }, - { - sessionTime: 10 + 1 / 60, - sessionTick: 601, - speed: 51.5, - lapDistancePct: 0.26, - brakeLinePressure: 1201.5, - }, - ]; - const rows = sourceRows.map(telemetryRow); + const rows = sourceRows.map((row) => telemetryRow(row, includeBrakeBias)); const sessionInfoOffset = DISK_HEADER_SIZE + variableHeaders.length; @@ -167,7 +171,7 @@ export function writeSyntheticIbt( ); header.writeInt32LE(DISK_HEADER_SIZE, 28); header.writeInt32LE(1, 32); - header.writeInt32LE(ROW_LENGTH, 36); + header.writeInt32LE(ROW_LENGTH + (includeBrakeBias ? 4 : 0), 36); header.writeBigInt64LE(1_757_390_931n, 112); header.writeDoubleLE(sourceRows[0]?.sessionTime ?? 0, 120); header.writeDoubleLE( diff --git a/packages/game-lmu/test/package.json b/packages/game-lmu/test/package.json index d05ec8daf..7195496e4 100644 --- a/packages/game-lmu/test/package.json +++ b/packages/game-lmu/test/package.json @@ -30,6 +30,7 @@ "@raceiq/telemetry-processor": "workspace:*", "@raceiq/tooling": "workspace:*", "@raceiq/tooling-data": "workspace:*", + "@raceiq/tooling": "workspace:*", "@raceiq/tooling-release": "workspace:*", "@raceiq/tracks-catalogue": "workspace:*", "@types/react": "^19.3.0", diff --git a/packages/lap-replay/contracts/messages.ts b/packages/lap-replay/contracts/messages.ts index c381cd41f..97b0f9ac5 100644 --- a/packages/lap-replay/contracts/messages.ts +++ b/packages/lap-replay/contracts/messages.ts @@ -16,6 +16,12 @@ export interface ReplayMessages { overlay_segments: () => string; analyse_unavailable: () => string; analyse_wheels_wear_s: () => string; + analyse_wheels_pit_temp: () => string; + analyse_wheels_pit_health: () => string; + analyse_wheels_cold_pressure: () => string; + analyse_wheels_pressure: () => string; + analyse_wheels_brake: () => string; + carwire_snapshot_stale: () => string; carwire_hidden: () => string; carwire_solid: () => string; carwire_wire: () => string; diff --git a/packages/lap-replay/contracts/spectate.ts b/packages/lap-replay/contracts/spectate.ts index c85036741..d522381ea 100644 --- a/packages/lap-replay/contracts/spectate.ts +++ b/packages/lap-replay/contracts/spectate.ts @@ -9,6 +9,9 @@ export interface SpectateSample { distance: number; run: number; values: Readonly>; +// keep both + /** Visualization geometry only; never replaces canonical telemetry values. */ + scenePose?: (SpectatePose & { source: "native" | "measured" | "lap-fraction" }) | null; frame?: SemanticReplayFrame; } export interface SpectateLap { diff --git a/packages/lap-replay/server/index.ts b/packages/lap-replay/server/index.ts index 004bf636c..673b4f350 100644 --- a/packages/lap-replay/server/index.ts +++ b/packages/lap-replay/server/index.ts @@ -21,6 +21,13 @@ import { canonicalizeTelemetryScalar } from "@raceiq/telemetry-core/telemetry/re import { createLmuContextEncoder } from "@raceiq/shared/telemetry/lmu-context"; import { createSemanticReplayEncoder, SEMANTIC_REPLAY_MEDIA_TYPE } from "../contracts/replay-wire"; import type { SemanticLapTelemetry } from "../contracts/replay"; +const IRACING_TRACK_MAP_SEMANTIC_IDS: Record = { + "identity.track.latitude": true, + "identity.track.longitude": true, + "motion.latitude": true, + "motion.longitude": true, + "timing.lap-fraction": true, +}; import type { ProcessingMetadata } from "@raceiq/shared/racing/sessions/types"; export function semanticReplayIds(gameId: GameId): readonly string[] { @@ -57,7 +64,10 @@ export const replayRoutes = new Hono() } const lap = await getLapById(id, meta.processing); if (!lap) return c.json({ error: "Lap not found" }, 404); + const trackMapView = c.req.query("view") === "track-map"; + if (trackMapView && gameIdResult.data !== "iracing") return c.json({ error: "Track map replay is only available for iRacing" }, 400); if (lap.parseError) { + if (trackMapView) return c.json({ lapId: id, trackOrigin: null, samples: [], parseError: lap.parseError }); return c.json({ lapId: id, requestedSemanticIds: [], sectorTimes: meta.sectorTimes ?? null, sectorStarts: null, insights: [], detectorCoverage: INSIGHT_DETECTORS.map((detector) => ({ ...detector, status: "unavailable" as const, reason: "Lap telemetry could not be parsed" })), parseError: lap.parseError, envelopes: [], @@ -80,6 +90,38 @@ export const replayRoutes = new Hono() ) : source.rawFile ? await loadRawCaptureIdentity(source.rawFile) : undefined : undefined; + if (trackMapView) { + const trackMapSemanticIds = semanticReplayIds("iracing").filter((semanticId) => IRACING_TRACK_MAP_SEMANTIC_IDS[semanticId] === true); + const trackMapReplay = resolveTelemetryReplay(id, replaySource, lap.telemetry, trackMapSemanticIds, rawCapture); + const readNumber = (values: typeof trackMapReplay.envelopes[number]["values"], semanticId: string) => { + const value = values.find((entry) => entry.semanticId === semanticId); + return value + && (value.state === undefined || value.state === "ok") + && (value.freshness === undefined || value.freshness === "fresh") + && typeof value.value === "number" + && Number.isFinite(value.value) + ? value.value + : null; + }; + const firstNumber = (semanticId: string) => { + for (const envelope of trackMapReplay.envelopes) { + const value = readNumber(envelope.values, semanticId); + if (value !== null) return value; + } + return null; + }; + const latitude = firstNumber("identity.track.latitude"); + const longitude = firstNumber("identity.track.longitude"); + return c.json({ + lapId: trackMapReplay.lapId, + trackOrigin: latitude !== null && longitude !== null ? { latitude, longitude } : null, + samples: trackMapReplay.envelopes.map(({ values }) => ({ + latitude: readNumber(values, "motion.latitude"), + longitude: readNumber(values, "motion.longitude"), + fraction: readNumber(values, "timing.lap-fraction"), + })), + }); + } const semanticIds = meta.gameId === "lmu" ? [...new Set([...semanticReplayIds(meta.gameId), ...liveSemanticIds("lmu")])] : semanticReplayIds(meta.gameId); const compact = c.req.header("Accept")?.split(",").some((value) => value.trim() === SEMANTIC_REPLAY_MEDIA_TYPE) ?? false; const encoder = compact ? createSemanticReplayEncoder() : undefined; diff --git a/packages/lap-replay/test/e2e/compare/spectate-data.spec.ts b/packages/lap-replay/test/e2e/compare/spectate-data.spec.ts index 99ef484be..4b0819c5d 100644 --- a/packages/lap-replay/test/e2e/compare/spectate-data.spec.ts +++ b/packages/lap-replay/test/e2e/compare/spectate-data.spec.ts @@ -64,6 +64,116 @@ test("ACC Spectate preserves recorded gaps and recovers beyond them", async ({ p await expect.poll(async () => clock.getAttribute(`data-${sideKey}-time`)).not.toBe(""); await expect.poll(async () => page.locator("[data-channel-id='motion.speed'] canvas").first().getAttribute(`data-current-${sideKey}`)).not.toBe("—"); }); +test("iRacing Spectate projects measured geography without native world positions", async ({ page, request }) => { + const game = SEEDED_GAME_CASES.find((candidate) => candidate.gameId === "iracing" && candidate.supportedFeatures.includes("compare")); + if (!game) throw new Error("Spectate acceptance prerequisite missing: seeded compare case for iracing"); + const pair = await getDistinctPair(request, "iracing"); + await page.goto(`/${game.prefix}/compare?${compareQuery(pair)}&view=spectate`, { waitUntil: "domcontentloaded" }); + let gps: Array<{ time: number; latitude: number; longitude: number }> = []; + for (const lap of [pair.lapA, pair.lapB]) { + // Read through the browser, retaining only reference channels across the protocol. + // Full native replay bodies can exceed Chromium's inspector response cache. + const replay = await page.evaluate(async (lapId) => { + const response = await fetch(`/api/laps/${lapId}/semantic-telemetry`, { headers: { "X-Game-Id": "iracing" } }); + if (!response.ok) throw new Error(`Native iRacing replay failed: ${response.status}`); + const body = await response.json() as { envelopes: Array<{ values: Array<{ semanticId: string; value: unknown; state?: string; freshness?: string }> }> }; + const ids: Record = { "motion.latitude": true, "motion.longitude": true, "motion.position-x": true, "motion.position-z": true, "timing.current-lap": true }; + return { envelopes: body.envelopes.map((frame) => ({ values: frame.values.filter((entry) => ids[entry.semanticId] === true) })) }; + }, lap.id); + if (lap.id === pair.lapA.id) { + const firstTime = replay.envelopes[0]!.values.find((entry) => entry.semanticId === "timing.current-lap")?.value; + if (typeof firstTime !== "number") throw new Error("iRacing replay has no native lap clock"); + gps = replay.envelopes.flatMap((frame) => { + const latitude = frame.values.find((entry) => entry.semanticId === "motion.latitude"); + const longitude = frame.values.find((entry) => entry.semanticId === "motion.longitude"); + const time = frame.values.find((entry) => entry.semanticId === "timing.current-lap")?.value; + return typeof time === "number" && typeof latitude?.value === "number" && typeof longitude?.value === "number" + && latitude.state === "ok" && longitude.state === "ok" && latitude.freshness === "fresh" && longitude.freshness === "fresh" + ? [{ time: time - firstTime, latitude: latitude.value, longitude: longitude.value }] : []; + }); + expect(gps.length, "iRacing canonical measured GPS samples").toBeGreaterThan(2); + } + for (const frame of replay.envelopes) { + for (const id of ["motion.position-x", "motion.position-z"]) { + const position = frame.values.find((entry) => entry.semanticId === id); + expect( + position && typeof position.value === "number" && Number.isFinite(position.value) + && (position.state == null || position.state === "ok") + && (position.freshness == null || position.freshness === "fresh"), + `iRacing replay keeps unsupported ${id} unavailable`, + ).toBe(false); + } + } + } + + const scene = page.locator("[data-spectate-scene]"); + await expect.poll(async () => scene.getAttribute("data-scene-ready")).toBe("true"); + await expect(scene).toHaveAttribute("data-scene-error", ""); + await expect(scene).toHaveAttribute("data-side-a-state", "ready"); + await expect(scene).toHaveAttribute("data-side-b-state", "ready"); + const canvas = scene.locator("canvas").first(); + for (const side of ["a", "b"] as const) { + await expect.poll(async () => canvas.getAttribute(`data-${side}-x`)).not.toBe(""); + await expect.poll(async () => canvas.getAttribute(`data-${side}-z`)).not.toBe(""); + } + const map = page.getByRole("group", { name: "Circuit segment map" }); + await expect(map).toBeVisible(); + const segmentButtons = map.getByRole("button"); + expect(await segmentButtons.count(), "iRacing circuit segments").toBeGreaterThan(1); + await expect(segmentButtons.filter({ has: page.locator("text", { hasText: /^T1$/ }) })).toBeVisible(); + await expect(segmentButtons.filter({ has: page.locator("text", { hasText: /^T2$/ }) })).toBeVisible(); + await expect(map.locator("text").filter({ hasText: /^19$/ })).toHaveCount(0); + + const clock = page.locator("[data-spectate-playback]"); + const seek = page.getByRole("slider", { name: "Replay position" }); + await seek.press("Home"); + const initialX = Number(await canvas.getAttribute("data-a-x")); + const initialZ = Number(await canvas.getAttribute("data-a-z")); + const bounds = await seek.boundingBox(); + if (!bounds) throw new Error("Replay slider has no layout"); + const duration = Number(await clock.getAttribute("data-duration")); + await seek.click({ position: { x: 8 + (bounds.width - 16) * (10 / duration), y: bounds.height / 2 } }); + await expect.poll(async () => Number(await clock.getAttribute("data-time"))).toBeGreaterThan(9); + await expect.poll(async () => Number(await canvas.getAttribute("data-render-time"))).toBeGreaterThan(9); + const time = Number(await canvas.getAttribute("data-render-time")); + const gpsAt = (target: number) => { + const index = gps.findIndex((sample) => sample.time >= target); + if (index < 0) throw new Error("Replay cursor outside measured GPS span"); + const after = gps[index]!, before = gps[Math.max(0, index - 1)]!; + const fraction = after.time === before.time ? 0 : (target - before.time) / (after.time - before.time); + return { + latitude: before.latitude + (after.latitude - before.latitude) * fraction, + longitude: before.longitude + (after.longitude - before.longitude) * fraction, + }; + }; + const first = gpsAt(0), current = gpsAt(time); + const metresPerDegree = Math.PI / 180 * 6_378_137; + const eastScale = metresPerDegree * Math.cos(first.latitude * Math.PI / 180); + const expectedX = -(current.longitude - first.longitude) * eastScale; + const expectedZ = (current.latitude - first.latitude) * metresPerDegree; + expect(Number(await canvas.getAttribute("data-a-x")) - initialX, "Compare mirrors X like replay's map").toBeCloseTo(expectedX, 0); + expect(Number(await canvas.getAttribute("data-a-z")) - initialZ, "Compare preserves forward north-south travel").toBeCloseTo(expectedZ, 0); + const before = gpsAt(time - 0.05), after = gpsAt(time + 0.05); + const dx = -(after.longitude - before.longitude) * eastScale; + const dz = (after.latitude - before.latitude) * metresPerDegree; + const yaw = Number(await canvas.getAttribute("data-a-yaw")); + expect((Math.sin(yaw) * dx + Math.cos(yaw) * dz) / Math.hypot(dx, dz), "Car faces its forward path, not backward").toBeGreaterThan(0.95); + await seek.press("Home"); + await expect(clock).toHaveAttribute("data-time", "0"); + await page.getByRole("button", { name: "Play replay" }).click(); + await expect(clock).toHaveAttribute("data-playing", "true"); + const beforePlay = Number(await clock.getAttribute("data-time")); + await expect.poll(async () => Number(await clock.getAttribute("data-time"))).toBeGreaterThan(beforePlay); + await page.getByRole("button", { name: "Pause replay" }).click(); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect.poll(async () => scene.getAttribute("data-scene-ready")).toBe("true"); + await expect(scene).toHaveAttribute("data-scene-error", ""); + for (const side of ["a", "b"] as const) { + await expect.poll(async () => canvas.getAttribute(`data-${side}-x`)).not.toBe(""); + await expect.poll(async () => canvas.getAttribute(`data-${side}-z`)).not.toBe(""); + } + await expect(map).toBeVisible(); +}); test("FM Spectate segment seeking retargets and pauses", async ({ page, request }) => { const game = SEEDED_GAME_CASES.find((candidate) => candidate.gameId === "fm-2023" && candidate.supportedFeatures.includes("compare")); diff --git a/packages/lap-replay/test/e2e/compare/spectate-focus.spec.ts b/packages/lap-replay/test/e2e/compare/spectate-focus.spec.ts index bc18b2fc8..88d381106 100644 --- a/packages/lap-replay/test/e2e/compare/spectate-focus.spec.ts +++ b/packages/lap-replay/test/e2e/compare/spectate-focus.spec.ts @@ -186,6 +186,7 @@ for (const gameId of ["fm-2023", "f1-2025"] as const) { await page.setViewportSize({ width: 600, height: 900 }); await expect(page.locator("[data-mobile-panel-tabs]")).toBeVisible(); const carPanel = page.getByRole("button", { name: "Car layout", exact: true }); + if ((await carPanel.getAttribute("aria-pressed")) === "true") await carPanel.click(); await carPanel.focus(); await carPanel.press("Enter"); await expect(carPanel).toHaveAttribute("aria-pressed", "true"); diff --git a/packages/lap-replay/test/e2e/compare/spectate-preferences.spec.ts b/packages/lap-replay/test/e2e/compare/spectate-preferences.spec.ts index 2df658963..2b353b820 100644 --- a/packages/lap-replay/test/e2e/compare/spectate-preferences.spec.ts +++ b/packages/lap-replay/test/e2e/compare/spectate-preferences.spec.ts @@ -18,8 +18,6 @@ test("Spectate panel headers stay aligned and remember opened panels", async ({ for (const mask of [0, 1, 3, 31, 29, 28, 30, 2]) { for (const [index, id] of ids.entries()) await toggle(id, Boolean(mask & (1 << index))); const layout = await page.evaluate((panelIds) => { - const strip = document.querySelector("[data-spectate-panel-headers]")!; - const clip = strip.getBoundingClientRect(); return { headers: Array.from(document.querySelectorAll("[data-panel-header]"), (element) => { const box = element.getBoundingClientRect(); @@ -61,7 +59,12 @@ test("Spectate panel headers stay aligned and remember opened panels", async ({ await expect(page.getByRole("tooltip")).toContainText("Share of current shock compression; this is not measured tire load."); await page.mouse.move(0, 0); await page.setViewportSize({ width: 700, height: 950 }); - for (const id of ids) await toggle(id, true); + for (const id of ids) { + await toggle(id, true); + for (const other of ids) { + await expect(page.locator(`[data-panel-toggle="${other}"]`)).toHaveAttribute("aria-expanded", String(other === id)); + } + } await page.reload(); // Narrow workspaces show one panel at a time; the last selection survives reload. for (const id of ids) await expect(page.locator(`[data-panel-toggle="${id}"]`)).toHaveAttribute("aria-expanded", String(id === ids.at(-1))); diff --git a/packages/lap-replay/test/integration/replay-routes.test.ts b/packages/lap-replay/test/integration/replay-routes.test.ts index c0e39d6fd..e540e1fea 100644 --- a/packages/lap-replay/test/integration/replay-routes.test.ts +++ b/packages/lap-replay/test/integration/replay-routes.test.ts @@ -37,7 +37,7 @@ describe("GET /api/laps/:id/semantic-telemetry", () => { headers: { "X-Game-Id": "f1-2025" }, }); - expect(response.status).toBe(200); + expect(response.status, await response.clone().text()).toBe(200); const body = await response.json() as { lapId: number; requestedSemanticIds: readonly string[]; diff --git a/packages/lap-replay/test/unit/spectate-replay.test.ts b/packages/lap-replay/test/unit/spectate-replay.test.ts index 61205dbce..b96e641d4 100644 --- a/packages/lap-replay/test/unit/spectate-replay.test.ts +++ b/packages/lap-replay/test/unit/spectate-replay.test.ts @@ -1,11 +1,12 @@ import { resolveBalance, resolveWheelStates } from "@raceiq/analysis-core/racing/analysis/metric-values"; import { describe, expect, test } from "bun:test"; import type { ComparisonData } from "@raceiq/shared/racing/comparison/types"; -import type { SemanticLapTelemetry } from "@raceiq/lap-replay/contracts" -import { createSpectateController, prepareSpectateLap, vehicleSideslipDegrees } from "@raceiq/lap-replay/ui" -import { estimateWheelSlipAngles, resolveWheelSlipGeometry, AMS2_POLO_APPROXIMATE_GEOMETRY, AMS2_POLO_GEOMETRY_DISCLOSURE } from "../../ui/spectate-replay"; -import type { SpectateLap, SpectateSample } from "@raceiq/lap-replay/contracts" -import { channelValue } from "../../ui/spectate-channels"; +import type { SemanticLapTelemetry } from "@raceiq/lap-replay/contracts"; +import { createSpectateController, placeIRacingSpectateLaps, prepareSpectateLap, sampleLap, vehicleSideslipDegrees } from "@raceiq/lap-replay/ui"; +import type { SpectateLap, SpectateSample } from "@raceiq/lap-replay/contracts"; +import { channelValue } from "@raceiq/lap-replay/ui"; +import type { SpectateChannel } from "../../ui/spectate-channels"; +import { estimateWheelSlipAngles, resolveWheelSlipGeometry, spectateSamplePosition, AMS2_POLO_APPROXIMATE_GEOMETRY, AMS2_POLO_GEOMETRY_DISCLOSURE } from "../../ui/spectate-replay"; import { initGameAdapters } from "@raceiq/game-catalogs/games/init"; import { buildSpectateChannels } from "../../ui/spectate-channels" import type { SpectatePair } from "../../contracts/spectate" @@ -807,6 +808,96 @@ describe("Spectate native sampling", () => { }); }); +describe("iRacing Spectate scene geometry", () => { + const projection = { + points: [{ x: 0, z: 0 }, { x: 0, z: 100 }, { x: 100, z: 100 }, { x: 100, z: 0 }, { x: 0, z: 0 }], + source: "official-svg" as const, units: "m" as const, start: "start-finish" as const, direction: "forward" as const, + }; + const comparison = { traces: { distance: [0, 10, 20], elapsedTimeA: [0, 0.05, 0.1], elapsedTimeB: [0, 0.05, 0.1] } } as unknown as ComparisonData; + const recording = (rows: Record[], stale?: string): SemanticLapTelemetry => ({ + lapId: 1, requestedSemanticIds: [], + envelopes: rows.map((row, index) => ({ + sequence: index, simulator: "iracing", + observedAt: { domain: "session", milliseconds: index * 50 }, + receivedAt: { domain: "session", milliseconds: index * 50 }, + values: Object.entries({ "timing.current-lap": index * 0.05, ...row }).map(([semanticId, value]) => ({ + semanticId, value, state: "ok", freshness: index === 1 && semanticId === stale ? "stale" : "fresh", + })), + })), + }); + test("projects valid progress and interpolates scene pose without creating native positions", () => { + const replay = recording([0, 0.125, 0.25].map((fraction) => ({ "timing.lap-fraction": fraction, "motion.yaw": -2 }))); + const native = prepareSpectateLap(replay, comparison, "a"); + const [a, b] = placeIRacingSpectateLaps([native, native], projection); + expect(sampleLap(a, 0.025, false)?.pose).toEqual({ x: 0, y: 0, z: 25, yaw: 0 }); + expect(sampleLap(b, 0.05, false)?.pose).toMatchObject({ x: 0, y: 0, z: 50, yaw: 0 }); + expect(a.samples[1].values["motion.position-x"]).toBeUndefined(); + expect(a.samples[1].values["motion.yaw"]).toBe(-2); + expect(native.samples[1].scenePose).toBeUndefined(); + const [missing] = placeIRacingSpectateLaps([native, native], null); + expect(sampleLap(missing, 0.05, false)?.pose).toBeNull(); + const stale = prepareSpectateLap(recording([0, 0.125, 0.25].map((fraction) => ({ "timing.lap-fraction": fraction })), "timing.lap-fraction"), comparison, "a"); + const [gapped] = placeIRacingSpectateLaps([stale, stale], projection); + expect(sampleLap(gapped, 0.025, false)?.pose).toBeNull(); + expect(sampleLap(gapped, 0.05, false)?.pose).toBeNull(); + expect(sampleLap(gapped, 0.1, false)?.pose?.z).toBe(100); + }); + test("iRacing measured replay keeps map positions when recorded compass heading is absent", () => { + const replay = recording([ + { "motion.latitude": 53, "motion.longitude": -2 }, + { "motion.latitude": 53.0001, "motion.longitude": -2 }, + { "motion.latitude": 53.0001, "motion.longitude": -1.9999 }, + ]); + const native = prepareSpectateLap(replay, comparison, "a"); + const [placed] = placeIRacingSpectateLaps([native, native], projection); + expect(placed.samples.every(sample => spectateSamplePosition(sample) !== null)).toBe(true); + expect(sampleLap(placed, 0.05, false)?.pose).toMatchObject({ source: "measured", z: expect.closeTo(11.1, 0) }); + }); + test("measured laps share one origin and missing GPS never snaps onto the reference", () => { + const row = (latitude: number) => ({ "motion.latitude": latitude, "motion.longitude": -2, "motion.yaw-north": 0.4, "timing.lap-fraction": 0.5 }); + const a = prepareSpectateLap(recording([row(53), row(53.0001), row(53.0002)]), comparison, "a"); + const b = prepareSpectateLap(recording([row(53.0001), { "timing.lap-fraction": 0.5 }, row(53.0003)]), comparison, "b"); + const [placedA, placedB] = placeIRacingSpectateLaps([a, b], projection); + expect(sampleLap(placedA, 0, false)?.pose).toMatchObject({ x: 0, y: 0, z: 0, yaw: -0.4 }); + expect(sampleLap(placedB, 0, false)?.pose?.z).toBeCloseTo(11.132, 2); + expect(sampleLap(placedB, 0.05, false)?.pose).toBeNull(); + expect(sampleLap(placedB, 0.025, false)?.pose).toBeNull(); + expect(sampleLap(placedB, 0.1, false)?.pose?.z).toBeCloseTo(33.396, 2); + }); + test("places a live-only comparison lap in the measured lap's reference frame", () => { + const radians = Math.PI / 180; + const measuredRows = projection.points.map((point) => ({ + "motion.latitude": 53 + point.z / (radians * 6_378_137), + "motion.longitude": -2 + point.x / (radians * 6_378_137 * Math.cos(53 * radians)), + "motion.yaw-north": 0, + })); + const measured = prepareSpectateLap(recording(measuredRows), comparison, "a"); + const live = prepareSpectateLap(recording([0, 0.125, 0.25].map((fraction) => ({ "timing.lap-fraction": fraction }))), comparison, "b"); + const [a, b] = placeIRacingSpectateLaps([measured, live], projection); + expect(a.samples[1].scenePose).toMatchObject({ source: "measured", x: 0, z: expect.closeTo(100, 3) }); + expect(sampleLap(b, 0.05, false)?.pose).toMatchObject({ source: "lap-fraction", x: expect.closeTo(0, 3), z: expect.closeTo(50, 3) }); + expect(b.samples[1].values["motion.position-x"]).toBeUndefined(); + expect(measured.samples[1].scenePose).toBeUndefined(); + }); + test("matches replay map handedness and keeps projected cars facing forward through a turn", () => { + const replay = recording([0.25, 0.375, 0.5].map((fraction) => ({ "timing.lap-fraction": fraction }))); + const native = prepareSpectateLap(replay, comparison, "a"); + const [placed] = placeIRacingSpectateLaps([native, native], projection); + const start = sampleLap(placed, 0, false)!.pose!; + const middle = sampleLap(placed, 0.05, false)!.pose!; + const end = sampleLap(placed, 0.1, false)!.pose!; + // Replay draws maxX - x: increasing source X must move left on both maps. + expect(middle.x).toBe(-50); + expect(middle.z).toBe(100); + expect(end.x).toBe(-100); + expect(Math.sin(middle.yaw)).toBeCloseTo(-1); + const dx = end.x - start.x, dz = end.z - start.z; + expect((Math.sin(middle.yaw) * dx + Math.cos(middle.yaw) * dz) / Math.hypot(dx, dz)).toBeCloseTo(1); + expect(native.samples[1].scenePose).toBeUndefined(); + expect(placed.samples[1].values["motion.position-x"]).toBeUndefined(); + }); +}); + function reducedRateReplay(simulator: "ams2" | "fm-2023" = "ams2"): SemanticLapTelemetry { const replay = recordedLap(1.2, time => time * 20).replay; let milliseconds = 0; @@ -856,3 +947,26 @@ test("reduced-rate AMS2 cadence does not loosen gap handling for other simulator try { expect(controller.sample((a.samples[0]!.time + a.samples[1]!.time) / 2).a).toBeNull(); } finally { controller.dispose(); } }); + +test("iRacing interpolates short capture stalls but preserves long gaps and other games", () => { + const recording = (simulator: "iracing" | "fm-2023", gap: number) => ({ + lapId: 1, requestedSemanticIds: [], + envelopes: [0, gap].map((time, sequence) => ({ + sequence, simulator, + observedAt: { domain: "session" as const, milliseconds: time * 1000 }, + values: [ + { semanticId: "motion.position-x", value: time * 20 }, + { semanticId: "motion.position-z", value: 0 }, + { semanticId: "motion.yaw", value: 0 }, + { semanticId: "motion.speed", value: 20 + time * 10 }, + ], + })), + }); + for (const gap of [7 / 60, 0.15, 0.25]) { + const lap = prepareSpectateLap(recording("iracing", gap)); + expect(sampleLap(lap, gap / 2, false)?.pose?.x).toBeCloseTo(gap * 10); + expect(sampleLap(lap, gap / 2, false)?.values["motion.speed"]).toBeCloseTo(20 + gap * 5); + } + expect(sampleLap(prepareSpectateLap(recording("iracing", 0.3)), 0.15, false)).toBeNull(); + expect(sampleLap(prepareSpectateLap(recording("fm-2023", 0.15)), 0.075, false)).toBeNull(); +}); diff --git a/packages/lap-replay/ui/SingleReplaySession.tsx b/packages/lap-replay/ui/SingleReplaySession.tsx index 595d02f91..16d60e253 100644 --- a/packages/lap-replay/ui/SingleReplaySession.tsx +++ b/packages/lap-replay/ui/SingleReplaySession.tsx @@ -1,10 +1,11 @@ +import type { TrackProjection } from "@raceiq/scene-3d/wireframe/ScenePlacement"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { GameId } from "@raceiq/shared/games/ids"; import type { LapMeta } from "@raceiq/shared/racing/sessions/types"; import type { SemanticLapTelemetry } from "../contracts/replay"; import type { ReplayOpponentMetadata, ReplaySession as ReplaySessionData, SpectateController, SpectateSegment } from "../contracts/spectate"; import type { OpponentSemanticLapTelemetry } from "../contracts/opponents"; -import { createSpectateController, prepareSpectateLap } from "./spectate-replay"; +import { createSpectateController, placeIRacingSpectateLaps, prepareSpectateLap } from "./spectate-replay"; import { SpectateView } from "./SpectateView"; import { useLocalStorage, useSettings } from "./host"; export interface SingleReplaySessionProps { @@ -12,6 +13,7 @@ export interface SingleReplaySessionProps { lap: LapMeta | ReplayOpponentMetadata; replay: SemanticLapTelemetry | OpponentSemanticLapTelemetry; outline: readonly { x: number; z: number }[] | null; + trackProjection?: TrackProjection | null; segments: readonly SpectateSegment[]; trackLengthM?: number; initialFrame?: number; @@ -23,12 +25,12 @@ export interface SingleReplaySessionProps { } /** One native replay clock rendered through the same scene and panels as comparison. */ -export function SingleReplaySession({ gameId, lap, replay, outline, segments, trackLengthM, initialFrame, ai, aiOpen, onAiOpenChange, onController, panelHeaderTarget }: SingleReplaySessionProps) { +export function SingleReplaySession({ gameId, lap, replay, outline, trackProjection, segments, trackLengthM, initialFrame, ai, aiOpen, onAiOpenChange, onController, panelHeaderTarget }: SingleReplaySessionProps) { const a = useMemo(() => { const prepared = prepareSpectateLap(replay); if (typeof trackLengthM === "number" && Number.isFinite(trackLengthM) && trackLengthM > 0) prepared.trackLengthM = trackLengthM; - return prepared; - }, [replay, trackLengthM]); + return gameId === "iracing" ? placeIRacingSpectateLaps([prepared, prepared], trackProjection ?? null)[0] : prepared; + }, [gameId, replay, trackLengthM, trackProjection]); const session = useMemo(() => ({ gameId, a, diff --git a/packages/lap-replay/ui/SpectateInputs.tsx b/packages/lap-replay/ui/SpectateInputs.tsx index 083f1c5ac..c412a2298 100644 --- a/packages/lap-replay/ui/SpectateInputs.tsx +++ b/packages/lap-replay/ui/SpectateInputs.tsx @@ -206,6 +206,7 @@ return (
{pair.b &&
{m.spectate_time_delta()} · A − B diff --git a/packages/lap-replay/ui/SpectateOverview.tsx b/packages/lap-replay/ui/SpectateOverview.tsx index 88716e62d..dbdff8f8e 100644 --- a/packages/lap-replay/ui/SpectateOverview.tsx +++ b/packages/lap-replay/ui/SpectateOverview.tsx @@ -2,15 +2,17 @@ import { useMessages } from "./host" import { useMemo, useSyncExternalStore } from "react"; import type { SpectateController, ReplaySession, SpectateSegment } from "../contracts/spectate"; import { focusRuns, type SpectateFocus } from "./spectate-focus"; +import { spectateSamplePosition } from "./spectate-replay"; +import { segmentDisplayNames } from "@raceiq/shared/racing/tracks/segment-label"; -type TrackPoint = { distance: number; run: number; x: number; z: number }; +type TrackPoint = { distance: number; run: number; x: number; y: number; z: number }; function trackRuns(pair: ReplaySession): TrackPoint[][] { const runs: TrackPoint[][] = []; let current: TrackPoint[] = []; let prior: TrackPoint | null = null; for (const sample of pair.a.samples) { - const x = sample.values["motion.position-x"], - z = sample.values["motion.position-z"]; + const position = spectateSamplePosition(sample); + const x = position?.x, z = position?.z; const distance = pair.a.trackLengthM ? sample.values["timing.distance-traveled"] : sample.distance; if (typeof distance !== "number" || !Number.isFinite(distance) || !Number.isFinite(x) || !Number.isFinite(z)) { if (current.length) runs.push(current); @@ -18,7 +20,7 @@ function trackRuns(pair: ReplaySession): TrackPoint[][] { prior = null; continue; } - const point = { distance, run: sample.run, x: x as number, z: z as number }; + const point = { distance, run: sample.run, x: x as number, y: 0, z: z as number }; if (prior && (prior.run !== point.run || point.distance < prior.distance)) { if (current.length) runs.push(current); current = []; @@ -45,7 +47,7 @@ function positionAtDistance(points: readonly TrackPoint[], target: number) { span = next.distance - before.distance; if (span <= 0) return before; const fraction = (target - before.distance) / span; - return { x: before.x + (next.x - before.x) * fraction, z: before.z + (next.z - before.z) * fraction }; + return { x: before.x + (next.x - before.x) * fraction, y: 0, z: before.z + (next.z - before.z) * fraction }; } function positionAcrossRuns(runs: readonly TrackPoint[][], target: number) { for (const run of runs) { @@ -120,14 +122,17 @@ function splitText(delta: number | null, unavailable: string) { export function SpectateOverview({ pair, controller, segments, focus }: { pair: ReplaySession; controller: SpectateController; segments: readonly SpectateSegment[]; focus: SpectateFocus }) { const m = useMessages(); const coloredRuns = useMemo(() => [focusRuns(pair.a.samples, focus, "opponentLapId" in pair.a.replay ? 0.3 : pair.a.maximumGapSeconds), ...(pair.b ? [focusRuns(pair.b.samples, focus, "opponentLapId" in pair.b.replay ? 0.3 : pair.b.maximumGapSeconds)] : [])], [pair, focus]); const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); const splitAt = useMemo(() => splitLookup(pair), [pair]); - const hasAuthoritativeOpponentLength = !("opponentLapId" in pair.a.replay) || (typeof pair.a.trackLengthM === "number" && Number.isFinite(pair.a.trackLengthM) && pair.a.trackLengthM > 0); - const rows = useMemo( - () => - hasAuthoritativeOpponentLength ? segments - .filter((segment) => Number.isFinite(segment.startFrac) && Number.isFinite(segment.endFrac) && segment.startFrac >= 0 && segment.startFrac <= 1 && segment.endFrac >= 0 && segment.endFrac <= 1) - .map((segment, index) => ({ segment, number: index + 1, delta: splitAt(segment) })) : [], - [segments, splitAt, hasAuthoritativeOpponentLength], - ); +const rows = useMemo(() => { + const valid = segments.filter((segment) => Number.isFinite(segment.startFrac) && Number.isFinite(segment.endFrac) && segment.startFrac >= 0 && segment.startFrac <= 1 && segment.endFrac >= 0 && segment.endFrac <= 1); + const labels = segmentDisplayNames(valid); + let straight = 0, corner = 0; + return valid.map((segment, index) => { + const ordinal = segment.type === "straight" ? ++straight : ++corner; + const label = labels[index]!; + const badge = label.match(/^[TS]\d+(?:[-,/]\d+)*/)?.[0] ?? `${segment.type === "straight" ? "S" : "T"}${ordinal}`; + return { segment, number: index + 1, label, badge, delta: splitAt(segment) }; + }); +}, [segments, splitAt]); const runs = useMemo(() => trackRuns(pair), [pair]); const maximum = pair.a.trackLengthM ?? pair.comparison.traces?.distance.at(-1) ?? pair.a.distance; const distanceOrigin = useMemo(() => { @@ -177,7 +182,7 @@ const geometry = useMemo(() => { } return true; }; - const markers = rows.map(({ segment, number, delta }) => { + const markers = rows.map(({ segment, number, label, badge, delta }) => { const endFraction = segment.endFrac < segment.startFrac ? segment.endFrac + 1 : segment.endFrac; const fraction = ((segment.startFrac + endFraction) / 2) % 1; const seekPosition = positionAcrossRuns(runs, segment.startFrac * maximum); @@ -208,7 +213,7 @@ const geometry = useMemo(() => { } } } - return { number, segment, delta, anchor: projectedAnchor, position: marker, seekable: seekPosition !== null, sectionPaths: segmentPaths(runs, segment.startFrac, segment.endFrac, maximum, projectString) }; + return { number, segment, label, badge, delta, anchor, position: marker, seekable: seekPosition !== null, sectionPaths: segmentPaths(runs, segment.startFrac, segment.endFrac, maximum, projectString) }; }); return { project, paths: runs.filter((run) => run.length > 1).map((run) => run.map(projectString).join(" ")), markers }; }, [maximum, rows, runs]); @@ -220,16 +225,17 @@ const poseA = geometry?.project(snapshot.a?.pose ?? { x: Number.NaN, z: Number.N const poseB = geometry?.project(snapshot.b?.pose ?? { x: Number.NaN, z: Number.NaN }); return (
{m.spectate_overview()} {m.spectate_select_segment()}
{geometry ? ( - + {geometry.paths.map((path, index) => ( ))} @@ -239,7 +245,7 @@ return ( fill="none" stroke={`var(${run.token})`} strokeWidth={side === 0 ? "4" : "1.8"} strokeLinecap="round" strokeLinejoin="round" pointerEvents="none" /> )))} - {geometry.markers.map(({ number, segment, delta, anchor, position, sectionPaths, seekable }) => ( + {geometry.markers.map(({ number, segment, label, badge, delta, anchor, position, sectionPaths, seekable }) => ( controller.seekDistance(segment.startFrac * maximum - distanceOrigin) : undefined} onKeyDown={seekable ? (event) => { if (event.key === "Enter" || event.key === " ") { @@ -259,7 +265,7 @@ return ( } : undefined} className={`${seekable ? "cursor-pointer" : "cursor-not-allowed"} outline-none focus-visible:opacity-70 sm:pointer-events-none sm:opacity-0 sm:group-hover/overview:pointer-events-auto sm:group-hover/overview:opacity-100 sm:group-focus-within/overview:pointer-events-auto sm:group-focus-within/overview:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100`} > - {pair.b ? `${segment.name || m.spectate_segment_number({ number })} · A − B: ${splitText(delta, m.analyse_unavailable())}` : segment.name || m.spectate_segment_number({ number })} + {pair.b ? `${label} · A − B: ${splitText(delta, m.analyse_unavailable())}` : label} {sectionPaths.map((path, index) => ( ))} @@ -268,7 +274,7 @@ return ( {anchor && } 0 ? "var(--app-text)" : "var(--app-bg)"} fontSize="6" fontWeight="var(--font-weight-bold)"> - {number} + {badge} )} diff --git a/packages/lap-replay/ui/SpectateScene.tsx b/packages/lap-replay/ui/SpectateScene.tsx index e690b03c1..0ae8e0e97 100644 --- a/packages/lap-replay/ui/SpectateScene.tsx +++ b/packages/lap-replay/ui/SpectateScene.tsx @@ -144,6 +144,10 @@ useEffect(() => { inner: m.label_inner(), middle: m.label_middle(), outer: m.label_outer(), core: m.label_core(), carcass: m.label_carcass(), surface: m.label_surface(), }, + pitTemperatureLabel: m.analyse_wheels_pit_temp(), carcassLabel: m.label_carcass(), + pitHealthLabel: m.analyse_wheels_pit_health(), coldPressureLabel: m.analyse_wheels_cold_pressure(), + pressureLabel: m.analyse_wheels_pressure(), brakeLabel: m.analyse_wheels_brake(), + wearRateLabel: m.analyse_wheels_wear_s(), snapshotStaleLabel: m.carwire_snapshot_stale(), pressureOptimal: [pressureA, pressureB], }); }, [tireInfo, units.temp, units.tempLabel, pressureA, pressureB, m, locale, key, controller, displaySettings.renderFpsCap]); @@ -187,7 +191,7 @@ useEffect(() => { }, [pair, key]); return (