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// FFI-safe structures with C-compatible layouts
18
19#[repr(C)]
20pub struct FFIBallisticInputs {
21    pub muzzle_velocity: c_double,         // m/s
22    pub muzzle_angle: c_double,            // radians (launch angle)
23    pub bc_value: c_double,                // ballistic coefficient
24    pub bullet_mass: c_double,             // kg
25    pub bullet_diameter: c_double,         // meters
26    pub bc_type: c_int,                    // 0=G1, 1=G7, etc.
27    pub sight_height: c_double,            // meters
28    pub target_distance: c_double,         // meters
29    pub temperature: c_double,             // Celsius
30    pub twist_rate: c_double,              // inches per turn
31    pub is_twist_right: c_int,             // 0=false, 1=true
32    pub shooting_angle: c_double,          // uphill/downhill angle in radians
33    pub altitude: c_double,                // meters
34    pub latitude: c_double,                // degrees (use NAN if not provided)
35    pub azimuth_angle: c_double,           // horizontal aiming angle in radians
36    pub use_rk4: c_int,                    // 0=Euler, 1=RK4
37    pub use_adaptive_rk45: c_int,          // 0=false, 1=true (adaptive RK45)
38    pub enable_wind_shear: c_int,          // 0=false, 1=true
39    pub enable_trajectory_sampling: c_int, // 0=false, 1=true
40    pub sample_interval: c_double,         // meters
41    pub enable_pitch_damping: c_int,       // 0=false, 1=true
42    pub enable_precession_nutation: c_int, // 0=false, 1=true
43    pub enable_spin_drift: c_int,          // 0=false, 1=true
44    pub enable_magnus: c_int,              // 0=false, 1=true
45    pub enable_coriolis: c_int,            // 0=false, 1=true
46    // Appended (keeps existing field offsets): compass bearing the shot is fired
47    // along, radians, 0=North, PI/2=East. Drives the Coriolis Eötvös/drift azimuth.
48    // Distinct from azimuth_angle (the small aiming offset). 0.0 if unset.
49    pub shot_azimuth: c_double,
50    // Appended (keeps existing field offsets): rifle cant angle in RADIANS about the line
51    // of sight, positive = clockwise from the shooter. Rotates the sight-frame aim offsets
52    // and bore geometry ("zeroed level, fired canted" -> POI right and low). 0.0 if unset.
53    pub cant_angle: c_double,
54}
55
56#[repr(C)]
57pub struct FFIWindConditions {
58    pub speed: c_double, // m/s
59    // radians, wind-FROM convention: 0 = headwind, PI/2 = from the right,
60    // PI = tailwind, 3*PI/2 = from the left (matches WindConditions / WindSock).
61    pub direction: c_double,
62    // Appended (keeps existing field offsets): vertical wind m/s, positive = updraft;
63    // 0.0 if unset.
64    pub vertical_speed: c_double,
65}
66
67#[repr(C)]
68pub struct FFIAtmosphericConditions {
69    pub temperature: c_double, // Celsius
70    pub pressure: c_double,    // hPa
71    pub humidity: c_double,    // percentage (0-100)
72    pub altitude: c_double,    // meters
73}
74
75#[repr(C)]
76pub struct FFITrajectorySample {
77    pub distance: c_double,       // meters
78    pub time: c_double,           // seconds
79    pub velocity_mps: c_double,   // meters per second
80    pub energy_joules: c_double,  // joules
81    pub drop_meters: c_double,    // meters
82    pub windage_meters: c_double, // meters
83    pub mach: c_double,           // Mach number
84    pub spin_rate_rps: c_double,  // revolutions per second
85}
86
87#[repr(C)]
88pub struct FFITrajectoryPoint {
89    pub time: c_double,
90    pub position_x: c_double,
91    pub position_y: c_double,
92    pub position_z: c_double,
93    pub velocity_magnitude: c_double,
94    pub kinetic_energy: c_double,
95}
96
97#[repr(C)]
98pub struct FFITrajectoryResult {
99    pub max_range: c_double,
100    pub max_height: c_double,
101    pub time_of_flight: c_double,
102    pub impact_velocity: c_double,
103    pub impact_energy: c_double,
104    pub points: *mut FFITrajectoryPoint,
105    pub point_count: c_int,
106    pub sampled_points: *mut FFITrajectorySample,
107    pub sampled_point_count: c_int,
108    pub min_pitch_damping: c_double,    // NAN if not calculated
109    pub transonic_mach: c_double,       // NAN if not reached
110    pub final_pitch_angle: c_double,    // NAN if not calculated
111    pub final_yaw_angle: c_double,      // NAN if not calculated
112    pub max_yaw_angle: c_double,        // NAN if not calculated
113    pub max_precession_angle: c_double, // NAN if not calculated
114}
115
116// Monte Carlo simulation parameters
117#[repr(C)]
118pub struct FFIMonteCarloParams {
119    pub num_simulations: c_int,
120    pub velocity_std_dev: c_double,
121    pub angle_std_dev: c_double,
122    pub bc_std_dev: c_double,
123    pub wind_speed_std_dev: c_double,
124    pub target_distance: c_double,     // Use NAN if not specified
125    pub base_wind_speed: c_double,     // Base wind speed in m/s
126    pub base_wind_direction: c_double, // Base wind direction in radians
127    pub azimuth_std_dev: c_double,     // Horizontal aiming variation in radians
128}
129
130// Monte Carlo simulation results
131#[repr(C)]
132pub struct FFIMonteCarloResults {
133    pub ranges: *mut c_double,
134    pub impact_velocities: *mut c_double,
135    pub impact_positions_x: *mut c_double,
136    /// `-1.0e9` marks a sample that did not reach the target plane; exclude it from dispersion
137    /// statistics but retain it as a miss for probability calculations.
138    pub impact_positions_y: *mut c_double,
139    pub impact_positions_z: *mut c_double,
140    pub num_results: c_int,
141    pub mean_range: c_double,
142    pub std_dev_range: c_double,
143    pub mean_impact_velocity: c_double,
144    pub std_dev_impact_velocity: c_double,
145    pub hit_probability: c_double, // If target_distance was specified
146}
147
148// Helper function to convert FFI inputs to internal types
149#[allow(clippy::field_reassign_with_default)] // Keep the C-to-Rust field mapping sequential/auditable.
150fn convert_inputs(inputs: &FFIBallisticInputs) -> BallisticInputs {
151    let mut ballistic_inputs = BallisticInputs::default();
152
153    ballistic_inputs.muzzle_velocity = inputs.muzzle_velocity;
154    ballistic_inputs.muzzle_angle = inputs.muzzle_angle;
155    ballistic_inputs.azimuth_angle = inputs.azimuth_angle;
156    ballistic_inputs.shot_azimuth = inputs.shot_azimuth;
157    ballistic_inputs.cant_angle = inputs.cant_angle;
158    ballistic_inputs.use_rk4 = inputs.use_rk4 != 0;
159    ballistic_inputs.use_adaptive_rk45 = inputs.use_adaptive_rk45 != 0;
160    ballistic_inputs.bc_value = inputs.bc_value;
161    ballistic_inputs.bullet_mass = inputs.bullet_mass;
162    ballistic_inputs.bullet_diameter = inputs.bullet_diameter;
163    ballistic_inputs.bc_type = match inputs.bc_type {
164        1 => DragModel::G7,
165        2 => DragModel::G2,
166        3 => DragModel::G5,
167        4 => DragModel::G6,
168        5 => DragModel::G8,
169        6 => DragModel::GI,
170        7 => DragModel::GS,
171        _ => DragModel::G1,
172    };
173    ballistic_inputs.sight_height = inputs.sight_height;
174    ballistic_inputs.target_distance = inputs.target_distance;
175    ballistic_inputs.temperature = inputs.temperature;
176    ballistic_inputs.twist_rate = inputs.twist_rate;
177    ballistic_inputs.is_twist_right = inputs.is_twist_right != 0;
178    ballistic_inputs.shooting_angle = inputs.shooting_angle;
179    ballistic_inputs.altitude = inputs.altitude;
180
181    if !inputs.latitude.is_nan() {
182        ballistic_inputs.latitude = Some(inputs.latitude);
183    }
184
185    // Set derived values
186    ballistic_inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
187    ballistic_inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
188    // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default). The C ABI does
189    // not carry a bullet length, so derive it from diameter + mass; fall back to 4.5-cal if mass<=0.
190    ballistic_inputs.bullet_length = {
191        let est = crate::stability::estimate_bullet_length_m(
192            ballistic_inputs.bullet_diameter,
193            ballistic_inputs.bullet_mass,
194        );
195        if est > 0.0 {
196            est
197        } else {
198            ballistic_inputs.bullet_diameter * 4.5
199        }
200    };
201
202    // New advanced physics flags
203    ballistic_inputs.enable_wind_shear = inputs.enable_wind_shear != 0;
204    ballistic_inputs.enable_trajectory_sampling = inputs.enable_trajectory_sampling != 0;
205    ballistic_inputs.sample_interval = inputs.sample_interval;
206    ballistic_inputs.enable_pitch_damping = inputs.enable_pitch_damping != 0;
207    ballistic_inputs.enable_precession_nutation = inputs.enable_precession_nutation != 0;
208    ballistic_inputs.use_enhanced_spin_drift = inputs.enable_spin_drift != 0;
209    ballistic_inputs.enable_advanced_effects =
210        inputs.enable_magnus != 0 || inputs.enable_coriolis != 0;
211    // Gate Magnus and Coriolis independently so enabling one does not enable the other.
212    ballistic_inputs.enable_magnus = inputs.enable_magnus != 0;
213    ballistic_inputs.enable_coriolis = inputs.enable_coriolis != 0;
214
215    ballistic_inputs
216}
217
218/// Build a validated [`crate::drag::DragTable`] from borrowed C arrays.
219///
220/// Both arrays must contain `len` elements. The data is copied; the caller retains
221/// ownership. Returns `Err(())` for null pointers, `len < 2`, or any deck that fails
222/// [`crate::drag::DragTable::try_new`] validation (non-ascending Mach, non-positive
223/// or non-finite Cd). No error detail crosses the ABI, matching the null/NAN
224/// error convention of this module.
225///
226/// # Safety
227///
228/// When non-null, `mach` and `cd` must each point to `len` readable `f64` values
229/// that remain valid and unmutated for the duration of the call.
230unsafe fn drag_table_from_raw(
231    mach: *const c_double,
232    cd: *const c_double,
233    len: c_int,
234) -> Result<crate::drag::DragTable, ()> {
235    if mach.is_null() || cd.is_null() || len < 2 {
236        return Err(());
237    }
238    let len = len as usize;
239    let mach = unsafe { std::slice::from_raw_parts(mach, len) }.to_vec();
240    let cd = unsafe { std::slice::from_raw_parts(cd, len) }.to_vec();
241    crate::drag::DragTable::try_new(mach, cd).map_err(|_| ())
242}
243
244/// Shared implementation for the trajectory exports. `custom_drag_table`, when
245/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
246/// density — see `BallisticInputs::custom_drag_denominator`).
247unsafe fn calculate_trajectory_impl(
248    inputs: *const FFIBallisticInputs,
249    wind: *const FFIWindConditions,
250    atmosphere: *const FFIAtmosphericConditions,
251    max_range: c_double,
252    step_size: c_double,
253    custom_drag_table: Option<crate::drag::DragTable>,
254) -> *mut FFITrajectoryResult {
255    if inputs.is_null() {
256        return ptr::null_mut();
257    }
258    if !step_size.is_finite() || step_size < MIN_FFI_STEP_SIZE_MS {
259        return ptr::null_mut();
260    }
261
262    let inputs = unsafe { &*inputs };
263    let mut ballistic_inputs = convert_inputs(inputs);
264    ballistic_inputs.custom_drag_table = custom_drag_table;
265    let twist_rate_in = ballistic_inputs.twist_rate;
266
267    let wind_conditions = if wind.is_null() {
268        WindConditions::default()
269    } else {
270        let wind = unsafe { &*wind };
271        WindConditions {
272            speed: wind.speed,
273            direction: wind.direction,
274            vertical_speed: wind.vertical_speed,
275        }
276    };
277
278    let atmospheric_conditions = if atmosphere.is_null() {
279        AtmosphericConditions::default()
280    } else {
281        let atmo = unsafe { &*atmosphere };
282        AtmosphericConditions {
283            temperature: atmo.temperature,
284            pressure: atmo.pressure,
285            humidity: atmo.humidity,
286            altitude: atmo.altitude,
287        }
288    };
289
290    // Create solver and calculate trajectory
291    let (sample_temp_c, sample_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
292        atmospheric_conditions.temperature,
293        atmospheric_conditions.pressure,
294        atmospheric_conditions.altitude,
295    );
296    let (_, sample_speed_of_sound) = crate::atmosphere::calculate_atmosphere(
297        atmospheric_conditions.altitude,
298        Some(sample_temp_c),
299        Some(sample_pressure_hpa),
300        atmospheric_conditions.humidity,
301    );
302
303    let mut solver =
304        TrajectorySolver::new(ballistic_inputs, wind_conditions, atmospheric_conditions);
305
306    // Set max range and time step
307    solver.set_max_range(max_range);
308    solver.set_time_step(step_size / 1000.0); // milliseconds -> seconds
309
310    match solver.solve() {
311        Ok(result) => {
312            // Convert trajectory points to FFI format
313            let point_count = result.points.len();
314            let points = if point_count > 0 {
315                let mut ffi_points = Vec::with_capacity(point_count);
316                for point in result.points.iter() {
317                    ffi_points.push(FFITrajectoryPoint {
318                        time: point.time,
319                        position_x: point.position[0],
320                        position_y: point.position[1],
321                        position_z: point.position[2],
322                        velocity_magnitude: point.velocity_magnitude,
323                        kinetic_energy: point.kinetic_energy,
324                    });
325                }
326                let points_ptr = ffi_points.as_mut_ptr();
327                std::mem::forget(ffi_points); // Prevent deallocation
328                points_ptr
329            } else {
330                ptr::null_mut()
331            };
332
333            // Convert sampled points if available
334            let (sampled_points, sampled_point_count) =
335                if let Some(ref samples) = result.sampled_points {
336                    let mut ffi_samples = Vec::with_capacity(samples.len());
337                    for sample in samples {
338                        ffi_samples.push(FFITrajectorySample {
339                            distance: sample.distance_m,
340                            time: sample.time_s,
341                            velocity_mps: sample.velocity_mps,
342                            energy_joules: sample.energy_j,
343                            drop_meters: sample.drop_m,
344                            windage_meters: sample.wind_drift_m,
345                            mach: if sample_speed_of_sound > 0.0 {
346                                sample.velocity_mps / sample_speed_of_sound
347                            } else {
348                                0.0
349                            },
350                            spin_rate_rps: if twist_rate_in > 0.0 {
351                                sample.velocity_mps / (twist_rate_in * 0.0254)
352                            } else {
353                                0.0
354                            },
355                        });
356                    }
357                    let count = ffi_samples.len() as c_int;
358                    let samples_ptr = ffi_samples.as_mut_ptr();
359                    std::mem::forget(ffi_samples);
360                    (samples_ptr, count)
361                } else {
362                    (ptr::null_mut(), 0)
363                };
364
365            // Extract angular state values if available
366            let (final_pitch, final_yaw, max_yaw, max_prec) =
367                if let Some(ref angular) = result.angular_state {
368                    (
369                        angular.pitch_angle,
370                        angular.yaw_angle,
371                        result.max_yaw_angle.unwrap_or(f64::NAN),
372                        result.max_precession_angle.unwrap_or(f64::NAN),
373                    )
374                } else {
375                    (f64::NAN, f64::NAN, f64::NAN, f64::NAN)
376                };
377
378            // Create result on heap
379            let ffi_result = Box::new(FFITrajectoryResult {
380                max_range: result.max_range,
381                max_height: result.max_height,
382                time_of_flight: result.time_of_flight,
383                impact_velocity: result.impact_velocity,
384                impact_energy: result.impact_energy,
385                points,
386                point_count: point_count as c_int,
387                sampled_points,
388                sampled_point_count,
389                min_pitch_damping: result.min_pitch_damping.unwrap_or(f64::NAN),
390                transonic_mach: result.transonic_mach.unwrap_or(f64::NAN),
391                final_pitch_angle: final_pitch,
392                final_yaw_angle: final_yaw,
393                max_yaw_angle: max_yaw,
394                max_precession_angle: max_prec,
395            });
396
397            Box::into_raw(ffi_result)
398        }
399        Err(_) => ptr::null_mut(),
400    }
401}
402
403/// Calculate a trajectory through the C ABI.
404///
405/// `step_size` is expressed in milliseconds and must be finite and at least
406/// [`MIN_FFI_STEP_SIZE_MS`]. This boundary contract is validated for every solver mode, although
407/// adaptive RK45 chooses its integration steps internally. Invalid values return null without
408/// starting a solve. A solve that would exceed [`crate::MAX_TRAJECTORY_POINTS`] also returns null;
409/// an enabled sampling grid above [`crate::MAX_TRAJECTORY_SAMPLES`] does likewise. Callers can
410/// increase `step_size`, reduce `max_range`, or select adaptive RK45.
411///
412/// # Safety
413///
414/// `inputs` may be null, in which case this function returns null. When non-null,
415/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
416/// readable and is not mutated for the duration of this call. `wind` and
417/// `atmosphere` may also be null; each non-null pointer has the same requirements
418/// for its corresponding type.
419/// The returned pointer, when non-null, must be released exactly once with
420/// [`ballistics_free_trajectory_result`].
421#[no_mangle]
422pub unsafe extern "C" fn ballistics_calculate_trajectory(
423    inputs: *const FFIBallisticInputs,
424    wind: *const FFIWindConditions,
425    atmosphere: *const FFIAtmosphericConditions,
426    max_range: c_double,
427    step_size: c_double,
428) -> *mut FFITrajectoryResult {
429    unsafe { calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, None) }
430}
431
432/// [`ballistics_calculate_trajectory`] with a caller-supplied custom drag deck
433/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
434/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
435/// denominator becomes the projectile's sectional density (mass and diameter in
436/// `inputs` must therefore be positive). Mach values must be strictly ascending
437/// with at least 2 points and finite positive Cd; outside the deck's Mach domain
438/// the nearest endpoint Cd is held.
439///
440/// Returns null for an invalid deck (null arrays, `drag_table_len < 2`, or failed
441/// validation), in addition to every failure mode of the base function.
442///
443/// # Safety
444///
445/// Same contract as [`ballistics_calculate_trajectory`] for `inputs`, `wind`, and
446/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
447/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
448/// of this call (the deck is copied; the caller retains ownership — no new free
449/// function is required). The returned pointer, when non-null, must be released
450/// exactly once with [`ballistics_free_trajectory_result`].
451#[no_mangle]
452pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table(
453    inputs: *const FFIBallisticInputs,
454    wind: *const FFIWindConditions,
455    atmosphere: *const FFIAtmosphericConditions,
456    max_range: c_double,
457    step_size: c_double,
458    drag_mach: *const c_double,
459    drag_cd: *const c_double,
460    drag_table_len: c_int,
461) -> *mut FFITrajectoryResult {
462    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
463        Ok(t) => t,
464        Err(()) => return ptr::null_mut(),
465    };
466    unsafe {
467        calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, Some(table))
468    }
469}
470
471/// Release a trajectory result allocated by [`ballistics_calculate_trajectory`].
472///
473/// # Safety
474///
475/// `result` must be null or a pointer returned by
476/// [`ballistics_calculate_trajectory`] that has not already been freed. Its
477/// embedded pointers and counts must be unchanged from the returned values.
478/// After this call, the result and its point arrays must not be accessed again.
479#[no_mangle]
480pub unsafe extern "C" fn ballistics_free_trajectory_result(result: *mut FFITrajectoryResult) {
481    if !result.is_null() {
482        unsafe {
483            let result = Box::from_raw(result);
484            if !result.points.is_null() && result.point_count > 0 {
485                let points = Vec::from_raw_parts(
486                    result.points,
487                    result.point_count as usize,
488                    result.point_count as usize,
489                );
490                drop(points);
491            }
492            if !result.sampled_points.is_null() && result.sampled_point_count > 0 {
493                let samples = Vec::from_raw_parts(
494                    result.sampled_points,
495                    result.sampled_point_count as usize,
496                    result.sampled_point_count as usize,
497                );
498                drop(samples);
499            }
500            drop(result);
501        }
502    }
503}
504
505/// Shared implementation for the zero-angle exports. `custom_drag_table`, when
506/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
507/// density — see `BallisticInputs::custom_drag_denominator`), matching the deck
508/// semantics of [`calculate_trajectory_impl`] so a zero solved with a deck and a
509/// trajectory flown with the same deck agree.
510unsafe fn calculate_zero_angle_impl(
511    inputs: *const FFIBallisticInputs,
512    wind: *const FFIWindConditions,
513    atmosphere: *const FFIAtmosphericConditions,
514    zero_distance: c_double,
515    custom_drag_table: Option<crate::drag::DragTable>,
516) -> c_double {
517    if inputs.is_null() {
518        return f64::NAN;
519    }
520
521    let inputs = unsafe { &*inputs };
522    let mut ballistic_inputs = convert_inputs(inputs);
523    ballistic_inputs.custom_drag_table = custom_drag_table;
524
525    let wind_conditions = if wind.is_null() {
526        WindConditions::default()
527    } else {
528        let wind = unsafe { &*wind };
529        WindConditions {
530            speed: wind.speed,
531            direction: wind.direction,
532            vertical_speed: wind.vertical_speed,
533        }
534    };
535
536    let atmospheric_conditions = if atmosphere.is_null() {
537        AtmosphericConditions::default()
538    } else {
539        let atmo = unsafe { &*atmosphere };
540        AtmosphericConditions {
541            temperature: atmo.temperature,
542            pressure: atmo.pressure,
543            humidity: atmo.humidity,
544            altitude: atmo.altitude,
545        }
546    };
547
548    // For zero angle, we want the bullet to hit at sight height at the zero distance
549    // This means the bullet crosses the line of sight at the zero distance
550    let target_height = ballistic_inputs.sight_height;
551
552    match calculate_zero_angle_with_conditions(
553        ballistic_inputs,
554        zero_distance,
555        target_height,
556        wind_conditions,
557        atmospheric_conditions,
558    ) {
559        Ok(angle) => angle,
560        Err(_) => f64::NAN,
561    }
562}
563
564/// Calculate the zero angle for a target distance through the C ABI.
565///
566/// # Safety
567///
568/// `inputs` may be null, in which case this function returns NaN. When non-null,
569/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
570/// readable and is not mutated for the duration of this call. `wind` and
571/// `atmosphere` may also be null; each non-null pointer has the same requirements
572/// for its corresponding type.
573#[no_mangle]
574pub unsafe extern "C" fn ballistics_calculate_zero_angle(
575    inputs: *const FFIBallisticInputs,
576    wind: *const FFIWindConditions,
577    atmosphere: *const FFIAtmosphericConditions,
578    zero_distance: c_double,
579) -> c_double {
580    unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, None) }
581}
582
583/// [`ballistics_calculate_zero_angle`] with a caller-supplied custom drag deck
584/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
585/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
586/// denominator becomes the projectile's sectional density (mass and diameter in
587/// `inputs` must therefore be positive). Mach values must be strictly ascending
588/// with at least 2 points and finite positive Cd; outside the deck's Mach domain
589/// the nearest endpoint Cd is held. Pair this with
590/// [`ballistics_calculate_trajectory_with_drag_table`] using the same deck to fly
591/// the solved angle — the two exports share identical deck semantics.
592///
593/// Returns NaN for an invalid deck (null arrays, `drag_table_len < 2`, or failed
594/// validation), in addition to every failure mode of the base function.
595///
596/// # Safety
597///
598/// Same contract as [`ballistics_calculate_zero_angle`] for `inputs`, `wind`, and
599/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
600/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
601/// of this call (the deck is copied; the caller retains ownership — no new free
602/// function is required).
603#[no_mangle]
604pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table(
605    inputs: *const FFIBallisticInputs,
606    wind: *const FFIWindConditions,
607    atmosphere: *const FFIAtmosphericConditions,
608    zero_distance: c_double,
609    drag_mach: *const c_double,
610    drag_cd: *const c_double,
611    drag_table_len: c_int,
612) -> c_double {
613    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
614        Ok(t) => t,
615        Err(()) => return f64::NAN,
616    };
617    unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, Some(table)) }
618}
619
620// Simple trajectory calculation for quick results
621#[no_mangle]
622#[allow(clippy::field_reassign_with_default)] // Preserve the staged zero-angle workflow below.
623pub extern "C" fn ballistics_quick_trajectory(
624    muzzle_velocity: c_double,
625    bc: c_double,
626    sight_height: c_double,
627    zero_distance: c_double,
628    target_distance: c_double,
629) -> c_double {
630    // This provides a simple drop calculation at target distance
631    // Using simplified ballistic calculations
632
633    let mut inputs = BallisticInputs::default();
634    inputs.muzzle_velocity = muzzle_velocity;
635    inputs.bc_value = bc;
636    inputs.sight_height = sight_height;
637    inputs.target_distance = target_distance;
638
639    let wind = WindConditions::default();
640    let atmo = AtmosphericConditions::default();
641
642    // First calculate zero angle
643    let zero_angle = match calculate_zero_angle_with_conditions(
644        inputs.clone(),
645        zero_distance,
646        sight_height,
647        wind.clone(),
648        atmo.clone(),
649    ) {
650        Ok(angle) => angle,
651        Err(_) => return f64::NAN,
652    };
653
654    // Now calculate trajectory with that zero angle
655    inputs.muzzle_angle = zero_angle;
656
657    let mut solver = TrajectorySolver::new(inputs, wind, atmo);
658    solver.set_max_range(target_distance * 1.1);
659
660    match solver.solve() {
661        Ok(result) => {
662            // Find the drop at target distance
663            for point in result.points {
664                if point.position[0] >= target_distance {
665                    return sight_height - point.position[1];
666                }
667            }
668            f64::NAN
669        }
670        Err(_) => f64::NAN,
671    }
672}
673
674/// Run a Monte Carlo simulation through the C ABI.
675///
676/// # Safety
677///
678/// `inputs` and `params` may be null, in which case this function returns null.
679/// Each non-null pointer must point to a valid, properly aligned value of its
680/// corresponding FFI type that remains readable and is not mutated for the
681/// duration of this call. `atmosphere` may be null; a non-null pointer has the
682/// same requirements for [`FFIAtmosphericConditions`]. The returned pointer,
683/// when non-null, must be released exactly once with
684/// [`ballistics_free_monte_carlo_results`].
685#[no_mangle]
686pub unsafe extern "C" fn ballistics_monte_carlo(
687    inputs: *const FFIBallisticInputs,
688    atmosphere: *const FFIAtmosphericConditions,
689    params: *const FFIMonteCarloParams,
690) -> *mut FFIMonteCarloResults {
691    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, 0.0) }
692}
693
694/// Run a Monte Carlo simulation with independent wind-direction uncertainty through the C ABI.
695///
696/// `wind_direction_std_dev` is in radians. This additive entry point keeps
697/// [`FFIMonteCarloParams`] and [`ballistics_monte_carlo`] binary-compatible; the older function
698/// delegates with zero wind-direction uncertainty.
699///
700/// # Safety
701///
702/// The pointer and ownership requirements are identical to [`ballistics_monte_carlo`].
703#[no_mangle]
704pub unsafe extern "C" fn ballistics_monte_carlo_with_direction_std_dev(
705    inputs: *const FFIBallisticInputs,
706    atmosphere: *const FFIAtmosphericConditions,
707    params: *const FFIMonteCarloParams,
708    wind_direction_std_dev: c_double,
709) -> *mut FFIMonteCarloResults {
710    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, wind_direction_std_dev) }
711}
712
713unsafe fn ballistics_monte_carlo_impl(
714    inputs: *const FFIBallisticInputs,
715    atmosphere: *const FFIAtmosphericConditions,
716    params: *const FFIMonteCarloParams,
717    wind_direction_std_dev: f64,
718) -> *mut FFIMonteCarloResults {
719    if inputs.is_null() || params.is_null() {
720        return ptr::null_mut();
721    }
722
723    let inputs = unsafe { &*inputs };
724    let params = unsafe { &*params };
725
726    // Reject an out-of-range simulation count. num_simulations is a c_int (i32) cast straight to
727    // usize: a negative value would wrap to a near-max usize, and even a large positive value (up
728    // to i32::MAX ~ 2.1e9) would drive billions of iterations with the result arrays scaling to
729    // match — an unbounded-loop / OOM DoS from a single FFI call. Bound it to a sane maximum.
730    // (n == 0 also yields NaN stats and a zero-size allocation.)
731    const MAX_SIMULATIONS: c_int = 1_000_000;
732    if params.num_simulations <= 0 || params.num_simulations > MAX_SIMULATIONS {
733        return ptr::null_mut();
734    }
735
736    // Convert FFI inputs to internal types
737    let mut ballistic_inputs = convert_inputs(inputs);
738    ballistic_inputs.muzzle_height = 1.5;
739    ballistic_inputs.ground_threshold = 0.0;
740    if !atmosphere.is_null() {
741        let atmo = unsafe { &*atmosphere };
742        ballistic_inputs.temperature = atmo.temperature;
743        ballistic_inputs.pressure = atmo.pressure;
744        ballistic_inputs.humidity = (atmo.humidity / 100.0).clamp(0.0, 1.0);
745        ballistic_inputs.altitude = atmo.altitude;
746    }
747
748    // Create Monte Carlo parameters
749    let mc_params = MonteCarloParams {
750        num_simulations: params.num_simulations as usize,
751        velocity_std_dev: params.velocity_std_dev,
752        angle_std_dev: params.angle_std_dev,
753        bc_std_dev: params.bc_std_dev,
754        wind_speed_std_dev: params.wind_speed_std_dev,
755        target_distance: if params.target_distance.is_nan() {
756            None
757        } else {
758            Some(params.target_distance)
759        },
760        base_wind_speed: params.base_wind_speed,
761        base_wind_direction: params.base_wind_direction,
762        azimuth_std_dev: params.azimuth_std_dev,
763    };
764
765    // Run Monte Carlo simulation
766    match run_monte_carlo_with_direction_std_dev(
767        ballistic_inputs,
768        mc_params,
769        wind_direction_std_dev,
770    ) {
771        Ok(results) => {
772            let num_results = results.ranges.len() as c_int;
773
774            // Calculate statistics
775            let mean_range: f64 = results.ranges.iter().sum::<f64>() / num_results as f64;
776            let variance_range: f64 = results
777                .ranges
778                .iter()
779                .map(|r| (r - mean_range).powi(2))
780                .sum::<f64>()
781                / num_results as f64;
782            let std_dev_range = variance_range.sqrt();
783
784            let mean_velocity: f64 =
785                results.impact_velocities.iter().sum::<f64>() / num_results as f64;
786            let variance_velocity: f64 = results
787                .impact_velocities
788                .iter()
789                .map(|v| (v - mean_velocity).powi(2))
790                .sum::<f64>()
791                / num_results as f64;
792            let std_dev_velocity = variance_velocity.sqrt();
793
794            // Calculate hit probability if target distance was specified. MBA-971: use the shared
795            // position-based criterion (fraction within DEFAULT_HIT_RADIUS_M of the point of aim
796            // at the target plane). The old inline version had a redundant `distance < target`
797            // clause comparing a ~meter deviation to the ~hundreds-of-meters target distance
798            // (effectively always true), and the CLI used a different range-based notion entirely.
799            let hit_probability = if params.target_distance.is_nan() {
800                0.0
801            } else {
802                results.hit_probability(crate::DEFAULT_HIT_RADIUS_M)
803            };
804
805            // Allocate memory for arrays
806            let ranges_ptr = unsafe {
807                let ptr = std::alloc::alloc(
808                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
809                ) as *mut c_double;
810                for (i, &range) in results.ranges.iter().enumerate() {
811                    *ptr.add(i) = range;
812                }
813                ptr
814            };
815
816            let velocities_ptr = unsafe {
817                let ptr = std::alloc::alloc(
818                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
819                ) as *mut c_double;
820                for (i, &vel) in results.impact_velocities.iter().enumerate() {
821                    *ptr.add(i) = vel;
822                }
823                ptr
824            };
825
826            let pos_x_ptr = unsafe {
827                let ptr = std::alloc::alloc(
828                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
829                ) as *mut c_double;
830                for (i, pos) in results.impact_positions.iter().enumerate() {
831                    *ptr.add(i) = pos.x;
832                }
833                ptr
834            };
835
836            let pos_y_ptr = unsafe {
837                let ptr = std::alloc::alloc(
838                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
839                ) as *mut c_double;
840                for (i, pos) in results.impact_positions.iter().enumerate() {
841                    *ptr.add(i) = pos.y;
842                }
843                ptr
844            };
845
846            let pos_z_ptr = unsafe {
847                let ptr = std::alloc::alloc(
848                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
849                ) as *mut c_double;
850                for (i, pos) in results.impact_positions.iter().enumerate() {
851                    *ptr.add(i) = pos.z;
852                }
853                ptr
854            };
855
856            // Create result structure
857            let result = Box::new(FFIMonteCarloResults {
858                ranges: ranges_ptr,
859                impact_velocities: velocities_ptr,
860                impact_positions_x: pos_x_ptr,
861                impact_positions_y: pos_y_ptr,
862                impact_positions_z: pos_z_ptr,
863                num_results,
864                mean_range,
865                std_dev_range,
866                mean_impact_velocity: mean_velocity,
867                std_dev_impact_velocity: std_dev_velocity,
868                hit_probability,
869            });
870
871            Box::into_raw(result)
872        }
873        Err(_) => ptr::null_mut(),
874    }
875}
876
877/// Release Monte Carlo results allocated by either Monte Carlo C entry point.
878///
879/// # Safety
880///
881/// `results` must be null or a pointer returned by [`ballistics_monte_carlo`] or
882/// [`ballistics_monte_carlo_with_direction_std_dev`] that has not already been freed. Its
883/// embedded pointers and result count must be unchanged from the returned values. After this
884/// call, the result and all of its arrays must not be accessed again.
885#[no_mangle]
886pub unsafe extern "C" fn ballistics_free_monte_carlo_results(results: *mut FFIMonteCarloResults) {
887    if results.is_null() {
888        return;
889    }
890
891    unsafe {
892        let results = Box::from_raw(results);
893        let num = results.num_results as usize;
894
895        // Free arrays
896        if !results.ranges.is_null() {
897            std::alloc::dealloc(
898                results.ranges as *mut u8,
899                std::alloc::Layout::array::<c_double>(num).unwrap(),
900            );
901        }
902
903        if !results.impact_velocities.is_null() {
904            std::alloc::dealloc(
905                results.impact_velocities as *mut u8,
906                std::alloc::Layout::array::<c_double>(num).unwrap(),
907            );
908        }
909
910        if !results.impact_positions_x.is_null() {
911            std::alloc::dealloc(
912                results.impact_positions_x as *mut u8,
913                std::alloc::Layout::array::<c_double>(num).unwrap(),
914            );
915        }
916
917        if !results.impact_positions_y.is_null() {
918            std::alloc::dealloc(
919                results.impact_positions_y as *mut u8,
920                std::alloc::Layout::array::<c_double>(num).unwrap(),
921            );
922        }
923
924        if !results.impact_positions_z.is_null() {
925            std::alloc::dealloc(
926                results.impact_positions_z as *mut u8,
927                std::alloc::Layout::array::<c_double>(num).unwrap(),
928            );
929        }
930
931        // Box automatically deallocates the result structure
932    }
933}
934
935// Get library version
936#[no_mangle]
937pub extern "C" fn ballistics_get_version() -> *const c_char {
938    // Return a pointer to a static NUL-terminated string (the caller must NOT free it).
939    // Previously this leaked a freshly-allocated CString on every call and reported a
940    // stale hardcoded "0.3.0"; use the real crate version with no allocation.
941    concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    fn valid_trajectory_inputs() -> FFIBallisticInputs {
949        FFIBallisticInputs {
950            muzzle_velocity: 800.0,
951            muzzle_angle: 0.0,
952            bc_value: 0.5,
953            bullet_mass: 0.01,
954            bullet_diameter: 0.00762,
955            bc_type: 0,
956            sight_height: 0.05,
957            target_distance: 1.0,
958            temperature: 15.0,
959            twist_rate: 12.0,
960            is_twist_right: 1,
961            shooting_angle: 0.0,
962            altitude: 0.0,
963            latitude: f64::NAN,
964            azimuth_angle: 0.0,
965            use_rk4: 1,
966            use_adaptive_rk45: 0,
967            enable_wind_shear: 0,
968            enable_trajectory_sampling: 0,
969            sample_interval: 10.0,
970            enable_pitch_damping: 0,
971            enable_precession_nutation: 0,
972            enable_spin_drift: 0,
973            enable_magnus: 0,
974            enable_coriolis: 0,
975            shot_azimuth: 0.0,
976            cant_angle: 0.0,
977        }
978    }
979
980    #[allow(dead_code)]
981    #[repr(C)]
982    struct LegacyFFIMonteCarloParams {
983        num_simulations: c_int,
984        velocity_std_dev: c_double,
985        angle_std_dev: c_double,
986        bc_std_dev: c_double,
987        wind_speed_std_dev: c_double,
988        target_distance: c_double,
989        base_wind_speed: c_double,
990        base_wind_direction: c_double,
991        azimuth_std_dev: c_double,
992    }
993
994    #[test]
995    fn monte_carlo_params_legacy_abi_size_is_unchanged() {
996        assert_eq!(
997            std::mem::size_of::<FFIMonteCarloParams>(),
998            std::mem::size_of::<LegacyFFIMonteCarloParams>()
999        );
1000        assert_eq!(
1001            std::mem::align_of::<FFIMonteCarloParams>(),
1002            std::mem::align_of::<LegacyFFIMonteCarloParams>()
1003        );
1004    }
1005
1006    #[test]
1007    fn null_pointer_contracts_return_sentinels_and_free_safely() {
1008        unsafe {
1009            assert!(ballistics_calculate_trajectory(
1010                std::ptr::null(),
1011                std::ptr::null(),
1012                std::ptr::null(),
1013                1_000.0,
1014                1.0,
1015            )
1016            .is_null());
1017            assert!(ballistics_calculate_zero_angle(
1018                std::ptr::null(),
1019                std::ptr::null(),
1020                std::ptr::null(),
1021                100.0,
1022            )
1023            .is_nan());
1024            assert!(ballistics_calculate_trajectory_with_drag_table(
1025                std::ptr::null(),
1026                std::ptr::null(),
1027                std::ptr::null(),
1028                1_000.0,
1029                1.0,
1030                DECK_MACH.as_ptr(),
1031                DECK_CD_LOW.as_ptr(),
1032                DECK_MACH.len() as c_int,
1033            )
1034            .is_null());
1035            assert!(ballistics_calculate_zero_angle_with_drag_table(
1036                std::ptr::null(),
1037                std::ptr::null(),
1038                std::ptr::null(),
1039                100.0,
1040                DECK_MACH.as_ptr(),
1041                DECK_CD_LOW.as_ptr(),
1042                DECK_MACH.len() as c_int,
1043            )
1044            .is_nan());
1045            assert!(
1046                ballistics_monte_carlo(std::ptr::null(), std::ptr::null(), std::ptr::null(),)
1047                    .is_null()
1048            );
1049            assert!(ballistics_monte_carlo_with_direction_std_dev(
1050                std::ptr::null(),
1051                std::ptr::null(),
1052                std::ptr::null(),
1053                0.1,
1054            )
1055            .is_null());
1056
1057            ballistics_free_trajectory_result(std::ptr::null_mut());
1058            ballistics_free_monte_carlo_results(std::ptr::null_mut());
1059        }
1060    }
1061
1062    #[test]
1063    fn mba1283_ffi_enforces_step_floor_for_every_solver_mode() {
1064        for (mode, use_rk4, use_adaptive_rk45) in [("Euler", 0, 0), ("RK4", 1, 0), ("RK45", 1, 1)] {
1065            for step_size in [
1066                f64::NAN,
1067                f64::INFINITY,
1068                f64::NEG_INFINITY,
1069                -1.0,
1070                -0.0,
1071                0.0,
1072                0.001,
1073                MIN_FFI_STEP_SIZE_MS - 0.001,
1074            ] {
1075                let mut inputs = valid_trajectory_inputs();
1076                inputs.use_rk4 = use_rk4;
1077                inputs.use_adaptive_rk45 = use_adaptive_rk45;
1078                let result = unsafe {
1079                    ballistics_calculate_trajectory(
1080                        &inputs,
1081                        std::ptr::null(),
1082                        std::ptr::null(),
1083                        0.01,
1084                        step_size,
1085                    )
1086                };
1087                assert!(
1088                    result.is_null(),
1089                    "{mode} step_size={step_size:?} bypassed the FFI floor"
1090                );
1091            }
1092
1093            let mut inputs = valid_trajectory_inputs();
1094            inputs.use_rk4 = use_rk4;
1095            inputs.use_adaptive_rk45 = use_adaptive_rk45;
1096            let result = unsafe {
1097                ballistics_calculate_trajectory(
1098                    &inputs,
1099                    std::ptr::null(),
1100                    std::ptr::null(),
1101                    0.01,
1102                    MIN_FFI_STEP_SIZE_MS,
1103                )
1104            };
1105            assert!(
1106                !result.is_null(),
1107                "the documented minimum step must remain usable in {mode}"
1108            );
1109            unsafe {
1110                assert!((*result).point_count >= 0);
1111                assert!((*result).point_count as usize <= crate::MAX_TRAJECTORY_POINTS);
1112                ballistics_free_trajectory_result(result);
1113            }
1114        }
1115    }
1116
1117    /// A tiny valid deck: strictly ascending Mach, positive Cd.
1118    const DECK_MACH: [f64; 4] = [0.5, 1.0, 2.0, 3.0];
1119    /// Deliberately LOW drag so the deck measurably increases impact velocity vs G1.
1120    const DECK_CD_LOW: [f64; 4] = [0.05, 0.08, 0.06, 0.05];
1121
1122    #[test]
1123    fn trajectory_with_drag_table_applies_the_deck() {
1124        let inputs = valid_trajectory_inputs();
1125        unsafe {
1126            let plain = ballistics_calculate_trajectory(
1127                &inputs,
1128                std::ptr::null(),
1129                std::ptr::null(),
1130                300.0,
1131                1.0,
1132            );
1133            let decked = ballistics_calculate_trajectory_with_drag_table(
1134                &inputs,
1135                std::ptr::null(),
1136                std::ptr::null(),
1137                300.0,
1138                1.0,
1139                DECK_MACH.as_ptr(),
1140                DECK_CD_LOW.as_ptr(),
1141                DECK_MACH.len() as c_int,
1142            );
1143            assert!(!plain.is_null() && !decked.is_null());
1144            // The low-drag deck must retain materially more velocity than the G-model.
1145            assert!(
1146                (*decked).impact_velocity > (*plain).impact_velocity + 1.0,
1147                "deck did not change the solve: plain={} decked={}",
1148                (*plain).impact_velocity,
1149                (*decked).impact_velocity
1150            );
1151            ballistics_free_trajectory_result(plain);
1152            ballistics_free_trajectory_result(decked);
1153        }
1154    }
1155
1156    #[test]
1157    fn trajectory_with_drag_table_rejects_invalid_decks() {
1158        let inputs = valid_trajectory_inputs();
1159        let descending = [3.0, 2.0, 1.0, 0.5];
1160        let negative_cd = [0.05, -0.08, 0.06, 0.05];
1161        unsafe {
1162            // null arrays
1163            assert!(ballistics_calculate_trajectory_with_drag_table(
1164                &inputs,
1165                std::ptr::null(),
1166                std::ptr::null(),
1167                300.0,
1168                1.0,
1169                std::ptr::null(),
1170                DECK_CD_LOW.as_ptr(),
1171                4,
1172            )
1173            .is_null());
1174            assert!(ballistics_calculate_trajectory_with_drag_table(
1175                &inputs,
1176                std::ptr::null(),
1177                std::ptr::null(),
1178                300.0,
1179                1.0,
1180                DECK_MACH.as_ptr(),
1181                std::ptr::null(),
1182                4,
1183            )
1184            .is_null());
1185            // too few points
1186            assert!(ballistics_calculate_trajectory_with_drag_table(
1187                &inputs,
1188                std::ptr::null(),
1189                std::ptr::null(),
1190                300.0,
1191                1.0,
1192                DECK_MACH.as_ptr(),
1193                DECK_CD_LOW.as_ptr(),
1194                1,
1195            )
1196            .is_null());
1197            // non-ascending Mach
1198            assert!(ballistics_calculate_trajectory_with_drag_table(
1199                &inputs,
1200                std::ptr::null(),
1201                std::ptr::null(),
1202                300.0,
1203                1.0,
1204                descending.as_ptr(),
1205                DECK_CD_LOW.as_ptr(),
1206                4,
1207            )
1208            .is_null());
1209            // non-positive Cd
1210            assert!(ballistics_calculate_trajectory_with_drag_table(
1211                &inputs,
1212                std::ptr::null(),
1213                std::ptr::null(),
1214                300.0,
1215                1.0,
1216                DECK_MACH.as_ptr(),
1217                negative_cd.as_ptr(),
1218                4,
1219            )
1220            .is_null());
1221            // null inputs still rejected
1222            assert!(ballistics_calculate_trajectory_with_drag_table(
1223                std::ptr::null(),
1224                std::ptr::null(),
1225                std::ptr::null(),
1226                300.0,
1227                1.0,
1228                DECK_MACH.as_ptr(),
1229                DECK_CD_LOW.as_ptr(),
1230                4,
1231            )
1232            .is_null());
1233        }
1234    }
1235
1236    #[test]
1237    fn zero_angle_with_drag_table_applies_the_deck() {
1238        // A realistic zeroing setup: 100 m zero.
1239        let inputs = valid_trajectory_inputs();
1240        unsafe {
1241            let plain =
1242                ballistics_calculate_zero_angle(&inputs, std::ptr::null(), std::ptr::null(), 100.0);
1243            let decked = ballistics_calculate_zero_angle_with_drag_table(
1244                &inputs,
1245                std::ptr::null(),
1246                std::ptr::null(),
1247                100.0,
1248                DECK_MACH.as_ptr(),
1249                DECK_CD_LOW.as_ptr(),
1250                DECK_MACH.len() as c_int,
1251            );
1252            assert!(plain.is_finite() && decked.is_finite());
1253            // A much lower-drag deck needs a flatter (smaller) zero angle; at minimum it
1254            // must differ measurably from the G-model zero.
1255            assert!(
1256                (plain - decked).abs() > 1e-6,
1257                "deck did not change the zero: plain={plain} decked={decked}"
1258            );
1259        }
1260    }
1261
1262    #[test]
1263    fn zero_angle_with_drag_table_rejects_invalid_decks() {
1264        let inputs = valid_trajectory_inputs();
1265        let descending = [3.0, 2.0, 1.0, 0.5];
1266        unsafe {
1267            assert!(ballistics_calculate_zero_angle_with_drag_table(
1268                &inputs,
1269                std::ptr::null(),
1270                std::ptr::null(),
1271                100.0,
1272                std::ptr::null(),
1273                DECK_CD_LOW.as_ptr(),
1274                4,
1275            )
1276            .is_nan());
1277            assert!(ballistics_calculate_zero_angle_with_drag_table(
1278                &inputs,
1279                std::ptr::null(),
1280                std::ptr::null(),
1281                100.0,
1282                DECK_MACH.as_ptr(),
1283                DECK_CD_LOW.as_ptr(),
1284                0,
1285            )
1286            .is_nan());
1287            assert!(ballistics_calculate_zero_angle_with_drag_table(
1288                &inputs,
1289                std::ptr::null(),
1290                std::ptr::null(),
1291                100.0,
1292                descending.as_ptr(),
1293                DECK_CD_LOW.as_ptr(),
1294                4,
1295            )
1296            .is_nan());
1297            // null inputs still rejected
1298            assert!(ballistics_calculate_zero_angle_with_drag_table(
1299                std::ptr::null(),
1300                std::ptr::null(),
1301                std::ptr::null(),
1302                100.0,
1303                DECK_MACH.as_ptr(),
1304                DECK_CD_LOW.as_ptr(),
1305                4,
1306            )
1307            .is_nan());
1308        }
1309    }
1310
1311    #[test]
1312    fn zero_then_fly_with_same_deck_is_consistent() {
1313        // The pair-use case the two exports exist for: zero with the deck, fly with the
1314        // deck at the solved angle; the trajectory must cross near sight height at the
1315        // zero distance (i.e. the two functions share identical deck semantics).
1316        let mut inputs = valid_trajectory_inputs();
1317        unsafe {
1318            let angle = ballistics_calculate_zero_angle_with_drag_table(
1319                &inputs,
1320                std::ptr::null(),
1321                std::ptr::null(),
1322                100.0,
1323                DECK_MACH.as_ptr(),
1324                DECK_CD_LOW.as_ptr(),
1325                DECK_MACH.len() as c_int,
1326            );
1327            assert!(angle.is_finite());
1328            inputs.muzzle_angle = angle;
1329            let result = ballistics_calculate_trajectory_with_drag_table(
1330                &inputs,
1331                std::ptr::null(),
1332                std::ptr::null(),
1333                150.0,
1334                1.0,
1335                DECK_MACH.as_ptr(),
1336                DECK_CD_LOW.as_ptr(),
1337                DECK_MACH.len() as c_int,
1338            );
1339            assert!(!result.is_null());
1340            // Interpolate y at exactly the zero distance (100 m) rather than snapping to the
1341            // nearest raw trajectory sample, so the residual reflects only zero-solver
1342            // convergence, not the sampling grid's x-offset from 100 m.
1343            let zero_distance = 100.0;
1344            let pts = std::slice::from_raw_parts((*result).points, (*result).point_count as usize);
1345            let bracket = pts
1346                .windows(2)
1347                .find(|w| w[0].position_x <= zero_distance && w[1].position_x >= zero_distance)
1348                .expect("trajectory brackets the zero distance");
1349            let (lo, hi) = (&bracket[0], &bracket[1]);
1350            let y_at_zero = if hi.position_x > lo.position_x {
1351                let t = (zero_distance - lo.position_x) / (hi.position_x - lo.position_x);
1352                lo.position_y + t * (hi.position_y - lo.position_y)
1353            } else {
1354                lo.position_y
1355            };
1356            assert!(
1357                (y_at_zero - inputs.sight_height).abs() < 0.002,
1358                "zeroed flight missed the line of sight at 100 m: y={} (sight_height={})",
1359                y_at_zero,
1360                inputs.sight_height
1361            );
1362            ballistics_free_trajectory_result(result);
1363        }
1364    }
1365
1366    #[test]
1367    fn ffi_cant_angle_deflects_laterally() {
1368        let mut level = valid_trajectory_inputs();
1369        level.muzzle_angle = 0.003;
1370        let mut canted = valid_trajectory_inputs();
1371        canted.muzzle_angle = 0.003;
1372        canted.cant_angle = 10f64.to_radians();
1373        unsafe {
1374            let a = ballistics_calculate_trajectory(&level, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1375            let b = ballistics_calculate_trajectory(&canted, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1376            assert!(!a.is_null() && !b.is_null());
1377            let za = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_z;
1378            let zb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_z;
1379            assert!(zb > za + 0.005, "FFI cant must deflect right: level={za} canted={zb}");
1380            ballistics_free_trajectory_result(a);
1381            ballistics_free_trajectory_result(b);
1382        }
1383    }
1384
1385    #[test]
1386    fn ffi_vertical_wind_raises_trajectory() {
1387        let inputs = valid_trajectory_inputs();
1388        let no_wind = FFIWindConditions {
1389            speed: 0.0,
1390            direction: 0.0,
1391            vertical_speed: 0.0,
1392        };
1393        let updraft = FFIWindConditions {
1394            speed: 0.0,
1395            direction: 0.0,
1396            vertical_speed: 5.0,
1397        };
1398        unsafe {
1399            let a = ballistics_calculate_trajectory(&inputs, &no_wind, std::ptr::null(), 400.0, 1.0);
1400            let b = ballistics_calculate_trajectory(&inputs, &updraft, std::ptr::null(), 400.0, 1.0);
1401            assert!(!a.is_null() && !b.is_null());
1402            let ya = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_y;
1403            let yb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_y;
1404            assert!(yb > ya + 0.01, "FFI updraft must raise the trajectory: no_wind={ya} updraft={yb}");
1405            ballistics_free_trajectory_result(a);
1406            ballistics_free_trajectory_result(b);
1407        }
1408    }
1409}