Added intial plugin
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Runtime captures and logs
|
||||
captures/
|
||||
*.log
|
||||
*.err
|
||||
|
||||
# OS/editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Project CARS UDP Telemetry Viewer
|
||||
|
||||
Simple TypeScript/Node app that listens for Project CARS UDP telemetry on port `5606` and displays the latest packet data in a browser table UI.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
In Project CARS, enable UDP telemetry/streaming and point it at this computer. The app binds UDP on `0.0.0.0:5606`.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` starts the TypeScript server directly with `tsx`.
|
||||
- `npm run build` compiles TypeScript into `dist/`.
|
||||
- `npm start` runs the compiled server.
|
||||
|
||||
## Notes
|
||||
|
||||
The parser covers the packet types from the supplied header: car physics, race definition, participants, timings, game state, time stats, and participant vehicle/class names. The frontend focuses on the most useful live values, while the server keeps the full parsed payload available through `/api/state`.
|
||||
|
||||
## Recording and Replay
|
||||
|
||||
Stop the Node app before capturing because only one process can bind UDP `5606`.
|
||||
|
||||
```powershell
|
||||
python scripts\capture_udp.py --mode auto
|
||||
```
|
||||
|
||||
This creates a replayable session folder like `captures/session_20260514_160000` containing raw packet files and `manifest.jsonl`.
|
||||
|
||||
Replay into the Node app:
|
||||
|
||||
```powershell
|
||||
npm.cmd run dev
|
||||
python scripts\replay_udp.py captures\session_20260514_160000
|
||||
```
|
||||
|
||||
Useful replay options:
|
||||
|
||||
```powershell
|
||||
python scripts\replay_udp.py captures\session_20260514_160000 --speed 4
|
||||
python scripts\replay_udp.py captures\session_20260514_160000 --loop
|
||||
python scripts\replay_udp.py captures\session_20260514_160000 --types 1,2,3,4,7,8
|
||||
```
|
||||
718
docs/udp-packets.md
Normal file
718
docs/udp-packets.md
Normal 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.
|
||||
746
docs/udp-packets.schema.json
Normal file
746
docs/udp-packets.schema.json
Normal 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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
560
package-lock.json
generated
Normal file
560
package-lock.json
generated
Normal file
@@ -0,0 +1,560 @@
|
||||
{
|
||||
"name": "projectcars-data",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "projectcars-data",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "projectcars-data",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2"
|
||||
}
|
||||
}
|
||||
273
public/app.js
Normal file
273
public/app.js
Normal file
@@ -0,0 +1,273 @@
|
||||
const state = {
|
||||
latest: {},
|
||||
recent: [],
|
||||
packetCount: 0
|
||||
};
|
||||
|
||||
const packetCount = document.querySelector("#packet-count");
|
||||
const status = document.querySelector("#status");
|
||||
const latestBody = document.querySelector("#latest-body");
|
||||
const packetSections = document.querySelector("#packet-sections");
|
||||
const recentBody = document.querySelector("#recent-body");
|
||||
|
||||
const packetOrder = ["0", "1", "2", "3", "4", "7", "8", "5", "6"];
|
||||
const openSections = new Set(["0", "2", "3"]);
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function fmt(value) {
|
||||
if (value === undefined || value === null || value === "") return '<span class="empty">-</span>';
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function timeOnly(value) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function packetSort(a, b) {
|
||||
const aIndex = packetOrder.indexOf(String(a.base.packetType));
|
||||
const bIndex = packetOrder.indexOf(String(b.base.packetType));
|
||||
return (aIndex === -1 ? 99 : aIndex) - (bIndex === -1 ? 99 : bIndex);
|
||||
}
|
||||
|
||||
function flatten(value, prefix = "") {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return [[prefix, "[]"]];
|
||||
if (value.every((item) => item === null || typeof item !== "object")) {
|
||||
return [[prefix, value.join(", ")]];
|
||||
}
|
||||
return value.flatMap((item, index) => flatten(item, `${prefix}[${index}]`));
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) return [[prefix, "{}"]];
|
||||
return entries.flatMap(([key, nested]) => flatten(nested, prefix ? `${prefix}.${key}` : key));
|
||||
}
|
||||
|
||||
return [[prefix, value]];
|
||||
}
|
||||
|
||||
function renderKeyValueTable(data, emptyText = "No data received yet") {
|
||||
const rows = flatten(data).filter(([key]) => key);
|
||||
if (!rows.length) {
|
||||
return `<div class="table-wrap"><table><tbody><tr><td class="empty">${emptyText}</td></tr></tbody></table></div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map(([key, value]) => `
|
||||
<tr>
|
||||
<th>${escapeHtml(key)}</th>
|
||||
<td>${fmt(value)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function participantNameMap() {
|
||||
const packet = state.latest["2"];
|
||||
const participants = packet?.data?.participants ?? [];
|
||||
return new Map(participants.map((participant) => [participant.index, participant.name]));
|
||||
}
|
||||
|
||||
function vehicleNameMap() {
|
||||
const packet = state.latest["8"];
|
||||
const vehicles = packet?.data?.vehicles ?? [];
|
||||
return new Map(vehicles.map((vehicle) => [vehicle.index, vehicle.name]));
|
||||
}
|
||||
|
||||
function groupedItemTitle(groupName, item, index) {
|
||||
const participantNames = participantNameMap();
|
||||
const vehicleNames = vehicleNameMap();
|
||||
|
||||
if (groupName === "participants") {
|
||||
const participantIndex = item.mpParticipantIndex ?? item.index;
|
||||
const name = participantNames.get(participantIndex) || item.name || `Participant ${index + 1}`;
|
||||
const position = item.racePosition ? `P${item.racePosition} | ` : "";
|
||||
const vehicle = vehicleNames.get(participantIndex);
|
||||
return `${position}${name}${vehicle ? ` | ${vehicle}` : ""}`;
|
||||
}
|
||||
|
||||
if (groupName === "vehicles") {
|
||||
return item.name || `Vehicle ${index + 1}`;
|
||||
}
|
||||
|
||||
if (groupName === "classes") {
|
||||
return item.name || `Class ${index + 1}`;
|
||||
}
|
||||
|
||||
return `${groupName.slice(0, 1).toUpperCase()}${groupName.slice(1)} ${index + 1}`;
|
||||
}
|
||||
|
||||
function splitGroupedArrays(data) {
|
||||
const summary = {};
|
||||
const groups = [];
|
||||
|
||||
for (const [key, value] of Object.entries(data ?? {})) {
|
||||
if (Array.isArray(value) && value.some((item) => item && typeof item === "object")) {
|
||||
groups.push([key, value]);
|
||||
} else {
|
||||
summary[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { summary, groups };
|
||||
}
|
||||
|
||||
function renderGroupedArray(name, items) {
|
||||
if (!items.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `
|
||||
<section class="nested-groups">
|
||||
<h3>${escapeHtml(name)}</h3>
|
||||
${items.map((item, index) => `
|
||||
<details class="subpanel" ${index < 3 ? "open" : ""}>
|
||||
<summary>
|
||||
<strong>${escapeHtml(groupedItemTitle(name, item, index))}</strong>
|
||||
<span>#${index}</span>
|
||||
</summary>
|
||||
${renderKeyValueTable(item)}
|
||||
</details>
|
||||
`).join("")}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPacketContent(packet) {
|
||||
const { summary, groups } = splitGroupedArrays(packet.data);
|
||||
const packetSummary = {
|
||||
receivedAt: packet.receivedAt,
|
||||
source: packet.source,
|
||||
size: packet.size,
|
||||
base: packet.base,
|
||||
data: summary
|
||||
};
|
||||
|
||||
return `
|
||||
${renderKeyValueTable(packetSummary)}
|
||||
${groups.map(([name, items]) => renderGroupedArray(name, items)).join("")}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLatest() {
|
||||
const packets = Object.values(state.latest).sort(packetSort);
|
||||
latestBody.innerHTML = packets.length
|
||||
? packets.map((packet) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(packet.name)}</td>
|
||||
<td>${packet.base.packetNumber}</td>
|
||||
<td>${packet.base.packetVersion}</td>
|
||||
<td>${packet.size}</td>
|
||||
<td>${timeOnly(packet.receivedAt)}</td>
|
||||
<td>${escapeHtml(packet.source)}</td>
|
||||
</tr>
|
||||
`).join("")
|
||||
: `<tr><td colspan="6" class="empty">No packets received yet</td></tr>`;
|
||||
}
|
||||
|
||||
function renderPacketSections() {
|
||||
const packets = Object.values(state.latest).sort(packetSort);
|
||||
|
||||
packetSections.innerHTML = packets.length
|
||||
? packets.map((packet) => {
|
||||
const type = String(packet.base.packetType);
|
||||
return `
|
||||
<details class="panel" data-packet-type="${type}" ${openSections.has(type) ? "open" : ""}>
|
||||
<summary class="panel-header">
|
||||
<h2>${escapeHtml(packet.name)}</h2>
|
||||
<span>${packet.size} bytes | packet ${packet.base.packetNumber} | ${timeOnly(packet.receivedAt)}</span>
|
||||
</summary>
|
||||
${renderPacketContent(packet)}
|
||||
</details>
|
||||
`;
|
||||
}).join("")
|
||||
: `
|
||||
<details class="panel" open>
|
||||
<summary class="panel-header">
|
||||
<h2>Packet Data</h2>
|
||||
</summary>
|
||||
${renderKeyValueTable({}, "Waiting for packets from Project CARS")}
|
||||
</details>
|
||||
`;
|
||||
|
||||
packetSections.querySelectorAll("details[data-packet-type]").forEach((details) => {
|
||||
details.addEventListener("toggle", () => {
|
||||
const type = details.dataset.packetType;
|
||||
if (!type) return;
|
||||
if (details.open) {
|
||||
openSections.add(type);
|
||||
} else {
|
||||
openSections.delete(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecent() {
|
||||
recentBody.innerHTML = state.recent.length
|
||||
? state.recent.slice(0, 30).map((packet) => `
|
||||
<tr>
|
||||
<td>${timeOnly(packet.receivedAt)}</td>
|
||||
<td>${escapeHtml(packet.name)}</td>
|
||||
<td>${packet.base.packetNumber}</td>
|
||||
<td>${packet.size}</td>
|
||||
</tr>
|
||||
`).join("")
|
||||
: `<tr><td colspan="4" class="empty">No recent packets</td></tr>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
packetCount.textContent = String(state.packetCount);
|
||||
renderLatest();
|
||||
renderPacketSections();
|
||||
renderRecent();
|
||||
}
|
||||
|
||||
const events = new EventSource("/events");
|
||||
|
||||
events.addEventListener("state", (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
state.latest = payload.latest ?? {};
|
||||
state.recent = payload.recent ?? [];
|
||||
state.packetCount = state.recent.length;
|
||||
render();
|
||||
});
|
||||
|
||||
events.addEventListener("packet", (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
state.latest = payload.state.latest ?? {};
|
||||
state.recent = payload.state.recent ?? [];
|
||||
state.packetCount += 1;
|
||||
status.textContent = `Receiving packets. Last update ${timeOnly(payload.packet.receivedAt)}`;
|
||||
status.className = "good";
|
||||
render();
|
||||
});
|
||||
|
||||
events.onerror = () => {
|
||||
status.textContent = "Connection to local telemetry server lost";
|
||||
status.className = "warn";
|
||||
};
|
||||
|
||||
render();
|
||||
72
public/index.html
Normal file
72
public/index.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Project CARS UDP Telemetry</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Project CARS UDP Telemetry</h1>
|
||||
<p id="status">Waiting for UDP packets on port 5606</p>
|
||||
</div>
|
||||
<nav class="page-nav" aria-label="Pages">
|
||||
<a href="/">Packet Data</a>
|
||||
<a href="/overview.html">Race Overview</a>
|
||||
<a href="/swagger.html">API Docs</a>
|
||||
</nav>
|
||||
<div class="stat">
|
||||
<span>Packets</span>
|
||||
<strong id="packet-count">0</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<details class="panel" open>
|
||||
<summary class="panel-header">
|
||||
<h2>Latest Packets</h2>
|
||||
</summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Packet #</th>
|
||||
<th>Version</th>
|
||||
<th>Size</th>
|
||||
<th>Received</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="latest-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="packet-sections" class="packet-sections"></div>
|
||||
|
||||
<details class="panel">
|
||||
<summary class="panel-header">
|
||||
<h2>Recent Packets</h2>
|
||||
</summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Packet #</th>
|
||||
<th>Bytes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
440
public/openapi.json
Normal file
440
public/openapi.json
Normal file
@@ -0,0 +1,440 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Project CARS UDP Telemetry API",
|
||||
"version": "1.0.0",
|
||||
"description": "Local API for decoded Project CARS UDP telemetry. The game sends binary UDP packets; this app decodes them into JSON, serves current state snapshots, exposes a server-sent events stream, and calculates a race overview leaderboard."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:3000",
|
||||
"description": "Default local development server"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "State",
|
||||
"description": "Decoded packet state snapshots"
|
||||
},
|
||||
{
|
||||
"name": "Leaderboard",
|
||||
"description": "Server-calculated race overview"
|
||||
},
|
||||
{
|
||||
"name": "Events",
|
||||
"description": "Live telemetry stream. The current server implementation uses Server-Sent Events at /events; it does not expose a WebSocket upgrade endpoint."
|
||||
},
|
||||
{
|
||||
"name": "Ingest",
|
||||
"description": "Telemetry frame ingestion"
|
||||
},
|
||||
{
|
||||
"name": "Pages",
|
||||
"description": "Static browser pages served by the local app"
|
||||
},
|
||||
{
|
||||
"name": "Schemas",
|
||||
"description": "Reusable JSON schemas"
|
||||
}
|
||||
],
|
||||
"x-websocket-routes": [],
|
||||
"x-streaming-routes": [
|
||||
{
|
||||
"protocol": "sse",
|
||||
"path": "/events",
|
||||
"events": ["state", "packet", "leaderboard"]
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/": {
|
||||
"get": {
|
||||
"tags": ["Pages"],
|
||||
"summary": "Open packet viewer page",
|
||||
"description": "Serves the main packet inspection UI from public/index.html.",
|
||||
"operationId": "getPacketViewerPage",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML packet viewer page",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/overview.html": {
|
||||
"get": {
|
||||
"tags": ["Pages"],
|
||||
"summary": "Open race overview page",
|
||||
"description": "Serves the leaderboard/race overview UI.",
|
||||
"operationId": "getOverviewPage",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML race overview page",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/swagger.html": {
|
||||
"get": {
|
||||
"tags": ["Pages"],
|
||||
"summary": "Open Swagger documentation page",
|
||||
"description": "Serves the browser documentation UI for this OpenAPI document.",
|
||||
"operationId": "getSwaggerPage",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML Swagger page",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/state": {
|
||||
"get": {
|
||||
"tags": ["State"],
|
||||
"summary": "Get latest decoded UDP packet state",
|
||||
"description": "Returns the latest merged packet by UDP packet type plus the most recent parsed packets, newest first.",
|
||||
"operationId": "getState",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current decoded packet state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/leaderboard": {
|
||||
"get": {
|
||||
"tags": ["Leaderboard"],
|
||||
"summary": "Get calculated leaderboard overview",
|
||||
"description": "Returns participant rows merged from UDP packets, including timing, status, nationality override, car name, lap splits, sector splits, pit state, and decoded race flag data.",
|
||||
"operationId": "getLeaderboard",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current leaderboard overview",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/leaderboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ingest/shared-memory": {
|
||||
"post": {
|
||||
"tags": ["Ingest"],
|
||||
"summary": "Ingest shared-memory bridge packets",
|
||||
"description": "Receives packet-like JSON frames from scripts/shared_memory_bridge.py and merges them into the same state used by the UDP parser and leaderboard.",
|
||||
"operationId": "ingestSharedMemory",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/sharedMemoryIngestRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Frame accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/ingestAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid frame",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/errorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events": {
|
||||
"get": {
|
||||
"tags": ["Events"],
|
||||
"summary": "Open live telemetry SSE stream",
|
||||
"description": "Server-Sent Events stream. This is the app's live route; there is no WebSocket upgrade route in the current server. On connection it sends a `state` event. For each parsed UDP packet it sends a `packet` event containing the packet, state and leaderboard, followed by a `leaderboard` event containing just the leaderboard.",
|
||||
"operationId": "streamEvents",
|
||||
"x-streaming-protocol": "sse",
|
||||
"x-sse-events": {
|
||||
"state": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/stateEvent"
|
||||
},
|
||||
"example": {
|
||||
"latest": {
|
||||
"3": {
|
||||
"receivedAt": "2026-05-18T06:31:12.812Z",
|
||||
"source": "192.168.1.55:5606",
|
||||
"base": {
|
||||
"packetNumber": 1210,
|
||||
"categoryPacketNumber": 309,
|
||||
"partialPacketIndex": 0,
|
||||
"partialPacketNumber": 1,
|
||||
"packetType": 3,
|
||||
"packetVersion": 1
|
||||
},
|
||||
"name": "Timings",
|
||||
"size": 1064,
|
||||
"data": {
|
||||
"numParticipants": 2,
|
||||
"eventTimeRemaining": 1275.442,
|
||||
"participants": [
|
||||
{
|
||||
"slot": 0,
|
||||
"racePosition": 1,
|
||||
"active": true,
|
||||
"currentLap": 5,
|
||||
"currentTime": 402.733,
|
||||
"mpParticipantIndex": 1001
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"recent": [],
|
||||
"leaderboard": {
|
||||
"rows": [
|
||||
{
|
||||
"id": 1001,
|
||||
"slot": 0,
|
||||
"position": 1,
|
||||
"status": {
|
||||
"code": "racing",
|
||||
"label": "Racing",
|
||||
"tone": "good"
|
||||
},
|
||||
"driver": "Alex Taylor",
|
||||
"lap": 5,
|
||||
"currentTime": 402.733
|
||||
}
|
||||
],
|
||||
"note": "1 participant merged from UDP packets",
|
||||
"session": {
|
||||
"trackName": "Bathurst - Mount Panorama",
|
||||
"sessionType": "Race",
|
||||
"timeLeft": 1275.442,
|
||||
"weather": "22 C air / 31 C track / rain 0",
|
||||
"lastUpdate": "2026-05-18T06:31:12.812Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"packet": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/packetEvent"
|
||||
},
|
||||
"example": {
|
||||
"packet": {
|
||||
"receivedAt": "2026-05-18T06:31:13.020Z",
|
||||
"source": "192.168.1.55:5606",
|
||||
"base": {
|
||||
"packetNumber": 1211,
|
||||
"categoryPacketNumber": 310,
|
||||
"partialPacketIndex": 0,
|
||||
"partialPacketNumber": 1,
|
||||
"packetType": 3,
|
||||
"packetVersion": 1
|
||||
},
|
||||
"name": "Timings",
|
||||
"size": 1064,
|
||||
"data": {
|
||||
"numParticipants": 2,
|
||||
"eventTimeRemaining": 1274.991,
|
||||
"participants": [
|
||||
{
|
||||
"slot": 0,
|
||||
"racePosition": 1,
|
||||
"active": true,
|
||||
"sector": 2,
|
||||
"currentLap": 5,
|
||||
"currentTime": 403.184,
|
||||
"currentSectorTime": 32.333,
|
||||
"mpParticipantIndex": 1001
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"latest": {},
|
||||
"recent": []
|
||||
},
|
||||
"leaderboard": {
|
||||
"rows": [],
|
||||
"note": "Waiting for participant or timing packets",
|
||||
"session": {
|
||||
"trackName": "-",
|
||||
"sessionType": "-",
|
||||
"weather": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaderboard": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/leaderboard"
|
||||
},
|
||||
"example": {
|
||||
"rows": [
|
||||
{
|
||||
"id": 1001,
|
||||
"slot": 0,
|
||||
"sortPosition": 1,
|
||||
"position": 1,
|
||||
"active": true,
|
||||
"status": {
|
||||
"code": "racing",
|
||||
"label": "Racing",
|
||||
"tone": "good"
|
||||
},
|
||||
"driver": "Alex Taylor",
|
||||
"nationality": {
|
||||
"code": "GBR",
|
||||
"country": "United Kingdom",
|
||||
"source": "udp"
|
||||
},
|
||||
"vehicle": "Formula A",
|
||||
"lap": 5,
|
||||
"sector": 2,
|
||||
"currentTime": 403.184,
|
||||
"lastLapTime": 83.214,
|
||||
"fastestLapTime": 82.901
|
||||
}
|
||||
],
|
||||
"note": "1 participant merged from UDP packets",
|
||||
"session": {
|
||||
"trackName": "Bathurst - Mount Panorama",
|
||||
"sessionType": "Race",
|
||||
"timeLeft": 1274.991,
|
||||
"weather": "22 C air / 31 C track / rain 0",
|
||||
"lastUpdate": "2026-05-18T06:31:13.020Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SSE stream of telemetry updates",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Server-Sent Events stream. Event names currently include `state`, `packet`, and `leaderboard`; payload schemas are documented in `x-sse-events`."
|
||||
},
|
||||
"examples": {
|
||||
"state": {
|
||||
"summary": "State event after timing and participant packets",
|
||||
"value": "event: state\ndata: {\"latest\":{\"2\":{\"receivedAt\":\"2026-05-18T06:31:12.420Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1201,\"categoryPacketNumber\":42,\"partialPacketIndex\":1,\"partialPacketNumber\":2,\"packetType\":2,\"packetVersion\":1},\"name\":\"Participants\",\"size\":1136,\"data\":{\"participantsChangedTimestamp\":88342,\"participants\":[{\"slot\":0,\"localSlot\":0,\"partialPacketIndex\":1,\"name\":\"Alex Taylor\",\"nationality\":85,\"index\":1001},{\"slot\":1,\"localSlot\":1,\"partialPacketIndex\":1,\"name\":\"Sam Rivera\",\"nationality\":226,\"index\":1002}]}},\"3\":{\"receivedAt\":\"2026-05-18T06:31:12.812Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1210,\"categoryPacketNumber\":309,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"participantsChangedTimestamp\":88342,\"eventTimeRemaining\":1275.442,\"splitTimeAhead\":0,\"splitTimeBehind\":1.248,\"splitTime\":1.248,\"localParticipantIndex\":0,\"participants\":[{\"slot\":0,\"worldPosition\":[124,0,842],\"orientation\":[0,1024,0],\"currentLapDistance\":2840,\"racePosition\":1,\"active\":true,\"sector\":2,\"sectorRaw\":2,\"sectorExtraPrecision\":0,\"highestFlag\":0,\"pitModeSchedule\":0,\"carIndex\":45,\"raceState\":2,\"currentLap\":5,\"currentTime\":402.733,\"currentSectorTime\":31.882,\"mpParticipantIndex\":1001},{\"slot\":1,\"worldPosition\":[118,0,806],\"orientation\":[0,1008,0],\"currentLapDistance\":2790,\"racePosition\":2,\"active\":true,\"sector\":2,\"sectorRaw\":2,\"sectorExtraPrecision\":0,\"highestFlag\":2,\"pitModeSchedule\":0,\"carIndex\":47,\"raceState\":2,\"currentLap\":5,\"currentTime\":403.981,\"currentSectorTime\":33.130,\"mpParticipantIndex\":1002}]}}},\"recent\":[{\"receivedAt\":\"2026-05-18T06:31:12.812Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1210,\"categoryPacketNumber\":309,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"eventTimeRemaining\":1275.442,\"participants\":[]}}],\"leaderboard\":{\"rows\":[{\"id\":1001,\"slot\":0,\"sortPosition\":1,\"position\":1,\"active\":true,\"status\":{\"code\":\"racing\",\"label\":\"Racing\",\"tone\":\"good\"},\"driver\":\"Alex Taylor\",\"nationality\":{\"code\":\"GBR\",\"country\":\"United Kingdom\",\"source\":\"udp\"},\"vehicle\":\"Formula A\",\"vehicleShortName\":\"FORM\",\"lap\":5,\"sector\":2,\"currentTime\":402.733,\"currentSectorTime\":31.882,\"lastLapTime\":83.214,\"fastestLapTime\":82.901,\"pitModeSchedule\":0,\"highestFlag\":0,\"raceState\":2,\"raceStateCode\":2,\"invalidatedLap\":false,\"carIndex\":45},{\"id\":1002,\"slot\":1,\"sortPosition\":2,\"position\":2,\"active\":true,\"status\":{\"code\":\"blue_flag\",\"label\":\"Blue flag\",\"tone\":\"warn\"},\"driver\":\"Sam Rivera\",\"nationality\":{\"code\":\"USA\",\"country\":\"United States\",\"source\":\"udp\"},\"vehicle\":\"Formula A\",\"vehicleShortName\":\"FORM\",\"lap\":5,\"sector\":2,\"currentTime\":403.981,\"currentSectorTime\":33.13,\"lastLapTime\":84.012,\"fastestLapTime\":83.441,\"pitModeSchedule\":0,\"highestFlag\":2,\"flag\":{\"raw\":2,\"colour\":2,\"reason\":0,\"observed\":\"blue\"},\"raceState\":2,\"raceStateCode\":2,\"invalidatedLap\":false,\"carIndex\":47}],\"note\":\"2 participants merged from UDP packets\",\"session\":{\"trackName\":\"Bathurst - Mount Panorama\",\"sessionType\":\"Race\",\"timeLeft\":1275.442,\"weather\":\"22 C air / 31 C track / rain 0\",\"lastUpdate\":\"2026-05-18T06:31:12.812Z\"}}}\n\n"
|
||||
},
|
||||
"packet": {
|
||||
"summary": "Packet event with state and leaderboard",
|
||||
"value": "event: packet\ndata: {\"packet\":{\"receivedAt\":\"2026-05-18T06:31:13.020Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1211,\"categoryPacketNumber\":310,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"participantsChangedTimestamp\":88342,\"eventTimeRemaining\":1274.991,\"splitTimeAhead\":0,\"splitTimeBehind\":1.192,\"splitTime\":1.192,\"localParticipantIndex\":0,\"participants\":[{\"slot\":0,\"worldPosition\":[128,0,851],\"orientation\":[0,1026,0],\"currentLapDistance\":2875,\"racePosition\":1,\"active\":true,\"sector\":2,\"sectorRaw\":2,\"sectorExtraPrecision\":0,\"highestFlag\":0,\"pitModeSchedule\":0,\"carIndex\":45,\"raceState\":2,\"currentLap\":5,\"currentTime\":403.184,\"currentSectorTime\":32.333,\"mpParticipantIndex\":1001}] }},\"state\":{\"latest\":{\"3\":{\"receivedAt\":\"2026-05-18T06:31:13.020Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1211,\"categoryPacketNumber\":310,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"eventTimeRemaining\":1274.991,\"participants\":[]}}},\"recent\":[{\"receivedAt\":\"2026-05-18T06:31:13.020Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1211,\"categoryPacketNumber\":310,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"eventTimeRemaining\":1274.991,\"participants\":[]}}]},\"leaderboard\":{\"rows\":[{\"id\":1001,\"slot\":0,\"sortPosition\":1,\"position\":1,\"active\":true,\"status\":{\"code\":\"racing\",\"label\":\"Racing\",\"tone\":\"good\"},\"driver\":\"Alex Taylor\",\"lap\":5,\"sector\":2,\"currentTime\":403.184,\"currentSectorTime\":32.333}],\"note\":\"1 participant merged from UDP packets\",\"session\":{\"trackName\":\"Bathurst - Mount Panorama\",\"sessionType\":\"Race\",\"timeLeft\":1274.991,\"weather\":\"22 C air / 31 C track / rain 0\",\"lastUpdate\":\"2026-05-18T06:31:13.020Z\"}}}\n\n"
|
||||
},
|
||||
"leaderboard": {
|
||||
"summary": "Leaderboard update event",
|
||||
"value": "event: leaderboard\ndata: {\"rows\":[{\"id\":1001,\"slot\":0,\"sortPosition\":1,\"position\":1,\"active\":true,\"status\":{\"code\":\"racing\",\"label\":\"Racing\",\"tone\":\"good\"},\"driver\":\"Alex Taylor\",\"nationality\":{\"code\":\"GBR\",\"country\":\"United Kingdom\",\"source\":\"udp\"},\"vehicle\":\"Formula A\",\"vehicleShortName\":\"FORM\",\"lap\":5,\"sector\":2,\"currentTime\":403.184,\"currentSectorTime\":32.333,\"checkpointSplit\":{\"source\":\"checkpoint\",\"basis\":\"raceOrder\",\"checkpoint\":\"L5 S2\",\"time\":403.184,\"delta\":0},\"lastLapTime\":83.214,\"latestLapSplit\":null,\"fastestLapTime\":82.901,\"bestLapSplit\":null,\"fastestSectors\":[27.101,28.334,27.466],\"pitModeSchedule\":0,\"pit\":{\"raw\":0,\"pitMode\":0,\"pitSchedule\":0},\"highestFlag\":0,\"flag\":{\"raw\":0,\"colour\":0,\"reason\":0},\"raceState\":2,\"raceStateCode\":2,\"invalidatedLap\":false,\"carIndex\":45},{\"id\":1002,\"slot\":1,\"sortPosition\":2,\"position\":2,\"active\":true,\"status\":{\"code\":\"blue_flag\",\"label\":\"Blue flag\",\"tone\":\"warn\"},\"driver\":\"Sam Rivera\",\"nationality\":{\"code\":\"USA\",\"country\":\"United States\",\"source\":\"udp\"},\"vehicle\":\"Formula A\",\"vehicleShortName\":\"FORM\",\"lap\":5,\"sector\":2,\"currentTime\":404.376,\"currentSectorTime\":33.525,\"checkpointSplit\":{\"source\":\"checkpoint\",\"basis\":\"raceOrder\",\"checkpoint\":\"L5 S2\",\"comparedTo\":\"P1\",\"time\":404.376,\"delta\":1.192},\"lastLapTime\":84.012,\"latestLapSplit\":{\"comparedTo\":\"P1\",\"time\":84.012,\"referenceTime\":83.214,\"delta\":0.798},\"fastestLapTime\":83.441,\"bestLapSplit\":{\"comparedTo\":\"P1\",\"time\":83.441,\"referenceTime\":82.901,\"delta\":0.54},\"fastestSectors\":[27.488,28.619,27.334],\"pitModeSchedule\":0,\"pit\":{\"raw\":0,\"pitMode\":0,\"pitSchedule\":0},\"highestFlag\":2,\"flag\":{\"raw\":2,\"colour\":2,\"reason\":0,\"observed\":\"blue\"},\"raceState\":2,\"raceStateCode\":2,\"invalidatedLap\":false,\"carIndex\":47}],\"note\":\"2 participants merged from UDP packets\",\"session\":{\"trackName\":\"Bathurst - Mount Panorama\",\"sessionType\":\"Race\",\"timeLeft\":1274.991,\"weather\":\"22 C air / 31 C track / rain 0\",\"lastUpdate\":\"2026-05-18T06:31:13.020Z\"}}\n\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.json": {
|
||||
"get": {
|
||||
"tags": ["Schemas"],
|
||||
"summary": "Get this OpenAPI document from the static public route",
|
||||
"operationId": "getOpenApiSpec",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OpenAPI 3.1 document",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/openapi.json": {
|
||||
"get": {
|
||||
"tags": ["Schemas"],
|
||||
"summary": "Get this OpenAPI document from the API alias",
|
||||
"description": "Serves the same public/openapi.json document as /openapi.json.",
|
||||
"operationId": "getOpenApiSpecApiAlias",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OpenAPI 3.1 document",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schemas/udp-packets.schema.json": {
|
||||
"get": {
|
||||
"tags": ["Schemas"],
|
||||
"summary": "Get decoded UDP packet JSON Schema",
|
||||
"description": "JSON Schema used by the OpenAPI document for decoded packet state and leaderboard responses.",
|
||||
"operationId": "getUdpPacketSchema",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "JSON Schema for decoded UDP packets",
|
||||
"content": {
|
||||
"application/schema+json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
public/overview.html
Normal file
83
public/overview.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Race Overview</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Race Overview</h1>
|
||||
<p id="status">Waiting for UDP packets on port 5606</p>
|
||||
</div>
|
||||
<nav class="page-nav" aria-label="Pages">
|
||||
<a href="/">Packet Data</a>
|
||||
<a href="/overview.html">Race Overview</a>
|
||||
<a href="/swagger.html">API Docs</a>
|
||||
</nav>
|
||||
<div class="stat">
|
||||
<span>Drivers</span>
|
||||
<strong id="driver-count">0</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="overview-strip">
|
||||
<div>
|
||||
<span>Track</span>
|
||||
<strong id="track-name">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Session</span>
|
||||
<strong id="session-type">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Time Left</span>
|
||||
<strong id="time-left">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Weather</span>
|
||||
<strong id="weather">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last Update</span>
|
||||
<strong id="last-update">-</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Leaderboard</h2>
|
||||
<span id="leaderboard-note">Waiting for timing packets</span>
|
||||
</div>
|
||||
<div class="table-wrap leaderboard-wrap">
|
||||
<table class="leaderboard">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pos</th>
|
||||
<th>Driver</th>
|
||||
<th>Nat</th>
|
||||
<th>Car</th>
|
||||
<th>Lap</th>
|
||||
<th>Sector</th>
|
||||
<th>Current</th>
|
||||
<th>Last Lap</th>
|
||||
<th>Last Split</th>
|
||||
<th>Best Lap</th>
|
||||
<th>Best Split</th>
|
||||
<th>Sector Split</th>
|
||||
<th>Checkpoint</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="leaderboard-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/overview.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
213
public/overview.js
Normal file
213
public/overview.js
Normal file
@@ -0,0 +1,213 @@
|
||||
const status = document.querySelector("#status");
|
||||
const driverCount = document.querySelector("#driver-count");
|
||||
const trackName = document.querySelector("#track-name");
|
||||
const sessionType = document.querySelector("#session-type");
|
||||
const timeLeft = document.querySelector("#time-left");
|
||||
const weather = document.querySelector("#weather");
|
||||
const lastUpdate = document.querySelector("#last-update");
|
||||
const leaderboardNote = document.querySelector("#leaderboard-note");
|
||||
const leaderboardBody = document.querySelector("#leaderboard-body");
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function fmt(value, fallback = "-") {
|
||||
if (value === undefined || value === null || value === "" || Number.isNaN(value)) {
|
||||
return `<span class="empty">${fallback}</span>`;
|
||||
}
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function fmtTime(seconds) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds - minutes * 60;
|
||||
return `${minutes}:${remainder.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function fmtSplit(split) {
|
||||
if (!split || typeof split.delta !== "number" || !Number.isFinite(split.delta)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (split.delta === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
const sign = split.delta > 0 ? "+" : "-";
|
||||
return `${sign}${Math.abs(split.delta).toFixed(3)}`;
|
||||
}
|
||||
|
||||
function sectorSplitForRow(row, session) {
|
||||
const sessionType = String(session?.sessionType || "").toLowerCase();
|
||||
const usePersonalBest = sessionType === "practice" || sessionType === "qualifying";
|
||||
return usePersonalBest
|
||||
? row?.sectorTimeSplit || row?.checkpointSplit
|
||||
: row?.checkpointSplit || row?.sectorTimeSplit;
|
||||
}
|
||||
|
||||
function positionChange(change) {
|
||||
if (!change?.direction) {
|
||||
return '<span class="position-change-placeholder" aria-hidden="true"></span>';
|
||||
}
|
||||
|
||||
const isUp = change.direction === "up";
|
||||
const label = `${isUp ? "Moved up" : "Moved down"} ${change.places ?? 1}`;
|
||||
return `
|
||||
<span class="position-change ${isUp ? "position-up" : "position-down"}" title="${escapeHtml(label)}">
|
||||
${isUp ? "▲" : "▼"}${change.places && change.places > 1 ? escapeHtml(change.places) : ""}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function nationalityBadge(nationality) {
|
||||
if (!nationality?.code) {
|
||||
return '<span class="empty">-</span>';
|
||||
}
|
||||
|
||||
const title = `${nationality.country || nationality.code}${nationality.source === "override" ? " (name override)" : ""}`;
|
||||
const flag = flagSvg(nationality.code);
|
||||
return `<span class="nat-badge" title="${escapeHtml(`${title} - ${nationality.code}`)}">${flag || escapeHtml(nationality.code)}</span>`;
|
||||
}
|
||||
|
||||
function carShortName(row) {
|
||||
if (!row?.vehicleShortName) {
|
||||
return '<span class="empty">-</span>';
|
||||
}
|
||||
|
||||
return `<span title="${escapeHtml(row.vehicle || row.vehicleShortName)}">${escapeHtml(row.vehicleShortName)}</span>`;
|
||||
}
|
||||
|
||||
function statusBadge(row) {
|
||||
const status = row?.status;
|
||||
const label = status?.label || (row?.active === false ? "Inactive" : "Active");
|
||||
const tone = status?.tone || (row?.active === false ? "warn" : "good");
|
||||
return `<span class="${tone === "warn" ? "warn" : "good"}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function raceFlagDetails(row) {
|
||||
const flag = row?.flag;
|
||||
if (!flag || row.highestFlag === undefined || row.highestFlag === null) {
|
||||
return `flag ${fmt(row?.highestFlag)}`;
|
||||
}
|
||||
|
||||
const label = flag.observed ? ` ${escapeHtml(flag.observed)}` : "";
|
||||
return `flag ${fmt(row.highestFlag)} c${fmt(flag.colour)} r${fmt(flag.reason)}${label}`;
|
||||
}
|
||||
|
||||
function flagSvg(code) {
|
||||
const iso2ByIso3 = {
|
||||
DEU: "DE",
|
||||
FRA: "FR",
|
||||
GBR: "GB",
|
||||
JPN: "JP",
|
||||
USA: "US"
|
||||
};
|
||||
const iso2 = iso2ByIso3[String(code).toUpperCase()];
|
||||
if (!iso2) return "";
|
||||
|
||||
const flags = {
|
||||
DE: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#000" d="M0 0h60v13.333H0z"/><path fill="#dd0000" d="M0 13.333h60v13.334H0z"/><path fill="#ffce00" d="M0 26.667h60V40H0z"/></svg>`,
|
||||
FR: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#002395" d="M0 0h20v40H0z"/><path fill="#fff" d="M20 0h20v40H20z"/><path fill="#ed2939" d="M40 0h20v40H40z"/></svg>`,
|
||||
GB: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#012169" d="M0 0h60v40H0z"/><path stroke="#fff" stroke-width="8" d="m0 0 60 40M60 0 0 40"/><path stroke="#c8102e" stroke-width="4" d="m0 0 60 40M60 0 0 40"/><path fill="#fff" d="M25 0h10v40H25zM0 15h60v10H0z"/><path fill="#c8102e" d="M27 0h6v40h-6zM0 17h60v6H0z"/></svg>`,
|
||||
JP: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#fff" d="M0 0h60v40H0z"/><circle cx="30" cy="20" r="11" fill="#bc002d"/></svg>`,
|
||||
US: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#b22234" d="M0 0h60v40H0z"/><path stroke="#fff" stroke-width="3.08" d="M0 3.08h60M0 9.23h60M0 15.38h60M0 21.54h60M0 27.69h60M0 33.85h60"/><path fill="#3c3b6e" d="M0 0h24v21.54H0z"/></svg>`
|
||||
};
|
||||
|
||||
const svg = flags[iso2];
|
||||
return svg ? `<img class="flag-img" alt="${escapeHtml(iso2)} flag" src="data:image/svg+xml,${encodeURIComponent(svg)}">` : "";
|
||||
}
|
||||
|
||||
function clock(value) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function renderLeaderboard(leaderboard) {
|
||||
const rows = leaderboard?.rows ?? [];
|
||||
const session = leaderboard?.session ?? {};
|
||||
|
||||
driverCount.textContent = String(rows.length);
|
||||
leaderboardNote.textContent = leaderboard?.note ?? "Waiting for participant or timing packets";
|
||||
trackName.textContent = session.trackName || "-";
|
||||
sessionType.textContent = session.sessionType || "-";
|
||||
timeLeft.textContent = fmtTime(session.timeLeft);
|
||||
weather.textContent = session.weather || "-";
|
||||
lastUpdate.textContent = clock(session.lastUpdate);
|
||||
|
||||
leaderboardBody.innerHTML = rows.length
|
||||
? rows.map((row) => `
|
||||
${(() => {
|
||||
const sectorSplit = sectorSplitForRow(row, session);
|
||||
return `
|
||||
<tr class="${row.active === false ? "inactive-row" : ""}">
|
||||
<td class="pos"><span class="pos-inner"><span>${fmt(row.position)}</span>${positionChange(row.positionChange)}</span></td>
|
||||
<td>
|
||||
<strong>${fmt(row.driver)}</strong>
|
||||
<span class="subtle">slot ${fmt(row.slot)} / id ${fmt(row.id)}</span>
|
||||
</td>
|
||||
<td>${nationalityBadge(row.nationality)}</td>
|
||||
<td>${carShortName(row)}</td>
|
||||
<td>${fmt(row.lap)}</td>
|
||||
<td>${fmt(row.sector)}</td>
|
||||
<td>${fmtTime(row.currentTime)}</td>
|
||||
<td>${fmtTime(row.lastLapTime)}</td>
|
||||
<td>
|
||||
${fmtSplit(row.latestLapSplit)}
|
||||
<span class="subtle">${row.latestLapSplit?.comparedTo ? `vs ${fmt(row.latestLapSplit.comparedTo)}` : ""}</span>
|
||||
</td>
|
||||
<td>${fmtTime(row.fastestLapTime)}</td>
|
||||
<td>
|
||||
${fmtSplit(row.bestLapSplit)}
|
||||
<span class="subtle">${row.bestLapSplit?.comparedTo ? `vs ${fmt(row.bestLapSplit.comparedTo)}` : ""}</span>
|
||||
</td>
|
||||
<td>
|
||||
${fmtSplit(sectorSplit)}
|
||||
<span class="subtle">${fmt(sectorSplit?.checkpoint)}${sectorSplit?.comparedTo ? ` vs ${fmt(sectorSplit.comparedTo)}` : ""}</span>
|
||||
</td>
|
||||
<td>${fmtTime(sectorSplit?.time)}</td>
|
||||
<td>
|
||||
${statusBadge(row)}
|
||||
<span class="subtle">pit ${fmt(row.pitModeSchedule)} / ${raceFlagDetails(row)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})()}
|
||||
`).join("")
|
||||
: `<tr><td colspan="14" class="empty">No leaderboard data received yet</td></tr>`;
|
||||
}
|
||||
|
||||
fetch("/api/leaderboard")
|
||||
.then((response) => response.json())
|
||||
.then(renderLeaderboard)
|
||||
.catch(() => {
|
||||
status.textContent = "Could not load leaderboard state";
|
||||
status.className = "warn";
|
||||
});
|
||||
|
||||
const events = new EventSource("/events");
|
||||
|
||||
events.addEventListener("state", (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
renderLeaderboard(payload.leaderboard);
|
||||
});
|
||||
|
||||
events.addEventListener("leaderboard", (event) => {
|
||||
const leaderboard = JSON.parse(event.data);
|
||||
status.textContent = `Receiving packets. Last update ${clock(leaderboard?.session?.lastUpdate)}`;
|
||||
status.className = "good";
|
||||
renderLeaderboard(leaderboard);
|
||||
});
|
||||
|
||||
events.onerror = () => {
|
||||
status.textContent = "Connection to local telemetry server lost";
|
||||
status.className = "warn";
|
||||
};
|
||||
429
public/styles.css
Normal file
429
public/styles.css
Normal file
@@ -0,0 +1,429 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #101316;
|
||||
color: #eef3f5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: #101316;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 24px clamp(16px, 4vw, 48px);
|
||||
border-bottom: 1px solid #273039;
|
||||
background: #171c20;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #32404a;
|
||||
border-radius: 8px;
|
||||
color: #dce8ed;
|
||||
background: #11171b;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-nav a:hover {
|
||||
border-color: #5a6b75;
|
||||
background: #1e252a;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 3vw, 2.3rem);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p,
|
||||
td,
|
||||
th {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#status {
|
||||
margin-top: 6px;
|
||||
color: #9fb0ba;
|
||||
}
|
||||
|
||||
.stat {
|
||||
min-width: 110px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #32404a;
|
||||
border-radius: 8px;
|
||||
background: #11171b;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stat span {
|
||||
display: block;
|
||||
color: #9fb0ba;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 18px clamp(12px, 3vw, 32px) 36px;
|
||||
}
|
||||
|
||||
.overview-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr repeat(4, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-strip div {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid #273039;
|
||||
border-radius: 8px;
|
||||
background: #171c20;
|
||||
}
|
||||
|
||||
.overview-strip span,
|
||||
.subtle {
|
||||
display: block;
|
||||
color: #9fb0ba;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.overview-strip strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
border: 1px solid #273039;
|
||||
border-radius: 8px;
|
||||
background: #171c20;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
details.panel summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
details.panel summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
details.panel summary::before {
|
||||
content: ">";
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #3d4b55;
|
||||
border-radius: 6px;
|
||||
color: #a8bbc4;
|
||||
font-size: 0.82rem;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
details.panel[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #273039;
|
||||
background: #1e252a;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
margin-left: auto;
|
||||
color: #9fb0ba;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.packet-sections {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.nested-groups {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-top: 1px solid #273039;
|
||||
}
|
||||
|
||||
.nested-groups h3 {
|
||||
margin: 0;
|
||||
color: #a8bbc4;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subpanel {
|
||||
border: 1px solid #273039;
|
||||
border-radius: 8px;
|
||||
background: #11171b;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subpanel summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
background: #1b2227;
|
||||
}
|
||||
|
||||
.subpanel summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subpanel summary::before {
|
||||
content: ">";
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #3d4b55;
|
||||
border-radius: 6px;
|
||||
color: #a8bbc4;
|
||||
font-size: 0.76rem;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.subpanel[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.subpanel strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subpanel span {
|
||||
margin-left: auto;
|
||||
color: #9fb0ba;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.subpanel .table-wrap {
|
||||
border-top: 1px solid #273039;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.leaderboard thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #1e252a;
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:nth-child(even) {
|
||||
background: #141a1e;
|
||||
}
|
||||
|
||||
.leaderboard td strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.leaderboard .pos {
|
||||
min-width: 52px;
|
||||
color: #eef3f5;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pos-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.position-change-placeholder {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
|
||||
.position-up {
|
||||
color: #68d391;
|
||||
background: rgba(104, 211, 145, 0.12);
|
||||
}
|
||||
|
||||
.position-down {
|
||||
color: #ff7b7b;
|
||||
background: rgba(255, 123, 123, 0.12);
|
||||
}
|
||||
|
||||
.nat-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
border: 1px solid #3d4b55;
|
||||
border-radius: 6px;
|
||||
color: #dce8ed;
|
||||
background: #11171b;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flag-img {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.inactive-row {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #273039;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #a8bbc4;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
width: 38%;
|
||||
color: #c5d3da;
|
||||
font-size: 0.86rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #eef3f5;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #7f9099;
|
||||
}
|
||||
|
||||
.good {
|
||||
color: #68d391;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: #f6c177;
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.topbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.overview-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
width: 100%;
|
||||
margin-left: 30px;
|
||||
}
|
||||
}
|
||||
33
public/swagger.html
Normal file
33
public/swagger.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Project CARS Telemetry API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
tryItOutEnabled: true
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
179
scripts/capture_udp.py
Normal file
179
scripts/capture_udp.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
UDP_PORT = 5606
|
||||
OUT_ROOT = Path("captures")
|
||||
OUT_ROOT.mkdir(exist_ok=True)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Capture Project CARS UDP packets.")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("auto", "v1", "v2"),
|
||||
default="auto",
|
||||
help="Packet labelling mode. V2 uses the 12-byte packet header; V1 is a fixed telemetry block."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Recording directory. Defaults to captures/session_<timestamp>."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--flat",
|
||||
action="store_true",
|
||||
help="Also write packet files directly into captures/ for quick manual inspection."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
session_dir = args.out or OUT_ROOT / f"session_{time.strftime('%Y%m%d_%H%M%S')}"
|
||||
packet_dir = session_dir / "packets"
|
||||
packet_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = session_dir / "manifest.jsonl"
|
||||
metadata_path = session_dir / "metadata.json"
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(0.5)
|
||||
sock.bind(("0.0.0.0", UDP_PORT))
|
||||
|
||||
started_monotonic = time.monotonic()
|
||||
started_wall = time.time()
|
||||
packet_index = 0
|
||||
stop_requested = False
|
||||
|
||||
metadata_path.write_text(json.dumps({
|
||||
"format": "projectcars-udp-recording",
|
||||
"formatVersion": 1,
|
||||
"mode": args.mode,
|
||||
"udpPort": UDP_PORT,
|
||||
"startedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"packetDirectory": "packets",
|
||||
"manifest": "manifest.jsonl"
|
||||
}, indent=2), encoding="utf8")
|
||||
|
||||
print(f"Listening on UDP {UDP_PORT} in {args.mode} mode...")
|
||||
print(f"Recording to {session_dir}")
|
||||
|
||||
|
||||
def request_stop(_signum, _frame) -> None:
|
||||
global stop_requested
|
||||
stop_requested = True
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
if hasattr(signal, "SIGBREAK"):
|
||||
signal.signal(signal.SIGBREAK, request_stop)
|
||||
|
||||
|
||||
def looks_like_v2(data: bytes) -> bool:
|
||||
if len(data) < 12:
|
||||
return False
|
||||
|
||||
packet_type = data[10]
|
||||
packet_version = data[11]
|
||||
partial_count = data[9]
|
||||
expected_sizes = {
|
||||
0: {556, 559},
|
||||
1: {308},
|
||||
2: {1136},
|
||||
3: {1059, 1063},
|
||||
4: {24},
|
||||
7: {1040},
|
||||
8: {1164, 1452},
|
||||
}
|
||||
|
||||
return (
|
||||
packet_type in expected_sizes
|
||||
and len(data) in expected_sizes[packet_type]
|
||||
and packet_version <= 8
|
||||
and partial_count <= 8
|
||||
)
|
||||
|
||||
|
||||
def packet_label(data: bytes) -> dict:
|
||||
if args.mode == "v2" or (args.mode == "auto" and looks_like_v2(data)):
|
||||
packet_type = data[10]
|
||||
packet_number = int.from_bytes(data[0:4], "little")
|
||||
category_number = int.from_bytes(data[4:8], "little")
|
||||
partial_index = data[8]
|
||||
partial_count = data[9]
|
||||
version = data[11]
|
||||
stem = (
|
||||
f"{packet_index:06d}_type{packet_type}_pkt{packet_number}_cat{category_number}_"
|
||||
f"part{partial_index}-of-{partial_count}_v{version}"
|
||||
)
|
||||
return {
|
||||
"format": "v2",
|
||||
"stem": stem,
|
||||
"packetType": packet_type,
|
||||
"packetNumber": packet_number,
|
||||
"categoryPacketNumber": category_number,
|
||||
"partialPacketIndex": partial_index,
|
||||
"partialPacketNumber": partial_count,
|
||||
"packetVersion": version
|
||||
}
|
||||
|
||||
sequence = int.from_bytes(data[2:4], "little") if len(data) >= 4 else 0
|
||||
return {
|
||||
"format": "v1",
|
||||
"stem": f"{packet_index:06d}_v1_seq{sequence}_size{len(data)}",
|
||||
"sequence": sequence
|
||||
}
|
||||
|
||||
|
||||
def write_final_metadata(duration: float) -> None:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf8"))
|
||||
metadata.update({
|
||||
"stoppedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"durationSeconds": round(duration, 3),
|
||||
"packetCount": packet_index
|
||||
})
|
||||
metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf8")
|
||||
|
||||
|
||||
try:
|
||||
with manifest_path.open("a", encoding="utf8") as manifest:
|
||||
while not stop_requested:
|
||||
try:
|
||||
data, addr = sock.recvfrom(1500)
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
now_monotonic = time.monotonic()
|
||||
now_wall = time.time()
|
||||
label = packet_label(data)
|
||||
filename = f"{label['stem']}_{int(now_wall * 1000)}.bin"
|
||||
packet_path = packet_dir / filename
|
||||
packet_path.write_bytes(data)
|
||||
|
||||
if args.flat:
|
||||
(OUT_ROOT / filename).write_bytes(data)
|
||||
|
||||
record = {
|
||||
"index": packet_index,
|
||||
"timeOffset": round(now_monotonic - started_monotonic, 6),
|
||||
"wallTimeOffset": round(now_wall - started_wall, 6),
|
||||
"source": f"{addr[0]}:{addr[1]}",
|
||||
"size": len(data),
|
||||
"file": f"packets/{filename}",
|
||||
**{key: value for key, value in label.items() if key != "stem"}
|
||||
}
|
||||
manifest.write(json.dumps(record, separators=(",", ":")) + "\n")
|
||||
manifest.flush()
|
||||
|
||||
if label["format"] == "v1" or label.get("packetType") in (1, 2, 3, 7, 8):
|
||||
print(f"{filename} size={len(data)} from={addr[0]}:{addr[1]}")
|
||||
|
||||
packet_index += 1
|
||||
except KeyboardInterrupt:
|
||||
stop_requested = True
|
||||
finally:
|
||||
sock.close()
|
||||
duration = time.monotonic() - started_monotonic
|
||||
write_final_metadata(duration)
|
||||
print(f"\nStopped. Captured {packet_index} packets over {duration:.1f}s.", flush=True)
|
||||
print(f"Replay with: python scripts\\replay_udp.py {session_dir}", flush=True)
|
||||
73
scripts/replay_udp.py
Normal file
73
scripts/replay_udp.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
parser = argparse.ArgumentParser(description="Replay a Project CARS UDP recording.")
|
||||
parser.add_argument("recording", type=Path, help="Recording directory containing manifest.jsonl.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Destination host. Defaults to 127.0.0.1.")
|
||||
parser.add_argument("--port", type=int, default=5606, help="Destination UDP port. Defaults to 5606.")
|
||||
parser.add_argument("--speed", type=float, default=1.0, help="Playback speed multiplier.")
|
||||
parser.add_argument("--loop", action="store_true", help="Loop playback until interrupted.")
|
||||
parser.add_argument(
|
||||
"--types",
|
||||
default=None,
|
||||
help="Comma-separated V2 packet types to replay, for example 1,2,3,4,7,8. V1 packets are skipped when this is set."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
recording_dir = args.recording
|
||||
manifest_path = recording_dir / "manifest.jsonl"
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise SystemExit(f"Missing manifest: {manifest_path}")
|
||||
|
||||
if args.speed <= 0:
|
||||
raise SystemExit("--speed must be greater than 0")
|
||||
|
||||
type_filter = None
|
||||
if args.types:
|
||||
type_filter = {int(part.strip()) for part in args.types.split(",") if part.strip()}
|
||||
|
||||
with manifest_path.open("r", encoding="utf8") as manifest:
|
||||
records = [json.loads(line) for line in manifest if line.strip()]
|
||||
|
||||
if type_filter is not None:
|
||||
records = [
|
||||
record for record in records
|
||||
if record.get("format") == "v2" and record.get("packetType") in type_filter
|
||||
]
|
||||
|
||||
if not records:
|
||||
raise SystemExit("No packets to replay after applying filters.")
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
|
||||
def play_once() -> None:
|
||||
playback_started = time.monotonic()
|
||||
first_offset = records[0]["timeOffset"]
|
||||
|
||||
for record in records:
|
||||
target_offset = (record["timeOffset"] - first_offset) / args.speed
|
||||
delay = target_offset - (time.monotonic() - playback_started)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
data = (recording_dir / record["file"]).read_bytes()
|
||||
sock.sendto(data, (args.host, args.port))
|
||||
|
||||
|
||||
print(
|
||||
f"Replaying {len(records)} packets to {args.host}:{args.port} "
|
||||
f"at {args.speed:g}x from {recording_dir}"
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
play_once()
|
||||
if not args.loop:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped replay.")
|
||||
443
scripts/shared_memory_bridge.py
Normal file
443
scripts/shared_memory_bridge.py
Normal file
@@ -0,0 +1,443 @@
|
||||
import argparse
|
||||
import json
|
||||
import mmap
|
||||
import struct
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
SHARED_MEMORY_NAME = "$pcars2$"
|
||||
SHARED_MEMORY_SIZE = 20704
|
||||
DEFAULT_URL = "http://127.0.0.1:3000/api/ingest/shared-memory"
|
||||
|
||||
GAME_STATE_NAMES = {
|
||||
0: "Exited",
|
||||
1: "Front end",
|
||||
2: "Playing",
|
||||
3: "Paused",
|
||||
4: "In menu",
|
||||
5: "Restarting",
|
||||
6: "Replay",
|
||||
7: "Front end replay",
|
||||
}
|
||||
|
||||
SESSION_STATE_NAMES = {
|
||||
0: "Invalid",
|
||||
1: "Practice",
|
||||
2: "Test",
|
||||
3: "Qualifying",
|
||||
4: "Formation lap",
|
||||
5: "Race",
|
||||
6: "Time attack",
|
||||
}
|
||||
|
||||
OFFSETS = {
|
||||
"version": 0,
|
||||
"buildVersionNumber": 4,
|
||||
"gameState": 8,
|
||||
"sessionState": 12,
|
||||
"raceState": 16,
|
||||
"viewedParticipantIndex": 20,
|
||||
"numParticipants": 24,
|
||||
"participantInfo": 28,
|
||||
"lapsInEvent": 6572,
|
||||
"trackLocation": 6576,
|
||||
"trackVariation": 6640,
|
||||
"trackLength": 6704,
|
||||
"lapInvalidated": 6712,
|
||||
"splitTimeAhead": 6728,
|
||||
"splitTimeBehind": 6732,
|
||||
"splitTime": 6736,
|
||||
"eventTimeRemaining": 6740,
|
||||
"personalFastestLapTime": 6744,
|
||||
"worldFastestLapTime": 6748,
|
||||
"currentSector1Time": 6752,
|
||||
"currentSector2Time": 6756,
|
||||
"currentSector3Time": 6760,
|
||||
"fastestSector1Time": 6764,
|
||||
"fastestSector2Time": 6768,
|
||||
"fastestSector3Time": 6772,
|
||||
"personalFastestSector1Time": 6776,
|
||||
"personalFastestSector2Time": 6780,
|
||||
"personalFastestSector3Time": 6784,
|
||||
"worldFastestSector1Time": 6788,
|
||||
"worldFastestSector2Time": 6792,
|
||||
"worldFastestSector3Time": 6796,
|
||||
"ambientTemperature": 7292,
|
||||
"trackTemperature": 7296,
|
||||
"rainDensity": 7300,
|
||||
"windSpeed": 7304,
|
||||
"windDirectionX": 7308,
|
||||
"windDirectionY": 7312,
|
||||
"currentSector1Times": 7408,
|
||||
"currentSector2Times": 7664,
|
||||
"currentSector3Times": 7920,
|
||||
"fastestSector1Times": 8176,
|
||||
"fastestSector2Times": 8432,
|
||||
"fastestSector3Times": 8688,
|
||||
"fastestLapTimes": 8944,
|
||||
"lastLapTimes": 9200,
|
||||
"lapsInvalidated": 9456,
|
||||
"raceStates": 9520,
|
||||
"pitModes": 9776,
|
||||
"speeds": 10800,
|
||||
"carNames": 11056,
|
||||
"carClassNames": 15152,
|
||||
"pitSchedules": 19548,
|
||||
"highestFlagColours": 19804,
|
||||
"highestFlagReasons": 20060,
|
||||
"nationalities": 20316,
|
||||
"snowDensity": 20572,
|
||||
"sessionDuration": 20576,
|
||||
"sessionAdditionalLaps": 20580,
|
||||
"yellowFlagState": 20688,
|
||||
"launchStage": 20696,
|
||||
}
|
||||
|
||||
PARTICIPANT_STRIDE = 100
|
||||
MAX_PARTICIPANTS = 64
|
||||
UDP_PARTICIPANTS = 32
|
||||
STRING_LENGTH = 64
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def u32(data: bytes, offset: int) -> int:
|
||||
return struct.unpack_from("<I", data, offset)[0]
|
||||
|
||||
|
||||
def i32(data: bytes, offset: int) -> int:
|
||||
return struct.unpack_from("<i", data, offset)[0]
|
||||
|
||||
|
||||
def f32(data: bytes, offset: int) -> float:
|
||||
value = struct.unpack_from("<f", data, offset)[0]
|
||||
return round(value, 3)
|
||||
|
||||
|
||||
def boolean(data: bytes, offset: int) -> bool:
|
||||
return data[offset] != 0
|
||||
|
||||
|
||||
def c_string(data: bytes, offset: int, length: int = STRING_LENGTH) -> str:
|
||||
raw = data[offset:offset + length]
|
||||
return raw.split(b"\x00", 1)[0].decode("utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
def float_array_value(data: bytes, offset: int, index: int) -> float:
|
||||
return f32(data, offset + index * 4)
|
||||
|
||||
|
||||
def u32_array_value(data: bytes, offset: int, index: int) -> int:
|
||||
return u32(data, offset + index * 4)
|
||||
|
||||
|
||||
def bool_array_value(data: bytes, offset: int, index: int) -> bool:
|
||||
return boolean(data, offset + index)
|
||||
|
||||
|
||||
def participant_base(offset: int) -> int:
|
||||
return OFFSETS["participantInfo"] + offset * PARTICIPANT_STRIDE
|
||||
|
||||
|
||||
def participant_name(data: bytes, slot: int) -> str:
|
||||
return c_string(data, participant_base(slot) + 1)
|
||||
|
||||
|
||||
def participant_world_position(data: bytes, slot: int) -> list[float]:
|
||||
base = participant_base(slot) + 68
|
||||
return [f32(data, base), f32(data, base + 4), f32(data, base + 8)]
|
||||
|
||||
|
||||
def participant_lap_distance(data: bytes, slot: int) -> int:
|
||||
return max(0, min(65535, round(f32(data, participant_base(slot) + 80))))
|
||||
|
||||
|
||||
def participant_u32(data: bytes, slot: int, relative_offset: int) -> int:
|
||||
return u32(data, participant_base(slot) + relative_offset)
|
||||
|
||||
|
||||
def car_name(data: bytes, slot: int) -> str:
|
||||
return c_string(data, OFFSETS["carNames"] + slot * STRING_LENGTH)
|
||||
|
||||
|
||||
def car_class_name(data: bytes, slot: int) -> str:
|
||||
return c_string(data, OFFSETS["carClassNames"] + slot * STRING_LENGTH)
|
||||
|
||||
|
||||
def active_slots(data: bytes) -> list[int]:
|
||||
declared = max(0, min(MAX_PARTICIPANTS, i32(data, OFFSETS["numParticipants"])))
|
||||
slots = []
|
||||
for slot in range(declared):
|
||||
is_active = boolean(data, participant_base(slot))
|
||||
name = participant_name(data, slot)
|
||||
race_position = participant_u32(data, slot, 84)
|
||||
if is_active or name or race_position:
|
||||
slots.append(slot)
|
||||
return slots[:UDP_PARTICIPANTS]
|
||||
|
||||
|
||||
def packet(packet_type: int, packet_number: int, data: dict, name: str) -> dict:
|
||||
return {
|
||||
"receivedAt": utc_now(),
|
||||
"source": "shared-memory",
|
||||
"base": {
|
||||
"packetNumber": packet_number,
|
||||
"categoryPacketNumber": packet_number,
|
||||
"partialPacketIndex": 0,
|
||||
"partialPacketNumber": 1,
|
||||
"packetType": packet_type,
|
||||
"packetVersion": 0,
|
||||
},
|
||||
"name": name,
|
||||
"size": 0,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
def build_frame(data: bytes, sequence: int) -> dict:
|
||||
slots = active_slots(data)
|
||||
timestamp = u32(data, OFFSETS["version"])
|
||||
|
||||
participants = []
|
||||
timings = []
|
||||
stats = []
|
||||
vehicles = []
|
||||
classes = {}
|
||||
|
||||
for slot in slots:
|
||||
name = participant_name(data, slot)
|
||||
race_position = participant_u32(data, slot, 84)
|
||||
laps_completed = participant_u32(data, slot, 88)
|
||||
current_lap = participant_u32(data, slot, 92)
|
||||
sector = i32(data, participant_base(slot) + 96)
|
||||
race_state = u32_array_value(data, OFFSETS["raceStates"], slot)
|
||||
lap_invalidated = bool_array_value(data, OFFSETS["lapsInvalidated"], slot)
|
||||
pit_mode = u32_array_value(data, OFFSETS["pitModes"], slot)
|
||||
pit_schedule = u32_array_value(data, OFFSETS["pitSchedules"], slot)
|
||||
flag_colour = u32_array_value(data, OFFSETS["highestFlagColours"], slot)
|
||||
flag_reason = u32_array_value(data, OFFSETS["highestFlagReasons"], slot)
|
||||
nationality = u32_array_value(data, OFFSETS["nationalities"], slot)
|
||||
vehicle_name = car_name(data, slot)
|
||||
vehicle_class = car_class_name(data, slot)
|
||||
current_sector_times = [
|
||||
float_array_value(data, OFFSETS["currentSector1Times"], slot),
|
||||
float_array_value(data, OFFSETS["currentSector2Times"], slot),
|
||||
float_array_value(data, OFFSETS["currentSector3Times"], slot),
|
||||
]
|
||||
|
||||
participants.append({
|
||||
"slot": slot,
|
||||
"localSlot": slot % 16,
|
||||
"partialPacketIndex": slot // 16,
|
||||
"name": name,
|
||||
"nationality": nationality,
|
||||
"index": slot + 1,
|
||||
})
|
||||
|
||||
timings.append({
|
||||
"slot": slot,
|
||||
"worldPosition": participant_world_position(data, slot),
|
||||
"orientation": [0, 0, 0],
|
||||
"currentLapDistance": participant_lap_distance(data, slot),
|
||||
"racePosition": race_position,
|
||||
"active": boolean(data, participant_base(slot)),
|
||||
"sector": sector,
|
||||
"sectorRaw": sector,
|
||||
"sectorExtraPrecision": 0,
|
||||
"highestFlag": flag_colour,
|
||||
"highestFlagColour": flag_colour,
|
||||
"highestFlagReason": flag_reason,
|
||||
"pitModeSchedule": pit_mode | ((pit_schedule & 0x07) << 3),
|
||||
"pitMode": pit_mode,
|
||||
"pitSchedule": pit_schedule,
|
||||
"carIndex": slot + 1,
|
||||
"raceState": race_state | (0x08 if lap_invalidated else 0),
|
||||
"currentLap": current_lap or laps_completed + 1,
|
||||
"currentTime": max(current_sector_times),
|
||||
"currentSectorTime": current_sector_times[sector - 1] if 1 <= sector <= 3 else 0,
|
||||
"mpParticipantIndex": slot + 1,
|
||||
"speed": float_array_value(data, OFFSETS["speeds"], slot),
|
||||
})
|
||||
|
||||
stats.append({
|
||||
"slot": slot,
|
||||
"fastestLapTime": float_array_value(data, OFFSETS["fastestLapTimes"], slot),
|
||||
"lastLapTime": float_array_value(data, OFFSETS["lastLapTimes"], slot),
|
||||
"lastSectorTime": max(current_sector_times),
|
||||
"fastestSectors": [
|
||||
float_array_value(data, OFFSETS["fastestSector1Times"], slot),
|
||||
float_array_value(data, OFFSETS["fastestSector2Times"], slot),
|
||||
float_array_value(data, OFFSETS["fastestSector3Times"], slot),
|
||||
],
|
||||
"participantOnlineRep": 0,
|
||||
"mpParticipantIndex": slot + 1,
|
||||
})
|
||||
|
||||
if vehicle_name:
|
||||
class_index = zlib.crc32(vehicle_class.encode("utf-8")) if vehicle_class else 0
|
||||
vehicles.append({
|
||||
"index": slot + 1,
|
||||
"class": class_index,
|
||||
"name": vehicle_name,
|
||||
})
|
||||
if vehicle_class:
|
||||
classes[class_index] = {"classIndex": class_index, "name": vehicle_class}
|
||||
|
||||
game_state = u32(data, OFFSETS["gameState"])
|
||||
session_state = u32(data, OFFSETS["sessionState"])
|
||||
|
||||
packets = [
|
||||
packet(1, sequence, {
|
||||
"worldFastestLapTime": f32(data, OFFSETS["worldFastestLapTime"]),
|
||||
"personalFastestLapTime": f32(data, OFFSETS["personalFastestLapTime"]),
|
||||
"personalFastestSectors": [
|
||||
f32(data, OFFSETS["personalFastestSector1Time"]),
|
||||
f32(data, OFFSETS["personalFastestSector2Time"]),
|
||||
f32(data, OFFSETS["personalFastestSector3Time"]),
|
||||
],
|
||||
"worldFastestSectors": [
|
||||
f32(data, OFFSETS["worldFastestSector1Time"]),
|
||||
f32(data, OFFSETS["worldFastestSector2Time"]),
|
||||
f32(data, OFFSETS["worldFastestSector3Time"]),
|
||||
],
|
||||
"trackLength": f32(data, OFFSETS["trackLength"]),
|
||||
"trackLocation": c_string(data, OFFSETS["trackLocation"]),
|
||||
"trackVariation": c_string(data, OFFSETS["trackVariation"]),
|
||||
"translatedTrackLocation": "",
|
||||
"translatedTrackVariation": "",
|
||||
"lapsTimeInEvent": u32(data, OFFSETS["lapsInEvent"]),
|
||||
"enforcedPitStopLap": 0,
|
||||
}, "Race Definition"),
|
||||
packet(2, sequence, {
|
||||
"participantsChangedTimestamp": timestamp,
|
||||
"participants": participants,
|
||||
}, "Participants"),
|
||||
packet(3, sequence, {
|
||||
"numParticipants": len(slots),
|
||||
"participantsChangedTimestamp": timestamp,
|
||||
"eventTimeRemaining": f32(data, OFFSETS["eventTimeRemaining"]),
|
||||
"splitTimeAhead": f32(data, OFFSETS["splitTimeAhead"]),
|
||||
"splitTimeBehind": f32(data, OFFSETS["splitTimeBehind"]),
|
||||
"splitTime": f32(data, OFFSETS["splitTime"]),
|
||||
"localParticipantIndex": i32(data, OFFSETS["viewedParticipantIndex"]) + 1,
|
||||
"participants": timings,
|
||||
}, "Timings"),
|
||||
packet(4, sequence, {
|
||||
"buildVersionNumber": u32(data, OFFSETS["buildVersionNumber"]),
|
||||
"gameStateRaw": game_state | (session_state << 4),
|
||||
"gameState": game_state,
|
||||
"gameStateName": GAME_STATE_NAMES.get(game_state),
|
||||
"sessionState": session_state,
|
||||
"sessionStateName": SESSION_STATE_NAMES.get(session_state),
|
||||
"ambientTemperature": round(f32(data, OFFSETS["ambientTemperature"])),
|
||||
"trackTemperature": round(f32(data, OFFSETS["trackTemperature"])),
|
||||
"rainDensity": round(f32(data, OFFSETS["rainDensity"]) * 255),
|
||||
"snowDensity": round(f32(data, OFFSETS["snowDensity"]) * 255),
|
||||
"windSpeed": round(f32(data, OFFSETS["windSpeed"])),
|
||||
"windDirectionX": round(f32(data, OFFSETS["windDirectionX"])),
|
||||
"windDirectionY": round(f32(data, OFFSETS["windDirectionY"])),
|
||||
"yellowFlagState": i32(data, OFFSETS["yellowFlagState"]),
|
||||
"launchStage": i32(data, OFFSETS["launchStage"]),
|
||||
}, "Game State"),
|
||||
packet(7, sequence, {
|
||||
"participantsChangedTimestamp": timestamp,
|
||||
"participants": stats,
|
||||
}, "Time Stats"),
|
||||
packet(8, sequence, {
|
||||
"vehicles": vehicles,
|
||||
"classes": list(classes.values()),
|
||||
}, "Participant Vehicle Names"),
|
||||
]
|
||||
|
||||
return {
|
||||
"source": "shared-memory",
|
||||
"sequence": sequence,
|
||||
"capturedAt": utc_now(),
|
||||
"packets": packets,
|
||||
}
|
||||
|
||||
|
||||
def open_shared_memory() -> mmap.mmap:
|
||||
return mmap.mmap(
|
||||
-1,
|
||||
SHARED_MEMORY_SIZE,
|
||||
tagname=SHARED_MEMORY_NAME,
|
||||
access=mmap.ACCESS_READ,
|
||||
)
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict, timeout: float) -> None:
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
response.read()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read AMS2/Project CARS shared memory and feed JSON frames to the Node server."
|
||||
)
|
||||
parser.add_argument("--url", default=DEFAULT_URL, help=f"Node ingest URL. Default: {DEFAULT_URL}")
|
||||
parser.add_argument("--interval", type=float, default=0.2, help="Seconds between frames.")
|
||||
parser.add_argument("--timeout", type=float, default=2.0, help="HTTP timeout in seconds.")
|
||||
parser.add_argument("--once", action="store_true", help="Read and send one frame, then exit.")
|
||||
parser.add_argument("--print", action="store_true", dest="print_frame", help="Print frames instead of POSTing.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.interval <= 0:
|
||||
raise SystemExit("--interval must be greater than 0")
|
||||
|
||||
try:
|
||||
shared_memory = open_shared_memory()
|
||||
except OSError as error:
|
||||
raise SystemExit(
|
||||
f"Could not open shared memory '{SHARED_MEMORY_NAME}'. "
|
||||
"Start AMS2/Project CARS and enable shared memory first. "
|
||||
f"Original error: {error}"
|
||||
)
|
||||
|
||||
print(f"Reading shared memory '{SHARED_MEMORY_NAME}' every {args.interval:g}s.")
|
||||
if args.print_frame:
|
||||
print("Printing frames to stdout.")
|
||||
else:
|
||||
print(f"Posting frames to {args.url}")
|
||||
|
||||
sequence = 0
|
||||
try:
|
||||
while True:
|
||||
shared_memory.seek(0)
|
||||
data = shared_memory.read(SHARED_MEMORY_SIZE)
|
||||
frame = build_frame(data, sequence)
|
||||
|
||||
if args.print_frame:
|
||||
print(json.dumps(frame, indent=2))
|
||||
else:
|
||||
try:
|
||||
post_json(args.url, frame, args.timeout)
|
||||
if sequence % 20 == 0:
|
||||
print(f"sent frame {sequence} with {len(frame['packets'][2]['data']['participants'])} participants")
|
||||
except urllib.error.URLError as error:
|
||||
print(f"POST failed: {error}")
|
||||
|
||||
sequence += 1
|
||||
if args.once:
|
||||
break
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped shared-memory bridge.")
|
||||
finally:
|
||||
shared_memory.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
589
src/leaderboard.ts
Normal file
589
src/leaderboard.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import type { ParsedPacket } from "./parser.js";
|
||||
import { nationalityForDriver } from "./nationalities.js";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
type LeaderboardState = {
|
||||
previousProgress: Map<string, AnyRecord>;
|
||||
checkpointTimes: Map<string, Map<string, { time: number; observedAt: number }>>;
|
||||
driverCheckpointTimes: Map<string, number>;
|
||||
lapTimes: Map<string, { lastLapTime?: number; fastestLapTime?: number }>;
|
||||
sectorSplits: Map<string, AnyRecord>;
|
||||
sectorTimeSplits: Map<string, AnyRecord>;
|
||||
pendingSectorCompletions: Map<string, AnyRecord>;
|
||||
previousPositions: Map<string, number>;
|
||||
positionChanges: Map<string, AnyRecord>;
|
||||
lastTimingPacketNumber?: number;
|
||||
lastMaxLap?: number;
|
||||
};
|
||||
|
||||
type BaseRow = {
|
||||
slot: unknown;
|
||||
id: unknown;
|
||||
timing?: AnyRecord;
|
||||
};
|
||||
|
||||
const state: LeaderboardState = {
|
||||
previousProgress: new Map(),
|
||||
checkpointTimes: new Map(),
|
||||
driverCheckpointTimes: new Map(),
|
||||
lapTimes: new Map(),
|
||||
sectorSplits: new Map(),
|
||||
sectorTimeSplits: new Map(),
|
||||
pendingSectorCompletions: new Map(),
|
||||
previousPositions: new Map(),
|
||||
positionChanges: new Map()
|
||||
};
|
||||
|
||||
function packetData(latestByType: Map<string, ParsedPacket>, type: number): AnyRecord {
|
||||
return latestByType.get(String(type))?.data ?? {};
|
||||
}
|
||||
|
||||
function list(value: unknown): AnyRecord[] {
|
||||
return Array.isArray(value) ? value.filter((item) => item && typeof item === "object") : [];
|
||||
}
|
||||
|
||||
function validTime(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function mapBy(items: AnyRecord[], key: string, skipZero = false): Map<unknown, AnyRecord> {
|
||||
const map = new Map<unknown, AnyRecord>();
|
||||
for (const item of items) {
|
||||
const value = item[key];
|
||||
if (value === undefined || value === null || (skipZero && value === 0)) continue;
|
||||
if (!map.has(value)) map.set(value, item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function participantSlotCandidates(slot: unknown): unknown[] {
|
||||
if (typeof slot !== "number") return [slot];
|
||||
const candidates: unknown[] = [slot];
|
||||
if (slot >= 16) candidates.push(slot - 16);
|
||||
candidates.push(slot % 16);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
function lookupByCandidates(map: Map<unknown, AnyRecord>, candidates: unknown[]): AnyRecord | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const value = map.get(candidate);
|
||||
if (value) return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shortVehicleName(vehicleName: unknown): string | undefined {
|
||||
if (typeof vehicleName !== "string" || !vehicleName.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return vehicleName.trim().slice(0, 4).toUpperCase();
|
||||
}
|
||||
|
||||
function decodePitModeSchedule(
|
||||
value: unknown,
|
||||
pitModeOverride?: unknown,
|
||||
pitScheduleOverride?: unknown
|
||||
): AnyRecord | undefined {
|
||||
if (typeof value !== "number" &&
|
||||
typeof pitModeOverride !== "number" &&
|
||||
typeof pitScheduleOverride !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const packedValue = typeof value === "number" ? value : 0;
|
||||
const pitMode = typeof pitModeOverride === "number" ? pitModeOverride : packedValue & 0x07;
|
||||
const pitSchedule = typeof pitScheduleOverride === "number"
|
||||
? pitScheduleOverride
|
||||
: (packedValue >> 3) & 0x03;
|
||||
const phaseLabels: Record<number, string> = {
|
||||
1: "Pit entry",
|
||||
2: "Pit stop",
|
||||
3: "Pit exit",
|
||||
4: "Garage",
|
||||
5: "Garage exit"
|
||||
};
|
||||
const scheduledPenalty = pitSchedule === 2 || pitSchedule === 5
|
||||
? "drive-through"
|
||||
: pitSchedule === 6
|
||||
? "stop-go"
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
raw: packedValue,
|
||||
pitMode,
|
||||
pitSchedule,
|
||||
phase: scheduledPenalty && pitMode === 1 ? "Serving drive-through" : phaseLabels[pitMode],
|
||||
penalty: scheduledPenalty
|
||||
};
|
||||
}
|
||||
|
||||
function participantStatus(timing: AnyRecord | undefined): AnyRecord {
|
||||
if (!timing) {
|
||||
return { code: "unknown", label: "Unknown", tone: "warn" };
|
||||
}
|
||||
|
||||
const raceState = typeof timing.raceState === "number" ? timing.raceState : 0;
|
||||
const raceStateCode = raceState & 0x07;
|
||||
const invalidatedLap = Boolean(raceState & 0x08);
|
||||
const pit = decodePitModeSchedule(timing.pitModeSchedule, timing.pitMode, timing.pitSchedule);
|
||||
const flag = decodeHighestFlag(timing.highestFlag);
|
||||
|
||||
if (raceStateCode === 5 && timing.currentTime < 0) {
|
||||
return { code: "dnf", label: "DNF", tone: "warn" };
|
||||
}
|
||||
|
||||
if (raceStateCode === 4) {
|
||||
return { code: "dsq", label: "DSQ", tone: "warn" };
|
||||
}
|
||||
|
||||
if (raceStateCode === 3) {
|
||||
return { code: "finished", label: "Finished", tone: "good" };
|
||||
}
|
||||
|
||||
if (raceStateCode === 1 || timing.racePosition === 0 || timing.currentTime < 0) {
|
||||
return { code: "waiting", label: "Waiting", tone: "warn" };
|
||||
}
|
||||
|
||||
if (pit?.penalty === "drive-through") {
|
||||
return { code: "drive_through", label: "Drive-through", tone: "warn" };
|
||||
}
|
||||
|
||||
if (pit?.phase) {
|
||||
return { code: pit.phase.toLowerCase().replace(" ", "_"), label: pit.phase, tone: "good" };
|
||||
}
|
||||
|
||||
if (invalidatedLap) {
|
||||
return { code: "invalidated", label: "Invalid lap", tone: "warn" };
|
||||
}
|
||||
|
||||
if (flag?.observed === "blue") {
|
||||
return { code: "blue_flag", label: "Blue flag", tone: "warn" };
|
||||
}
|
||||
|
||||
if (timing.active === false) {
|
||||
return { code: "inactive", label: "Inactive", tone: "warn" };
|
||||
}
|
||||
|
||||
return { code: "racing", label: "Racing", tone: "good" };
|
||||
}
|
||||
|
||||
function decodeHighestFlag(value: unknown): AnyRecord | undefined {
|
||||
if (typeof value !== "number") return undefined;
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
colour: value & 0x07,
|
||||
reason: value >> 3,
|
||||
observed: value === 2
|
||||
? "blue"
|
||||
: value === 11
|
||||
? "checkered"
|
||||
: value === 10
|
||||
? "black"
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function checkpointLabel(lap: number, sector: number): string {
|
||||
return sector === 3 ? `L${lap} finish` : `L${lap} S${sector + 1}`;
|
||||
}
|
||||
|
||||
function sectorLabel(lap: number, sector: number): string {
|
||||
return `L${lap} S${sector === 3 ? 3 : sector + 1}`;
|
||||
}
|
||||
|
||||
function sectorArrayIndex(sector: number): number {
|
||||
return sector === 3 ? 2 : sector;
|
||||
}
|
||||
|
||||
function timingOrder(timings: AnyRecord[]): AnyRecord[] {
|
||||
return timings
|
||||
.filter((timing) => typeof timing.slot === "number")
|
||||
.slice()
|
||||
.sort((a, b) => (a.racePosition || 999) - (b.racePosition || 999));
|
||||
}
|
||||
|
||||
function roundSplit(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function lapTimeSplit(row: AnyRecord, reference: AnyRecord | undefined, field: string): AnyRecord | undefined {
|
||||
const rowTime = row[field];
|
||||
const referenceTime = reference?.[field];
|
||||
|
||||
if (!validTime(rowTime) || !validTime(referenceTime)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
comparedTo: `P${reference?.position ?? "-"}`,
|
||||
time: rowTime,
|
||||
referenceTime,
|
||||
delta: roundSplit(rowTime - referenceTime)
|
||||
};
|
||||
}
|
||||
|
||||
function resetRaceDerivedState() {
|
||||
state.previousProgress.clear();
|
||||
state.checkpointTimes.clear();
|
||||
state.driverCheckpointTimes.clear();
|
||||
state.lapTimes.clear();
|
||||
state.sectorSplits.clear();
|
||||
state.sectorTimeSplits.clear();
|
||||
state.pendingSectorCompletions.clear();
|
||||
state.previousPositions.clear();
|
||||
state.positionChanges.clear();
|
||||
}
|
||||
|
||||
function updateCachedLapTime(slot: unknown, lapTime: unknown) {
|
||||
if (!validTime(lapTime)) return;
|
||||
|
||||
const key = String(slot);
|
||||
const cached = state.lapTimes.get(key) ?? {};
|
||||
state.lapTimes.set(key, {
|
||||
lastLapTime: lapTime,
|
||||
fastestLapTime: validTime(cached.fastestLapTime)
|
||||
? Math.min(cached.fastestLapTime, lapTime)
|
||||
: lapTime
|
||||
});
|
||||
}
|
||||
|
||||
function shouldAcceptStatLapTime(timing: AnyRecord | undefined): boolean {
|
||||
if (!timing) return false;
|
||||
|
||||
const raceStateCode = typeof timing.raceState === "number" ? timing.raceState & 0x07 : undefined;
|
||||
if (raceStateCode === 1 || timing.racePosition === 0 || !validTime(timing.currentTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof timing.currentLap === "number" && timing.currentLap > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return timing.currentTime > 10 || state.lapTimes.has(String(timing.slot));
|
||||
}
|
||||
|
||||
function updateCachedLapTimesFromStats(timings: AnyRecord[], stats: AnyRecord[]) {
|
||||
for (const stat of stats) {
|
||||
const timing = timings.find((item) =>
|
||||
item.slot === stat.slot ||
|
||||
(stat.mpParticipantIndex && item.mpParticipantIndex === stat.mpParticipantIndex)
|
||||
);
|
||||
|
||||
if (!shouldAcceptStatLapTime(timing)) continue;
|
||||
|
||||
const key = String(timing?.slot ?? stat.slot);
|
||||
const cached = state.lapTimes.get(key) ?? {};
|
||||
state.lapTimes.set(key, {
|
||||
lastLapTime: validTime(stat.lastLapTime) ? stat.lastLapTime : cached.lastLapTime,
|
||||
fastestLapTime: validTime(stat.fastestLapTime) ? stat.fastestLapTime : cached.fastestLapTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function maybeResetForNewRace(latestByType: Map<string, ParsedPacket>, timings: AnyRecord[]) {
|
||||
const timingPacket = latestByType.get("3");
|
||||
const packetNumber = timingPacket?.base.categoryPacketNumber;
|
||||
const laps = timings
|
||||
.map((timing) => timing.currentLap)
|
||||
.filter((lap): lap is number => typeof lap === "number");
|
||||
const maxLap = laps.length ? Math.max(...laps) : undefined;
|
||||
const minLap = laps.length ? Math.min(...laps) : undefined;
|
||||
const packetCounterReset = typeof packetNumber === "number" &&
|
||||
typeof state.lastTimingPacketNumber === "number" &&
|
||||
packetNumber < state.lastTimingPacketNumber;
|
||||
const lapCounterReset = typeof maxLap === "number" &&
|
||||
typeof state.lastMaxLap === "number" &&
|
||||
maxLap < state.lastMaxLap;
|
||||
const newRaceGrid = minLap === 1 &&
|
||||
timings.some((timing) =>
|
||||
timing.currentLap === 1 &&
|
||||
timing.sector === 0 &&
|
||||
validTime(timing.currentTime) &&
|
||||
timing.currentTime < 5
|
||||
) &&
|
||||
(state.lapTimes.size > 0 || state.sectorSplits.size > 0);
|
||||
|
||||
if (packetCounterReset || lapCounterReset || newRaceGrid) {
|
||||
resetRaceDerivedState();
|
||||
}
|
||||
|
||||
if (typeof packetNumber === "number") state.lastTimingPacketNumber = packetNumber;
|
||||
if (typeof maxLap === "number") state.lastMaxLap = maxLap;
|
||||
}
|
||||
|
||||
function comparisonForCheckpoint(
|
||||
timing: AnyRecord,
|
||||
orderedTimings: AnyRecord[],
|
||||
checkpointKey: string,
|
||||
sector: number
|
||||
): AnyRecord | null {
|
||||
const orderIndex = orderedTimings.findIndex((item) => item.slot === timing.slot);
|
||||
const isLeader = timing.racePosition === 1 || orderIndex === 0;
|
||||
|
||||
if (isLeader) {
|
||||
const previousOwnTime = state.driverCheckpointTimes.get(`${timing.slot}:${sector}`);
|
||||
return previousOwnTime === undefined
|
||||
? null
|
||||
: { kind: "self", label: "previous run", time: previousOwnTime };
|
||||
}
|
||||
|
||||
const ahead = orderedTimings[orderIndex - 1];
|
||||
if (!ahead) return null;
|
||||
|
||||
const aheadCheckpoint = state.checkpointTimes.get(checkpointKey)?.get(String(ahead.slot));
|
||||
return aheadCheckpoint?.observedAt === undefined
|
||||
? null
|
||||
: { kind: "ahead", label: `P${ahead.racePosition || orderIndex}`, observedAt: aheadCheckpoint.observedAt };
|
||||
}
|
||||
|
||||
function applyPersonalSectorSplit(slot: string, completion: AnyRecord, stat: AnyRecord): boolean {
|
||||
const sectorIndex = sectorArrayIndex(completion.completedSector);
|
||||
const lastSectorTime = stat.lastSectorTime;
|
||||
const referenceTime = Array.isArray(stat.fastestSectors) ? stat.fastestSectors[sectorIndex] : undefined;
|
||||
|
||||
if (!validTime(lastSectorTime) || !validTime(referenceTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
validTime(completion.expectedSectorTime) &&
|
||||
Math.abs(lastSectorTime - completion.expectedSectorTime) > 0.15
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.sectorTimeSplits.set(slot, {
|
||||
source: "timeStats",
|
||||
basis: "personalBestSector",
|
||||
checkpoint: sectorLabel(completion.completedLap, completion.completedSector),
|
||||
comparedTo: "personal best",
|
||||
time: lastSectorTime,
|
||||
referenceTime,
|
||||
delta: roundSplit(lastSectorTime - referenceTime)
|
||||
});
|
||||
state.pendingSectorCompletions.delete(slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updatePersonalSectorSplitsFromStats(stats: AnyRecord[], observedAt: number) {
|
||||
for (const [slot, completion] of state.pendingSectorCompletions) {
|
||||
const stat = stats.find((item) =>
|
||||
String(item.slot) === slot ||
|
||||
(completion.mpParticipantIndex && item.mpParticipantIndex === completion.mpParticipantIndex)
|
||||
);
|
||||
|
||||
if (stat) {
|
||||
applyPersonalSectorSplit(slot, completion, stat);
|
||||
}
|
||||
|
||||
if (observedAt - completion.observedAt > 5) {
|
||||
state.pendingSectorCompletions.delete(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLeaderboard(latestByType: Map<string, ParsedPacket>) {
|
||||
const timings = list(packetData(latestByType, 3).participants);
|
||||
const stats = list(packetData(latestByType, 7).participants);
|
||||
const orderedTimings = timingOrder(timings);
|
||||
const observedAt = Date.now() / 1000;
|
||||
|
||||
maybeResetForNewRace(latestByType, timings);
|
||||
updateCachedLapTimesFromStats(timings, stats);
|
||||
updatePersonalSectorSplitsFromStats(stats, observedAt);
|
||||
|
||||
for (const timing of timings) {
|
||||
const slot = String(timing.slot);
|
||||
const previous = state.previousProgress.get(slot);
|
||||
const current = { lap: timing.currentLap, sector: timing.sector };
|
||||
|
||||
if (typeof timing.racePosition === "number" && timing.racePosition > 0) {
|
||||
const previousPosition = state.previousPositions.get(slot);
|
||||
if (previousPosition !== undefined && previousPosition !== timing.racePosition) {
|
||||
const delta = previousPosition - timing.racePosition;
|
||||
state.positionChanges.set(slot, {
|
||||
direction: delta > 0 ? "up" : "down",
|
||||
places: Math.abs(delta),
|
||||
previousPosition,
|
||||
currentPosition: timing.racePosition,
|
||||
changedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
state.previousPositions.set(slot, timing.racePosition);
|
||||
}
|
||||
|
||||
if (
|
||||
previous &&
|
||||
typeof previous.lap === "number" &&
|
||||
typeof previous.sector === "number" &&
|
||||
typeof current.lap === "number" &&
|
||||
typeof current.sector === "number" &&
|
||||
(previous.lap !== current.lap || previous.sector !== current.sector)
|
||||
) {
|
||||
const completedLap = current.lap > previous.lap ? previous.lap : current.lap;
|
||||
const completedSector = current.lap > previous.lap ? 3 : previous.sector;
|
||||
const checkpointKey = `${completedLap}:${completedSector}`;
|
||||
const stat = stats.find((item) =>
|
||||
item.slot === timing.slot ||
|
||||
(timing.mpParticipantIndex && item.mpParticipantIndex === timing.mpParticipantIndex)
|
||||
);
|
||||
const checkpointTime = current.lap > previous.lap ? stat?.lastLapTime : timing.currentTime;
|
||||
const fallbackLapTime = current.lap > previous.lap ? previous.currentTime : undefined;
|
||||
const effectiveCheckpointTime = validTime(checkpointTime) ? checkpointTime : fallbackLapTime;
|
||||
|
||||
if (current.lap > previous.lap) {
|
||||
updateCachedLapTime(timing.slot, effectiveCheckpointTime);
|
||||
}
|
||||
|
||||
if (validTime(effectiveCheckpointTime)) {
|
||||
state.positionChanges.delete(slot);
|
||||
|
||||
const completion = {
|
||||
completedLap,
|
||||
completedSector,
|
||||
expectedSectorTime: previous.currentSectorTime,
|
||||
mpParticipantIndex: timing.mpParticipantIndex,
|
||||
observedAt
|
||||
};
|
||||
const accepted = stat ? applyPersonalSectorSplit(slot, completion, stat) : false;
|
||||
if (!accepted) {
|
||||
state.pendingSectorCompletions.set(slot, completion);
|
||||
}
|
||||
|
||||
const checkpoint = state.checkpointTimes.get(checkpointKey) ?? new Map();
|
||||
const comparison = comparisonForCheckpoint(timing, orderedTimings, checkpointKey, completedSector);
|
||||
checkpoint.set(slot, { time: effectiveCheckpointTime, observedAt });
|
||||
state.checkpointTimes.set(checkpointKey, checkpoint);
|
||||
state.driverCheckpointTimes.set(`${slot}:${completedSector}`, effectiveCheckpointTime);
|
||||
|
||||
const delta = comparison
|
||||
? comparison.kind === "self"
|
||||
? effectiveCheckpointTime - comparison.time
|
||||
: -(observedAt - comparison.observedAt)
|
||||
: undefined;
|
||||
|
||||
state.sectorSplits.set(slot, {
|
||||
source: "checkpoint",
|
||||
basis: "raceOrder",
|
||||
checkpoint: checkpointLabel(completedLap, completedSector),
|
||||
comparedTo: comparison?.label,
|
||||
time: effectiveCheckpointTime,
|
||||
delta: typeof delta === "number" ? Math.round(delta * 1000) / 1000 : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.previousProgress.set(slot, {
|
||||
...current,
|
||||
currentTime: timing.currentTime,
|
||||
currentSectorTime: timing.currentSectorTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getLeaderboard(latestByType: Map<string, ParsedPacket>, recentPackets: ParsedPacket[]) {
|
||||
const names = list(packetData(latestByType, 2).participants);
|
||||
const timings = list(packetData(latestByType, 3).participants);
|
||||
const stats = list(packetData(latestByType, 7).participants);
|
||||
const vehicles = list(packetData(latestByType, 8).vehicles);
|
||||
const race = packetData(latestByType, 1);
|
||||
const game = packetData(latestByType, 4);
|
||||
const timingPacket = packetData(latestByType, 3);
|
||||
|
||||
const nameMap = mapBy(names, "index", true);
|
||||
const nameSlotMap = mapBy(names, "slot");
|
||||
const nameLocalSlotMap = mapBy(names, "localSlot");
|
||||
const timingMap = mapBy(timings, "mpParticipantIndex", true);
|
||||
const statsMap = mapBy(stats, "mpParticipantIndex", true);
|
||||
const statsSlotMap = mapBy(stats, "slot");
|
||||
const vehicleMap = mapBy(vehicles, "index", true);
|
||||
|
||||
const baseRows: BaseRow[] = timings.length
|
||||
? timings.map((timing) => ({ slot: timing.slot, id: timing.mpParticipantIndex, timing }))
|
||||
: names.map((name) => ({ slot: name.slot, id: name.index }));
|
||||
|
||||
const seen = new Set(baseRows.map((row) => `${row.slot}:${row.id}`));
|
||||
for (const stat of stats) {
|
||||
const key = `${stat.slot}:${stat.mpParticipantIndex}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
baseRows.push({ slot: stat.slot, id: stat.mpParticipantIndex });
|
||||
}
|
||||
}
|
||||
|
||||
const rows: AnyRecord[] = baseRows.map((base, fallbackIndex) => {
|
||||
const id = base.id;
|
||||
const slot = base.slot ?? fallbackIndex;
|
||||
const slotIndex = typeof slot === "number" ? slot : fallbackIndex;
|
||||
const timing = base.timing || (id ? timingMap.get(id) : undefined) || timings[slotIndex];
|
||||
const name = (id ? nameMap.get(id) : undefined) ||
|
||||
lookupByCandidates(nameSlotMap, participantSlotCandidates(slot)) ||
|
||||
lookupByCandidates(nameLocalSlotMap, participantSlotCandidates(slot));
|
||||
const stat = (id ? statsMap.get(id) : undefined) || statsSlotMap.get(slot);
|
||||
const vehicle = vehicleMap.get(timing?.carIndex);
|
||||
const cachedLap = state.lapTimes.get(String(slot));
|
||||
|
||||
return {
|
||||
id: id || slot,
|
||||
slot,
|
||||
sortPosition: timing?.racePosition || 999 + fallbackIndex,
|
||||
position: timing?.racePosition,
|
||||
active: timing?.active,
|
||||
status: participantStatus(timing),
|
||||
driver: name?.name || `Driver ${fallbackIndex + 1}`,
|
||||
nationality: nationalityForDriver(name?.name, name?.nationality),
|
||||
vehicle: vehicle?.name,
|
||||
vehicleShortName: shortVehicleName(vehicle?.name),
|
||||
lap: timing?.currentLap,
|
||||
sector: timing?.sector,
|
||||
sectorRaw: timing?.sectorRaw,
|
||||
sectorExtraPrecision: timing?.sectorExtraPrecision,
|
||||
currentTime: timing?.currentTime,
|
||||
currentSectorTime: timing?.currentSectorTime,
|
||||
checkpointSplit: state.sectorSplits.get(String(slot)),
|
||||
sectorTimeSplit: state.sectorTimeSplits.get(String(slot)),
|
||||
positionChange: state.positionChanges.get(String(slot)),
|
||||
lastLapTime: cachedLap?.lastLapTime,
|
||||
fastestLapTime: cachedLap?.fastestLapTime,
|
||||
fastestSectors: stat?.fastestSectors,
|
||||
pitModeSchedule: timing?.pitModeSchedule,
|
||||
pit: decodePitModeSchedule(timing?.pitModeSchedule, timing?.pitMode, timing?.pitSchedule),
|
||||
highestFlag: timing?.highestFlag,
|
||||
flag: decodeHighestFlag(timing?.highestFlag),
|
||||
raceState: timing?.raceState,
|
||||
raceStateCode: typeof timing?.raceState === "number" ? timing.raceState & 0x07 : undefined,
|
||||
invalidatedLap: typeof timing?.raceState === "number" ? Boolean(timing.raceState & 0x08) : undefined,
|
||||
carIndex: timing?.carIndex
|
||||
};
|
||||
}).sort((a, b) => a.sortPosition - b.sortPosition);
|
||||
|
||||
const leader = rows[0];
|
||||
rows.forEach((row, index) => {
|
||||
const reference = index === 0 ? undefined : leader;
|
||||
row.latestLapSplit = lapTimeSplit(row, reference, "lastLapTime");
|
||||
row.bestLapSplit = lapTimeSplit(row, reference, "fastestLapTime");
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
note: rows.length
|
||||
? `${rows.length} participant${rows.length === 1 ? "" : "s"} merged from UDP packets`
|
||||
: "Waiting for participant or timing packets",
|
||||
session: {
|
||||
trackName: [
|
||||
race.translatedTrackLocation || race.trackLocation,
|
||||
race.translatedTrackVariation || race.trackVariation
|
||||
].filter(Boolean).join(" - ") || "-",
|
||||
sessionType: game.sessionStateName || "-",
|
||||
timeLeft: timingPacket.eventTimeRemaining,
|
||||
weather: game.ambientTemperature === undefined
|
||||
? "-"
|
||||
: `${game.ambientTemperature} C air / ${game.trackTemperature} C track / rain ${game.rainDensity}`,
|
||||
lastUpdate: recentPackets[0]?.receivedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
67
src/nationalities.ts
Normal file
67
src/nationalities.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type NationalityInfo = {
|
||||
code: string;
|
||||
country: string;
|
||||
source: "udp" | "override";
|
||||
};
|
||||
|
||||
const aiNationalityOverrides = new Map<string, Omit<NationalityInfo, "source">>([
|
||||
["Stefano Pecchio", { code: "GBR", country: "United Kingdom" }],
|
||||
["David Haggar", { code: "FRA", country: "France" }],
|
||||
["Hans-Rolf Schade", { code: "DEU", country: "Germany" }],
|
||||
["Martin Franek", { code: "JPN", country: "Japan" }],
|
||||
["Martin G Webb", { code: "USA", country: "United States" }],
|
||||
["Brook Murray", { code: "DEU", country: "Germany" }],
|
||||
["Frank Lehmann", { code: "DEU", country: "Germany" }],
|
||||
["Tony Rickard", { code: "GBR", country: "United Kingdom" }],
|
||||
["Chris Rae", { code: "DEU", country: "Germany" }],
|
||||
["Keith Brunnenkant", { code: "GBR", country: "United Kingdom" }],
|
||||
["Jens Schmitt", { code: "USA", country: "United States" }],
|
||||
["Andy Garton", { code: "USA", country: "United States" }],
|
||||
["Rod Chong", { code: "USA", country: "United States" }],
|
||||
["Vittorio Rapa", { code: "USA", country: "United States" }],
|
||||
["Nathan Kammerud", { code: "USA", country: "United States" }],
|
||||
["Peter Stuart", { code: "USA", country: "United States" }],
|
||||
["Martin Webb", { code: "USA", country: "United States" }],
|
||||
["Kay Schurig", { code: "USA", country: "United States" }],
|
||||
["Xristo Papageorgas", { code: "USA", country: "United States" }],
|
||||
["Graham Ritter", { code: "USA", country: "United States" }],
|
||||
["Michael Steiner", { code: "DEU", country: "Germany" }],
|
||||
["Victor Moraal", { code: "FRA", country: "France" }],
|
||||
["Roucourt Rony", { code: "USA", country: "United States" }],
|
||||
["Stefan Forster", { code: "DEU", country: "Germany" }],
|
||||
["Kirk Kosinski", { code: "USA", country: "United States" }],
|
||||
["Jon Large", { code: "FRA", country: "France" }],
|
||||
["Sven Bender", { code: "DEU", country: "Germany" }],
|
||||
["Gary Hunt", { code: "DEU", country: "Germany" }],
|
||||
["Spencer McCarthy", { code: "USA", country: "United States" }],
|
||||
["Chris Stenersen", { code: "DEU", country: "Germany" }],
|
||||
["Kevin McKenzie", { code: "DEU", country: "Germany" }],
|
||||
["Mark Turnbull", { code: "DEU", country: "Germany" }],
|
||||
["Percy Veer", { code: "DEU", country: "Germany" }],
|
||||
["Gareth Robinson", { code: "DEU", country: "Germany" }],
|
||||
["Oliver Kaiser", { code: "DEU", country: "Germany" }],
|
||||
["Gareth Harwood", { code: "DEU", country: "Germany" }],
|
||||
["Rene Owara", { code: "DEU", country: "Germany" }],
|
||||
["Holzer Rene", { code: "DEU", country: "Germany" }],
|
||||
["Chrisi Schmidt", { code: "DEU", country: "Germany" }],
|
||||
["John Atkinson", { code: "DEU", country: "Germany" }],
|
||||
["Filipe Nunes", { code: "JPN", country: "Japan" }],
|
||||
["INS3RT", { code: "DEU", country: "Germany" }]
|
||||
]);
|
||||
|
||||
export function nationalityForDriver(name: unknown, udpNationality: unknown): NationalityInfo | undefined {
|
||||
if (typeof udpNationality === "number" && udpNationality > 0) {
|
||||
return {
|
||||
code: String(udpNationality),
|
||||
country: `UDP nationality ${udpNationality}`,
|
||||
source: "udp"
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof name !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const override = aiNationalityOverrides.get(name);
|
||||
return override ? { ...override, source: "override" } : undefined;
|
||||
}
|
||||
397
src/parser.ts
Normal file
397
src/parser.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
export const UDP_PORT = 5606;
|
||||
export const MAX_PACKET_SIZE = 1500;
|
||||
|
||||
export enum PacketType {
|
||||
CarPhysics = 0,
|
||||
RaceDefinition = 1,
|
||||
Participants = 2,
|
||||
Timings = 3,
|
||||
GameState = 4,
|
||||
WeatherState = 5,
|
||||
VehicleNames = 6,
|
||||
TimeStats = 7,
|
||||
ParticipantVehicleNames = 8
|
||||
}
|
||||
|
||||
export type PacketBase = {
|
||||
packetNumber: number;
|
||||
categoryPacketNumber: number;
|
||||
partialPacketIndex: number;
|
||||
partialPacketNumber: number;
|
||||
packetType: PacketType;
|
||||
packetVersion: number;
|
||||
};
|
||||
|
||||
export type ParsedPacket = {
|
||||
receivedAt: string;
|
||||
source: string;
|
||||
base: PacketBase;
|
||||
name: string;
|
||||
size: number;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const packetNames: Record<number, string> = {
|
||||
[PacketType.CarPhysics]: "Car Physics",
|
||||
[PacketType.RaceDefinition]: "Race Definition",
|
||||
[PacketType.Participants]: "Participants",
|
||||
[PacketType.Timings]: "Timings",
|
||||
[PacketType.GameState]: "Game State",
|
||||
[PacketType.WeatherState]: "Weather State",
|
||||
[PacketType.VehicleNames]: "Vehicle Names",
|
||||
[PacketType.TimeStats]: "Time Stats",
|
||||
[PacketType.ParticipantVehicleNames]: "Participant Vehicle Names"
|
||||
};
|
||||
|
||||
function readBase(buffer: Buffer): PacketBase {
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function readFloatArray(buffer: Buffer, offset: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
round(buffer.readFloatLE(offset + index * 4))
|
||||
);
|
||||
}
|
||||
|
||||
function readUInt8Array(buffer: Buffer, offset: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) => buffer.readUInt8(offset + index));
|
||||
}
|
||||
|
||||
function readUInt16Array(buffer: Buffer, offset: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
buffer.readUInt16LE(offset + index * 2)
|
||||
);
|
||||
}
|
||||
|
||||
function readInt16Array(buffer: Buffer, offset: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
buffer.readInt16LE(offset + index * 2)
|
||||
);
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function gearFromPacked(value: number): string {
|
||||
const gear = value & 0x0f;
|
||||
if (gear === 0) return "N";
|
||||
if (gear === 15) return "R";
|
||||
return String(gear);
|
||||
}
|
||||
|
||||
function parseTelemetry(buffer: Buffer): Record<string, unknown> {
|
||||
return {
|
||||
viewedParticipantIndex: buffer.readInt8(12),
|
||||
input: {
|
||||
throttle: buffer.readUInt8(13),
|
||||
brake: buffer.readUInt8(14),
|
||||
steering: buffer.readInt8(15),
|
||||
clutch: buffer.readUInt8(16)
|
||||
},
|
||||
car: {
|
||||
flags: buffer.readUInt8(17),
|
||||
oilTempCelsius: buffer.readInt16LE(18),
|
||||
oilPressureKPa: buffer.readUInt16LE(20),
|
||||
waterTempCelsius: buffer.readInt16LE(22),
|
||||
waterPressureKpa: buffer.readUInt16LE(24),
|
||||
fuelPressureKpa: buffer.readUInt16LE(26),
|
||||
fuelCapacity: buffer.readUInt8(28),
|
||||
brake: buffer.readUInt8(29),
|
||||
throttle: buffer.readUInt8(30),
|
||||
clutch: buffer.readUInt8(31),
|
||||
fuelLevel: round(buffer.readFloatLE(32)),
|
||||
speedMps: round(buffer.readFloatLE(36)),
|
||||
speedKph: round(buffer.readFloatLE(36) * 3.6),
|
||||
rpm: buffer.readUInt16LE(40),
|
||||
maxRpm: buffer.readUInt16LE(42),
|
||||
steering: buffer.readInt8(44),
|
||||
gear: gearFromPacked(buffer.readUInt8(45)),
|
||||
numGears: buffer.readUInt8(45) >> 4,
|
||||
boostAmount: buffer.readUInt8(46),
|
||||
crashState: buffer.readUInt8(47),
|
||||
odometerKM: round(buffer.readFloatLE(48)),
|
||||
brakeBias: buffer.readUInt8(554)
|
||||
},
|
||||
vectors: {
|
||||
orientation: readFloatArray(buffer, 52, 3),
|
||||
localVelocity: readFloatArray(buffer, 64, 3),
|
||||
worldVelocity: readFloatArray(buffer, 76, 3),
|
||||
angularVelocity: readFloatArray(buffer, 88, 3),
|
||||
localAcceleration: readFloatArray(buffer, 100, 3),
|
||||
worldAcceleration: readFloatArray(buffer, 112, 3),
|
||||
extentsCentre: readFloatArray(buffer, 124, 3),
|
||||
fullPosition: readFloatArray(buffer, 542, 3)
|
||||
},
|
||||
tyres: {
|
||||
flags: readUInt8Array(buffer, 136, 4),
|
||||
terrain: readUInt8Array(buffer, 140, 4),
|
||||
y: readFloatArray(buffer, 144, 4),
|
||||
rps: readFloatArray(buffer, 160, 4),
|
||||
temp: readUInt8Array(buffer, 176, 4),
|
||||
heightAboveGround: readFloatArray(buffer, 180, 4),
|
||||
wear: readUInt8Array(buffer, 196, 4),
|
||||
brakeTempCelsius: readInt16Array(buffer, 208, 4),
|
||||
treadTemp: readUInt16Array(buffer, 216, 4),
|
||||
layerTemp: readUInt16Array(buffer, 224, 4),
|
||||
carcassTemp: readUInt16Array(buffer, 232, 4),
|
||||
rimTemp: readUInt16Array(buffer, 240, 4),
|
||||
internalAirTemp: readUInt16Array(buffer, 248, 4),
|
||||
tempLeft: readUInt16Array(buffer, 256, 4),
|
||||
tempCenter: readUInt16Array(buffer, 264, 4),
|
||||
tempRight: readUInt16Array(buffer, 272, 4),
|
||||
wheelLocalPositionY: readFloatArray(buffer, 280, 4),
|
||||
rideHeight: readFloatArray(buffer, 296, 4),
|
||||
suspensionTravel: readFloatArray(buffer, 312, 4),
|
||||
suspensionVelocity: readFloatArray(buffer, 328, 4),
|
||||
suspensionRideHeight: readUInt16Array(buffer, 344, 4),
|
||||
airPressure: readUInt16Array(buffer, 352, 4),
|
||||
compound: Array.from({ length: 4 }, (_, index) =>
|
||||
readCString(buffer, 378 + index * 40, 40)
|
||||
)
|
||||
},
|
||||
engine: {
|
||||
speed: round(buffer.readFloatLE(360)),
|
||||
torque: round(buffer.readFloatLE(364)),
|
||||
turboBoostPressure: round(buffer.readFloatLE(538)),
|
||||
wings: readUInt8Array(buffer, 368, 2),
|
||||
handBrake: buffer.readUInt8(370)
|
||||
},
|
||||
damage: {
|
||||
aero: buffer.readUInt8(371),
|
||||
engine: buffer.readUInt8(372),
|
||||
brake: readUInt8Array(buffer, 200, 4),
|
||||
suspension: readUInt8Array(buffer, 204, 4)
|
||||
},
|
||||
hardware: {
|
||||
joyPad0: buffer.readUInt32LE(376),
|
||||
dPad: buffer.readUInt8(377)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseRace(buffer: Buffer): Record<string, unknown> {
|
||||
return {
|
||||
worldFastestLapTime: round(buffer.readFloatLE(12)),
|
||||
personalFastestLapTime: round(buffer.readFloatLE(16)),
|
||||
personalFastestSectors: readFloatArray(buffer, 20, 3),
|
||||
worldFastestSectors: readFloatArray(buffer, 32, 3),
|
||||
trackLength: round(buffer.readFloatLE(44)),
|
||||
trackLocation: readCString(buffer, 48, 64),
|
||||
trackVariation: readCString(buffer, 112, 64),
|
||||
translatedTrackLocation: readCString(buffer, 176, 64),
|
||||
translatedTrackVariation: readCString(buffer, 240, 64),
|
||||
lapsTimeInEvent: buffer.readUInt16LE(304),
|
||||
enforcedPitStopLap: buffer.readInt8(306)
|
||||
};
|
||||
}
|
||||
|
||||
function parseParticipants(buffer: Buffer, base: PacketBase): Record<string, unknown> {
|
||||
const participantPacketOffset = Math.max(0, base.partialPacketIndex - 1);
|
||||
const names = Array.from({ length: 16 }, (_, index) => ({
|
||||
slot: participantPacketOffset * 16 + index,
|
||||
localSlot: index,
|
||||
partialPacketIndex: base.partialPacketIndex,
|
||||
name: readCString(buffer, 16 + index * 64, 64),
|
||||
nationality: buffer.readUInt32LE(1040 + index * 4),
|
||||
index: buffer.readUInt16LE(1104 + index * 2)
|
||||
})).filter((participant) => participant.name || participant.index !== 0);
|
||||
|
||||
return {
|
||||
participantsChangedTimestamp: buffer.readUInt32LE(12),
|
||||
participants: names
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimings(buffer: Buffer): Record<string, unknown> {
|
||||
const numParticipants = Math.max(0, Math.min(32, buffer.readInt8(12)));
|
||||
const participants = Array.from({ length: numParticipants }, (_, index) => {
|
||||
const offset = 33 + index * 32;
|
||||
const racePositionRaw = buffer.readUInt8(offset + 14);
|
||||
const sectorRaw = buffer.readUInt8(offset + 15);
|
||||
return {
|
||||
slot: index,
|
||||
worldPosition: readInt16Array(buffer, offset, 3),
|
||||
orientation: readInt16Array(buffer, offset + 6, 3),
|
||||
currentLapDistance: buffer.readUInt16LE(offset + 12),
|
||||
racePosition: racePositionRaw & 0x7f,
|
||||
active: Boolean(racePositionRaw & 0x80),
|
||||
sector: sectorRaw & 0x03,
|
||||
sectorRaw,
|
||||
sectorExtraPrecision: sectorRaw >> 2,
|
||||
highestFlag: buffer.readUInt8(offset + 16),
|
||||
pitModeSchedule: buffer.readUInt8(offset + 17),
|
||||
carIndex: buffer.readUInt16LE(offset + 18),
|
||||
raceState: buffer.readUInt8(offset + 20),
|
||||
currentLap: buffer.readUInt8(offset + 21),
|
||||
currentTime: round(buffer.readFloatLE(offset + 22)),
|
||||
currentSectorTime: round(buffer.readFloatLE(offset + 26)),
|
||||
mpParticipantIndex: buffer.readUInt16LE(offset + 30)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
numParticipants,
|
||||
participantsChangedTimestamp: buffer.readUInt32LE(13),
|
||||
eventTimeRemaining: round(buffer.readFloatLE(17)),
|
||||
splitTimeAhead: round(buffer.readFloatLE(21)),
|
||||
splitTimeBehind: round(buffer.readFloatLE(25)),
|
||||
splitTime: round(buffer.readFloatLE(29)),
|
||||
localParticipantIndex: buffer.readUInt16LE(1057),
|
||||
participants
|
||||
};
|
||||
}
|
||||
|
||||
function parseGameState(buffer: Buffer): Record<string, unknown> {
|
||||
const gameStateRaw = buffer.readUInt8(14);
|
||||
const gameState = gameStateRaw & 0x07;
|
||||
const sessionState = (gameStateRaw >> 4) & 0x07;
|
||||
const gameStateNames: Record<number, string> = {
|
||||
1: "Front end",
|
||||
2: "Playing",
|
||||
3: "Paused",
|
||||
4: "In menu"
|
||||
};
|
||||
const sessionStateNames: Record<number, string> = {
|
||||
0: "Invalid",
|
||||
1: "Practice",
|
||||
2: "Test",
|
||||
3: "Qualifying",
|
||||
4: "Formation lap",
|
||||
5: "Race",
|
||||
6: "Time attack"
|
||||
};
|
||||
|
||||
return {
|
||||
buildVersionNumber: buffer.readUInt16LE(12),
|
||||
gameStateRaw,
|
||||
gameState,
|
||||
gameStateName: gameStateNames[gameState],
|
||||
sessionState,
|
||||
sessionStateName: sessionStateNames[sessionState],
|
||||
ambientTemperature: buffer.readInt8(15),
|
||||
trackTemperature: buffer.readInt8(16),
|
||||
rainDensity: buffer.readUInt8(17),
|
||||
snowDensity: buffer.readUInt8(18),
|
||||
windSpeed: buffer.readInt8(19),
|
||||
windDirectionX: buffer.readInt8(20),
|
||||
windDirectionY: buffer.readInt8(21)
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimeStats(buffer: Buffer): Record<string, unknown> {
|
||||
const participants = Array.from({ length: 32 }, (_, index) => {
|
||||
const offset = 16 + index * 32;
|
||||
const fastestSectors = [
|
||||
round(buffer.readFloatLE(offset + 12)),
|
||||
round(buffer.readFloatLE(offset + 16)),
|
||||
round(buffer.readFloatLE(offset + 20))
|
||||
];
|
||||
return {
|
||||
slot: index,
|
||||
fastestLapTime: round(buffer.readFloatLE(offset)),
|
||||
lastLapTime: round(buffer.readFloatLE(offset + 4)),
|
||||
lastSectorTime: round(buffer.readFloatLE(offset + 8)),
|
||||
fastestSectors,
|
||||
participantOnlineRep: buffer.readUInt32LE(offset + 24),
|
||||
mpParticipantIndex: buffer.readUInt16LE(offset + 28)
|
||||
};
|
||||
}).filter((participant) =>
|
||||
participant.mpParticipantIndex !== 0 ||
|
||||
participant.fastestLapTime > 0 ||
|
||||
participant.lastLapTime > 0 ||
|
||||
participant.lastSectorTime > 0 ||
|
||||
participant.fastestSectors.some((time) => time > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
participantsChangedTimestamp: buffer.readUInt32LE(12),
|
||||
participants
|
||||
};
|
||||
}
|
||||
|
||||
function parseVehicleNames(buffer: Buffer): Record<string, unknown> {
|
||||
const isClassPacket = buffer.length >= 1452;
|
||||
if (isClassPacket) {
|
||||
return {
|
||||
classes: Array.from({ length: 60 }, (_, index) => {
|
||||
const offset = 12 + index * 24;
|
||||
return {
|
||||
classIndex: buffer.readUInt32LE(offset),
|
||||
name: readCString(buffer, offset + 4, 20)
|
||||
};
|
||||
}).filter((item) => item.name)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
vehicles: Array.from({ length: 16 }, (_, index) => {
|
||||
const offset = 12 + index * 72;
|
||||
return {
|
||||
index: buffer.readUInt16LE(offset),
|
||||
class: buffer.readUInt32LE(offset + 4),
|
||||
name: readCString(buffer, offset + 8, 64)
|
||||
};
|
||||
}).filter((item) => item.name || item.index !== 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePacket(buffer: Buffer, source: string): ParsedPacket {
|
||||
if (buffer.length < 12) {
|
||||
throw new Error(`Packet too small: ${buffer.length} bytes`);
|
||||
}
|
||||
|
||||
const base = readBase(buffer);
|
||||
let data: Record<string, unknown>;
|
||||
|
||||
switch (base.packetType) {
|
||||
case PacketType.CarPhysics:
|
||||
data = parseTelemetry(buffer);
|
||||
break;
|
||||
case PacketType.RaceDefinition:
|
||||
data = parseRace(buffer);
|
||||
break;
|
||||
case PacketType.Participants:
|
||||
data = parseParticipants(buffer, base);
|
||||
break;
|
||||
case PacketType.Timings:
|
||||
data = parseTimings(buffer);
|
||||
break;
|
||||
case PacketType.GameState:
|
||||
data = parseGameState(buffer);
|
||||
break;
|
||||
case PacketType.TimeStats:
|
||||
data = parseTimeStats(buffer);
|
||||
break;
|
||||
case PacketType.ParticipantVehicleNames:
|
||||
data = parseVehicleNames(buffer);
|
||||
break;
|
||||
default:
|
||||
data = {
|
||||
rawPacketType: base.packetType,
|
||||
message: "Parser for this packet type is not implemented yet."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
receivedAt: new Date().toISOString(),
|
||||
source,
|
||||
base,
|
||||
name: packetNames[base.packetType] ?? `Unknown (${base.packetType})`,
|
||||
size: buffer.length,
|
||||
data
|
||||
};
|
||||
}
|
||||
283
src/server.ts
Normal file
283
src/server.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import dgram from "node:dgram";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
||||
import { extname, join, normalize } from "node:path";
|
||||
import { getLeaderboard, updateLeaderboard } from "./leaderboard.js";
|
||||
import { parsePacket, UDP_PORT, type ParsedPacket } from "./parser.js";
|
||||
|
||||
const HTTP_PORT = Number(process.env.HTTP_PORT ?? 3000);
|
||||
const publicDir = join(process.cwd(), "public");
|
||||
const docsDir = join(process.cwd(), "docs");
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
const latestByType = new Map<string, ParsedPacket>();
|
||||
const recentPackets: ParsedPacket[] = [];
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8"
|
||||
};
|
||||
|
||||
function sendEvent(response: ServerResponse, event: string, payload: unknown) {
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function broadcast(packet: ParsedPacket) {
|
||||
const state = {
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets
|
||||
};
|
||||
const leaderboard = getLeaderboard(latestByType, recentPackets);
|
||||
|
||||
for (const client of sseClients) {
|
||||
sendEvent(client, "packet", { packet, state, leaderboard });
|
||||
sendEvent(client, "leaderboard", leaderboard);
|
||||
}
|
||||
}
|
||||
|
||||
function processPacket(packet: ParsedPacket) {
|
||||
const mergedPacket = mergeLatestPacket(packet);
|
||||
latestByType.set(String(mergedPacket.base.packetType), mergedPacket);
|
||||
recentPackets.unshift(packet);
|
||||
recentPackets.splice(50);
|
||||
updateLeaderboard(latestByType);
|
||||
broadcast(mergedPacket);
|
||||
}
|
||||
|
||||
function readRequestBody(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function isParsedPacket(value: unknown): value is ParsedPacket {
|
||||
if (!isRecord(value) || !isRecord(value.base) || !isRecord(value.data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof value.base.packetType === "number";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function records(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function mergeArrayByKey(
|
||||
previous: unknown,
|
||||
next: unknown,
|
||||
key: string,
|
||||
fallbackKey = "slot"
|
||||
): Record<string, unknown>[] {
|
||||
const merged = new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const item of [...records(previous), ...records(next)]) {
|
||||
const keyValue = item[key] ?? item[fallbackKey];
|
||||
if (keyValue === undefined || keyValue === null) continue;
|
||||
merged.set(String(keyValue), item);
|
||||
}
|
||||
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function mergeLatestPacket(packet: ParsedPacket): ParsedPacket {
|
||||
const existing = latestByType.get(String(packet.base.packetType));
|
||||
if (!existing) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
if (packet.base.packetType === 2) {
|
||||
return {
|
||||
...packet,
|
||||
data: {
|
||||
...existing.data,
|
||||
...packet.data,
|
||||
participants: mergeArrayByKey(
|
||||
existing.data.participants,
|
||||
packet.data.participants,
|
||||
"slot"
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (packet.base.packetType === 8) {
|
||||
return {
|
||||
...packet,
|
||||
data: {
|
||||
...existing.data,
|
||||
...packet.data,
|
||||
vehicles: mergeArrayByKey(existing.data.vehicles, packet.data.vehicles, "index"),
|
||||
classes: mergeArrayByKey(existing.data.classes, packet.data.classes, "classIndex")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return packet;
|
||||
}
|
||||
|
||||
function serveStatic(pathname: string, response: ServerResponse) {
|
||||
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
||||
const filePath = normalize(join(publicDir, requestedPath));
|
||||
|
||||
if (!filePath.startsWith(publicDir) || !existsSync(filePath)) {
|
||||
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": contentTypes[extname(filePath)] ?? "application/octet-stream"
|
||||
});
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
function serveFile(filePath: string, response: ServerResponse, contentType?: string) {
|
||||
if (!existsSync(filePath)) {
|
||||
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": contentType ?? contentTypes[extname(filePath)] ?? "application/octet-stream"
|
||||
});
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
const httpServer = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204, {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||||
"access-control-allow-headers": "content-type"
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/events") {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.write("\n");
|
||||
sseClients.add(response);
|
||||
sendEvent(response, "state", {
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets,
|
||||
leaderboard: getLeaderboard(latestByType, recentPackets)
|
||||
});
|
||||
request.on("close", () => sseClients.delete(response));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/state") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/leaderboard") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(getLeaderboard(latestByType, recentPackets)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/ingest/shared-memory" && request.method === "POST") {
|
||||
void readRequestBody(request)
|
||||
.then((body) => {
|
||||
const payload = JSON.parse(body) as unknown;
|
||||
const incomingPackets = isRecord(payload) && Array.isArray(payload.packets)
|
||||
? payload.packets.filter(isParsedPacket)
|
||||
: [];
|
||||
|
||||
if (!incomingPackets.length) {
|
||||
response.writeHead(400, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({ ok: false, error: "Expected a packets array." }));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const packet of incomingPackets) {
|
||||
processPacket({
|
||||
...packet,
|
||||
receivedAt: packet.receivedAt ?? new Date().toISOString(),
|
||||
source: packet.source || "shared-memory"
|
||||
});
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({ ok: true, packets: incomingPackets.length }));
|
||||
})
|
||||
.catch((error) => {
|
||||
response.writeHead(400, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/openapi.json") {
|
||||
serveFile(join(publicDir, "openapi.json"), response, "application/json; charset=utf-8");
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/schemas/udp-packets.schema.json") {
|
||||
serveFile(
|
||||
join(docsDir, "udp-packets.schema.json"),
|
||||
response,
|
||||
"application/schema+json; charset=utf-8"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(url.pathname, response);
|
||||
});
|
||||
|
||||
const udpServer = dgram.createSocket("udp4");
|
||||
|
||||
udpServer.on("message", (message, remote) => {
|
||||
try {
|
||||
const packet = parsePacket(message, `${remote.address}:${remote.port}`);
|
||||
processPacket(packet);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse UDP packet:", error);
|
||||
}
|
||||
});
|
||||
|
||||
udpServer.on("listening", () => {
|
||||
const address = udpServer.address();
|
||||
console.log(`UDP listener ready on ${address.address}:${address.port}`);
|
||||
});
|
||||
|
||||
udpServer.bind(UDP_PORT, "0.0.0.0");
|
||||
|
||||
httpServer.listen(HTTP_PORT, () => {
|
||||
console.log(`Frontend ready at http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user