.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.

Uncompressed, readable on open Compressed data Diagrams are not to scale

24 May 2026 · the day after the first commit

A gzipped log

Shipped in 1.2.0

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
File layout
gzip stream
HeaderJSON line
Rowstelemetry · lap · status · damage · motion · … in arrival order

13 July 2026

Zstandard instead of gzip

Shipped in 1.3.0 – 1.3.3

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
File layout
Zstandard stream
Header+ compression
Rowsunchanged from V1

5 August 2026

Track length, for lap delta

Never released

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
File layout
Zstandard stream
Header+ track_length_m
Rowsunchanged from V2

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

Shipped in 1.4.0 – 1.4.1

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
File layout
Header96 B
SessionJSON
ChunksL1 telemetry · L1 status · L1 damage · L2 telemetry · …
Lap table24 B / lap
Directory48 B / chunk
Footer32 B

23 August 2026

Write down what you already know

Shipped in 1.5.0

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
File layout
Header128 B
SessionJSON
Chunkssame JSON chunks as V4
Branches16 B each
Laps24 B
Directory96 B + time bounds
Row indexat close
Footer48 B

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.

Shipped in 1.6.0

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
File layout
Header128 B
ChunksVER L1 speed · VER L1 RPM · … · ALO L5 speed · shared session records
Drivers + lapsmetadata
Directory56 B / chunk
Footer48 B

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.

One chunk, top to bottom
  1. Prefix32 B · driver, lap, type, sizes, checksum
  2. Column headerrow count, column count
  3. Timeown column, or a bitmap over the lap clock
  4. Field descriptorsname, kind, width, encoding
  5. 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.

Raw UDP capture 2.52 GB
V6 - All Drivers 76.4 MB
V6 - Driver Only 4.9 MB

The Driver Only file is about 1/500th the size of the raw capture.

Side by side

V1 to V6, compared

Capability V1V2V3 V4V5V6
Shipped in 1.2.01.3.xNever1.4.x1.5.01.6.0
Compression gzipzstd 3zstd 3zstd 3zstd 3zstd 9
Rows stored as JSONJSONJSONJSONJSONColumns
Lap delta in Analysis NoNoYesYesYesYes
Opens without a temporary file NoNoNoYesYesYes
Reads one lap on its own NoNoNoYesYesYes
Knows chunk times without decompressing NoNoNoNoYesYes
Flashbacks never rewrite saved data NoNoNoCopies chunksYesYes
Records every driver PartialPartialPartialPartialPartialYes
Reads one data type for one driver NoNoNoNoNoYes