.tnrd
Every session Track N Race records is saved as a .tnrd file. TNRD stands for Track N Race Data.
It started as a gzipped text log and is now an indexed container split by driver, lap and data type. This is
the full history: how each version lays out the file, what it fixed, and what it still got wrong.
24 May 2026 · the day after the first commit
A gzipped log
The first format did the obvious thing. The first line of the file is a JSON header: the format
name TNRD_V1, the game protocol, the track, the session type and when recording
started. After that, every packet the parser understood is written as one more line of JSON,
in the order it arrived: telemetry, lap data, car status, damage, motion and so on.
The whole thing goes through a single gzip stream. Every few seconds the recorder flushes it, so if the app crashes mid-session the file is still readable up to the last flush.
Playback is where it hurts. gzip can only be read from the start, so opening a recording means decompressing the entire file into a temporary file on disk, then scanning that to build an index before anything appears on screen.
- Header
- One JSON line
- Rows
- JSON, one per line
- Compression
- gzip, one stream
- Index
- None
- To open
- Decompress everything
13 July 2026
Zstandard instead of gzip
Same idea, better compressor. The rows are unchanged, but the stream is now
Zstandard at level 3 with a checksum on the frame, which compresses smaller and
decompresses much faster than gzip. The header gains a compression: "zstd" field.
It's still one continuous stream, so it still has to be decompressed from the beginning, in full, into a temporary file before playback.
- Header
- One JSON line
- Rows
- JSON, one per line
- Compression
- Zstandard level 3, checksummed
- Index
- None
- To open
- Decompress everything
5 August 2026
Track length, for lap delta
The Analysis page was getting a lap delta chart: how far ahead or behind one lap is against
another at every point on track. Working that out needs the length of the circuit, so V3 adds
track_length_m to the header. The delta chart only switches on for V3 files and
newer.
That's the only change to the format. It's still the same single Zstandard stream as V2, so it still has to be decompressed in full before playback, and a flashback that goes further back than the recorder's in-memory buffer can still force the whole compressed file to be rewritten.
Those costs are what kept V3 from ever shipping. The story is below.
- Header
- One JSON line, + track length
- Rows
- JSON, one per line
- Compression
- Zstandard level 3
- Unlocks
- Lap delta in Analysis
- To open
- Decompress everything
Identical to V2 apart from one new field in the header.
What V1 to V3 got right
- Simple to write and easy to inspect
- Readable after a crash, up to the last flush
What V1 to V3 got wrong
- Every open decompresses the whole session
- Multi-gigabyte temporary files for a long race
- No way to read one lap without reading all of them
11 August 2026
A container, split by lap
V4 throws away the single stream. The rule is simple: everything needed to find data stays uncompressed, and everything large is compressed in small independent pieces.
The data is cut into chunks. Each chunk holds one family of rows for one lap, for example lap 17's telemetry or lap 17's damage, and is its own Zstandard frame, so it can be decompressed without touching anything else. There are 14 families, including telemetry, car status, damage, lap data, motion, positions, participants, race events and tyre sets.
Two uncompressed tables at the end of the file describe it all. The lap table lists every lap with its start time, end time and lap time. The chunk directory gives every chunk's lap, family, position in the file, sizes, row count and checksum. Opening a file is now a handful of small reads and no decompression. Each page then loads only the laps and families it actually draws.
Chunks are written as you drive, tables last, and the fixed header is patched with their positions when recording stops. A lap stays in memory for 30 seconds after it ends in case of a flashback. Every so often a checkpoint footer is appended, and after a crash the reader scans backwards for the last good one. A flashback into data already on disk writes replacement chunks and points the directory at them, instead of rewriting the file.
- Fixed header
- 96 bytes
- Session info
- JSON, uncompressed
- Chunk key
- Lap × row family
- Row families
- 14
- Lap table
- 24 bytes per lap
- Directory
- 48 bytes per chunk
- Compression
- Zstandard level 3, per chunk
- To open
- Read the tables, decompress nothing
23 August 2026
Write down what you already know
V4's directory said where each chunk was, but not when it was. To find which chunk covered a moment in the session, the reader had to decompress chunks and read the timestamp of every row. The recorder knew all of this while writing, and simply threw it away.
V5 keeps it. Each directory entry doubles to 96 bytes and now carries the chunk's first and last session time, its lap distance range, and a pointer into a row index. The row index lists where each row sits inside the decompressed chunk: every row for rarely-changing state such as damage or tyre sets, and one entry per 64 rows for high-rate data such as telemetry. It's written once, when recording finishes.
Flashbacks changed too. Instead of replacement chunks, a flashback now appends a small branch entry: the wall-clock time it happened and the session time it rewound to. Newer branches hide older data from their rewind point onward, so nothing already written is ever decompressed, filtered or recompressed.
Or ... that's what's supposed to happen. Except, I set the threshold too high. And what that caused is, data never got to rewind because the maximum flashback threshold of the game was lower than what was needed to trigger a branched flashback. If it was below the threshold, it did a in memory replacement and didn't need a branch.
Which means ... I wrote and designed the entire system for nothing. The code is there but it has never been tested or triggered.
The result: opening a file decompresses zero chunks, seeking jumps straight to the right one, and finding the latest state at a moment decompresses exactly one.
- Fixed header
- 128 bytes
- Directory
- 96 bytes per chunk, with time and distance bounds
- Row index
- 24 bytes per entry
- Branches
- 16 bytes per flashback
- Payload
- Unchanged JSON chunks
- Compression
- Zstandard level 3, per chunk
- Chunks read on open
- 0
What V4 and V5 got right
- No temporary files, no full decompression
- Pages read only the laps and data they show
- Crash recovery from checkpoint footers
What V4 and V5 got wrong
- Built around your own car: other drivers got only a little data, mostly usable on the Session and Standings pages
- Chunks were still JSON text, repeating every field name on every row
17 to 19 September 2026 · current
Chunks. Chunks Everywhere.
V5 had been public for about a day when it became clear it couldn't handle comparing your laps against other drivers. So V6 starts over with a different question: what does each chart actually need?
Earlier versions kept only a little data for the other cars, enough for the Session and Standings pages. V6 records full telemetry for every car on the grid, and the file is split three ways: by driver, then by lap, then by data type. There are 24 data types, each matching something a chart or card shows: speed, RPM, gear, throttle, brake, steering, aero, tyre surface and inner temperature, brake temperature, engine temperature, tyre wear, tyre state, damage, fuel, ERS store, harvest and deployment, engine power, brake bias, G-force, ride height, position, and lap timing. Alonso's speed on lap 5 is exactly one chunk.
Each driver has a small header with their name, team, number and whether they've set their telemetry to restricted, plus a lap table. Listing drivers, showing restrictions and drawing lap times needs no decompression. Things that belong to the whole session, such as weather, race events and participant changes, are stored once rather than 24 times.
- Fixed header
- 128 bytes
- Chunk key
- Driver × lap × data type
- Data types
- 24
- Chunk prefix
- 32 bytes
- Directory
- 56 bytes per chunk, compressed
- Footer
- 48 bytes
- Payload
- Binary column tables
- Compression
- Zstandard level 9
The driver metadata and directory are compressed too, but they're small and read in one go when the file opens.
Inside one chunk
Chunks stopped being JSON. Each one is a column table: all the timestamps together, then all of one field's values together, and so on. Field names are written once per chunk instead of once per sample.
Every numeric column is stored as the difference from the previous value, or the difference of those differences, whichever is smaller, packed into the narrowest width that fits: 1, 2, 4 or 8 bytes. A speed trace that only changes by a few km/h between samples can often be stored in a single byte per sample. Values the game rounds to a few decimal places, such as G-force, steering and fuel, are stored as whole numbers. The bytes are then regrouped so similar high bytes sit together, which Zstandard compresses very well.
None of this is lossy. Floats are differenced as their raw bits, so every value comes back exactly as it was recorded.
Car telemetry, status, lap data and motion all arrive with the same timestamp, so a lap's timestamps are stored once in a shared lap clock, and the other chunks refer to it.
- Prefix32 B · driver, lap, type, sizes, checksum
- Column headerrow count, column count
- Timeown column, or a bitmap over the lap clock
- Field descriptorsname, kind, width, encoding
- Field arraysdelta-encoded · narrowest width · byte-planed
Everything below the prefix is one Zstandard frame.
Recording and flashbacks
Each driver's current lap is kept in memory. When a lap ends it waits another 30 seconds of session time, then it's compressed and written in one go. Pausing the game doesn't count down that timer.
A flashback simply throws away anything buffered after the point you rewound to, for every driver at once. Nothing on disk is touched, and V5's branch table is gone. The 30 seconds isn't a guess: in a 92-minute São Paulo race capture there were 50 flashbacks, and the longest went back exactly 15 seconds.
The same capture also showed the session clock resetting from 128 seconds to zero at the end of the formation lap. V6 recognises that from the safety car and race-start events, and files the formation lap separately instead of treating it as a flashback.
When a session ends you choose what to keep: Driver Only, just your own car; All Drivers; or both.
- Write delay
- 30 s of session time after a lap ends
- Flashbacks in test race
- 50
- Longest rewind
- 15 s
- On disk after a flashback
- Nothing changes
- Save options
- Driver Only · All Drivers · Both
File Size Evolution
The same 92-minute São Paulo race with 22 cars, from the raw UDP capture to V6 today. If you need evidence, reach out to me and I can send the raw datagram binary and the two files Track N Race spat out.
The Driver Only file is about 1/500th the size of the raw capture.
Side by side
V1 to V6, compared
| Capability | V1 | V2 | V3 | V4 | V5 | V6 |
|---|---|---|---|---|---|---|
| Shipped in | 1.2.0 | 1.3.x | Never | 1.4.x | 1.5.0 | 1.6.0 |
| Compression | gzip | zstd 3 | zstd 3 | zstd 3 | zstd 3 | zstd 9 |
| Rows stored as | JSON | JSON | JSON | JSON | JSON | Columns |
| Lap delta in Analysis | No | No | Yes | Yes | Yes | Yes |
| Opens without a temporary file | No | No | No | Yes | Yes | Yes |
| Reads one lap on its own | No | No | No | Yes | Yes | Yes |
| Knows chunk times without decompressing | No | No | No | No | Yes | Yes |
| Flashbacks never rewrite saved data | No | No | No | Copies chunks | Yes | Yes |
| Records every driver | Partial | Partial | Partial | Partial | Partial | Yes |
| Reads one data type for one driver | No | No | No | No | No | Yes |