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