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    /// The table's own header caliber is not the caliber of the shot it was handed to.
103    /// See [`Bc5dTable::ensure_caliber_matches`] for why this is refused rather than
104    /// applied or silently ignored. Both calibers are in inches.
105    CaliberMismatch { table_caliber: f64, shot_caliber: f64 },
106}
107
108impl std::fmt::Display for Bc5dError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            Bc5dError::IoError(e) => write!(f, "IO error: {}", e),
112            Bc5dError::InvalidMagic => write!(f, "Invalid file magic (expected 'BC5D')"),
113            Bc5dError::UnsupportedVersion(v) => write!(f, "Unsupported table version: {}", v),
114            Bc5dError::ChecksumMismatch { expected, actual } => {
115                write!(f, "Checksum mismatch: expected {:08x}, got {:08x}", expected, actual)
116            }
117            Bc5dError::InvalidDimensions => write!(f, "Invalid table dimensions"),
118            Bc5dError::TableNotFound(cal) => write!(f, "No BC5D table found for caliber {:.3}", cal),
119            Bc5dError::NoTableDirectory => write!(f, "No BC table directory configured"),
120            Bc5dError::CaliberMismatch {
121                table_caliber,
122                shot_caliber,
123            } => write!(
124                f,
125                "BC5D table caliber does not match the shot: table is for {:.3}, shot is {:.3} \
126                 (BC5D tables are keyed to the nearest 0.001 in: {} vs {})",
127                table_caliber,
128                shot_caliber,
129                caliber_to_key(*table_caliber),
130                caliber_to_key(*shot_caliber),
131            ),
132        }
133    }
134}
135
136impl std::error::Error for Bc5dError {}
137
138impl From<std::io::Error> for Bc5dError {
139    fn from(e: std::io::Error) -> Self {
140        Bc5dError::IoError(e)
141    }
142}
143
144impl Bc5dTable {
145    /// Load a BC5D table from a binary file
146    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Bc5dError> {
147        let file = File::open(&path)?;
148        Self::from_reader(BufReader::new(file))
149    }
150
151    /// Parse a BC5D table directly from an in-memory byte slice.
152    ///
153    /// Behaves identically to [`Bc5dTable::load`] but performs no filesystem
154    /// access, which makes it usable from WASM (`wasm32-unknown-unknown`, where
155    /// there is no `std::fs`). The host JS/Node layer fetches the `.bin` and
156    /// hands the raw bytes across the boundary.
157    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Bc5dError> {
158        Self::from_reader(std::io::Cursor::new(bytes))
159    }
160
161    fn from_reader<R: Read>(mut reader: R) -> Result<Self, Bc5dError> {
162        // Read and validate magic
163        let mut magic = [0u8; 4];
164        reader.read_exact(&mut magic)?;
165        if &magic != MAGIC {
166            return Err(Bc5dError::InvalidMagic);
167        }
168
169        // Read header fields
170        let version = read_u32(&mut reader)?;
171        if version != SUPPORTED_VERSION {
172            return Err(Bc5dError::UnsupportedVersion(version));
173        }
174
175        let caliber = read_f32(&mut reader)?;
176        let _flags = read_u32(&mut reader)?;
177        let _padding = read_u32(&mut reader)?;
178
179        let dim_weight = read_u32(&mut reader)? as usize;
180        let dim_bc = read_u32(&mut reader)? as usize;
181        let dim_muzzle_vel = read_u32(&mut reader)? as usize;
182        let dim_current_vel = read_u32(&mut reader)? as usize;
183        let dim_drag_types = read_u32(&mut reader)? as usize;
184
185        let timestamp = read_u64(&mut reader)?;
186        let stored_checksum = read_u32(&mut reader)?;
187
188        // Read API version (16 bytes, null-terminated)
189        let mut api_version_bytes = [0u8; 16];
190        reader.read_exact(&mut api_version_bytes)?;
191        let api_version = String::from_utf8_lossy(&api_version_bytes)
192            .trim_end_matches('\0')
193            .to_string();
194
195        // Skip reserved bytes
196        let mut reserved = [0u8; 12];
197        reader.read_exact(&mut reserved)?;
198
199        // Validate dimensions
200        if dim_weight == 0 || dim_bc == 0 || dim_muzzle_vel == 0 || dim_current_vel == 0 || dim_drag_types == 0 {
201            return Err(Bc5dError::InvalidDimensions);
202        }
203
204        // Read bin definitions
205        let weight_bins = read_f32_array(&mut reader, dim_weight)?;
206        let bc_bins = read_f32_array(&mut reader, dim_bc)?;
207        let muzzle_vel_bins = read_f32_array(&mut reader, dim_muzzle_vel)?;
208        let current_vel_bins = read_f32_array(&mut reader, dim_current_vel)?;
209
210        // Read data section. Bound the product with checked arithmetic so a corrupt or
211        // hostile file cannot overflow (debug panic / release wrap) or trigger a huge OOM
212        // allocation before the trailing CRC check can reject it.
213        const MAX_TOTAL_CELLS: usize = 64_000_000; // ~256 MB of f32; far above any real table
214        let total_cells = dim_drag_types
215            .checked_mul(dim_weight)
216            .and_then(|x| x.checked_mul(dim_bc))
217            .and_then(|x| x.checked_mul(dim_muzzle_vel))
218            .and_then(|x| x.checked_mul(dim_current_vel))
219            .filter(|&n| n <= MAX_TOTAL_CELLS)
220            .ok_or(Bc5dError::InvalidDimensions)?;
221        let data = read_f32_array(&mut reader, total_cells)?;
222
223        // Verify checksum (CRC32 of bins + data)
224        let mut checksum_data = Vec::new();
225        for &v in &weight_bins {
226            checksum_data.extend_from_slice(&v.to_le_bytes());
227        }
228        for &v in &bc_bins {
229            checksum_data.extend_from_slice(&v.to_le_bytes());
230        }
231        for &v in &muzzle_vel_bins {
232            checksum_data.extend_from_slice(&v.to_le_bytes());
233        }
234        for &v in &current_vel_bins {
235            checksum_data.extend_from_slice(&v.to_le_bytes());
236        }
237        for &v in &data {
238            checksum_data.extend_from_slice(&v.to_le_bytes());
239        }
240
241        let calculated_checksum = crc32_ieee(&checksum_data);
242        if calculated_checksum != stored_checksum {
243            return Err(Bc5dError::ChecksumMismatch {
244                expected: stored_checksum,
245                actual: calculated_checksum,
246            });
247        }
248
249        Ok(Bc5dTable {
250            caliber,
251            data,
252            weight_bins,
253            bc_bins,
254            muzzle_vel_bins,
255            current_vel_bins,
256            num_drag_types: dim_drag_types,
257            version,
258            api_version,
259            timestamp,
260        })
261    }
262
263    /// Look up a BC correction factor with 4D linear interpolation
264    /// (drag type is discrete, not interpolated)
265    ///
266    /// # Arguments
267    /// * `weight_grains` - Bullet weight in grains
268    /// * `base_bc` - Published BC value
269    /// * `muzzle_velocity` - Initial muzzle velocity in fps
270    /// * `current_velocity` - Current bullet velocity in fps
271    /// * `drag_type` - "G1" or "G7"
272    ///
273    /// # Returns
274    /// Correction factor (multiply published BC by this value)
275    pub fn lookup(
276        &self,
277        weight_grains: f64,
278        base_bc: f64,
279        muzzle_velocity: f64,
280        current_velocity: f64,
281        drag_type: &str,
282    ) -> f64 {
283        // Get drag type index (0 = G1, 1 = G7)
284        let drag_idx = if drag_type.eq_ignore_ascii_case("G7") { 1 } else { 0 };
285
286        // Clamp drag_idx to valid range
287        let drag_idx = drag_idx.min(self.num_drag_types - 1);
288
289        // Find interpolation indices and weights for each continuous dimension
290        let (weight_idx, weight_w) = self.interp_idx(weight_grains as f32, &self.weight_bins);
291        let (bc_idx, bc_w) = self.interp_idx(base_bc as f32, &self.bc_bins);
292        let (muzzle_idx, muzzle_w) = self.interp_idx(muzzle_velocity as f32, &self.muzzle_vel_bins);
293        let (current_idx, current_w) = self.interp_idx(current_velocity as f32, &self.current_vel_bins);
294
295        // 4D linear interpolation (16 corners of a hypercube)
296        let mut result = 0.0f64;
297
298        for dw in 0..2 {
299            for db in 0..2 {
300                for dm in 0..2 {
301                    for dc in 0..2 {
302                        // Calculate weight for this corner
303                        let weight = (if dw == 0 { 1.0 - weight_w } else { weight_w })
304                            * (if db == 0 { 1.0 - bc_w } else { bc_w })
305                            * (if dm == 0 { 1.0 - muzzle_w } else { muzzle_w })
306                            * (if dc == 0 { 1.0 - current_w } else { current_w });
307
308                        // Get clamped indices
309                        let wi = (weight_idx + dw).min(self.weight_bins.len() - 1);
310                        let bi = (bc_idx + db).min(self.bc_bins.len() - 1);
311                        let mi = (muzzle_idx + dm).min(self.muzzle_vel_bins.len() - 1);
312                        let ci = (current_idx + dc).min(self.current_vel_bins.len() - 1);
313
314                        // Calculate flat index
315                        let idx = self.flat_index(drag_idx, wi, bi, mi, ci);
316                        result += weight * self.data[idx] as f64;
317                    }
318                }
319            }
320        }
321
322        // A correction is multiplicative, so an undefined table result must be neutral rather
323        // than silently becoming the most aggressive allowed degradation.
324        if !result.is_finite() {
325            return 1.0;
326        }
327
328        result.clamp(0.5, 1.5)
329    }
330
331    /// Get the effective BC at a given velocity
332    ///
333    /// This multiplies the base BC by the correction factor from the table.
334    pub fn get_effective_bc(
335        &self,
336        weight_grains: f64,
337        base_bc: f64,
338        muzzle_velocity: f64,
339        current_velocity: f64,
340        drag_type: &str,
341    ) -> f64 {
342        let correction = self.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type);
343        base_bc * correction
344    }
345
346    /// Generate velocity-dependent BC segments for a bullet from this table.
347    ///
348    /// This mirrors the CLI's `--bc-table-dir` segment synthesis: the 4D
349    /// correction surface is sampled at a fixed ladder of velocity breakpoints
350    /// (from 500 fps up through the muzzle velocity) and each adjacent pair
351    /// becomes a [`crate::BCSegmentData`] carrying the corrected BC over that
352    /// band. The solver consumes these via `inputs.bc_segments_data` +
353    /// `use_bc_segments`, giving the same velocity-dependent BC degradation
354    /// offline that the online solver produces.
355    ///
356    /// All velocities are in fps and `weight_grains` in grains, matching the
357    /// table's native units. Returns `None` when the table carries no
358    /// meaningful correction for this bullet (every sampled cell ~= 1.0), so
359    /// callers can leave the constant published BC in place.
360    pub fn generate_segments(
361        &self,
362        base_bc: f64,
363        drag_type: &str,
364        weight_grains: f64,
365        muzzle_velocity_fps: Option<f64>,
366    ) -> Option<Vec<crate::BCSegmentData>> {
367        let mut breakpoints: Vec<f64> = vec![
368            4000.0, 3500.0, 3000.0, 2700.0, 2500.0, 2300.0, 2100.0, 2000.0, 1900.0, 1800.0, 1700.0,
369            1600.0, 1500.0, 1400.0, 1350.0, 1300.0, 1250.0, 1200.0, 1150.0, 1100.0, 1050.0, 1000.0,
370            950.0, 900.0, 850.0, 800.0, 700.0, 600.0, 500.0,
371        ];
372        if let Some(mv) = muzzle_velocity_fps {
373            breakpoints.push(mv);
374        }
375
376        let mut velocities: Vec<f64> = breakpoints
377            .into_iter()
378            .filter(|&v| v >= 500.0 && muzzle_velocity_fps.is_none_or(|mv| v <= mv))
379            .collect();
380        velocities.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
381        velocities.dedup();
382
383        // Correction factors were generated relative to the highest (muzzle)
384        // velocity, so anchor every lookup to that reference.
385        let reference_mv = velocities.first().copied().unwrap_or(3000.0);
386
387        let mut segments: Vec<crate::BCSegmentData> = Vec::new();
388        let mut any_correction = false;
389        for i in 0..velocities.len().saturating_sub(1) {
390            let vel_max = velocities[i];
391            let vel_min = velocities[i + 1];
392            let vel_mid = (vel_max + vel_min) / 2.0;
393
394            let correction =
395                self.lookup(weight_grains, base_bc, reference_mv, vel_mid, drag_type);
396            if (correction - 1.0).abs() > 1e-6 {
397                any_correction = true;
398            }
399            segments.push(crate::BCSegmentData {
400                velocity_min: vel_min,
401                velocity_max: vel_max,
402                bc_value: base_bc * correction,
403            });
404        }
405
406        if any_correction && !segments.is_empty() {
407            Some(segments)
408        } else {
409            None
410        }
411    }
412
413    /// Generate the BC5D schedule consumed by the WASM frontend.
414    #[cfg(any(test, target_arch = "wasm32"))]
415    pub(crate) fn generate_segment_schedule(
416        &self,
417        base_bc: f64,
418        drag_type: &str,
419        weight_grains: f64,
420        muzzle_velocity_fps: f64,
421    ) -> Option<Bc5dSegmentSchedule> {
422        let segments =
423            self.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_velocity_fps))?;
424        let fallback_bc = self.get_effective_bc(
425            weight_grains,
426            base_bc,
427            muzzle_velocity_fps,
428            muzzle_velocity_fps,
429            drag_type,
430        );
431
432        Some(Bc5dSegmentSchedule {
433            segments,
434            fallback_bc,
435        })
436    }
437
438    /// Find interpolation index and weight for a value in bins
439    fn interp_idx(&self, value: f32, bins: &[f32]) -> (usize, f64) {
440        if bins.len() < 2 || value.is_nan() {
441            return (0, 0.0);
442        }
443
444        // Handle out of range (clamp to edges)
445        if value <= bins[0] {
446            return (0, 0.0);
447        }
448        if value >= bins[bins.len() - 1] {
449            return (bins.len().saturating_sub(2), 1.0);
450        }
451
452        // Binary search for interval containing value
453        let last_interval = bins.len().saturating_sub(2);
454        let idx = match bins.binary_search_by(|probe| {
455            probe
456                .partial_cmp(&value)
457                .unwrap_or(std::cmp::Ordering::Equal)
458        }) {
459            Ok(i) => i.saturating_sub(1).min(last_interval),
460            Err(i) => i.saturating_sub(1).min(last_interval),
461        };
462
463        // Calculate interpolation weight
464        let low = bins[idx];
465        let high = bins[idx + 1];
466        let weight = if high > low {
467            ((value - low) / (high - low)) as f64
468        } else {
469            0.0
470        };
471
472        (idx, weight)
473    }
474
475    /// Calculate flat array index from 5D indices
476    fn flat_index(&self, drag_idx: usize, weight_idx: usize, bc_idx: usize, muzzle_idx: usize, current_idx: usize) -> usize {
477        let n_weight = self.weight_bins.len();
478        let n_bc = self.bc_bins.len();
479        let n_muzzle = self.muzzle_vel_bins.len();
480        let n_current = self.current_vel_bins.len();
481
482        drag_idx * (n_weight * n_bc * n_muzzle * n_current)
483            + weight_idx * (n_bc * n_muzzle * n_current)
484            + bc_idx * (n_muzzle * n_current)
485            + muzzle_idx * n_current
486            + current_idx
487    }
488
489    /// Get caliber this table is for
490    pub fn caliber(&self) -> f32 {
491        self.caliber
492    }
493
494    /// The table's own caliber as a BC5D key ([`caliber_to_key`]) — exactly the value
495    /// [`Self::ensure_caliber_matches`] compares against, and the value `bc5d.info`
496    /// reports as `caliber_key` so an app can pre-check a downloaded table itself.
497    pub fn caliber_key(&self) -> i32 {
498        caliber_to_key(self.caliber as f64)
499    }
500
501    /// Refuse this table for a shot of a different caliber.
502    ///
503    /// `shot_caliber_in` is the shot's bullet diameter in INCHES.
504    ///
505    /// # Why this is an error and not a warning or a silent skip
506    ///
507    /// Nothing in the lookup path fails on a foreign table: [`Self::lookup`] clamps
508    /// out-of-range values to the edge bins, so a table for another caliber still
509    /// returns a plausible-looking correction for every cell and
510    /// [`Self::generate_segments`] still emits a full ladder. Measured: the published
511    /// `bc5d_224.bin` handed a 175 gr / G1 0.505 / 2600 fps .308 shot yields a
512    /// 25-segment ladder whose segment BCs are 0.4710..0.5114 where the .308 table
513    /// gives 0.4989..0.5072 — ~6.7 % off in the low bands, with no diagnostic
514    /// anywhere; a .308 table on a .243 shot measures -17.9 % effective BC. A
515    /// wrong-caliber table is therefore WORSE than no table at all: no table leaves
516    /// the published BC intact, while a wrong one silently biases every row. So the
517    /// caller is refused outright — never corrected with foreign data, and never
518    /// quietly downgraded to an uncorrected solve either, because a caller that asked
519    /// for a table and got an unannotated uncorrected answer cannot tell.
520    ///
521    /// # Matching rule
522    ///
523    /// Equality of the 3-digit BC5D caliber key ([`caliber_to_key`]) — the same key
524    /// `find_table_file` uses to choose `bc5d_<key>.bin` for `--bc-table-dir`. The
525    /// guard therefore accepts exactly the diameters for which the CLI would have
526    /// selected this table's own filename: precedent decides the rule, so the CLI and
527    /// the path/bytes consumers (bridge cards, bridge solve, solve-json, WASM) cannot
528    /// disagree about what "this table is for my shot" means.
529    ///
530    /// In practice that is a half-thousandth tolerance — a `308` table accepts
531    /// `[0.3075, 0.3085)` — expressed as a bucket rather than a centered epsilon.
532    /// Bucketing matters: the header caliber is an `f32`, so `0.308` arrives as
533    /// 0.30799998, and rounding to the nearest thousandth absorbs that representation
534    /// error, whereas a centered `|a - b| <= 0.0005` comparison would have to carry a
535    /// separate fudge for it.
536    pub fn ensure_caliber_matches(&self, shot_caliber_in: f64) -> Result<(), Bc5dError> {
537        if self.caliber_key() == caliber_to_key(shot_caliber_in) {
538            return Ok(());
539        }
540        Err(Bc5dError::CaliberMismatch {
541            table_caliber: self.caliber as f64,
542            shot_caliber: shot_caliber_in,
543        })
544    }
545
546    /// Get table version
547    pub fn version(&self) -> u32 {
548        self.version
549    }
550
551    /// Get API version used to generate the table
552    pub fn api_version(&self) -> &str {
553        &self.api_version
554    }
555
556    /// Get generation timestamp
557    pub fn timestamp(&self) -> u64 {
558        self.timestamp
559    }
560
561    /// Get total number of cells in the table
562    pub fn total_cells(&self) -> usize {
563        self.data.len()
564    }
565
566    /// Bin counts per axis: `(weight, bc, muzzle_vel, current_vel, drag_types)`.
567    ///
568    /// The structured counterpart of [`Self::dimensions_str`], for callers that
569    /// report table metadata over a wire (e.g. the bridge's `bc5d.info`).
570    pub fn bin_counts(&self) -> (usize, usize, usize, usize, usize) {
571        (
572            self.weight_bins.len(),
573            self.bc_bins.len(),
574            self.muzzle_vel_bins.len(),
575            self.current_vel_bins.len(),
576            self.num_drag_types,
577        )
578    }
579
580    /// Get table dimensions as a string
581    pub fn dimensions_str(&self) -> String {
582        format!(
583            "{}x{}x{}x{}x{} (weight x bc x muzzle_vel x current_vel x drag_types)",
584            self.weight_bins.len(),
585            self.bc_bins.len(),
586            self.muzzle_vel_bins.len(),
587            self.current_vel_bins.len(),
588            self.num_drag_types
589        )
590    }
591
592    /// Get weight range
593    pub fn weight_range(&self) -> (f32, f32) {
594        (*self.weight_bins.first().unwrap_or(&0.0), *self.weight_bins.last().unwrap_or(&0.0))
595    }
596
597    /// Get velocity range
598    pub fn velocity_range(&self) -> (f32, f32) {
599        (*self.current_vel_bins.first().unwrap_or(&0.0), *self.current_vel_bins.last().unwrap_or(&0.0))
600    }
601}
602
603impl Bc5dTableManager {
604    /// Create a new table manager with a directory path
605    pub fn new<P: AsRef<Path>>(table_dir: P) -> Self {
606        Bc5dTableManager {
607            table_dir: Some(table_dir.as_ref().to_path_buf()),
608            tables: HashMap::new(),
609        }
610    }
611
612    /// Create an empty manager (no table directory)
613    pub fn empty() -> Self {
614        Bc5dTableManager {
615            table_dir: None,
616            tables: HashMap::new(),
617        }
618    }
619
620    /// Get or load the table for a caliber
621    ///
622    /// Tables are cached after first load.
623    ///
624    /// The file is selected by NAME (`bc5d_<key>.bin`) but verified by CONTENT: a file
625    /// whose header caliber is not `caliber` is rejected with
626    /// [`Bc5dError::CaliberMismatch`] and never cached, so a rotated manifest, a
627    /// hand-copied `.bin`, or a future generator bug cannot silently bias every lookup
628    /// (see [`Bc5dTable::ensure_caliber_matches`]).
629    pub fn get_table(&mut self, caliber: f64) -> Result<&Bc5dTable, Bc5dError> {
630        let caliber_key = caliber_to_key(caliber);
631
632        // Check if already loaded
633        if self.tables.contains_key(&caliber_key) {
634            return Ok(self.tables.get(&caliber_key).unwrap());
635        }
636
637        // Need to load
638        let table_dir = self.table_dir.as_ref().ok_or(Bc5dError::NoTableDirectory)?;
639        let table_path = find_table_file(table_dir, caliber)?;
640        let table = Bc5dTable::load(&table_path)?;
641        table.ensure_caliber_matches(caliber)?;
642        self.tables.insert(caliber_key, table);
643        Ok(self.tables.get(&caliber_key).unwrap())
644    }
645
646    /// Look up BC correction for a bullet
647    pub fn lookup(
648        &mut self,
649        caliber: f64,
650        weight_grains: f64,
651        base_bc: f64,
652        muzzle_velocity: f64,
653        current_velocity: f64,
654        drag_type: &str,
655    ) -> Result<f64, Bc5dError> {
656        let table = self.get_table(caliber)?;
657        Ok(table.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
658    }
659
660    /// Get effective BC with correction applied
661    pub fn get_effective_bc(
662        &mut self,
663        caliber: f64,
664        weight_grains: f64,
665        base_bc: f64,
666        muzzle_velocity: f64,
667        current_velocity: f64,
668        drag_type: &str,
669    ) -> Result<f64, Bc5dError> {
670        let table = self.get_table(caliber)?;
671        Ok(table.get_effective_bc(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
672    }
673
674    /// Check if a table is available for a caliber
675    pub fn has_table(&self, caliber: f64) -> bool {
676        if let Some(ref table_dir) = self.table_dir {
677            find_table_file(table_dir, caliber).is_ok()
678        } else {
679            false
680        }
681    }
682
683    /// List available calibers in the table directory
684    pub fn available_calibers(&self) -> Vec<f64> {
685        let mut calibers = Vec::new();
686        if let Some(ref table_dir) = self.table_dir {
687            if let Ok(entries) = std::fs::read_dir(table_dir) {
688                for entry in entries.flatten() {
689                    let path = entry.path();
690                    if let Some(ext) = path.extension() {
691                        if ext == "bin" {
692                            if let Some(stem) = path.file_stem() {
693                                let name = stem.to_string_lossy();
694                                if let Some(caliber) = name.strip_prefix("bc5d_") {
695                                    // Parse caliber from filename (e.g., bc5d_308.bin -> 0.308)
696                                    if let Ok(cal_int) = caliber.parse::<i32>() {
697                                        calibers.push(cal_int as f64 / 1000.0);
698                                    }
699                                }
700                            }
701                        }
702                    }
703                }
704            }
705        }
706        calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
707        calibers
708    }
709}
710
711/// The canonical BC5D caliber key: a caliber in inches rounded to the nearest
712/// thousandth, as an integer (0.308 -> 308, 0.224 -> 224).
713///
714/// This is the ONE key BC5D identity is expressed in. `find_table_file` builds
715/// `bc5d_<key>.bin` from it (so it decides which file `--bc-table-dir` picks),
716/// [`Bc5dTableManager`] caches by it, and [`Bc5dTable::ensure_caliber_matches`]
717/// compares by it — one rule, so the CLI's file selection and every path/bytes
718/// consumer's identity check cannot drift apart.
719pub fn caliber_to_key(caliber: f64) -> i32 {
720    // A non-finite caliber saturates to 0 here (Rust's float->int cast), which is not
721    // any real table's key, so it falls out as a mismatch rather than matching anything.
722    (caliber * 1000.0).round() as i32
723}
724
725/// Find the table file for a caliber
726fn find_table_file(table_dir: &Path, caliber: f64) -> Result<PathBuf, Bc5dError> {
727    let caliber_int = (caliber * 1000.0).round() as i32;
728    let filename = format!("bc5d_{}.bin", caliber_int);
729    let path = table_dir.join(&filename);
730
731    if path.exists() {
732        return Ok(path);
733    }
734
735    // Try common variations
736    let variations = [
737        format!("bc5d_{:03}.bin", caliber_int),
738        format!("bc5d_0{}.bin", caliber_int),
739    ];
740
741    for var in &variations {
742        let var_path = table_dir.join(var);
743        if var_path.exists() {
744            return Ok(var_path);
745        }
746    }
747
748    Err(Bc5dError::TableNotFound(caliber))
749}
750
751// Helper functions for reading binary data
752
753fn read_u32<R: Read>(reader: &mut R) -> Result<u32, std::io::Error> {
754    let mut buf = [0u8; 4];
755    reader.read_exact(&mut buf)?;
756    Ok(u32::from_le_bytes(buf))
757}
758
759fn read_u64<R: Read>(reader: &mut R) -> Result<u64, std::io::Error> {
760    let mut buf = [0u8; 8];
761    reader.read_exact(&mut buf)?;
762    Ok(u64::from_le_bytes(buf))
763}
764
765fn read_f32<R: Read>(reader: &mut R) -> Result<f32, std::io::Error> {
766    let mut buf = [0u8; 4];
767    reader.read_exact(&mut buf)?;
768    Ok(f32::from_le_bytes(buf))
769}
770
771fn read_f32_array<R: Read>(reader: &mut R, count: usize) -> Result<Vec<f32>, std::io::Error> {
772    // Defensive bounds: reject absurd lengths from corrupt/hostile files before
773    // allocating, and guard the byte-count multiply against overflow.
774    const MAX_ELEMS: usize = 64_000_000; // 256 MB of f32
775    if count > MAX_ELEMS {
776        return Err(std::io::Error::new(
777            std::io::ErrorKind::InvalidData,
778            "f32 array length too large",
779        ));
780    }
781    let byte_len = count.checked_mul(4).ok_or_else(|| {
782        std::io::Error::new(std::io::ErrorKind::InvalidData, "f32 array length overflow")
783    })?;
784    let mut data = vec![0f32; count];
785    let mut buf = vec![0u8; byte_len];
786    reader.read_exact(&mut buf)?;
787
788    for (i, chunk) in buf.chunks_exact(4).enumerate() {
789        data[i] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
790    }
791
792    Ok(data)
793}
794
795/// Simple CRC32 (IEEE polynomial) implementation
796pub(crate) fn crc32_ieee(data: &[u8]) -> u32 {
797    const TABLE: [u32; 256] = make_crc32_table();
798    let mut crc = 0xFFFFFFFFu32;
799    for &byte in data {
800        let idx = ((crc ^ byte as u32) & 0xFF) as usize;
801        crc = (crc >> 8) ^ TABLE[idx];
802    }
803    !crc
804}
805
806const fn make_crc32_table() -> [u32; 256] {
807    const POLY: u32 = 0xEDB88320;
808    let mut table = [0u32; 256];
809    let mut i = 0;
810    while i < 256 {
811        let mut crc = i as u32;
812        let mut j = 0;
813        while j < 8 {
814            if crc & 1 != 0 {
815                crc = (crc >> 1) ^ POLY;
816            } else {
817                crc >>= 1;
818            }
819            j += 1;
820        }
821        table[i] = crc;
822        i += 1;
823    }
824    table
825}
826
827/// Process-wide cache of BC5D tables loaded from explicit filesystem paths.
828///
829/// The bridge/solve-json surfaces accept a caller-supplied table PATH (mobile apps
830/// download the `.bin` themselves and hand the engine a file path), and a parsed
831/// table is several MB — re-reading and re-CRC-ing it on every card or solve call
832/// would dominate the request. Entries are keyed by `(canonical path, file size,
833/// mtime)` so replacing a downloaded table under the same name is picked up on the
834/// next call, and the cache is bounded (oldest entry evicted at capacity).
835///
836/// Filesystem-only by construction, so the whole module is compiled out on
837/// `wasm32` (where WASM callers pass table BYTES via `loadBc5dTable` instead).
838#[cfg(not(target_arch = "wasm32"))]
839pub mod path_cache {
840    use super::{Bc5dError, Bc5dTable};
841    use std::path::{Path, PathBuf};
842    use std::sync::{Arc, OnceLock, RwLock};
843    use std::time::SystemTime;
844
845    /// Small on purpose: a mobile app realistically has one or two calibers live.
846    const CACHE_CAPACITY: usize = 4;
847
848    #[derive(Debug, Clone, PartialEq, Eq)]
849    struct CacheKey {
850        canonical_path: PathBuf,
851        file_size: u64,
852        modified: Option<SystemTime>,
853    }
854
855    type CacheEntries = Vec<(CacheKey, Arc<Bc5dTable>)>;
856
857    fn cache() -> &'static RwLock<CacheEntries> {
858        static CACHE: OnceLock<RwLock<CacheEntries>> = OnceLock::new();
859        CACHE.get_or_init(|| RwLock::new(Vec::new()))
860    }
861
862    /// Load a BC5D table from `path`, verifying the header (magic, version,
863    /// dimensions) and the stored CRC32 exactly as [`Bc5dTable::load`] does, with
864    /// the parsed result cached process-wide.
865    ///
866    /// A cache hit requires the canonical path, file size, AND mtime to match, so
867    /// an in-place re-download invalidates naturally. Corrupt, truncated, or
868    /// missing files are never cached.
869    pub fn load_verified(path: &Path) -> Result<Arc<Bc5dTable>, Bc5dError> {
870        let canonical_path = std::fs::canonicalize(path)?;
871        let metadata = std::fs::metadata(&canonical_path)?;
872        let key = CacheKey {
873            canonical_path,
874            file_size: metadata.len(),
875            modified: metadata.modified().ok(),
876        };
877
878        if let Ok(entries) = cache().read() {
879            if let Some((_, table)) = entries.iter().find(|(k, _)| *k == key) {
880                return Ok(Arc::clone(table));
881            }
882        }
883
884        let bytes = std::fs::read(&key.canonical_path)?;
885        let table = Arc::new(Bc5dTable::from_bytes(&bytes)?);
886
887        if let Ok(mut entries) = cache().write() {
888            // Re-check under the write lock: another thread may have inserted the
889            // same key between our read miss and here.
890            if let Some((_, existing)) = entries.iter().find(|(k, _)| *k == key) {
891                return Ok(Arc::clone(existing));
892            }
893            if entries.len() >= CACHE_CAPACITY {
894                entries.remove(0);
895            }
896            entries.push((key, Arc::clone(&table)));
897        }
898        Ok(table)
899    }
900
901    /// [`load_verified`] plus the caliber-identity guard: the loaded table must be for
902    /// the caliber of the shot that is about to use it, per
903    /// [`Bc5dTable::ensure_caliber_matches`] (`shot_caliber_in` in INCHES).
904    ///
905    /// This is the entry point every consumer that HAS a shot must use — bridge cards,
906    /// bridge `solve`, and `solve-json` all take a caller-supplied table path, and a
907    /// path says nothing about content. Only surfaces with no shot in hand (e.g. the
908    /// bridge's `bc5d.info`, which just describes a file) call [`load_verified`]
909    /// directly. Keeping the guard inside the loader is deliberate: adding a fourth
910    /// path-based consumer cannot forget it.
911    pub fn load_verified_for_caliber(
912        path: &Path,
913        shot_caliber_in: f64,
914    ) -> Result<Arc<Bc5dTable>, Bc5dError> {
915        let table = load_verified(path)?;
916        table.ensure_caliber_matches(shot_caliber_in)?;
917        Ok(table)
918    }
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    fn create_test_table() -> Bc5dTable {
926        // Create a small test table with known values
927        let weight_bins = vec![100.0, 150.0, 200.0];
928        let bc_bins = vec![0.3, 0.4, 0.5];
929        let muzzle_vel_bins = vec![2500.0, 3000.0];
930        let current_vel_bins = vec![1000.0, 2000.0, 3000.0];
931        let num_drag_types = 2;
932
933        // Total cells: 2 * 3 * 3 * 2 * 3 = 108
934        let total = num_drag_types * weight_bins.len() * bc_bins.len() * muzzle_vel_bins.len() * current_vel_bins.len();
935        let mut data = vec![1.0f32; total];
936
937        // Set some non-uniform values for testing interpolation
938        // At weight=150, bc=0.4, muzzle=2750 (interpolated), current=2000, G1
939        // We'll set corners to test 4D interpolation
940        data[0] = 0.95; // First corner
941        data[total - 1] = 1.05; // Last corner
942
943        Bc5dTable {
944            caliber: 0.308,
945            data,
946            weight_bins,
947            bc_bins,
948            muzzle_vel_bins,
949            current_vel_bins,
950            num_drag_types,
951            version: 2,
952            api_version: "test".to_string(),
953            timestamp: 0,
954        }
955    }
956
957    fn create_single_cell_test_table() -> Bc5dTable {
958        Bc5dTable {
959            caliber: 0.308,
960            data: vec![0.875],
961            weight_bins: vec![168.0],
962            bc_bins: vec![0.4],
963            muzzle_vel_bins: vec![2500.0],
964            current_vel_bins: vec![2000.0],
965            num_drag_types: 1,
966            version: 2,
967            api_version: "test".to_string(),
968            timestamp: 0,
969        }
970    }
971
972    /// Serialize a table into the BC5D v2 `.bin` byte layout so we can exercise
973    /// the `from_bytes` parser without depending on an external file.
974    fn serialize_test_table(t: &Bc5dTable) -> Vec<u8> {
975        let mut out = Vec::new();
976        out.extend_from_slice(MAGIC);
977        out.extend_from_slice(&t.version.to_le_bytes());
978        out.extend_from_slice(&t.caliber.to_le_bytes());
979        out.extend_from_slice(&0u32.to_le_bytes()); // flags
980        out.extend_from_slice(&0u32.to_le_bytes()); // padding
981        out.extend_from_slice(&(t.weight_bins.len() as u32).to_le_bytes());
982        out.extend_from_slice(&(t.bc_bins.len() as u32).to_le_bytes());
983        out.extend_from_slice(&(t.muzzle_vel_bins.len() as u32).to_le_bytes());
984        out.extend_from_slice(&(t.current_vel_bins.len() as u32).to_le_bytes());
985        out.extend_from_slice(&(t.num_drag_types as u32).to_le_bytes());
986        out.extend_from_slice(&t.timestamp.to_le_bytes());
987
988        // Checksum is CRC32 of bins + data, in declaration order.
989        let mut checksum_data = Vec::new();
990        for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
991            .chain(&t.current_vel_bins).chain(&t.data) {
992            checksum_data.extend_from_slice(&v.to_le_bytes());
993        }
994        out.extend_from_slice(&crc32_ieee(&checksum_data).to_le_bytes());
995
996        let mut api = [0u8; 16];
997        let bytes = t.api_version.as_bytes();
998        api[..bytes.len().min(16)].copy_from_slice(&bytes[..bytes.len().min(16)]);
999        out.extend_from_slice(&api);
1000        out.extend_from_slice(&[0u8; 12]); // reserved
1001
1002        for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
1003            .chain(&t.current_vel_bins).chain(&t.data) {
1004            out.extend_from_slice(&v.to_le_bytes());
1005        }
1006        out
1007    }
1008
1009    #[test]
1010    fn test_from_bytes_roundtrip() {
1011        let original = create_test_table();
1012        let bytes = serialize_test_table(&original);
1013        let parsed = Bc5dTable::from_bytes(&bytes).expect("from_bytes should parse");
1014
1015        assert_eq!(parsed.caliber, original.caliber);
1016        assert_eq!(parsed.num_drag_types, original.num_drag_types);
1017        assert_eq!(parsed.weight_bins, original.weight_bins);
1018        assert_eq!(parsed.current_vel_bins, original.current_vel_bins);
1019        assert_eq!(parsed.data, original.data);
1020        assert_eq!(parsed.api_version, original.api_version);
1021
1022        // A corrupted body must be rejected by the CRC check.
1023        let mut bad = bytes.clone();
1024        *bad.last_mut().unwrap() ^= 0xFF;
1025        assert!(Bc5dTable::from_bytes(&bad).is_err());
1026    }
1027
1028    #[test]
1029    fn test_generate_segments() {
1030        // A table whose corrections are all exactly 1.0 carries no useful
1031        // correction, so generate_segments returns None (leave published BC).
1032        let mut uniform = create_test_table();
1033        uniform.data.iter_mut().for_each(|v| *v = 1.0);
1034        assert!(uniform
1035            .generate_segments(0.4, "G1", 150.0, Some(2700.0))
1036            .is_none());
1037
1038        // A table with a real (0.9) correction across the sampled slice must
1039        // produce contiguous, descending velocity segments carrying bc*corr.
1040        let mut corrected = create_test_table();
1041        corrected.data.iter_mut().for_each(|v| *v = 0.9);
1042        let segments = corrected
1043            .generate_segments(0.4, "G1", 150.0, Some(2700.0))
1044            .expect("segments expected for a table with corrections");
1045        assert!(!segments.is_empty());
1046        for w in segments.windows(2) {
1047            // Bands are contiguous and descend in velocity.
1048            assert!((segments[0].velocity_max - w[0].velocity_max).abs() >= 0.0);
1049            assert!(w[0].velocity_min >= w[1].velocity_max - 1e-6);
1050        }
1051        for s in &segments {
1052            assert!((s.bc_value - 0.4 * 0.9).abs() < 1e-6); // base_bc * correction
1053            assert!(s.velocity_max > s.velocity_min);
1054        }
1055    }
1056
1057    #[test]
1058    fn segment_schedule_carries_muzzle_corrected_fallback_bc() {
1059        let table = create_single_cell_test_table();
1060        let base_bc = 0.4;
1061        let schedule = table
1062            .generate_segment_schedule(base_bc, "G1", 168.0, 2500.0)
1063            .expect("uniform non-neutral correction should produce a schedule");
1064        let expected_fallback = table.get_effective_bc(168.0, base_bc, 2500.0, 2500.0, "G1");
1065
1066        assert!(!schedule.segments.is_empty());
1067        assert_eq!(expected_fallback.to_bits(), (base_bc * 0.875).to_bits());
1068        assert_eq!(schedule.fallback_bc.to_bits(), expected_fallback.to_bits());
1069    }
1070
1071    #[test]
1072    fn test_interp_idx_in_range() {
1073        let table = create_test_table();
1074
1075        // Test middle of range
1076        let (idx, weight) = table.interp_idx(125.0, &table.weight_bins);
1077        assert_eq!(idx, 0);
1078        assert!((weight - 0.5).abs() < 0.01);
1079
1080        // Test at bin boundary
1081        let (idx, weight) = table.interp_idx(150.0, &table.weight_bins);
1082        assert_eq!(idx, 0);
1083        assert!((weight - 1.0).abs() < 0.01);
1084    }
1085
1086    #[test]
1087    fn test_interp_idx_out_of_range() {
1088        let table = create_test_table();
1089
1090        // Test below range
1091        let (idx, weight) = table.interp_idx(50.0, &table.weight_bins);
1092        assert_eq!(idx, 0);
1093        assert_eq!(weight, 0.0);
1094
1095        // Test above range
1096        let (idx, weight) = table.interp_idx(250.0, &table.weight_bins);
1097        assert_eq!(idx, 1); // len - 2
1098        assert_eq!(weight, 1.0);
1099    }
1100
1101    #[test]
1102    fn test_interp_idx_nan_defaults_to_first_bin() {
1103        let table = create_test_table();
1104
1105        assert_eq!(table.interp_idx(f32::NAN, &table.weight_bins), (0, 0.0));
1106        assert_eq!(
1107            table.interp_idx(f32::NEG_INFINITY, &table.weight_bins),
1108            (0, 0.0)
1109        );
1110        assert_eq!(
1111            table.interp_idx(f32::INFINITY, &table.weight_bins),
1112            (1, 1.0)
1113        );
1114    }
1115
1116    #[test]
1117    fn test_lookup_nan_with_single_bin_axes_uses_only_cell() {
1118        let table = create_single_cell_test_table();
1119
1120        assert_eq!(table.lookup(f64::NAN, 0.4, 2500.0, 2000.0, "G1"), 0.875);
1121    }
1122
1123    #[test]
1124    fn test_lookup_non_finite_table_cells_are_neutral() {
1125        for cell in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1126            let mut source = create_test_table();
1127            source.data.fill(cell);
1128            let bytes = serialize_test_table(&source);
1129            let table = Bc5dTable::from_bytes(&bytes).expect("CRC-valid table should load");
1130
1131            assert_eq!(
1132                table.lookup(125.0, 0.35, 2750.0, 1500.0, "G1"),
1133                1.0,
1134                "non-finite cell {cell:?} must produce a neutral correction"
1135            );
1136            assert_eq!(
1137                table.get_effective_bc(125.0, 0.35, 2750.0, 1500.0, "G1"),
1138                0.35,
1139                "neutral correction must preserve base BC for {cell:?}"
1140            );
1141        }
1142    }
1143
1144    #[test]
1145    fn test_lookup_returns_valid_range() {
1146        let table = create_test_table();
1147
1148        let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G1");
1149        assert!((0.5..=1.5).contains(&correction));
1150
1151        let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G7");
1152        assert!((0.5..=1.5).contains(&correction));
1153    }
1154
1155    #[test]
1156    fn test_effective_bc() {
1157        let table = create_test_table();
1158
1159        let base_bc = 0.4;
1160        let effective = table.get_effective_bc(150.0, base_bc, 2750.0, 2000.0, "G1");
1161
1162        // Effective BC should be base_bc * correction
1163        assert!(effective >= base_bc * 0.5 && effective <= base_bc * 1.5);
1164    }
1165
1166    #[test]
1167    fn test_caliber_to_key() {
1168        assert_eq!(caliber_to_key(0.308), 308);
1169        assert_eq!(caliber_to_key(0.224), 224);
1170        assert_eq!(caliber_to_key(0.338), 338);
1171    }
1172
1173    /// The matching rule's boundaries, pinned. A `308` table is the bucket of diameters
1174    /// that round to 0.308 at the thousandth — precisely the diameters for which
1175    /// `find_table_file` would have chosen this table's own `bc5d_308.bin`.
1176    #[test]
1177    fn ensure_caliber_matches_accepts_the_rounding_bucket_and_refuses_outside_it() {
1178        let table = create_test_table(); // header caliber 0.308 (as f32)
1179
1180        // The f32 header (0.30799998) must not cost us the exact match.
1181        assert_eq!(table.caliber_key(), 308);
1182        assert!(table.ensure_caliber_matches(0.308).is_ok());
1183
1184        // Inclusive at the bottom edge: 0.3075 * 1000 is exactly 307.5, which rounds
1185        // half-away-from-zero to 308.
1186        assert!(table.ensure_caliber_matches(0.3075).is_ok());
1187        assert!(table.ensure_caliber_matches(0.3084).is_ok());
1188
1189        // Exclusive at the top edge: 0.3085 * 1000 is exactly 308.5 -> key 309.
1190        assert!(matches!(
1191            table.ensure_caliber_matches(0.3085),
1192            Err(Bc5dError::CaliberMismatch { .. })
1193        ));
1194        assert!(matches!(
1195            table.ensure_caliber_matches(0.3074),
1196            Err(Bc5dError::CaliberMismatch { .. })
1197        ));
1198
1199        // The real-world failure mode: a whole different caliber, named in the message.
1200        let err = table
1201            .ensure_caliber_matches(0.224)
1202            .expect_err("a .224 shot must not be served a .308 table");
1203        let message = err.to_string();
1204        assert!(
1205            message.contains("table is for 0.308, shot is 0.224"),
1206            "the error must name both calibers: {message}"
1207        );
1208
1209        // A garbage diameter cannot accidentally match a real table either.
1210        assert!(table.ensure_caliber_matches(f64::NAN).is_err());
1211        assert!(table.ensure_caliber_matches(0.0).is_err());
1212    }
1213
1214    /// A file named `bc5d_308.bin` whose CONTENT is a .224 table must be refused by the
1215    /// CLI's manager, and must not be cached (so a retry cannot resurrect it).
1216    #[cfg(not(target_arch = "wasm32"))]
1217    #[test]
1218    fn manager_refuses_a_file_whose_header_caliber_is_foreign() {
1219        let dir = std::env::temp_dir().join(format!(
1220            "bc5d-mislabeled-{}-{}",
1221            std::process::id(),
1222            std::time::SystemTime::now()
1223                .duration_since(std::time::UNIX_EPOCH)
1224                .unwrap()
1225                .as_nanos()
1226        ));
1227        std::fs::create_dir_all(&dir).unwrap();
1228
1229        let mut foreign = create_test_table();
1230        foreign.caliber = 0.224;
1231        foreign.data.fill(0.9);
1232        std::fs::write(dir.join("bc5d_308.bin"), serialize_test_table(&foreign)).unwrap();
1233
1234        let mut manager = Bc5dTableManager::new(&dir);
1235        let err = manager
1236            .get_table(0.308)
1237            .expect_err("a mislabeled table must be refused, not applied");
1238        assert!(matches!(err, Bc5dError::CaliberMismatch { .. }), "{err}");
1239        // Not cached: the second attempt fails the same way rather than succeeding.
1240        assert!(matches!(
1241            manager.get_table(0.308),
1242            Err(Bc5dError::CaliberMismatch { .. })
1243        ));
1244        // And no correction leaks out through the convenience wrappers.
1245        assert!(manager
1246            .lookup(0.308, 168.0, 0.4, 2500.0, 2000.0, "G1")
1247            .is_err());
1248
1249        // The same bytes ARE usable for the caliber they actually describe.
1250        std::fs::write(dir.join("bc5d_224.bin"), serialize_test_table(&foreign)).unwrap();
1251        assert!(manager.get_table(0.224).is_ok());
1252
1253        std::fs::remove_dir_all(&dir).unwrap();
1254    }
1255
1256    /// `load_verified_for_caliber` is the guarded loader every shot-bearing consumer
1257    /// uses: same bytes, accepted for their own caliber and refused for another.
1258    #[cfg(not(target_arch = "wasm32"))]
1259    #[test]
1260    fn path_cache_guarded_loader_refuses_a_foreign_caliber() {
1261        let dir = std::env::temp_dir().join(format!(
1262            "bc5d-guarded-load-{}-{}",
1263            std::process::id(),
1264            std::time::SystemTime::now()
1265                .duration_since(std::time::UNIX_EPOCH)
1266                .unwrap()
1267                .as_nanos()
1268        ));
1269        std::fs::create_dir_all(&dir).unwrap();
1270        let path = dir.join("bc5d_308.bin");
1271        std::fs::write(&path, serialize_test_table(&create_test_table())).unwrap();
1272
1273        assert!(path_cache::load_verified_for_caliber(&path, 0.308).is_ok());
1274        let err = path_cache::load_verified_for_caliber(&path, 0.243)
1275            .expect_err("a .243 shot must be refused a .308 table");
1276        assert!(matches!(err, Bc5dError::CaliberMismatch { .. }), "{err}");
1277        assert!(
1278            err.to_string().contains("table is for 0.308, shot is 0.243"),
1279            "{err}"
1280        );
1281
1282        std::fs::remove_dir_all(&dir).unwrap();
1283    }
1284
1285    #[test]
1286    fn test_table_metadata() {
1287        let table = create_test_table();
1288        assert!((table.caliber() - 0.308).abs() < 0.001);
1289        assert_eq!(table.version(), 2);
1290        assert_eq!(table.api_version(), "test");
1291    }
1292
1293    #[test]
1294    fn test_crc32() {
1295        // Test with known CRC32 value
1296        let data = b"123456789";
1297        let crc = crc32_ieee(data);
1298        assert_eq!(crc, 0xCBF43926);
1299    }
1300
1301    #[test]
1302    fn test_bin_counts_matches_dimensions() {
1303        let table = create_test_table();
1304        assert_eq!(table.bin_counts(), (3, 3, 2, 3, 2));
1305    }
1306
1307    /// The path cache must hand back the SAME parsed table for repeated loads of an
1308    /// unchanged file, reject corruption instead of caching it, and pick up an
1309    /// in-place replacement (size change breaks the key).
1310    #[cfg(not(target_arch = "wasm32"))]
1311    #[test]
1312    fn path_cache_reuses_parsed_tables_and_detects_replacement() {
1313        let dir = std::env::temp_dir().join(format!(
1314            "bc5d-path-cache-{}-{}",
1315            std::process::id(),
1316            std::time::SystemTime::now()
1317                .duration_since(std::time::UNIX_EPOCH)
1318                .unwrap()
1319                .as_nanos()
1320        ));
1321        std::fs::create_dir_all(&dir).unwrap();
1322        let path = dir.join("bc5d_308.bin");
1323
1324        let table = create_test_table();
1325        let bytes = serialize_test_table(&table);
1326        std::fs::write(&path, &bytes).unwrap();
1327
1328        let first = path_cache::load_verified(&path).expect("valid table loads");
1329        let second = path_cache::load_verified(&path).expect("cached table loads");
1330        assert!(
1331            std::sync::Arc::ptr_eq(&first, &second),
1332            "an unchanged file must be served from the cache"
1333        );
1334
1335        // Replace the file with a differently sized (still valid) table: the key
1336        // changes, so the next load parses the new content.
1337        let mut replacement = create_test_table();
1338        replacement.weight_bins.push(250.0);
1339        let extra_cells = replacement.num_drag_types
1340            * replacement.bc_bins.len()
1341            * replacement.muzzle_vel_bins.len()
1342            * replacement.current_vel_bins.len();
1343        replacement
1344            .data
1345            .resize(replacement.data.len() + extra_cells, 0.9f32);
1346        std::fs::write(&path, serialize_test_table(&replacement)).unwrap();
1347        let third = path_cache::load_verified(&path).expect("replacement loads");
1348        assert!(!std::sync::Arc::ptr_eq(&first, &third));
1349        assert_eq!(third.bin_counts().0, 4, "replacement content must be parsed");
1350
1351        // Corruption is a clean error, not a cached table.
1352        let mut corrupt = serialize_test_table(&table);
1353        *corrupt.last_mut().unwrap() ^= 0xFF;
1354        std::fs::write(&path, corrupt).unwrap();
1355        assert!(matches!(
1356            path_cache::load_verified(&path),
1357            Err(Bc5dError::ChecksumMismatch { .. })
1358        ));
1359
1360        std::fs::remove_dir_all(&dir).unwrap();
1361    }
1362}