Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ client/storybook-static/
.superset/

playwright/test-data/

# Dev dump recordings — track gzipped fixtures only
test/artifacts/laps/*.bin
94 changes: 94 additions & 0 deletions docs/dev-recordings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Recording and importing telemetry dumps

Telemetry dumps are raw packet captures saved to `test/artifacts/laps/`.
They are useful for reproducing parser bugs, building test fixtures, and
replaying a session through the pipeline without the game running.

## Capture a session

Pick the `dev:dump:*` script for the game you're running. Each launches
the dev server in recording mode and opens the dashboard:

| Game | Script | Recording mechanism |
| --- | --- | --- |
| Forza Motorsport (2023) | `bun run dev:dump:fm` | UDP — raw datagrams |
| F1 2025 | `bun run dev:dump:f1` | UDP — raw datagrams |
| Assetto Corsa Competizione | `bun run dev:dump:acc` | Shared memory (Windows only) |
| Assetto Corsa Evo | `bun run dev:dump:ac-evo` | Shared memory (Windows only) |

Drive your session. The server appends packets live. When you're done,
hit `Ctrl+C` — the signal handler flushes the recorder before exiting,
so the file ends on a clean packet boundary. Recording files are
timestamped:

```
test/artifacts/laps/fm-2023-2026-04-18T17-32-09-418Z.bin
test/artifacts/laps/f1-2025-2026-04-18T17-45-12-902Z.bin
test/artifacts/laps/acc-2026-04-18T17-51-03-776Z.bin
test/artifacts/laps/ac-evo-2026-04-18T17-59-44-112Z.bin
```

The filename prefix encodes the `gameId` — don't rename it, or the
importer can't auto-detect which parser to use.

## Import a dump

Importing feeds the file through the full pipeline — parser, lap
detector, DB writer — so any detected laps land in
`data/forza-telemetry.db` as if you had played the session live.

Both raw `.bin` and gzipped `.bin.gz` are accepted — the server detects
gzip magic bytes and decompresses on the fly. No need to gunzip first.

1. Run the dev server: `bun run dev`
2. Open http://raceiq.localhost:1355/dev
3. Drag a `.bin` or `.bin.gz` onto the **Import Dump** panel
4. The panel reports detected `gameId`, parsed packet count, detected
car/track, and how many laps were written

The `/dev` route is only mounted when `IS_DEV` is true — not available
in production builds.

## Committing a recording as a test fixture

Raw `.bin` dumps are gitignored — they're developer-local by default.
To commit one as a regression fixture, **gzip it first**:

```sh
bun run gzip:recording test/artifacts/laps/fm-2023-2026-04-18T17-28-14-420Z.bin
git add test/artifacts/laps/fm-2023-2026-04-18T17-28-14-420Z.bin.gz
```

The script keeps the raw `.bin` next to the `.bin.gz` so you can still
replay it locally without decompressing. Run it once per recording
you want to commit — recordings tend to be a deliberate choice, so the
script doesn't sweep the whole directory.

Test helpers accept `.bin.gz` directly — they decompress on load, so
fixtures stay compressed in the repo and nothing has to be unpacked
into a temp file first:

```ts
// test/e2e/fm-2023-recording.test.ts
import { parseDump } from "../helpers/parse-dump";

const result = await parseDump(
"fm-2023",
"test/artifacts/laps/fm-2023-2026-04-18T17-28-14-420Z.bin.gz"
);
expect(result.laps).toHaveLength(3);
```

Same for the `/dev` Import Dump panel — drop a `.bin.gz`, the server
gunzips it server-side before replaying.

## Tips

- Recordings are append-only. A clean `Ctrl+C` runs the SIGINT handler
and flushes the buffer, so the file ends on a packet boundary. A
hard kill (e.g. `kill -9`) can still truncate the in-flight record,
but everything written prior remains importable.
- Shared-memory games (ACC, AC Evo) use their own `.bin` triplet
format; UDP games (FM, F1) use the `UdpRecorder` `[uint32 len][N
bytes]` format. The importer picks the reader automatically from the
filename prefix.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"scrape:car-images:ac-evo": "bun scripts/scrape-ac-evo-car-images.ts",
"laps:export": "bun scripts/export-laps.ts",
"laps:import": "bun scripts/import-laps.ts",
"gzip:recording": "bun scripts/gzip-recording.ts",
"build:installer": "bun scripts/build-installer.ts",
"mastra:dev": "DATA_DIR=\"$PWD/data\" npx mastra dev --dir mastra",
"postinstall": "lefthook install",
Expand Down
38 changes: 38 additions & 0 deletions scripts/gzip-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readFileSync, writeFileSync, existsSync, statSync } from "fs";
import { gzipSync } from "zlib";
import { resolve, basename } from "path";

// Gzips a single raw .bin dump next to itself (like `gzip -k` — original
// kept, .bin.gz created). The raw .bin stays gitignored; the .bin.gz is what
// you commit as a test fixture.
//
// Usage:
// bun run gzip:recording test/artifacts/laps/fm-2023-2026-…-.bin

const target = process.argv[2];
if (!target) {
console.error("Usage: bun run gzip:recording <path/to/file.bin>");
process.exit(1);
}

const binPath = resolve(target);
if (!existsSync(binPath)) {
console.error(`[err] ${binPath} not found`);
process.exit(1);
}
if (!binPath.endsWith(".bin")) {
console.error(`[err] expected a .bin file, got ${binPath}`);
process.exit(1);
}

const gzPath = `${binPath}.gz`;
if (existsSync(gzPath)) {
console.error(`[err] ${basename(gzPath)} already exists — delete it first if you mean to re-gzip`);
process.exit(1);
}

const raw = readFileSync(binPath);
writeFileSync(gzPath, gzipSync(raw));
const srcKb = (raw.length / 1024).toFixed(0);
const gzKb = (statSync(gzPath).size / 1024).toFixed(0);
console.log(`[ok] ${basename(binPath)} (${srcKb} KB) -> ${basename(gzPath)} (${gzKb} KB)`);
31 changes: 31 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { wsManager, type WSData } from "./ws";
import { loadSettings } from "./settings";
import { initServerGameAdapters } from "./games/init";
import { initGameAdapters } from "../shared/games/init";
import { accRecorder } from "./games/acc/recorder";
import { acEvoRecorder } from "./games/ac-evo/recorder";

// Register all game adapters (shared + server)
initGameAdapters();
Expand Down Expand Up @@ -160,6 +162,35 @@ Bun.serve<WSData>({

console.log(`[Server] HTTP/WS server listening on http://localhost:${HTTP_PORT}`);

// UDP-based recording for `dev:dump:fm` / `dev:dump:f1`. Shared-memory games
// (acc, ac-evo) record via their own readers further down. Set before start()
// so the listener opens its .bin the moment it begins receiving packets —
// same init-time shape as AccSharedMemoryReader's constructor flag.
if (recordingGameId === "fm-2023" || recordingGameId === "f1-2025") {
udpListener.setRecordingGameId(recordingGameId);
}

// Flush every recorder on Ctrl+C / kill so each .bin has a clean tail. All
// three recorders buffer via Bun.file().writer() — without this handler the
// default SIGINT path exits before the buffer drains and the file ends up
// zero-length (or missing the tail).
if (recordingGameId) {
const gracefulShutdown = async (signal: NodeJS.Signals) => {
console.log(`[Server] Received ${signal} — finalizing recording...`);
try {
await Promise.allSettled([
udpListener.stop(),
accRecorder.stop(),
acEvoRecorder.stop(),
]);
} finally {
process.exit(0);
}
};
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
}

// Start UDP listener — settings.udpPort takes priority, env var is the fallback
const udpPort = settings.udpPort ?? (Number(process.env.UDP_PORT) || 5301);
udpListener.start(udpPort);
Expand Down
2 changes: 1 addition & 1 deletion server/udp-recorder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync } from "fs";
import { resolve, dirname } from "path";
import { dirname } from "path";

/**
* Appends raw UDP packets to a binary dump file.
Expand Down
39 changes: 37 additions & 2 deletions server/udp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
* The parser auto-detects whether incoming packets are Forza Dash (324 bytes)
* or F1 2025 format based on packet structure and header signatures.
*/
import { resolve } from "path";
import { parsePacket } from "./parser";
import { wsManager } from "./ws";
import { processPacket } from "./pipeline";
import { getRunningGame } from "./games/registry";
import { lapDetector } from "./pipeline";
import { UdpRecorder } from "./udp-recorder";
import type { GameId } from "../shared/types";

const MIN_PACKET_LENGTH = 29; // Minimum: F1 header size
const PACKETS_PER_SEC_WINDOW = 1000; // 1-second sliding window for rate display
Expand All @@ -30,6 +33,8 @@ class UdpListener {
private _socket: { stop(): void } | null = null;
private _port = 5301;
private _hostname = "0.0.0.0";
private _recorder: UdpRecorder | null = null;
private _recordingGameId: GameId | null = null;

get droppedPackets(): number {
return this._droppedPackets;
Expand All @@ -55,11 +60,30 @@ class UdpListener {
return this._hostname;
}

/**
* Pin a recording gameId. When set, `start()` opens a timestamped .bin file
* under test/artifacts/laps/ and every incoming datagram is appended to it
* (in addition to the normal parse → pipeline → DB/WS flow). Mirrors how the
* AccSharedMemoryReader/AcEvoSharedMemoryReader constructors create their
* .bin files when `recordingOnly=true`. Used by `dev:dump:fm` / `dev:dump:f1`.
*/
setRecordingGameId(gameId: GameId | null): void {
this._recordingGameId = gameId;
}

async start(port: number = 5301, hostname: string = "0.0.0.0"): Promise<void> {
this._port = port;
this._hostname = hostname;
console.log(`[UDP] Starting listener on ${hostname}:${port}...`);

if (this._recordingGameId && !this._recorder) {
const dir = resolve(process.cwd(), "test", "artifacts", "laps");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filePath = resolve(dir, `${this._recordingGameId}-${timestamp}.bin`);
this._recorder = new UdpRecorder();
this._recorder.start(filePath);
}

// Use dgram for socket buffer tuning — Bun.udpSocket doesn't expose setsockopt
const dgram = require("dgram");
const sock = dgram.createSocket("udp4");
Expand Down Expand Up @@ -124,6 +148,10 @@ class UdpListener {
return;
}

// Append raw datagrams to the dump BEFORE parsing so recordings preserve
// the exact wire format (including any packets parsePacket would skip).
this._recorder?.writePacket(buf);

// Returns null when game is paused/in menus (IsRaceOn == 0)
const packet = parsePacket(buf);
if (!packet) {
Expand All @@ -134,16 +162,23 @@ class UdpListener {
await processPacket(packet);
}

stop(): void {
async stop(): Promise<void> {
if (this._socket) {
this._socket.stop();
this._socket = null;
console.log("[UDP] Listener stopped");
}
if (this._recorder) {
// Await the flush on a clean shutdown. The format is append-only, so
// even a hard kill only risks the last packet being truncated — but
// waiting here means `Ctrl+C` produces a complete file.
await this._recorder.stop();
this._recorder = null;
}
}

async restart(port: number, hostname?: string): Promise<void> {
this.stop();
await this.stop();
this._droppedPackets = 0;
this._totalPackets = 0;
this._receiving = false;
Expand Down
Loading
Loading