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