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)