- Fix KSUDP clutch field reading fuel at offset 59 (KSUDP has no clutch input) - Replace Notify with watch::channel for reliable shutdown (no missed wakeups) - Deduplicate read_u16 from parser_pcars into shared binary_utils - Remove dead Display impl for FeedSource - Remove unused read_i8 helper from binary_utils - Change Vec<Instant> to [Instant; 50] in FeedState (eliminate heap alloc) - Cache rarely-changing strings by driver_id in Telemetry Tool parser
407 lines
14 KiB
Rust
407 lines
14 KiB
Rust
//! Binary parser for Telemetry Tool plugin packets (port 10101).
|
|
//!
|
|
//! The Telemetry Tool is a third-party AC Python plugin by IkoRein v1.3.
|
|
//! It sends binary telemetry for all cars in the session.
|
|
//!
|
|
//! Reference: `<AC>/apps/python/Telemetry_Tool_plugin/Telemetry_Tool_plugin.py`
|
|
|
|
use crate::binary_utils::{
|
|
read_bool, read_f32, read_f32_2, read_f32_3, read_f32_4, read_f64, read_i16, read_i32_3,
|
|
read_string, read_u32,
|
|
};
|
|
use crate::telemetry_frame::{FeedSource, TelemetryFrame};
|
|
|
|
/// Packet type byte indicating telemetry data.
|
|
const PACKET_TYPE_TELEMETRY: u8 = 22;
|
|
/// Packet type byte indicating version info (sent every ~100 frames).
|
|
const PACKET_TYPE_VERSION: u8 = 23;
|
|
|
|
/// Size of a single per-driver telemetry structure.
|
|
const DRIVER_STRUCT_SIZE: usize = 357;
|
|
|
|
/// Validation constants embedded by the plugin.
|
|
const TEST_FLOAT: f32 = 666.666;
|
|
const TEST_INT: u32 = 666;
|
|
|
|
/// Parse error type for Telemetry Tool packets.
|
|
#[derive(Debug)]
|
|
pub enum TelemetryToolParseError {
|
|
/// Packet too short for the declared content.
|
|
TooShort { actual: usize, needed: usize },
|
|
/// Unknown packet type.
|
|
UnknownPacketType { got: u8 },
|
|
}
|
|
|
|
impl std::fmt::Display for TelemetryToolParseError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
TelemetryToolParseError::TooShort { actual, needed } => {
|
|
write!(f, "packet too short: {} bytes (needed {})", actual, needed)
|
|
}
|
|
TelemetryToolParseError::UnknownPacketType { got } => {
|
|
write!(f, "unknown packet type: {} (expected 22 or 23)", got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for TelemetryToolParseError {}
|
|
|
|
/// Parsed result of a Telemetry Tool packet.
|
|
///
|
|
/// Reserved for future multi-driver UI support.
|
|
#[allow(dead_code)]
|
|
pub enum TelemetryToolPacket {
|
|
/// Telemetry data for one or more drivers.
|
|
Telemetry(Vec<TelemetryFrame>),
|
|
/// Version info packet (typically ignored).
|
|
Version,
|
|
}
|
|
|
|
/// Parse a complete Telemetry Tool UDP packet.
|
|
///
|
|
/// Returns either a list of parsed driver frames or a version marker.
|
|
///
|
|
/// Reserved for future multi-driver UI support.
|
|
#[allow(dead_code)]
|
|
pub fn parse_packet(data: &[u8]) -> Result<TelemetryToolPacket, TelemetryToolParseError> {
|
|
if data.is_empty() {
|
|
return Err(TelemetryToolParseError::TooShort { actual: 0, needed: 1 });
|
|
}
|
|
|
|
let packet_type = data[0];
|
|
|
|
match packet_type {
|
|
PACKET_TYPE_TELEMETRY => {
|
|
let payload = &data[1..];
|
|
if payload.len() < DRIVER_STRUCT_SIZE {
|
|
return Err(TelemetryToolParseError::TooShort {
|
|
actual: data.len(),
|
|
needed: 1 + DRIVER_STRUCT_SIZE,
|
|
});
|
|
}
|
|
|
|
let num_drivers = payload.len() / DRIVER_STRUCT_SIZE;
|
|
let mut drivers = Vec::with_capacity(num_drivers);
|
|
for i in 0..num_drivers {
|
|
let start = i * DRIVER_STRUCT_SIZE;
|
|
let end = start + DRIVER_STRUCT_SIZE;
|
|
if end > payload.len() {
|
|
break;
|
|
}
|
|
let driver_data = &payload[start..end];
|
|
drivers.push(parse_driver(driver_data)?);
|
|
}
|
|
|
|
Ok(TelemetryToolPacket::Telemetry(drivers))
|
|
}
|
|
PACKET_TYPE_VERSION => Ok(TelemetryToolPacket::Version),
|
|
_ => Err(TelemetryToolParseError::UnknownPacketType { got: packet_type }),
|
|
}
|
|
}
|
|
|
|
/// Parse only the first driver from a Telemetry Tool UDP packet.
|
|
///
|
|
/// Avoids allocating a `Vec` for all drivers when only the local player's
|
|
/// telemetry is needed.
|
|
#[allow(dead_code)]
|
|
pub fn parse_first_driver(data: &[u8]) -> Result<TelemetryFrame, TelemetryToolParseError> {
|
|
if data.is_empty() {
|
|
return Err(TelemetryToolParseError::TooShort { actual: 0, needed: 1 });
|
|
}
|
|
|
|
let packet_type = data[0];
|
|
if packet_type != PACKET_TYPE_TELEMETRY {
|
|
return Err(TelemetryToolParseError::UnknownPacketType { got: packet_type });
|
|
}
|
|
|
|
let payload = &data[1..];
|
|
if payload.len() < DRIVER_STRUCT_SIZE {
|
|
return Err(TelemetryToolParseError::TooShort {
|
|
actual: data.len(),
|
|
needed: 1 + DRIVER_STRUCT_SIZE,
|
|
});
|
|
}
|
|
|
|
parse_driver(&payload[..DRIVER_STRUCT_SIZE])
|
|
}
|
|
|
|
/// Parse only the first driver, skipping rarely-changing string fields.
|
|
///
|
|
/// Returns a tuple of (frame, raw_driver_id). Strings can be populated
|
|
/// from a cache keyed by `raw_driver_id`.
|
|
pub fn parse_first_driver_no_strings(data: &[u8]) -> Result<(TelemetryFrame, u32), TelemetryToolParseError> {
|
|
if data.is_empty() {
|
|
return Err(TelemetryToolParseError::TooShort { actual: 0, needed: 1 });
|
|
}
|
|
|
|
let packet_type = data[0];
|
|
if packet_type != PACKET_TYPE_TELEMETRY {
|
|
return Err(TelemetryToolParseError::UnknownPacketType { got: packet_type });
|
|
}
|
|
|
|
let payload = &data[1..];
|
|
if payload.len() < DRIVER_STRUCT_SIZE {
|
|
return Err(TelemetryToolParseError::TooShort {
|
|
actual: data.len(),
|
|
needed: 1 + DRIVER_STRUCT_SIZE,
|
|
});
|
|
}
|
|
|
|
let driver_data = &payload[..DRIVER_STRUCT_SIZE];
|
|
let driver_id = read_u32(driver_data, 0);
|
|
let mut frame = parse_driver_core(driver_data)?;
|
|
frame.driver_id = Some(driver_id);
|
|
Ok((frame, driver_id))
|
|
}
|
|
|
|
pub const STRING_OFFSET_DRIVER_NAME: usize = 1 + 97;
|
|
pub const STRING_LEN_DRIVER_NAME: usize = 33;
|
|
pub const STRING_OFFSET_NATIONALITY: usize = 1 + 130;
|
|
pub const STRING_LEN_NATIONALITY: usize = 4;
|
|
pub const STRING_OFFSET_CAR_NAME: usize = 1 + 134;
|
|
pub const STRING_LEN_CAR_NAME: usize = 33;
|
|
pub const STRING_OFFSET_TYRE_COMPOUND: usize = 1 + 167;
|
|
pub const STRING_LEN_TYRE_COMPOUND: usize = 10;
|
|
|
|
/// Parse a single 357-byte driver structure.
|
|
fn parse_driver(data: &[u8]) -> Result<TelemetryFrame, TelemetryToolParseError> {
|
|
let mut frame = parse_driver_core(data)?;
|
|
frame.driver_id = Some(read_u32(data, 0));
|
|
frame.driver_name = Some(read_string(data, 97, 33));
|
|
frame.nationality = Some(read_string(data, 130, 4));
|
|
frame.car_name = Some(read_string(data, 134, 33));
|
|
frame.tyre_compound = Some(read_string(data, 167, 10));
|
|
Ok(frame)
|
|
}
|
|
|
|
fn parse_driver_core(data: &[u8]) -> Result<TelemetryFrame, TelemetryToolParseError> {
|
|
// Validation: check test_float at offset 93
|
|
let test_float_val = read_f32(data, 93);
|
|
if (test_float_val - TEST_FLOAT).abs() > 1.0 {
|
|
eprintln!(
|
|
"[WARN] Telemetry Tool: test_float validation: expected {}, got {}",
|
|
TEST_FLOAT, test_float_val
|
|
);
|
|
}
|
|
|
|
// Validation: check test_int at offset 177
|
|
let test_int_val = read_u32(data, 177);
|
|
if test_int_val != TEST_INT {
|
|
eprintln!(
|
|
"[WARN] Telemetry Tool: test_int validation: expected {}, got {}",
|
|
TEST_INT, test_int_val
|
|
);
|
|
}
|
|
|
|
let speed_kmh = read_f32(data, 63);
|
|
|
|
let frame = TelemetryFrame {
|
|
source: FeedSource::TelemetryTool,
|
|
raw_size: data.len(),
|
|
|
|
// Speed (convert km/h back)
|
|
speed_kmh,
|
|
speed_mph: speed_kmh * 0.621371,
|
|
speed_ms: speed_kmh / 3.6,
|
|
|
|
// Engine & drivetrain
|
|
engine_rpm: read_f32(data, 59),
|
|
gear: read_i16(data, 57) as i32,
|
|
|
|
// Driver inputs
|
|
steer: read_f32(data, 41),
|
|
throttle: read_f32(data, 45),
|
|
brake: read_f32(data, 49),
|
|
clutch: read_f32(data, 53),
|
|
|
|
// Lap timing
|
|
lap_time_ms: read_u32(data, 4) as i32,
|
|
last_lap_ms: read_u32(data, 8) as i32,
|
|
best_lap_ms: read_u32(data, 12) as i32,
|
|
lap_count: read_i16(data, 71) as i32,
|
|
|
|
// World position
|
|
world_x: read_f32(data, 73),
|
|
world_y: read_f32(data, 77),
|
|
world_z: read_f32(data, 81),
|
|
spline_position: read_f64(data, 85) as f32,
|
|
|
|
// G-forces
|
|
acc_g_vertical: read_f32(data, 185),
|
|
acc_g_horizontal: read_f32(data, 189),
|
|
acc_g_frontal: read_f32(data, 193),
|
|
|
|
// Boolean flags
|
|
is_in_pit: read_bool(data, 182),
|
|
is_engine_limiter_on: read_bool(data, 196),
|
|
|
|
// Per-wheel data
|
|
tyre_dirty_level: read_f32_4(data, 213),
|
|
slip_ratio: read_f32_4(data, 257),
|
|
slip_angle: read_f32_4(data, 273),
|
|
nd_slip: read_f32_4(data, 289),
|
|
tyre_slip: read_f32_4(data, 305),
|
|
|
|
// Telemetry Tool-specific fields
|
|
track_position: Some(read_i16(data, 67)),
|
|
leaderboard_position: Some(read_i16(data, 69)),
|
|
is_connected: Some(read_bool(data, 181)),
|
|
is_car_in_pitlane: Some(read_bool(data, 183)),
|
|
lap_invalidated: Some(read_bool(data, 184)),
|
|
is_drs_available: Some(read_bool(data, 197)),
|
|
is_drs_enabled: Some(read_bool(data, 198)),
|
|
is_race_finished: Some(read_bool(data, 199)),
|
|
turbo_boost: Some(read_f32(data, 229)),
|
|
ers_max_kj: Some(read_f32(data, 205)),
|
|
ers_current_kj: Some(read_f32(data, 209)),
|
|
ride_height_front_rear: Some(read_f32_2(data, 233)),
|
|
tyre_core_temp: Some(read_f32_4(data, 241)),
|
|
world_velocity: Some(read_f32_3(data, 321)),
|
|
local_velocity: Some(read_f32_3(data, 333)),
|
|
sector_times: Some(read_i32_3(data, 17)),
|
|
last_sector_times: Some(read_i32_3(data, 29)),
|
|
|
|
// Remaining KSUDP-only fields stay at default (0.0 / [0.0; 4])
|
|
..Default::default()
|
|
};
|
|
|
|
Ok(frame)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_driver_struct() -> Vec<u8> {
|
|
let mut data = vec![0u8; DRIVER_STRUCT_SIZE];
|
|
|
|
// driver_id = 42
|
|
data[0..4].copy_from_slice(&42u32.to_le_bytes());
|
|
// lap_time = 65000
|
|
data[4..8].copy_from_slice(&65000u32.to_le_bytes());
|
|
// test_float at offset 93
|
|
data[93..97].copy_from_slice(&TEST_FLOAT.to_le_bytes());
|
|
// driver_name at offset 97
|
|
let name = b"TestDriver";
|
|
data[97..97 + name.len()].copy_from_slice(name);
|
|
// test_int at offset 177
|
|
data[177..181].copy_from_slice(&TEST_INT.to_le_bytes());
|
|
// speed at offset 63 = 180.5
|
|
data[63..67].copy_from_slice(&180.5f32.to_le_bytes());
|
|
// engineRPM at offset 59 = 7200.0
|
|
data[59..63].copy_from_slice(&7200.0f32.to_le_bytes());
|
|
// gear at offset 57 = 5
|
|
data[57..59].copy_from_slice(&5i16.to_le_bytes());
|
|
// throttle at offset 45 = 0.85
|
|
data[45..49].copy_from_slice(&0.85f32.to_le_bytes());
|
|
// spline_position at offset 85 = 0.5
|
|
data[85..93].copy_from_slice(&0.5f64.to_le_bytes());
|
|
// x at offset 73
|
|
data[73..77].copy_from_slice(&100.0f32.to_le_bytes());
|
|
// is_in_pit at offset 182 = false
|
|
data[182] = 0;
|
|
|
|
data
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_telemetry_packet_single_driver() {
|
|
let driver = make_driver_struct();
|
|
let mut packet = vec![PACKET_TYPE_TELEMETRY];
|
|
packet.extend_from_slice(&driver);
|
|
|
|
let result = parse_packet(&packet).unwrap();
|
|
match result {
|
|
TelemetryToolPacket::Telemetry(drivers) => {
|
|
assert_eq!(drivers.len(), 1);
|
|
let f = &drivers[0];
|
|
assert_eq!(f.source, FeedSource::TelemetryTool);
|
|
assert_eq!(f.driver_id, Some(42));
|
|
assert_eq!(f.lap_time_ms, 65000);
|
|
assert!((f.speed_kmh - 180.5).abs() < 0.01);
|
|
assert!((f.engine_rpm - 7200.0).abs() < 0.01);
|
|
assert_eq!(f.gear, 5);
|
|
assert!((f.throttle - 0.85).abs() < 0.01);
|
|
assert!((f.world_x - 100.0).abs() < 0.01);
|
|
assert_eq!(f.driver_name.as_deref(), Some("TestDriver"));
|
|
}
|
|
TelemetryToolPacket::Version => panic!("Expected Telemetry, got Version"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_version_packet() {
|
|
let packet = vec![PACKET_TYPE_VERSION, 1, 2, 3];
|
|
let result = parse_packet(&packet).unwrap();
|
|
match result {
|
|
TelemetryToolPacket::Version => {}
|
|
_ => panic!("Expected Version"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_packet_type() {
|
|
let packet = vec![99u8];
|
|
let result = parse_packet(&packet);
|
|
assert!(result.is_err());
|
|
if let Err(TelemetryToolParseError::UnknownPacketType { got }) = result {
|
|
assert_eq!(got, 99);
|
|
} else {
|
|
panic!("Expected UnknownPacketType");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_packet() {
|
|
let result = parse_packet(&[]);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_drivers() {
|
|
let driver1 = make_driver_struct();
|
|
let mut driver2 = make_driver_struct();
|
|
// Change driver_id for second driver
|
|
driver2[0..4].copy_from_slice(&99u32.to_le_bytes());
|
|
|
|
let mut packet = vec![PACKET_TYPE_TELEMETRY];
|
|
packet.extend_from_slice(&driver1);
|
|
packet.extend_from_slice(&driver2);
|
|
|
|
let result = parse_packet(&packet).unwrap();
|
|
match result {
|
|
TelemetryToolPacket::Telemetry(drivers) => {
|
|
assert_eq!(drivers.len(), 2);
|
|
assert_eq!(drivers[0].driver_id, Some(42));
|
|
assert_eq!(drivers[1].driver_id, Some(99));
|
|
}
|
|
TelemetryToolPacket::Version => panic!("Expected Telemetry"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_per_wheel_slip_ratio() {
|
|
let mut driver = make_driver_struct();
|
|
// slip_ratio at offset 257: [0.1, 0.2, 0.3, 0.4]
|
|
for i in 0..4 {
|
|
let val = (i as f32 + 1.0) * 0.1;
|
|
driver[257 + i * 4..257 + i * 4 + 4].copy_from_slice(&val.to_le_bytes());
|
|
}
|
|
|
|
let mut packet = vec![PACKET_TYPE_TELEMETRY];
|
|
packet.extend_from_slice(&driver);
|
|
|
|
let result = parse_packet(&packet).unwrap();
|
|
match result {
|
|
TelemetryToolPacket::Telemetry(drivers) => {
|
|
let f = &drivers[0];
|
|
assert!((f.slip_ratio[0] - 0.1).abs() < 0.001);
|
|
assert!((f.slip_ratio[1] - 0.2).abs() < 0.001);
|
|
assert!((f.slip_ratio[2] - 0.3).abs() < 0.001);
|
|
assert!((f.slip_ratio[3] - 0.4).abs() < 0.001);
|
|
}
|
|
TelemetryToolPacket::Version => panic!("Expected Telemetry"),
|
|
}
|
|
}
|
|
}
|