Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Detect imported file contents before accepting ZIP/BIN session data and reject unrelated archives
### Fixes
- Raise Windows timer resolution during ACC and AC Evo capture so shared-memory polling no longer collapses to the default ~64 Hz tick
- Preserve every iRacing SDK tick around lap completion so saved laps begin at start/finish without telemetry gaps
- Make stale-session reprocessing recoverable with retry and dismissal actions, accessible progress states, and clear failure feedback
- Skip unavailable raw captures during stale-session reprocessing instead of failing the entire maintenance run
- Keep newly started session captures from being removed by concurrent storage cleanup
Expand All @@ -24,7 +25,10 @@
- Preview and import iRacing IBT recordings larger than 128 MiB without upload connection failures
- Ignore one-frame iRacing lap-counter resets that created invalid duplicate lap numbers in session recaps
- Show iRacing steering direction and signed values correctly in live views, Analyse, Compare, and saved recordings
- Roll iRacing wireframe wheels in Analyse when per-wheel rotation telemetry is unavailable
- Show iRacing lateral G-force on the correct side during turns
- Draw iRacing left-turning oval laps in the correct direction on Analyse track maps
- Restore the moving car pointer on iRacing Analyse track maps
- Honor Analyse and Compare URL state so saved chats open with their AI panel visible and comparison cursor links are preserved
- Restore experiment version loading, editing, deletion, and recovery after the version API rename
- Keep Analyse insight navigation aligned on desktop and move the timeline tracking bar when stepping through events
Expand Down Expand Up @@ -65,6 +69,7 @@
- Group rear setup controls with their populated mechanical-balance section
- Close searchable dropdowns, including Analyse lap selection, after choosing an option
- Show vehicle roll in the correct direction on the Analyse attitude indicator
- Keep Analyse attitude indicator and roll/pitch readouts moving while replaying saved laps
- Restore Analyse Data panel rows, section grouping, source-native tyre temperatures, copied values, F1 ERS/DRS details, and green throttle traces on both 2D and 3D views

### Internal
Expand Down
40 changes: 37 additions & 3 deletions client/src/components/analyse/track-map/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,44 @@ import type { Point, SemanticAnalysisFrame } from "./types";
const number = (frame: SemanticAnalysisFrame, id: keyof SemanticAnalysisFrame["values"]) => {
const value = frame.values[id];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
};

const worldPosition = (frame: SemanticAnalysisFrame): Point => ({
x: number(frame, "motion.position-x") ?? 0,
z: number(frame, "motion.position-z") ?? 0,
});

export function resolveTrackPositions(telemetry: SemanticAnalysisFrame[], outline: Point[] | null): Point[] {
const worldPositions = telemetry.map(worldPosition);
if (worldPositions.some((point) => point.x !== 0 || point.z !== 0) || !outline || outline.length < 2) return worldPositions;

const fractions = telemetry.map((frame) => number(frame, "timing.lap-fraction"));
if (fractions.some((fraction) => fraction === null)) return worldPositions;

export function resolveTrackPositions(telemetry: SemanticAnalysisFrame[], _outline: Point[] | null): Point[] {
return telemetry.map((frame) => ({ x: number(frame, "motion.position-x") ?? 0, z: number(frame, "motion.position-z") ?? 0 }));
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,
};
});
}

export function pathForwardOffsets(points: readonly Point[]): ([number, number] | null)[] {
Expand Down
31 changes: 18 additions & 13 deletions client/src/components/wireframe/CarScene.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getGame } from "@shared/games/registry";
import { resolveAnalysisTelemetry } from "@shared/racing/analysis/telemetry-capabilities";
import { Grid, Line } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
Expand All @@ -8,7 +10,7 @@ import { useTirePressureOptimal } from "../../hooks/catalog-queries";
import { normalizeSuspensionTravel } from "../../lib/suspension";
import { tireState } from "../../lib/vehicle-dynamics";
import type { ViewPreset, ViewToggles } from "../../lib/wireframe-data";
import { steeringAngleRadians, THREE_COLORS } from "../../lib/wireframe-utils";
import { steeringAngleRadians, THREE_COLORS, visualWheelRotationSpeed } from "../../lib/wireframe-utils";
import { type SemanticAnalysisFrame, semanticNumber } from "../analyse/track-map/types";
import { AutoChaseCamera, CameraController } from "./CameraControllers";
import { CarBody } from "./CarBody";
Expand Down Expand Up @@ -181,28 +183,31 @@ export function CarScene({
const cambRL = 0;
const cambRR = 0;

const fTireR = carModel.frontTireRadius ?? carModel.tireRadius;
const rTireR = carModel.rearTireRadius ?? carModel.tireRadius;
const vehicleSpeed = semanticNumber(frame, "motion.speed") ?? 0;
const rotationValue = frame.values["tires.wheel-rotation-speed"];
const measuredRotation = Array.isArray(rotationValue) ? rotationValue : undefined;
const wheelRotationAvailable = resolveAnalysisTelemetry(getGame(gameId)).wheelRotation.source !== "unavailable";

// Zero out wheel rotation during lockup — locked wheel = no spin
const ws = {
fl: { state: "nominal", slipRatio: wheel(frame, "tires.tire-slip-ratio", 0) },
fr: { state: "nominal", slipRatio: wheel(frame, "tires.tire-slip-ratio", 1) },
rl: { state: "nominal", slipRatio: wheel(frame, "tires.tire-slip-ratio", 2) },
rr: { state: "nominal", slipRatio: wheel(frame, "tires.tire-slip-ratio", 3) },
} as {
fl: { state: "nominal" | "lockup"; slipRatio: number };
fr: { state: "nominal" | "lockup"; slipRatio: number };
rl: { state: "nominal" | "lockup"; slipRatio: number };
rr: { state: "nominal" | "lockup"; slipRatio: number };
};
const rotFL = ws.fl.state === "lockup" ? 0 : wheel(frame, "tires.wheel-rotation-speed", 0);
const rotFR = ws.fr.state === "lockup" ? 0 : wheel(frame, "tires.wheel-rotation-speed", 1);
const rotRL = ws.rl.state === "lockup" ? 0 : wheel(frame, "tires.wheel-rotation-speed", 2);
const rotRR = ws.rr.state === "lockup" ? 0 : wheel(frame, "tires.wheel-rotation-speed", 3);
} as Record<"fl" | "fr" | "rl" | "rr", { state: "nominal" | "lockup"; slipRatio: number }>;

// Preserve measured zeroes (including lockups). iRacing does not expose
// per-wheel speed, so derive visual rolling from vehicle speed and tire radius.
const rotFL = ws.fl.state === "lockup" ? 0 : visualWheelRotationSpeed(measuredRotation?.[0], vehicleSpeed, fTireR, wheelRotationAvailable);
const rotFR = ws.fr.state === "lockup" ? 0 : visualWheelRotationSpeed(measuredRotation?.[1], vehicleSpeed, fTireR, wheelRotationAvailable);
const rotRL = ws.rl.state === "lockup" ? 0 : visualWheelRotationSpeed(measuredRotation?.[2], vehicleSpeed, rTireR, wheelRotationAvailable);
const rotRR = ws.rr.state === "lockup" ? 0 : visualWheelRotationSpeed(measuredRotation?.[3], vehicleSpeed, rTireR, wheelRotationAvailable);

const wb = carModel.halfWheelbase;
const ft = carModel.halfFrontTrack;
const rt = carModel.halfRearTrack;
const fTireR = carModel.frontTireRadius ?? carModel.tireRadius;
const rTireR = carModel.rearTireRadius ?? carModel.tireRadius;
const fTireW = carModel.frontTireWidth ?? 0.3;
const rTireW = carModel.rearTireWidth ?? 0.3;
const pressFL = semanticNumber(frame, "tires.tire-pressure") ?? 0;
Expand Down
7 changes: 7 additions & 0 deletions client/src/lib/wireframe-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export function steeringAngleRadians(steerInput: number): number {
return steerInput === 0 ? 0 : -(steerInput / 127) * 0.35;
}

/** Use measured wheel speed when supported; otherwise derive visual rolling from v = ωr. */
export function visualWheelRotationSpeed(measuredRadS: unknown, speedMps: number, radiusM: number, measurementAvailable: boolean): number {
if (measurementAvailable && typeof measuredRadS === "number" && Number.isFinite(measuredRadS)) return measuredRadS;
if (!Number.isFinite(speedMps) || !Number.isFinite(radiusM) || radiusM <= 0) return 0;
return speedMps / radiusM;
}

/** Interpolate a 0–255 pedal channel into its rendered 3D line color. */
export function pedalInputColor(inactive: THREE.Color, active: THREE.Color, rawInput: number): THREE.Color {
return inactive.clone().lerp(active, rawInput / 255);
Expand Down
29 changes: 29 additions & 0 deletions client/test/static-track-drawing.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import { drawStaticTrack } from "../src/components/analyse/track-map/static-drawing";
import { resolveTrackPositions } from "../src/components/analyse/track-map/path";

test("returns no transform when replay has no drawable track points", () => {
const previousWindow = globalThis.window;
Expand Down Expand Up @@ -29,6 +30,34 @@ test("returns no transform when replay has no drawable track points", () => {
Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow });
}
});
test("projects telemetry without world coordinates onto the track outline", () => {
const frame = (fraction: number) => ({
values: { "motion.position-x": null, "motion.position-z": null, "timing.lap-fraction": fraction },
states: {},
freshness: {},
});
const outline = [{ x: 0, z: 0 }, { x: 100, z: 0 }, { x: 100, z: 100 }];

expect(resolveTrackPositions([frame(0), frame(0.25), frame(0.75), frame(1)], outline)).toEqual([
{ x: 0, z: 0 },
{ x: 50, z: 0 },
{ x: 100, z: 50 },
{ x: 100, z: 100 },
]);
});

test("prefers recorded world coordinates over lap-fraction projection", () => {
const frame = (x: number, z: number, fraction: number) => ({
values: { "motion.position-x": x, "motion.position-z": z, "timing.lap-fraction": fraction },
states: {},
freshness: {},
});

expect(resolveTrackPositions(
[frame(20, 30, 0), frame(40, 50, 1)],
[{ x: 0, z: 0 }, { x: 100, z: 0 }],
)).toEqual([{ x: 20, z: 30 }, { x: 40, z: 50 }]);
});

test("draws throttle input traces in the throttle channel color", () => {
const previousWindow = globalThis.window;
Expand Down
8 changes: 7 additions & 1 deletion server/games/iracing/lap-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ export class LapDetectorIRacing implements ILapDetector {
private lastActivePacketTime = 0;

constructor(options: LapDetectorOptions) {
this.detector = new LapDetector(options);
// Live iRacing frames are already gated by IsOnTrack. Its SDK source may
// drain a short queued burst after lap persistence, so wall-clock packet
// rate is not a valid activity signal and must not drop the lap boundary.
this.detector = new LapDetector({
...options,
bypassPacketRateFilter: true,
});
}

get session(): SessionState | null {
Expand Down
6 changes: 3 additions & 3 deletions server/games/iracing/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,9 @@ export function normalizeIRacingFrame(
EngineIdleRpm: session.engineIdleRpm,
CurrentEngineRpm: scalar(values, "RPM", 0),

// The iRacing SDK publishes these accelerations in m/s², matching the
// canonical values consumed by RaceIQ's G-force views.
AccelerationX: scalar(values, "LatAccel", 0),
// 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),

Expand Down
105 changes: 88 additions & 17 deletions server/games/iracing/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface IRacingTelemetrySourceOptions {
recordingDir?: string;
recorder?: IRacingRecorder;
}
interface QueuedIRacingFrame {
rawFrame: Buffer;
identity?: IRacingSessionSnapshot;
identityKey?: string;
resolve: (processed: boolean) => void;
}

async function dispatchThroughParser(rawFrame: Buffer): Promise<void> {
const packet = parsePacket(rawFrame);
Expand Down Expand Up @@ -61,13 +67,16 @@ export class IRacingTelemetrySource {
private readonly framePipeline = new IRacingFramePipeline();
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
private polling = false;
private lastErrorLogAt = 0;
private cachedSessionInfoUpdate: number | null = null;
private cachedSessionInfo: string | null = null;
private cachedSessionNum: number | null = null;
private cachedSession: IRacingSessionSnapshot | null = null;

private cachedIdentityKey: string | null = null;
// SDK retains only its newest row. Capture and encode on every timer tick;
// serialize slower persistence and pipeline work behind this in-memory queue.
private readonly frameQueue: QueuedIRacingFrame[] = [];
private drainPromise: Promise<void> | null = null;
constructor(options: IRacingTelemetrySourceOptions = {}) {
this.reader = options.reader ?? new IRacingSdkReader();
this.dispatchRawFrame = options.dispatchRawFrame ?? dispatchThroughParser;
Expand Down Expand Up @@ -102,6 +111,7 @@ export class IRacingTelemetrySource {
}
try {
await this.reader.stop();
await this.drainPromise;
} finally {
if (this.recordingEnabled) {
await this.recorder.stop();
Expand All @@ -110,19 +120,21 @@ export class IRacingTelemetrySource {
this.cachedSessionInfo = null;
this.cachedSessionNum = null;
this.cachedSession = null;
this.cachedIdentityKey = null;
this.frameQueue.length = 0;
this.drainPromise = null;
this.frameEncoder.reset();
console.log("[iRacing] Telemetry source stopped");
}
}

async pollOnce(): Promise<boolean> {
if (this.polling) return false;
this.polling = true;
try {
const snapshot = this.reader.readLatest();
if (!snapshot) return false;

const sessionNum = Math.trunc(numeric(snapshot.values, "SessionNum", 0));
let identity: IRacingSessionSnapshot | undefined;
if (
!this.cachedSession ||
this.cachedSessionInfoUpdate !== snapshot.sessionInfoUpdate ||
Expand All @@ -133,11 +145,21 @@ export class IRacingTelemetrySource {
snapshot.sessionInfo,
sessionNum,
);
await this.registerIdentity?.(session);
this.cachedSessionInfoUpdate = snapshot.sessionInfoUpdate;
this.cachedSessionInfo = snapshot.sessionInfo;
this.cachedSessionNum = sessionNum;
this.cachedSession = session;

const identityKey = [
session.carId,
session.carName,
session.trackId,
session.trackName,
].join("\0");
if (identityKey !== this.cachedIdentityKey) {
this.cachedIdentityKey = identityKey;
identity = session;
}
}
const frame: IRacingSourceFrameV3 = {
schemaVersion: 3,
Expand All @@ -147,20 +169,69 @@ export class IRacingTelemetrySource {
sessionInfoUpdate: snapshot.sessionInfoUpdate,
};
const rawFrame = this.frameEncoder.encode(frame);
await this.framePipeline.process(rawFrame);
return true;
return await new Promise<boolean>((resolve) => {
this.frameQueue.push({
rawFrame,
identity,
identityKey: identity ? this.cachedIdentityKey ?? undefined : undefined,
resolve,
});
this.startDrain();
});
} catch (error) {
const now = Date.now();
if (now - this.lastErrorLogAt >= 5000) {
this.lastErrorLogAt = now;
console.error(
"[iRacing] Telemetry source frame failed:",
error instanceof Error ? error.message : error,
);
}
this.logSourceError(error);
return false;
} finally {
this.polling = false;
}
}

private startDrain(): void {
if (this.drainPromise) return;
const drain = this.drainFrames();
this.drainPromise = drain;
void drain
.finally(() => {
if (this.drainPromise !== drain) return;
this.drainPromise = null;
if (this.frameQueue.length > 0) this.startDrain();
})
.catch(() => {});
}

private async drainFrames(): Promise<void> {
let entry: QueuedIRacingFrame | undefined;
while ((entry = this.frameQueue.shift())) {
try {
if (entry.identity) {
await this.registerIdentity?.(entry.identity);
}
} catch (error) {
if (
entry.identityKey &&
entry.identityKey === this.cachedIdentityKey
) {
this.cachedIdentityKey = null;
this.cachedSessionInfoUpdate = null;
}
this.logSourceError(error);
}

try {
await this.framePipeline.process(entry.rawFrame);
entry.resolve(true);
} catch (error) {
this.logSourceError(error);
entry.resolve(false);
}
}
}

private logSourceError(error: unknown): void {
const now = Date.now();
if (now - this.lastErrorLogAt < 5000) return;
this.lastErrorLogAt = now;
console.error(
"[iRacing] Telemetry source frame failed:",
error instanceof Error ? error.message : error,
);
}
}
Loading