Added intial plugin
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user