Added intial plugin

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

589
src/leaderboard.ts Normal file
View 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
View 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
View 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
View 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}`);
});