Skip to main content

ballistics_engine/
ffi.rs

1//! FFI bindings for iOS/Swift integration
2
3use crate::{
4    calculate_zero_angle_with_conditions, run_monte_carlo_with_direction_std_dev,
5    AtmosphericConditions, BallisticInputs, DragModel, MonteCarloParams, TrajectorySolver,
6    WindConditions,
7};
8use std::os::raw::{c_char, c_double, c_int};
9use std::ptr;
10
11/// Minimum C-ABI trajectory `step_size`, in milliseconds (0.1 ms = 0.0001 s).
12///
13/// Smaller steps are rejected before integration because fixed-step solves retain one public
14/// trajectory point per step. All in-repository FFI examples use values in `[0.1, 1.0]` ms.
15pub const MIN_FFI_STEP_SIZE_MS: c_double = 0.1;
16
17/// Maximum C-ABI custom drag-table row count.
18///
19/// `drag_table_from_raw` copies both caller arrays before validation; an unbounded
20/// `len` lets a single call request two multi-gigabyte allocations and abort the
21/// process (MBA-1407). Real Cd decks are two to three orders of magnitude smaller
22/// (the embedded G1/G7 references are under 100 rows; doppler exports run a few
23/// hundred), so 4096 is far above any legitimate deck.
24pub const MAX_FFI_DRAG_TABLE_LEN: c_int = 4096;
25
26// FFI-safe structures with C-compatible layouts
27
28#[repr(C)]
29pub struct FFIBallisticInputs {
30    pub muzzle_velocity: c_double,         // m/s
31    pub muzzle_angle: c_double,            // radians (launch angle)
32    pub bc_value: c_double,                // ballistic coefficient
33    pub bullet_mass: c_double,             // kg
34    pub bullet_diameter: c_double,         // meters
35    pub bc_type: c_int,                    // 0=G1, 1=G7, 2=G2, 3=G5, 4=G6, 5=G8, 6=GI, 7=GS, 8=RA4 (MBA-1386; unrecognized -> G1)
36    pub sight_height: c_double,            // meters
37    pub target_distance: c_double,         // meters
38    pub temperature: c_double,             // Celsius
39    pub twist_rate: c_double,              // inches per turn
40    pub is_twist_right: c_int,             // 0=false, 1=true
41    pub shooting_angle: c_double,          // uphill/downhill angle in radians
42    pub altitude: c_double,                // meters
43    pub latitude: c_double,                // degrees (use NAN if not provided)
44    pub azimuth_angle: c_double,           // horizontal aiming angle in radians
45    pub use_rk4: c_int,                    // 0=Euler, 1=RK4
46    pub use_adaptive_rk45: c_int,          // 0=false, 1=true (adaptive RK45)
47    pub enable_wind_shear: c_int,          // 0=false, 1=true
48    pub enable_trajectory_sampling: c_int, // 0=false, 1=true
49    pub sample_interval: c_double,         // meters
50    pub enable_pitch_damping: c_int,       // 0=false, 1=true
51    pub enable_precession_nutation: c_int, // 0=false, 1=true
52    pub enable_spin_drift: c_int,          // 0=false, 1=true
53    pub enable_magnus: c_int,              // 0=false, 1=true
54    pub enable_coriolis: c_int,            // 0=false, 1=true
55    // Appended (keeps existing field offsets): compass bearing the shot is fired
56    // along, radians, 0=North, PI/2=East. Drives the Coriolis Eötvös/drift azimuth.
57    // Distinct from azimuth_angle (the small aiming offset). 0.0 if unset.
58    pub shot_azimuth: c_double,
59    // Appended (keeps existing field offsets): rifle cant angle in RADIANS about the line
60    // of sight, positive = clockwise from the shooter. Rotates the sight-frame aim offsets
61    // and bore geometry ("zeroed level, fired canted" -> POI right and low). 0.0 if unset.
62    pub cant_angle: c_double,
63}
64
65#[repr(C)]
66pub struct FFIWindConditions {
67    pub speed: c_double, // m/s
68    // radians, wind-FROM convention: 0 = headwind, PI/2 = from the right,
69    // PI = tailwind, 3*PI/2 = from the left (matches WindConditions / WindSock).
70    pub direction: c_double,
71    // Appended (keeps existing field offsets): vertical wind m/s, positive = updraft;
72    // 0.0 if unset.
73    pub vertical_speed: c_double,
74}
75
76#[repr(C)]
77pub struct FFIAtmosphericConditions {
78    pub temperature: c_double, // Celsius
79    pub pressure: c_double,    // hPa
80    pub humidity: c_double,    // percentage (0-100)
81    pub altitude: c_double,    // meters
82}
83
84#[repr(C)]
85pub struct FFITrajectorySample {
86    pub distance: c_double,       // meters
87    pub time: c_double,           // seconds
88    pub velocity_mps: c_double,   // meters per second
89    pub energy_joules: c_double,  // joules
90    pub drop_meters: c_double,    // meters
91    pub windage_meters: c_double, // meters
92    pub mach: c_double,           // Mach number
93    pub spin_rate_rps: c_double,  // revolutions per second
94}
95
96#[repr(C)]
97pub struct FFITrajectoryPoint {
98    pub time: c_double,
99    pub position_x: c_double,
100    pub position_y: c_double,
101    pub position_z: c_double,
102    pub velocity_magnitude: c_double,
103    pub kinetic_energy: c_double,
104}
105
106#[repr(C)]
107pub struct FFITrajectoryResult {
108    pub max_range: c_double,
109    pub max_height: c_double,
110    pub time_of_flight: c_double,
111    pub impact_velocity: c_double,
112    pub impact_energy: c_double,
113    pub points: *mut FFITrajectoryPoint,
114    pub point_count: c_int,
115    pub sampled_points: *mut FFITrajectorySample,
116    pub sampled_point_count: c_int,
117    pub min_pitch_damping: c_double,    // NAN if not calculated
118    pub transonic_mach: c_double,       // NAN if not reached
119    pub final_pitch_angle: c_double,    // NAN if not calculated
120    pub final_yaw_angle: c_double,      // NAN if not calculated
121    pub max_yaw_angle: c_double,        // NAN if not calculated
122    pub max_precession_angle: c_double, // NAN if not calculated
123}
124
125// Monte Carlo simulation parameters
126#[repr(C)]
127pub struct FFIMonteCarloParams {
128    pub num_simulations: c_int,
129    pub velocity_std_dev: c_double,
130    pub angle_std_dev: c_double,
131    pub bc_std_dev: c_double,
132    pub wind_speed_std_dev: c_double,
133    pub target_distance: c_double,     // Use NAN if not specified
134    pub base_wind_speed: c_double,     // Base wind speed in m/s
135    pub base_wind_direction: c_double, // Base wind direction in radians
136    pub azimuth_std_dev: c_double,     // Horizontal aiming variation in radians
137}
138
139// Monte Carlo simulation results
140#[repr(C)]
141pub struct FFIMonteCarloResults {
142    pub ranges: *mut c_double,
143    pub impact_velocities: *mut c_double,
144    pub impact_positions_x: *mut c_double,
145    /// `-1.0e9` marks a sample that did not reach the target plane; exclude it from dispersion
146    /// statistics but retain it as a miss for probability calculations.
147    pub impact_positions_y: *mut c_double,
148    pub impact_positions_z: *mut c_double,
149    pub num_results: c_int,
150    pub mean_range: c_double,
151    pub std_dev_range: c_double,
152    pub mean_impact_velocity: c_double,
153    pub std_dev_impact_velocity: c_double,
154    pub hit_probability: c_double, // If target_distance was specified
155}
156
157// Helper function to convert FFI inputs to internal types
158#[allow(clippy::field_reassign_with_default)] // Keep the C-to-Rust field mapping sequential/auditable.
159fn convert_inputs(inputs: &FFIBallisticInputs) -> BallisticInputs {
160    let mut ballistic_inputs = BallisticInputs::default();
161
162    ballistic_inputs.muzzle_velocity = inputs.muzzle_velocity;
163    ballistic_inputs.muzzle_angle = inputs.muzzle_angle;
164    ballistic_inputs.azimuth_angle = inputs.azimuth_angle;
165    ballistic_inputs.shot_azimuth = inputs.shot_azimuth;
166    ballistic_inputs.cant_angle = inputs.cant_angle;
167    ballistic_inputs.use_rk4 = inputs.use_rk4 != 0;
168    ballistic_inputs.use_adaptive_rk45 = inputs.use_adaptive_rk45 != 0;
169    ballistic_inputs.bc_value = inputs.bc_value;
170    ballistic_inputs.bullet_mass = inputs.bullet_mass;
171    ballistic_inputs.bullet_diameter = inputs.bullet_diameter;
172    ballistic_inputs.bc_type = match inputs.bc_type {
173        1 => DragModel::G7,
174        2 => DragModel::G2,
175        3 => DragModel::G5,
176        4 => DragModel::G6,
177        5 => DragModel::G8,
178        6 => DragModel::GI,
179        7 => DragModel::GS,
180        // MBA-1386: additive slot for the new RA4 family. Existing callers passing
181        // 0-7 are unaffected; any other/unrecognized value still falls back to G1.
182        8 => DragModel::RA4,
183        _ => DragModel::G1,
184    };
185    ballistic_inputs.sight_height = inputs.sight_height;
186    ballistic_inputs.target_distance = inputs.target_distance;
187    ballistic_inputs.temperature = inputs.temperature;
188    ballistic_inputs.twist_rate = inputs.twist_rate;
189    ballistic_inputs.is_twist_right = inputs.is_twist_right != 0;
190    ballistic_inputs.shooting_angle = inputs.shooting_angle;
191    ballistic_inputs.altitude = inputs.altitude;
192
193    if !inputs.latitude.is_nan() {
194        ballistic_inputs.latitude = Some(inputs.latitude);
195    }
196
197    // Set derived values
198    ballistic_inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
199    ballistic_inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
200    // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default). The C ABI does
201    // not carry a bullet length, so derive it from diameter + mass; fall back to 4.5-cal if mass<=0.
202    ballistic_inputs.bullet_length = {
203        let est = crate::stability::estimate_bullet_length_m(
204            ballistic_inputs.bullet_diameter,
205            ballistic_inputs.bullet_mass,
206        );
207        if est > 0.0 {
208            est
209        } else {
210            ballistic_inputs.bullet_diameter * 4.5
211        }
212    };
213
214    // New advanced physics flags
215    ballistic_inputs.enable_wind_shear = inputs.enable_wind_shear != 0;
216    ballistic_inputs.enable_trajectory_sampling = inputs.enable_trajectory_sampling != 0;
217    ballistic_inputs.sample_interval = inputs.sample_interval;
218    ballistic_inputs.enable_pitch_damping = inputs.enable_pitch_damping != 0;
219    ballistic_inputs.enable_precession_nutation = inputs.enable_precession_nutation != 0;
220    ballistic_inputs.use_enhanced_spin_drift = inputs.enable_spin_drift != 0;
221    ballistic_inputs.enable_advanced_effects =
222        inputs.enable_magnus != 0 || inputs.enable_coriolis != 0;
223    // Gate Magnus and Coriolis independently so enabling one does not enable the other.
224    ballistic_inputs.enable_magnus = inputs.enable_magnus != 0;
225    ballistic_inputs.enable_coriolis = inputs.enable_coriolis != 0;
226
227    ballistic_inputs
228}
229
230/// Build a validated [`crate::drag::DragTable`] from borrowed C arrays.
231///
232/// Both arrays must contain `len` elements, with `len` in `[2, MAX_FFI_DRAG_TABLE_LEN]`.
233/// The data is copied; the caller retains ownership. Returns `Err(())` for null
234/// pointers, `len` outside that range, or any deck that fails
235/// [`crate::drag::DragTable::try_new`] validation (non-ascending Mach, non-positive
236/// or non-finite Cd). No error detail crosses the ABI, matching the null/NAN
237/// error convention of this module.
238///
239/// # Safety
240///
241/// When non-null, `mach` and `cd` must each point to `len` readable `f64` values
242/// that remain valid and unmutated for the duration of the call.
243unsafe fn drag_table_from_raw(
244    mach: *const c_double,
245    cd: *const c_double,
246    len: c_int,
247) -> Result<crate::drag::DragTable, ()> {
248    if mach.is_null() || cd.is_null() || !(2..=MAX_FFI_DRAG_TABLE_LEN).contains(&len) {
249        return Err(());
250    }
251    let len = len as usize;
252    let mach = unsafe { std::slice::from_raw_parts(mach, len) }.to_vec();
253    let cd = unsafe { std::slice::from_raw_parts(cd, len) }.to_vec();
254    crate::drag::DragTable::try_new(mach, cd).map_err(|_| ())
255}
256
257/// Shared implementation for the trajectory exports. `custom_drag_table`, when
258/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
259/// density — see `BallisticInputs::custom_drag_denominator`). `cd_scale` multiplies the
260/// deck's interpolated Cd (MBA-1356); callers that don't expose a scale pass `1.0`
261/// (neutral, byte-identical to no scale). It is inert when `custom_drag_table` is `None`.
262unsafe fn calculate_trajectory_impl(
263    inputs: *const FFIBallisticInputs,
264    wind: *const FFIWindConditions,
265    atmosphere: *const FFIAtmosphericConditions,
266    max_range: c_double,
267    step_size: c_double,
268    custom_drag_table: Option<crate::drag::DragTable>,
269    cd_scale: c_double,
270) -> *mut FFITrajectoryResult {
271    if inputs.is_null() {
272        return ptr::null_mut();
273    }
274    if !step_size.is_finite() || step_size < MIN_FFI_STEP_SIZE_MS {
275        return ptr::null_mut();
276    }
277
278    let inputs = unsafe { &*inputs };
279    let mut ballistic_inputs = convert_inputs(inputs);
280    ballistic_inputs.custom_drag_table = custom_drag_table;
281    ballistic_inputs.cd_scale = cd_scale;
282    let twist_rate_in = ballistic_inputs.twist_rate;
283
284    let wind_conditions = if wind.is_null() {
285        WindConditions::default()
286    } else {
287        let wind = unsafe { &*wind };
288        WindConditions {
289            speed: wind.speed,
290            direction: wind.direction,
291            vertical_speed: wind.vertical_speed,
292        }
293    };
294
295    let atmospheric_conditions = if atmosphere.is_null() {
296        AtmosphericConditions::default()
297    } else {
298        let atmo = unsafe { &*atmosphere };
299        AtmosphericConditions {
300            temperature: atmo.temperature,
301            pressure: atmo.pressure,
302            humidity: atmo.humidity,
303            altitude: atmo.altitude,
304        }
305    };
306
307    // Create solver and calculate trajectory
308    let (sample_temp_c, sample_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
309        atmospheric_conditions.temperature,
310        atmospheric_conditions.pressure,
311        atmospheric_conditions.altitude,
312    );
313    let (_, sample_speed_of_sound) = crate::atmosphere::calculate_atmosphere(
314        atmospheric_conditions.altitude,
315        Some(sample_temp_c),
316        Some(sample_pressure_hpa),
317        atmospheric_conditions.humidity,
318    );
319
320    let mut solver =
321        TrajectorySolver::new(ballistic_inputs, wind_conditions, atmospheric_conditions);
322
323    // Set max range and time step
324    solver.set_max_range(max_range);
325    solver.set_time_step(step_size / 1000.0); // milliseconds -> seconds
326
327    match solver.solve() {
328        Ok(result) => {
329            // Convert trajectory points to FFI format
330            let point_count = result.points.len();
331            let points = if point_count > 0 {
332                let mut ffi_points = Vec::with_capacity(point_count);
333                for point in result.points.iter() {
334                    ffi_points.push(FFITrajectoryPoint {
335                        time: point.time,
336                        position_x: point.position[0],
337                        position_y: point.position[1],
338                        position_z: point.position[2],
339                        velocity_magnitude: point.velocity_magnitude,
340                        kinetic_energy: point.kinetic_energy,
341                    });
342                }
343                let points_ptr = ffi_points.as_mut_ptr();
344                std::mem::forget(ffi_points); // Prevent deallocation
345                points_ptr
346            } else {
347                ptr::null_mut()
348            };
349
350            // Convert sampled points if available
351            let (sampled_points, sampled_point_count) =
352                if let Some(ref samples) = result.sampled_points {
353                    let mut ffi_samples = Vec::with_capacity(samples.len());
354                    for sample in samples {
355                        ffi_samples.push(FFITrajectorySample {
356                            distance: sample.distance_m,
357                            time: sample.time_s,
358                            velocity_mps: sample.velocity_mps,
359                            energy_joules: sample.energy_j,
360                            drop_meters: sample.drop_m,
361                            windage_meters: sample.wind_drift_m,
362                            mach: if sample_speed_of_sound > 0.0 {
363                                sample.velocity_mps / sample_speed_of_sound
364                            } else {
365                                0.0
366                            },
367                            spin_rate_rps: if twist_rate_in > 0.0 {
368                                sample.velocity_mps / (twist_rate_in * 0.0254)
369                            } else {
370                                0.0
371                            },
372                        });
373                    }
374                    let count = ffi_samples.len() as c_int;
375                    let samples_ptr = ffi_samples.as_mut_ptr();
376                    std::mem::forget(ffi_samples);
377                    (samples_ptr, count)
378                } else {
379                    (ptr::null_mut(), 0)
380                };
381
382            // Extract angular state values if available
383            let (final_pitch, final_yaw, max_yaw, max_prec) =
384                if let Some(ref angular) = result.angular_state {
385                    (
386                        angular.pitch_angle,
387                        angular.yaw_angle,
388                        result.max_yaw_angle.unwrap_or(f64::NAN),
389                        result.max_precession_angle.unwrap_or(f64::NAN),
390                    )
391                } else {
392                    (f64::NAN, f64::NAN, f64::NAN, f64::NAN)
393                };
394
395            // Create result on heap
396            let ffi_result = Box::new(FFITrajectoryResult {
397                max_range: result.max_range,
398                max_height: result.max_height,
399                time_of_flight: result.time_of_flight,
400                impact_velocity: result.impact_velocity,
401                impact_energy: result.impact_energy,
402                points,
403                point_count: point_count as c_int,
404                sampled_points,
405                sampled_point_count,
406                min_pitch_damping: result.min_pitch_damping.unwrap_or(f64::NAN),
407                transonic_mach: result.transonic_mach.unwrap_or(f64::NAN),
408                final_pitch_angle: final_pitch,
409                final_yaw_angle: final_yaw,
410                max_yaw_angle: max_yaw,
411                max_precession_angle: max_prec,
412            });
413
414            Box::into_raw(ffi_result)
415        }
416        Err(_) => ptr::null_mut(),
417    }
418}
419
420/// Calculate a trajectory through the C ABI.
421///
422/// `step_size` is expressed in milliseconds and must be finite and at least
423/// [`MIN_FFI_STEP_SIZE_MS`]. This boundary contract is validated for every solver mode, although
424/// adaptive RK45 chooses its integration steps internally. Invalid values return null without
425/// starting a solve. A solve that would exceed [`crate::MAX_TRAJECTORY_POINTS`] also returns null;
426/// an enabled sampling grid above [`crate::MAX_TRAJECTORY_SAMPLES`] does likewise. Callers can
427/// increase `step_size`, reduce `max_range`, or select adaptive RK45.
428///
429/// # Safety
430///
431/// `inputs` may be null, in which case this function returns null. When non-null,
432/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
433/// readable and is not mutated for the duration of this call. `wind` and
434/// `atmosphere` may also be null; each non-null pointer has the same requirements
435/// for its corresponding type.
436/// The returned pointer, when non-null, must be released exactly once with
437/// [`ballistics_free_trajectory_result`].
438#[no_mangle]
439pub unsafe extern "C" fn ballistics_calculate_trajectory(
440    inputs: *const FFIBallisticInputs,
441    wind: *const FFIWindConditions,
442    atmosphere: *const FFIAtmosphericConditions,
443    max_range: c_double,
444    step_size: c_double,
445) -> *mut FFITrajectoryResult {
446    unsafe { calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, None, 1.0) }
447}
448
449/// [`ballistics_calculate_trajectory`] with a caller-supplied custom drag deck
450/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
451/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
452/// denominator becomes the projectile's sectional density (mass and diameter in
453/// `inputs` must therefore be positive). Mach values must be strictly ascending
454/// with `drag_table_len` in `[2, MAX_FFI_DRAG_TABLE_LEN]` and finite positive Cd;
455/// outside the deck's Mach domain the nearest endpoint Cd is held.
456///
457/// Returns null for an invalid deck (null arrays, `drag_table_len` outside
458/// `[2, MAX_FFI_DRAG_TABLE_LEN]`, or failed validation), in addition to every
459/// failure mode of the base function.
460///
461/// # Safety
462///
463/// Same contract as [`ballistics_calculate_trajectory`] for `inputs`, `wind`, and
464/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
465/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
466/// of this call (the deck is copied; the caller retains ownership — no new free
467/// function is required). The returned pointer, when non-null, must be released
468/// exactly once with [`ballistics_free_trajectory_result`].
469#[no_mangle]
470pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table(
471    inputs: *const FFIBallisticInputs,
472    wind: *const FFIWindConditions,
473    atmosphere: *const FFIAtmosphericConditions,
474    max_range: c_double,
475    step_size: c_double,
476    drag_mach: *const c_double,
477    drag_cd: *const c_double,
478    drag_table_len: c_int,
479) -> *mut FFITrajectoryResult {
480    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
481        Ok(t) => t,
482        Err(()) => return ptr::null_mut(),
483    };
484    unsafe {
485        calculate_trajectory_impl(
486            inputs, wind, atmosphere, max_range, step_size, Some(table), 1.0,
487        )
488    }
489}
490
491/// [`ballistics_calculate_trajectory_with_drag_table`] with an additional whole-curve
492/// drag scale (MBA-1356): the deck's interpolated Cd is multiplied by `cd_scale` at the
493/// same site the base export uses, i.e. `Cd_used = table.interpolate(mach) * cd_scale`.
494/// `cd_scale = 1.0` is neutral and produces byte-identical output to
495/// [`ballistics_calculate_trajectory_with_drag_table`] on the same deck. Typical truing
496/// values are in `[0.90, 1.10]`; values outside that band are accepted here (the engine
497/// only rejects non-finite or non-positive) — an "unusually large" warning is a CLI-only
498/// concern (Task 2), not part of this frozen C ABI.
499///
500/// Returns null when `cd_scale` is not finite or not `> 0`, in addition to every failure
501/// mode of [`ballistics_calculate_trajectory_with_drag_table`] (matching that export's
502/// null sentinel for an invalid deck).
503///
504/// # Safety
505///
506/// Same contract as [`ballistics_calculate_trajectory_with_drag_table`].
507#[no_mangle]
508pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table_scaled(
509    inputs: *const FFIBallisticInputs,
510    wind: *const FFIWindConditions,
511    atmosphere: *const FFIAtmosphericConditions,
512    max_range: c_double,
513    step_size: c_double,
514    drag_mach: *const c_double,
515    drag_cd: *const c_double,
516    drag_table_len: c_int,
517    cd_scale: c_double,
518) -> *mut FFITrajectoryResult {
519    if !cd_scale.is_finite() || cd_scale <= 0.0 {
520        return ptr::null_mut();
521    }
522    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
523        Ok(t) => t,
524        Err(()) => return ptr::null_mut(),
525    };
526    unsafe {
527        calculate_trajectory_impl(
528            inputs, wind, atmosphere, max_range, step_size, Some(table), cd_scale,
529        )
530    }
531}
532
533/// Release a trajectory result allocated by [`ballistics_calculate_trajectory`].
534///
535/// # Safety
536///
537/// `result` must be null or a pointer returned by
538/// [`ballistics_calculate_trajectory`] that has not already been freed. Its
539/// embedded pointers and counts must be unchanged from the returned values.
540/// After this call, the result and its point arrays must not be accessed again.
541#[no_mangle]
542pub unsafe extern "C" fn ballistics_free_trajectory_result(result: *mut FFITrajectoryResult) {
543    if !result.is_null() {
544        unsafe {
545            let result = Box::from_raw(result);
546            if !result.points.is_null() && result.point_count > 0 {
547                let points = Vec::from_raw_parts(
548                    result.points,
549                    result.point_count as usize,
550                    result.point_count as usize,
551                );
552                drop(points);
553            }
554            if !result.sampled_points.is_null() && result.sampled_point_count > 0 {
555                let samples = Vec::from_raw_parts(
556                    result.sampled_points,
557                    result.sampled_point_count as usize,
558                    result.sampled_point_count as usize,
559                );
560                drop(samples);
561            }
562            drop(result);
563        }
564    }
565}
566
567/// Shared implementation for the zero-angle exports. `custom_drag_table`, when
568/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
569/// density — see `BallisticInputs::custom_drag_denominator`), matching the deck
570/// semantics of [`calculate_trajectory_impl`] so a zero solved with a deck and a
571/// trajectory flown with the same deck agree. `cd_scale` multiplies the deck's
572/// interpolated Cd (MBA-1356); callers that don't expose a scale pass `1.0` (neutral).
573unsafe fn calculate_zero_angle_impl(
574    inputs: *const FFIBallisticInputs,
575    wind: *const FFIWindConditions,
576    atmosphere: *const FFIAtmosphericConditions,
577    zero_distance: c_double,
578    custom_drag_table: Option<crate::drag::DragTable>,
579    cd_scale: c_double,
580) -> c_double {
581    if inputs.is_null() {
582        return f64::NAN;
583    }
584
585    let inputs = unsafe { &*inputs };
586    let mut ballistic_inputs = convert_inputs(inputs);
587    ballistic_inputs.custom_drag_table = custom_drag_table;
588    ballistic_inputs.cd_scale = cd_scale;
589
590    let wind_conditions = if wind.is_null() {
591        WindConditions::default()
592    } else {
593        let wind = unsafe { &*wind };
594        WindConditions {
595            speed: wind.speed,
596            direction: wind.direction,
597            vertical_speed: wind.vertical_speed,
598        }
599    };
600
601    let atmospheric_conditions = if atmosphere.is_null() {
602        AtmosphericConditions::default()
603    } else {
604        let atmo = unsafe { &*atmosphere };
605        AtmosphericConditions {
606            temperature: atmo.temperature,
607            pressure: atmo.pressure,
608            humidity: atmo.humidity,
609            altitude: atmo.altitude,
610        }
611    };
612
613    // For zero angle, we want the bullet to hit at sight height at the zero distance
614    // This means the bullet crosses the line of sight at the zero distance
615    let target_height = ballistic_inputs.sight_height;
616
617    calculate_zero_angle_with_conditions(
618        ballistic_inputs,
619        zero_distance,
620        target_height,
621        wind_conditions,
622        atmospheric_conditions,
623    )
624    .unwrap_or(f64::NAN)
625}
626
627/// Calculate the zero angle for a target distance through the C ABI.
628///
629/// # Safety
630///
631/// `inputs` may be null, in which case this function returns NaN. When non-null,
632/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
633/// readable and is not mutated for the duration of this call. `wind` and
634/// `atmosphere` may also be null; each non-null pointer has the same requirements
635/// for its corresponding type.
636#[no_mangle]
637pub unsafe extern "C" fn ballistics_calculate_zero_angle(
638    inputs: *const FFIBallisticInputs,
639    wind: *const FFIWindConditions,
640    atmosphere: *const FFIAtmosphericConditions,
641    zero_distance: c_double,
642) -> c_double {
643    unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, None, 1.0) }
644}
645
646/// [`ballistics_calculate_zero_angle`] with a caller-supplied custom drag deck
647/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
648/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
649/// denominator becomes the projectile's sectional density (mass and diameter in
650/// `inputs` must therefore be positive). Mach values must be strictly ascending
651/// with `drag_table_len` in `[2, MAX_FFI_DRAG_TABLE_LEN]` and finite positive Cd;
652/// outside the deck's Mach domain the nearest endpoint Cd is held. Pair this with
653/// [`ballistics_calculate_trajectory_with_drag_table`] using the same deck to fly
654/// the solved angle — the two exports share identical deck semantics.
655///
656/// Returns NaN for an invalid deck (null arrays, `drag_table_len` outside
657/// `[2, MAX_FFI_DRAG_TABLE_LEN]`, or failed validation), in addition to every
658/// failure mode of the base function.
659///
660/// # Safety
661///
662/// Same contract as [`ballistics_calculate_zero_angle`] for `inputs`, `wind`, and
663/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
664/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
665/// of this call (the deck is copied; the caller retains ownership — no new free
666/// function is required).
667#[no_mangle]
668pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table(
669    inputs: *const FFIBallisticInputs,
670    wind: *const FFIWindConditions,
671    atmosphere: *const FFIAtmosphericConditions,
672    zero_distance: c_double,
673    drag_mach: *const c_double,
674    drag_cd: *const c_double,
675    drag_table_len: c_int,
676) -> c_double {
677    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
678        Ok(t) => t,
679        Err(()) => return f64::NAN,
680    };
681    unsafe {
682        calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, Some(table), 1.0)
683    }
684}
685
686/// [`ballistics_calculate_zero_angle_with_drag_table`] with an additional whole-curve
687/// drag scale (MBA-1356): the deck's interpolated Cd is multiplied by `cd_scale` at the
688/// same site the base export uses, i.e. `Cd_used = table.interpolate(mach) * cd_scale`.
689/// `cd_scale = 1.0` is neutral and produces byte-identical output to
690/// [`ballistics_calculate_zero_angle_with_drag_table`] on the same deck. Pair this with
691/// [`ballistics_calculate_trajectory_with_drag_table_scaled`] using the same deck AND the
692/// same `cd_scale` to fly the solved angle. Typical truing values are in `[0.90, 1.10]`;
693/// values outside that band are accepted here (the engine only rejects non-finite or
694/// non-positive) — an "unusually large" warning is a CLI-only concern (Task 2).
695///
696/// Returns NaN when `cd_scale` is not finite or not `> 0`, in addition to every failure
697/// mode of [`ballistics_calculate_zero_angle_with_drag_table`] (matching that export's
698/// NaN sentinel for an invalid deck).
699///
700/// # Safety
701///
702/// Same contract as [`ballistics_calculate_zero_angle_with_drag_table`].
703#[no_mangle]
704pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table_scaled(
705    inputs: *const FFIBallisticInputs,
706    wind: *const FFIWindConditions,
707    atmosphere: *const FFIAtmosphericConditions,
708    zero_distance: c_double,
709    drag_mach: *const c_double,
710    drag_cd: *const c_double,
711    drag_table_len: c_int,
712    cd_scale: c_double,
713) -> c_double {
714    if !cd_scale.is_finite() || cd_scale <= 0.0 {
715        return f64::NAN;
716    }
717    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
718        Ok(t) => t,
719        Err(()) => return f64::NAN,
720    };
721    unsafe {
722        calculate_zero_angle_impl(
723            inputs,
724            wind,
725            atmosphere,
726            zero_distance,
727            Some(table),
728            cd_scale,
729        )
730    }
731}
732
733// Simple trajectory calculation for quick results
734#[no_mangle]
735#[allow(clippy::field_reassign_with_default)] // Preserve the staged zero-angle workflow below.
736pub extern "C" fn ballistics_quick_trajectory(
737    muzzle_velocity: c_double,
738    bc: c_double,
739    sight_height: c_double,
740    zero_distance: c_double,
741    target_distance: c_double,
742) -> c_double {
743    // This provides a simple drop calculation at target distance
744    // Using simplified ballistic calculations
745
746    let mut inputs = BallisticInputs::default();
747    inputs.muzzle_velocity = muzzle_velocity;
748    inputs.bc_value = bc;
749    inputs.sight_height = sight_height;
750    inputs.target_distance = target_distance;
751
752    let wind = WindConditions::default();
753    let atmo = AtmosphericConditions::default();
754
755    // First calculate zero angle
756    let zero_angle = match calculate_zero_angle_with_conditions(
757        inputs.clone(),
758        zero_distance,
759        sight_height,
760        wind.clone(),
761        atmo.clone(),
762    ) {
763        Ok(angle) => angle,
764        Err(_) => return f64::NAN,
765    };
766
767    // Now calculate trajectory with that zero angle
768    inputs.muzzle_angle = zero_angle;
769
770    let mut solver = TrajectorySolver::new(inputs, wind, atmo);
771    solver.set_max_range(target_distance * 1.1);
772
773    match solver.solve() {
774        Ok(result) => {
775            // Find the drop at target distance
776            for point in result.points {
777                if point.position[0] >= target_distance {
778                    return sight_height - point.position[1];
779                }
780            }
781            f64::NAN
782        }
783        Err(_) => f64::NAN,
784    }
785}
786
787/// Run a Monte Carlo simulation through the C ABI.
788///
789/// # Safety
790///
791/// `inputs` and `params` may be null, in which case this function returns null.
792/// Each non-null pointer must point to a valid, properly aligned value of its
793/// corresponding FFI type that remains readable and is not mutated for the
794/// duration of this call. `atmosphere` may be null; a non-null pointer has the
795/// same requirements for [`FFIAtmosphericConditions`]. The returned pointer,
796/// when non-null, must be released exactly once with
797/// [`ballistics_free_monte_carlo_results`].
798#[no_mangle]
799pub unsafe extern "C" fn ballistics_monte_carlo(
800    inputs: *const FFIBallisticInputs,
801    atmosphere: *const FFIAtmosphericConditions,
802    params: *const FFIMonteCarloParams,
803) -> *mut FFIMonteCarloResults {
804    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, 0.0) }
805}
806
807/// Run a Monte Carlo simulation with independent wind-direction uncertainty through the C ABI.
808///
809/// `wind_direction_std_dev` is in radians. This additive entry point keeps
810/// [`FFIMonteCarloParams`] and [`ballistics_monte_carlo`] binary-compatible; the older function
811/// delegates with zero wind-direction uncertainty.
812///
813/// # Safety
814///
815/// The pointer and ownership requirements are identical to [`ballistics_monte_carlo`].
816#[no_mangle]
817pub unsafe extern "C" fn ballistics_monte_carlo_with_direction_std_dev(
818    inputs: *const FFIBallisticInputs,
819    atmosphere: *const FFIAtmosphericConditions,
820    params: *const FFIMonteCarloParams,
821    wind_direction_std_dev: c_double,
822) -> *mut FFIMonteCarloResults {
823    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, wind_direction_std_dev) }
824}
825
826unsafe fn ballistics_monte_carlo_impl(
827    inputs: *const FFIBallisticInputs,
828    atmosphere: *const FFIAtmosphericConditions,
829    params: *const FFIMonteCarloParams,
830    wind_direction_std_dev: f64,
831) -> *mut FFIMonteCarloResults {
832    if inputs.is_null() || params.is_null() {
833        return ptr::null_mut();
834    }
835
836    let inputs = unsafe { &*inputs };
837    let params = unsafe { &*params };
838
839    // Reject an out-of-range simulation count. num_simulations is a c_int (i32) cast straight to
840    // usize: a negative value would wrap to a near-max usize, and even a large positive value (up
841    // to i32::MAX ~ 2.1e9) would drive billions of iterations with the result arrays scaling to
842    // match — an unbounded-loop / OOM DoS from a single FFI call. Bound it to a sane maximum.
843    // (n == 0 also yields NaN stats and a zero-size allocation.)
844    const MAX_SIMULATIONS: c_int = 1_000_000;
845    if params.num_simulations <= 0 || params.num_simulations > MAX_SIMULATIONS {
846        return ptr::null_mut();
847    }
848
849    // Convert FFI inputs to internal types
850    let mut ballistic_inputs = convert_inputs(inputs);
851    ballistic_inputs.muzzle_height = 1.5;
852    ballistic_inputs.ground_threshold = 0.0;
853    if !atmosphere.is_null() {
854        let atmo = unsafe { &*atmosphere };
855        ballistic_inputs.temperature = atmo.temperature;
856        ballistic_inputs.pressure = atmo.pressure;
857        ballistic_inputs.humidity = (atmo.humidity / 100.0).clamp(0.0, 1.0);
858        ballistic_inputs.altitude = atmo.altitude;
859    }
860
861    // Create Monte Carlo parameters
862    let mc_params = MonteCarloParams {
863        num_simulations: params.num_simulations as usize,
864        velocity_std_dev: params.velocity_std_dev,
865        angle_std_dev: params.angle_std_dev,
866        bc_std_dev: params.bc_std_dev,
867        wind_speed_std_dev: params.wind_speed_std_dev,
868        target_distance: if params.target_distance.is_nan() {
869            None
870        } else {
871            Some(params.target_distance)
872        },
873        base_wind_speed: params.base_wind_speed,
874        base_wind_direction: params.base_wind_direction,
875        azimuth_std_dev: params.azimuth_std_dev,
876    };
877
878    // Run Monte Carlo simulation
879    match run_monte_carlo_with_direction_std_dev(
880        ballistic_inputs,
881        mc_params,
882        wind_direction_std_dev,
883    ) {
884        Ok(results) => {
885            let num_results = results.ranges.len() as c_int;
886
887            // Calculate statistics
888            let mean_range: f64 = results.ranges.iter().sum::<f64>() / num_results as f64;
889            let variance_range: f64 = results
890                .ranges
891                .iter()
892                .map(|r| (r - mean_range).powi(2))
893                .sum::<f64>()
894                / num_results as f64;
895            let std_dev_range = variance_range.sqrt();
896
897            let mean_velocity: f64 =
898                results.impact_velocities.iter().sum::<f64>() / num_results as f64;
899            let variance_velocity: f64 = results
900                .impact_velocities
901                .iter()
902                .map(|v| (v - mean_velocity).powi(2))
903                .sum::<f64>()
904                / num_results as f64;
905            let std_dev_velocity = variance_velocity.sqrt();
906
907            // Calculate hit probability if target distance was specified. MBA-971: use the shared
908            // position-based criterion (fraction within DEFAULT_HIT_RADIUS_M of the point of aim
909            // at the target plane). The old inline version had a redundant `distance < target`
910            // clause comparing a ~meter deviation to the ~hundreds-of-meters target distance
911            // (effectively always true), and the CLI used a different range-based notion entirely.
912            let hit_probability = if params.target_distance.is_nan() {
913                0.0
914            } else {
915                results.hit_probability(crate::DEFAULT_HIT_RADIUS_M)
916            };
917
918            // Allocate memory for arrays
919            let ranges_ptr = unsafe {
920                let ptr = std::alloc::alloc(
921                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
922                ) as *mut c_double;
923                for (i, &range) in results.ranges.iter().enumerate() {
924                    *ptr.add(i) = range;
925                }
926                ptr
927            };
928
929            let velocities_ptr = unsafe {
930                let ptr = std::alloc::alloc(
931                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
932                ) as *mut c_double;
933                for (i, &vel) in results.impact_velocities.iter().enumerate() {
934                    *ptr.add(i) = vel;
935                }
936                ptr
937            };
938
939            let pos_x_ptr = unsafe {
940                let ptr = std::alloc::alloc(
941                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
942                ) as *mut c_double;
943                for (i, pos) in results.impact_positions.iter().enumerate() {
944                    *ptr.add(i) = pos.x;
945                }
946                ptr
947            };
948
949            let pos_y_ptr = unsafe {
950                let ptr = std::alloc::alloc(
951                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
952                ) as *mut c_double;
953                for (i, pos) in results.impact_positions.iter().enumerate() {
954                    *ptr.add(i) = pos.y;
955                }
956                ptr
957            };
958
959            let pos_z_ptr = unsafe {
960                let ptr = std::alloc::alloc(
961                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
962                ) as *mut c_double;
963                for (i, pos) in results.impact_positions.iter().enumerate() {
964                    *ptr.add(i) = pos.z;
965                }
966                ptr
967            };
968
969            // Create result structure
970            let result = Box::new(FFIMonteCarloResults {
971                ranges: ranges_ptr,
972                impact_velocities: velocities_ptr,
973                impact_positions_x: pos_x_ptr,
974                impact_positions_y: pos_y_ptr,
975                impact_positions_z: pos_z_ptr,
976                num_results,
977                mean_range,
978                std_dev_range,
979                mean_impact_velocity: mean_velocity,
980                std_dev_impact_velocity: std_dev_velocity,
981                hit_probability,
982            });
983
984            Box::into_raw(result)
985        }
986        Err(_) => ptr::null_mut(),
987    }
988}
989
990/// Release Monte Carlo results allocated by either Monte Carlo C entry point.
991///
992/// # Safety
993///
994/// `results` must be null or a pointer returned by [`ballistics_monte_carlo`] or
995/// [`ballistics_monte_carlo_with_direction_std_dev`] that has not already been freed. Its
996/// embedded pointers and result count must be unchanged from the returned values. After this
997/// call, the result and all of its arrays must not be accessed again.
998#[no_mangle]
999pub unsafe extern "C" fn ballistics_free_monte_carlo_results(results: *mut FFIMonteCarloResults) {
1000    if results.is_null() {
1001        return;
1002    }
1003
1004    unsafe {
1005        let results = Box::from_raw(results);
1006        let num = results.num_results as usize;
1007
1008        // Free arrays
1009        if !results.ranges.is_null() {
1010            std::alloc::dealloc(
1011                results.ranges as *mut u8,
1012                std::alloc::Layout::array::<c_double>(num).unwrap(),
1013            );
1014        }
1015
1016        if !results.impact_velocities.is_null() {
1017            std::alloc::dealloc(
1018                results.impact_velocities as *mut u8,
1019                std::alloc::Layout::array::<c_double>(num).unwrap(),
1020            );
1021        }
1022
1023        if !results.impact_positions_x.is_null() {
1024            std::alloc::dealloc(
1025                results.impact_positions_x as *mut u8,
1026                std::alloc::Layout::array::<c_double>(num).unwrap(),
1027            );
1028        }
1029
1030        if !results.impact_positions_y.is_null() {
1031            std::alloc::dealloc(
1032                results.impact_positions_y as *mut u8,
1033                std::alloc::Layout::array::<c_double>(num).unwrap(),
1034            );
1035        }
1036
1037        if !results.impact_positions_z.is_null() {
1038            std::alloc::dealloc(
1039                results.impact_positions_z as *mut u8,
1040                std::alloc::Layout::array::<c_double>(num).unwrap(),
1041            );
1042        }
1043
1044        // Box automatically deallocates the result structure
1045    }
1046}
1047
1048// Get library version
1049#[no_mangle]
1050pub extern "C" fn ballistics_get_version() -> *const c_char {
1051    // Return a pointer to a static NUL-terminated string (the caller must NOT free it).
1052    // Previously this leaked a freshly-allocated CString on every call and reported a
1053    // stale hardcoded "0.3.0"; use the real crate version with no allocation.
1054    concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    fn valid_trajectory_inputs() -> FFIBallisticInputs {
1062        FFIBallisticInputs {
1063            muzzle_velocity: 800.0,
1064            muzzle_angle: 0.0,
1065            bc_value: 0.5,
1066            bullet_mass: 0.01,
1067            bullet_diameter: 0.00762,
1068            bc_type: 0,
1069            sight_height: 0.05,
1070            target_distance: 1.0,
1071            temperature: 15.0,
1072            twist_rate: 12.0,
1073            is_twist_right: 1,
1074            shooting_angle: 0.0,
1075            altitude: 0.0,
1076            latitude: f64::NAN,
1077            azimuth_angle: 0.0,
1078            use_rk4: 1,
1079            use_adaptive_rk45: 0,
1080            enable_wind_shear: 0,
1081            enable_trajectory_sampling: 0,
1082            sample_interval: 10.0,
1083            enable_pitch_damping: 0,
1084            enable_precession_nutation: 0,
1085            enable_spin_drift: 0,
1086            enable_magnus: 0,
1087            enable_coriolis: 0,
1088            shot_azimuth: 0.0,
1089            cant_angle: 0.0,
1090        }
1091    }
1092
1093    #[allow(dead_code)]
1094    #[repr(C)]
1095    struct LegacyFFIMonteCarloParams {
1096        num_simulations: c_int,
1097        velocity_std_dev: c_double,
1098        angle_std_dev: c_double,
1099        bc_std_dev: c_double,
1100        wind_speed_std_dev: c_double,
1101        target_distance: c_double,
1102        base_wind_speed: c_double,
1103        base_wind_direction: c_double,
1104        azimuth_std_dev: c_double,
1105    }
1106
1107    #[test]
1108    fn monte_carlo_params_legacy_abi_size_is_unchanged() {
1109        assert_eq!(
1110            std::mem::size_of::<FFIMonteCarloParams>(),
1111            std::mem::size_of::<LegacyFFIMonteCarloParams>()
1112        );
1113        assert_eq!(
1114            std::mem::align_of::<FFIMonteCarloParams>(),
1115            std::mem::align_of::<LegacyFFIMonteCarloParams>()
1116        );
1117    }
1118
1119    /// MBA-1386 scope addition: `bc_type` gains an additive numeric slot (8) for the
1120    /// new RA4 family, appended after the existing 0-7 mapping (G1, G7, G2, G5, G6,
1121    /// G8, GI, GS). No existing caller's value changes meaning; `8` is simply new,
1122    /// and anything still outside 0-8 keeps falling back to G1.
1123    #[test]
1124    fn bc_type_8_maps_to_ra4() {
1125        let mut inputs = valid_trajectory_inputs();
1126        inputs.bc_type = 8;
1127        assert_eq!(convert_inputs(&inputs).bc_type, DragModel::RA4);
1128
1129        // Every pre-existing code is unaffected by the addition.
1130        let expected = [
1131            (0, DragModel::G1),
1132            (1, DragModel::G7),
1133            (2, DragModel::G2),
1134            (3, DragModel::G5),
1135            (4, DragModel::G6),
1136            (5, DragModel::G8),
1137            (6, DragModel::GI),
1138            (7, DragModel::GS),
1139        ];
1140        for (code, model) in expected {
1141            let mut inputs = valid_trajectory_inputs();
1142            inputs.bc_type = code;
1143            assert_eq!(convert_inputs(&inputs).bc_type, model, "code {code}");
1144        }
1145
1146        // Unrecognized codes (including ones above the new 8) still fall back to G1.
1147        for code in [9, 42, -1] {
1148            let mut inputs = valid_trajectory_inputs();
1149            inputs.bc_type = code;
1150            assert_eq!(convert_inputs(&inputs).bc_type, DragModel::G1, "code {code}");
1151        }
1152    }
1153
1154    #[test]
1155    fn null_pointer_contracts_return_sentinels_and_free_safely() {
1156        unsafe {
1157            assert!(ballistics_calculate_trajectory(
1158                std::ptr::null(),
1159                std::ptr::null(),
1160                std::ptr::null(),
1161                1_000.0,
1162                1.0,
1163            )
1164            .is_null());
1165            assert!(ballistics_calculate_zero_angle(
1166                std::ptr::null(),
1167                std::ptr::null(),
1168                std::ptr::null(),
1169                100.0,
1170            )
1171            .is_nan());
1172            assert!(ballistics_calculate_trajectory_with_drag_table(
1173                std::ptr::null(),
1174                std::ptr::null(),
1175                std::ptr::null(),
1176                1_000.0,
1177                1.0,
1178                DECK_MACH.as_ptr(),
1179                DECK_CD_LOW.as_ptr(),
1180                DECK_MACH.len() as c_int,
1181            )
1182            .is_null());
1183            assert!(ballistics_calculate_zero_angle_with_drag_table(
1184                std::ptr::null(),
1185                std::ptr::null(),
1186                std::ptr::null(),
1187                100.0,
1188                DECK_MACH.as_ptr(),
1189                DECK_CD_LOW.as_ptr(),
1190                DECK_MACH.len() as c_int,
1191            )
1192            .is_nan());
1193            assert!(
1194                ballistics_monte_carlo(std::ptr::null(), std::ptr::null(), std::ptr::null(),)
1195                    .is_null()
1196            );
1197            assert!(ballistics_monte_carlo_with_direction_std_dev(
1198                std::ptr::null(),
1199                std::ptr::null(),
1200                std::ptr::null(),
1201                0.1,
1202            )
1203            .is_null());
1204
1205            ballistics_free_trajectory_result(std::ptr::null_mut());
1206            ballistics_free_monte_carlo_results(std::ptr::null_mut());
1207        }
1208    }
1209
1210    #[test]
1211    fn mba1283_ffi_enforces_step_floor_for_every_solver_mode() {
1212        for (mode, use_rk4, use_adaptive_rk45) in [("Euler", 0, 0), ("RK4", 1, 0), ("RK45", 1, 1)] {
1213            for step_size in [
1214                f64::NAN,
1215                f64::INFINITY,
1216                f64::NEG_INFINITY,
1217                -1.0,
1218                -0.0,
1219                0.0,
1220                0.001,
1221                MIN_FFI_STEP_SIZE_MS - 0.001,
1222            ] {
1223                let mut inputs = valid_trajectory_inputs();
1224                inputs.use_rk4 = use_rk4;
1225                inputs.use_adaptive_rk45 = use_adaptive_rk45;
1226                let result = unsafe {
1227                    ballistics_calculate_trajectory(
1228                        &inputs,
1229                        std::ptr::null(),
1230                        std::ptr::null(),
1231                        0.01,
1232                        step_size,
1233                    )
1234                };
1235                assert!(
1236                    result.is_null(),
1237                    "{mode} step_size={step_size:?} bypassed the FFI floor"
1238                );
1239            }
1240
1241            let mut inputs = valid_trajectory_inputs();
1242            inputs.use_rk4 = use_rk4;
1243            inputs.use_adaptive_rk45 = use_adaptive_rk45;
1244            let result = unsafe {
1245                ballistics_calculate_trajectory(
1246                    &inputs,
1247                    std::ptr::null(),
1248                    std::ptr::null(),
1249                    0.01,
1250                    MIN_FFI_STEP_SIZE_MS,
1251                )
1252            };
1253            assert!(
1254                !result.is_null(),
1255                "the documented minimum step must remain usable in {mode}"
1256            );
1257            unsafe {
1258                assert!((*result).point_count >= 0);
1259                assert!((*result).point_count as usize <= crate::MAX_TRAJECTORY_POINTS);
1260                ballistics_free_trajectory_result(result);
1261            }
1262        }
1263    }
1264
1265    /// A tiny valid deck: strictly ascending Mach, positive Cd.
1266    const DECK_MACH: [f64; 4] = [0.5, 1.0, 2.0, 3.0];
1267    /// Deliberately LOW drag so the deck measurably increases impact velocity vs G1.
1268    const DECK_CD_LOW: [f64; 4] = [0.05, 0.08, 0.06, 0.05];
1269
1270    #[test]
1271    fn trajectory_with_drag_table_applies_the_deck() {
1272        let inputs = valid_trajectory_inputs();
1273        unsafe {
1274            let plain = ballistics_calculate_trajectory(
1275                &inputs,
1276                std::ptr::null(),
1277                std::ptr::null(),
1278                300.0,
1279                1.0,
1280            );
1281            let decked = ballistics_calculate_trajectory_with_drag_table(
1282                &inputs,
1283                std::ptr::null(),
1284                std::ptr::null(),
1285                300.0,
1286                1.0,
1287                DECK_MACH.as_ptr(),
1288                DECK_CD_LOW.as_ptr(),
1289                DECK_MACH.len() as c_int,
1290            );
1291            assert!(!plain.is_null() && !decked.is_null());
1292            // The low-drag deck must retain materially more velocity than the G-model.
1293            assert!(
1294                (*decked).impact_velocity > (*plain).impact_velocity + 1.0,
1295                "deck did not change the solve: plain={} decked={}",
1296                (*plain).impact_velocity,
1297                (*decked).impact_velocity
1298            );
1299            ballistics_free_trajectory_result(plain);
1300            ballistics_free_trajectory_result(decked);
1301        }
1302    }
1303
1304    #[test]
1305    fn trajectory_with_drag_table_rejects_invalid_decks() {
1306        let inputs = valid_trajectory_inputs();
1307        let descending = [3.0, 2.0, 1.0, 0.5];
1308        let negative_cd = [0.05, -0.08, 0.06, 0.05];
1309        unsafe {
1310            // null arrays
1311            assert!(ballistics_calculate_trajectory_with_drag_table(
1312                &inputs,
1313                std::ptr::null(),
1314                std::ptr::null(),
1315                300.0,
1316                1.0,
1317                std::ptr::null(),
1318                DECK_CD_LOW.as_ptr(),
1319                4,
1320            )
1321            .is_null());
1322            assert!(ballistics_calculate_trajectory_with_drag_table(
1323                &inputs,
1324                std::ptr::null(),
1325                std::ptr::null(),
1326                300.0,
1327                1.0,
1328                DECK_MACH.as_ptr(),
1329                std::ptr::null(),
1330                4,
1331            )
1332            .is_null());
1333            // too few points
1334            assert!(ballistics_calculate_trajectory_with_drag_table(
1335                &inputs,
1336                std::ptr::null(),
1337                std::ptr::null(),
1338                300.0,
1339                1.0,
1340                DECK_MACH.as_ptr(),
1341                DECK_CD_LOW.as_ptr(),
1342                1,
1343            )
1344            .is_null());
1345            // non-ascending Mach
1346            assert!(ballistics_calculate_trajectory_with_drag_table(
1347                &inputs,
1348                std::ptr::null(),
1349                std::ptr::null(),
1350                300.0,
1351                1.0,
1352                descending.as_ptr(),
1353                DECK_CD_LOW.as_ptr(),
1354                4,
1355            )
1356            .is_null());
1357            // non-positive Cd
1358            assert!(ballistics_calculate_trajectory_with_drag_table(
1359                &inputs,
1360                std::ptr::null(),
1361                std::ptr::null(),
1362                300.0,
1363                1.0,
1364                DECK_MACH.as_ptr(),
1365                negative_cd.as_ptr(),
1366                4,
1367            )
1368            .is_null());
1369            // null inputs still rejected
1370            assert!(ballistics_calculate_trajectory_with_drag_table(
1371                std::ptr::null(),
1372                std::ptr::null(),
1373                std::ptr::null(),
1374                300.0,
1375                1.0,
1376                DECK_MACH.as_ptr(),
1377                DECK_CD_LOW.as_ptr(),
1378                4,
1379            )
1380            .is_null());
1381        }
1382    }
1383
1384    #[test]
1385    fn zero_angle_with_drag_table_applies_the_deck() {
1386        // A realistic zeroing setup: 100 m zero.
1387        let inputs = valid_trajectory_inputs();
1388        unsafe {
1389            let plain =
1390                ballistics_calculate_zero_angle(&inputs, std::ptr::null(), std::ptr::null(), 100.0);
1391            let decked = ballistics_calculate_zero_angle_with_drag_table(
1392                &inputs,
1393                std::ptr::null(),
1394                std::ptr::null(),
1395                100.0,
1396                DECK_MACH.as_ptr(),
1397                DECK_CD_LOW.as_ptr(),
1398                DECK_MACH.len() as c_int,
1399            );
1400            assert!(plain.is_finite() && decked.is_finite());
1401            // A much lower-drag deck needs a flatter (smaller) zero angle; at minimum it
1402            // must differ measurably from the G-model zero.
1403            assert!(
1404                (plain - decked).abs() > 1e-6,
1405                "deck did not change the zero: plain={plain} decked={decked}"
1406            );
1407        }
1408    }
1409
1410    #[test]
1411    fn zero_angle_with_drag_table_rejects_invalid_decks() {
1412        let inputs = valid_trajectory_inputs();
1413        let descending = [3.0, 2.0, 1.0, 0.5];
1414        unsafe {
1415            assert!(ballistics_calculate_zero_angle_with_drag_table(
1416                &inputs,
1417                std::ptr::null(),
1418                std::ptr::null(),
1419                100.0,
1420                std::ptr::null(),
1421                DECK_CD_LOW.as_ptr(),
1422                4,
1423            )
1424            .is_nan());
1425            assert!(ballistics_calculate_zero_angle_with_drag_table(
1426                &inputs,
1427                std::ptr::null(),
1428                std::ptr::null(),
1429                100.0,
1430                DECK_MACH.as_ptr(),
1431                DECK_CD_LOW.as_ptr(),
1432                0,
1433            )
1434            .is_nan());
1435            assert!(ballistics_calculate_zero_angle_with_drag_table(
1436                &inputs,
1437                std::ptr::null(),
1438                std::ptr::null(),
1439                100.0,
1440                descending.as_ptr(),
1441                DECK_CD_LOW.as_ptr(),
1442                4,
1443            )
1444            .is_nan());
1445            // null inputs still rejected
1446            assert!(ballistics_calculate_zero_angle_with_drag_table(
1447                std::ptr::null(),
1448                std::ptr::null(),
1449                std::ptr::null(),
1450                100.0,
1451                DECK_MACH.as_ptr(),
1452                DECK_CD_LOW.as_ptr(),
1453                4,
1454            )
1455            .is_nan());
1456        }
1457    }
1458
1459    #[test]
1460    fn zero_then_fly_with_same_deck_is_consistent() {
1461        // The pair-use case the two exports exist for: zero with the deck, fly with the
1462        // deck at the solved angle; the trajectory must cross near sight height at the
1463        // zero distance (i.e. the two functions share identical deck semantics).
1464        let mut inputs = valid_trajectory_inputs();
1465        unsafe {
1466            let angle = ballistics_calculate_zero_angle_with_drag_table(
1467                &inputs,
1468                std::ptr::null(),
1469                std::ptr::null(),
1470                100.0,
1471                DECK_MACH.as_ptr(),
1472                DECK_CD_LOW.as_ptr(),
1473                DECK_MACH.len() as c_int,
1474            );
1475            assert!(angle.is_finite());
1476            inputs.muzzle_angle = angle;
1477            let result = ballistics_calculate_trajectory_with_drag_table(
1478                &inputs,
1479                std::ptr::null(),
1480                std::ptr::null(),
1481                150.0,
1482                1.0,
1483                DECK_MACH.as_ptr(),
1484                DECK_CD_LOW.as_ptr(),
1485                DECK_MACH.len() as c_int,
1486            );
1487            assert!(!result.is_null());
1488            // Interpolate y at exactly the zero distance (100 m) rather than snapping to the
1489            // nearest raw trajectory sample, so the residual reflects only zero-solver
1490            // convergence, not the sampling grid's x-offset from 100 m.
1491            let zero_distance = 100.0;
1492            let pts = std::slice::from_raw_parts((*result).points, (*result).point_count as usize);
1493            let bracket = pts
1494                .windows(2)
1495                .find(|w| w[0].position_x <= zero_distance && w[1].position_x >= zero_distance)
1496                .expect("trajectory brackets the zero distance");
1497            let (lo, hi) = (&bracket[0], &bracket[1]);
1498            let y_at_zero = if hi.position_x > lo.position_x {
1499                let t = (zero_distance - lo.position_x) / (hi.position_x - lo.position_x);
1500                lo.position_y + t * (hi.position_y - lo.position_y)
1501            } else {
1502                lo.position_y
1503            };
1504            assert!(
1505                (y_at_zero - inputs.sight_height).abs() < 0.002,
1506                "zeroed flight missed the line of sight at 100 m: y={} (sight_height={})",
1507                y_at_zero,
1508                inputs.sight_height
1509            );
1510            ballistics_free_trajectory_result(result);
1511        }
1512    }
1513
1514    #[test]
1515    fn drag_table_len_above_cap_is_rejected() {
1516        // A valid, monotonically increasing deck that is simply too long: the cap
1517        // must reject it BEFORE the to_vec() copies, returning the null sentinel.
1518        let n = (MAX_FFI_DRAG_TABLE_LEN + 1) as usize;
1519        let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1520        let cd: Vec<f64> = vec![0.3; n];
1521        let inputs = valid_trajectory_inputs();
1522        unsafe {
1523            let r = ballistics_calculate_trajectory_with_drag_table(
1524                &inputs,
1525                std::ptr::null(),
1526                std::ptr::null(),
1527                300.0,
1528                1.0,
1529                mach.as_ptr(),
1530                cd.as_ptr(),
1531                n as c_int,
1532            );
1533            assert!(r.is_null(), "len {n} must be rejected by the cap");
1534        }
1535    }
1536
1537    #[test]
1538    fn drag_table_len_at_cap_is_accepted() {
1539        let n = MAX_FFI_DRAG_TABLE_LEN as usize;
1540        let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1541        let cd: Vec<f64> = vec![0.3; n];
1542        let inputs = valid_trajectory_inputs();
1543        unsafe {
1544            let r = ballistics_calculate_trajectory_with_drag_table(
1545                &inputs,
1546                std::ptr::null(),
1547                std::ptr::null(),
1548                300.0,
1549                1.0,
1550                mach.as_ptr(),
1551                cd.as_ptr(),
1552                n as c_int,
1553            );
1554            assert!(!r.is_null(), "len == cap must be accepted");
1555            ballistics_free_trajectory_result(r);
1556        }
1557    }
1558
1559    #[test]
1560    fn ffi_cant_angle_deflects_laterally() {
1561        let mut level = valid_trajectory_inputs();
1562        level.muzzle_angle = 0.003;
1563        let mut canted = valid_trajectory_inputs();
1564        canted.muzzle_angle = 0.003;
1565        canted.cant_angle = 10f64.to_radians();
1566        unsafe {
1567            let a = ballistics_calculate_trajectory(&level, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1568            let b = ballistics_calculate_trajectory(&canted, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1569            assert!(!a.is_null() && !b.is_null());
1570            let za = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_z;
1571            let zb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_z;
1572            assert!(zb > za + 0.005, "FFI cant must deflect right: level={za} canted={zb}");
1573            ballistics_free_trajectory_result(a);
1574            ballistics_free_trajectory_result(b);
1575        }
1576    }
1577
1578    #[test]
1579    fn ffi_vertical_wind_raises_trajectory() {
1580        let inputs = valid_trajectory_inputs();
1581        let no_wind = FFIWindConditions {
1582            speed: 0.0,
1583            direction: 0.0,
1584            vertical_speed: 0.0,
1585        };
1586        let updraft = FFIWindConditions {
1587            speed: 0.0,
1588            direction: 0.0,
1589            vertical_speed: 5.0,
1590        };
1591        unsafe {
1592            let a = ballistics_calculate_trajectory(&inputs, &no_wind, std::ptr::null(), 400.0, 1.0);
1593            let b = ballistics_calculate_trajectory(&inputs, &updraft, std::ptr::null(), 400.0, 1.0);
1594            assert!(!a.is_null() && !b.is_null());
1595            let ya = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_y;
1596            let yb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_y;
1597            assert!(yb > ya + 0.01, "FFI updraft must raise the trajectory: no_wind={ya} updraft={yb}");
1598            ballistics_free_trajectory_result(a);
1599            ballistics_free_trajectory_result(b);
1600        }
1601    }
1602
1603    // --- MBA-1356: cd_scale `_scaled` FFI variants ---
1604
1605    #[test]
1606    fn trajectory_scaled_at_one_matches_unscaled_export() {
1607        let inputs = valid_trajectory_inputs();
1608        unsafe {
1609            let unscaled = ballistics_calculate_trajectory_with_drag_table(
1610                &inputs,
1611                std::ptr::null(),
1612                std::ptr::null(),
1613                300.0,
1614                1.0,
1615                DECK_MACH.as_ptr(),
1616                DECK_CD_LOW.as_ptr(),
1617                DECK_MACH.len() as c_int,
1618            );
1619            let scaled = ballistics_calculate_trajectory_with_drag_table_scaled(
1620                &inputs,
1621                std::ptr::null(),
1622                std::ptr::null(),
1623                300.0,
1624                1.0,
1625                DECK_MACH.as_ptr(),
1626                DECK_CD_LOW.as_ptr(),
1627                DECK_MACH.len() as c_int,
1628                1.0,
1629            );
1630            assert!(!unscaled.is_null() && !scaled.is_null());
1631            assert_eq!(
1632                (*unscaled).impact_velocity.to_bits(),
1633                (*scaled).impact_velocity.to_bits(),
1634                "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={} scaled={}",
1635                (*unscaled).impact_velocity,
1636                (*scaled).impact_velocity
1637            );
1638            ballistics_free_trajectory_result(unscaled);
1639            ballistics_free_trajectory_result(scaled);
1640        }
1641    }
1642
1643    #[test]
1644    fn trajectory_scaled_at_1_10_lowers_impact_velocity() {
1645        let inputs = valid_trajectory_inputs();
1646        unsafe {
1647            let baseline = ballistics_calculate_trajectory_with_drag_table_scaled(
1648                &inputs,
1649                std::ptr::null(),
1650                std::ptr::null(),
1651                300.0,
1652                1.0,
1653                DECK_MACH.as_ptr(),
1654                DECK_CD_LOW.as_ptr(),
1655                DECK_MACH.len() as c_int,
1656                1.0,
1657            );
1658            let scaled_up = ballistics_calculate_trajectory_with_drag_table_scaled(
1659                &inputs,
1660                std::ptr::null(),
1661                std::ptr::null(),
1662                300.0,
1663                1.0,
1664                DECK_MACH.as_ptr(),
1665                DECK_CD_LOW.as_ptr(),
1666                DECK_MACH.len() as c_int,
1667                1.10,
1668            );
1669            assert!(!baseline.is_null() && !scaled_up.is_null());
1670            assert!(
1671                (*scaled_up).impact_velocity < (*baseline).impact_velocity,
1672                "cd_scale=1.10 must increase drag -> lower impact velocity: base={} scaled={}",
1673                (*baseline).impact_velocity,
1674                (*scaled_up).impact_velocity
1675            );
1676            ballistics_free_trajectory_result(baseline);
1677            ballistics_free_trajectory_result(scaled_up);
1678        }
1679    }
1680
1681    #[test]
1682    fn trajectory_scaled_rejects_invalid_cd_scale() {
1683        let inputs = valid_trajectory_inputs();
1684        unsafe {
1685            for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1686                let r = ballistics_calculate_trajectory_with_drag_table_scaled(
1687                    &inputs,
1688                    std::ptr::null(),
1689                    std::ptr::null(),
1690                    300.0,
1691                    1.0,
1692                    DECK_MACH.as_ptr(),
1693                    DECK_CD_LOW.as_ptr(),
1694                    DECK_MACH.len() as c_int,
1695                    bad,
1696                );
1697                assert!(r.is_null(), "cd_scale={bad} must be rejected (null sentinel)");
1698            }
1699        }
1700    }
1701
1702    #[test]
1703    fn zero_angle_scaled_at_one_matches_unscaled_export() {
1704        let inputs = valid_trajectory_inputs();
1705        unsafe {
1706            let unscaled = ballistics_calculate_zero_angle_with_drag_table(
1707                &inputs,
1708                std::ptr::null(),
1709                std::ptr::null(),
1710                100.0,
1711                DECK_MACH.as_ptr(),
1712                DECK_CD_LOW.as_ptr(),
1713                DECK_MACH.len() as c_int,
1714            );
1715            let scaled = ballistics_calculate_zero_angle_with_drag_table_scaled(
1716                &inputs,
1717                std::ptr::null(),
1718                std::ptr::null(),
1719                100.0,
1720                DECK_MACH.as_ptr(),
1721                DECK_CD_LOW.as_ptr(),
1722                DECK_MACH.len() as c_int,
1723                1.0,
1724            );
1725            assert!(unscaled.is_finite() && scaled.is_finite());
1726            assert_eq!(
1727                unscaled.to_bits(),
1728                scaled.to_bits(),
1729                "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={unscaled} scaled={scaled}"
1730            );
1731        }
1732    }
1733
1734    #[test]
1735    fn zero_angle_scaled_at_1_10_differs_from_baseline() {
1736        let inputs = valid_trajectory_inputs();
1737        unsafe {
1738            let baseline = ballistics_calculate_zero_angle_with_drag_table_scaled(
1739                &inputs,
1740                std::ptr::null(),
1741                std::ptr::null(),
1742                100.0,
1743                DECK_MACH.as_ptr(),
1744                DECK_CD_LOW.as_ptr(),
1745                DECK_MACH.len() as c_int,
1746                1.0,
1747            );
1748            let scaled_up = ballistics_calculate_zero_angle_with_drag_table_scaled(
1749                &inputs,
1750                std::ptr::null(),
1751                std::ptr::null(),
1752                100.0,
1753                DECK_MACH.as_ptr(),
1754                DECK_CD_LOW.as_ptr(),
1755                DECK_MACH.len() as c_int,
1756                1.10,
1757            );
1758            assert!(baseline.is_finite() && scaled_up.is_finite());
1759            // More drag needs a steeper (larger) zero angle to still reach 100 m.
1760            assert!(
1761                scaled_up > baseline,
1762                "cd_scale=1.10 must need a larger zero angle: base={baseline} scaled={scaled_up}"
1763            );
1764        }
1765    }
1766
1767    #[test]
1768    fn zero_angle_scaled_rejects_invalid_cd_scale() {
1769        let inputs = valid_trajectory_inputs();
1770        unsafe {
1771            for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1772                let angle = ballistics_calculate_zero_angle_with_drag_table_scaled(
1773                    &inputs,
1774                    std::ptr::null(),
1775                    std::ptr::null(),
1776                    100.0,
1777                    DECK_MACH.as_ptr(),
1778                    DECK_CD_LOW.as_ptr(),
1779                    DECK_MACH.len() as c_int,
1780                    bad,
1781                );
1782                assert!(angle.is_nan(), "cd_scale={bad} must be rejected (NaN sentinel)");
1783            }
1784        }
1785    }
1786
1787    /// Legacy exports must remain byte-identical: the same deck through the unscaled
1788    /// export must be unaffected by the new cd_scale plumbing (a regression guard
1789    /// alongside the pre-existing, untouched drag-table tests above).
1790    #[test]
1791    fn legacy_drag_table_exports_unaffected_by_cd_scale_plumbing() {
1792        let inputs = valid_trajectory_inputs();
1793        unsafe {
1794            let a = ballistics_calculate_trajectory_with_drag_table(
1795                &inputs,
1796                std::ptr::null(),
1797                std::ptr::null(),
1798                300.0,
1799                1.0,
1800                DECK_MACH.as_ptr(),
1801                DECK_CD_LOW.as_ptr(),
1802                DECK_MACH.len() as c_int,
1803            );
1804            let b = ballistics_calculate_trajectory_with_drag_table(
1805                &inputs,
1806                std::ptr::null(),
1807                std::ptr::null(),
1808                300.0,
1809                1.0,
1810                DECK_MACH.as_ptr(),
1811                DECK_CD_LOW.as_ptr(),
1812                DECK_MACH.len() as c_int,
1813            );
1814            assert!(!a.is_null() && !b.is_null());
1815            assert_eq!((*a).impact_velocity.to_bits(), (*b).impact_velocity.to_bits());
1816            ballistics_free_trajectory_result(a);
1817            ballistics_free_trajectory_result(b);
1818
1819            let za = ballistics_calculate_zero_angle_with_drag_table(
1820                &inputs,
1821                std::ptr::null(),
1822                std::ptr::null(),
1823                100.0,
1824                DECK_MACH.as_ptr(),
1825                DECK_CD_LOW.as_ptr(),
1826                DECK_MACH.len() as c_int,
1827            );
1828            let zb = ballistics_calculate_zero_angle_with_drag_table(
1829                &inputs,
1830                std::ptr::null(),
1831                std::ptr::null(),
1832                100.0,
1833                DECK_MACH.as_ptr(),
1834                DECK_CD_LOW.as_ptr(),
1835                DECK_MACH.len() as c_int,
1836            );
1837            assert!(za.is_finite() && zb.is_finite());
1838            assert_eq!(za.to_bits(), zb.to_bits());
1839        }
1840    }
1841}