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