diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be115650..18ec2cfda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Show telemetry quality, evidence limits, and analysis suitability per lap, with safe rebuild actions when source recordings remain available ### Fixes +- Preserve long ACC recordings across quick game restarts without corrupting the next capture or flooding recorder warnings - Raise Windows timer resolution during ACC and AC Evo capture so shared-memory polling no longer collapses to the default ~64 Hz tick - Keep live and replay telemetry gap measurements aligned across native packet IDs and timestamp-only sources - Clear stale degraded lap-quality states after a clean recording rebuild @@ -65,6 +66,7 @@ - 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 +- Add immutable, hash-verified golden recording manifests and register ACC GT3 Spa v1 with source-backed stint, lap, fuel, environmental, assist, damage, and event observations - Catch repository-wide staged lint violations before commit and generate localization modules before root type-checking - Preserve complete exports when startup-job tests mock background schedulers - Keep tune prompt formatting compatible with game-specific setup blobs diff --git a/package.json b/package.json index 89e4a6f81..65feb3151 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "iracing:cars:seed": "bun scripts/iracing/seed-cars.ts", "iracing:tracks:seed": "bun scripts/iracing/seed-tracks.ts", "gzip:recording": "bun scripts/telemetry/recordings/gzip-recording.ts", + "golden:validate": "bun scripts/telemetry/recordings/validate-golden-recordings.ts", "fixtures:iracing": "bun scripts/iracing/generate-recording-fixture.ts", "fixtures:iracing:seed": "bun scripts/iracing/generate-seed-fixture.ts", "build:installer": "bun scripts/build/build-installer.ts", diff --git a/scripts/telemetry/recordings/golden-manifest.ts b/scripts/telemetry/recordings/golden-manifest.ts new file mode 100644 index 000000000..2e0274de3 --- /dev/null +++ b/scripts/telemetry/recordings/golden-manifest.ts @@ -0,0 +1,580 @@ +import { createHash } from "node:crypto"; +import { + createReadStream, + existsSync, + readFileSync, + readdirSync, +} from "node:fs"; +import { relative, resolve } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Writable } from "node:stream"; +import { createGunzip } from "node:zlib"; +import { z } from "zod"; + +export const GOLDEN_MANIFEST_SCHEMA_VERSION = 1 as const; + +const Sha256Schema = z.string().regex(/^sha256:[0-9a-f]{64}$/); +const IsoTimestampSchema = z.string().refine( + (value) => !Number.isNaN(Date.parse(value)), + "Expected an ISO timestamp", +); +const IdSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); +const PositiveIntegerSchema = z.number().int().positive(); +const NonNegativeIntegerSchema = z.number().int().nonnegative(); +const EvidenceKindSchema = z.enum([ + "source_observed", + "machine_derived", + "driver_reported", +]); + +const ArtifactSchema = z + .object({ + path: z.string().min(1), + format: z.literal("raceiq-session-capture"), + format_version: PositiveIntegerSchema, + compression: z.literal("gzip"), + byte_length: PositiveIntegerSchema, + sha256: Sha256Schema, + uncompressed_byte_length: PositiveIntegerSchema, + uncompressed_sha256: Sha256Schema, + record_count: PositiveIntegerSchema, + started_at: IsoTimestampSchema, + ended_at: IsoTimestampSchema, + duration_ms: PositiveIntegerSchema, + }) + .strict(); + +const SimulatorSchema = z + .object({ + game_id: z.enum(["fm-2023", "f1-2025", "acc", "ac-evo", "iracing"]), + name: z.string().min(1), + version: z.string().nullable(), + shared_memory_version: z.string().nullable(), + source_adapter: z.string().min(1), + }) + .strict(); + +const VehicleSchema = z + .object({ + car: z.string().min(1), + source_model: z.string().min(1), + class: z.string().min(1), + catalog_ordinal: NonNegativeIntegerSchema, + }) + .strict(); + +const CircuitSchema = z + .object({ + track: z.string().min(1), + layout: z.string().min(1), + source_name: z.string().min(1), + catalog_ordinal: NonNegativeIntegerSchema, + }) + .strict(); + +const ConditionsSchema = z + .object({ + session_type: z.string().min(1), + weather: z.enum(["dry", "wet", "mixed", "unknown"]), + track_condition: z.enum(["dry", "wet", "mixed", "unknown"]), + time_of_day: z.enum(["day", "night", "mixed", "unknown"]), + traffic: z.enum(["none", "minimal", "present", "unknown"]), + participant_count: PositiveIntegerSchema.nullable(), + evidence: z.array(z.string().min(1)).min(1), + }) + .strict(); + +const PurposeSchema = z.enum([ + "canonical_telemetry_reference", + "parser_regression", + "live_replay_parity", + "archive_rebuild_parity", + "lap_analysis", + "stint_analysis", + "tire_degradation_analysis", + "fuel_consumption_analysis", + "pit_transition_analysis", + "pit_service_analysis", + "damage_analysis", + "assist_analysis", + "abnormal_event_analysis", +]); + +const ProtocolStintSchema = z + .object({ + id: IdSchema, + role: z.enum(["clean_reference", "varied_eventful"]), + approximate_laps: PositiveIntegerSchema, + intent: z.string().min(1), + }) + .strict(); + +const ProtocolSchema = z + .object({ + stints: z.array(ProtocolStintSchema).length(2), + transition: z + .object({ + intent: z.enum(["normal_pit_service", "return_to_garage_reset"]), + tires_expected: z.enum(["changed", "unchanged", "not_required"]), + fuel_expected: z.enum(["added", "unchanged", "not_required"]), + }) + .strict(), + finish_intent: z.string().min(1), + }) + .strict(); + +const CompletedLapsSchema = z + .object({ + value: NonNegativeIntegerSchema, + basis: EvidenceKindSchema, + source_counter_start: NonNegativeIntegerSchema, + source_counter_end: NonNegativeIntegerSchema, + }) + .strict(); + +const RaceIqLapRowsSchema = z + .object({ + complete: NonNegativeIntegerSchema, + incomplete: NonNegativeIntegerSchema, + matched_source_completions: NonNegativeIntegerSchema, + }) + .strict(); + +const ObservedStintSchema = z + .object({ + id: IdSchema, + assessment: z.enum([ + "generally_clean", + "mixed", + "intentionally_varied", + "incomplete", + ]), + source_completed_lap_range: z + .tuple([PositiveIntegerSchema, PositiveIntegerSchema]) + .nullable(), + notes: z.array(z.string().min(1)), + }) + .strict(); + +const ObservedTransitionSchema = z + .object({ + kind: z.enum([ + "normal_pit_service", + "pit_entry_then_return_to_garage", + "none", + ]), + source_current_lap: PositiveIntegerSchema.nullable(), + tires_changed: z.enum(["yes", "no", "unknown"]), + fuel_added: z.enum(["yes", "no", "unknown"]), + service_observed: z.boolean(), + notes: z.array(z.string().min(1)), + }) + .strict(); + +const LapAlignmentSchema = z + .object({ + source_completed_lap: PositiveIntegerSchema, + source_lap_time_seconds: z.number().positive(), + raceiq_lap_row: PositiveIntegerSchema.nullable(), + coverage: z.enum(["full", "partial", "missing"]), + note: z.string().min(1).optional(), + }) + .strict(); + +const UnmatchedLapRowSchema = z + .object({ + raceiq_lap_row: PositiveIntegerSchema, + phase: z.enum(["flying", "out", "in", "pit", "grid_start", "unknown"]), + lap_time_seconds: z.number().positive(), + interpretation: z.string().min(1), + }) + .strict(); + +const LapReferenceSchema = z + .object({ + driver_reported_lap: PositiveIntegerSchema.optional(), + source_current_lap: PositiveIntegerSchema.optional(), + source_completed_lap: PositiveIntegerSchema.optional(), + raceiq_lap_row: PositiveIntegerSchema.optional(), + note: z.string().min(1).optional(), + }) + .strict() + .refine( + (value) => + value.driver_reported_lap !== undefined || + value.source_current_lap !== undefined || + value.source_completed_lap !== undefined || + value.raceiq_lap_row !== undefined, + "At least one lap reference is required", + ); + +const EventEvidenceSchema = z + .object({ + kind: EvidenceKindSchema, + detail: z.string().min(1), + }) + .strict(); + +const ObservedEventSchema = z + .object({ + id: IdSchema, + type: z.enum([ + "spin", + "off_track", + "damage", + "pit_entry", + "return_to_garage", + "assist_change", + "wheel_lock", + "wheelspin", + "session_end", + ]), + stint_id: IdSchema.nullable(), + approximate_corner: z.string().min(1).nullable(), + lap_reference: LapReferenceSchema.nullable(), + evidence: z.array(EventEvidenceSchema).min(1), + }) + .strict(); + +const KnownIssueSchema = z + .object({ + type: z.enum([ + "telemetry_gap", + "missing_lap_telemetry", + "partial_lap_telemetry", + "protocol_deviation", + "source_limitation", + ]), + severity: z.enum(["info", "warning", "degraded"]), + evidence_kind: EvidenceKindSchema, + description: z.string().min(1), + duration_ms: PositiveIntegerSchema.optional(), + missing_records: PositiveIntegerSchema.optional(), + missing_fraction: z.number().min(0).max(1).optional(), + affected_source_laps: z.array(PositiveIntegerSchema).optional(), + }) + .strict(); + +const CapabilityLimitationSchema = z + .object({ + capability: IdSchema, + state: z.enum(["unavailable", "unpopulated", "inferred", "limited"]), + description: z.string().min(1), + }) + .strict(); + +const ValidationRoleSchema = z + .object({ + role: z.enum([ + "clean_baseline", + "parser_regression", + "live_replay_parity", + "archive_rebuild_parity", + "lap_analysis", + "stint_analysis", + "tire_degradation_analysis", + "fuel_consumption_analysis", + "pit_transition", + "pit_service", + "damage_analysis", + "assist_analysis", + "abnormal_event_analysis", + "opponent_analysis", + "caution_analysis", + ]), + enabled: z.boolean(), + scope: z.string().min(1).optional(), + limitation: z.string().min(1).optional(), + }) + .strict(); + +const ProvenanceSchema = z + .object({ + parser_version: z.string().min(1), + lap_detector_version: z.string().min(1), + catalog_version: z.string().min(1), + catalog_hash: Sha256Schema, + catalog_schema_version: z.string().min(1), + resolver_version: z.string().min(1), + derivation_version: z.string().min(1), + quality_schema_version: z.string().min(1), + quality_policy_version: z.string().min(1), + quality_config_version: z.string().min(1), + }) + .strict(); + +const AcceptanceSchema = z + .object({ + accepted_at: IsoTimestampSchema, + accepted_by: z.string().min(1), + immutable_source: z.literal(true), + basis: z.array(z.string().min(1)).min(1), + }) + .strict(); + +export const GoldenRecordingManifestSchema = z + .object({ + manifest_schema_version: z.literal(GOLDEN_MANIFEST_SCHEMA_VERSION), + id: IdSchema, + recording_version: PositiveIntegerSchema, + status: z.enum(["candidate", "reviewed", "accepted", "superseded", "rejected"]), + artifact: ArtifactSchema, + simulator: SimulatorSchema, + vehicle: VehicleSchema, + circuit: CircuitSchema, + purpose: z.array(PurposeSchema).min(1), + conditions: ConditionsSchema, + protocol: ProtocolSchema, + observations: z + .object({ + actual_completed_laps: CompletedLapsSchema, + raceiq_lap_rows: RaceIqLapRowsSchema, + stints: z.array(ObservedStintSchema).length(2), + transition: ObservedTransitionSchema, + lap_alignment: z.array(LapAlignmentSchema).min(1), + unmatched_raceiq_rows: z.array(UnmatchedLapRowSchema), + events: z.array(ObservedEventSchema), + known_recording_issues: z.array(KnownIssueSchema), + recording_quality: z.enum(["clean", "accepted_with_known_limitations", "degraded"]), + }) + .strict(), + capability_limitations: z.array(CapabilityLimitationSchema), + validation_roles: z.array(ValidationRoleSchema).min(1), + provenance: ProvenanceSchema, + acceptance: AcceptanceSchema.nullable(), + notes: z.array(z.string().min(1)), + }) + .strict() + .superRefine((manifest, context) => { + if (!manifest.id.endsWith(`-v${manifest.recording_version}`)) { + context.addIssue({ + code: "custom", + path: ["id"], + message: "ID version suffix must match recording_version", + }); + } + + if (manifest.status === "accepted" && manifest.acceptance === null) { + context.addIssue({ + code: "custom", + path: ["acceptance"], + message: "Accepted recordings require acceptance metadata", + }); + } + + const protocolStintIds = manifest.protocol.stints.map((stint) => stint.id); + if (new Set(protocolStintIds).size !== protocolStintIds.length) { + context.addIssue({ + code: "custom", + path: ["protocol", "stints"], + message: "Protocol stint IDs must be unique", + }); + } + + const roles = new Set(manifest.protocol.stints.map((stint) => stint.role)); + if (!roles.has("clean_reference") || !roles.has("varied_eventful")) { + context.addIssue({ + code: "custom", + path: ["protocol", "stints"], + message: "Protocol requires clean_reference and varied_eventful stints", + }); + } + + const observedStintIds = manifest.observations.stints.map((stint) => stint.id); + if ( + observedStintIds.length !== protocolStintIds.length || + observedStintIds.some((id) => !protocolStintIds.includes(id)) + ) { + context.addIssue({ + code: "custom", + path: ["observations", "stints"], + message: "Observed stints must match protocol stint IDs", + }); + } + + for (const [index, event] of manifest.observations.events.entries()) { + if (event.stint_id !== null && !protocolStintIds.includes(event.stint_id)) { + context.addIssue({ + code: "custom", + path: ["observations", "events", index, "stint_id"], + message: "Event references an unknown stint", + }); + } + } + + const alignmentLaps = manifest.observations.lap_alignment.map( + (alignment) => alignment.source_completed_lap, + ); + if (new Set(alignmentLaps).size !== alignmentLaps.length) { + context.addIssue({ + code: "custom", + path: ["observations", "lap_alignment"], + message: "Source completed laps must be unique", + }); + } + + const validationRoles = manifest.validation_roles.map((role) => role.role); + if (new Set(validationRoles).size !== validationRoles.length) { + context.addIssue({ + code: "custom", + path: ["validation_roles"], + message: "Validation roles must be unique", + }); + } + }); + +export type GoldenRecordingManifest = z.infer< + typeof GoldenRecordingManifestSchema +>; + +export interface GoldenArtifactVerification { + artifactPath: string; + artifactBytes: number; + artifactSha256: string; + uncompressedBytes: number; + uncompressedSha256: string; +} + +function formatSchemaError(error: z.ZodError): string { + return error.issues + .map((issue) => `${issue.path.join(".") || "manifest"}: ${issue.message}`) + .join("\n"); +} + +export function parseGoldenRecordingManifest( + input: unknown, +): GoldenRecordingManifest { + const result = GoldenRecordingManifestSchema.safeParse(input); + if (!result.success) { + throw new Error(formatSchemaError(result.error)); + } + return result.data; +} + +export function readGoldenRecordingManifest( + manifestPath: string, +): GoldenRecordingManifest { + let input: unknown; + try { + input = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${manifestPath}: invalid JSON: ${message}`); + } + try { + return parseGoldenRecordingManifest(input); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${manifestPath}: ${message}`); + } +} + +async function sha256File(path: string): Promise<{ bytes: number; sha256: string }> { + const hash = createHash("sha256"); + let bytes = 0; + for await (const chunk of createReadStream(path)) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + hash.update(buffer); + bytes += buffer.length; + } + return { bytes, sha256: `sha256:${hash.digest("hex")}` }; +} + +async function sha256GzipPayload( + path: string, +): Promise<{ bytes: number; sha256: string }> { + const hash = createHash("sha256"); + let bytes = 0; + const sink = new Writable({ + write(chunk, _encoding, callback) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + hash.update(buffer); + bytes += buffer.length; + callback(); + }, + }); + await pipeline(createReadStream(path), createGunzip(), sink); + return { bytes, sha256: `sha256:${hash.digest("hex")}` }; +} + +export async function verifyGoldenRecordingArtifact( + manifest: GoldenRecordingManifest, + rootDir: string, +): Promise { + const artifactPath = resolve(rootDir, manifest.artifact.path); + const rootRelativePath = relative(resolve(rootDir), artifactPath); + if ( + rootRelativePath === "" || + rootRelativePath.startsWith("..") || + rootRelativePath.startsWith("/") || + rootRelativePath.startsWith("\\") + ) { + throw new Error(`${manifest.id}: artifact path must remain inside repository root`); + } + if (!existsSync(artifactPath)) { + throw new Error(`${manifest.id}: artifact does not exist: ${manifest.artifact.path}`); + } + + const artifact = await sha256File(artifactPath); + if (artifact.bytes !== manifest.artifact.byte_length) { + throw new Error( + `${manifest.id}: artifact byte length mismatch: expected ${manifest.artifact.byte_length}, got ${artifact.bytes}`, + ); + } + if (artifact.sha256 !== manifest.artifact.sha256) { + throw new Error( + `${manifest.id}: artifact SHA-256 mismatch: expected ${manifest.artifact.sha256}, got ${artifact.sha256}`, + ); + } + + const source = await sha256GzipPayload(artifactPath); + if (source.bytes !== manifest.artifact.uncompressed_byte_length) { + throw new Error( + `${manifest.id}: uncompressed byte length mismatch: expected ${manifest.artifact.uncompressed_byte_length}, got ${source.bytes}`, + ); + } + if (source.sha256 !== manifest.artifact.uncompressed_sha256) { + throw new Error( + `${manifest.id}: uncompressed SHA-256 mismatch: expected ${manifest.artifact.uncompressed_sha256}, got ${source.sha256}`, + ); + } + + return { + artifactPath, + artifactBytes: artifact.bytes, + artifactSha256: artifact.sha256, + uncompressedBytes: source.bytes, + uncompressedSha256: source.sha256, + }; +} + +export async function validateGoldenRecordingDirectory( + manifestDir: string, + rootDir: string, +): Promise> { + const manifestPaths = readdirSync(manifestDir) + .filter((filename) => filename.endsWith(".golden.json")) + .sort() + .map((filename) => resolve(manifestDir, filename)); + if (manifestPaths.length === 0) { + throw new Error(`No golden recording manifests found in ${manifestDir}`); + } + + const manifests = manifestPaths.map(readGoldenRecordingManifest); + const ids = new Set(); + for (const manifest of manifests) { + if (ids.has(manifest.id)) { + throw new Error(`Duplicate golden recording ID: ${manifest.id}`); + } + ids.add(manifest.id); + } + + const validated = []; + for (const manifest of manifests) { + validated.push({ + manifest, + verification: await verifyGoldenRecordingArtifact(manifest, rootDir), + }); + } + return validated; +} diff --git a/scripts/telemetry/recordings/validate-golden-recordings.ts b/scripts/telemetry/recordings/validate-golden-recordings.ts new file mode 100644 index 000000000..12aa9c89b --- /dev/null +++ b/scripts/telemetry/recordings/validate-golden-recordings.ts @@ -0,0 +1,18 @@ +import { resolve } from "node:path"; +import { validateGoldenRecordingDirectory } from "./golden-manifest"; + +const rootDir = resolve(import.meta.dir, "../../.."); +const manifestDir = resolve(rootDir, "test", "golden-recordings"); + +try { + const validated = await validateGoldenRecordingDirectory(manifestDir, rootDir); + for (const { manifest, verification } of validated) { + console.log( + `[ok] ${manifest.id}: ${verification.artifactBytes} compressed bytes, ${verification.uncompressedBytes} source bytes`, + ); + } +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[error] ${message}`); + process.exitCode = 1; +} diff --git a/server/games/kunos/recorder.ts b/server/games/kunos/recorder.ts index 3916bf83e..f87b51358 100644 --- a/server/games/kunos/recorder.ts +++ b/server/games/kunos/recorder.ts @@ -21,6 +21,7 @@ * so V2 bins parse fine — tail fields just return null on V2 buffers. */ import { existsSync, mkdirSync } from "node:fs"; +import { open } from "node:fs/promises"; import { resolve } from "node:path"; // Header: magic(8) + version(4) + frameCount(4) = 16 bytes (same layout v2 and v3) @@ -51,7 +52,9 @@ export class KunosRecorder { /** Start recording to a new file. Returns the file path. */ start(dir?: string, prefix = "acc"): string { - if (this._file) this.stop(); + if (this._file) { + throw new Error("ACC recorder is already recording"); + } const outDir = dir ?? defaultRecordingDir(); if (!existsSync(outDir)) { @@ -104,25 +107,35 @@ export class KunosRecorder { /** Stop recording and flush to disk */ async stop(): Promise { - if (!this._file) return; - - await this._file.end(); + const sink = this._file; + const path = this._path; + const frameCount = this._frameCount; + if (!sink || !path) return; - // Update frameCount in header and get final file size - if (this._path) { - const file = Bun.file(this._path); - const data = await file.arrayBuffer(); - const buf = Buffer.from(data); - buf.writeUInt32LE(this._frameCount, 12); - await Bun.write(this._path, buf); + // Detach before awaiting I/O so a restarted telemetry source can safely + // begin a new recording while this file finishes flushing. + this._file = null; + this._lastStatic = null; - const fileSizeKb = (buf.length / 1024).toFixed(2); - const filename = this._path.split(/[\\/]/).pop(); - console.log(`[ACC Recorder] Stopped. ${this._frameCount} frames (${fileSizeKb}KB) written to ${filename}`); + await sink.end(); + + // Patch only the frame-count field. Reading and rewriting the full capture + // made long-session shutdown slow and allowed source restarts to race with + // finalization through the recorder's mutable path/file state. + const frameCountBuffer = Buffer.allocUnsafe(4); + frameCountBuffer.writeUInt32LE(frameCount, 0); + const file = await open(path, "r+"); + let fileSize: number; + try { + await file.write(frameCountBuffer, 0, frameCountBuffer.length, 12); + fileSize = (await file.stat()).size; + } finally { + await file.close(); } - this._file = null; - this._lastStatic = null; + const fileSizeKb = (fileSize / 1024).toFixed(2); + const filename = path.split(/[\\/]/).pop(); + console.log(`[ACC Recorder] Stopped. ${frameCount} frames (${fileSizeKb}KB) written to ${filename}`); } private _writeBufferFrame(type: number, buffer: Buffer): void { diff --git a/test/artifacts/sessions/acc-gt3-spa-v1.bin.gz b/test/artifacts/sessions/acc-gt3-spa-v1.bin.gz new file mode 100644 index 000000000..e94346c7f Binary files /dev/null and b/test/artifacts/sessions/acc-gt3-spa-v1.bin.gz differ diff --git a/test/games/acc/acc-recorder.test.ts b/test/games/acc/acc-recorder.test.ts index b5255d4b4..345a50af0 100644 --- a/test/games/acc/acc-recorder.test.ts +++ b/test/games/acc/acc-recorder.test.ts @@ -119,4 +119,47 @@ describe("readKunosFrames", () => { rmSync(dir, { recursive: true }); } }); + test("finalizes a stopped file without clobbering a restarted recorder", async () => { + const dir = mkdtempSync(join(os.tmpdir(), "acc-test-")); + try { + const recorder = new KunosRecorder(); + const firstPath = recorder.start(join(dir, "first")); + const firstPhysics = Buffer.alloc(PHYSICS.SIZE, 0x11); + const firstGraphics = Buffer.alloc(GRAPHICS.SIZE, 0x12); + const firstStatic = Buffer.alloc(STATIC.SIZE, 0x13); + recorder.writePhysics(firstPhysics); + recorder.writeGraphics(firstGraphics); + recorder.writeStatic(firstStatic); + + const firstStop = recorder.stop(); + const secondPath = recorder.start(join(dir, "second")); + const secondPhysics = Buffer.alloc(PHYSICS.SIZE, 0x21); + const secondGraphics = Buffer.alloc(GRAPHICS.SIZE, 0x22); + const secondStatic = Buffer.alloc(STATIC.SIZE, 0x23); + recorder.writePhysics(secondPhysics); + recorder.writeGraphics(secondGraphics); + recorder.writeStatic(secondStatic); + + await firstStop; + expect(recorder.recording).toBe(true); + expect(recorder.path).toBe(secondPath); + expect(recorder.frameCount).toBe(3); + await recorder.stop(); + + const firstFrames = readKunosFrames(firstPath); + expect(firstFrames).toHaveLength(1); + expect(firstFrames[0].physics).toEqual(firstPhysics); + expect(firstFrames[0].graphics).toEqual(firstGraphics); + expect(firstFrames[0].staticData).toEqual(firstStatic); + + const secondFrames = readKunosFrames(secondPath); + expect(secondFrames).toHaveLength(1); + expect(secondFrames[0].physics).toEqual(secondPhysics); + expect(secondFrames[0].graphics).toEqual(secondGraphics); + expect(secondFrames[0].staticData).toEqual(secondStatic); + } finally { + rmSync(dir, { recursive: true }); + } + }); + }); diff --git a/test/golden-recordings/acc-gt3-spa-v1.golden.json b/test/golden-recordings/acc-gt3-spa-v1.golden.json new file mode 100644 index 000000000..210404e73 --- /dev/null +++ b/test/golden-recordings/acc-gt3-spa-v1.golden.json @@ -0,0 +1,609 @@ +{ + "manifest_schema_version": 1, + "id": "acc-gt3-spa-v1", + "recording_version": 1, + "status": "accepted", + "artifact": { + "path": "test/artifacts/sessions/acc-gt3-spa-v1.bin.gz", + "format": "raceiq-session-capture", + "format_version": 1, + "compression": "gzip", + "byte_length": 85468118, + "sha256": "sha256:38dad481216366de6a4bc539f70f85fbae1a3a1e7dfff8a9a565c4bbd4ac46f5", + "uncompressed_byte_length": 610631308, + "uncompressed_sha256": "sha256:8a21a7a33943db13b5dadf7a5a28c8e786968ad5bef95b86e8156df4e7ad49b4", + "record_count": 196724, + "started_at": "2026-08-11T17:11:00.610Z", + "ended_at": "2026-08-11T17:49:27.566Z", + "duration_ms": 2306956 + }, + "simulator": { + "game_id": "acc", + "name": "Assetto Corsa Competizione", + "version": "1.7", + "shared_memory_version": "1.9", + "source_adapter": "acc-shared-memory@1.9" + }, + "vehicle": { + "car": "Porsche 911 II GT3 R 2019", + "source_model": "porsche_991ii_gt3_r", + "class": "GT3", + "catalog_ordinal": 23 + }, + "circuit": { + "track": "Circuit de Spa-Francorchamps", + "layout": "Grand Prix", + "source_name": "Spa", + "catalog_ordinal": 6 + }, + "purpose": [ + "canonical_telemetry_reference", + "parser_regression", + "live_replay_parity", + "archive_rebuild_parity", + "lap_analysis", + "stint_analysis", + "pit_transition_analysis", + "damage_analysis", + "assist_analysis", + "fuel_consumption_analysis", + "abnormal_event_analysis" + ], + "conditions": { + "session_type": "practice", + "weather": "dry", + "track_condition": "dry", + "time_of_day": "day", + "traffic": "none", + "participant_count": 1, + "evidence": [ + "ACC graphics session value remained practice.", + "Static shared memory reported one car.", + "Graphics reported dry compound and rainTyres=0 throughout.", + "ACC session clock covered approximately 09:00 through 09:38.", + "ACC physics air temperature rose from 27.039 C to 27.863 C and road temperature rose from 27.637 C to 30.098 C." + ] + }, + "protocol": { + "stints": [ + { + "id": "stint-1", + "role": "clean_reference", + "approximate_laps": 9, + "intent": "Drive consistently to establish baseline pace, then document genuine late-stint mistakes rather than restarting the capture." + }, + { + "id": "stint-2", + "role": "varied_eventful", + "approximate_laps": 4, + "intent": "Reduce TC and ABS, push harder, deliberately provoke brake lock and wheelspin, and retain resulting spins and recovery behavior." + } + ], + "transition": { + "intent": "normal_pit_service", + "tires_expected": "changed", + "fuel_expected": "added" + }, + "finish_intent": "End after intentionally running without TC produced a final partial lap with multiple spins." + }, + "observations": { + "actual_completed_laps": { + "value": 13, + "basis": "source_observed", + "source_counter_start": 0, + "source_counter_end": 13 + }, + "raceiq_lap_rows": { + "complete": 14, + "incomplete": 1, + "matched_source_completions": 12 + }, + "stints": [ + { + "id": "stint-1", + "assessment": "generally_clean", + "source_completed_lap_range": [1, 9], + "notes": [ + "Early laps provide the primary clean baseline.", + "Driver reported a late-stint spin followed by nose damage.", + "Source current lap 9 became invalid near the reported Eau Rouge and Kemmel event." + ] + }, + { + "id": "stint-2", + "assessment": "intentionally_varied", + "source_completed_lap_range": [11, 13], + "notes": [ + "TC and ABS were reduced after return to garage.", + "ABS was disabled during source current lap 13 for deliberate lockups.", + "TC was disabled for final incomplete source current lap 14." + ] + } + ], + "transition": { + "kind": "pit_entry_then_return_to_garage", + "source_current_lap": 10, + "tires_changed": "unknown", + "fuel_added": "yes", + "service_observed": false, + "notes": [ + "Driver entered pit lane after first stint but did not complete normal service.", + "Return to garage reset position and recorded damage before second stint; source fuel rose from approximately 21 to 62.", + "Fuel increase was a garage reset, not evidence of a normal pit service." + ] + }, + "lap_alignment": [ + { + "source_completed_lap": 1, + "source_lap_time_seconds": 149.27, + "raceiq_lap_row": 2, + "coverage": "full" + }, + { + "source_completed_lap": 2, + "source_lap_time_seconds": 148.175, + "raceiq_lap_row": 3, + "coverage": "full" + }, + { + "source_completed_lap": 3, + "source_lap_time_seconds": 147.027, + "raceiq_lap_row": 4, + "coverage": "full" + }, + { + "source_completed_lap": 4, + "source_lap_time_seconds": 147.822, + "raceiq_lap_row": null, + "coverage": "missing", + "note": "ACC counter and last-lap time survived, but telemetry samples for this lap did not." + }, + { + "source_completed_lap": 5, + "source_lap_time_seconds": 147.292, + "raceiq_lap_row": 5, + "coverage": "partial", + "note": "First 18.515 seconds are absent after the capture gap." + }, + { + "source_completed_lap": 6, + "source_lap_time_seconds": 147.482, + "raceiq_lap_row": 6, + "coverage": "full" + }, + { + "source_completed_lap": 7, + "source_lap_time_seconds": 146.192, + "raceiq_lap_row": 7, + "coverage": "full" + }, + { + "source_completed_lap": 8, + "source_lap_time_seconds": 146.382, + "raceiq_lap_row": 8, + "coverage": "full" + }, + { + "source_completed_lap": 9, + "source_lap_time_seconds": 160.177, + "raceiq_lap_row": 9, + "coverage": "full" + }, + { + "source_completed_lap": 10, + "source_lap_time_seconds": 166.225, + "raceiq_lap_row": 10, + "coverage": "full" + }, + { + "source_completed_lap": 11, + "source_lap_time_seconds": 145.397, + "raceiq_lap_row": 12, + "coverage": "full" + }, + { + "source_completed_lap": 12, + "source_lap_time_seconds": 145.287, + "raceiq_lap_row": 13, + "coverage": "full" + }, + { + "source_completed_lap": 13, + "source_lap_time_seconds": 151.102, + "raceiq_lap_row": 14, + "coverage": "full" + } + ], + "unmatched_raceiq_rows": [ + { + "raceiq_lap_row": 1, + "phase": "in", + "lap_time_seconds": 178.631, + "interpretation": "Pit-cycle timer segment with no matching ACC completedLaps transition." + }, + { + "raceiq_lap_row": 11, + "phase": "out", + "lap_time_seconds": 193.122, + "interpretation": "Return-to-garage and out-lap timer segment with no matching ACC completedLaps transition." + } + ], + "events": [ + { + "id": "stint-1-spin", + "type": "spin", + "stint_id": "stint-1", + "approximate_corner": "Les Combes", + "lap_reference": { + "driver_reported_lap": 8, + "source_current_lap": 9, + "raceiq_lap_row": 9, + "note": "Driver and source numbering differ because RaceIQ retained pit-cycle rows and missed source lap 4." + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver reported spinning in the esses after Eau Rouge and Kemmel Straight." + }, + { + "kind": "source_observed", + "detail": "Damage and lap-validity state changed during source current lap 9 near the reported location." + } + ] + }, + { + "id": "stint-1-damage", + "type": "damage", + "stint_id": "stint-1", + "approximate_corner": "Les Combes", + "lap_reference": { + "driver_reported_lap": 8, + "source_current_lap": 9, + "raceiq_lap_row": 9 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver reported light nose damage after the spin." + }, + { + "kind": "source_observed", + "detail": "ACC carDamage rose from zero to front=24.502, rear=5.597, centre=30.099 and reset after return to garage." + } + ] + }, + { + "id": "stint-1-eau-rouge-cut", + "type": "off_track", + "stint_id": "stint-1", + "approximate_corner": "Eau Rouge and Raidillon", + "lap_reference": { + "driver_reported_lap": 9, + "source_current_lap": 9, + "raceiq_lap_row": 9 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver reported severely cutting Eau Rouge after damage made the car difficult to drive." + }, + { + "kind": "source_observed", + "detail": "ACC isValidLap became false on source current lap 9 at 47.625 seconds and remained invalid for that lap." + } + ] + }, + { + "id": "pit-entry", + "type": "pit_entry", + "stint_id": null, + "approximate_corner": "Pit entry", + "lap_reference": { + "source_current_lap": 10, + "raceiq_lap_row": 10 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver entered pits after the first stint." + }, + { + "kind": "source_observed", + "detail": "ACC isInPitLane changed to 1 during source current lap 10." + } + ] + }, + { + "id": "return-to-garage", + "type": "return_to_garage", + "stint_id": null, + "approximate_corner": null, + "lap_reference": { + "source_current_lap": 11, + "raceiq_lap_row": 11 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver used return to garage instead of completing practice pit service." + }, + { + "kind": "source_observed", + "detail": "ACC status briefly changed from live to pause, distance reset, and damage returned to zero." + } + ] + }, + { + "id": "stint-2-assists-reduced", + "type": "assist_change", + "stint_id": "stint-2", + "approximate_corner": null, + "lap_reference": { + "source_current_lap": 11, + "raceiq_lap_row": 12 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver reduced TC and ABS, then pushed harder with a faster but more mobile car." + }, + { + "kind": "source_observed", + "detail": "ACC graphics settings changed from TC 5 and ABS 6 to TC 2 and ABS 2 after return to garage." + } + ] + }, + { + "id": "stint-2-abs-off-lockups", + "type": "wheel_lock", + "stint_id": "stint-2", + "approximate_corner": null, + "lap_reference": { + "source_current_lap": 13, + "source_completed_lap": 13, + "raceiq_lap_row": 14 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver disabled ABS and deliberately locked brakes in several places." + }, + { + "kind": "source_observed", + "detail": "ACC ABS setting reached 0 during source current lap 13." + } + ] + }, + { + "id": "final-lap-tc-off", + "type": "assist_change", + "stint_id": "stint-2", + "approximate_corner": null, + "lap_reference": { + "source_current_lap": 14, + "raceiq_lap_row": 15 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver disabled TC for the final incomplete lap." + }, + { + "kind": "source_observed", + "detail": "ACC TC setting changed to 0 on source current lap 14." + } + ] + }, + { + "id": "final-lap-turn-1-spin", + "type": "spin", + "stint_id": "stint-2", + "approximate_corner": "La Source", + "lap_reference": { + "source_current_lap": 14, + "raceiq_lap_row": 15 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver spun on throttle after Turn 1 and reversed to recover." + } + ] + }, + { + "id": "final-lap-les-combes-spin", + "type": "spin", + "stint_id": "stint-2", + "approximate_corner": "Les Combes", + "lap_reference": { + "source_current_lap": 14, + "raceiq_lap_row": 15 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver spun again, more severely, at the earlier first-stint spin location." + } + ] + }, + { + "id": "session-ended-after-spin", + "type": "session_end", + "stint_id": "stint-2", + "approximate_corner": "Les Combes", + "lap_reference": { + "source_current_lap": 14, + "raceiq_lap_row": 15 + }, + "evidence": [ + { + "kind": "driver_reported", + "detail": "Driver ended the session after the second final-lap spin." + }, + { + "kind": "source_observed", + "detail": "RaceIQ retained the final 5.750-second segment as incomplete." + } + ] + } + ], + "known_recording_issues": [ + { + "type": "telemetry_gap", + "severity": "degraded", + "evidence_kind": "source_observed", + "description": "One event-loop capture gap occurred between source completed laps 3 and 4.", + "duration_ms": 165713, + "missing_records": 33688, + "missing_fraction": 0.14620766279534053, + "affected_source_laps": [4, 5] + }, + { + "type": "missing_lap_telemetry", + "severity": "degraded", + "evidence_kind": "machine_derived", + "description": "Source completed lap 4 has ACC counter and 147.822-second last-lap evidence but no retained lap telemetry row.", + "affected_source_laps": [4] + }, + { + "type": "partial_lap_telemetry", + "severity": "warning", + "evidence_kind": "machine_derived", + "description": "Source completed lap 5 resumes 18.515 seconds into the lap after the capture gap.", + "affected_source_laps": [5] + }, + { + "type": "protocol_deviation", + "severity": "warning", + "evidence_kind": "driver_reported", + "description": "Capture contains 13 completed laps rather than the 20-25 target and uses return to garage instead of a normal serviced pit stop." + } + ], + "recording_quality": "accepted_with_known_limitations" + }, + "capability_limitations": [ + { + "capability": "tire-wear", + "state": "unpopulated", + "description": "ACC tire-wear values remained zero, so degradation by reported wear cannot be validated." + }, + { + "capability": "pit-service", + "state": "limited", + "description": "Return to garage reset car state; no normal tire or fuel service was observed." + }, + { + "capability": "invalidity-cause", + "state": "limited", + "description": "ACC exposes current lap validity but not a structured invalidity reason." + } + ], + "validation_roles": [ + { + "role": "clean_baseline", + "enabled": true, + "scope": "Source completed laps 1-3 and 6-7.", + "limitation": "Exclude gap-affected laps 4-5 and late-stint abnormal laps 8-9." + }, + { + "role": "parser_regression", + "enabled": true + }, + { + "role": "live_replay_parity", + "enabled": true + }, + { + "role": "archive_rebuild_parity", + "enabled": true, + "limitation": "Expected gap and partial-lap evidence must remain explicit after rebuild." + }, + { + "role": "lap_analysis", + "enabled": true, + "limitation": "Source lap 4 is missing and source lap 5 has partial telemetry." + }, + { + "role": "stint_analysis", + "enabled": true, + "scope": "Baseline first run versus post-garage varied second run.", + "limitation": "Boundary is return to garage, not normal pit service." + }, + { + "role": "tire_degradation_analysis", + "enabled": false, + "limitation": "Tire-wear channel is unpopulated and second stint is short." + }, + { + "role": "fuel_consumption_analysis", + "enabled": true, + "scope": "ACC fuel remaining and fuel-per-lap evidence across uninterrupted full-coverage lap segments in both stints.", + "limitation": "Exclude gap-affected, partial, and return-to-garage reset transitions." + }, + { + "role": "pit_transition", + "enabled": true, + "scope": "Pit entry, pit lane, return-to-garage reset, and second departure." + }, + { + "role": "pit_service", + "enabled": false, + "limitation": "No normal service completed." + }, + { + "role": "damage_analysis", + "enabled": true, + "scope": "Damage onset during source current lap 9 and reset at return to garage." + }, + { + "role": "assist_analysis", + "enabled": true, + "scope": "TC and ABS reductions, ABS-off lap, and final TC-off partial lap." + }, + { + "role": "abnormal_event_analysis", + "enabled": true, + "scope": "Driver-reported spins, off-track, lockups, wheelspin, damage, and recoveries." + }, + { + "role": "opponent_analysis", + "enabled": false, + "limitation": "Single participant and no traffic." + }, + { + "role": "caution_analysis", + "enabled": false, + "limitation": "Practice capture contains no caution scenario." + } + ], + "provenance": { + "parser_version": "acc-shared-memory@1.9", + "lap_detector_version": "acc_lapdetector_v3", + "catalog_version": "0.13.0", + "catalog_hash": "sha256:569f71649acc717ac85c6dec9055c558ad5be532e6e82cd2e877c23694fd556c", + "catalog_schema_version": "v6", + "resolver_version": "1.0.0", + "derivation_version": "1.0.0", + "quality_schema_version": "1", + "quality_policy_version": "1", + "quality_config_version": "1" + }, + "acceptance": { + "accepted_at": "2026-08-11T18:00:00.000Z", + "accepted_by": "recording-owner", + "immutable_source": true, + "basis": [ + "Recording owner supplied stint intent and post-session event observations.", + "ACC source counters, validity, assists, pit state, damage, fuel, environmental conditions, and hashes were independently derived from retained source evidence.", + "Known gap, missing lap, short duration, and absent normal service are accepted as explicit v1 limitations rather than hidden assumptions." + ] + }, + "notes": [ + "Use source completed-lap numbering for deterministic comparisons; RaceIQ lap rows include two unmatched pit-cycle segments.", + "Driver-reported lap numbers are approximate where pit-cycle rows and the missing source lap shift visible numbering.", + "ACC physics fuel declines through both stints and ACC fuel-per-lap remains populated; return to garage resets fuel.", + "ACC air and road temperatures remain populated and rise throughout the recording.", + "ACC tire-wear fields remain exactly zero even while pressure and temperature channels evolve.", + "Accepted identity acc-gt3-spa-v1 must not be silently replaced; create v2 for materially different source evidence." + ] +} diff --git a/test/telemetry/golden-manifest.test.ts b/test/telemetry/golden-manifest.test.ts new file mode 100644 index 000000000..db79fedec --- /dev/null +++ b/test/telemetry/golden-manifest.test.ts @@ -0,0 +1,135 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { gzipSync } from "node:zlib"; +import { describe, expect, test } from "bun:test"; +import { + parseGoldenRecordingManifest, + readGoldenRecordingManifest, + validateGoldenRecordingDirectory, + verifyGoldenRecordingArtifact, +} from "../../scripts/telemetry/recordings/golden-manifest"; + +const ROOT_DIR = resolve(import.meta.dir, "../.."); +const MANIFEST_DIR = resolve(ROOT_DIR, "test", "golden-recordings"); +const ACC_MANIFEST_PATH = resolve( + MANIFEST_DIR, + "acc-gt3-spa-v1.golden.json", +); + +function sha256(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +describe("golden recording manifests", () => { + test("keeps ACC v1 intent, observations, and validation scope explicit", () => { + const manifest = readGoldenRecordingManifest(ACC_MANIFEST_PATH); + + expect(manifest.id).toBe("acc-gt3-spa-v1"); + expect(manifest.status).toBe("accepted"); + expect(manifest.observations.actual_completed_laps.value).toBe(13); + expect(manifest.observations.lap_alignment).toHaveLength(13); + expect( + manifest.observations.lap_alignment.find( + (lap) => lap.source_completed_lap === 4, + ), + ).toMatchObject({ coverage: "missing", raceiq_lap_row: null }); + expect( + manifest.observations.events.find( + (event) => event.id === "stint-1-eau-rouge-cut", + )?.evidence, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "source_observed", + detail: expect.stringContaining("isValidLap became false"), + }), + ]), + ); + expect( + manifest.validation_roles.find( + (role) => role.role === "pit_service", + )?.enabled, + ).toBe(false); + expect( + manifest.validation_roles.find( + (role) => role.role === "abnormal_event_analysis", + )?.enabled, + ).toBe(true); + expect( + manifest.validation_roles.find( + (role) => role.role === "fuel_consumption_analysis", + )?.enabled, + ).toBe(true); + expect( + manifest.capability_limitations.map(({ capability }) => capability), + ).not.toEqual( + expect.arrayContaining(["fuel-remaining", "air-and-track-temperature"]), + ); + expect( + manifest.capability_limitations.find( + ({ capability }) => capability === "tire-wear", + ), + ).toMatchObject({ state: "unpopulated" }); + }); + + test( + "verifies registered compressed and source identities", + async () => { + const validated = await validateGoldenRecordingDirectory( + MANIFEST_DIR, + ROOT_DIR, + ); + + expect(validated).toHaveLength(1); + expect(validated[0].manifest.id).toBe("acc-gt3-spa-v1"); + expect(validated[0].verification.artifactBytes).toBe(85_468_118); + expect(validated[0].verification.uncompressedBytes).toBe(610_631_308); + }, + 120_000, + ); + + test("rejects unknown fields and mismatched identity versions", () => { + const manifest = JSON.parse( + JSON.stringify(readGoldenRecordingManifest(ACC_MANIFEST_PATH)), + ); + + expect(() => + parseGoldenRecordingManifest({ ...manifest, unexpected: true }), + ).toThrow("Unrecognized key"); + expect(() => + parseGoldenRecordingManifest({ + ...manifest, + id: "acc-gt3-spa-v2", + }), + ).toThrow("ID version suffix must match recording_version"); + }); + + test("rejects an artifact whose uncompressed source hash changed", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "golden-recording-")); + try { + const source = Buffer.from("deterministic golden source"); + const compressed = gzipSync(source); + writeFileSync(join(tempRoot, "fixture.bin.gz"), compressed); + + const manifest = structuredClone( + readGoldenRecordingManifest(ACC_MANIFEST_PATH), + ); + manifest.artifact = { + ...manifest.artifact, + path: "fixture.bin.gz", + byte_length: compressed.length, + sha256: sha256(compressed), + uncompressed_byte_length: source.length, + uncompressed_sha256: `sha256:${"0".repeat(64)}`, + }; + + await expect( + verifyGoldenRecordingArtifact(manifest, tempRoot), + ).rejects.toThrow("uncompressed SHA-256 mismatch"); + } finally { + rmSync(tempRoot, { recursive: true }); + } + }); +});