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:
parent
7a1c37fc86
commit
a50d3af290
4 changed files with 570 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
14
src/main.rs
14
src/main.rs
|
|
@ -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
504
src/parser_pcars.rs
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue