Skip to main content

ballistics_engine/
bc_table_5d.rs

1// BC5D - 5-Dimensional BC Correction Table with Caliber-Specific Files
2//
3// This module provides offline BC corrections by loading precomputed tables
4// of correction factors derived from ML model predictions. The tables are
5// caliber-specific and indexed by:
6//   - Weight (grains) - caliber-specific ranges
7//   - Base BC (0.05-1.2)
8//   - Muzzle Velocity (2000-4000 fps)
9//   - Current Velocity (500-4000 fps, dense in transonic)
10//   - Drag Model (G1, G7)
11//
12// Binary file format (BC5D v2):
13//   Header (80 bytes):
14//     - Magic: 4 bytes ('BC5D')
15//     - Version: 4 bytes (uint32)
16//     - Caliber: 4 bytes (float32)
17//     - Flags: 4 bytes (uint32)
18//     - Padding: 4 bytes
19//     - dim_weight: 4 bytes (uint32)
20//     - dim_bc: 4 bytes (uint32)
21//     - dim_muzzle_vel: 4 bytes (uint32)
22//     - dim_current_vel: 4 bytes (uint32)
23//     - dim_drag_types: 4 bytes (uint32)
24//     - timestamp: 8 bytes (uint64)
25//     - checksum: 4 bytes (uint32, CRC32 of data section)
26//     - api_version: 16 bytes (null-padded string)
27//     - reserved: 12 bytes
28//   Bin definitions:
29//     - Weight bins: dim_weight * 4 bytes (float32)
30//     - BC bins: dim_bc * 4 bytes (float32)
31//     - Muzzle velocity bins: dim_muzzle_vel * 4 bytes (float32)
32//     - Current velocity bins: dim_current_vel * 4 bytes (float32)
33//   Data section:
34//     - Correction factors: total_cells * 4 bytes (float32)
35//     - Layout: [drag_type][weight][bc][muzzle_vel][current_vel]
36//
37// Correction factors are ratios: predicted_bc / base_bc
38// Range: 0.5 to 1.5 (clipped during generation)
39
40use std::collections::HashMap;
41use std::fs::File;
42use std::io::{BufReader, Read};
43use std::path::{Path, PathBuf};
44
45/// Magic bytes for BC5D format
46const MAGIC: &[u8; 4] = b"BC5D";
47
48/// Supported format version
49const SUPPORTED_VERSION: u32 = 2;
50
51/// BC5D table with 4D interpolation (drag type is discrete)
52#[derive(Debug)]
53pub struct Bc5dTable {
54    /// Caliber this table is for
55    caliber: f32,
56    /// Correction data: [drag_type][weight][bc][muzzle_vel][current_vel]
57    data: Vec<f32>,
58    /// Weight bin values (grains)
59    weight_bins: Vec<f32>,
60    /// BC bin values
61    bc_bins: Vec<f32>,
62    /// Muzzle velocity bin values (fps)
63    muzzle_vel_bins: Vec<f32>,
64    /// Current velocity bin values (fps)
65    current_vel_bins: Vec<f32>,
66    /// Number of drag types (typically 2: G1=0, G7=1)
67    num_drag_types: usize,
68    /// Table version
69    version: u32,
70    /// API version used to generate the table
71    api_version: String,
72    /// Generation timestamp
73    timestamp: u64,
74}
75
76/// A velocity-keyed BC schedule and the scalar BC used for any interior coverage gap.
77#[cfg(any(test, target_arch = "wasm32"))]
78pub(crate) struct Bc5dSegmentSchedule {
79    pub(crate) segments: Vec<crate::BCSegmentData>,
80    pub(crate) fallback_bc: f64,
81}
82
83/// Manager for loading caliber-specific BC5D tables
84#[derive(Debug, Default)]
85pub struct Bc5dTableManager {
86    /// Directory containing BC5D table files
87    table_dir: Option<PathBuf>,
88    /// Loaded tables by caliber (rounded to 3 decimal places)
89    tables: HashMap<i32, Bc5dTable>,
90}
91
92/// Error type for BC5D table operations
93#[derive(Debug)]
94pub enum Bc5dError {
95    IoError(std::io::Error),
96    InvalidMagic,
97    UnsupportedVersion(u32),
98    ChecksumMismatch { expected: u32, actual: u32 },
99    InvalidDimensions,
100    TableNotFound(f64),
101    NoTableDirectory,
102}
103
104impl std::fmt::Display for Bc5dError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            Bc5dError::IoError(e) => write!(f, "IO error: {}", e),
108            Bc5dError::InvalidMagic => write!(f, "Invalid file magic (expected 'BC5D')"),
109            Bc5dError::UnsupportedVersion(v) => write!(f, "Unsupported table version: {}", v),
110            Bc5dError::ChecksumMismatch { expected, actual } => {
111                write!(f, "Checksum mismatch: expected {:08x}, got {:08x}", expected, actual)
112            }
113            Bc5dError::InvalidDimensions => write!(f, "Invalid table dimensions"),
114            Bc5dError::TableNotFound(cal) => write!(f, "No BC5D table found for caliber {:.3}", cal),
115            Bc5dError::NoTableDirectory => write!(f, "No BC table directory configured"),
116        }
117    }
118}
119
120impl std::error::Error for Bc5dError {}
121
122impl From<std::io::Error> for Bc5dError {
123    fn from(e: std::io::Error) -> Self {
124        Bc5dError::IoError(e)
125    }
126}
127
128impl Bc5dTable {
129    /// Load a BC5D table from a binary file
130    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Bc5dError> {
131        let file = File::open(&path)?;
132        Self::from_reader(BufReader::new(file))
133    }
134
135    /// Parse a BC5D table directly from an in-memory byte slice.
136    ///
137    /// Behaves identically to [`Bc5dTable::load`] but performs no filesystem
138    /// access, which makes it usable from WASM (`wasm32-unknown-unknown`, where
139    /// there is no `std::fs`). The host JS/Node layer fetches the `.bin` and
140    /// hands the raw bytes across the boundary.
141    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Bc5dError> {
142        Self::from_reader(std::io::Cursor::new(bytes))
143    }
144
145    fn from_reader<R: Read>(mut reader: R) -> Result<Self, Bc5dError> {
146        // Read and validate magic
147        let mut magic = [0u8; 4];
148        reader.read_exact(&mut magic)?;
149        if &magic != MAGIC {
150            return Err(Bc5dError::InvalidMagic);
151        }
152
153        // Read header fields
154        let version = read_u32(&mut reader)?;
155        if version != SUPPORTED_VERSION {
156            return Err(Bc5dError::UnsupportedVersion(version));
157        }
158
159        let caliber = read_f32(&mut reader)?;
160        let _flags = read_u32(&mut reader)?;
161        let _padding = read_u32(&mut reader)?;
162
163        let dim_weight = read_u32(&mut reader)? as usize;
164        let dim_bc = read_u32(&mut reader)? as usize;
165        let dim_muzzle_vel = read_u32(&mut reader)? as usize;
166        let dim_current_vel = read_u32(&mut reader)? as usize;
167        let dim_drag_types = read_u32(&mut reader)? as usize;
168
169        let timestamp = read_u64(&mut reader)?;
170        let stored_checksum = read_u32(&mut reader)?;
171
172        // Read API version (16 bytes, null-terminated)
173        let mut api_version_bytes = [0u8; 16];
174        reader.read_exact(&mut api_version_bytes)?;
175        let api_version = String::from_utf8_lossy(&api_version_bytes)
176            .trim_end_matches('\0')
177            .to_string();
178
179        // Skip reserved bytes
180        let mut reserved = [0u8; 12];
181        reader.read_exact(&mut reserved)?;
182
183        // Validate dimensions
184        if dim_weight == 0 || dim_bc == 0 || dim_muzzle_vel == 0 || dim_current_vel == 0 || dim_drag_types == 0 {
185            return Err(Bc5dError::InvalidDimensions);
186        }
187
188        // Read bin definitions
189        let weight_bins = read_f32_array(&mut reader, dim_weight)?;
190        let bc_bins = read_f32_array(&mut reader, dim_bc)?;
191        let muzzle_vel_bins = read_f32_array(&mut reader, dim_muzzle_vel)?;
192        let current_vel_bins = read_f32_array(&mut reader, dim_current_vel)?;
193
194        // Read data section. Bound the product with checked arithmetic so a corrupt or
195        // hostile file cannot overflow (debug panic / release wrap) or trigger a huge OOM
196        // allocation before the trailing CRC check can reject it.
197        const MAX_TOTAL_CELLS: usize = 64_000_000; // ~256 MB of f32; far above any real table
198        let total_cells = dim_drag_types
199            .checked_mul(dim_weight)
200            .and_then(|x| x.checked_mul(dim_bc))
201            .and_then(|x| x.checked_mul(dim_muzzle_vel))
202            .and_then(|x| x.checked_mul(dim_current_vel))
203            .filter(|&n| n <= MAX_TOTAL_CELLS)
204            .ok_or(Bc5dError::InvalidDimensions)?;
205        let data = read_f32_array(&mut reader, total_cells)?;
206
207        // Verify checksum (CRC32 of bins + data)
208        let mut checksum_data = Vec::new();
209        for &v in &weight_bins {
210            checksum_data.extend_from_slice(&v.to_le_bytes());
211        }
212        for &v in &bc_bins {
213            checksum_data.extend_from_slice(&v.to_le_bytes());
214        }
215        for &v in &muzzle_vel_bins {
216            checksum_data.extend_from_slice(&v.to_le_bytes());
217        }
218        for &v in &current_vel_bins {
219            checksum_data.extend_from_slice(&v.to_le_bytes());
220        }
221        for &v in &data {
222            checksum_data.extend_from_slice(&v.to_le_bytes());
223        }
224
225        let calculated_checksum = crc32_ieee(&checksum_data);
226        if calculated_checksum != stored_checksum {
227            return Err(Bc5dError::ChecksumMismatch {
228                expected: stored_checksum,
229                actual: calculated_checksum,
230            });
231        }
232
233        Ok(Bc5dTable {
234            caliber,
235            data,
236            weight_bins,
237            bc_bins,
238            muzzle_vel_bins,
239            current_vel_bins,
240            num_drag_types: dim_drag_types,
241            version,
242            api_version,
243            timestamp,
244        })
245    }
246
247    /// Look up a BC correction factor with 4D linear interpolation
248    /// (drag type is discrete, not interpolated)
249    ///
250    /// # Arguments
251    /// * `weight_grains` - Bullet weight in grains
252    /// * `base_bc` - Published BC value
253    /// * `muzzle_velocity` - Initial muzzle velocity in fps
254    /// * `current_velocity` - Current bullet velocity in fps
255    /// * `drag_type` - "G1" or "G7"
256    ///
257    /// # Returns
258    /// Correction factor (multiply published BC by this value)
259    pub fn lookup(
260        &self,
261        weight_grains: f64,
262        base_bc: f64,
263        muzzle_velocity: f64,
264        current_velocity: f64,
265        drag_type: &str,
266    ) -> f64 {
267        // Get drag type index (0 = G1, 1 = G7)
268        let drag_idx = if drag_type.eq_ignore_ascii_case("G7") { 1 } else { 0 };
269
270        // Clamp drag_idx to valid range
271        let drag_idx = drag_idx.min(self.num_drag_types - 1);
272
273        // Find interpolation indices and weights for each continuous dimension
274        let (weight_idx, weight_w) = self.interp_idx(weight_grains as f32, &self.weight_bins);
275        let (bc_idx, bc_w) = self.interp_idx(base_bc as f32, &self.bc_bins);
276        let (muzzle_idx, muzzle_w) = self.interp_idx(muzzle_velocity as f32, &self.muzzle_vel_bins);
277        let (current_idx, current_w) = self.interp_idx(current_velocity as f32, &self.current_vel_bins);
278
279        // 4D linear interpolation (16 corners of a hypercube)
280        let mut result = 0.0f64;
281
282        for dw in 0..2 {
283            for db in 0..2 {
284                for dm in 0..2 {
285                    for dc in 0..2 {
286                        // Calculate weight for this corner
287                        let weight = (if dw == 0 { 1.0 - weight_w } else { weight_w })
288                            * (if db == 0 { 1.0 - bc_w } else { bc_w })
289                            * (if dm == 0 { 1.0 - muzzle_w } else { muzzle_w })
290                            * (if dc == 0 { 1.0 - current_w } else { current_w });
291
292                        // Get clamped indices
293                        let wi = (weight_idx + dw).min(self.weight_bins.len() - 1);
294                        let bi = (bc_idx + db).min(self.bc_bins.len() - 1);
295                        let mi = (muzzle_idx + dm).min(self.muzzle_vel_bins.len() - 1);
296                        let ci = (current_idx + dc).min(self.current_vel_bins.len() - 1);
297
298                        // Calculate flat index
299                        let idx = self.flat_index(drag_idx, wi, bi, mi, ci);
300                        result += weight * self.data[idx] as f64;
301                    }
302                }
303            }
304        }
305
306        // A correction is multiplicative, so an undefined table result must be neutral rather
307        // than silently becoming the most aggressive allowed degradation.
308        if !result.is_finite() {
309            return 1.0;
310        }
311
312        result.clamp(0.5, 1.5)
313    }
314
315    /// Get the effective BC at a given velocity
316    ///
317    /// This multiplies the base BC by the correction factor from the table.
318    pub fn get_effective_bc(
319        &self,
320        weight_grains: f64,
321        base_bc: f64,
322        muzzle_velocity: f64,
323        current_velocity: f64,
324        drag_type: &str,
325    ) -> f64 {
326        let correction = self.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type);
327        base_bc * correction
328    }
329
330    /// Generate velocity-dependent BC segments for a bullet from this table.
331    ///
332    /// This mirrors the CLI's `--bc-table-dir` segment synthesis: the 4D
333    /// correction surface is sampled at a fixed ladder of velocity breakpoints
334    /// (from 500 fps up through the muzzle velocity) and each adjacent pair
335    /// becomes a [`crate::BCSegmentData`] carrying the corrected BC over that
336    /// band. The solver consumes these via `inputs.bc_segments_data` +
337    /// `use_bc_segments`, giving the same velocity-dependent BC degradation
338    /// offline that the online solver produces.
339    ///
340    /// All velocities are in fps and `weight_grains` in grains, matching the
341    /// table's native units. Returns `None` when the table carries no
342    /// meaningful correction for this bullet (every sampled cell ~= 1.0), so
343    /// callers can leave the constant published BC in place.
344    pub fn generate_segments(
345        &self,
346        base_bc: f64,
347        drag_type: &str,
348        weight_grains: f64,
349        muzzle_velocity_fps: Option<f64>,
350    ) -> Option<Vec<crate::BCSegmentData>> {
351        let mut breakpoints: Vec<f64> = vec![
352            4000.0, 3500.0, 3000.0, 2700.0, 2500.0, 2300.0, 2100.0, 2000.0, 1900.0, 1800.0, 1700.0,
353            1600.0, 1500.0, 1400.0, 1350.0, 1300.0, 1250.0, 1200.0, 1150.0, 1100.0, 1050.0, 1000.0,
354            950.0, 900.0, 850.0, 800.0, 700.0, 600.0, 500.0,
355        ];
356        if let Some(mv) = muzzle_velocity_fps {
357            breakpoints.push(mv);
358        }
359
360        let mut velocities: Vec<f64> = breakpoints
361            .into_iter()
362            .filter(|&v| v >= 500.0 && muzzle_velocity_fps.map_or(true, |mv| v <= mv))
363            .collect();
364        velocities.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
365        velocities.dedup();
366
367        // Correction factors were generated relative to the highest (muzzle)
368        // velocity, so anchor every lookup to that reference.
369        let reference_mv = velocities.first().copied().unwrap_or(3000.0);
370
371        let mut segments: Vec<crate::BCSegmentData> = Vec::new();
372        let mut any_correction = false;
373        for i in 0..velocities.len().saturating_sub(1) {
374            let vel_max = velocities[i];
375            let vel_min = velocities[i + 1];
376            let vel_mid = (vel_max + vel_min) / 2.0;
377
378            let correction =
379                self.lookup(weight_grains, base_bc, reference_mv, vel_mid, drag_type);
380            if (correction - 1.0).abs() > 1e-6 {
381                any_correction = true;
382            }
383            segments.push(crate::BCSegmentData {
384                velocity_min: vel_min,
385                velocity_max: vel_max,
386                bc_value: base_bc * correction,
387            });
388        }
389
390        if any_correction && !segments.is_empty() {
391            Some(segments)
392        } else {
393            None
394        }
395    }
396
397    /// Generate the BC5D schedule consumed by the WASM frontend.
398    #[cfg(any(test, target_arch = "wasm32"))]
399    pub(crate) fn generate_segment_schedule(
400        &self,
401        base_bc: f64,
402        drag_type: &str,
403        weight_grains: f64,
404        muzzle_velocity_fps: f64,
405    ) -> Option<Bc5dSegmentSchedule> {
406        let segments =
407            self.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_velocity_fps))?;
408        let fallback_bc = self.get_effective_bc(
409            weight_grains,
410            base_bc,
411            muzzle_velocity_fps,
412            muzzle_velocity_fps,
413            drag_type,
414        );
415
416        Some(Bc5dSegmentSchedule {
417            segments,
418            fallback_bc,
419        })
420    }
421
422    /// Find interpolation index and weight for a value in bins
423    fn interp_idx(&self, value: f32, bins: &[f32]) -> (usize, f64) {
424        if bins.len() < 2 || value.is_nan() {
425            return (0, 0.0);
426        }
427
428        // Handle out of range (clamp to edges)
429        if value <= bins[0] {
430            return (0, 0.0);
431        }
432        if value >= bins[bins.len() - 1] {
433            return (bins.len().saturating_sub(2), 1.0);
434        }
435
436        // Binary search for interval containing value
437        let last_interval = bins.len().saturating_sub(2);
438        let idx = match bins.binary_search_by(|probe| {
439            probe
440                .partial_cmp(&value)
441                .unwrap_or(std::cmp::Ordering::Equal)
442        }) {
443            Ok(i) => i.saturating_sub(1).min(last_interval),
444            Err(i) => i.saturating_sub(1).min(last_interval),
445        };
446
447        // Calculate interpolation weight
448        let low = bins[idx];
449        let high = bins[idx + 1];
450        let weight = if high > low {
451            ((value - low) / (high - low)) as f64
452        } else {
453            0.0
454        };
455
456        (idx, weight)
457    }
458
459    /// Calculate flat array index from 5D indices
460    fn flat_index(&self, drag_idx: usize, weight_idx: usize, bc_idx: usize, muzzle_idx: usize, current_idx: usize) -> usize {
461        let n_weight = self.weight_bins.len();
462        let n_bc = self.bc_bins.len();
463        let n_muzzle = self.muzzle_vel_bins.len();
464        let n_current = self.current_vel_bins.len();
465
466        drag_idx * (n_weight * n_bc * n_muzzle * n_current)
467            + weight_idx * (n_bc * n_muzzle * n_current)
468            + bc_idx * (n_muzzle * n_current)
469            + muzzle_idx * n_current
470            + current_idx
471    }
472
473    /// Get caliber this table is for
474    pub fn caliber(&self) -> f32 {
475        self.caliber
476    }
477
478    /// Get table version
479    pub fn version(&self) -> u32 {
480        self.version
481    }
482
483    /// Get API version used to generate the table
484    pub fn api_version(&self) -> &str {
485        &self.api_version
486    }
487
488    /// Get generation timestamp
489    pub fn timestamp(&self) -> u64 {
490        self.timestamp
491    }
492
493    /// Get total number of cells in the table
494    pub fn total_cells(&self) -> usize {
495        self.data.len()
496    }
497
498    /// Get table dimensions as a string
499    pub fn dimensions_str(&self) -> String {
500        format!(
501            "{}x{}x{}x{}x{} (weight x bc x muzzle_vel x current_vel x drag_types)",
502            self.weight_bins.len(),
503            self.bc_bins.len(),
504            self.muzzle_vel_bins.len(),
505            self.current_vel_bins.len(),
506            self.num_drag_types
507        )
508    }
509
510    /// Get weight range
511    pub fn weight_range(&self) -> (f32, f32) {
512        (*self.weight_bins.first().unwrap_or(&0.0), *self.weight_bins.last().unwrap_or(&0.0))
513    }
514
515    /// Get velocity range
516    pub fn velocity_range(&self) -> (f32, f32) {
517        (*self.current_vel_bins.first().unwrap_or(&0.0), *self.current_vel_bins.last().unwrap_or(&0.0))
518    }
519}
520
521impl Bc5dTableManager {
522    /// Create a new table manager with a directory path
523    pub fn new<P: AsRef<Path>>(table_dir: P) -> Self {
524        Bc5dTableManager {
525            table_dir: Some(table_dir.as_ref().to_path_buf()),
526            tables: HashMap::new(),
527        }
528    }
529
530    /// Create an empty manager (no table directory)
531    pub fn empty() -> Self {
532        Bc5dTableManager {
533            table_dir: None,
534            tables: HashMap::new(),
535        }
536    }
537
538    /// Get or load the table for a caliber
539    ///
540    /// Tables are cached after first load.
541    pub fn get_table(&mut self, caliber: f64) -> Result<&Bc5dTable, Bc5dError> {
542        let caliber_key = caliber_to_key(caliber);
543
544        // Check if already loaded
545        if self.tables.contains_key(&caliber_key) {
546            return Ok(self.tables.get(&caliber_key).unwrap());
547        }
548
549        // Need to load
550        let table_dir = self.table_dir.as_ref().ok_or(Bc5dError::NoTableDirectory)?;
551        let table_path = find_table_file(table_dir, caliber)?;
552        let table = Bc5dTable::load(&table_path)?;
553        self.tables.insert(caliber_key, table);
554        Ok(self.tables.get(&caliber_key).unwrap())
555    }
556
557    /// Look up BC correction for a bullet
558    pub fn lookup(
559        &mut self,
560        caliber: f64,
561        weight_grains: f64,
562        base_bc: f64,
563        muzzle_velocity: f64,
564        current_velocity: f64,
565        drag_type: &str,
566    ) -> Result<f64, Bc5dError> {
567        let table = self.get_table(caliber)?;
568        Ok(table.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
569    }
570
571    /// Get effective BC with correction applied
572    pub fn get_effective_bc(
573        &mut self,
574        caliber: f64,
575        weight_grains: f64,
576        base_bc: f64,
577        muzzle_velocity: f64,
578        current_velocity: f64,
579        drag_type: &str,
580    ) -> Result<f64, Bc5dError> {
581        let table = self.get_table(caliber)?;
582        Ok(table.get_effective_bc(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
583    }
584
585    /// Check if a table is available for a caliber
586    pub fn has_table(&self, caliber: f64) -> bool {
587        if let Some(ref table_dir) = self.table_dir {
588            find_table_file(table_dir, caliber).is_ok()
589        } else {
590            false
591        }
592    }
593
594    /// List available calibers in the table directory
595    pub fn available_calibers(&self) -> Vec<f64> {
596        let mut calibers = Vec::new();
597        if let Some(ref table_dir) = self.table_dir {
598            if let Ok(entries) = std::fs::read_dir(table_dir) {
599                for entry in entries.flatten() {
600                    let path = entry.path();
601                    if let Some(ext) = path.extension() {
602                        if ext == "bin" {
603                            if let Some(stem) = path.file_stem() {
604                                let name = stem.to_string_lossy();
605                                if name.starts_with("bc5d_") {
606                                    // Parse caliber from filename (e.g., bc5d_308.bin -> 0.308)
607                                    if let Ok(cal_int) = name[5..].parse::<i32>() {
608                                        calibers.push(cal_int as f64 / 1000.0);
609                                    }
610                                }
611                            }
612                        }
613                    }
614                }
615            }
616        }
617        calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
618        calibers
619    }
620}
621
622/// Convert caliber to integer key (multiply by 1000)
623fn caliber_to_key(caliber: f64) -> i32 {
624    (caliber * 1000.0).round() as i32
625}
626
627/// Find the table file for a caliber
628fn find_table_file(table_dir: &Path, caliber: f64) -> Result<PathBuf, Bc5dError> {
629    let caliber_int = (caliber * 1000.0).round() as i32;
630    let filename = format!("bc5d_{}.bin", caliber_int);
631    let path = table_dir.join(&filename);
632
633    if path.exists() {
634        return Ok(path);
635    }
636
637    // Try common variations
638    let variations = [
639        format!("bc5d_{:03}.bin", caliber_int),
640        format!("bc5d_0{}.bin", caliber_int),
641    ];
642
643    for var in &variations {
644        let var_path = table_dir.join(var);
645        if var_path.exists() {
646            return Ok(var_path);
647        }
648    }
649
650    Err(Bc5dError::TableNotFound(caliber))
651}
652
653// Helper functions for reading binary data
654
655fn read_u32<R: Read>(reader: &mut R) -> Result<u32, std::io::Error> {
656    let mut buf = [0u8; 4];
657    reader.read_exact(&mut buf)?;
658    Ok(u32::from_le_bytes(buf))
659}
660
661fn read_u64<R: Read>(reader: &mut R) -> Result<u64, std::io::Error> {
662    let mut buf = [0u8; 8];
663    reader.read_exact(&mut buf)?;
664    Ok(u64::from_le_bytes(buf))
665}
666
667fn read_f32<R: Read>(reader: &mut R) -> Result<f32, std::io::Error> {
668    let mut buf = [0u8; 4];
669    reader.read_exact(&mut buf)?;
670    Ok(f32::from_le_bytes(buf))
671}
672
673fn read_f32_array<R: Read>(reader: &mut R, count: usize) -> Result<Vec<f32>, std::io::Error> {
674    // Defensive bounds: reject absurd lengths from corrupt/hostile files before
675    // allocating, and guard the byte-count multiply against overflow.
676    const MAX_ELEMS: usize = 64_000_000; // 256 MB of f32
677    if count > MAX_ELEMS {
678        return Err(std::io::Error::new(
679            std::io::ErrorKind::InvalidData,
680            "f32 array length too large",
681        ));
682    }
683    let byte_len = count.checked_mul(4).ok_or_else(|| {
684        std::io::Error::new(std::io::ErrorKind::InvalidData, "f32 array length overflow")
685    })?;
686    let mut data = vec![0f32; count];
687    let mut buf = vec![0u8; byte_len];
688    reader.read_exact(&mut buf)?;
689
690    for (i, chunk) in buf.chunks_exact(4).enumerate() {
691        data[i] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
692    }
693
694    Ok(data)
695}
696
697/// Simple CRC32 (IEEE polynomial) implementation
698pub(crate) fn crc32_ieee(data: &[u8]) -> u32 {
699    const TABLE: [u32; 256] = make_crc32_table();
700    let mut crc = 0xFFFFFFFFu32;
701    for &byte in data {
702        let idx = ((crc ^ byte as u32) & 0xFF) as usize;
703        crc = (crc >> 8) ^ TABLE[idx];
704    }
705    !crc
706}
707
708const fn make_crc32_table() -> [u32; 256] {
709    const POLY: u32 = 0xEDB88320;
710    let mut table = [0u32; 256];
711    let mut i = 0;
712    while i < 256 {
713        let mut crc = i as u32;
714        let mut j = 0;
715        while j < 8 {
716            if crc & 1 != 0 {
717                crc = (crc >> 1) ^ POLY;
718            } else {
719                crc >>= 1;
720            }
721            j += 1;
722        }
723        table[i] = crc;
724        i += 1;
725    }
726    table
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    fn create_test_table() -> Bc5dTable {
734        // Create a small test table with known values
735        let weight_bins = vec![100.0, 150.0, 200.0];
736        let bc_bins = vec![0.3, 0.4, 0.5];
737        let muzzle_vel_bins = vec![2500.0, 3000.0];
738        let current_vel_bins = vec![1000.0, 2000.0, 3000.0];
739        let num_drag_types = 2;
740
741        // Total cells: 2 * 3 * 3 * 2 * 3 = 108
742        let total = num_drag_types * weight_bins.len() * bc_bins.len() * muzzle_vel_bins.len() * current_vel_bins.len();
743        let mut data = vec![1.0f32; total];
744
745        // Set some non-uniform values for testing interpolation
746        // At weight=150, bc=0.4, muzzle=2750 (interpolated), current=2000, G1
747        // We'll set corners to test 4D interpolation
748        data[0] = 0.95; // First corner
749        data[total - 1] = 1.05; // Last corner
750
751        Bc5dTable {
752            caliber: 0.308,
753            data,
754            weight_bins,
755            bc_bins,
756            muzzle_vel_bins,
757            current_vel_bins,
758            num_drag_types,
759            version: 2,
760            api_version: "test".to_string(),
761            timestamp: 0,
762        }
763    }
764
765    fn create_single_cell_test_table() -> Bc5dTable {
766        Bc5dTable {
767            caliber: 0.308,
768            data: vec![0.875],
769            weight_bins: vec![168.0],
770            bc_bins: vec![0.4],
771            muzzle_vel_bins: vec![2500.0],
772            current_vel_bins: vec![2000.0],
773            num_drag_types: 1,
774            version: 2,
775            api_version: "test".to_string(),
776            timestamp: 0,
777        }
778    }
779
780    /// Serialize a table into the BC5D v2 `.bin` byte layout so we can exercise
781    /// the `from_bytes` parser without depending on an external file.
782    fn serialize_test_table(t: &Bc5dTable) -> Vec<u8> {
783        let mut out = Vec::new();
784        out.extend_from_slice(MAGIC);
785        out.extend_from_slice(&t.version.to_le_bytes());
786        out.extend_from_slice(&t.caliber.to_le_bytes());
787        out.extend_from_slice(&0u32.to_le_bytes()); // flags
788        out.extend_from_slice(&0u32.to_le_bytes()); // padding
789        out.extend_from_slice(&(t.weight_bins.len() as u32).to_le_bytes());
790        out.extend_from_slice(&(t.bc_bins.len() as u32).to_le_bytes());
791        out.extend_from_slice(&(t.muzzle_vel_bins.len() as u32).to_le_bytes());
792        out.extend_from_slice(&(t.current_vel_bins.len() as u32).to_le_bytes());
793        out.extend_from_slice(&(t.num_drag_types as u32).to_le_bytes());
794        out.extend_from_slice(&t.timestamp.to_le_bytes());
795
796        // Checksum is CRC32 of bins + data, in declaration order.
797        let mut checksum_data = Vec::new();
798        for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
799            .chain(&t.current_vel_bins).chain(&t.data) {
800            checksum_data.extend_from_slice(&v.to_le_bytes());
801        }
802        out.extend_from_slice(&crc32_ieee(&checksum_data).to_le_bytes());
803
804        let mut api = [0u8; 16];
805        let bytes = t.api_version.as_bytes();
806        api[..bytes.len().min(16)].copy_from_slice(&bytes[..bytes.len().min(16)]);
807        out.extend_from_slice(&api);
808        out.extend_from_slice(&[0u8; 12]); // reserved
809
810        for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
811            .chain(&t.current_vel_bins).chain(&t.data) {
812            out.extend_from_slice(&v.to_le_bytes());
813        }
814        out
815    }
816
817    #[test]
818    fn test_from_bytes_roundtrip() {
819        let original = create_test_table();
820        let bytes = serialize_test_table(&original);
821        let parsed = Bc5dTable::from_bytes(&bytes).expect("from_bytes should parse");
822
823        assert_eq!(parsed.caliber, original.caliber);
824        assert_eq!(parsed.num_drag_types, original.num_drag_types);
825        assert_eq!(parsed.weight_bins, original.weight_bins);
826        assert_eq!(parsed.current_vel_bins, original.current_vel_bins);
827        assert_eq!(parsed.data, original.data);
828        assert_eq!(parsed.api_version, original.api_version);
829
830        // A corrupted body must be rejected by the CRC check.
831        let mut bad = bytes.clone();
832        *bad.last_mut().unwrap() ^= 0xFF;
833        assert!(Bc5dTable::from_bytes(&bad).is_err());
834    }
835
836    #[test]
837    fn test_generate_segments() {
838        // A table whose corrections are all exactly 1.0 carries no useful
839        // correction, so generate_segments returns None (leave published BC).
840        let mut uniform = create_test_table();
841        uniform.data.iter_mut().for_each(|v| *v = 1.0);
842        assert!(uniform
843            .generate_segments(0.4, "G1", 150.0, Some(2700.0))
844            .is_none());
845
846        // A table with a real (0.9) correction across the sampled slice must
847        // produce contiguous, descending velocity segments carrying bc*corr.
848        let mut corrected = create_test_table();
849        corrected.data.iter_mut().for_each(|v| *v = 0.9);
850        let segments = corrected
851            .generate_segments(0.4, "G1", 150.0, Some(2700.0))
852            .expect("segments expected for a table with corrections");
853        assert!(!segments.is_empty());
854        for w in segments.windows(2) {
855            // Bands are contiguous and descend in velocity.
856            assert!((segments[0].velocity_max - w[0].velocity_max).abs() >= 0.0);
857            assert!(w[0].velocity_min >= w[1].velocity_max - 1e-6);
858        }
859        for s in &segments {
860            assert!((s.bc_value - 0.4 * 0.9).abs() < 1e-6); // base_bc * correction
861            assert!(s.velocity_max > s.velocity_min);
862        }
863    }
864
865    #[test]
866    fn segment_schedule_carries_muzzle_corrected_fallback_bc() {
867        let table = create_single_cell_test_table();
868        let base_bc = 0.4;
869        let schedule = table
870            .generate_segment_schedule(base_bc, "G1", 168.0, 2500.0)
871            .expect("uniform non-neutral correction should produce a schedule");
872        let expected_fallback = table.get_effective_bc(168.0, base_bc, 2500.0, 2500.0, "G1");
873
874        assert!(!schedule.segments.is_empty());
875        assert_eq!(expected_fallback.to_bits(), (base_bc * 0.875).to_bits());
876        assert_eq!(schedule.fallback_bc.to_bits(), expected_fallback.to_bits());
877    }
878
879    #[test]
880    fn test_interp_idx_in_range() {
881        let table = create_test_table();
882
883        // Test middle of range
884        let (idx, weight) = table.interp_idx(125.0, &table.weight_bins);
885        assert_eq!(idx, 0);
886        assert!((weight - 0.5).abs() < 0.01);
887
888        // Test at bin boundary
889        let (idx, weight) = table.interp_idx(150.0, &table.weight_bins);
890        assert_eq!(idx, 0);
891        assert!((weight - 1.0).abs() < 0.01);
892    }
893
894    #[test]
895    fn test_interp_idx_out_of_range() {
896        let table = create_test_table();
897
898        // Test below range
899        let (idx, weight) = table.interp_idx(50.0, &table.weight_bins);
900        assert_eq!(idx, 0);
901        assert_eq!(weight, 0.0);
902
903        // Test above range
904        let (idx, weight) = table.interp_idx(250.0, &table.weight_bins);
905        assert_eq!(idx, 1); // len - 2
906        assert_eq!(weight, 1.0);
907    }
908
909    #[test]
910    fn test_interp_idx_nan_defaults_to_first_bin() {
911        let table = create_test_table();
912
913        assert_eq!(table.interp_idx(f32::NAN, &table.weight_bins), (0, 0.0));
914        assert_eq!(
915            table.interp_idx(f32::NEG_INFINITY, &table.weight_bins),
916            (0, 0.0)
917        );
918        assert_eq!(
919            table.interp_idx(f32::INFINITY, &table.weight_bins),
920            (1, 1.0)
921        );
922    }
923
924    #[test]
925    fn test_lookup_nan_with_single_bin_axes_uses_only_cell() {
926        let table = create_single_cell_test_table();
927
928        assert_eq!(table.lookup(f64::NAN, 0.4, 2500.0, 2000.0, "G1"), 0.875);
929    }
930
931    #[test]
932    fn test_lookup_non_finite_table_cells_are_neutral() {
933        for cell in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
934            let mut source = create_test_table();
935            source.data.fill(cell);
936            let bytes = serialize_test_table(&source);
937            let table = Bc5dTable::from_bytes(&bytes).expect("CRC-valid table should load");
938
939            assert_eq!(
940                table.lookup(125.0, 0.35, 2750.0, 1500.0, "G1"),
941                1.0,
942                "non-finite cell {cell:?} must produce a neutral correction"
943            );
944            assert_eq!(
945                table.get_effective_bc(125.0, 0.35, 2750.0, 1500.0, "G1"),
946                0.35,
947                "neutral correction must preserve base BC for {cell:?}"
948            );
949        }
950    }
951
952    #[test]
953    fn test_lookup_returns_valid_range() {
954        let table = create_test_table();
955
956        let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G1");
957        assert!(correction >= 0.5 && correction <= 1.5);
958
959        let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G7");
960        assert!(correction >= 0.5 && correction <= 1.5);
961    }
962
963    #[test]
964    fn test_effective_bc() {
965        let table = create_test_table();
966
967        let base_bc = 0.4;
968        let effective = table.get_effective_bc(150.0, base_bc, 2750.0, 2000.0, "G1");
969
970        // Effective BC should be base_bc * correction
971        assert!(effective >= base_bc * 0.5 && effective <= base_bc * 1.5);
972    }
973
974    #[test]
975    fn test_caliber_to_key() {
976        assert_eq!(caliber_to_key(0.308), 308);
977        assert_eq!(caliber_to_key(0.224), 224);
978        assert_eq!(caliber_to_key(0.338), 338);
979    }
980
981    #[test]
982    fn test_table_metadata() {
983        let table = create_test_table();
984        assert!((table.caliber() - 0.308).abs() < 0.001);
985        assert_eq!(table.version(), 2);
986        assert_eq!(table.api_version(), "test");
987    }
988
989    #[test]
990    fn test_crc32() {
991        // Test with known CRC32 value
992        let data = b"123456789";
993        let crc = crc32_ieee(data);
994        assert_eq!(crc, 0xCBF43926);
995    }
996}