- LICENSE: GNU AGPL-3.0 (full text) - Copyright headers added to all 15 source files in src/ - COMMERCIAL_LICENSE.md: dual-license explanation (AGPL + commercial)
573 lines
21 KiB
Rust
573 lines
21 KiB
Rust
// rusty-telemetry — Sim racing telemetry analysis platform
|
||
// Copyright (C) 2026 Jan-Peter v. Hunnius
|
||
//
|
||
// This program is free software: you can redistribute it and/or modify
|
||
// it under the terms of the GNU Affero General Public License as published by
|
||
// the Free Software Foundation, either version 3 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// This program is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU Affero General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU Affero General Public License
|
||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
//! 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);
|
||
|
||
// V1 position data from viewed participant
|
||
let viewed_idx = read_i8_at(data, 4) as usize;
|
||
let participant_offset = 464 + viewed_idx * 16;
|
||
let v1_world_x = read_i16(data, participant_offset) as f32;
|
||
let v1_world_y = read_i16(data, participant_offset + 2) as f32;
|
||
let v1_world_z = read_i16(data, participant_offset + 4) as f32;
|
||
let current_lap_distance = read_u16(data, participant_offset + 6);
|
||
let spline_pos = if track_length > 0.0 {
|
||
(current_lap_distance as f32 / track_length).clamp(0.0, 1.0)
|
||
} else {
|
||
0.0
|
||
};
|
||
let laps_completed = (read_u8_at(data, participant_offset + 9) & 0x7F) as i32;
|
||
let _current_lap = read_u8_at(data, participant_offset + 10) as i32;
|
||
|
||
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: laps_completed,
|
||
|
||
world_x: v1_world_x,
|
||
world_y: v1_world_y,
|
||
world_z: v1_world_z,
|
||
spline_position: spline_pos,
|
||
|
||
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: Some([v1_world_x, v1_world_y, v1_world_z]),
|
||
current_lap_distance: Some(current_lap_distance),
|
||
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)),
|
||
current_lap_distance: None,
|
||
|
||
..Default::default()
|
||
};
|
||
|
||
Ok(frame)
|
||
}
|
||
|
||
#[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());
|
||
|
||
// World position for participant 0 at offset 464: x=100, y=5, z=-200
|
||
packet[464..466].copy_from_slice(&100i16.to_le_bytes());
|
||
packet[466..468].copy_from_slice(&5i16.to_le_bytes());
|
||
packet[468..470].copy_from_slice(&(-200i16).to_le_bytes());
|
||
// Current lap distance at offset 464+6 = 470: 2500 meters
|
||
packet[470..472].copy_from_slice(&2500u16.to_le_bytes());
|
||
// Track length at offset 1360: 5000.0 meters
|
||
packet[1360..1364].copy_from_slice(&5000.0f32.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!((frame.world_x - 100.0).abs() < 0.1);
|
||
assert!((frame.world_y - 5.0).abs() < 0.1);
|
||
assert!((frame.world_z - (-200.0)).abs() < 0.1);
|
||
assert!((frame.spline_position - 0.5).abs() < 0.01);
|
||
assert!(frame.full_position.is_some());
|
||
assert_eq!(frame.current_lap_distance, Some(2500));
|
||
assert_eq!(frame.oil_temp_celsius, Some(0));
|
||
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)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_v1_position_nonzero_viewed_participant() {
|
||
let mut packet = vec![0u8; V1_TELEMETRY_SIZE];
|
||
packet[0..2].copy_from_slice(&1104u16.to_le_bytes());
|
||
packet[2] = 0x00;
|
||
|
||
packet[4] = 3i8.to_le_bytes()[0];
|
||
|
||
packet[120..124].copy_from_slice(&30.0f32.to_le_bytes());
|
||
|
||
packet[1360..1364].copy_from_slice(&6000.0f32.to_le_bytes());
|
||
|
||
let p_offset = 464 + 3 * 16;
|
||
packet[p_offset..p_offset+2].copy_from_slice(&300i16.to_le_bytes());
|
||
packet[p_offset+2..p_offset+4].copy_from_slice(&10i16.to_le_bytes());
|
||
packet[p_offset+4..p_offset+6].copy_from_slice(&(-400i16).to_le_bytes());
|
||
packet[p_offset+6..p_offset+8].copy_from_slice(&3000u16.to_le_bytes());
|
||
|
||
let result = parse_pcars_packet(&packet).unwrap();
|
||
match result {
|
||
PcarsPacket::V1Telemetry(frame) => {
|
||
assert!((frame.world_x - 300.0).abs() < 0.1);
|
||
assert!((frame.world_y - 10.0).abs() < 0.1);
|
||
assert!((frame.world_z - (-400.0)).abs() < 0.1);
|
||
assert!((frame.spline_position - 0.5).abs() < 0.01);
|
||
assert_eq!(frame.current_lap_distance, Some(3000));
|
||
}
|
||
_ => panic!("Expected V1Telemetry"),
|
||
}
|
||
}
|
||
}
|