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

179
scripts/capture_udp.py Normal file
View 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
View 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.")

View 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()