// 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 .
//! 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: `/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),
/// 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 {
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 {
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 {
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 {
// 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 {
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"),
}
}
}