Files
racing-data/docs/udp-packets.md
2026-05-19 15:38:16 +10:00

39 KiB

Project CARS UDP Packets

This app receives binary UDP packets from Project CARS on port 5606, decodes them in src/parser.ts, keeps the latest packet of each type in memory, and exposes the decoded data as JSON through /api/state.

The game does not send JSON. The JSON shape is our readable decoded version of the binary packet structs. See docs/udp-packets.schema.json for a schema of the decoded output.

Packet Header

Every packet starts with the same 12 byte header:

Field Meaning
packetNumber Global packet counter across all packet types.
categoryPacketNumber Counter for this packet type/category.
partialPacketIndex Which part this packet is when the data is split. In captures, this can be 1 for the first participant packet.
partialPacketNumber Total parts for split data.
packetType Packet category, listed below.
packetVersion Version for that packet category.

How To Decode Packets

The UDP payloads are packed binary structs. Decode them with these general rules:

Rule Detail
Byte order Little-endian. Use readUInt32LE, readFloatLE, etc.
Packing Structs are packed as byte-aligned data for the main telemetry/timing packets. Do not assume natural C padding except where the packet definition explicitly results in padded structs.
Header The first 12 bytes are always PacketBase.
Packet type Byte offset 10 is the packet type. Use this to decide which decoder to run.
Version Byte offset 11 is the packet version. Captures showed type 0 as version 4, type 2 as version 3, type 3 as version 1, type 4 as version 2, type 7 as version 1, and type 8 as version 2.
Strings Fixed-length C strings. Read up to the first null byte or the maximum field length.
Arrays Repeated values are contiguous. Example: float[3] is three little-endian floats, 4 bytes each.
Split packets Some low-frequency data uses partial packets. Merge parts by packet category before using them.

Minimal packet dispatch example:

const packetType = buffer.readUInt8(10);
const packetVersion = buffer.readUInt8(11);

switch (packetType) {
  case 0:
    return parseCarPhysics(buffer);
  case 2:
    return parseParticipants(buffer);
  case 3:
    return parseTimings(buffer);
}

Header Offsets

Offset Type Field
0 uint32 packetNumber
4 uint32 categoryPacketNumber
8 uint8 partialPacketIndex
9 uint8 partialPacketNumber
10 uint8 packetType
11 uint8 packetVersion

Header decoder:

function parseBase(buffer: Buffer) {
  return {
    packetNumber: buffer.readUInt32LE(0),
    categoryPacketNumber: buffer.readUInt32LE(4),
    partialPacketIndex: buffer.readUInt8(8),
    partialPacketNumber: buffer.readUInt8(9),
    packetType: buffer.readUInt8(10),
    packetVersion: buffer.readUInt8(11)
  };
}

Fixed Strings

Strings are fixed-width byte arrays, usually null-terminated. Read only within the field length:

function readCString(buffer: Buffer, offset: number, length: number): string {
  const end = buffer.indexOf(0, offset);
  const safeEnd = end >= offset && end < offset + length ? end : offset + length;
  return buffer.toString("utf8", offset, safeEnd).trim();
}

Arrays

Arrays are decoded by repeating the primitive read at the correct stride:

function readFloatArray(buffer: Buffer, offset: number, count: number): number[] {
  return Array.from({ length: count }, (_, index) =>
    buffer.readFloatLE(offset + index * 4)
  );
}

function readUInt16Array(buffer: Buffer, offset: number, count: number): number[] {
  return Array.from({ length: count }, (_, index) =>
    buffer.readUInt16LE(offset + index * 2)
  );
}

Partial Packets

Participant names and vehicle names can be repeated/split packets.

Captured behavior:

Packet Type Observed Partial Behavior
Type 2 participants Capture showed partialPacketIndex = 1 and partialPacketNumber = 1, even though this was the first/only participant packet.
Type 8 vehicles/classes Capture showed part1-of-2 for vehicles and part2-of-2 for class names.

For participants, this app normalizes the first packet like this:

const packetOffset = Math.max(0, partialPacketIndex - 1);
const slot = packetOffset * 16 + localSlot;

For type 8, merge the latest vehicles and classes arrays instead of replacing one with the other. Otherwise, part2-of-2 will overwrite part1-of-2 and vehicle names will disappear.

Captured Gotchas

These details were discovered from the real captures in captures/:

Gotcha Impact Fix Used By App
Type 2 participant index was 0 for every driver. Cannot join names using participant.index. Join names by timing slot.
Type 2 partialPacketIndex was 1 for first/only packet. Naive slot calculation shifted names to 16..26. Normalize first participant packet to slot 0..15.
Type 8 vehicle index matched Type 3 carIndex. Vehicle names will not join via participant id. Join timings.participants[].carIndex to vehicles[].index.
Type 8 vehicle and class data arrived in separate packets. Keeping only latest type 8 packet loses either vehicles or classes. Merge vehicles/classes in server state.
Type 2 and 8 are low-frequency. Captures started mid-race may miss names/cars. Start capture before loading/entering the race.

External Decoder Notes

The jkowa/pcars2 Python decoder is useful as an enum reference for the Project CARS 2 V2 UDP stream:

Enum Useful Values
RaceState 0 INVALID, 1 NOT_STARTED, 2 RACING, 3 FINISHED, 4 DISQUALIFIED, 5 RETIRED, 6 DNF.
FlagColour 0 NONE, 1 GREEN, 2 BLUE, 3 WHITE, 4 YELLOW, 5 DOUBLE_YELLOW, 6 BLACK, 7 CHEQUERED.
FlagReason 0 NONE, 1 SOLO_CRASH, 2 VEHICLE_CRASH, 3 VEHICLE_OBSTRUCTION.
PitMode 0 NONE, 1 DRIVING_INTO_PITS, 2 IN_PIT, 3 DRIVING_OUT_OF_PITS, 4 IN_GARAGE.
PitSchedule 0 NONE, 1 STANDARD, 2 DRIVE_THROUGH, 3 STOP_GO.

This confirms the broad interpretation of raceState, highestFlag, and pitModeSchedule. It does not expose a separate invalid-time-warning or penalty-reason field. Invalidated laps still appear to be represented by the extra 0x08 bit on UDP Type 3 raceState.

The jkowa/pcars2 Type 3 structure does not exactly match the V2 packet layout seen in our captures and the SMS UDP header we started from, so the app keeps its current offsets and uses the external project mainly for enum names.

The ralfhergert/pc2-telemetry Java project confirms the V2 BasePacket header and implements a Type 0 car-physics parser, but it does not decode Type 3 timings, Type 7 stats, penalties, race flags, or participant state beyond the base packet type. It also appears to contain a few Type 0 offset mistakes, so it is useful as corroboration for the broad V2 packet family but not as a source for new penalty/status mappings.

The eckhchri/pcars-ds-liveview project is not a UDP streamer decoder. It polls the Project CARS Dedicated Server HTTP API and CREST/CREST2 APIs, where CREST exposes shared-memory data over HTTP. It is still useful as a cross-check for labels:

Source Useful Hint
CREST race state mapping 0 Invalid, 1 Not Started, 2 Racing, 3 Finished, 4 Disqualified, 5 Retired, 6 DNF. This matches the current UDP Type 3 low-bit interpretation.
CREST pit mode mapping 0 None, 1 EnteringPits, 2 InPits, 3 ExitingPits, 4 InGarage, 5 ExitingGarage. This supports the observed pitModeSchedule & 0x07 values, including 5 as garage exit rather than a penalty.
CREST sector mapping CREST sectors are remapped as 0 -> 1, 1 -> 2, 2 -> 3. This is a reminder that sector numbers are source-specific. The UDP app keeps the UDP values and only derives display labels around observed transitions.
Timing fields CREST2 exposes mFastestLapTimes, mLastLapTimes, and current sector time fields. In UDP, the nearest equivalent is Type 7 stats, which we already use for last/best lap and sector-time split calculations.

It does not add a confirmed UDP field for penalties, invalid-time warnings, Joker-lap completion, nationality, or race flags. For those, captures remain the stronger evidence.

The RangeyRover/Automobilista-2-Auto-Director project is more directly relevant for AMS2. It supports UDP and shared memory, and includes an AMS2-edited UDP header:

Area Useful Hint
AMS2 UDP Type 3 size It documents the timing packet as 1063 bytes with participant data starting at byte 31. This matches our captured AMS2 packets and the current parser behavior.
AMS2 Type 3 participant offsets Within each 32-byte participant block it uses lap distance at +14, position at +16, sector at +17, flag at +18, pit mode/schedule at +19, race state at +22, current lap at +23, current time at +24, and current sector time at +28.
Race-state decoding Its UDP code masks raceState & 0x07, which agrees with treating the lower three bits as the base state and keeping extra bits such as invalidated lap separate.
Pit decoding Its UDP code uses pitMode = value & 0x07 and pitSchedule = (value & 0x18) >> 3, matching the bit layout used by this app for UDP.
Shared-memory extras Its shared-memory struct includes per-driver mLapsInvalidated, mPitSchedules, mHighestFlagColours, mHighestFlagReasons, and mNationalities, plus session-level mYellowFlagState. These fields explain why some AMS2 state may be available in shared memory even when it is not obvious in UDP captures.

It does not add a new confirmed UDP field for Joker-lap completion or normal penalty reasons. Its auto-director "pit mode penalty" is a camera-scoring penalty applied by that tool, not a decoded game penalty.

Shared Memory Bridge

The project can also accept normalized frames from scripts/shared_memory_bridge.py. Run the Python helper on the same Windows PC as AMS2/Project CARS, because shared memory is local to the game machine:

python scripts\shared_memory_bridge.py --url http://NODE_SERVER_IP:3000/api/ingest/shared-memory

Useful options:

Option Meaning
--url Node server ingest endpoint. Defaults to http://127.0.0.1:3000/api/ingest/shared-memory.
--interval Seconds between reads. Defaults to 0.2.
--once Send one frame and exit. Useful for testing.
--print Print the normalized JSON instead of posting it.

The helper reads the Windows shared-memory mapping named $pcars2$, converts the data into the same packet-like JSON shapes used by the UDP frontend, and POSTs them to the Node server. The backend keeps UDP and shared-memory input on the same leaderboard path.

The jamesremuscat/pcars Python package decodes the older Project CARS UDP stream, not the PCars2/AMS2 V2 streamer used by the main app. It is useful mainly as a V1 reference:

Area Useful Hint
Packet header V1 starts with uint16 buildVersion plus one byte containing sequence/type bits. It does not use the 12-byte V2 PacketBase.
Packet types It decodes only V1 packet types 0 telemetry, 1 participant strings, and 2 additional participant strings.
Sample sizes Its fixture packets are 1367, 1347, and 1028 bytes, matching the V1-size blocks seen in our V1-mode capture.
V1 bit fields It masks race state with raceStateFlags & 0x07, and treats raceStateFlags & 0x08 as lap invalidated.
V1 participant strings It notes that name/fastest-lap slots can contain stale data when there are fewer participants than the packet capacity, so consumers should trust numParticipants.

Do not copy its offsets into the V2 parser. It uses a different packet layout, different participant capacity, and different string packets from the V2 Type 2, Type 3, Type 7, and Type 8 packets.

The archived SMS forum thread "HowTo: Companion App UDP Streaming" is the clearest source found for the original Project CARS UDP stream. It confirms the same V1 layout used by jamesremuscat/pcars:

Area Forum Detail
Port UDP port 5606.
Stream rate Game menu values range from off through UDP 1 at 60/sec down to UDP 9 at 1/sec.
V1 telemetry packet sTelemetryData, size 1367/effectively padded to 1368, packet type 0.
V1 participant strings sParticipantInfoStrings, size 1347, packet type 1.
V1 extra participant strings sParticipantInfoStringsAdditional, size 1028, packet type 2.
V1 header u16 buildVersion at byte 0, then byte 2 stores `packetType
V1 race flags `raceStateFlags = raceState
V1 highest flag `highestFlag = flagColour
V1 pit info `pitModeSchedule = pitMode
V1 participant activity Participant race-position top bit is active/inactive.
V1 per-driver invalidation Participant lapsCompleted top bit is the per-driver lap-invalidated flag.
V1 sector byte Low bits hold sector, bit 0x08 is same-class-as-player, and high bits carry extra x/z position precision.

That V1 pit/flag packing is different from the AMS2 V2 timing packet behavior we have observed and from the current V2 parser, so it should be treated as V1-only unless captures prove otherwise.

Packet Size Checks

The provided C++ header lists nominal struct sizes, but captures may include slightly larger payloads due to protocol/version differences. Use packet type and version first, then decode known offsets defensively.

Observed sizes from capture:

Type Observed Size
0 559
1 308
2 1136
3 1063
4 24
7 1040
8 vehicles 1164
8 classes 1452

If a packet is shorter than the offset you need, skip that field rather than throwing. The current app assumes the captured packet sizes above.

V1 UDP Mode Notes

Project CARS V1 UDP mode uses a different wire format from the V2 UDP streamer documented above.

In a V1-mode capture, packets were mostly fixed-size blocks:

Observed Size Notes
1367 Main V1 telemetry-style packet size seen in the capture.
1347 Another V1-size block seen intermittently.

Important: the 12-byte PacketBase header used by V2 is not present in the same way for V1.

V2 Assumption V1 Reality
Byte 10 is packetType. Byte 10 is just part of the V1 telemetry block.
Byte 11 is packetVersion. Byte 11 is not a V2 packet version.
File names like type3_... identify timing packets. In V1 mode those labels are false if produced by the old capture script.
The app can dispatch to Type 0/1/2/3/4/7/8 parsers. The current app does not decode V1 blocks yet.

The jamesremuscat/pcars decoder is the best reference found so far for this V1 mode. Its packet fixtures are 1367, 1347, and 1028 bytes, and its header logic derives packet type from the low two bits of the third byte rather than byte offset 10.

The archived SMS forum post independently confirms this V1 structure and gives the full byte offsets for the three V1 packet types. It also explains the UDP menu rates:

Setting Approximate Rate
UDP 1 60/sec
UDP 2 50/sec
UDP 3 40/sec
UDP 4 30/sec
UDP 5 20/sec
UDP 6 15/sec
UDP 7 10/sec
UDP 8 5/sec
UDP 9 1/sec

The capture script at scripts/capture_udp.py now has an auto mode. It only labels packets as V2 typeN when the size/type/version combination looks like the V2 streamer. Otherwise it writes files as:

v1_seq<sequence>_size<size>_<timestamp>.bin

To force V1 capture labelling:

python scripts\capture_udp.py --mode v1

To force V2 capture labelling:

python scripts\capture_udp.py --mode v2

Recordings are replayable. The capture script writes a session folder containing:

File/Folder Meaning
metadata.json Recording metadata.
manifest.jsonl One JSON record per packet, including relative timestamp and packet file path.
packets/*.bin Raw UDP payloads.

Replay a recording into the Node app with:

python scripts\replay_udp.py captures\session_20260514_160000

Replay faster or loop:

python scripts\replay_udp.py captures\session_20260514_160000 --speed 4
python scripts\replay_udp.py captures\session_20260514_160000 --loop

The current Node app is built for V2 mode. Supporting V1 would need a separate parser for the V1 1367 byte shared-memory-style packet.

Captured Packet Types

From the capture taken from the beginning of a race, we saw:

Type Name Count Notes
0 Car Physics 1015 High frequency local/viewed car telemetry.
1 Race Definition 4 Track and event details.
2 Participants 12 Driver names and participant slots.
3 Timings 1015 Live timing/position data for all participants.
4 Game State 133 Session/weather state.
7 Time Stats 20 Lap/sector records.
8 Participant Vehicle Names 24 Vehicle names and class names.

Types 5 and 6 were not present. The original header comments say those are not currently sent.

Lowest Frequency Full-Race Capture

A later capture was taken from menu, through a full one-lap race, and back to menu with the game UDP data rate set to the lowest possible frequency. The app still received all packet types needed for the overview:

Type Name Count Observed Timing
0 Car Physics 105 About once per second during the race.
1 Race Definition 4 Sent around session setup and during race.
2 Participants 4 Sent during setup/participant changes.
3 Timings 105 About once per second during the race.
4 Game State 71 Sent before race, during race, and after returning to menu.
7 Time Stats 28 Sent during setup and when stats/lap records changed.
8 Participant Vehicle Names 8 Sent during setup as vehicle part and class part pairs.

Lifecycle observations from that capture:

Observation Meaning For The App
Type 4 started with gameState = 1, sessionState = 0, then moved to gameState = 2, sessionState = 5, then returned to gameState = 1, sessionState = 0. Game State can be used to detect menu/race transitions, though the current overview mostly relies on timing packets and race-start reset rules.
Type 2 and 8 arrived before regular Type 3 timing updates began. Capture must start before session/race load if we want names and cars.
Type 3 timing packets appeared about once per second at minimum frequency. Leaderboard still works, but position/split updates are lower resolution.
Early Type 3 rows had racePosition = 0, currentTime = -1, and raceState = 9. Ignore invalid timing values until race positions/times become valid.
Type 7 sometimes arrived with old-looking stats before the race and sometimes with an empty participant list. Do not blindly show Type 7 as current race stats at race start. The app keeps race-local lap stats from observed lap completions.
After the one-lap race finished, Type 3 rows showed currentLap = 2 with final race states. In a one-lap race, lap completion can be detected by the lap increment to 2.

This full-race capture confirms the overview can work at the lowest data rate, but low-frequency updates make sector split timing approximate because checkpoint arrival is only observed when the next UDP timing packet arrives.

Type 0: Car Physics

This is high-frequency telemetry for the viewed participant only.

Useful fields:

JSON Path Meaning
data.car.speedKph Viewed car speed in km/h.
data.car.rpm / data.car.maxRpm Engine revs.
data.car.gear Current gear decoded from packed gear byte.
data.input.* Raw throttle/brake/steering/clutch inputs.
data.tyres.* Tyre temps, wear, pressure, suspension, compound.
data.vectors.fullPosition Viewed car position.

This packet does not identify every racer; it is mainly useful for the local/viewed car telemetry panel.

Type 1: Race Definition

This packet contains track and event metadata.

Useful fields:

JSON Path Meaning
data.trackLocation Track location.
data.trackVariation Track variation/layout.
data.translatedTrackLocation Localized track location.
data.translatedTrackVariation Localized track variation.
data.trackLength Track length.
data.lapsTimeInEvent Lap count or timed-session duration encoding.

The overview uses this for the track name at the top of the page.

Type 2: Participants

This packet carries driver names. It is low-frequency and easiest to miss if capture starts after session load.

Useful fields:

JSON Path Meaning
data.participants[].slot Slot used by the app to line names up with timing rows.
data.participants[].localSlot Slot within this packet part, usually 0..15.
data.participants[].partialPacketIndex Packet part index from the UDP header.
data.participants[].name Driver name.
data.participants[].index MP participant index. In the capture this was 0 for all rows, so it was not useful for joining.

Important capture finding: partialPacketIndex was 1 even though this was the first/only participant-name packet. The parser normalizes this so the first packet maps to slots 0..15.

The overview joins names primarily by slot, not by index, because index can be 0 for every participant.

Type 3: Timings

This is the most important packet for the leaderboard. It is high-frequency and contains one row per participant.

Useful fields:

JSON Path Meaning
data.numParticipants Number of participants in the timing packet.
data.eventTimeRemaining Time remaining, or negative encoded lap/time state.
data.participants[].slot Timing row slot.
data.participants[].racePosition Position in race order.
data.participants[].active Whether the participant is active.
data.participants[].currentLap Current lap number.
data.participants[].sector Current sector.
data.participants[].sectorRaw Original packed sector byte. Low two bits are sector; upper bits are exposed for inspection.
data.participants[].sectorExtraPrecision sectorRaw >> 2. The UDP header describes these upper bits as extra x/z position precision.
data.participants[].currentTime Current lap time.
data.participants[].currentSectorTime Current sector time.
data.participants[].currentLapDistance Quantized lap distance/progress.
data.participants[].carIndex Vehicle index. This lines up with Type 8 vehicles[].index.
data.participants[].mpParticipantIndex MP participant index. In the capture this was 0 for every row, so it was not useful for joining.

The overview sorts rows by racePosition.

Rallycross Joker Notes

The Wildcrest Rallycross capture (session_20260515_012538) did not reveal a clean "Joker completed" field in the decoded UDP packets. Type 1 Race Definition identified the layout as Wildcrest Rallycross and Type 3 timing packets exposed normal lap, sector, distance, position, race-state, pit-state, and flag fields.

The Type 3 sSector byte had many raw values such as 0, 16, 32, 48, up to 242, but the low two bits still decoded to sector 0..2. This matches the original packet comment that sSector contains "sector + extra precision bits for x/z position", so the app now exposes sectorRaw and sectorExtraPrecision for future captures but does not treat those bits as Joker state.

Current conclusion: if Joker completion is present in UDP V2, it is not obvious in the parsed fields we have mapped so far. It may need to be inferred from position/distance/path, or it may not be sent.

Type 4: Game State

This packet contains session and weather state.

Useful fields:

JSON Path Meaning
data.gameStateRaw Original packed game/session-state byte.
data.gameState Game state enum value.
data.gameStateName Decoded game-state label when known.
data.sessionState Session state enum value.
data.sessionStateName Decoded session-state label when known.
data.ambientTemperature Air temperature.
data.trackTemperature Track temperature.
data.rainDensity Rain density.
data.snowDensity Snow density.
data.windSpeed Wind speed.

Observed session values:

Raw sessionState Label Evidence
0 Invalid Menu/front-end state in full-race capture.
1 Practice session_20260515_013811, free practice.
3 Qualifying session_20260515_014223, qualifying.
5 Race Race captures.

The overview uses this for the weather summary and session label.

Practice and Qualifying Capture Notes

The practice capture (session_20260515_013811) and qualifying capture (session_20260515_014223) used the same core packets as races: Type 0, 1, 2, 3, 4, 7, and 8. Both were at Willow Springs - Horse Thief Mile with six participants.

Key differences from race captures:

Observation Meaning For The App
Practice used sessionState = 1; qualifying used sessionState = 3. The parser now exposes sessionStateName, and the overview shows the session type.
lapsTimeInEvent = 32770. Top bit is set, so this is a timed-session encoding rather than a lap-count race.
raceState = 9 appeared while cars were staged with invalid timing, then changed to 2 when valid laps began. Same invalid timing guard still applies outside race sessions.
raceState = 10 appeared for the local car after an invalid lap. This continues to decode as racing state 2 plus invalid-lap bit 0x08.
raceState = 5 appeared in practice for an AI after it returned to garage with a positive lap time. Do not treat state code 5 as DNF unless timing is negative; the current app keeps that guard.
Qualifying started with highestFlag = 1 for all cars, then cleared quickly. This looks like a session-start/staging flag, but is not named yet.
Pit values included 4 -> 5 -> 3 -> 0 while leaving the garage. 5 is now labelled Garage exit; normal pit stops still use 1 -> 2 -> 3 -> 0.

Type 7: Time Stats

This packet contains lap and sector record data for participants. It is lower-frequency than Type 3.

Useful fields:

JSON Path Meaning
data.participants[].slot Participant slot.
data.participants[].fastestLapTime Fastest lap time reported by the game.
data.participants[].lastLapTime Last lap time reported by the game.
data.participants[].lastSectorTime Last completed sector time.
data.participants[].fastestSectors Fastest sector 1/2/3 times.
data.participants[].mpParticipantIndex MP participant index.

The overview currently maintains race-local Last Lap and Best Lap from observed lap completions so those fields start empty at race start and do not leak stale stats from a previous race.

Type 8: Participant Vehicle Names

This packet arrives in multiple parts:

Packet Part Content
part1-of-2 Participant vehicle names.
part2-of-2 Vehicle class names.

Useful fields:

JSON Path Meaning
data.vehicles[].index Vehicle index. This lines up with Type 3 participants[].carIndex.
data.vehicles[].name Single vehicle name string, usually make/model.
data.vehicles[].class Vehicle class index.
data.classes[].classIndex Class index.
data.classes[].name Class name, such as GT3.

Important capture finding: vehicle index matches timing carIndex, not participant id. The overview joins car model as:

timings.participants[].carIndex -> vehicleNames.vehicles[].index

The game provides car make/model as one string. It does not provide separate make and model fields.

How The Overview Joins Data

The server-side leaderboard is built in src/leaderboard.ts.

Overview Field Source
Position Type 3 racePosition
Position change Server compares the latest Type 3 racePosition against the last seen position for the same slot.
Driver Type 2 participant name by timing slot
Nationality Type 2 nationality if non-zero, otherwise a local name-based override for known AI drivers from the captured race.
Car Type 8 vehicle name by Type 3 carIndex
Lap/Sector/Current Type 3 timing row
Last/Best Lap Server race-local cache from observed lap completions
Sector Split Server checkpoint cache
Status Server-derived status from Type 3 raceState, timing validity, and active/pit/flag fields.

Position change indicators are emitted as leaderboard.rows[].positionChange:

{
  "direction": "up",
  "places": 1,
  "previousPosition": 5,
  "currentPosition": 4,
  "changedAt": "2026-05-14T05:50:00.000Z"
}

The overview renders up as a green upward arrow and down as a red downward arrow. The indicator persists until that driver changes position again, and it is cleared when the app detects a new race start.

Status Notes

The UDP timing packet does not expose a clean dnf: true field. The overview derives a readable status from Type 3 timing fields:

Derived Status Observed Signal
DNF Low 3 bits of raceState are 5 and currentTime < 0. In captures, INS3RT used raw raceState = 13 while retired/DNF.
DSQ Low 3 bits of raceState are 4. In the unserved drive-through capture, INS3RT switched to raw raceState = 4 at 180.875s, then raw 12 (4 + invalidated bit) from 183.875s.
Finished Low 3 bits of raceState are 3.
Waiting Low 3 bits of raceState are 1, racePosition = 0, or invalid negative timing before a valid race state.
Drive-through pitSchedule = 2, decoded from (pitModeSchedule >> 3) & 0x03. In captures, pending drive-through used raw 80.
Pit entry pitModeSchedule = 1. In the pit-stop capture, this began around 79.812s.
Pit stop pitModeSchedule = 2. In the pit-stop capture, this began around 97.218s while the car was stopped.
Pit exit pitModeSchedule = 3. In the pit-stop capture, this began around 105.500s and cleared around 121.625s.
Invalid lap Bit 0x08 is set in raceState. In the contact-penalty capture, INS3RT used raw raceState = 10, which decodes to state 2 plus invalidated-lap bit 8.
Blue flag highestFlag = 2. In session_20260515_012538, this appeared only on the lapped/local car during the user-observed blue-flag period.
Checkered flag highestFlag = 11 (colour = 3, reason = 1). Observed on the pole/leader row, but this appears to represent a race flag rather than an individual driver flag.
Inactive active = false, if it ever appears.
Racing Fallback for valid active timing rows.

The leaderboard keeps the original raceState value and also exposes raceStateCode = raceState & 0x07 and invalidatedLap = Boolean(raceState & 0x08). The UDP packets do not currently expose a precise penalty reason string, so a game message like "contact penalty" is represented by the decoded invalidated-lap signal plus the raw flag fields.

The original pitModeSchedule byte is also preserved and decoded as:

{
  "raw": 3,
  "pitMode": 3,
  "pitSchedule": 0,
  "phase": "Pit exit",
  "penalty": null
}

Confirmed values so far:

Raw value Meaning
0 No active pit state.
1 Pit entry.
2 Pit stop/service.
3 Pit exit.
84 Disqualified/garage-like end state after unserved drive-throughs: pitMode = 4, pitSchedule = 2, upper bits 2.
5 Garage exit / leaving garage staging, observed before cars joined practice/qualifying.
80 Pending drive-through penalty: pitMode = 0, pitSchedule = 2, upper bits 2.
81 Serving drive-through at pit entry: pitMode = 1, pitSchedule = 2, upper bits 2.

The unsafe pit-exit warning did not create a new highestFlag, raceState, or pitModeSchedule value in the inspected capture. The viewed-car physics packet briefly changed car.flags from 2 to 18 shortly after pit exit, but that same value appears in non-pit captures too, so it is kept as an observed raw flag rather than labelled as unsafe pit exit.

In the drive-through serving capture, INS3RT received a pending drive-through at 230.484s (pitModeSchedule = 80), served it on the next pass at 336.094s (pitModeSchedule = 81 briefly), then the schedule cleared to normal pit exit (3) from 336.219s. A later normal pit stop used 1 -> 2 -> 3, then the second drive-through penalty appeared as raw 80 again at 501.953s. This suggests UDP exposes the penalty type, but not the UI reason such as illegal overtake, track limits, or unsafe pit exit.

In the unserved drive-through capture, two pending drive-through penalties still appeared as the same raw 80; UDP did not expose a separate "second penalty" count. The disqualification showed as:

Time Observed values
180.875s raceState = 4, highestFlag = 10, pitModeSchedule = 80, position = 4.
183.875s raceState = 12, highestFlag = 11, pitModeSchedule = 84, currentTime = -1.

This confirms raceState & 0x07 = 4 as disqualified. highestFlag = 10 appeared during the black-flag/DSQ sequence. highestFlag = 11 was later identified as a checkered race flag: it may be reported on the pole/leader row even though it is not an individual driver penalty. highestFlag = 2 was observed during a blue-flag sequence. Another capture contained highestFlag = 51 (colour = 3, reason = 6) on several AI cars, but that value is still unmapped.

Nationality Notes

The UDP participant packet includes participants[].nationality, but in the inspected capture every value was 0, even though the game UI showed flags for AI drivers.

To make the overview useful for this AI roster, the server has a small name-based override table in src/nationalities.ts. The leaderboard row exposes this as:

{
  "nationality": {
    "code": "DEU",
    "country": "Germany",
    "source": "override"
  }
}

If a future UDP packet provides a non-zero nationality value, the app will expose it with source: "udp". We do not currently have the game nationality enum table, so non-zero UDP values are shown as numeric codes until that table is added.

Sector Split Logic

Splits are calculated server-side and only update when a car crosses a sector or lap checkpoint.

The backend now exposes two separate split sources:

Field Source Used For
checkpointSplit Type 3 checkpoint arrival timing Race-order / car-ahead comparison.
sectorTimeSplit Type 7.lastSectorTime and Type 7.fastestSectors[] Driver's own best sector comparison.

The backend keeps both split sources available whenever the needed UDP data exists. The overview chooses what to display from the current session type: practice/qualifying prefer sectorTimeSplit, while race sessions prefer checkpointSplit. If the preferred source is not available yet, the frontend falls back to the other one.

Race session rules for checkpointSplit:

Rules:

Driver Comparison
P1 Compared to their own previous run through the same checkpoint.
P2+ Compared to the car directly ahead at the same checkpoint.

For P2+, the split uses checkpoint arrival time, so cars behind should show negative values when they arrive after the car ahead.

Practice and qualifying rules for sectorTimeSplit:

Driver Comparison
All drivers Latest completed sector time from Type 7.lastSectorTime compared to that driver's own best sector from Type 7.fastestSectors[].

For practice/qualifying, negative means the driver improved versus their own best sector, positive means they were slower, and 0 usually means that sector became their new personal best.

Type 7 Sector-Time Reliability

Several captures were checked by comparing Type 3 sector/lap transitions against Type 7 lastSectorTime updates:

Capture Session Sector Completions Checked Type 7 Match
session_20260515_012538 Race / rallycross 36 36 / 36
session_20260515_013811 Practice 64 64 / 64
session_20260515_014223 Qualifying 36 36 / 36
session_20260515_005752 Race 11 11 / 11
session_20260515_010100 Race with pit/penalties 51 47 / 51
session_20260515_011453 Race / DSQ 19 19 / 19

When Type 7 updated, it arrived one packet after the Type 3 transition and matched the completed sector time from Type 3. The four misses in session_20260515_010100 were very early invalid-lap transitions from raw raceState = 10 back to 2, with sector times around 2-3s, so they do not look like normal completed racing sectors.

This makes Type 7.lastSectorTime a good candidate for more accurate sector split calculations. The parser keeps rows with positive lastSectorTime even before a valid lap/best-lap exists, because early sector updates can arrive while lap times are still invalid.

Lap Split Logic

The overview also calculates lap-time splits server-side:

Field Calculation
latestLapSplit Driver's latest completed lap time minus the leader's latest completed lap time.
bestLapSplit Driver's best completed lap time minus the leader's best completed lap time.

These stay empty for the leader and until both the driver and leader have valid completed lap times. Unlike the checkpoint split, these are pure lap-time comparisons: positive means this driver's lap was slower than the leader's matching lap metric, and negative means it was faster.

The app prefers Type 7 lap stats for these values because fastestLapTime and lastLapTime are the game's own recorded lap times. This is more accurate than sampling Type 3 currentTime around the lap transition. To avoid showing stale stats at race start, Type 7 lap stats are only accepted once the matching timing row has valid running timing and is past the opening moments of lap 1, or once the driver is on lap 2+.

Capture Notes

To capture useful name and vehicle packets, start capturing before entering/loading the race session. If capture starts after the session is already running, Type 2 and Type 8 may be missed because they are low-frequency/logarithmic packets.

The captures inspected here included Type 2 and Type 8 only when capture started from the beginning of race/session load.