use super::format::{IRSDK_VAR_HEADER_SIZE, IbtDiskSubHeader, IbtHeader, extract_variable_schema};
use crate::{Result, TelemetryError, VariableSchema, yaml_utils};
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use tracing::warn;
pub struct IbtReader {
data: Vec<u8>,
current_position: usize,
path: PathBuf,
header: IbtHeader,
disk_header: IbtDiskSubHeader,
variable_schema: VariableSchema,
current_frame: usize,
total_frames: usize,
frame_data_start: usize,
}
impl IbtReader {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let mut file = File::open(&path)
.map_err(|e| TelemetryError::File { path: path.as_ref().to_path_buf(), source: e })?;
let mut data = Vec::new();
file.read_to_end(&mut data)
.map_err(|e| TelemetryError::File { path: path.as_ref().to_path_buf(), source: e })?;
Self::from_bytes_with_path(&data, path.as_ref().to_path_buf())
}
pub fn from_bytes(data: &[u8]) -> Result<Self> {
Self::from_bytes_with_path(data, PathBuf::from("<memory>"))
}
fn from_bytes_with_path(data: &[u8], path: PathBuf) -> Result<Self> {
let mut cursor = std::io::Cursor::new(data);
let header = IbtHeader::parse_from_reader(&mut cursor)?;
header.validate()?;
let disk_header = IbtDiskSubHeader::parse_from_reader(&mut cursor)?;
let variable_schema = extract_variable_schema(&mut cursor, &header)?;
let var_headers_size = header
.num_vars
.checked_mul(IRSDK_VAR_HEADER_SIZE as i32)
.ok_or_else(|| TelemetryError::Parse {
context: "Frame data calculation".to_string(),
details: "Variable headers size calculation overflowed".to_string(),
})?;
let var_headers_end =
header.var_header_offset.checked_add(var_headers_size).ok_or_else(|| {
TelemetryError::Parse {
context: "Frame data calculation".to_string(),
details: "Variable headers end calculation overflowed".to_string(),
}
})?;
let session_info_end = if header.session_info_len > 0 {
header.session_info_offset.checked_add(header.session_info_len).ok_or_else(|| {
TelemetryError::Parse {
context: "Frame data calculation".to_string(),
details: "Session info end calculation overflowed".to_string(),
}
})?
} else {
var_headers_end
};
let frame_data_start = session_info_end.max(var_headers_end) as usize;
let remaining_bytes =
data.len().checked_sub(frame_data_start).ok_or_else(|| TelemetryError::Parse {
context: "Frame data calculation".to_string(),
details: "Frame data start position exceeds file size".to_string(),
})?;
let total_frames = if header.buf_len > 0 {
remaining_bytes / header.buf_len as usize
} else {
0 };
if disk_header.record_count > 0 && total_frames > 0 {
let expected_frames = disk_header.record_count as usize;
if expected_frames != total_frames {
warn!(
"Frame count mismatch: disk header reports {} records, calculated {} frames from file size",
disk_header.record_count, total_frames
);
}
}
let reader = IbtReader {
data: data.to_vec(),
current_position: frame_data_start,
path,
header,
disk_header,
variable_schema,
current_frame: 0,
total_frames,
frame_data_start,
};
Ok(reader)
}
pub fn session_yaml(&self) -> Result<Option<String>> {
if self.header.session_info_len <= 0 || self.header.session_info_offset <= 0 {
return Ok(None);
}
let raw_yaml = yaml_utils::extract_yaml_from_memory(
&self.data,
self.header.session_info_offset,
self.header.session_info_len,
)?;
if raw_yaml.trim().is_empty() {
return Ok(None);
}
let cleaned_yaml = yaml_utils::preprocess_iracing_yaml(&raw_yaml)?;
Ok(Some(cleaned_yaml))
}
pub fn variables(&self) -> &VariableSchema {
&self.variable_schema
}
pub fn total_frames(&self) -> usize {
self.total_frames
}
pub fn current_frame(&self) -> usize {
self.current_frame
}
pub fn tick_rate(&self) -> f64 {
if self.header.tick_rate > 0 {
self.header.tick_rate as f64
} else {
60.0
}
}
pub fn file_path(&self) -> &Path {
&self.path
}
pub fn disk_header(&self) -> &IbtDiskSubHeader {
&self.disk_header
}
pub fn header(&self) -> &IbtHeader {
&self.header
}
pub fn seek_to_frame(&mut self, frame_number: usize) -> Result<()> {
if frame_number >= self.total_frames {
return Err(TelemetryError::Parse {
context: "Frame seek".to_string(),
details: format!("Frame {} out of range (0..{})", frame_number, self.total_frames),
});
}
let frame_size = self.header.buf_len as usize;
let frame_byte_offset =
frame_number.checked_mul(frame_size).ok_or_else(|| TelemetryError::Parse {
context: "Frame seek".to_string(),
details: "Frame offset calculation overflowed".to_string(),
})?;
let frame_offset =
self.frame_data_start.checked_add(frame_byte_offset).ok_or_else(|| {
TelemetryError::Parse {
context: "Frame seek".to_string(),
details: "Frame position calculation overflowed".to_string(),
}
})?;
self.current_position = frame_offset;
self.current_frame = frame_number;
Ok(())
}
pub fn read_next_frame(&mut self) -> Result<Option<(Vec<u8>, u32, u32)>> {
if self.current_frame >= self.total_frames {
return Ok(None);
}
if self.header.buf_len == 0 {
return Ok(None);
}
let frame_size = self.header.buf_len as usize;
let start_pos = self.current_position;
let end_pos = start_pos + frame_size;
if end_pos > self.data.len() {
return Err(TelemetryError::Parse {
context: "Frame reading".to_string(),
details: format!(
"Frame {} extends beyond data bounds ({} > {})",
self.current_frame,
end_pos,
self.data.len()
),
});
}
let frame_data = self.data[start_pos..end_pos].to_vec();
let tick_count = self.current_frame as u32;
let session_version = self.header.session_info_update as u32;
self.current_frame += 1;
self.current_position = end_pos;
Ok(Some((frame_data, tick_count, session_version)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::require_smallest_ibt_fixture;
use anyhow::{Context, Result, ensure};
use std::path::PathBuf;
use std::time::{Duration, Instant};
fn fixture_path() -> Result<PathBuf> {
Ok(require_smallest_ibt_fixture()?)
}
#[test]
fn test_real_ibt_reader_construction() -> Result<()> {
let test_file = fixture_path()?;
println!("Testing reader construction with: {}", test_file.display());
let reader = IbtReader::open(&test_file)
.with_context(|| format!("Opening {}", test_file.display()))?;
println!("Reader constructed successfully:");
println!(" Total frames: {}", reader.total_frames());
println!(" Current frame: {}", reader.current_frame());
assert_eq!(reader.current_frame(), 0, "Should start at frame 0");
if reader.total_frames() == 0 {
println!(" This IBT file contains only session info (no telemetry data)");
} else {
println!(" This IBT file contains {} frames of telemetry data", reader.total_frames());
}
Ok(())
}
#[test]
fn test_real_ibt_read_next_frame() -> Result<()> {
let test_file = fixture_path()?;
let mut reader = IbtReader::open(&test_file)
.with_context(|| format!("Opening {}", test_file.display()))?;
let total_frames = reader.total_frames();
println!("IBT file has {} total frames", total_frames);
if total_frames == 0 {
println!(
"Fixture {} contains no telemetry frames; skipping frame validation",
test_file.display()
);
return Ok(());
}
let first = reader
.read_next_frame()
.with_context(|| format!("Reading first frame from {}", test_file.display()))?;
let (data, tick_count, _session_version) =
first.expect("IBT fixtures should yield at least one frame");
ensure!(!data.is_empty(), "Expected non-empty frame data from {}", test_file.display());
ensure!(
data.len() == reader.variables().frame_size,
"Frame data length {} must match schema frame size {}",
data.len(),
reader.variables().frame_size
);
ensure!(
reader.variables().variable_count() > 0,
"Schema should expose telemetry variables"
);
ensure!(
tick_count == 0,
"First frame should have tick_count = 0, but got {} from {}",
tick_count,
test_file.display()
);
ensure!(
reader.current_frame() == 1,
"Reader should advance to frame index 1 after consuming the first frame"
);
Ok(())
}
#[test]
fn test_real_ibt_end_of_file_handling() -> Result<()> {
let test_file = fixture_path()?;
let mut reader = IbtReader::open(&test_file)
.with_context(|| format!("Opening {}", test_file.display()))?;
let total_frames = reader.total_frames();
if total_frames == 0 {
println!(
"Fixture {} contains session info only; skipping EOF handling test",
test_file.display()
);
return Ok(());
}
let last_index = total_frames - 1;
reader.seek_to_frame(last_index).with_context(|| {
format!("Seeking to final frame {} in {}", last_index, test_file.display())
})?;
let last = reader
.read_next_frame()
.with_context(|| format!("Reading final frame from {}", test_file.display()))?;
let (_, tick_count, _) = last.expect("Expected frame data after seeking to final frame");
ensure!(
tick_count as usize == last_index,
"Final frame tick {} should match requested index {}",
tick_count,
last_index
);
let eof = reader
.read_next_frame()
.with_context(|| format!("Reading EOF sentinel from {}", test_file.display()))?;
ensure!(eof.is_none(), "read_next_frame should return None once EOF is reached");
Ok(())
}
#[test]
fn test_real_ibt_frame_seeking() -> Result<()> {
let test_file = fixture_path()?;
let mut reader = IbtReader::open(&test_file)
.with_context(|| format!("Opening {}", test_file.display()))?;
let total_frames = reader.total_frames();
if total_frames < 3 {
println!(
"Fixture {} has {} frames; skipping seek test that requires at least 3",
test_file.display(),
total_frames
);
return Ok(());
}
let middle = total_frames / 2;
reader.seek_to_frame(middle).context("Seeking to middle frame")?;
let frame = reader
.read_next_frame()
.context("Reading frame after seek")?
.expect("Expected frame after seeking to target index");
let (_, tick_count, _) = frame;
ensure!(
tick_count as usize == middle,
"Frame tick {} should match requested index {}",
tick_count,
middle
);
Ok(())
}
#[test]
fn test_real_ibt_read_next_frame_performance() -> Result<()> {
let test_file = fixture_path()?;
let mut reader = IbtReader::open(&test_file)
.with_context(|| format!("Opening {}", test_file.display()))?;
if reader.total_frames() == 0 {
println!(
"Fixture {} contains no frames; skipping latency measurement",
test_file.display()
);
return Ok(());
}
let start = Instant::now();
let frame = reader.read_next_frame().context("Reading frame to measure latency")?;
let elapsed = start.elapsed();
ensure!(frame.is_some(), "Expected frame data on first call to read_next_frame()");
ensure!(
elapsed < Duration::from_millis(100),
"Frame retrieval should be fast (took {:?})",
elapsed
);
Ok(())
}
#[test]
fn test_real_ibt_raw_frame_validation() -> Result<()> {
let test_file = fixture_path()?;
let mut reader = IbtReader::open(&test_file)
.with_context(|| format!("Opening {}", test_file.display()))?;
if reader.total_frames() == 0 {
println!(
"Fixture {} contains no frames; skipping raw frame validation",
test_file.display()
);
return Ok(());
}
let frame = reader
.read_next_frame()
.with_context(|| format!("Reading frame for validation from {}", test_file.display()))?
.expect("Expected frame for validation");
let (data, _, _) = frame;
let schema = reader.variables();
ensure!(schema.variable_count() > 0, "Schema should contain telemetry variables");
ensure!(schema.has_variable("SessionTime"), "Schema should expose SessionTime variable");
ensure!(
schema.frame_size == data.len(),
"Schema frame size {} must match data length {}",
schema.frame_size,
data.len()
);
if let Some(speed) = schema.get_variable("Speed") {
ensure!(
speed.offset + speed.data_type.size() * speed.count <= data.len(),
"Speed variable must fit within the frame buffer"
);
}
Ok(())
}
#[test]
fn test_real_ibt_session_yaml_extraction() -> Result<()> {
let test_file = fixture_path()?;
let reader = IbtReader::open(&test_file)
.with_context(|| format!("Opening {}", test_file.display()))?;
println!("Testing session YAML extraction from {}", test_file.display());
let yaml_result = reader.session_yaml().with_context(|| "Extracting session YAML")?;
let yaml = yaml_result.expect("IBT file should contain session YAML");
ensure!(!yaml.is_empty(), "Session YAML should not be empty");
println!(" Session YAML extracted: {} bytes", yaml.len());
ensure!(yaml.contains("WeekendInfo:"), "YAML should contain WeekendInfo section");
ensure!(yaml.contains("SessionInfo:"), "YAML should contain SessionInfo section");
for (i, ch) in yaml.chars().enumerate() {
if matches!(ch, '\x00'..='\x08' | '\x0B'..='\x0C' | '\x0E'..='\x1F') {
anyhow::bail!(
"Found control character 0x{:02X} at position {} - YAML not properly preprocessed",
ch as u8,
i
);
}
}
let session = crate::SessionInfo::parse(&yaml)
.with_context(|| "Parsing extracted YAML into SessionInfo")?;
println!(" Track: {}", session.weekend_info.track_name);
println!(" Sessions: {}", session.session_info.sessions.len());
ensure!(!session.weekend_info.track_name.is_empty(), "Track name should not be empty");
ensure!(!session.session_info.sessions.is_empty(), "Should have at least one session");
Ok(())
}
}