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