TFR V6
Binary Format
Specification

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.

Version 6
Header 312 B
Frame 907 B
Endian Little
Extension .tfr
Software 7.0.0

Design Principles

312 Byte header (fixed)
907 Bytes per frame (fixed)
20 Drivers per frame
239 Track LEDs
40 Pit lane LEDs
5 Default Hz sample rate
Self-contained: Unlike V5 .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.

File Naming Convention

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

File Size Reference

DurationFrames (5 Hz)File SizeFormula
30 min9,0007.8 MB312 + (seconds × Hz × 907)
60 min18,00015.6 MB
90 min27,00023.4 MB
120 min36,00031.1 MB

File Structure

A .tfr file is a fixed 312-byte header followed by N sequential 907-byte frames. No separators, no variable-length fields, no compression.

0
Fixed Preamble — magic, version, session metadata, sizing64 bytes
64
Driver Table — 20 slots × 8 bytes (number, abbreviation, team, colour)160 bytes
224
Team Colour Table — 10 slots × 7 bytes (primary + secondary RGB)70 bytes
294
Reserved — zero-filled, future expansion18 bytes
312
Frame 0 — complete race state at t=0907 bytes
1219
Frame 1 — complete race state at t=0.2s907 bytes
Frame N-1 — final frame907 bytes
Random access: To seek to frame i, compute offset = 312 + (i × 907). The header stores both header_size and frame_size so the firmware doesn't need hardcoded constants.

Frame Data — 907 Bytes Per Frame

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

4.1   Core Block (67 bytes, offset 0–66 within frame)

Essential LED playback data. Identical in structure to V5 frames.

OffsetSizeTypeFieldDescription
0–34float32timestampSeconds since session start (or autocut normalisation). Monotonically non-decreasing.
4–52uint16lapCurrent lap number (1-indexed).
61uint8race_statusTrack status flag (0–4).
7–2620uint8[20]trackLedsLED number per driver. 1–239 = on track. 0 = off track / in pit.
27–4620uint8[20]positionsRace position per driver. 1–20. 0 = no data.
47–6620uint8[20]pitLedsPit LED index. 0–39 = in pit. 255 (0xFF) = not in pit.

Race Status Codes

0 — Green Flag
1 — Yellow Flag
2 — Virtual Safety Car
3 — Safety Car
4 — Red Flag

LED Encoding Logic

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

239-LED Track Strip (Animated)

Each lit LED represents a driver position. Colors correspond to team colours. LEDs are 1-indexed (1–239).

4.2   Extended Block (840 bytes, offset 67–906 within frame)

Enriched telemetry data for HUD displays and data overlays. 14 per-driver data fields.

OffsetSizeTypeFieldDescription
67–10640uint16[20]speedSpeed in km/h. 0–370 typical. 0 = stationary/no data.
107–12620uint8[20]drsDRS status. 0 = closed, 1 = open.
127–14620uint8[20]compoundTyre compound (see below).
147–16620uint8[20]tyreLifeTyre age in laps (0–255).
167–24680float32[20]gapToLeaderGap to leader in seconds. 0.0 for P1.
247–32680float32[20]intervalGap to car ahead in seconds. 0.0 for P1.
327–40680float32[20]bestLapPersonal best lap time (running min). 0.0 if no lap.
407–48680float32[20]lastLapLast completed lap time. 0.0 if none.
487–56680float32[20]lastS1Last Sector 1 time.
567–64680float32[20]lastS2Last Sector 2 time.
647–72680float32[20]lastS3Last Sector 3 time.
727–80680float32[20]currentLapTimeElapsed time on current lap.
807–88680float32[20]currentSectorTimeElapsed time in current sector.
887–90620uint8[20]currentSectorCurrent sector (1–3). 0 = unknown.

Tyre Compound Codes

0 Unknown 1 Soft 2 Medium 3 Hard 4 Intermediate 5 Wet

Frame Size Breakdown

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
No NaN values: All missing/NaN data is converted before writing. uint8/uint160. float320.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).

CRC-32 Integrity Check

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.

Step 1
Write header with
crc32 = 0
Step 2
Write all frames,
computing CRC
incrementally
Step 3
Mask to uint32:
crc & 0xFFFFFFFF
Step 4
Seek to offset 34,
write 4-byte CRC

Verification (Python)

import 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
Firmware note: CRC verification is optional on Teensy. Reading all frame data takes ~2 seconds for a 30 MB file at SDIO speed. Recommended as a startup diagnostic only. A stored CRC of 0 means "skip verification".

Firmware C Structs

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");

Firmware Usage Example

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;
}

Real-World Hex Dump

From 2025-spa-race-uncut.tfr — 2025 Belgian Grand Prix, 33,599 frames, 29.1 MB.

0x0000 54 46 52 06 14 EF 00 28 2C 00 3F 83 00 00 D6 41 TFR....(,.?.....
0x0010 87 47 60 21 13 44 05 00 E9 07 0D 19 07 1B 38 01 .G`!.D........8.
0x0020 8B 03 D2 84 6F D3 53 70 61 2D 46 72 61 6E 63 6F ....o.Spa-Franco
0x0030 72 63 68 61 6D 70 73 00 00 00 00 00 00 00 00 00 rchamps.........
Magic Version Metadata Sizing Float CRC-32 String
FieldRaw BytesDecoded Value
magic54 46 52"TFR"
version066
num_drivers1420
num_track_ledsEF 00239
num_pit_leds2840
total_laps2C 0044
total_frames3F 83 00 0033,599
track_lengthD6 41 87 4770046.2 dm (7004.62 m)
pit_length60 21 13 449421.4 dm (942.14 m)
sample_rate_hz055 Hz
session_type000 (Race)
season_yearE9 072025
round_number0D13
date19 07 1B2025-07-27
header_size38 01312
frame_size8B 03907
data_crc32D2 84 6F D30xD36F84D2
circuit_name53 70 61 2D ..."Spa-Francorchamps"

Header Validation Rules

RuleCheckSeverity
Magic is "TFR"magic[0..2] == {0x54, 0x46, 0x52}Fatal
Version is 6version == 6Fatal
Drivers in range1 <= num_drivers <= 20Fatal
Driver count matches tablecount(drivers[i].num > 0) == num_driversFatal
Frame count validtotal_frames > 0Fatal
File size consistent(file_size - header_size) == total_frames * frame_sizeFatal
Track LEDs standardnum_track_leds == 239Warning
Pit LEDs expectednum_pit_leds in {0, 40}Warning
Track length positivetrack_length > 0Warning
Sample rate plausible1 <= sample_rate_hz <= 60Warning
Session type knownsession_type in 0–7 or 255Warning
Header/frame size match V6header_size==312, frame_size==907Info
Circuit name terminatedcircuit_name contains \0 in 26 bytesWarning
Abbreviations ASCIIabbreviation[0..2] are A-Z (0x41-0x5A)Warning

V5 vs V6 Comparison

AspectV5 (.bin)V6 (.tfr)
Extension.bin.tfr
File naming2025_silverstone_race.bin2025-silverstone-race.tfr
Magic bytesNone (first byte = 0x05)"TFR" (0x54 0x46 0x52)
Header size38 bytes312 bytes
Frame size907 bytes907 bytes (identical)
Frame dataIdenticalIdentical (byte-compatible)
Driver namesExternal _colours.csvEmbedded in header
Team coloursExternal _colours.csvEmbedded in header
Circuit nameParsed from filenameEmbedded in header
Sample rateHardcoded assumptionStored in header
Frame countCalculated from file sizeStored in header
CRC-32NoneStored in header
Session typeParsed from filenameStored in header
Date / roundNot availableStored in header

Detecting V5 vs V6

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()}")

Data Sources & Extraction Pipeline

Every field in a .tfr file traces back to a specific data source in the Python extraction pipeline.

Extraction Pipeline

1
load_session()
Load F1 session via FastF1 API
2
visualize_racing_line_and_get_rotation()
Track analysis + LED positioning via KDTree
3
create_master_timeline()
Unified timestamp generation at sample_rate Hz
4
add_position_columns()
High-fidelity leaderboard with 5000-point precision → positions, rel_lap, rel_pt
5
add_speed_and_drs_columns()
merge_asof from session.car_data → speed, drs
6
add_tyre_columns()
merge_asof backward from session.laps → compound, tyre_life
7
add_timing_columns()
searchsorted boundaries from session.laps → sector times, lap times, best lap
8
add_gap_columns()
distance_delta / speed calculation → gap_to_leader, interval
9
save_led_binary_data()
Write .tfr with 312-byte header + 907-byte frames + CRC-32

Frame Field Sources

FieldFastF1 SourceMethod
timestampSession timelineManual numpy interpolation at resample_hz
lapsession.lapsLapMappingEngine timestamp-to-lap
race_statussession.race_control_messagesTrackStatus code mapping
trackLedsCar XY positionsLEDMappingEngine via KDTree
positionsHigh-res leaderboard5000-point track precision
pitLedsPit lane pathSubtraction + MST extraction
speedsession.car_data[drv].Speedmerge_asof nearest, 1s tolerance
drssession.car_data[drv].DRS≥10 = open → binary 0/1
compoundsession.laps.Compoundmerge_asof backward (forward-fill)
tyreLifesession.laps.TyreLifemerge_asof backward, capped 255
gapToLeaderCalculateddistance_delta / (speed / 3.6 * 10)
intervalCalculatedSame formula, car directly ahead
bestLapsession.laps.LapTimePrecomputed running minimum
lastLapsession.laps.LapTimeMost recent completed lap
lastS1/S2/S3session.laps.Sector*SessionTimesearchsorted boundary lookup
currentSectorsession.lapsSector boundary timestamps

Worked Example: Mid-Race Frame

Lap 15, ~56 minutes into the 2025 Belgian Grand Prix. All 20 drivers on track.

DriverLEDPosSpeedDRSTyreLifeGapIntvBestLastSec
LEC (16)82P11530M30.00.0109.970109.970S2
BEA (87)29P22910M419.119.1115.086115.086S1
PIA (81)108P31480M3150.7113.1110.884110.884S2
NOR (4)92P41980H2121.18.4119.136130.916S2
VER (1)81P52110M3119.05.3110.286110.286S2
HAM (44)73P83331M477.80.8109.564109.564S1
Key observations: LEC (Leclerc) leads with gap/interval both 0.0. HAM (Hamilton) has 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.

System Architecture

The complete data flow from F1 telemetry to physical LEDs:

API
FastF1 API
Official F1 timing & telemetry data
PY
Python Extractor (fidule-withcli.py)
~8000 lines. Extracts, interpolates, maps to LEDs, writes .tfr
SD
.tfr Binary File on SD Card
312-byte header + N × 907-byte frames. Self-contained.
MCU
Teensy 4.1 Microcontroller
Reads frames sequentially, maps to LED colours via FastLED
LED
Physical LED Strip
239 track LEDs + 40 pit LEDs + 20 progress LEDs