feat: add PCARS 1 & 2 UDP telemetry parser (port 5606)

- Auto-detect PCARS1 (1367-byte V1) vs PCARS2 (559-byte V2) packet format
- Parse speed, RPM, gear, inputs, tyres, engine temps, damage, weather
- Map PCARS-specific fields to TelemetryFrame (oil/water temp, damage, etc.)
- Add FeedSource::Pcars + 16 PCARS-specific Option fields to TelemetryFrame
- Port 5606 as optional bind-listen feed (like Telemetry Tool)
- 5 new unit tests for PCARS parser (32 total)
- Shared binary_utils module for all parsers
This commit is contained in:
Jan-Peter von Hunnius 2026-06-04 09:49:00 +02:00
parent 7a1c37fc86
commit a50d3af290
4 changed files with 570 additions and 0 deletions

View file

@ -41,6 +41,22 @@ pub fn read_i16(data: &[u8], offset: usize) -> i16 {
i16::from_le_bytes(buf)
}
/// Read a little-endian `u16` from the data at the given offset.
/// Returns 0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_u16(data: &[u8], offset: usize) -> u16 {
let buf: [u8; 2] = data
.get(offset..offset + 2)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 2]);
u16::from_le_bytes(buf)
}
/// Read a signed `i8` from the data at the given offset.
/// Returns 0 if the offset is out of bounds.
pub fn read_i8(data: &[u8], offset: usize) -> i8 {
data.get(offset).map(|&b| b as i8).unwrap_or(0)
}
/// Read a little-endian `f32` from the data at the given offset.
/// Returns 0.0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_f32(data: &[u8], offset: usize) -> f32 {

View file

@ -4,6 +4,7 @@ mod ui;
mod telemetry_frame;
mod parser_ksudp;
mod parser_telemetry_tool;
mod parser_pcars;
use feed_state::*;
use ui::*;
@ -69,6 +70,12 @@ static FEEDS: &[FeedConfig] = &[
label: "Telemetry Tool",
needs_handshake: false,
},
FeedConfig {
port: 5606,
name: "pcars",
label: "PCARS",
needs_handshake: false,
},
];
type Term = Terminal<CrosstermBackend<Stdout>>;
@ -265,6 +272,13 @@ async fn run_feed(
} else {
None
}
} else if feed.name == "pcars" {
// PCARS: auto-detect V1/V2 format
match parser_pcars::parse_pcars_packet(&buf[..len]) {
Ok(parser_pcars::PcarsPacket::V1Telemetry(f)) => Some(f),
Ok(parser_pcars::PcarsPacket::V2CarPhysics(f)) => Some(f),
_ => None,
}
} else {
// Telemetry Tool: parse only the first driver
parser_telemetry_tool::parse_first_driver(&buf[..len]).ok()

504
src/parser_pcars.rs Normal file
View file

@ -0,0 +1,504 @@
//! Binary parser for Project CARS 1 & 2 UDP telemetry packets (port 5606).
//!
//! Both PCARS1 and PCARS2 broadcast on port 5606. The parser auto-detects the
//! format based on packet structure:
//! - PCARS 1 (Format 1): ~1367 bytes, 3-byte header (u16 build + packed u8)
//! - PCARS 2 (Format 2): 12-byte header with packet_type at offset 10
use crate::binary_utils::*;
use crate::telemetry_frame::{FeedSource, TelemetryFrame, NUM_WHEELS};
// ── PCARS 1 constants ──────────────────────────────────────────────────
const V1_TELEMETRY_SIZE: usize = 1367;
const V1_PACKET_TYPE_TELEMETRY: u8 = 0;
// ── PCARS 2 constants ──────────────────────────────────────────────────
const V2_HEADER_SIZE: usize = 12;
const V2_CAR_PHYSICS_SIZE: usize = 559;
const V2_PACKET_TYPE_CAR_PHYSICS: u8 = 0;
/// Parse error type for PCARS packets.
#[derive(Debug)]
pub enum PcarsParseError {
/// Packet too short.
TooShort { actual: usize, needed: usize },
}
impl std::fmt::Display for PcarsParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PcarsParseError::TooShort { actual, needed } => {
write!(f, "packet too short: {} bytes (needed {})", actual, needed)
}
}
}
}
impl std::error::Error for PcarsParseError {}
/// Parsed result of a PCARS packet.
pub enum PcarsPacket {
/// PCARS 1 telemetry data (Format 1, type 0).
V1Telemetry(TelemetryFrame),
/// PCARS 2 car physics data (Format 2, type 0).
V2CarPhysics(TelemetryFrame),
/// PCARS 2 non-telemetry packet (timings, game state, etc.) — ignored.
V2Other,
/// Unrecognised packet.
Unknown,
}
/// Read a single byte as u8 at the given offset.
fn read_u8_at(data: &[u8], offset: usize) -> u8 {
data.get(offset).copied().unwrap_or(0)
}
/// Read a single byte as i8 at the given offset.
fn read_i8_at(data: &[u8], offset: usize) -> i8 {
data.get(offset).copied().unwrap_or(0) as i8
}
/// Read 4 consecutive u8 values starting at offset.
fn read_u8_4(data: &[u8], offset: usize) -> [u8; NUM_WHEELS] {
let mut arr = [0u8; NUM_WHEELS];
for i in 0..NUM_WHEELS {
arr[i] = read_u8_at(data, offset + i);
}
arr
}
/// Read 4 consecutive i16 values starting at offset.
fn read_i16_4(data: &[u8], offset: usize) -> [i16; NUM_WHEELS] {
let mut arr = [0i16; NUM_WHEELS];
for i in 0..NUM_WHEELS {
arr[i] = read_i16(data, offset + i * 2);
}
arr
}
/// Normalize u8 (0–255) to f32 (0.0–1.0).
fn normalize_u8(v: u8) -> f32 {
v as f32 / 255.0
}
/// Normalize int8 (-127..127) to f32 (-1.0..1.0).
fn normalize_i8(v: i8) -> f32 {
v as f32 / 127.0
}
/// Auto-detect and parse a PCARS UDP packet.
pub fn parse_pcars_packet(data: &[u8]) -> Result<PcarsPacket, PcarsParseError> {
if data.len() < 3 {
return Err(PcarsParseError::TooShort { actual: data.len(), needed: 3 });
}
// Detection strategy:
// PCARS 1 telemetry packets are ~1367 bytes — much larger than any V2 packet.
// PCARS 2 packets have a 12-byte header with packet_type at offset 10.
//
// Check V1 first for large packets (V2 never produces packets > ~1100 bytes).
// Try V1 format: check packet type from packed byte at offset 2
let seq_type = read_u8_at(data, 2);
let v1_packet_type = seq_type & 0x03;
if v1_packet_type == V1_PACKET_TYPE_TELEMETRY && data.len() >= V1_TELEMETRY_SIZE {
return Ok(PcarsPacket::V1Telemetry(parse_v1_telemetry(data)?));
}
// Try V2 format: 12-byte header with packet_type at offset 10
if data.len() >= V2_HEADER_SIZE {
let packet_type = read_u8_at(data, 10);
if packet_type <= 8 {
match packet_type {
V2_PACKET_TYPE_CAR_PHYSICS if data.len() >= V2_CAR_PHYSICS_SIZE => {
return Ok(PcarsPacket::V2CarPhysics(parse_v2_car_physics(data)?));
}
_ => {
return Ok(PcarsPacket::V2Other);
}
}
}
}
Ok(PcarsPacket::Unknown)
}
/// Parse a PCARS 1 (Format 1) telemetry packet (1367 bytes).
fn parse_v1_telemetry(data: &[u8]) -> Result<TelemetryFrame, PcarsParseError> {
if data.len() < V1_TELEMETRY_SIZE {
return Err(PcarsParseError::TooShort { actual: data.len(), needed: V1_TELEMETRY_SIZE });
}
let speed_ms = read_f32(data, 120);
let speed_kmh = speed_ms * 3.6;
// Timing (f32 seconds → i32 ms)
let best_lap_s = read_f32(data, 12);
let last_lap_s = read_f32(data, 16);
let current_lap_s = read_f32(data, 20);
// Gear: packed byte at 128, gear in LSB 4 bits
let gear_packed = read_u8_at(data, 128);
let gear = (gear_packed & 0x0F) as i32;
// Inputs (u8 0-255, normalize to 0-1)
let throttle = normalize_u8(read_u8_at(data, 113));
let brake = normalize_u8(read_u8_at(data, 112));
let clutch = normalize_u8(read_u8_at(data, 114));
let steer = normalize_i8(read_i8_at(data, 115));
let rpm = read_u16(data, 124) as f32;
// Accelerations (local_acceleration at offset 184)
let acc_vert = read_f32(data, 184);
let acc_horiz = read_f32(data, 188);
let acc_front = read_f32(data, 192);
// Engine & damage
let aero_damage = read_u8_at(data, 456);
let engine_damage = read_u8_at(data, 457);
let ambient_temp = read_i8_at(data, 458);
let track_temp = read_i8_at(data, 459);
let rain_density = read_u8_at(data, 460);
// Track length
let track_length = read_f32(data, 1360);
let frame = TelemetryFrame {
source: FeedSource::Pcars,
raw_size: data.len(),
speed_kmh,
speed_mph: speed_ms * 2.236936,
speed_ms,
engine_rpm: rpm,
gear,
throttle,
brake,
clutch,
steer,
lap_time_ms: (current_lap_s * 1000.0) as i32,
last_lap_ms: (last_lap_s * 1000.0) as i32,
best_lap_ms: (best_lap_s * 1000.0) as i32,
lap_count: 0, // Not directly in V1 telemetry packet
world_x: 0.0,
world_y: 0.0,
world_z: 0.0,
spline_position: 0.0,
acc_g_vertical: acc_vert,
acc_g_horizontal: acc_horiz,
acc_g_frontal: acc_front,
is_abs_enabled: false,
is_abs_in_action: false,
is_tc_enabled: false,
is_tc_in_action: false,
is_in_pit: false,
is_engine_limiter_on: false,
// Per-wheel
wheel_angular_speed: read_f32_4(data, 244), // tyre_rps
slip_angle: [0.0; NUM_WHEELS],
slip_ratio: [0.0; NUM_WHEELS],
tyre_slip: [0.0; NUM_WHEELS],
nd_slip: [0.0; NUM_WHEELS],
suspension_height: read_f32_4(data, 408), // suspension_travel
tyre_dirty_level: [0.0; NUM_WHEELS],
// KSUDP-specific (defaults)
slip_angle_contact_patch: [0.0; NUM_WHEELS],
wheel_load: [0.0; NUM_WHEELS],
wheel_dy: read_f32_4(data, 228), // tyre_y
wheel_mz: [0.0; NUM_WHEELS],
camber_rad: [0.0; NUM_WHEELS],
tyre_radius: [0.0; NUM_WHEELS],
tyre_loaded_radius: [0.0; NUM_WHEELS],
cg_height: 0.0,
car_slope: 0.0,
car_position_normalized: 0.0,
// PCARS-specific
oil_temp_celsius: Some(read_i16(data, 100)),
water_temp_celsius: Some(read_i16(data, 104)),
fuel_capacity: Some(read_u8_at(data, 111)),
boost_amount: Some(read_u8_at(data, 129)),
crash_state: Some(read_u8_at(data, 131) & 0x07),
odometer_km: Some(read_f32(data, 132)),
aero_damage: Some(aero_damage),
engine_damage: Some(engine_damage),
ambient_temperature: Some(ambient_temp),
track_temperature: Some(track_temp),
rain_density: Some(rain_density),
track_length: Some(track_length),
full_position: None, // V1 does not have full 3D position
tyre_wear: Some(read_u8_4(data, 316)),
brake_temp_celsius: Some(read_i16_4(data, 328)),
suspension_damage: Some(read_u8_4(data, 324)),
..Default::default()
};
Ok(frame)
}
/// Parse a PCARS 2 (Format 2) car physics packet (559 bytes).
fn parse_v2_car_physics(data: &[u8]) -> Result<TelemetryFrame, PcarsParseError> {
if data.len() < V2_CAR_PHYSICS_SIZE {
return Err(PcarsParseError::TooShort { actual: data.len(), needed: V2_CAR_PHYSICS_SIZE });
}
let speed_ms = read_f32(data, 36);
let speed_kmh = speed_ms * 3.6;
// Gear: packed byte at 45, gear in LSB 4 bits
let gear_packed = read_u8_at(data, 45);
let gear = (gear_packed & 0x0F) as i32;
// Inputs
let throttle = normalize_u8(read_u8_at(data, 30));
let brake = normalize_u8(read_u8_at(data, 29));
let clutch = normalize_u8(read_u8_at(data, 31));
let steer = normalize_i8(read_i8_at(data, 44));
let rpm = read_u16(data, 40) as f32;
// Accelerations (local_acceleration at offset 100)
let acc_vert = read_f32(data, 100);
let acc_horiz = read_f32(data, 104);
let acc_front = read_f32(data, 108);
// Full 3D position (PCARS2 only, offset 542)
let full_pos = read_f32_3(data, 542);
// Tyre compounds (4 × 40 chars at offset 378)
let tyre_compound_str = read_string(data, 378, 40);
let frame = TelemetryFrame {
source: FeedSource::Pcars,
raw_size: data.len(),
speed_kmh,
speed_mph: speed_ms * 2.236936,
speed_ms,
engine_rpm: rpm,
gear,
throttle,
brake,
clutch,
steer,
lap_time_ms: 0, // V2 timing is in a separate packet (type 3)
last_lap_ms: 0,
best_lap_ms: 0,
lap_count: 0,
world_x: full_pos[0],
world_y: full_pos[1],
world_z: full_pos[2],
spline_position: 0.0,
acc_g_vertical: acc_vert,
acc_g_horizontal: acc_horiz,
acc_g_frontal: acc_front,
is_abs_enabled: false,
is_abs_in_action: false,
is_tc_enabled: false,
is_tc_in_action: false,
is_in_pit: false,
is_engine_limiter_on: false,
// Per-wheel
wheel_angular_speed: read_f32_4(data, 160), // tyre_rps
slip_angle: [0.0; NUM_WHEELS],
slip_ratio: [0.0; NUM_WHEELS],
tyre_slip: [0.0; NUM_WHEELS],
nd_slip: [0.0; NUM_WHEELS],
suspension_height: read_f32_4(data, 312), // suspension_travel
tyre_dirty_level: [0.0; NUM_WHEELS],
// KSUDP-specific (defaults)
slip_angle_contact_patch: [0.0; NUM_WHEELS],
wheel_load: [0.0; NUM_WHEELS],
wheel_dy: read_f32_4(data, 144), // tyre_y
wheel_mz: [0.0; NUM_WHEELS],
camber_rad: [0.0; NUM_WHEELS],
tyre_radius: [0.0; NUM_WHEELS],
tyre_loaded_radius: [0.0; NUM_WHEELS],
cg_height: 0.0,
car_slope: 0.0,
car_position_normalized: 0.0,
// Telemetry Tool-specific (none applicable)
tyre_compound: if tyre_compound_str.is_empty() { None } else { Some(tyre_compound_str) },
turbo_boost: Some(read_f32(data, 538)),
// PCARS-specific
oil_temp_celsius: Some(read_i16(data, 18)),
water_temp_celsius: Some(read_i16(data, 22)),
fuel_capacity: Some(read_u8_at(data, 28)),
boost_amount: Some(read_u8_at(data, 46)),
crash_state: Some(read_u8_at(data, 47)),
odometer_km: Some(read_f32(data, 48)),
aero_damage: Some(read_u8_at(data, 371)),
engine_damage: Some(read_u8_at(data, 372)),
ambient_temperature: None, // In Game State packet (type 4), not here
track_temperature: None,
rain_density: None,
track_length: None,
full_position: Some(full_pos),
tyre_wear: Some(read_u8_4(data, 196)),
brake_temp_celsius: Some(read_i16_4(data, 208)),
suspension_damage: Some(read_u8_4(data, 204)),
..Default::default()
};
Ok(frame)
}
/// Read a u16 at the given offset (little-endian).
fn read_u16(data: &[u8], offset: usize) -> u16 {
let buf: [u8; 2] = data
.get(offset..offset + 2)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 2]);
u16::from_le_bytes(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_v1_telemetry_packet() {
let mut packet = vec![0u8; V1_TELEMETRY_SIZE];
// Header: build_version = 1104, seq_type = 0 (type 0 = telemetry)
packet[0..2].copy_from_slice(&1104u16.to_le_bytes());
packet[2] = 0x00; // seq=0, type=0
// Speed = 50.0 m/s at offset 120
packet[120..124].copy_from_slice(&50.0f32.to_le_bytes());
// RPM = 7200 at offset 124
packet[124..126].copy_from_slice(&7200u16.to_le_bytes());
// Gear: packed at offset 128, gear=3 (meaning gear 2)
packet[128] = 0x30 | 0x03; // 3 gears-high, gear=3
// Throttle at 113 = 200/255
packet[113] = 200;
// Brake at 112 = 0
packet[112] = 0;
// Best lap at 12 = 78.123 seconds
packet[12..16].copy_from_slice(&78.123f32.to_le_bytes());
// Last lap at 16 = 80.456 seconds
packet[16..20].copy_from_slice(&80.456f32.to_le_bytes());
// Current time at 20 = 42.5 seconds
packet[20..24].copy_from_slice(&42.5f32.to_le_bytes());
let result = parse_pcars_packet(&packet).unwrap();
match result {
PcarsPacket::V1Telemetry(frame) => {
assert_eq!(frame.source, FeedSource::Pcars);
assert!((frame.speed_kmh - 180.0).abs() < 0.1);
assert!((frame.speed_ms - 50.0).abs() < 0.01);
assert!((frame.engine_rpm - 7200.0).abs() < 1.0);
assert_eq!(frame.gear, 3);
assert!((frame.throttle - (200.0 / 255.0)).abs() < 0.01);
assert!((frame.brake - 0.0).abs() < 0.01);
assert_eq!(frame.best_lap_ms, 78123);
assert_eq!(frame.last_lap_ms, 80456);
assert_eq!(frame.lap_time_ms, 42500);
assert_eq!(frame.oil_temp_celsius, Some(0)); // default
assert!(frame.aero_damage.is_some());
}
_ => panic!("Expected V1Telemetry"),
}
}
#[test]
fn test_v2_car_physics_packet() {
let mut packet = vec![0u8; V2_CAR_PHYSICS_SIZE];
// Header
packet[0..4].copy_from_slice(&1u32.to_le_bytes()); // packet_number
packet[4..8].copy_from_slice(&1u32.to_le_bytes()); // category_packet_number
packet[10] = V2_PACKET_TYPE_CAR_PHYSICS; // packet_type = 0
packet[11] = 1; // packet_version
// Speed = 60.0 m/s at offset 36
packet[36..40].copy_from_slice(&60.0f32.to_le_bytes());
// RPM = 8500 at offset 40
packet[40..42].copy_from_slice(&8500u16.to_le_bytes());
// Gear at offset 45: gear=4
packet[45] = 0x40 | 0x04;
// Throttle at offset 30 = 255
packet[30] = 255;
// Brake at offset 29 = 128
packet[29] = 128;
// Full position at offset 542
packet[542..546].copy_from_slice(&100.0f32.to_le_bytes());
packet[546..550].copy_from_slice(&5.0f32.to_le_bytes());
packet[550..554].copy_from_slice(&(-200.0f32).to_le_bytes());
let result = parse_pcars_packet(&packet).unwrap();
match result {
PcarsPacket::V2CarPhysics(frame) => {
assert_eq!(frame.source, FeedSource::Pcars);
assert!((frame.speed_kmh - 216.0).abs() < 0.1);
assert!((frame.engine_rpm - 8500.0).abs() < 1.0);
assert_eq!(frame.gear, 4);
assert!((frame.throttle - 1.0).abs() < 0.01);
assert!((frame.brake - (128.0 / 255.0)).abs() < 0.01);
assert!((frame.world_x - 100.0).abs() < 0.01);
assert!((frame.world_y - 5.0).abs() < 0.01);
assert!((frame.world_z - (-200.0)).abs() < 0.01);
assert!(frame.full_position.is_some());
assert!(frame.turbo_boost.is_some());
}
_ => panic!("Expected V2CarPhysics"),
}
}
#[test]
fn test_too_short() {
let result = parse_pcars_packet(&[0u8; 2]);
assert!(result.is_err());
}
#[test]
fn test_unknown_format() {
// A packet that's too small for V1 (< 1367) and has byte[10] > 8 (not a valid V2 type)
let mut packet = vec![0u8; 100];
packet[2] = 0xFF; // V1 type bits = 3 (not telemetry)
packet[10] = 9; // V2 type = 9 (invalid)
let result = parse_pcars_packet(&packet);
assert!(matches!(result, Ok(PcarsPacket::Unknown)));
// Very small packet with no recognizable format
let mut p2 = vec![0u8; 50];
p2[2] = 0xFF; // V1 type bits = 3 (not telemetry)
p2[10] = 9; // V2 type = 9 (invalid)
let result2 = parse_pcars_packet(&p2);
assert!(matches!(result2, Ok(PcarsPacket::Unknown)));
}
#[test]
fn test_v2_other_packet_types() {
let mut packet = vec![0u8; V2_HEADER_SIZE + 20];
packet[0..4].copy_from_slice(&1u32.to_le_bytes());
packet[10] = 3; // Timings packet
let result = parse_pcars_packet(&packet);
assert!(matches!(result, Ok(PcarsPacket::V2Other)));
packet[10] = 4; // Game State
let result = parse_pcars_packet(&packet);
assert!(matches!(result, Ok(PcarsPacket::V2Other)));
}
}

View file

@ -10,6 +10,7 @@ use std::fmt;
pub enum FeedSource {
Ksudp,
TelemetryTool,
Pcars,
}
/// Per-wheel index convention: FL=0, FR=1, RL=2, RR=3.
@ -106,6 +107,24 @@ pub struct TelemetryFrame {
pub last_sector_times: Option<[i32; 3]>,
pub driver_id: Option<u32>,
// ── PCARS-specific fields ──────────────────────────────────────
pub oil_temp_celsius: Option<i16>,
pub water_temp_celsius: Option<i16>,
pub fuel_capacity: Option<u8>,
pub boost_amount: Option<u8>,
pub crash_state: Option<u8>,
pub odometer_km: Option<f32>,
pub aero_damage: Option<u8>,
pub engine_damage: Option<u8>,
pub ambient_temperature: Option<i8>,
pub track_temperature: Option<i8>,
pub rain_density: Option<u8>,
pub track_length: Option<f32>,
pub full_position: Option<[f32; 3]>,
pub tyre_wear: Option<[u8; 4]>,
pub brake_temp_celsius: Option<[i16; 4]>,
pub suspension_damage: Option<[u8; 4]>,
// ── Meta ───────────────────────────────────────────────────────
pub source: FeedSource,
pub raw_size: usize,
@ -179,6 +198,22 @@ impl Default for TelemetryFrame {
sector_times: None,
last_sector_times: None,
driver_id: None,
oil_temp_celsius: None,
water_temp_celsius: None,
fuel_capacity: None,
boost_amount: None,
crash_state: None,
odometer_km: None,
aero_damage: None,
engine_damage: None,
ambient_temperature: None,
track_temperature: None,
rain_density: None,
track_length: None,
full_position: None,
tyre_wear: None,
brake_temp_celsius: None,
suspension_damage: None,
source: FeedSource::Ksudp,
raw_size: 0,
}
@ -213,6 +248,7 @@ impl fmt::Display for FeedSource {
match self {
FeedSource::Ksudp => write!(f, "KSUDP"),
FeedSource::TelemetryTool => write!(f, "Telemetry Tool"),
FeedSource::Pcars => write!(f, "PCARS"),
}
}
}