Added intial plugin

This commit is contained in:
2026-05-19 15:38:16 +10:00
commit 47178f0415
21 changed files with 5701 additions and 0 deletions

718
docs/udp-packets.md Normal file
View File

@@ -0,0 +1,718 @@
# 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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```powershell
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 | (sequence << 2)`. |
| V1 race flags | `raceStateFlags = raceState | lapInvalidated<<3 | antiLockActive<<4 | boostActive<<5`. |
| V1 highest flag | `highestFlag = flagColour | (flagReason << 4)`. |
| V1 pit info | `pitModeSchedule = pitMode | (pitSchedule << 4)`. |
| 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:
```text
v1_seq<sequence>_size<size>_<timestamp>.bin
```
To force V1 capture labelling:
```powershell
python scripts\capture_udp.py --mode v1
```
To force V2 capture labelling:
```powershell
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:
```powershell
python scripts\replay_udp.py captures\session_20260514_160000
```
Replay faster or loop:
```powershell
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:
```text
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`:
```json
{
"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:
```json
{
"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:
```json
{
"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.

View File

@@ -0,0 +1,746 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://local.projectcars-data/schemas/udp-packets.schema.json",
"title": "Project CARS UDP Parsed Packet State",
"description": "Readable JSON Schema for the parsed JSON emitted by this app from Project CARS UDP packets. The game sends binary UDP packets; this schema describes the decoded JSON shape exposed by /api/state and SSE events.",
"type": "object",
"additionalProperties": false,
"properties": {
"latest": {
"type": "object",
"description": "Latest parsed packet by packet type number.",
"additionalProperties": {
"$ref": "#/$defs/parsedPacket"
},
"properties": {
"0": { "$ref": "#/$defs/carPhysicsPacket" },
"1": { "$ref": "#/$defs/raceDefinitionPacket" },
"2": { "$ref": "#/$defs/participantsPacket" },
"3": { "$ref": "#/$defs/timingsPacket" },
"4": { "$ref": "#/$defs/gameStatePacket" },
"5": { "$ref": "#/$defs/weatherStatePacket" },
"6": { "$ref": "#/$defs/vehicleNamesPacket" },
"7": { "$ref": "#/$defs/timeStatsPacket" },
"8": { "$ref": "#/$defs/participantVehicleNamesPacket" }
}
},
"recent": {
"type": "array",
"description": "Most recent raw parsed packets, newest first.",
"items": { "$ref": "#/$defs/parsedPacket" }
},
"leaderboard": {
"$ref": "#/$defs/leaderboard"
}
},
"$defs": {
"parsedPacket": {
"type": "object",
"additionalProperties": false,
"required": ["receivedAt", "source", "base", "name", "size", "data"],
"properties": {
"receivedAt": { "type": "string", "format": "date-time" },
"source": { "type": "string", "examples": ["127.0.0.1:5606"] },
"base": { "$ref": "#/$defs/packetBase" },
"name": { "type": "string" },
"size": { "type": "integer", "minimum": 0 },
"data": { "type": "object" }
}
},
"packetBase": {
"type": "object",
"additionalProperties": false,
"required": [
"packetNumber",
"categoryPacketNumber",
"partialPacketIndex",
"partialPacketNumber",
"packetType",
"packetVersion"
],
"properties": {
"packetNumber": { "type": "integer", "minimum": 0 },
"categoryPacketNumber": { "type": "integer", "minimum": 0 },
"partialPacketIndex": { "type": "integer", "minimum": 0, "maximum": 255 },
"partialPacketNumber": { "type": "integer", "minimum": 0, "maximum": 255 },
"packetType": {
"type": "integer",
"enum": [0, 1, 2, 3, 4, 5, 6, 7, 8],
"description": "0 CarPhysics, 1 RaceDefinition, 2 Participants, 3 Timings, 4 GameState, 5 WeatherState, 6 VehicleNames, 7 TimeStats, 8 ParticipantVehicleNames"
},
"packetVersion": { "type": "integer", "minimum": 0, "maximum": 255 }
}
},
"number3": {
"type": "array",
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"minItems": 3,
"maxItems": 3
},
"number4": {
"type": "array",
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }, { "type": "number" }],
"minItems": 4,
"maxItems": 4
},
"integer3": {
"type": "array",
"prefixItems": [{ "type": "integer" }, { "type": "integer" }, { "type": "integer" }],
"minItems": 3,
"maxItems": 3
},
"integer4": {
"type": "array",
"prefixItems": [{ "type": "integer" }, { "type": "integer" }, { "type": "integer" }, { "type": "integer" }],
"minItems": 4,
"maxItems": 4
},
"carPhysicsPacket": {
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 0 } } }
]
},
"data": { "$ref": "#/$defs/carPhysicsData" }
}
}
]
},
"carPhysicsData": {
"type": "object",
"additionalProperties": false,
"properties": {
"viewedParticipantIndex": { "type": "integer" },
"input": {
"type": "object",
"additionalProperties": false,
"properties": {
"throttle": { "type": "integer" },
"brake": { "type": "integer" },
"steering": { "type": "integer" },
"clutch": { "type": "integer" }
}
},
"car": {
"type": "object",
"additionalProperties": false,
"properties": {
"flags": { "type": "integer" },
"oilTempCelsius": { "type": "integer" },
"oilPressureKPa": { "type": "integer" },
"waterTempCelsius": { "type": "integer" },
"waterPressureKpa": { "type": "integer" },
"fuelPressureKpa": { "type": "integer" },
"fuelCapacity": { "type": "integer" },
"brake": { "type": "integer" },
"throttle": { "type": "integer" },
"clutch": { "type": "integer" },
"fuelLevel": { "type": "number" },
"speedMps": { "type": "number" },
"speedKph": { "type": "number" },
"rpm": { "type": "integer" },
"maxRpm": { "type": "integer" },
"steering": { "type": "integer" },
"gear": { "type": "string" },
"numGears": { "type": "integer" },
"boostAmount": { "type": "integer" },
"crashState": { "type": "integer" },
"odometerKM": { "type": "number" },
"brakeBias": { "type": "integer" }
}
},
"vectors": {
"type": "object",
"additionalProperties": false,
"properties": {
"orientation": { "$ref": "#/$defs/number3" },
"localVelocity": { "$ref": "#/$defs/number3" },
"worldVelocity": { "$ref": "#/$defs/number3" },
"angularVelocity": { "$ref": "#/$defs/number3" },
"localAcceleration": { "$ref": "#/$defs/number3" },
"worldAcceleration": { "$ref": "#/$defs/number3" },
"extentsCentre": { "$ref": "#/$defs/number3" },
"fullPosition": { "$ref": "#/$defs/number3" }
}
},
"tyres": {
"type": "object",
"additionalProperties": false,
"properties": {
"flags": { "$ref": "#/$defs/integer4" },
"terrain": { "$ref": "#/$defs/integer4" },
"y": { "$ref": "#/$defs/number4" },
"rps": { "$ref": "#/$defs/number4" },
"temp": { "$ref": "#/$defs/integer4" },
"heightAboveGround": { "$ref": "#/$defs/number4" },
"wear": { "$ref": "#/$defs/integer4" },
"brakeTempCelsius": { "$ref": "#/$defs/integer4" },
"treadTemp": { "$ref": "#/$defs/integer4" },
"layerTemp": { "$ref": "#/$defs/integer4" },
"carcassTemp": { "$ref": "#/$defs/integer4" },
"rimTemp": { "$ref": "#/$defs/integer4" },
"internalAirTemp": { "$ref": "#/$defs/integer4" },
"tempLeft": { "$ref": "#/$defs/integer4" },
"tempCenter": { "$ref": "#/$defs/integer4" },
"tempRight": { "$ref": "#/$defs/integer4" },
"wheelLocalPositionY": { "$ref": "#/$defs/number4" },
"rideHeight": { "$ref": "#/$defs/number4" },
"suspensionTravel": { "$ref": "#/$defs/number4" },
"suspensionVelocity": { "$ref": "#/$defs/number4" },
"suspensionRideHeight": { "$ref": "#/$defs/integer4" },
"airPressure": { "$ref": "#/$defs/integer4" },
"compound": {
"type": "array",
"items": { "type": "string" },
"minItems": 4,
"maxItems": 4
}
}
},
"engine": {
"type": "object",
"additionalProperties": false,
"properties": {
"speed": { "type": "number" },
"torque": { "type": "number" },
"turboBoostPressure": { "type": "number" },
"wings": {
"type": "array",
"items": { "type": "integer" },
"minItems": 2,
"maxItems": 2
},
"handBrake": { "type": "integer" }
}
},
"damage": {
"type": "object",
"additionalProperties": false,
"properties": {
"aero": { "type": "integer" },
"engine": { "type": "integer" },
"brake": { "$ref": "#/$defs/integer4" },
"suspension": { "$ref": "#/$defs/integer4" }
}
},
"hardware": {
"type": "object",
"additionalProperties": false,
"properties": {
"joyPad0": { "type": "integer" },
"dPad": { "type": "integer" }
}
}
}
},
"raceDefinitionPacket": {
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 1 } } }
]
},
"data": { "$ref": "#/$defs/raceDefinitionData" }
}
}
]
},
"raceDefinitionData": {
"type": "object",
"additionalProperties": false,
"properties": {
"worldFastestLapTime": { "type": "number" },
"personalFastestLapTime": { "type": "number" },
"personalFastestSectors": { "$ref": "#/$defs/number3" },
"worldFastestSectors": { "$ref": "#/$defs/number3" },
"trackLength": { "type": "number" },
"trackLocation": { "type": "string" },
"trackVariation": { "type": "string" },
"translatedTrackLocation": { "type": "string" },
"translatedTrackVariation": { "type": "string" },
"lapsTimeInEvent": { "type": "integer" },
"enforcedPitStopLap": { "type": "integer" }
}
},
"participantsPacket": {
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 2 } } }
]
},
"data": { "$ref": "#/$defs/participantsData" }
}
}
]
},
"participantsData": {
"type": "object",
"additionalProperties": false,
"properties": {
"participantsChangedTimestamp": { "type": "integer" },
"participants": {
"type": "array",
"items": { "$ref": "#/$defs/participantName" }
}
}
},
"participantName": {
"type": "object",
"additionalProperties": false,
"properties": {
"slot": { "type": "integer" },
"localSlot": { "type": "integer" },
"partialPacketIndex": { "type": "integer" },
"name": { "type": "string" },
"nationality": { "type": "integer" },
"index": { "type": "integer" }
}
},
"timingsPacket": {
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 3 } } }
]
},
"data": { "$ref": "#/$defs/timingsData" }
}
}
]
},
"timingsData": {
"type": "object",
"additionalProperties": false,
"properties": {
"numParticipants": { "type": "integer" },
"participantsChangedTimestamp": { "type": "integer" },
"eventTimeRemaining": { "type": "number" },
"splitTimeAhead": { "type": "number" },
"splitTimeBehind": { "type": "number" },
"splitTime": { "type": "number" },
"localParticipantIndex": { "type": "integer" },
"participants": {
"type": "array",
"items": { "$ref": "#/$defs/timingParticipant" }
}
}
},
"timingParticipant": {
"type": "object",
"additionalProperties": false,
"properties": {
"slot": { "type": "integer" },
"worldPosition": { "$ref": "#/$defs/integer3" },
"orientation": { "$ref": "#/$defs/integer3" },
"currentLapDistance": { "type": "integer" },
"racePosition": { "type": "integer" },
"active": { "type": "boolean" },
"sector": { "type": "integer" },
"sectorRaw": { "type": "integer" },
"sectorExtraPrecision": { "type": "integer" },
"highestFlag": { "type": "integer" },
"pitModeSchedule": { "type": "integer" },
"carIndex": { "type": "integer" },
"raceState": { "type": "integer" },
"currentLap": { "type": "integer" },
"currentTime": { "type": "number" },
"currentSectorTime": { "type": "number" },
"mpParticipantIndex": { "type": "integer" }
}
},
"gameStatePacket": {
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 4 } } }
]
},
"data": { "$ref": "#/$defs/gameStateData" }
}
}
]
},
"gameStateData": {
"type": "object",
"additionalProperties": false,
"properties": {
"buildVersionNumber": { "type": "integer" },
"gameStateRaw": { "type": "integer" },
"gameState": { "type": "integer" },
"gameStateName": { "type": ["string", "null"] },
"sessionState": { "type": "integer" },
"sessionStateName": { "type": ["string", "null"] },
"ambientTemperature": { "type": "integer" },
"trackTemperature": { "type": "integer" },
"rainDensity": { "type": "integer" },
"snowDensity": { "type": "integer" },
"windSpeed": { "type": "integer" },
"windDirectionX": { "type": "integer" },
"windDirectionY": { "type": "integer" }
}
},
"weatherStatePacket": {
"description": "Packet type 5 is recognized by the app but currently emitted with the generic unimplemented parser payload.",
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 5 } } }
]
},
"data": { "$ref": "#/$defs/unimplementedPacketData" }
}
}
]
},
"vehicleNamesPacket": {
"description": "Packet type 6 is recognized by the app but currently emitted with the generic unimplemented parser payload. Participant vehicle/class names are decoded from packet type 8.",
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 6 } } }
]
},
"data": { "$ref": "#/$defs/unimplementedPacketData" }
}
}
]
},
"unimplementedPacketData": {
"type": "object",
"additionalProperties": false,
"required": ["rawPacketType", "message"],
"properties": {
"rawPacketType": { "type": "integer", "enum": [5, 6] },
"message": { "type": "string" }
}
},
"timeStatsPacket": {
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 7 } } }
]
},
"data": { "$ref": "#/$defs/timeStatsData" }
}
}
]
},
"timeStatsData": {
"type": "object",
"additionalProperties": false,
"properties": {
"participantsChangedTimestamp": { "type": "integer" },
"participants": {
"type": "array",
"items": { "$ref": "#/$defs/timeStatsParticipant" }
}
}
},
"timeStatsParticipant": {
"type": "object",
"additionalProperties": false,
"properties": {
"slot": { "type": "integer" },
"fastestLapTime": { "type": "number" },
"lastLapTime": { "type": "number" },
"lastSectorTime": { "type": "number" },
"fastestSectors": { "$ref": "#/$defs/number3" },
"participantOnlineRep": { "type": "integer" },
"mpParticipantIndex": { "type": "integer" }
}
},
"participantVehicleNamesPacket": {
"allOf": [
{ "$ref": "#/$defs/parsedPacket" },
{
"properties": {
"base": {
"allOf": [
{ "$ref": "#/$defs/packetBase" },
{ "properties": { "packetType": { "const": 8 } } }
]
},
"data": { "$ref": "#/$defs/participantVehicleNamesData" }
}
}
]
},
"participantVehicleNamesData": {
"type": "object",
"additionalProperties": false,
"properties": {
"vehicles": {
"type": "array",
"items": { "$ref": "#/$defs/vehicleInfo" }
},
"classes": {
"type": "array",
"items": { "$ref": "#/$defs/classInfo" }
}
}
},
"vehicleInfo": {
"type": "object",
"additionalProperties": false,
"properties": {
"index": { "type": "integer" },
"class": { "type": "integer" },
"name": { "type": "string" }
}
},
"classInfo": {
"type": "object",
"additionalProperties": false,
"properties": {
"classIndex": { "type": "integer" },
"name": { "type": "string" }
}
},
"leaderboard": {
"type": "object",
"additionalProperties": false,
"properties": {
"rows": {
"type": "array",
"items": { "$ref": "#/$defs/leaderboardRow" }
},
"note": { "type": "string" },
"session": {
"type": "object",
"additionalProperties": false,
"properties": {
"trackName": { "type": "string" },
"sessionType": { "type": "string" },
"timeLeft": { "type": ["number", "null"] },
"weather": { "type": "string" },
"lastUpdate": { "type": ["string", "null"], "format": "date-time" }
}
}
}
},
"stateEvent": {
"type": "object",
"additionalProperties": false,
"required": ["latest", "recent", "leaderboard"],
"properties": {
"latest": {
"type": "object",
"description": "Latest parsed packet by packet type number.",
"additionalProperties": {
"$ref": "#/$defs/parsedPacket"
}
},
"recent": {
"type": "array",
"items": { "$ref": "#/$defs/parsedPacket" }
},
"leaderboard": { "$ref": "#/$defs/leaderboard" }
}
},
"packetEvent": {
"type": "object",
"additionalProperties": false,
"required": ["packet", "state", "leaderboard"],
"properties": {
"packet": { "$ref": "#/$defs/parsedPacket" },
"state": {
"type": "object",
"additionalProperties": false,
"required": ["latest", "recent"],
"properties": {
"latest": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/parsedPacket"
}
},
"recent": {
"type": "array",
"items": { "$ref": "#/$defs/parsedPacket" }
}
}
},
"leaderboard": { "$ref": "#/$defs/leaderboard" }
}
},
"sharedMemoryIngestRequest": {
"type": "object",
"additionalProperties": true,
"required": ["packets"],
"properties": {
"source": { "type": "string", "examples": ["shared-memory"] },
"sequence": { "type": "integer", "minimum": 0 },
"capturedAt": { "type": "string", "format": "date-time" },
"packets": {
"type": "array",
"items": { "$ref": "#/$defs/parsedPacket" }
}
}
},
"ingestAccepted": {
"type": "object",
"additionalProperties": false,
"required": ["ok", "packets"],
"properties": {
"ok": { "type": "boolean", "const": true },
"packets": { "type": "integer", "minimum": 0 }
}
},
"errorResponse": {
"type": "object",
"additionalProperties": false,
"required": ["ok", "error"],
"properties": {
"ok": { "type": "boolean", "const": false },
"error": { "type": "string" }
}
},
"leaderboardRow": {
"type": "object",
"additionalProperties": true,
"properties": {
"id": { "type": ["integer", "string"] },
"slot": { "type": ["integer", "string"] },
"sortPosition": { "type": "number" },
"position": { "type": ["integer", "null"] },
"positionChange": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"direction": { "type": "string", "enum": ["up", "down"] },
"places": { "type": "integer", "minimum": 1 },
"previousPosition": { "type": "integer" },
"currentPosition": { "type": "integer" },
"changedAt": { "type": "string", "format": "date-time" }
}
},
"active": { "type": ["boolean", "null"] },
"status": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"code": {
"type": "string",
"enum": ["unknown", "dnf", "dsq", "finished", "waiting", "drive_through", "pit_entry", "pit_stop", "pit_exit", "garage", "garage_exit", "invalidated", "blue_flag", "inactive", "racing"]
},
"label": { "type": "string" },
"tone": { "type": "string", "enum": ["good", "warn"] }
}
},
"driver": { "type": "string" },
"nationality": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"code": { "type": "string" },
"country": { "type": "string" },
"source": { "type": "string", "enum": ["udp", "override"] }
}
},
"vehicle": { "type": ["string", "null"] },
"vehicleShortName": { "type": ["string", "null"] },
"lap": { "type": ["integer", "null"] },
"sector": { "type": ["integer", "null"] },
"sectorRaw": { "type": ["integer", "null"] },
"sectorExtraPrecision": { "type": ["integer", "null"] },
"currentTime": { "type": ["number", "null"] },
"currentSectorTime": { "type": ["number", "null"] },
"checkpointSplit": {
"$ref": "#/$defs/sectorSplit"
},
"sectorTimeSplit": { "$ref": "#/$defs/sectorSplit" },
"lastLapTime": { "type": ["number", "null"] },
"latestLapSplit": { "$ref": "#/$defs/lapSplit" },
"fastestLapTime": { "type": ["number", "null"] },
"bestLapSplit": { "$ref": "#/$defs/lapSplit" },
"fastestSectors": { "$ref": "#/$defs/number3" },
"pitModeSchedule": { "type": ["integer", "null"] },
"pit": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"raw": { "type": "integer" },
"pitMode": { "type": "integer" },
"pitSchedule": { "type": "integer" },
"phase": { "type": ["string", "null"], "enum": ["Pit entry", "Pit stop", "Pit exit", "Serving drive-through", "Garage", "Garage exit", null] },
"penalty": { "type": ["string", "null"], "enum": ["drive-through", null] }
}
},
"highestFlag": { "type": ["integer", "null"] },
"flag": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"raw": { "type": "integer" },
"colour": { "type": "integer" },
"reason": { "type": "integer" },
"observed": { "type": ["string", "null"], "enum": ["blue", "black", "checkered", null] }
}
},
"raceState": { "type": ["integer", "null"] },
"raceStateCode": { "type": ["integer", "null"] },
"invalidatedLap": { "type": ["boolean", "null"] },
"carIndex": { "type": ["integer", "null"] },
"nationalityRaw": { "type": ["integer", "null"] }
}
},
"lapSplit": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"comparedTo": { "type": "string" },
"time": { "type": "number" },
"referenceTime": { "type": "number" },
"delta": { "type": "number" }
}
},
"sectorSplit": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"source": { "type": ["string", "null"], "enum": ["checkpoint", "timeStats", null] },
"basis": { "type": ["string", "null"], "enum": ["raceOrder", "personalBestSector", null] },
"checkpoint": { "type": "string" },
"comparedTo": { "type": ["string", "null"] },
"time": { "type": "number" },
"referenceTime": { "type": ["number", "null"] },
"delta": { "type": ["number", "null"] }
}
}
}
}