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.