The self-contained file format for TrackFrame F1 LED race replays.
Every .tfr file embeds circuit metadata, driver identities, team colours,
and time-stamped telemetry frames — no sidecar files needed.
.bin files which required a companion _colours.csv,
each .tfr file embeds all metadata — driver names, team colours, circuit name, CRC integrity, session type,
date, and frame count. The firmware reads one file and has everything it needs.
{year}-{track}-{session}[-uncut].tfr
Examples:
2025-silverstone-race.tfr Production file (autocut)
2025-silverstone-race-uncut.tfr Includes formation lap
2025-monza-qualifying.tfr
2025-spa-sprint.tfr
2025-abu-dhabi-race.tfr
2025-las-vegas-practice1.tfr
Separators are always hyphens, never underscores. Extension is always .tfr, never .bin.
| Duration | Frames (5 Hz) | File Size | Formula |
|---|---|---|---|
| 30 min | 9,000 | 7.8 MB | 312 + (seconds × Hz × 907) |
| 60 min | 18,000 | 15.6 MB | |
| 90 min | 27,000 | 23.4 MB | |
| 120 min | 36,000 | 31.1 MB |
A .tfr file is a fixed 312-byte header followed by N sequential 907-byte frames. No separators, no variable-length fields, no compression.
offset = 312 + (i × 907).
The header stores both header_size and frame_size so the firmware doesn't need hardcoded constants.
The preamble is read first by the firmware. It identifies the file, describes the session, and provides sizing info for seeking.
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 0–2 | 3 | char[3] | magic | File signature: "TFR" (0x54, 0x46, 0x52). Distinguishes from legacy V5 .bin files. |
| 3 | 1 | uint8 | version | Format version. Always 6 (0x06). |
| 4 | 1 | uint8 | num_drivers | Active drivers (1–20). |
| 5–6 | 2 | uint16 | num_track_leds | Track LED count. Currently 239. |
| 7 | 1 | uint8 | num_pit_leds | Pit LED count. 40 or 0. |
| 8–9 | 2 | uint16 | total_laps | Highest lap number in frame data. |
| 10–13 | 4 | uint32 | total_frames | Total frame count. Must satisfy (file_size - 312) == total_frames × 907. |
| 14–17 | 4 | float32 | track_length | Track length in decimetres. Divide by 10 for metres. |
| 18–21 | 4 | float32 | pit_length | Pit lane path length. 0.0 if none extracted. |
| 22 | 1 | uint8 | sample_rate_hz | Frame rate. Typically 5. Range 1–60. |
| 23 | 1 | uint8 | session_type | Session code (see below). |
| 24–25 | 2 | uint16 | season_year | Calendar year, e.g. 2025. |
| 26 | 1 | uint8 | round_number | FIA round (1–24). 0 if unavailable. |
| 27 | 1 | uint8 | date_year_offset | Year minus 2000. E.g. 25 = 2025. |
| 28 | 1 | uint8 | date_month | Month (1–12). |
| 29 | 1 | uint8 | date_day | Day (1–31). |
| 30–31 | 2 | uint16 | header_size | Always 312 for V6. |
| 32–33 | 2 | uint16 | frame_size | Always 907 for V6. |
| 34–37 | 4 | uint32 | data_crc32 | CRC-32 over all frame data. 0 = skip verification. |
| 38–63 | 26 | char[26] | circuit_name | Null-terminated UTF-8. Max 25 chars + null. Zero-padded. |
'<3sBBHBHIffBBHBBBBHHI26s' # 64 bytes exactly
| Code | Session | CLI Flags |
|---|---|---|
| 0 | Race | R, Race |
| 1 | Qualifying | Q, Qualifying |
| 2 | Sprint | S, Sprint |
| 3 | Free Practice 1 | P1, FP1 |
| 4 | Free Practice 2 | P2, FP2 |
| 5 | Free Practice 3 | P3, FP3 |
| 6 | Sprint Qualifying | SQ |
| 7 | Sprint Shootout | SS |
| 255 | Unknown | Fallback |
20 fixed-size slots of 8 bytes each. Ordered by ascending racing number. Unused slots are zero-filled.
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| +0 | 1 | uint8 | driver_number | Racing number (1–99). 0 = empty slot. |
| +1 to +3 | 3 | char[3] | abbreviation | 3-letter FIA code. E.g. "VER", "HAM". |
| +4 | 1 | uint8 | team_id | Team index (0–9), references the team colour table. |
| +5 to +7 | 3 | uint8[3] | colour_r/g/b | Team primary colour RGB. Duplicated for O(1) LED lookup. |
Hex: 01 56 45 52 08 36 71 C6
| | | | | | | +-- B = 198
| | | | | | +----- G = 113
| | | | | +-------- R = 54 (#3671C6 = Red Bull blue)
| | | | +----------- team_id = 8
| +--+--+-------------- "VER"
+----------------------- driver_number = 1
10 fixed-size slots of 7 bytes each, indexed 0–9.
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| +0 to +2 | 3 | uint8[3] | primary RGB | Official team colour. |
| +3 to +5 | 3 | uint8[3] | secondary RGB | Lightened accent colour (primary + 40% toward white). |
| +6 | 1 | uint8 | team_id | Self-referencing index (0–9). Validation aid. |
secondary_channel = primary_channel + (255 - primary_channel) * 0.4
Clamped to 255. Produces a lighter tint for accent/gradient animations on the LED strip.
All zeros. Reserved for future fields (sector LED boundaries, weather flags, fastest lap driver). Firmware ignores these bytes.
Fixed Preamble 64 bytes (offsets 0 - 63)
Driver Table 160 bytes (offsets 64 - 223) 20 slots x 8 bytes
Team Colour Table 70 bytes (offsets 224 - 293) 10 slots x 7 bytes
Reserved 18 bytes (offsets 294 - 311)
---------
TOTAL HEADER 312 bytes
Each frame is a complete snapshot of the race at one instant. At 5 Hz, one frame every 200 ms. Frames start at byte 312; frame N at offset 312 + (N × 907).
Essential LED playback data. Identical in structure to V5 frames.
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 0–3 | 4 | float32 | timestamp | Seconds since session start (or autocut normalisation). Monotonically non-decreasing. |
| 4–5 | 2 | uint16 | lap | Current lap number (1-indexed). |
| 6 | 1 | uint8 | race_status | Track status flag (0–4). |
| 7–26 | 20 | uint8[20] | trackLeds | LED number per driver. 1–239 = on track. 0 = off track / in pit. |
| 27–46 | 20 | uint8[20] | positions | Race position per driver. 1–20. 0 = no data. |
| 47–66 | 20 | uint8[20] | pitLeds | Pit LED index. 0–39 = in pit. 255 (0xFF) = not in pit. |
if trackLeds[i] > 0 and pitLeds[i] == 255:
# Driver is ON TRACK at LED position trackLeds[i] (1-239)
elif trackLeds[i] == 0 and pitLeds[i] < 255:
# Driver is IN PIT LANE at pit LED index pitLeds[i] (0-39)
elif trackLeds[i] == 0 and pitLeds[i] == 255:
# Driver is OFF-TRACK / no data — not displayed anywhere
Each lit LED represents a driver position. Colors correspond to team colours. LEDs are 1-indexed (1–239).
Enriched telemetry data for HUD displays and data overlays. 14 per-driver data fields.
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 67–106 | 40 | uint16[20] | speed | Speed in km/h. 0–370 typical. 0 = stationary/no data. |
| 107–126 | 20 | uint8[20] | drs | DRS status. 0 = closed, 1 = open. |
| 127–146 | 20 | uint8[20] | compound | Tyre compound (see below). |
| 147–166 | 20 | uint8[20] | tyreLife | Tyre age in laps (0–255). |
| 167–246 | 80 | float32[20] | gapToLeader | Gap to leader in seconds. 0.0 for P1. |
| 247–326 | 80 | float32[20] | interval | Gap to car ahead in seconds. 0.0 for P1. |
| 327–406 | 80 | float32[20] | bestLap | Personal best lap time (running min). 0.0 if no lap. |
| 407–486 | 80 | float32[20] | lastLap | Last completed lap time. 0.0 if none. |
| 487–566 | 80 | float32[20] | lastS1 | Last Sector 1 time. |
| 567–646 | 80 | float32[20] | lastS2 | Last Sector 2 time. |
| 647–726 | 80 | float32[20] | lastS3 | Last Sector 3 time. |
| 727–806 | 80 | float32[20] | currentLapTime | Elapsed time on current lap. |
| 807–886 | 80 | float32[20] | currentSectorTime | Elapsed time in current sector. |
| 887–906 | 20 | uint8[20] | currentSector | Current sector (1–3). 0 = unknown. |
Core Block:
timestamp 4 bytes (float32)
lap 2 bytes (uint16)
race_status 1 byte (uint8)
trackLeds[20] 20 bytes (uint8 x 20)
positions[20] 20 bytes (uint8 x 20)
pitLeds[20] 20 bytes (uint8 x 20)
--------
Core subtotal 67 bytes
Extended Block:
speed[20] 40 bytes (uint16 x 20)
drs[20] 20 bytes (uint8 x 20)
compound[20] 20 bytes (uint8 x 20)
tyreLife[20] 20 bytes (uint8 x 20)
gapToLeader[20] 80 bytes (float32 x 20)
interval[20] 80 bytes (float32 x 20)
bestLap[20] 80 bytes (float32 x 20)
lastLap[20] 80 bytes (float32 x 20)
lastS1[20] 80 bytes (float32 x 20)
lastS2[20] 80 bytes (float32 x 20)
lastS3[20] 80 bytes (float32 x 20)
currentLapTime 80 bytes (float32 x 20)
currentSectorTime 80 bytes (float32 x 20)
currentSector[20] 20 bytes (uint8 x 20)
--------
Extended subtotal 840 bytes
TOTAL FRAME SIZE: 907 bytes
uint8/uint16 → 0.
float32 → 0.0.
Zero means "no data available" in fields where zero isn't a meaningful racing value
(e.g. bestLap = 0.0 means no lap completed, not a 0-second lap).
Standard CRC-32 (ISO 3309 / ITU-T V.42), same as Python's zlib.crc32(). Covers only frame data (bytes 312 to EOF). The header is excluded because the CRC is stored within it.
crc32 = 0crc & 0xFFFFFFFFimport struct, zlib
def verify_tfr_crc(filepath):
with open(filepath, 'rb') as f:
f.seek(34)
stored_crc = struct.unpack('<I', f.read(4))[0]
if stored_crc == 0:
return None # CRC was not computed
f.seek(30)
header_size = struct.unpack('<H', f.read(2))[0]
f.seek(32)
frame_size = struct.unpack('<H', f.read(2))[0]
f.seek(header_size)
computed_crc = 0
while True:
chunk = f.read(frame_size)
if not chunk:
break
computed_crc = zlib.crc32(chunk, computed_crc)
computed_crc &= 0xFFFFFFFF
return computed_crc == stored_crc
0 means "skip verification".
Packed structs for reading .tfr files directly on Teensy 4.1. Python output must produce bytes that these structs parse correctly.
#include <stdint.h>
#define TFR_MAGIC_0 'T'
#define TFR_MAGIC_1 'F'
#define TFR_MAGIC_2 'R'
#define TFR_VERSION_6 6
#define TFR_HEADER_SIZE 312
#define TFR_FRAME_SIZE 907
// --- Preamble (64 bytes) ---
struct __attribute__((packed)) TFRPreamble {
char magic[3]; // "TFR"
uint8_t version; // 6
uint8_t numDrivers; // 1-20
uint16_t numTrackLeds; // 239
uint8_t numPitLeds; // 0 or 40
uint16_t totalLaps;
uint32_t totalFrames;
float trackLength; // decimetres
float pitLength; // decimetres
uint8_t sampleRateHz; // 1-60 (typically 5)
uint8_t sessionType; // 0=Race, 1=Quali, ...
uint16_t seasonYear; // e.g. 2025
uint8_t roundNumber; // 1-24
uint8_t dateYearOffset; // year - 2000
uint8_t dateMonth; // 1-12
uint8_t dateDay; // 1-31
uint16_t headerSize; // 312
uint16_t frameSize; // 907
uint32_t dataCrc32; // CRC-32 of frame data
char circuitName[26]; // null-terminated UTF-8
};
static_assert(sizeof(TFRPreamble) == 64, "Preamble size mismatch");
// --- Driver Slot (8 bytes) ---
struct __attribute__((packed)) TFRDriverSlot {
uint8_t driverNumber; // 0 = unused
char abbreviation[3]; // "VER", "HAM"
uint8_t teamId; // 0-9
uint8_t colourR, colourG, colourB;
};
static_assert(sizeof(TFRDriverSlot) == 8, "Driver slot size mismatch");
// --- Team Slot (7 bytes) ---
struct __attribute__((packed)) TFRTeamSlot {
uint8_t primaryR, primaryG, primaryB;
uint8_t secondaryR, secondaryG, secondaryB;
uint8_t teamId; // self-ref, 0-9
};
static_assert(sizeof(TFRTeamSlot) == 7, "Team slot size mismatch");
// --- Complete Header (312 bytes) ---
struct __attribute__((packed)) TFRHeader {
TFRPreamble preamble; // 64 bytes
TFRDriverSlot drivers[20]; // 160 bytes
TFRTeamSlot teams[10]; // 70 bytes
uint8_t reserved[18]; // 18 bytes
};
static_assert(sizeof(TFRHeader) == 312, "Header size mismatch");
// --- Frame (907 bytes) ---
struct __attribute__((packed)) TFRFrame {
// Core block (67 bytes)
float timestamp;
uint16_t lap;
uint8_t raceStatus;
uint8_t trackLeds[20];
uint8_t positions[20];
uint8_t pitLeds[20];
// Extended block (840 bytes)
uint16_t speed[20]; // km/h
uint8_t drs[20]; // 0=closed, 1=open
uint8_t compound[20]; // 0-5
uint8_t tyreLife[20]; // laps
float gapToLeader[20]; // seconds
float interval[20]; // seconds
float bestLap[20]; // seconds
float lastLap[20]; // seconds
float lastS1[20]; // seconds
float lastS2[20]; // seconds
float lastS3[20]; // seconds
float currentLapTime[20]; // seconds
float currentSectorTime[20]; // seconds
uint8_t currentSector[20]; // 1-3, 0=unknown
};
static_assert(sizeof(TFRFrame) == 907, "Frame size mismatch");
bool openRaceFile(const char* path) {
File file = sd.open(path, O_READ);
if (!file) return false;
TFRHeader header;
if (file.read(&header, sizeof(header)) != sizeof(header)) {
file.close();
return false;
}
// Validate magic bytes
if (header.preamble.magic[0] != 'T' ||
header.preamble.magic[1] != 'F' ||
header.preamble.magic[2] != 'R') {
file.close();
return false;
}
// Validate version
if (header.preamble.version != TFR_VERSION_6) {
file.close();
return false;
}
// Playback parameters
uint32_t numFrames = header.preamble.totalFrames;
uint8_t sampleRate = header.preamble.sampleRateHz;
uint32_t interval_us = 1000000 / (sampleRate * speedMultiplier);
// Load driver colours for LED rendering
for (int i = 0; i < header.preamble.numDrivers; i++) {
driverColors[i] = CRGB(
header.drivers[i].colourR,
header.drivers[i].colourG,
header.drivers[i].colourB
);
}
// Read and render frames
TFRFrame frame;
while (file.read(&frame, sizeof(frame)) == sizeof(frame)) {
for (int i = 0; i < header.preamble.numDrivers; i++) {
if (frame.trackLeds[i] > 0) {
leds[frame.trackLeds[i] - 1] = driverColors[i];
}
}
FastLED.show();
delayMicroseconds(interval_us);
}
file.close();
return true;
}
From 2025-spa-race-uncut.tfr — 2025 Belgian Grand Prix, 33,599 frames, 29.1 MB.
| Field | Raw Bytes | Decoded Value |
|---|---|---|
magic | 54 46 52 | "TFR" |
version | 06 | 6 |
num_drivers | 14 | 20 |
num_track_leds | EF 00 | 239 |
num_pit_leds | 28 | 40 |
total_laps | 2C 00 | 44 |
total_frames | 3F 83 00 00 | 33,599 |
track_length | D6 41 87 47 | 70046.2 dm (7004.62 m) |
pit_length | 60 21 13 44 | 9421.4 dm (942.14 m) |
sample_rate_hz | 05 | 5 Hz |
session_type | 00 | 0 (Race) |
season_year | E9 07 | 2025 |
round_number | 0D | 13 |
date | 19 07 1B | 2025-07-27 |
header_size | 38 01 | 312 |
frame_size | 8B 03 | 907 |
data_crc32 | D2 84 6F D3 | 0xD36F84D2 |
circuit_name | 53 70 61 2D ... | "Spa-Francorchamps" |
| Rule | Check | Severity |
|---|---|---|
| Magic is "TFR" | magic[0..2] == {0x54, 0x46, 0x52} | Fatal |
| Version is 6 | version == 6 | Fatal |
| Drivers in range | 1 <= num_drivers <= 20 | Fatal |
| Driver count matches table | count(drivers[i].num > 0) == num_drivers | Fatal |
| Frame count valid | total_frames > 0 | Fatal |
| File size consistent | (file_size - header_size) == total_frames * frame_size | Fatal |
| Track LEDs standard | num_track_leds == 239 | Warning |
| Pit LEDs expected | num_pit_leds in {0, 40} | Warning |
| Track length positive | track_length > 0 | Warning |
| Sample rate plausible | 1 <= sample_rate_hz <= 60 | Warning |
| Session type known | session_type in 0–7 or 255 | Warning |
| Header/frame size match V6 | header_size==312, frame_size==907 | Info |
| Circuit name terminated | circuit_name contains \0 in 26 bytes | Warning |
| Abbreviations ASCII | abbreviation[0..2] are A-Z (0x41-0x5A) | Warning |
| Aspect | V5 (.bin) | V6 (.tfr) |
|---|---|---|
| Extension | .bin | .tfr |
| File naming | 2025_silverstone_race.bin | 2025-silverstone-race.tfr |
| Magic bytes | None (first byte = 0x05) | "TFR" (0x54 0x46 0x52) |
| Header size | 38 bytes | 312 bytes |
| Frame size | 907 bytes | 907 bytes (identical) |
| Frame data | Identical | Identical (byte-compatible) |
| Driver names | External _colours.csv | Embedded in header |
| Team colours | External _colours.csv | Embedded in header |
| Circuit name | Parsed from filename | Embedded in header |
| Sample rate | Hardcoded assumption | Stored in header |
| Frame count | Calculated from file size | Stored in header |
| CRC-32 | None | Stored in header |
| Session type | Parsed from filename | Stored in header |
| Date / round | Not available | Stored in header |
def detect_binary_version(filepath):
with open(filepath, 'rb') as f:
first_bytes = f.read(4)
if first_bytes[:3] == b'TFR':
version = first_bytes[3]
with open(filepath, 'rb') as f:
f.seek(30)
header_size = struct.unpack('<H', f.read(2))[0]
frame_size = struct.unpack('<H', f.read(2))[0]
return (version, header_size, frame_size)
elif first_bytes[0] == 5:
return (5, 38, 907) # V5: 38-byte header, same frame size
else:
raise ValueError(f"Unknown format: {first_bytes.hex()}")
Every field in a .tfr file traces back to a specific data source in the Python extraction pipeline.
| Field | FastF1 Source | Method |
|---|---|---|
timestamp | Session timeline | Manual numpy interpolation at resample_hz |
lap | session.laps | LapMappingEngine timestamp-to-lap |
race_status | session.race_control_messages | TrackStatus code mapping |
trackLeds | Car XY positions | LEDMappingEngine via KDTree |
positions | High-res leaderboard | 5000-point track precision |
pitLeds | Pit lane path | Subtraction + MST extraction |
speed | session.car_data[drv].Speed | merge_asof nearest, 1s tolerance |
drs | session.car_data[drv].DRS | ≥10 = open → binary 0/1 |
compound | session.laps.Compound | merge_asof backward (forward-fill) |
tyreLife | session.laps.TyreLife | merge_asof backward, capped 255 |
gapToLeader | Calculated | distance_delta / (speed / 3.6 * 10) |
interval | Calculated | Same formula, car directly ahead |
bestLap | session.laps.LapTime | Precomputed running minimum |
lastLap | session.laps.LapTime | Most recent completed lap |
lastS1/S2/S3 | session.laps.Sector*SessionTime | searchsorted boundary lookup |
currentSector | session.laps | Sector boundary timestamps |
Lap 15, ~56 minutes into the 2025 Belgian Grand Prix. All 20 drivers on track.
| Driver | LED | Pos | Speed | DRS | Tyre | Life | Gap | Intv | Best | Last | Sec |
|---|---|---|---|---|---|---|---|---|---|---|---|
| LEC (16) | 82 | P1 | 153 | 0 | M | 3 | 0.0 | 0.0 | 109.970 | 109.970 | S2 |
| BEA (87) | 29 | P2 | 291 | 0 | M | 4 | 19.1 | 19.1 | 115.086 | 115.086 | S1 |
| PIA (81) | 108 | P3 | 148 | 0 | M | 3 | 150.7 | 113.1 | 110.884 | 110.884 | S2 |
| NOR (4) | 92 | P4 | 198 | 0 | H | 2 | 121.1 | 8.4 | 119.136 | 130.916 | S2 |
| VER (1) | 81 | P5 | 211 | 0 | M | 3 | 119.0 | 5.3 | 110.286 | 110.286 | S2 |
| HAM (44) | 73 | P8 | 333 | 1 | M | 4 | 77.8 | 0.8 | 109.564 | 109.564 | S1 |
DRS = 1 (open), chasing the car ahead. All on Medium compound except NOR on Hard. All pitLeds = 255 (no one pitting). Drivers spread across LEDs 8–108 on the 239-LED strip.
The complete data flow from F1 telemetry to physical LEDs: