feat: KSUDP parser fix, warnings system, shift point rewrite, code quality (v0.5.1)

Bug Fixes:
- Fixed KSUDP binary parser offsets from packed (323B) to natural C
  alignment (328B) — 3 bytes padding after char identifier, 2 bytes
  after 6 bools
- Fixed setup_terminal() leaving terminal in raw mode on early exit

New Features:
- Thread-safe warning buffer (warnings.rs) with TUI bar + stderr fallback
- Rewrote shift point analysis per AC forum methodology: per-gear
  acceleration curves, crossover detection, same-gear merging
- UseCase::guidance() — step-by-step recording instructions
- Recording metadata enrichment in analysis responses
- recording_id field on sessions

Code Quality:
- Extracted finalize_run() helper (session.rs)
- Extracted execute_and_store_analysis() helper (api.rs)
- Extracted lerp_search<T>() generic helper (analysis.rs)

Breaking API Changes:
- GET /api/use-cases: string[] -> [{id, name, guidance}[]]
- Shift point analysis: gear_ranges/shift_events -> gear_curves/crossover_points

Updated README.md with new API response examples and TUI warning bar.
This commit is contained in:
Jan-Peter von Hunnius 2026-06-05 21:12:57 +02:00
parent 090ab7e559
commit e3aa511ac7
9 changed files with 696 additions and 312 deletions

View file

@ -2,13 +2,19 @@
//!
//! After the 3-phase handshake, AC sends continuous RTCarInfo structs at ~20 Hz.
//! This parser converts the raw binary data into a `TelemetryFrame`.
//!
//! IMPORTANT: The AC RTCarInfo C struct uses **natural alignment** (not packed).
//! This means padding bytes exist:
//! - 3 bytes after `char identifier` (offset 0) before `int size` (offset 4)
//! - 2 bytes after the 6 bools (offsets 20–25) before the next float (offset 28)
//! Total struct size: 328 bytes (not 323).
use crate::binary_utils::{read_bool, read_f32, read_f32_4, read_i32};
use crate::telemetry_frame::{FeedSource, TelemetryFrame};
/// Minimum expected packet size (identifier + size field + all known fields).
/// The `size` field in the packet itself reports the true struct size.
const MIN_PACKET_SIZE: usize = 323;
/// Minimum expected packet size for a naturally-aligned RTCarInfo struct.
/// The `size` field in the packet itself reports the true struct size (328).
const MIN_PACKET_SIZE: usize = 328;
/// Parse error type for KSUDP packets.
#[derive(Debug)]
@ -36,6 +42,47 @@ impl std::error::Error for KsudpParseError {}
/// Parse a raw KSUDP RTCarInfo packet into a `TelemetryFrame`.
///
/// Field offsets follow the naturally-aligned C struct layout:
/// - offset 0: char identifier
/// - offset 4: int size (3 bytes padding after char)
/// - offset 8: float speedKmh
/// - offset 12: float speedMph
/// - offset 16: float speedMs
/// - offset 20: bool isAbsEnabled .. isEngineLimiterOn (6 bools at 20-25)
/// - offset 28: float accG_vertical (2 bytes padding after bools)
/// - offset 32: float accG_horizontal
/// - offset 36: float accG_frontal
/// - offset 40: int lapTime
/// - offset 44: int lastLap
/// - offset 48: int bestLap
/// - offset 52: int lapCount
/// - offset 56: float gas
/// - offset 60: float brake
/// - offset 64: float fuel (not clutch — KSUDP has no clutch input)
/// - offset 68: float engineRPM
/// - offset 72: float steer
/// - offset 76: int gear
/// - offset 80: float cgHeight
/// - offset 84: float[4] wheelAngularSpeed
/// - offset 100: float[4] slipAngle
/// - offset 116: float[4] slipAngleContactPatch
/// - offset 132: float[4] slipRatio
/// - offset 148: float[4] tyreSlip
/// - offset 164: float[4] ndSlip
/// - offset 180: float[4] wheelLoad
/// - offset 196: float[4] wheelDy
/// - offset 212: float[4] wheelMz
/// - offset 228: float[4] tyreDirtyLevel
/// - offset 244: float[4] camberRAD
/// - offset 260: float[4] tyreRadius
/// - offset 276: float[4] tyreLoadedRadius
/// - offset 292: float[4] suspensionHeight
/// - offset 308: float carPositionNormalized
/// - offset 312: float carSlope
/// - offset 316: float worldX
/// - offset 320: float worldY
/// - offset 324: float worldZ
///
/// # Errors
/// Returns `KsudpParseError` if the packet is too short or has an invalid identifier.
pub fn parse_rt_car_info(data: &[u8]) -> Result<TelemetryFrame, KsudpParseError> {
@ -46,15 +93,13 @@ pub fn parse_rt_car_info(data: &[u8]) -> Result<TelemetryFrame, KsudpParseError>
});
}
// Validate identifier
let identifier = data[0];
if identifier != b'a' {
return Err(KsudpParseError::InvalidIdentifier { got: identifier });
}
let reported_size = read_i32(data, 1) as usize;
let reported_size = read_i32(data, 4) as usize;
if data.len() < reported_size && reported_size > 0 {
// Log warning but don't fail — the struct might have fields we don't read yet
eprintln!(
"[WARN] KSUDP: reported struct size {} but packet is {} bytes",
reported_size,
@ -66,73 +111,60 @@ pub fn parse_rt_car_info(data: &[u8]) -> Result<TelemetryFrame, KsudpParseError>
source: FeedSource::Ksudp,
raw_size: data.len(),
// Speed
speed_kmh: read_f32(data, 5),
speed_mph: read_f32(data, 9),
speed_ms: read_f32(data, 13),
speed_kmh: read_f32(data, 8),
speed_mph: read_f32(data, 12),
speed_ms: read_f32(data, 16),
// Flags
is_abs_enabled: read_bool(data, 17),
is_abs_in_action: read_bool(data, 18),
is_tc_in_action: read_bool(data, 19),
is_tc_enabled: read_bool(data, 20),
is_in_pit: read_bool(data, 21),
is_engine_limiter_on: read_bool(data, 22),
is_abs_enabled: read_bool(data, 20),
is_abs_in_action: read_bool(data, 21),
is_tc_in_action: read_bool(data, 22),
is_tc_enabled: read_bool(data, 23),
is_in_pit: read_bool(data, 24),
is_engine_limiter_on: read_bool(data, 25),
// G-forces
acc_g_vertical: read_f32(data, 23),
acc_g_horizontal: read_f32(data, 27),
acc_g_frontal: read_f32(data, 31),
acc_g_vertical: read_f32(data, 28),
acc_g_horizontal: read_f32(data, 32),
acc_g_frontal: read_f32(data, 36),
// Lap timing
lap_time_ms: read_i32(data, 35),
last_lap_ms: read_i32(data, 39),
best_lap_ms: read_i32(data, 43),
lap_count: read_i32(data, 47),
lap_time_ms: read_i32(data, 40),
last_lap_ms: read_i32(data, 44),
best_lap_ms: read_i32(data, 48),
lap_count: read_i32(data, 52),
// Driver inputs
throttle: read_f32(data, 51),
brake: read_f32(data, 55),
clutch: 0.0, // offset 59 is fuel (0.0–1.0), not clutch; KSUDP has no clutch input
engine_rpm: read_f32(data, 63),
steer: read_f32(data, 67),
throttle: read_f32(data, 56),
brake: read_f32(data, 60),
clutch: 0.0, // offset 64 is fuel level (0.0-1.0), not clutch; KSUDP has no clutch input
engine_rpm: read_f32(data, 68),
steer: read_f32(data, 72),
// Gear & chassis
gear: read_i32(data, 71),
cg_height: read_f32(data, 75),
gear: read_i32(data, 76),
cg_height: read_f32(data, 80),
// Per-wheel arrays
wheel_angular_speed: read_f32_4(data, 79),
slip_angle: read_f32_4(data, 95),
slip_angle_contact_patch: read_f32_4(data, 111),
slip_ratio: read_f32_4(data, 127),
tyre_slip: read_f32_4(data, 143),
nd_slip: read_f32_4(data, 159),
wheel_load: read_f32_4(data, 175),
wheel_dy: read_f32_4(data, 191),
wheel_mz: read_f32_4(data, 207),
tyre_dirty_level: read_f32_4(data, 223),
camber_rad: read_f32_4(data, 239),
tyre_radius: read_f32_4(data, 255),
tyre_loaded_radius: read_f32_4(data, 271),
suspension_height: read_f32_4(data, 287),
wheel_angular_speed: read_f32_4(data, 84),
slip_angle: read_f32_4(data, 100),
slip_angle_contact_patch: read_f32_4(data, 116),
slip_ratio: read_f32_4(data, 132),
tyre_slip: read_f32_4(data, 148),
nd_slip: read_f32_4(data, 164),
wheel_load: read_f32_4(data, 180),
wheel_dy: read_f32_4(data, 196),
wheel_mz: read_f32_4(data, 212),
tyre_dirty_level: read_f32_4(data, 228),
camber_rad: read_f32_4(data, 244),
tyre_radius: read_f32_4(data, 260),
tyre_loaded_radius: read_f32_4(data, 276),
suspension_height: read_f32_4(data, 292),
// Position
// NOTE: carPosition (offset 303) is the normalized spline position (0.0–1.0).
// Both car_position_normalized and spline_position map to the same AC field.
car_position_normalized: read_f32(data, 303),
car_slope: read_f32(data, 307),
world_x: read_f32(data, 311),
world_y: read_f32(data, 315),
world_z: read_f32(data, 319),
spline_position: read_f32(data, 303), // same as car_position_normalized (AC SDK: carPosition)
car_position_normalized: read_f32(data, 308),
car_slope: read_f32(data, 312),
world_x: read_f32(data, 316),
world_y: read_f32(data, 320),
world_z: read_f32(data, 324),
spline_position: read_f32(data, 308),
..Default::default()
};
// KSUDP-specific: mark Telemetry Tool fields as None (already default)
// Keep KSUDP-specific fields populated above.
Ok(frame)
}
@ -143,17 +175,12 @@ mod tests {
#[test]
fn test_parse_minimal_valid_packet() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a'; // identifier
// size = MIN_PACKET_SIZE
packet[1..5].copy_from_slice(&(MIN_PACKET_SIZE as i32).to_le_bytes());
// speed_Kmh = 123.4
packet[5..9].copy_from_slice(&123.4f32.to_le_bytes());
// gear = 3 (meaning gear 2)
packet[71..75].copy_from_slice(&3i32.to_le_bytes());
// engineRPM = 7500.0
packet[63..67].copy_from_slice(&7500.0f32.to_le_bytes());
// lapTime = 78123 ms (1:18.123)
packet[35..39].copy_from_slice(&78123i32.to_le_bytes());
packet[0] = b'a';
packet[4..8].copy_from_slice(&(MIN_PACKET_SIZE as i32).to_le_bytes());
packet[8..12].copy_from_slice(&123.4f32.to_le_bytes());
packet[76..80].copy_from_slice(&3i32.to_le_bytes());
packet[68..72].copy_from_slice(&7500.0f32.to_le_bytes());
packet[40..44].copy_from_slice(&78123i32.to_le_bytes());
let frame = parse_rt_car_info(&packet).unwrap();
assert_eq!(frame.source, FeedSource::Ksudp);
@ -192,12 +219,12 @@ mod tests {
fn test_boolean_flags() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
packet[17] = 1; // isAbsEnabled = true
packet[18] = 0; // isAbsInAction = false
packet[19] = 1; // isTcInAction = true
packet[20] = 1; // isTcEnabled = true
packet[21] = 0; // isInPit = false
packet[22] = 1; // isEngineLimiterOn = true
packet[20] = 1;
packet[21] = 0;
packet[22] = 1;
packet[23] = 1;
packet[24] = 0;
packet[25] = 1;
let frame = parse_rt_car_info(&packet).unwrap();
assert!(frame.is_abs_enabled);
@ -212,10 +239,9 @@ mod tests {
fn test_per_wheel_arrays() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
// slipRatio at offset 127: [0.1, 0.2, 0.3, 0.4]
for i in 0..4 {
let val = (i as f32 + 1.0) * 0.1;
packet[127 + i * 4..127 + i * 4 + 4].copy_from_slice(&val.to_le_bytes());
packet[132 + i * 4..132 + i * 4 + 4].copy_from_slice(&val.to_le_bytes());
}
let frame = parse_rt_car_info(&packet).unwrap();
@ -229,9 +255,9 @@ mod tests {
fn test_world_coordinates() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
packet[311..315].copy_from_slice(&100.5f32.to_le_bytes()); // x
packet[315..319].copy_from_slice(&2.3f32.to_le_bytes()); // y
packet[319..323].copy_from_slice(&(-50.7f32).to_le_bytes()); // z
packet[316..320].copy_from_slice(&100.5f32.to_le_bytes());
packet[320..324].copy_from_slice(&2.3f32.to_le_bytes());
packet[324..328].copy_from_slice(&(-50.7f32).to_le_bytes());
let frame = parse_rt_car_info(&packet).unwrap();
assert!((frame.world_x - 100.5).abs() < 0.01);