Skip to main content

ballistics_engine/
bc_table.rs

1// BC Correction Table - 5D Lookup Table with Linear Interpolation
2//
3// This module provides offline BC corrections by loading a precomputed table
4// of correction factors. The table is indexed by:
5//   - BC value (published BC)
6//   - BC type (G1 or G7)
7//   - Bullet mass (grains)
8//   - Bullet length (inches)
9//   - Velocity (fps)
10//
11// Binary file format (BCCR):
12//   Header (64 bytes):
13//     - Magic: 4 bytes ('BCCR')
14//     - Version: 4 bytes (uint32)
15//     - Flags: 4 bytes (uint32)
16//     - num_bc: 4 bytes (uint32)
17//     - num_mass: 4 bytes (uint32)
18//     - num_length: 4 bytes (uint32)
19//     - num_velocity: 4 bytes (uint32)
20//     - num_bc_types: 4 bytes (uint32)
21//     - timestamp: 8 bytes (uint64)
22//     - checksum: 4 bytes (uint32, CRC32)
23//     - reserved: 16 bytes
24//   Bin definitions:
25//     - BC bins: num_bc * 4 bytes (float32)
26//     - Mass bins: num_mass * 4 bytes (float32)
27//     - Length bins: num_length * 4 bytes (float32)
28//     - Velocity bins: num_velocity * 4 bytes (float32)
29//   Data section:
30//     - Correction factors: total_cells * 4 bytes (float32)
31//     - Layout: [bc_type][bc][mass][length][velocity]
32
33use std::fs::File;
34use std::io::{BufReader, Read};
35use std::path::Path;
36
37/// BC correction table with 5D interpolation
38#[derive(Debug)]
39pub struct BcCorrectionTable {
40    /// Table data: [bc_type][bc][mass][length][velocity]
41    data: Vec<f32>,
42    /// BC bin values
43    bc_bins: Vec<f32>,
44    /// Mass bin values (grains)
45    mass_bins: Vec<f32>,
46    /// Length bin values (inches)
47    length_bins: Vec<f32>,
48    /// Velocity bin values (fps)
49    velocity_bins: Vec<f32>,
50    /// Number of BC types (typically 2: G1, G7)
51    num_types: usize,
52    /// Table version
53    version: u32,
54}
55
56/// Error type for BC table operations
57#[derive(Debug)]
58pub enum BcTableError {
59    IoError(std::io::Error),
60    InvalidMagic,
61    UnsupportedVersion(u32),
62    ChecksumMismatch,
63    InvalidDimensions,
64}
65
66impl std::fmt::Display for BcTableError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            BcTableError::IoError(e) => write!(f, "IO error: {}", e),
70            BcTableError::InvalidMagic => write!(f, "Invalid file magic (expected 'BCCR')"),
71            BcTableError::UnsupportedVersion(v) => write!(f, "Unsupported table version: {}", v),
72            BcTableError::ChecksumMismatch => write!(f, "Data checksum mismatch"),
73            BcTableError::InvalidDimensions => write!(f, "Invalid table dimensions"),
74        }
75    }
76}
77
78impl std::error::Error for BcTableError {}
79
80impl From<std::io::Error> for BcTableError {
81    fn from(e: std::io::Error) -> Self {
82        BcTableError::IoError(e)
83    }
84}
85
86impl BcCorrectionTable {
87    /// Load a BC correction table from a binary file
88    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, BcTableError> {
89        let file = File::open(path)?;
90        let mut reader = BufReader::new(file);
91
92        // Read header (64 bytes)
93        let mut magic = [0u8; 4];
94        reader.read_exact(&mut magic)?;
95        if &magic != b"BCCR" {
96            return Err(BcTableError::InvalidMagic);
97        }
98
99        let version = read_u32(&mut reader)?;
100        if version != 1 {
101            return Err(BcTableError::UnsupportedVersion(version));
102        }
103
104        let _flags = read_u32(&mut reader)?;
105        let num_bc = read_u32(&mut reader)? as usize;
106        let num_mass = read_u32(&mut reader)? as usize;
107        let num_length = read_u32(&mut reader)? as usize;
108        let num_velocity = read_u32(&mut reader)? as usize;
109        let num_types = read_u32(&mut reader)? as usize;
110        let _timestamp = read_u64(&mut reader)?;
111        let stored_checksum = read_u32(&mut reader)?;
112
113        // Skip reserved bytes
114        let mut reserved = [0u8; 16];
115        reader.read_exact(&mut reserved)?;
116
117        // Validate dimensions
118        if num_bc == 0 || num_mass == 0 || num_length == 0 || num_velocity == 0 || num_types == 0 {
119            return Err(BcTableError::InvalidDimensions);
120        }
121
122        // Read bin definitions
123        let bc_bins = read_f32_array(&mut reader, num_bc)?;
124        let mass_bins = read_f32_array(&mut reader, num_mass)?;
125        let length_bins = read_f32_array(&mut reader, num_length)?;
126        let velocity_bins = read_f32_array(&mut reader, num_velocity)?;
127
128        // Read data section. Bound the product with checked arithmetic so a corrupt/hostile
129        // file cannot overflow (debug panic / release wrap to a too-short `data`, which then
130        // panics on the unchecked index in lookup()) or trigger a huge OOM allocation.
131        // Mirrors the bc_table_5d.rs hardening.
132        const MAX_TOTAL_CELLS: usize = 64_000_000; // ~256 MB of f32; far above any real table
133        let total_cells = num_types
134            .checked_mul(num_bc)
135            .and_then(|x| x.checked_mul(num_mass))
136            .and_then(|x| x.checked_mul(num_length))
137            .and_then(|x| x.checked_mul(num_velocity))
138            .filter(|&n| n <= MAX_TOTAL_CELLS)
139            .ok_or(BcTableError::InvalidDimensions)?;
140        let data = read_f32_array(&mut reader, total_cells)?;
141
142        // MBA-953: verify the stored CRC32 (previously read and discarded, so corrupt-but-in-range
143        // files loaded silently). BCCR CRCs the DATA section ONLY — the f32 cells — unlike
144        // bc_table_5d which CRCs bins+data. Gate on a non-zero stored value so older files written
145        // without a checksum (stored == 0) still load. The f32 round-trip is bit-exact, so this
146        // reproduces the on-disk data bytes.
147        if stored_checksum != 0 {
148            let mut data_bytes = Vec::with_capacity(data.len() * 4);
149            for &v in &data {
150                data_bytes.extend_from_slice(&v.to_le_bytes());
151            }
152            let calculated = crate::bc_table_5d::crc32_ieee(&data_bytes);
153            if calculated != stored_checksum {
154                return Err(BcTableError::ChecksumMismatch);
155            }
156        }
157
158        Ok(BcCorrectionTable {
159            data,
160            bc_bins,
161            mass_bins,
162            length_bins,
163            velocity_bins,
164            num_types,
165            version,
166        })
167    }
168
169    /// Look up a BC correction factor with 5D linear interpolation
170    ///
171    /// # Arguments
172    /// * `bc` - Published BC value
173    /// * `bc_type` - "G1" or "G7"
174    /// * `mass` - Bullet mass in grains
175    /// * `length` - Bullet length in inches
176    /// * `velocity` - Current velocity in fps
177    ///
178    /// # Returns
179    /// Correction factor (multiply published BC by this value)
180    pub fn lookup(&self, bc: f64, bc_type: &str, mass: f64, length: f64, velocity: f64) -> f64 {
181        // Get type index (0 = G1, 1 = G7), clamped to the table's type axis so a
182        // G1-only table (num_types == 1) queried with "G7" stays in bounds instead of
183        // producing an out-of-range flat_index (mirrors Bc5dTable::lookup).
184        let type_idx = (if bc_type.to_uppercase() == "G1" { 0 } else { 1 })
185            .min(self.num_types.saturating_sub(1));
186
187        // Find interpolation indices and weights for each dimension
188        let (bc_idx, bc_weight) = self.interp_idx(bc as f32, &self.bc_bins);
189        let (mass_idx, mass_weight) = self.interp_idx(mass as f32, &self.mass_bins);
190        let (length_idx, length_weight) = self.interp_idx(length as f32, &self.length_bins);
191        let (vel_idx, vel_weight) = self.interp_idx(velocity as f32, &self.velocity_bins);
192
193        // 4D linear interpolation (type is discrete, not interpolated)
194        let mut result = 0.0f64;
195
196        for di in 0..2 {
197            for dj in 0..2 {
198                for dk in 0..2 {
199                    for dl in 0..2 {
200                        // Calculate weight for this corner
201                        let w = (if di == 0 { 1.0 - bc_weight } else { bc_weight })
202                            * (if dj == 0 {
203                                1.0 - mass_weight
204                            } else {
205                                mass_weight
206                            })
207                            * (if dk == 0 {
208                                1.0 - length_weight
209                            } else {
210                                length_weight
211                            })
212                            * (if dl == 0 {
213                                1.0 - vel_weight
214                            } else {
215                                vel_weight
216                            });
217
218                        // Get clamped indices
219                        let i = (bc_idx + di).min(self.bc_bins.len() - 1);
220                        let j = (mass_idx + dj).min(self.mass_bins.len() - 1);
221                        let k = (length_idx + dk).min(self.length_bins.len() - 1);
222                        let l = (vel_idx + dl).min(self.velocity_bins.len() - 1);
223
224                        // Calculate flat index
225                        let idx = self.flat_index(type_idx, i, j, k, l);
226                        // Bounds-safe access (true defense-in-depth): the continuous axes are
227                        // clamped above and type_idx is clamped to num_types, so idx is always
228                        // in bounds for any valid table; the unwrap_or only guards corrupt data.
229                        result += w * self.data.get(idx).copied().unwrap_or(0.0) as f64;
230                    }
231                }
232            }
233        }
234
235        result
236    }
237
238    /// Get the BC correction factor for a given bullet and velocity
239    /// This is a convenience method that estimates bullet length from mass and caliber
240    ///
241    /// # Arguments
242    /// * `bc` - Published BC value
243    /// * `bc_type` - "G1" or "G7"
244    /// * `mass_grains` - Bullet mass in grains
245    /// * `caliber_inches` - Bullet caliber in inches
246    /// * `velocity_fps` - Current velocity in fps
247    pub fn lookup_with_caliber(
248        &self,
249        bc: f64,
250        bc_type: &str,
251        mass_grains: f64,
252        caliber_inches: f64,
253        velocity_fps: f64,
254    ) -> f64 {
255        // MBA-1135: estimate bullet length from mass + caliber (was a mass-blind caliber*3.5
256        // heuristic). Length is a real interpolation axis of this table, so a weight-aware
257        // estimate improves the correction. Fall back to the old heuristic if mass is unavailable.
258        let est_m = crate::stability::estimate_bullet_length_m(
259            caliber_inches * 0.0254,
260            mass_grains * crate::constants::GRAINS_TO_KG,
261        );
262        let estimated_length = if est_m > 0.0 {
263            est_m / 0.0254
264        } else {
265            caliber_inches * 3.5
266        };
267        self.lookup(bc, bc_type, mass_grains, estimated_length, velocity_fps)
268    }
269
270    /// Find interpolation index and weight for a value in bins
271    fn interp_idx(&self, value: f32, bins: &[f32]) -> (usize, f64) {
272        if bins.len() < 2 || value.is_nan() {
273            return (0, 0.0);
274        }
275
276        // Handle out of range
277        if value <= bins[0] {
278            return (0, 0.0);
279        }
280        if value >= bins[bins.len() - 1] {
281            return (bins.len().saturating_sub(2), 1.0);
282        }
283
284        // Binary search for interval
285        let last_interval = bins.len().saturating_sub(2);
286        let idx = match bins.binary_search_by(|probe| {
287            probe
288                .partial_cmp(&value)
289                .unwrap_or(std::cmp::Ordering::Equal)
290        }) {
291            Ok(i) => i.saturating_sub(1).min(last_interval),
292            Err(i) => i.saturating_sub(1).min(last_interval),
293        };
294
295        // Calculate interpolation weight
296        let low = bins[idx];
297        let high = bins[idx + 1];
298        let weight = if high > low {
299            ((value - low) / (high - low)) as f64
300        } else {
301            0.0
302        };
303
304        (idx, weight)
305    }
306
307    /// Calculate flat array index from 5D indices
308    fn flat_index(
309        &self,
310        type_idx: usize,
311        bc_idx: usize,
312        mass_idx: usize,
313        length_idx: usize,
314        vel_idx: usize,
315    ) -> usize {
316        let n_bc = self.bc_bins.len();
317        let n_mass = self.mass_bins.len();
318        let n_length = self.length_bins.len();
319        let n_velocity = self.velocity_bins.len();
320
321        type_idx * (n_bc * n_mass * n_length * n_velocity)
322            + bc_idx * (n_mass * n_length * n_velocity)
323            + mass_idx * (n_length * n_velocity)
324            + length_idx * n_velocity
325            + vel_idx
326    }
327
328    /// Get table version
329    pub fn version(&self) -> u32 {
330        self.version
331    }
332
333    /// Get total number of cells in the table
334    pub fn total_cells(&self) -> usize {
335        self.data.len()
336    }
337
338    /// Get table dimensions as a string
339    pub fn dimensions_str(&self) -> String {
340        format!(
341            "{}x{}x{}x{}x{}",
342            self.bc_bins.len(),
343            self.mass_bins.len(),
344            self.length_bins.len(),
345            self.velocity_bins.len(),
346            self.num_types
347        )
348    }
349}
350
351// Helper functions for reading binary data
352
353fn read_u32<R: Read>(reader: &mut R) -> Result<u32, std::io::Error> {
354    let mut buf = [0u8; 4];
355    reader.read_exact(&mut buf)?;
356    Ok(u32::from_le_bytes(buf))
357}
358
359fn read_u64<R: Read>(reader: &mut R) -> Result<u64, std::io::Error> {
360    let mut buf = [0u8; 8];
361    reader.read_exact(&mut buf)?;
362    Ok(u64::from_le_bytes(buf))
363}
364
365// A newer clippy stable added `chunks_exact_to_as_chunks`, which flags a constant chunk
366// size and suggests `as_chunks`. Suppressed rather than restructured: this is binary-format
367// parsing, `as_chunks` changes the element type from `&[u8]` to `&[u8; N]` and so ripples
368// into every use inside the loop, and the change would land in the middle of a 13-platform
369// release. `unknown_lints` is allowed alongside it so toolchains predating the lint do not
370// warn on the name. Adopting `as_chunks` properly is a follow-up.
371#[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
372fn read_f32_array<R: Read>(reader: &mut R, count: usize) -> Result<Vec<f32>, std::io::Error> {
373    // Defensive bounds: reject absurd lengths from corrupt/hostile files before allocating,
374    // and guard the byte-count multiply against overflow (mirrors bc_table_5d.rs).
375    const MAX_ELEMS: usize = 64_000_000; // 256 MB of f32
376    if count > MAX_ELEMS {
377        return Err(std::io::Error::new(
378            std::io::ErrorKind::InvalidData,
379            "f32 array length too large",
380        ));
381    }
382    let byte_len = count.checked_mul(4).ok_or_else(|| {
383        std::io::Error::new(std::io::ErrorKind::InvalidData, "f32 array length overflow")
384    })?;
385    let mut data = vec![0f32; count];
386    let mut buf = vec![0u8; byte_len];
387    reader.read_exact(&mut buf)?;
388
389    for (i, chunk) in buf.chunks_exact(4).enumerate() {
390        data[i] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
391    }
392
393    Ok(data)
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn create_single_cell_table() -> BcCorrectionTable {
401        BcCorrectionTable {
402            data: vec![0.875],
403            bc_bins: vec![0.4],
404            mass_bins: vec![168.0],
405            length_bins: vec![1.2],
406            velocity_bins: vec![2500.0],
407            num_types: 1,
408            version: 1,
409        }
410    }
411
412    #[test]
413    fn test_interp_idx_in_range() {
414        let table = BcCorrectionTable {
415            data: vec![1.0; 100],
416            bc_bins: vec![0.1, 0.2, 0.3, 0.4, 0.5],
417            mass_bins: vec![100.0, 150.0, 200.0],
418            length_bins: vec![1.0, 1.2, 1.4],
419            velocity_bins: vec![3000.0, 2500.0, 2000.0],
420            num_types: 2,
421            version: 1,
422        };
423
424        // Test middle of range
425        let (idx, weight) = table.interp_idx(0.25, &table.bc_bins);
426        assert_eq!(idx, 1);
427        assert!((weight - 0.5).abs() < 0.01);
428
429        // Test at bin boundary
430        let (idx, weight) = table.interp_idx(0.2, &table.bc_bins);
431        assert_eq!(idx, 0);
432        assert!((weight - 1.0).abs() < 0.01);
433    }
434
435    #[test]
436    fn test_interp_idx_out_of_range() {
437        let table = BcCorrectionTable {
438            data: vec![1.0; 100],
439            bc_bins: vec![0.1, 0.2, 0.3, 0.4, 0.5],
440            mass_bins: vec![100.0, 150.0, 200.0],
441            length_bins: vec![1.0, 1.2, 1.4],
442            velocity_bins: vec![3000.0, 2500.0, 2000.0],
443            num_types: 2,
444            version: 1,
445        };
446
447        // Test below range
448        let (idx, weight) = table.interp_idx(0.05, &table.bc_bins);
449        assert_eq!(idx, 0);
450        assert_eq!(weight, 0.0);
451
452        // Test above range
453        let (idx, weight) = table.interp_idx(0.6, &table.bc_bins);
454        assert_eq!(idx, 3); // len - 2
455        assert_eq!(weight, 1.0);
456    }
457
458    #[test]
459    fn test_interp_idx_nan_defaults_to_first_bin() {
460        let table = create_single_cell_table();
461        let bins = [0.1, 0.2, 0.3];
462
463        assert_eq!(table.interp_idx(f32::NAN, &bins), (0, 0.0));
464        assert_eq!(table.interp_idx(f32::NEG_INFINITY, &bins), (0, 0.0));
465        assert_eq!(table.interp_idx(f32::INFINITY, &bins), (1, 1.0));
466    }
467
468    #[test]
469    fn test_lookup_nan_with_single_bin_axes_uses_only_cell() {
470        let table = create_single_cell_table();
471
472        assert_eq!(table.lookup(f64::NAN, "G1", 168.0, 1.2, 2500.0), 0.875);
473    }
474}