Skip to main content

ballistics_engine/
ffi.rs

1//! FFI bindings for iOS/Swift integration
2
3use crate::{
4    calculate_zero_angle_with_conditions, run_monte_carlo_with_direction_std_dev,
5    AtmosphericConditions, BallisticInputs, DragModel, MonteCarloParams, TrajectorySolver,
6    WindConditions,
7};
8use std::os::raw::{c_char, c_double, c_int};
9use std::ptr;
10
11/// Minimum C-ABI trajectory `step_size`, in milliseconds (0.1 ms = 0.0001 s).
12///
13/// Smaller steps are rejected before integration because fixed-step solves retain one public
14/// trajectory point per step. All in-repository FFI examples use values in `[0.1, 1.0]` ms.
15pub const MIN_FFI_STEP_SIZE_MS: c_double = 0.1;
16
17/// Maximum C-ABI custom drag-table row count.
18///
19/// `drag_table_from_raw` copies both caller arrays before validation; an unbounded
20/// `len` lets a single call request two multi-gigabyte allocations and abort the
21/// process (MBA-1407). Real Cd decks are two to three orders of magnitude smaller
22/// (the embedded G1/G7 references are under 100 rows; doppler exports run a few
23/// hundred), so 4096 is far above any legitimate deck.
24pub const MAX_FFI_DRAG_TABLE_LEN: c_int = 4096;
25
26// FFI-safe structures with C-compatible layouts
27
28#[repr(C)]
29pub struct FFIBallisticInputs {
30    pub muzzle_velocity: c_double,         // m/s
31    pub muzzle_angle: c_double,            // radians (launch angle)
32    pub bc_value: c_double,                // ballistic coefficient
33    pub bullet_mass: c_double,             // kg
34    pub bullet_diameter: c_double,         // meters
35    pub bc_type: c_int,                    // 0=G1, 1=G7, 2=G2, 3=G5, 4=G6, 5=G8, 6=GI, 7=GS, 8=RA4 (MBA-1386; unrecognized -> G1)
36    pub sight_height: c_double,            // meters
37    pub target_distance: c_double,         // meters
38    pub temperature: c_double,             // Celsius
39    pub twist_rate: c_double,              // inches per turn
40    pub is_twist_right: c_int,             // 0=false, 1=true
41    pub shooting_angle: c_double,          // uphill/downhill angle in radians
42    pub altitude: c_double,                // meters
43    pub latitude: c_double,                // degrees (use NAN if not provided)
44    pub azimuth_angle: c_double,           // horizontal aiming angle in radians
45    pub use_rk4: c_int,                    // 0=Euler, 1=RK4
46    pub use_adaptive_rk45: c_int,          // 0=false, 1=true (adaptive RK45)
47    pub enable_wind_shear: c_int,          // 0=false, 1=true
48    pub enable_trajectory_sampling: c_int, // 0=false, 1=true
49    pub sample_interval: c_double,         // meters
50    pub enable_pitch_damping: c_int,       // 0=false, 1=true
51    pub enable_precession_nutation: c_int, // 0=false, 1=true
52    pub enable_spin_drift: c_int,          // 0=false, 1=true
53    pub enable_magnus: c_int,              // 0=false, 1=true
54    pub enable_coriolis: c_int,            // 0=false, 1=true
55    // Appended (keeps existing field offsets): compass bearing the shot is fired
56    // along, radians, 0=North, PI/2=East. Drives the Coriolis Eötvös/drift azimuth.
57    // Distinct from azimuth_angle (the small aiming offset). 0.0 if unset.
58    pub shot_azimuth: c_double,
59    // Appended (keeps existing field offsets): rifle cant angle in RADIANS about the line
60    // of sight, positive = clockwise from the shooter. Rotates the sight-frame aim offsets
61    // and bore geometry ("zeroed level, fired canted" -> POI right and low). 0.0 if unset.
62    pub cant_angle: c_double,
63    // Appended (keeps existing field offsets): deliberate vertical POI offset AT the zero
64    // range, METERS (MBA-1359, Kestrel "zero height"): positive = the rifle is deliberately
65    // zeroed to impact HIGH by this much at the zero distance. Applied by the zero-angle
66    // solve as an angular bias on the solved angle. 0.0 if unset (identical to absent).
67    pub zero_poi_vertical: c_double,
68    // Appended (keeps existing field offsets): deliberate horizontal POI offset AT the zero
69    // range, METERS (MBA-1359, Kestrel "zero offset"): positive = impacts RIGHT by this much
70    // at the zero distance. 0.0 if unset (identical to absent).
71    pub zero_poi_horizontal: c_double,
72    // Appended (keeps existing field offsets): lateral sight-to-bore mount offset, METERS
73    // (MBA-1396, offset-mounted optics): positive = the sight sits RIGHT of the bore, so
74    // the trajectory starts that far LEFT of the line of sight. 0.0 if unset (identical to
75    // absent). NOTE for callers pairing ballistics_calculate_zero_angle with a trajectory
76    // call: the returned zero angle carries the VERTICAL zero_poi bias, but the horizontal
77    // terms are azimuth corrections a bare elevation angle cannot carry — a caller
78    // replicating an auto-zero flow must add
79    // (zero_poi_horizontal + sight_offset_lateral) / zero_distance to azimuth_angle itself.
80    pub sight_offset_lateral: c_double,
81    // NOT plumbed (deliberately): BallisticInputs.drops_reference (MBA-1403) is an
82    // OUTPUT-mode toggle for the trajectory sampler's drop column, not physics. This FFI
83    // surface exposes raw kinematic samples only (world-frame positions — no drop/dial
84    // outputs), so there is nothing for the toggle to act on; C callers wanting
85    // target-plane drops divide their own LOS drop by cos(shooting_angle).
86}
87
88#[repr(C)]
89pub struct FFIWindConditions {
90    pub speed: c_double, // m/s
91    // radians, wind-FROM convention: 0 = headwind, PI/2 = from the right,
92    // PI = tailwind, 3*PI/2 = from the left (matches WindConditions / WindSock).
93    // ALWAYS shooter-relative: the FFI deliberately has NO earth-fixed compass mode
94    // (MBA-1368 decision — bindings stay shooter-relative numeric); a caller holding a
95    // compass bearing converts it BEFORE the call as
96    // (bearing_rad - shot_azimuth_rad).rem_euclid(2*PI), which is exactly what the
97    // CLI/WASM/solve-json `--wind-ref compass` surfaces do internally.
98    pub direction: c_double,
99    // Appended (keeps existing field offsets): vertical wind m/s, positive = updraft;
100    // 0.0 if unset.
101    pub vertical_speed: c_double,
102}
103
104#[repr(C)]
105pub struct FFIAtmosphericConditions {
106    pub temperature: c_double, // Celsius
107    pub pressure: c_double,    // hPa
108    pub humidity: c_double,    // percentage (0-100)
109    pub altitude: c_double,    // meters
110}
111
112#[repr(C)]
113pub struct FFITrajectorySample {
114    pub distance: c_double,       // meters
115    pub time: c_double,           // seconds
116    pub velocity_mps: c_double,   // meters per second
117    pub energy_joules: c_double,  // joules
118    pub drop_meters: c_double,    // meters
119    pub windage_meters: c_double, // meters
120    pub mach: c_double,           // Mach number
121    pub spin_rate_rps: c_double,  // revolutions per second
122}
123
124/// One integrated trajectory point, in the launch frame.
125///
126/// **Axis convention — read this before indexing the positions.** All three are ABSOLUTE
127/// positions in METERS, measured from the muzzle (the bore exit is the origin):
128///
129/// | field | axis | sign |
130/// |-------|------|------|
131/// | `position_x` | DOWNRANGE, toward the target | increases with range; bracket on this to find a given distance |
132/// | `position_y` | VERTICAL height | `+` up, `-` down (below the muzzle) |
133/// | `position_z` | LATERAL windage | `+` right as seen by the shooter, `-` left |
134///
135/// Two traps this ordering sets for consumers:
136///
137/// - It is **not** the "Z is downrange, X is lateral" convention. Engine 0.13.x used that
138///   opposite convention, and consumers and API layers written against those releases may
139///   still document it, so code ported from one must swap X and Z. Nothing fails loudly if you
140///   do not — all three fields stay valid `double`s, so a mix-up silently plots range as
141///   windage instead of erroring. Take the convention from this struct, not from a downstream
142///   document.
143/// - `position_y` is a height, not a drop. It is positive UP and relative to the muzzle,
144///   whereas [`FFITrajectorySample::drop_meters`] is positive DOWN and relative to the line
145///   of sight. The two disagree in both origin and sign; they are not interchangeable.
146///
147/// Enforced in this file's tests: `zero_then_fly_with_same_deck_is_consistent` brackets the
148/// zero distance on `position_x`, and `ffi_cant_angle_deflects_laterally` asserts `position_z`
149/// grows rightward under cant.
150#[repr(C)]
151pub struct FFITrajectoryPoint {
152    pub time: c_double,
153    /// Downrange distance from the muzzle, meters. See the [struct docs](FFITrajectoryPoint).
154    pub position_x: c_double,
155    /// Height above the muzzle, meters, positive up (NOT a drop). See the
156    /// [struct docs](FFITrajectoryPoint).
157    pub position_y: c_double,
158    /// Lateral offset from the bore line, meters, positive right. See the
159    /// [struct docs](FFITrajectoryPoint).
160    pub position_z: c_double,
161    pub velocity_magnitude: c_double,
162    pub kinetic_energy: c_double,
163}
164
165#[repr(C)]
166pub struct FFITrajectoryResult {
167    pub max_range: c_double,
168    pub max_height: c_double,
169    pub time_of_flight: c_double,
170    pub impact_velocity: c_double,
171    pub impact_energy: c_double,
172    pub points: *mut FFITrajectoryPoint,
173    pub point_count: c_int,
174    pub sampled_points: *mut FFITrajectorySample,
175    pub sampled_point_count: c_int,
176    pub min_pitch_damping: c_double,    // NAN if not calculated
177    pub transonic_mach: c_double,       // NAN if not reached
178    pub final_pitch_angle: c_double,    // NAN if not calculated
179    pub final_yaw_angle: c_double,      // NAN if not calculated
180    pub max_yaw_angle: c_double,        // NAN if not calculated
181    pub max_precession_angle: c_double, // NAN if not calculated
182}
183
184// Monte Carlo simulation parameters
185#[repr(C)]
186pub struct FFIMonteCarloParams {
187    pub num_simulations: c_int,
188    pub velocity_std_dev: c_double,
189    pub angle_std_dev: c_double,
190    pub bc_std_dev: c_double,
191    pub wind_speed_std_dev: c_double,
192    pub target_distance: c_double,     // Use NAN if not specified
193    pub base_wind_speed: c_double,     // Base wind speed in m/s
194    pub base_wind_direction: c_double, // Base wind direction in radians
195    pub azimuth_std_dev: c_double,     // Horizontal aiming variation in radians
196}
197
198/// Monte Carlo simulation results, one entry per simulated shot.
199///
200/// Every array holds `num_results` `double`s and all of them are indexed by the same sample
201/// number.
202///
203/// **The `impact_positions_*` arrays are DEVIATIONS, not positions.** Each triple is the
204/// sample's offset from the baseline point of aim, measured in the target plane (the plane at
205/// the requested target distance), in METERS — see [`crate::MonteCarloResults::impact_positions`].
206/// A triple of zeros means "exactly on the point of aim", not "at the muzzle".
207///
208/// Axes follow [`FFITrajectoryPoint`]:
209///
210/// | field | axis | use for dispersion? |
211/// |-------|------|---------------------|
212/// | `impact_positions_z` | HORIZONTAL / windage deviation, `+` right | YES — this is the horizontal dispersion axis |
213/// | `impact_positions_y` | VERTICAL deviation, `+` up | YES — this is the vertical dispersion axis |
214/// | `impact_positions_x` | downrange, and the target plane sits at a FIXED downrange | NO — never a dispersion axis |
215///
216/// Consumers ported from engine 0.13.x (`X` lateral, `Z` downrange) commonly read
217/// `impact_positions_x` as the horizontal spread. Under this convention that reads the
218/// downrange component instead, which is not scatter, and it fails silently.
219///
220/// # Filter the shortfall sentinel before computing any statistic
221///
222/// A sample that never reached the target plane has no deviation to report, so it is encoded as
223/// `(0, TARGET_NOT_REACHED_SENTINEL_M, 0)` — the Y component is
224/// [`crate::TARGET_NOT_REACHED_SENTINEL_M`] (`-1.0e9` meters) — which keeps these arrays the same
225/// length as `ranges` and `impact_velocities`. The engine does NOT drop those entries for you.
226/// A sample is a finite arrival exactly when
227/// [`crate::MonteCarloResults::position_reached_target`] accepts it: every component finite AND
228/// the Y component not equal to the sentinel.
229///
230/// Feeding an unfiltered array into a mean or standard deviation drags the result toward
231/// `-1e9` and produces a plainly absurd group size. Exclude sentinel samples from dispersion
232/// statistics, but keep them in the denominator for hit probability — they are definite misses,
233/// which is how `hit_probability` already counts them.
234#[repr(C)]
235pub struct FFIMonteCarloResults {
236    pub ranges: *mut c_double,
237    pub impact_velocities: *mut c_double,
238    /// Downrange component of the target-plane deviation, meters. NOT a dispersion axis; see
239    /// the [struct docs](FFIMonteCarloResults).
240    pub impact_positions_x: *mut c_double,
241    /// VERTICAL deviation from the point of aim, meters, positive up — and the field carrying
242    /// the shortfall marker: [`crate::TARGET_NOT_REACHED_SENTINEL_M`] (`-1.0e9`) marks a sample
243    /// that did not reach the target plane. Exclude those from dispersion statistics but retain
244    /// them as misses for probability calculations. See the [struct docs](FFIMonteCarloResults).
245    pub impact_positions_y: *mut c_double,
246    /// HORIZONTAL / windage deviation from the point of aim, meters, positive right. This is
247    /// the horizontal dispersion axis; see the [struct docs](FFIMonteCarloResults).
248    pub impact_positions_z: *mut c_double,
249    pub num_results: c_int,
250    pub mean_range: c_double,
251    pub std_dev_range: c_double,
252    pub mean_impact_velocity: c_double,
253    pub std_dev_impact_velocity: c_double,
254    pub hit_probability: c_double, // If target_distance was specified
255}
256
257// Helper function to convert FFI inputs to internal types
258#[allow(clippy::field_reassign_with_default)] // Keep the C-to-Rust field mapping sequential/auditable.
259fn convert_inputs(inputs: &FFIBallisticInputs) -> BallisticInputs {
260    let mut ballistic_inputs = BallisticInputs::default();
261
262    ballistic_inputs.muzzle_velocity = inputs.muzzle_velocity;
263    ballistic_inputs.muzzle_angle = inputs.muzzle_angle;
264    ballistic_inputs.azimuth_angle = inputs.azimuth_angle;
265    ballistic_inputs.shot_azimuth = inputs.shot_azimuth;
266    ballistic_inputs.cant_angle = inputs.cant_angle;
267    ballistic_inputs.zero_poi_vertical_m = inputs.zero_poi_vertical;
268    ballistic_inputs.zero_poi_horizontal_m = inputs.zero_poi_horizontal;
269    ballistic_inputs.sight_offset_lateral_m = inputs.sight_offset_lateral;
270    ballistic_inputs.use_rk4 = inputs.use_rk4 != 0;
271    ballistic_inputs.use_adaptive_rk45 = inputs.use_adaptive_rk45 != 0;
272    ballistic_inputs.bc_value = inputs.bc_value;
273    ballistic_inputs.bullet_mass = inputs.bullet_mass;
274    ballistic_inputs.bullet_diameter = inputs.bullet_diameter;
275    ballistic_inputs.bc_type = match inputs.bc_type {
276        1 => DragModel::G7,
277        2 => DragModel::G2,
278        3 => DragModel::G5,
279        4 => DragModel::G6,
280        5 => DragModel::G8,
281        6 => DragModel::GI,
282        7 => DragModel::GS,
283        // MBA-1386: additive slot for the new RA4 family. Existing callers passing
284        // 0-7 are unaffected; any other/unrecognized value still falls back to G1.
285        8 => DragModel::RA4,
286        _ => DragModel::G1,
287    };
288    ballistic_inputs.sight_height = inputs.sight_height;
289    ballistic_inputs.target_distance = inputs.target_distance;
290    ballistic_inputs.temperature = inputs.temperature;
291    ballistic_inputs.twist_rate = inputs.twist_rate;
292    ballistic_inputs.is_twist_right = inputs.is_twist_right != 0;
293    ballistic_inputs.shooting_angle = inputs.shooting_angle;
294    ballistic_inputs.altitude = inputs.altitude;
295
296    if !inputs.latitude.is_nan() {
297        ballistic_inputs.latitude = Some(inputs.latitude);
298    }
299
300    // Set derived values
301    ballistic_inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
302    ballistic_inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
303    // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default). The C ABI does
304    // not carry a bullet length, so derive it from diameter + mass; fall back to 4.5-cal if mass<=0.
305    ballistic_inputs.bullet_length = {
306        let est = crate::stability::estimate_bullet_length_m(
307            ballistic_inputs.bullet_diameter,
308            ballistic_inputs.bullet_mass,
309        );
310        if est > 0.0 {
311            est
312        } else {
313            ballistic_inputs.bullet_diameter * 4.5
314        }
315    };
316
317    // New advanced physics flags
318    ballistic_inputs.enable_wind_shear = inputs.enable_wind_shear != 0;
319    ballistic_inputs.enable_trajectory_sampling = inputs.enable_trajectory_sampling != 0;
320    ballistic_inputs.sample_interval = inputs.sample_interval;
321    ballistic_inputs.enable_pitch_damping = inputs.enable_pitch_damping != 0;
322    ballistic_inputs.enable_precession_nutation = inputs.enable_precession_nutation != 0;
323    ballistic_inputs.use_enhanced_spin_drift = inputs.enable_spin_drift != 0;
324    ballistic_inputs.enable_advanced_effects =
325        inputs.enable_magnus != 0 || inputs.enable_coriolis != 0;
326    // Gate Magnus and Coriolis independently so enabling one does not enable the other.
327    ballistic_inputs.enable_magnus = inputs.enable_magnus != 0;
328    ballistic_inputs.enable_coriolis = inputs.enable_coriolis != 0;
329
330    ballistic_inputs
331}
332
333/// Build a validated [`crate::drag::DragTable`] from borrowed C arrays.
334///
335/// Both arrays must contain `len` elements, with `len` in `[2, MAX_FFI_DRAG_TABLE_LEN]`.
336/// The data is copied; the caller retains ownership. Returns `Err(())` for null
337/// pointers, `len` outside that range, or any deck that fails
338/// [`crate::drag::DragTable::try_new`] validation (non-ascending Mach, non-positive
339/// or non-finite Cd). No error detail crosses the ABI, matching the null/NAN
340/// error convention of this module.
341///
342/// # Safety
343///
344/// When non-null, `mach` and `cd` must each point to `len` readable `f64` values
345/// that remain valid and unmutated for the duration of the call.
346unsafe fn drag_table_from_raw(
347    mach: *const c_double,
348    cd: *const c_double,
349    len: c_int,
350) -> Result<crate::drag::DragTable, ()> {
351    if mach.is_null() || cd.is_null() || !(2..=MAX_FFI_DRAG_TABLE_LEN).contains(&len) {
352        return Err(());
353    }
354    let len = len as usize;
355    let mach = unsafe { std::slice::from_raw_parts(mach, len) }.to_vec();
356    let cd = unsafe { std::slice::from_raw_parts(cd, len) }.to_vec();
357    crate::drag::DragTable::try_new(mach, cd).map_err(|_| ())
358}
359
360/// Shared implementation for the trajectory exports. `custom_drag_table`, when
361/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
362/// density — see `BallisticInputs::custom_drag_denominator`). `cd_scale` multiplies the
363/// deck's interpolated Cd (MBA-1356); callers that don't expose a scale pass `1.0`
364/// (neutral, byte-identical to no scale). It is inert when `custom_drag_table` is `None`.
365unsafe fn calculate_trajectory_impl(
366    inputs: *const FFIBallisticInputs,
367    wind: *const FFIWindConditions,
368    atmosphere: *const FFIAtmosphericConditions,
369    max_range: c_double,
370    step_size: c_double,
371    custom_drag_table: Option<crate::drag::DragTable>,
372    cd_scale: c_double,
373) -> *mut FFITrajectoryResult {
374    if inputs.is_null() {
375        return ptr::null_mut();
376    }
377    if !step_size.is_finite() || step_size < MIN_FFI_STEP_SIZE_MS {
378        return ptr::null_mut();
379    }
380
381    let inputs = unsafe { &*inputs };
382    let mut ballistic_inputs = convert_inputs(inputs);
383    ballistic_inputs.custom_drag_table = custom_drag_table;
384    ballistic_inputs.cd_scale = cd_scale;
385    let twist_rate_in = ballistic_inputs.twist_rate;
386
387    let wind_conditions = if wind.is_null() {
388        WindConditions::default()
389    } else {
390        let wind = unsafe { &*wind };
391        WindConditions {
392            speed: wind.speed,
393            direction: wind.direction,
394            vertical_speed: wind.vertical_speed,
395        }
396    };
397
398    let atmospheric_conditions = if atmosphere.is_null() {
399        AtmosphericConditions::default()
400    } else {
401        let atmo = unsafe { &*atmosphere };
402        AtmosphericConditions {
403            temperature: atmo.temperature,
404            pressure: atmo.pressure,
405            humidity: atmo.humidity,
406            altitude: atmo.altitude,
407        }
408    };
409
410    // Create solver and calculate trajectory
411    let (sample_temp_c, sample_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
412        atmospheric_conditions.temperature,
413        atmospheric_conditions.pressure,
414        atmospheric_conditions.altitude,
415    );
416    let (_, sample_speed_of_sound) = crate::atmosphere::calculate_atmosphere(
417        atmospheric_conditions.altitude,
418        Some(sample_temp_c),
419        Some(sample_pressure_hpa),
420        atmospheric_conditions.humidity,
421    );
422
423    let mut solver =
424        TrajectorySolver::new(ballistic_inputs, wind_conditions, atmospheric_conditions);
425
426    // Set max range and time step
427    solver.set_max_range(max_range);
428    solver.set_time_step(step_size / 1000.0); // milliseconds -> seconds
429
430    match solver.solve() {
431        Ok(result) => {
432            // Convert trajectory points to FFI format
433            let point_count = result.points.len();
434            let points = if point_count > 0 {
435                let mut ffi_points = Vec::with_capacity(point_count);
436                for point in result.points.iter() {
437                    ffi_points.push(FFITrajectoryPoint {
438                        time: point.time,
439                        position_x: point.position[0],
440                        position_y: point.position[1],
441                        position_z: point.position[2],
442                        velocity_magnitude: point.velocity_magnitude,
443                        kinetic_energy: point.kinetic_energy,
444                    });
445                }
446                let points_ptr = ffi_points.as_mut_ptr();
447                std::mem::forget(ffi_points); // Prevent deallocation
448                points_ptr
449            } else {
450                ptr::null_mut()
451            };
452
453            // Convert sampled points if available
454            let (sampled_points, sampled_point_count) =
455                if let Some(ref samples) = result.sampled_points {
456                    let mut ffi_samples = Vec::with_capacity(samples.len());
457                    for sample in samples {
458                        ffi_samples.push(FFITrajectorySample {
459                            distance: sample.distance_m,
460                            time: sample.time_s,
461                            velocity_mps: sample.velocity_mps,
462                            energy_joules: sample.energy_j,
463                            drop_meters: sample.drop_m,
464                            windage_meters: sample.wind_drift_m,
465                            mach: if sample_speed_of_sound > 0.0 {
466                                sample.velocity_mps / sample_speed_of_sound
467                            } else {
468                                0.0
469                            },
470                            spin_rate_rps: if twist_rate_in > 0.0 {
471                                sample.velocity_mps / (twist_rate_in * 0.0254)
472                            } else {
473                                0.0
474                            },
475                        });
476                    }
477                    let count = ffi_samples.len() as c_int;
478                    let samples_ptr = ffi_samples.as_mut_ptr();
479                    std::mem::forget(ffi_samples);
480                    (samples_ptr, count)
481                } else {
482                    (ptr::null_mut(), 0)
483                };
484
485            // Extract angular state values if available
486            let (final_pitch, final_yaw, max_yaw, max_prec) =
487                if let Some(ref angular) = result.angular_state {
488                    (
489                        angular.pitch_angle,
490                        angular.yaw_angle,
491                        result.max_yaw_angle.unwrap_or(f64::NAN),
492                        result.max_precession_angle.unwrap_or(f64::NAN),
493                    )
494                } else {
495                    (f64::NAN, f64::NAN, f64::NAN, f64::NAN)
496                };
497
498            // Create result on heap
499            let ffi_result = Box::new(FFITrajectoryResult {
500                max_range: result.max_range,
501                max_height: result.max_height,
502                time_of_flight: result.time_of_flight,
503                impact_velocity: result.impact_velocity,
504                impact_energy: result.impact_energy,
505                points,
506                point_count: point_count as c_int,
507                sampled_points,
508                sampled_point_count,
509                min_pitch_damping: result.min_pitch_damping.unwrap_or(f64::NAN),
510                transonic_mach: result.transonic_mach.unwrap_or(f64::NAN),
511                final_pitch_angle: final_pitch,
512                final_yaw_angle: final_yaw,
513                max_yaw_angle: max_yaw,
514                max_precession_angle: max_prec,
515            });
516
517            Box::into_raw(ffi_result)
518        }
519        Err(_) => ptr::null_mut(),
520    }
521}
522
523/// Calculate a trajectory through the C ABI.
524///
525/// `step_size` is expressed in milliseconds and must be finite and at least
526/// [`MIN_FFI_STEP_SIZE_MS`]. This boundary contract is validated for every solver mode, although
527/// adaptive RK45 chooses its integration steps internally. Invalid values return null without
528/// starting a solve. A solve that would exceed [`crate::MAX_TRAJECTORY_POINTS`] also returns null;
529/// an enabled sampling grid above [`crate::MAX_TRAJECTORY_SAMPLES`] does likewise. Callers can
530/// increase `step_size`, reduce `max_range`, or select adaptive RK45.
531///
532/// # Safety
533///
534/// `inputs` may be null, in which case this function returns null. When non-null,
535/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
536/// readable and is not mutated for the duration of this call. `wind` and
537/// `atmosphere` may also be null; each non-null pointer has the same requirements
538/// for its corresponding type.
539/// The returned pointer, when non-null, must be released exactly once with
540/// [`ballistics_free_trajectory_result`].
541#[no_mangle]
542pub unsafe extern "C" fn ballistics_calculate_trajectory(
543    inputs: *const FFIBallisticInputs,
544    wind: *const FFIWindConditions,
545    atmosphere: *const FFIAtmosphericConditions,
546    max_range: c_double,
547    step_size: c_double,
548) -> *mut FFITrajectoryResult {
549    unsafe { calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, None, 1.0) }
550}
551
552/// [`ballistics_calculate_trajectory`] with a caller-supplied custom drag deck
553/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
554/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
555/// denominator becomes the projectile's sectional density (mass and diameter in
556/// `inputs` must therefore be positive). Mach values must be strictly ascending
557/// with `drag_table_len` in `[2, MAX_FFI_DRAG_TABLE_LEN]` and finite positive Cd;
558/// outside the deck's Mach domain the nearest endpoint Cd is held.
559///
560/// Returns null for an invalid deck (null arrays, `drag_table_len` outside
561/// `[2, MAX_FFI_DRAG_TABLE_LEN]`, or failed validation), in addition to every
562/// failure mode of the base function.
563///
564/// # Safety
565///
566/// Same contract as [`ballistics_calculate_trajectory`] for `inputs`, `wind`, and
567/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
568/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
569/// of this call (the deck is copied; the caller retains ownership — no new free
570/// function is required). The returned pointer, when non-null, must be released
571/// exactly once with [`ballistics_free_trajectory_result`].
572#[no_mangle]
573pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table(
574    inputs: *const FFIBallisticInputs,
575    wind: *const FFIWindConditions,
576    atmosphere: *const FFIAtmosphericConditions,
577    max_range: c_double,
578    step_size: c_double,
579    drag_mach: *const c_double,
580    drag_cd: *const c_double,
581    drag_table_len: c_int,
582) -> *mut FFITrajectoryResult {
583    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
584        Ok(t) => t,
585        Err(()) => return ptr::null_mut(),
586    };
587    unsafe {
588        calculate_trajectory_impl(
589            inputs, wind, atmosphere, max_range, step_size, Some(table), 1.0,
590        )
591    }
592}
593
594/// [`ballistics_calculate_trajectory_with_drag_table`] with an additional whole-curve
595/// drag scale (MBA-1356): the deck's interpolated Cd is multiplied by `cd_scale` at the
596/// same site the base export uses, i.e. `Cd_used = table.interpolate(mach) * cd_scale`.
597/// `cd_scale = 1.0` is neutral and produces byte-identical output to
598/// [`ballistics_calculate_trajectory_with_drag_table`] on the same deck. Typical truing
599/// values are in `[0.90, 1.10]`; values outside that band are accepted here (the engine
600/// only rejects non-finite or non-positive) — an "unusually large" warning is a CLI-only
601/// concern (Task 2), not part of this frozen C ABI.
602///
603/// Returns null when `cd_scale` is not finite or not `> 0`, in addition to every failure
604/// mode of [`ballistics_calculate_trajectory_with_drag_table`] (matching that export's
605/// null sentinel for an invalid deck).
606///
607/// # Safety
608///
609/// Same contract as [`ballistics_calculate_trajectory_with_drag_table`].
610#[no_mangle]
611pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table_scaled(
612    inputs: *const FFIBallisticInputs,
613    wind: *const FFIWindConditions,
614    atmosphere: *const FFIAtmosphericConditions,
615    max_range: c_double,
616    step_size: c_double,
617    drag_mach: *const c_double,
618    drag_cd: *const c_double,
619    drag_table_len: c_int,
620    cd_scale: c_double,
621) -> *mut FFITrajectoryResult {
622    if !cd_scale.is_finite() || cd_scale <= 0.0 {
623        return ptr::null_mut();
624    }
625    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
626        Ok(t) => t,
627        Err(()) => return ptr::null_mut(),
628    };
629    unsafe {
630        calculate_trajectory_impl(
631            inputs, wind, atmosphere, max_range, step_size, Some(table), cd_scale,
632        )
633    }
634}
635
636/// Release a trajectory result allocated by [`ballistics_calculate_trajectory`].
637///
638/// # Safety
639///
640/// `result` must be null or a pointer returned by
641/// [`ballistics_calculate_trajectory`] that has not already been freed. Its
642/// embedded pointers and counts must be unchanged from the returned values.
643/// After this call, the result and its point arrays must not be accessed again.
644#[no_mangle]
645pub unsafe extern "C" fn ballistics_free_trajectory_result(result: *mut FFITrajectoryResult) {
646    if !result.is_null() {
647        unsafe {
648            let result = Box::from_raw(result);
649            if !result.points.is_null() && result.point_count > 0 {
650                let points = Vec::from_raw_parts(
651                    result.points,
652                    result.point_count as usize,
653                    result.point_count as usize,
654                );
655                drop(points);
656            }
657            if !result.sampled_points.is_null() && result.sampled_point_count > 0 {
658                let samples = Vec::from_raw_parts(
659                    result.sampled_points,
660                    result.sampled_point_count as usize,
661                    result.sampled_point_count as usize,
662                );
663                drop(samples);
664            }
665            drop(result);
666        }
667    }
668}
669
670/// Shared implementation for the zero-angle exports. `custom_drag_table`, when
671/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
672/// density — see `BallisticInputs::custom_drag_denominator`), matching the deck
673/// semantics of [`calculate_trajectory_impl`] so a zero solved with a deck and a
674/// trajectory flown with the same deck agree. `cd_scale` multiplies the deck's
675/// interpolated Cd (MBA-1356); callers that don't expose a scale pass `1.0` (neutral).
676unsafe fn calculate_zero_angle_impl(
677    inputs: *const FFIBallisticInputs,
678    wind: *const FFIWindConditions,
679    atmosphere: *const FFIAtmosphericConditions,
680    zero_distance: c_double,
681    custom_drag_table: Option<crate::drag::DragTable>,
682    cd_scale: c_double,
683) -> c_double {
684    if inputs.is_null() {
685        return f64::NAN;
686    }
687
688    let inputs = unsafe { &*inputs };
689    let mut ballistic_inputs = convert_inputs(inputs);
690    ballistic_inputs.custom_drag_table = custom_drag_table;
691    ballistic_inputs.cd_scale = cd_scale;
692
693    let wind_conditions = if wind.is_null() {
694        WindConditions::default()
695    } else {
696        let wind = unsafe { &*wind };
697        WindConditions {
698            speed: wind.speed,
699            direction: wind.direction,
700            vertical_speed: wind.vertical_speed,
701        }
702    };
703
704    let atmospheric_conditions = if atmosphere.is_null() {
705        AtmosphericConditions::default()
706    } else {
707        let atmo = unsafe { &*atmosphere };
708        AtmosphericConditions {
709            temperature: atmo.temperature,
710            pressure: atmo.pressure,
711            humidity: atmo.humidity,
712            altitude: atmo.altitude,
713        }
714    };
715
716    // For zero angle, we want the bullet to hit at sight height at the zero distance
717    // This means the bullet crosses the line of sight at the zero distance
718    let target_height = ballistic_inputs.sight_height;
719
720    calculate_zero_angle_with_conditions(
721        ballistic_inputs,
722        zero_distance,
723        target_height,
724        wind_conditions,
725        atmospheric_conditions,
726    )
727    .unwrap_or(f64::NAN)
728}
729
730/// Calculate the zero angle for a target distance through the C ABI.
731///
732/// # Safety
733///
734/// `inputs` may be null, in which case this function returns NaN. When non-null,
735/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
736/// readable and is not mutated for the duration of this call. `wind` and
737/// `atmosphere` may also be null; each non-null pointer has the same requirements
738/// for its corresponding type.
739#[no_mangle]
740pub unsafe extern "C" fn ballistics_calculate_zero_angle(
741    inputs: *const FFIBallisticInputs,
742    wind: *const FFIWindConditions,
743    atmosphere: *const FFIAtmosphericConditions,
744    zero_distance: c_double,
745) -> c_double {
746    unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, None, 1.0) }
747}
748
749/// [`ballistics_calculate_zero_angle`] with a caller-supplied custom drag deck
750/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
751/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
752/// denominator becomes the projectile's sectional density (mass and diameter in
753/// `inputs` must therefore be positive). Mach values must be strictly ascending
754/// with `drag_table_len` in `[2, MAX_FFI_DRAG_TABLE_LEN]` and finite positive Cd;
755/// outside the deck's Mach domain the nearest endpoint Cd is held. Pair this with
756/// [`ballistics_calculate_trajectory_with_drag_table`] using the same deck to fly
757/// the solved angle — the two exports share identical deck semantics.
758///
759/// Returns NaN for an invalid deck (null arrays, `drag_table_len` outside
760/// `[2, MAX_FFI_DRAG_TABLE_LEN]`, or failed validation), in addition to every
761/// failure mode of the base function.
762///
763/// # Safety
764///
765/// Same contract as [`ballistics_calculate_zero_angle`] for `inputs`, `wind`, and
766/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
767/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
768/// of this call (the deck is copied; the caller retains ownership — no new free
769/// function is required).
770#[no_mangle]
771pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table(
772    inputs: *const FFIBallisticInputs,
773    wind: *const FFIWindConditions,
774    atmosphere: *const FFIAtmosphericConditions,
775    zero_distance: c_double,
776    drag_mach: *const c_double,
777    drag_cd: *const c_double,
778    drag_table_len: c_int,
779) -> c_double {
780    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
781        Ok(t) => t,
782        Err(()) => return f64::NAN,
783    };
784    unsafe {
785        calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, Some(table), 1.0)
786    }
787}
788
789/// [`ballistics_calculate_zero_angle_with_drag_table`] with an additional whole-curve
790/// drag scale (MBA-1356): the deck's interpolated Cd is multiplied by `cd_scale` at the
791/// same site the base export uses, i.e. `Cd_used = table.interpolate(mach) * cd_scale`.
792/// `cd_scale = 1.0` is neutral and produces byte-identical output to
793/// [`ballistics_calculate_zero_angle_with_drag_table`] on the same deck. Pair this with
794/// [`ballistics_calculate_trajectory_with_drag_table_scaled`] using the same deck AND the
795/// same `cd_scale` to fly the solved angle. Typical truing values are in `[0.90, 1.10]`;
796/// values outside that band are accepted here (the engine only rejects non-finite or
797/// non-positive) — an "unusually large" warning is a CLI-only concern (Task 2).
798///
799/// Returns NaN when `cd_scale` is not finite or not `> 0`, in addition to every failure
800/// mode of [`ballistics_calculate_zero_angle_with_drag_table`] (matching that export's
801/// NaN sentinel for an invalid deck).
802///
803/// # Safety
804///
805/// Same contract as [`ballistics_calculate_zero_angle_with_drag_table`].
806#[no_mangle]
807pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table_scaled(
808    inputs: *const FFIBallisticInputs,
809    wind: *const FFIWindConditions,
810    atmosphere: *const FFIAtmosphericConditions,
811    zero_distance: c_double,
812    drag_mach: *const c_double,
813    drag_cd: *const c_double,
814    drag_table_len: c_int,
815    cd_scale: c_double,
816) -> c_double {
817    if !cd_scale.is_finite() || cd_scale <= 0.0 {
818        return f64::NAN;
819    }
820    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
821        Ok(t) => t,
822        Err(()) => return f64::NAN,
823    };
824    unsafe {
825        calculate_zero_angle_impl(
826            inputs,
827            wind,
828            atmosphere,
829            zero_distance,
830            Some(table),
831            cd_scale,
832        )
833    }
834}
835
836// Simple trajectory calculation for quick results
837#[no_mangle]
838#[allow(clippy::field_reassign_with_default)] // Preserve the staged zero-angle workflow below.
839pub extern "C" fn ballistics_quick_trajectory(
840    muzzle_velocity: c_double,
841    bc: c_double,
842    sight_height: c_double,
843    zero_distance: c_double,
844    target_distance: c_double,
845) -> c_double {
846    // This provides a simple drop calculation at target distance
847    // Using simplified ballistic calculations
848
849    let mut inputs = BallisticInputs::default();
850    inputs.muzzle_velocity = muzzle_velocity;
851    inputs.bc_value = bc;
852    inputs.sight_height = sight_height;
853    inputs.target_distance = target_distance;
854
855    let wind = WindConditions::default();
856    let atmo = AtmosphericConditions::default();
857
858    // First calculate zero angle
859    let zero_angle = match calculate_zero_angle_with_conditions(
860        inputs.clone(),
861        zero_distance,
862        sight_height,
863        wind.clone(),
864        atmo.clone(),
865    ) {
866        Ok(angle) => angle,
867        Err(_) => return f64::NAN,
868    };
869
870    // Now calculate trajectory with that zero angle
871    inputs.muzzle_angle = zero_angle;
872
873    let mut solver = TrajectorySolver::new(inputs, wind, atmo);
874    solver.set_max_range(target_distance * 1.1);
875
876    match solver.solve() {
877        Ok(result) => {
878            // Find the drop at target distance
879            for point in result.points {
880                if point.position[0] >= target_distance {
881                    return sight_height - point.position[1];
882                }
883            }
884            f64::NAN
885        }
886        Err(_) => f64::NAN,
887    }
888}
889
890/// Run a Monte Carlo simulation through the C ABI.
891///
892/// # Safety
893///
894/// `inputs` and `params` may be null, in which case this function returns null.
895/// Each non-null pointer must point to a valid, properly aligned value of its
896/// corresponding FFI type that remains readable and is not mutated for the
897/// duration of this call. `atmosphere` may be null; a non-null pointer has the
898/// same requirements for [`FFIAtmosphericConditions`]. The returned pointer,
899/// when non-null, must be released exactly once with
900/// [`ballistics_free_monte_carlo_results`].
901#[no_mangle]
902pub unsafe extern "C" fn ballistics_monte_carlo(
903    inputs: *const FFIBallisticInputs,
904    atmosphere: *const FFIAtmosphericConditions,
905    params: *const FFIMonteCarloParams,
906) -> *mut FFIMonteCarloResults {
907    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, 0.0) }
908}
909
910/// Run a Monte Carlo simulation with independent wind-direction uncertainty through the C ABI.
911///
912/// `wind_direction_std_dev` is in radians. This additive entry point keeps
913/// [`FFIMonteCarloParams`] and [`ballistics_monte_carlo`] binary-compatible; the older function
914/// delegates with zero wind-direction uncertainty.
915///
916/// # Safety
917///
918/// The pointer and ownership requirements are identical to [`ballistics_monte_carlo`].
919#[no_mangle]
920pub unsafe extern "C" fn ballistics_monte_carlo_with_direction_std_dev(
921    inputs: *const FFIBallisticInputs,
922    atmosphere: *const FFIAtmosphericConditions,
923    params: *const FFIMonteCarloParams,
924    wind_direction_std_dev: c_double,
925) -> *mut FFIMonteCarloResults {
926    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, wind_direction_std_dev) }
927}
928
929unsafe fn ballistics_monte_carlo_impl(
930    inputs: *const FFIBallisticInputs,
931    atmosphere: *const FFIAtmosphericConditions,
932    params: *const FFIMonteCarloParams,
933    wind_direction_std_dev: f64,
934) -> *mut FFIMonteCarloResults {
935    if inputs.is_null() || params.is_null() {
936        return ptr::null_mut();
937    }
938
939    let inputs = unsafe { &*inputs };
940    let params = unsafe { &*params };
941
942    // Reject an out-of-range simulation count. num_simulations is a c_int (i32) cast straight to
943    // usize: a negative value would wrap to a near-max usize, and even a large positive value (up
944    // to i32::MAX ~ 2.1e9) would drive billions of iterations with the result arrays scaling to
945    // match — an unbounded-loop / OOM DoS from a single FFI call. Bound it to a sane maximum.
946    // (n == 0 also yields NaN stats and a zero-size allocation.)
947    const MAX_SIMULATIONS: c_int = 1_000_000;
948    if params.num_simulations <= 0 || params.num_simulations > MAX_SIMULATIONS {
949        return ptr::null_mut();
950    }
951
952    // Convert FFI inputs to internal types
953    let mut ballistic_inputs = convert_inputs(inputs);
954    ballistic_inputs.muzzle_height = 1.5;
955    ballistic_inputs.ground_threshold = 0.0;
956    if !atmosphere.is_null() {
957        let atmo = unsafe { &*atmosphere };
958        ballistic_inputs.temperature = atmo.temperature;
959        ballistic_inputs.pressure = atmo.pressure;
960        ballistic_inputs.humidity = (atmo.humidity / 100.0).clamp(0.0, 1.0);
961        ballistic_inputs.altitude = atmo.altitude;
962    }
963
964    // Create Monte Carlo parameters
965    let mc_params = MonteCarloParams {
966        num_simulations: params.num_simulations as usize,
967        velocity_std_dev: params.velocity_std_dev,
968        angle_std_dev: params.angle_std_dev,
969        bc_std_dev: params.bc_std_dev,
970        wind_speed_std_dev: params.wind_speed_std_dev,
971        target_distance: if params.target_distance.is_nan() {
972            None
973        } else {
974            Some(params.target_distance)
975        },
976        base_wind_speed: params.base_wind_speed,
977        base_wind_direction: params.base_wind_direction,
978        azimuth_std_dev: params.azimuth_std_dev,
979    };
980
981    // Run Monte Carlo simulation
982    match run_monte_carlo_with_direction_std_dev(
983        ballistic_inputs,
984        mc_params,
985        wind_direction_std_dev,
986    ) {
987        Ok(results) => {
988            let num_results = results.ranges.len() as c_int;
989
990            // Calculate statistics
991            let mean_range: f64 = results.ranges.iter().sum::<f64>() / num_results as f64;
992            let variance_range: f64 = results
993                .ranges
994                .iter()
995                .map(|r| (r - mean_range).powi(2))
996                .sum::<f64>()
997                / num_results as f64;
998            let std_dev_range = variance_range.sqrt();
999
1000            let mean_velocity: f64 =
1001                results.impact_velocities.iter().sum::<f64>() / num_results as f64;
1002            let variance_velocity: f64 = results
1003                .impact_velocities
1004                .iter()
1005                .map(|v| (v - mean_velocity).powi(2))
1006                .sum::<f64>()
1007                / num_results as f64;
1008            let std_dev_velocity = variance_velocity.sqrt();
1009
1010            // Calculate hit probability if target distance was specified. MBA-971: use the shared
1011            // position-based criterion (fraction within DEFAULT_HIT_RADIUS_M of the point of aim
1012            // at the target plane). The old inline version had a redundant `distance < target`
1013            // clause comparing a ~meter deviation to the ~hundreds-of-meters target distance
1014            // (effectively always true), and the CLI used a different range-based notion entirely.
1015            let hit_probability = if params.target_distance.is_nan() {
1016                0.0
1017            } else {
1018                results.hit_probability(crate::DEFAULT_HIT_RADIUS_M)
1019            };
1020
1021            // Allocate memory for arrays
1022            let ranges_ptr = unsafe {
1023                let ptr = std::alloc::alloc(
1024                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
1025                ) as *mut c_double;
1026                for (i, &range) in results.ranges.iter().enumerate() {
1027                    *ptr.add(i) = range;
1028                }
1029                ptr
1030            };
1031
1032            let velocities_ptr = unsafe {
1033                let ptr = std::alloc::alloc(
1034                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
1035                ) as *mut c_double;
1036                for (i, &vel) in results.impact_velocities.iter().enumerate() {
1037                    *ptr.add(i) = vel;
1038                }
1039                ptr
1040            };
1041
1042            let pos_x_ptr = unsafe {
1043                let ptr = std::alloc::alloc(
1044                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
1045                ) as *mut c_double;
1046                for (i, pos) in results.impact_positions.iter().enumerate() {
1047                    *ptr.add(i) = pos.x;
1048                }
1049                ptr
1050            };
1051
1052            let pos_y_ptr = unsafe {
1053                let ptr = std::alloc::alloc(
1054                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
1055                ) as *mut c_double;
1056                for (i, pos) in results.impact_positions.iter().enumerate() {
1057                    *ptr.add(i) = pos.y;
1058                }
1059                ptr
1060            };
1061
1062            let pos_z_ptr = unsafe {
1063                let ptr = std::alloc::alloc(
1064                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
1065                ) as *mut c_double;
1066                for (i, pos) in results.impact_positions.iter().enumerate() {
1067                    *ptr.add(i) = pos.z;
1068                }
1069                ptr
1070            };
1071
1072            // Create result structure
1073            let result = Box::new(FFIMonteCarloResults {
1074                ranges: ranges_ptr,
1075                impact_velocities: velocities_ptr,
1076                impact_positions_x: pos_x_ptr,
1077                impact_positions_y: pos_y_ptr,
1078                impact_positions_z: pos_z_ptr,
1079                num_results,
1080                mean_range,
1081                std_dev_range,
1082                mean_impact_velocity: mean_velocity,
1083                std_dev_impact_velocity: std_dev_velocity,
1084                hit_probability,
1085            });
1086
1087            Box::into_raw(result)
1088        }
1089        Err(_) => ptr::null_mut(),
1090    }
1091}
1092
1093/// Release Monte Carlo results allocated by either Monte Carlo C entry point.
1094///
1095/// # Safety
1096///
1097/// `results` must be null or a pointer returned by [`ballistics_monte_carlo`] or
1098/// [`ballistics_monte_carlo_with_direction_std_dev`] that has not already been freed. Its
1099/// embedded pointers and result count must be unchanged from the returned values. After this
1100/// call, the result and all of its arrays must not be accessed again.
1101#[no_mangle]
1102pub unsafe extern "C" fn ballistics_free_monte_carlo_results(results: *mut FFIMonteCarloResults) {
1103    if results.is_null() {
1104        return;
1105    }
1106
1107    unsafe {
1108        let results = Box::from_raw(results);
1109        let num = results.num_results as usize;
1110
1111        // Free arrays
1112        if !results.ranges.is_null() {
1113            std::alloc::dealloc(
1114                results.ranges as *mut u8,
1115                std::alloc::Layout::array::<c_double>(num).unwrap(),
1116            );
1117        }
1118
1119        if !results.impact_velocities.is_null() {
1120            std::alloc::dealloc(
1121                results.impact_velocities as *mut u8,
1122                std::alloc::Layout::array::<c_double>(num).unwrap(),
1123            );
1124        }
1125
1126        if !results.impact_positions_x.is_null() {
1127            std::alloc::dealloc(
1128                results.impact_positions_x as *mut u8,
1129                std::alloc::Layout::array::<c_double>(num).unwrap(),
1130            );
1131        }
1132
1133        if !results.impact_positions_y.is_null() {
1134            std::alloc::dealloc(
1135                results.impact_positions_y as *mut u8,
1136                std::alloc::Layout::array::<c_double>(num).unwrap(),
1137            );
1138        }
1139
1140        if !results.impact_positions_z.is_null() {
1141            std::alloc::dealloc(
1142                results.impact_positions_z as *mut u8,
1143                std::alloc::Layout::array::<c_double>(num).unwrap(),
1144            );
1145        }
1146
1147        // Box automatically deallocates the result structure
1148    }
1149}
1150
1151/// Which standard atmosphere a `bc` value passed to [`ballistics_bc_for_reference_standard`]
1152/// is referenced to (MBA-1365). `0` = ICAO (the default every other export in this module
1153/// assumes), `1` = Army Standard Metro (some vendor-published BCs, notably many
1154/// Sierra/Hornady/Barnes bullets). Any other value is treated as `0` (ICAO), matching the
1155/// permissive unrecognized-value convention `convert_inputs` already uses for `bc_type`.
1156pub const FFI_BC_REFERENCE_ICAO: c_int = 0;
1157pub const FFI_BC_REFERENCE_ARMY_STANDARD_METRO: c_int = 1;
1158
1159/// Convert a ballistic coefficient declared under `reference_standard` to the ICAO-referenced
1160/// value every `FFIBallisticInputs.bc_value` in this module expects (MBA-1365).
1161///
1162/// `FFIBallisticInputs` is an ABI-frozen `repr(C)` struct (an iOS-consumer contract enforced
1163/// by a regression test) with no room to add a reference-standard field, so this is a
1164/// standalone pure conversion instead of a struct setter: call it once on a raw BC before
1165/// writing the result into `FFIBallisticInputs.bc_value`, then use every existing
1166/// `ballistics_calculate_trajectory*`/`ballistics_calculate_zero_angle*`/`ballistics_monte_carlo*`
1167/// export completely unchanged. `reference_standard == FFI_BC_REFERENCE_ICAO` (`0`) is a
1168/// no-op, so every existing caller that never calls this function is unaffected — this is a
1169/// pure addition to the ABI, not a modification, so no recompile is required unless a caller
1170/// opts into the new symbol.
1171///
1172/// `reference_standard == FFI_BC_REFERENCE_ARMY_STANDARD_METRO` (`1`) multiplies by
1173/// [`crate::constants::ASM_TO_ICAO_BC`], the same constant and the same single multiply
1174/// [`crate::cli_api::TrajectorySolver::new`] applies for the CLI/WASM/Rust-native surfaces.
1175/// A non-finite `bc` is returned unchanged (this function performs no validation; the
1176/// existing `bc_value must be finite and greater than zero` solve-time check still applies).
1177#[no_mangle]
1178pub extern "C" fn ballistics_bc_for_reference_standard(
1179    bc: c_double,
1180    reference_standard: c_int,
1181) -> c_double {
1182    if reference_standard == FFI_BC_REFERENCE_ARMY_STANDARD_METRO {
1183        bc * crate::constants::ASM_TO_ICAO_BC
1184    } else {
1185        bc
1186    }
1187}
1188
1189/// Reduce a sea-level-corrected altimeter setting (QNH, in hPa) to the station pressure at
1190/// `altitude_m` (MBA-1397; see [`crate::atmosphere::reduce_qnh_to_station_pressure`] for the
1191/// formula). `FFIAtmosphericConditions.pressure` has always meant absolute station pressure,
1192/// and remains a frozen `repr(C)` struct enforced by an ABI regression test with no room to
1193/// add a pressure-reference-mode field, so this is a standalone pure conversion instead of a
1194/// struct setter — exactly the same pattern as [`ballistics_bc_for_reference_standard`]: call
1195/// it once on a caller-declared QNH reading before writing the result into
1196/// `FFIAtmosphericConditions.pressure`, then use every existing
1197/// `ballistics_calculate_trajectory*`/`ballistics_calculate_zero_angle*`/`ballistics_monte_carlo*`
1198/// export completely unchanged — every one of them reads `pressure` as absolute station
1199/// pressure already, so feeding it an already-reduced value is a pure addition to the ABI,
1200/// not a modification. No existing caller that never calls this function is affected, and no
1201/// recompile is required for callers that don't opt into QNH support.
1202///
1203/// A non-finite `qnh_hpa` or `altitude_m` is returned unchanged (this function performs no
1204/// validation; the existing per-export input checks still apply to whatever ends up in
1205/// `FFIAtmosphericConditions.pressure`).
1206#[no_mangle]
1207pub extern "C" fn ballistics_reduce_qnh_pressure(
1208    qnh_hpa: c_double,
1209    altitude_m: c_double,
1210) -> c_double {
1211    if !qnh_hpa.is_finite() || !altitude_m.is_finite() {
1212        return qnh_hpa;
1213    }
1214    crate::atmosphere::reduce_qnh_to_station_pressure(qnh_hpa, altitude_m)
1215}
1216
1217/// Sentinel `explicit_temperature_c` value meaning "no explicit temperature override" for the
1218/// `ballistics_density_altitude_*` exports below (MBA-1366) — same NaN-means-absent convention
1219/// [`FFIBallisticInputs::latitude`] already uses. Any NaN bit pattern is accepted (checked via
1220/// `is_nan()`, not equality), matching that precedent.
1221pub const FFI_NO_EXPLICIT_TEMPERATURE: c_double = f64::NAN;
1222
1223/// Back-solve the station TEMPERATURE (Celsius) an ISA-equivalent atmosphere at
1224/// `density_altitude_m` implies (MBA-1366; see
1225/// [`crate::atmosphere::resolve_atmosphere_for_density_altitude`] for the full derivation).
1226///
1227/// `FFIAtmosphericConditions` is an ABI-frozen `repr(C)` struct (the same iOS-consumer contract
1228/// enforced by a regression test as [`ballistics_reduce_qnh_pressure`]/
1229/// [`ballistics_bc_for_reference_standard`]) with no room for a density-altitude field, so this
1230/// is a standalone pure conversion — call it (and its two companions below) once on a declared
1231/// density altitude, then write the three results into `FFIAtmosphericConditions.temperature`/
1232/// `.pressure`/`.altitude` before calling any existing `ballistics_calculate_trajectory*`/
1233/// `ballistics_calculate_zero_angle*`/`ballistics_monte_carlo*` export unchanged — a pure
1234/// addition to the C ABI requiring no recompile for existing callers.
1235///
1236/// `explicit_temperature_c`: pass [`FFI_NO_EXPLICIT_TEMPERATURE`] (NaN) for the ISA-at-density-
1237/// altitude default, or a real Celsius value to have it honored exactly (density is still
1238/// honored either way — only the implied pressure/altitude differ; see the Rust doc comment).
1239/// A non-finite `density_altitude_m` returns NaN for all three exports (there is no plausible
1240/// station value to fall back to, unlike the QNH/BC conversions above, which return their input
1241/// unchanged).
1242#[no_mangle]
1243pub extern "C" fn ballistics_density_altitude_temperature_c(
1244    density_altitude_m: c_double,
1245    explicit_temperature_c: c_double,
1246) -> c_double {
1247    if !density_altitude_m.is_finite() {
1248        return f64::NAN;
1249    }
1250    let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1251    crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).1
1252}
1253
1254/// Companion to [`ballistics_density_altitude_temperature_c`]: the back-solved station PRESSURE
1255/// (hPa) for the same `(density_altitude_m, explicit_temperature_c)` pair.
1256#[no_mangle]
1257pub extern "C" fn ballistics_density_altitude_pressure_hpa(
1258    density_altitude_m: c_double,
1259    explicit_temperature_c: c_double,
1260) -> c_double {
1261    if !density_altitude_m.is_finite() {
1262        return f64::NAN;
1263    }
1264    let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1265    crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).2
1266}
1267
1268/// Companion to [`ballistics_density_altitude_temperature_c`]: the back-solved station ALTITUDE
1269/// (meters, geometric) for the same `(density_altitude_m, explicit_temperature_c)` pair. This is
1270/// NOT necessarily equal to `density_altitude_m` — it only is when `explicit_temperature_c` is
1271/// [`FFI_NO_EXPLICIT_TEMPERATURE`] (see the Rust doc comment's algebraic identity).
1272#[no_mangle]
1273pub extern "C" fn ballistics_density_altitude_altitude_m(
1274    density_altitude_m: c_double,
1275    explicit_temperature_c: c_double,
1276) -> c_double {
1277    if !density_altitude_m.is_finite() {
1278        return f64::NAN;
1279    }
1280    let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1281    crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).0
1282}
1283
1284/// Largest `marks_len` [`ballistics_hold_point_in_reticle`] will accept (MBA-1361).
1285///
1286/// Mirrors [`MAX_FFI_DRAG_TABLE_LEN`] and exists for the same reason (MBA-1407): the
1287/// export copies a caller-owned array whose length it cannot otherwise verify, so an
1288/// unbounded `len` would let one call request a multi-gigabyte read. It is also
1289/// [`crate::reticle::MAX_RETICLE_MARKS`], so the C ABI and the Rust API reject the same
1290/// inputs. Real reticles carry tens of marks; a dense tree carries a few hundred.
1291pub const MAX_FFI_RETICLE_MARKS: c_int = crate::reticle::MAX_RETICLE_MARKS as c_int;
1292
1293/// `focal_plane` value selecting a FIRST-focal-plane reticle (subtensions constant across
1294/// magnification). Any value other than [`FFI_RETICLE_SECOND_FOCAL_PLANE`] is treated as FFP.
1295pub const FFI_RETICLE_FIRST_FOCAL_PLANE: c_int = 0;
1296/// `focal_plane` value selecting a SECOND-focal-plane reticle (subtensions scale as
1297/// `reference_magnification / magnification`).
1298pub const FFI_RETICLE_SECOND_FOCAL_PLANE: c_int = 1;
1299
1300/// [`ballistics_hold_point_in_reticle`] succeeded and `out` was written.
1301pub const FFI_RETICLE_OK: c_int = 0;
1302/// A null pointer, or a `marks_len` outside `1..=`[`MAX_FFI_RETICLE_MARKS`].
1303pub const FFI_RETICLE_ERR_INVALID_ARGUMENT: c_int = -1;
1304/// `magnification` was not finite and greater than zero.
1305pub const FFI_RETICLE_ERR_MAGNIFICATION: c_int = -2;
1306/// A second-focal-plane call carried a non-finite or non-positive `reference_magnification`.
1307pub const FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION: c_int = -3;
1308/// A mark coordinate, or the supplied firing solution, was not finite.
1309pub const FFI_RETICLE_ERR_NON_FINITE: c_int = -4;
1310
1311/// Where a firing solution lands in a reticle (MBA-1361).
1312///
1313/// A NEW appended struct — no existing `repr(C)` layout is touched by this addition, so
1314/// existing callers need no recompile. All angles are milliradians from the optical
1315/// center; `down` is positive BELOW center and `right` is positive to the shooter's
1316/// RIGHT (see [`crate::reticle`] for the full convention block).
1317#[repr(C)]
1318pub struct FFIReticleHold {
1319    /// True angular milliradians below center. Equals the supplied `drop_mil`.
1320    pub down_mil: c_double,
1321    /// True angular milliradians right of center. Equals the supplied `wind_mil`.
1322    pub right_mil: c_double,
1323    /// Index of the nearest mark in the caller's array, or `-1` when there is none
1324    /// (unreachable today: an empty mark array is rejected before the search).
1325    pub nearest_mark: c_int,
1326    /// Distance from the hold to that mark, milliradians, measured in TRUE angular space
1327    /// (i.e. after second-focal-plane scaling).
1328    pub nearest_mark_distance_mil: c_double,
1329    /// `1` when the hold falls outside the marks' bounding box grown by 20% of its span
1330    /// per axis, `0` otherwise.
1331    pub off_reticle: c_int,
1332    /// The subtension scale applied to the marks: `reference_magnification / magnification`
1333    /// for a second-focal-plane reticle, exactly `1.0` for first focal plane.
1334    pub mark_scale: c_double,
1335}
1336
1337/// Place an angular firing solution in a reticle (MBA-1361).
1338///
1339/// `marks` is a FLAT array of `2 * marks_len` doubles laid out as
1340/// `[down_0, right_0, down_1, right_1, ...]` in NOMINAL milliradians (as etched; for a
1341/// second-focal-plane reticle that means "true at `reference_magnification`").
1342/// `focal_plane` is [`FFI_RETICLE_FIRST_FOCAL_PLANE`] or
1343/// [`FFI_RETICLE_SECOND_FOCAL_PLANE`]; `reference_magnification` is consulted only in the
1344/// second-focal-plane case.
1345///
1346/// Returns [`FFI_RETICLE_OK`] and writes `out` on success, or one of the negative
1347/// `FFI_RETICLE_ERR_*` codes, in which case `out` is left untouched.
1348///
1349/// # Safety
1350///
1351/// `marks` must point to at least `2 * marks_len` readable `double`s and `out` to one
1352/// writable [`FFIReticleHold`]. `marks_len` is validated against
1353/// `1..=`[`MAX_FFI_RETICLE_MARKS`] BEFORE any element is read (MBA-1407 lesson: the
1354/// bound is the only thing standing between a caller typo and an out-of-range read).
1355#[no_mangle]
1356// A newer clippy stable added `chunks_exact_to_as_chunks`, which flags a constant chunk
1357// size and suggests `as_chunks`. Suppressed rather than restructured: this is binary-format
1358// parsing, `as_chunks` changes the element type from `&[u8]` to `&[u8; N]` and so ripples
1359// into every use inside the loop, and the change would land in the middle of a 13-platform
1360// release. `unknown_lints` is allowed alongside it so toolchains predating the lint do not
1361// warn on the name. Adopting `as_chunks` properly is a follow-up.
1362#[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
1363pub unsafe extern "C" fn ballistics_hold_point_in_reticle(
1364    drop_mil: c_double,
1365    wind_mil: c_double,
1366    magnification: c_double,
1367    marks: *const c_double,
1368    marks_len: c_int,
1369    focal_plane: c_int,
1370    reference_magnification: c_double,
1371    out: *mut FFIReticleHold,
1372) -> c_int {
1373    use crate::reticle::{
1374        hold_point_in_reticle, FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark,
1375    };
1376
1377    if marks.is_null() || out.is_null() || !(1..=MAX_FFI_RETICLE_MARKS).contains(&marks_len) {
1378        return FFI_RETICLE_ERR_INVALID_ARGUMENT;
1379    }
1380    let count = marks_len as usize;
1381    // Length validated above, so this read stays inside the caller's declared array.
1382    let flat = unsafe { std::slice::from_raw_parts(marks, count * 2) };
1383
1384    let description = ReticleDescription {
1385        name: String::new(),
1386        focal_plane: if focal_plane == FFI_RETICLE_SECOND_FOCAL_PLANE {
1387            FocalPlane::Second
1388        } else {
1389            FocalPlane::First
1390        },
1391        reference_magnification,
1392        marks: flat
1393            .chunks_exact(2)
1394            .map(|pair| ReticleMark::new(pair[0], pair[1], MarkKind::Dot))
1395            .collect(),
1396    };
1397
1398    let hold = match hold_point_in_reticle(drop_mil, wind_mil, magnification, &description) {
1399        Ok(hold) => hold,
1400        Err(ReticleError::NonPositiveMagnification { .. }) => return FFI_RETICLE_ERR_MAGNIFICATION,
1401        Err(ReticleError::NonPositiveReferenceMagnification { .. }) => {
1402            return FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION
1403        }
1404        Err(ReticleError::NonFiniteMark { .. }) | Err(ReticleError::NonFiniteHold { .. }) => {
1405            return FFI_RETICLE_ERR_NON_FINITE
1406        }
1407        Err(_) => return FFI_RETICLE_ERR_INVALID_ARGUMENT,
1408    };
1409
1410    unsafe {
1411        *out = FFIReticleHold {
1412            down_mil: hold.down_mil,
1413            right_mil: hold.right_mil,
1414            nearest_mark: hold.nearest_mark.map_or(-1, |index| index as c_int),
1415            nearest_mark_distance_mil: hold.nearest_mark_distance_mil,
1416            off_reticle: c_int::from(hold.off_reticle),
1417            mark_scale: hold.mark_scale,
1418        };
1419    }
1420    FFI_RETICLE_OK
1421}
1422
1423// Get library version
1424#[no_mangle]
1425pub extern "C" fn ballistics_get_version() -> *const c_char {
1426    // Return a pointer to a static NUL-terminated string (the caller must NOT free it).
1427    // Previously this leaked a freshly-allocated CString on every call and reported a
1428    // stale hardcoded "0.3.0"; use the real crate version with no allocation.
1429    concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
1430}
1431
1432#[cfg(test)]
1433mod tests {
1434    use super::*;
1435
1436    fn valid_trajectory_inputs() -> FFIBallisticInputs {
1437        FFIBallisticInputs {
1438            muzzle_velocity: 800.0,
1439            muzzle_angle: 0.0,
1440            bc_value: 0.5,
1441            bullet_mass: 0.01,
1442            bullet_diameter: 0.00762,
1443            bc_type: 0,
1444            sight_height: 0.05,
1445            target_distance: 1.0,
1446            temperature: 15.0,
1447            twist_rate: 12.0,
1448            is_twist_right: 1,
1449            shooting_angle: 0.0,
1450            altitude: 0.0,
1451            latitude: f64::NAN,
1452            azimuth_angle: 0.0,
1453            use_rk4: 1,
1454            use_adaptive_rk45: 0,
1455            enable_wind_shear: 0,
1456            enable_trajectory_sampling: 0,
1457            sample_interval: 10.0,
1458            enable_pitch_damping: 0,
1459            enable_precession_nutation: 0,
1460            enable_spin_drift: 0,
1461            enable_magnus: 0,
1462            enable_coriolis: 0,
1463            shot_azimuth: 0.0,
1464            cant_angle: 0.0,
1465            zero_poi_vertical: 0.0,
1466            zero_poi_horizontal: 0.0,
1467            sight_offset_lateral: 0.0,
1468        }
1469    }
1470
1471    #[allow(dead_code)]
1472    #[repr(C)]
1473    struct LegacyFFIMonteCarloParams {
1474        num_simulations: c_int,
1475        velocity_std_dev: c_double,
1476        angle_std_dev: c_double,
1477        bc_std_dev: c_double,
1478        wind_speed_std_dev: c_double,
1479        target_distance: c_double,
1480        base_wind_speed: c_double,
1481        base_wind_direction: c_double,
1482        azimuth_std_dev: c_double,
1483    }
1484
1485    #[test]
1486    fn monte_carlo_params_legacy_abi_size_is_unchanged() {
1487        assert_eq!(
1488            std::mem::size_of::<FFIMonteCarloParams>(),
1489            std::mem::size_of::<LegacyFFIMonteCarloParams>()
1490        );
1491        assert_eq!(
1492            std::mem::align_of::<FFIMonteCarloParams>(),
1493            std::mem::align_of::<LegacyFFIMonteCarloParams>()
1494        );
1495    }
1496
1497    /// MBA-1361: the reticle export is APPEND-ONLY — a new struct and a new function.
1498    /// This pins that no pre-existing `repr(C)` layout moved when it landed.
1499    #[test]
1500    fn reticle_addition_does_not_disturb_existing_layouts() {
1501        assert_eq!(
1502            std::mem::size_of::<FFIMonteCarloParams>(),
1503            std::mem::size_of::<LegacyFFIMonteCarloParams>()
1504        );
1505        // The new struct is 6 fields: 4 doubles + 2 ints, C-laid-out.
1506        assert_eq!(std::mem::align_of::<FFIReticleHold>(), 8);
1507    }
1508
1509    fn zeroed_hold() -> FFIReticleHold {
1510        FFIReticleHold {
1511            down_mil: 0.0,
1512            right_mil: 0.0,
1513            nearest_mark: -99,
1514            nearest_mark_distance_mil: -1.0,
1515            off_reticle: -1,
1516            mark_scale: -1.0,
1517        }
1518    }
1519
1520    #[test]
1521    fn ffi_hold_point_matches_the_rust_api_on_both_focal_planes() {
1522        // down/right pairs: center, 2 mil, 4 mil, and a windage dot.
1523        let marks: [c_double; 8] = [0.0, 0.0, 2.0, 0.0, 4.0, 0.0, 2.0, 1.0];
1524        let mut out = zeroed_hold();
1525
1526        // FFP at any magnification: marks are used as etched.
1527        let code = unsafe {
1528            ballistics_hold_point_in_reticle(
1529                4.0,
1530                0.0,
1531                6.0,
1532                marks.as_ptr(),
1533                4,
1534                FFI_RETICLE_FIRST_FOCAL_PLANE,
1535                0.0,
1536                &mut out,
1537            )
1538        };
1539        assert_eq!(code, FFI_RETICLE_OK);
1540        assert_eq!(out.down_mil, 4.0);
1541        assert_eq!(out.nearest_mark, 2);
1542        assert_eq!(out.nearest_mark_distance_mil, 0.0);
1543        assert_eq!(out.mark_scale, 1.0);
1544        assert_eq!(out.off_reticle, 0);
1545
1546        // SFP at half the reference magnification: the 2 mil mark reads 4 mil true.
1547        let mut out = zeroed_hold();
1548        let code = unsafe {
1549            ballistics_hold_point_in_reticle(
1550                4.0,
1551                0.0,
1552                5.0,
1553                marks.as_ptr(),
1554                4,
1555                FFI_RETICLE_SECOND_FOCAL_PLANE,
1556                10.0,
1557                &mut out,
1558            )
1559        };
1560        assert_eq!(code, FFI_RETICLE_OK);
1561        assert_eq!(out.nearest_mark, 1);
1562        assert_eq!(out.nearest_mark_distance_mil, 0.0);
1563        assert_eq!(out.mark_scale, 2.0);
1564    }
1565
1566    /// The MBA-1407 lesson applied to the new export: `marks_len` is validated against a
1567    /// stated bound BEFORE a single element is read, and a null pointer is rejected.
1568    #[test]
1569    fn ffi_hold_point_bounds_check_marks_len_before_reading() {
1570        let marks: [c_double; 4] = [0.0, 0.0, 2.0, 0.0];
1571        let mut out = zeroed_hold();
1572        let call = |len: c_int, ptr: *const c_double, out: &mut FFIReticleHold| unsafe {
1573            ballistics_hold_point_in_reticle(
1574                1.0,
1575                0.0,
1576                10.0,
1577                ptr,
1578                len,
1579                FFI_RETICLE_FIRST_FOCAL_PLANE,
1580                0.0,
1581                out,
1582            )
1583        };
1584
1585        assert_eq!(call(0, marks.as_ptr(), &mut out), FFI_RETICLE_ERR_INVALID_ARGUMENT);
1586        assert_eq!(call(-1, marks.as_ptr(), &mut out), FFI_RETICLE_ERR_INVALID_ARGUMENT);
1587        assert_eq!(
1588            call(MAX_FFI_RETICLE_MARKS + 1, marks.as_ptr(), &mut out),
1589            FFI_RETICLE_ERR_INVALID_ARGUMENT
1590        );
1591        assert_eq!(call(c_int::MAX, marks.as_ptr(), &mut out), FFI_RETICLE_ERR_INVALID_ARGUMENT);
1592        assert_eq!(call(2, std::ptr::null(), &mut out), FFI_RETICLE_ERR_INVALID_ARGUMENT);
1593        // `out` is untouched on every rejection.
1594        assert_eq!(out.nearest_mark, -99);
1595
1596        // A null `out` is rejected too, without reading the marks.
1597        assert_eq!(
1598            unsafe {
1599                ballistics_hold_point_in_reticle(
1600                    1.0,
1601                    0.0,
1602                    10.0,
1603                    marks.as_ptr(),
1604                    2,
1605                    FFI_RETICLE_FIRST_FOCAL_PLANE,
1606                    0.0,
1607                    std::ptr::null_mut(),
1608                )
1609            },
1610            FFI_RETICLE_ERR_INVALID_ARGUMENT
1611        );
1612    }
1613
1614    #[test]
1615    fn ffi_hold_point_maps_each_error_class_to_its_own_code() {
1616        let marks: [c_double; 4] = [0.0, 0.0, 2.0, 0.0];
1617        let bad_marks: [c_double; 4] = [0.0, 0.0, f64::NAN, 0.0];
1618        let mut out = zeroed_hold();
1619        let call = |drop: c_double, mag: c_double, plane: c_int, ref_mag: c_double,
1620                    m: &[c_double], out: &mut FFIReticleHold| unsafe {
1621            ballistics_hold_point_in_reticle(
1622                drop,
1623                0.0,
1624                mag,
1625                m.as_ptr(),
1626                (m.len() / 2) as c_int,
1627                plane,
1628                ref_mag,
1629                out,
1630            )
1631        };
1632
1633        assert_eq!(
1634            call(1.0, 0.0, FFI_RETICLE_FIRST_FOCAL_PLANE, 0.0, &marks, &mut out),
1635            FFI_RETICLE_ERR_MAGNIFICATION
1636        );
1637        assert_eq!(
1638            call(1.0, 10.0, FFI_RETICLE_SECOND_FOCAL_PLANE, 0.0, &marks, &mut out),
1639            FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION
1640        );
1641        assert_eq!(
1642            call(f64::NAN, 10.0, FFI_RETICLE_FIRST_FOCAL_PLANE, 0.0, &marks, &mut out),
1643            FFI_RETICLE_ERR_NON_FINITE
1644        );
1645        assert_eq!(
1646            call(1.0, 10.0, FFI_RETICLE_FIRST_FOCAL_PLANE, 0.0, &bad_marks, &mut out),
1647            FFI_RETICLE_ERR_NON_FINITE
1648        );
1649        assert_eq!(out.nearest_mark, -99, "out stays untouched on every error");
1650    }
1651
1652    /// MBA-1386 scope addition: `bc_type` gains an additive numeric slot (8) for the
1653    /// new RA4 family, appended after the existing 0-7 mapping (G1, G7, G2, G5, G6,
1654    /// G8, GI, GS). No existing caller's value changes meaning; `8` is simply new,
1655    /// and anything still outside 0-8 keeps falling back to G1.
1656    #[test]
1657    fn bc_type_8_maps_to_ra4() {
1658        let mut inputs = valid_trajectory_inputs();
1659        inputs.bc_type = 8;
1660        assert_eq!(convert_inputs(&inputs).bc_type, DragModel::RA4);
1661
1662        // Every pre-existing code is unaffected by the addition.
1663        let expected = [
1664            (0, DragModel::G1),
1665            (1, DragModel::G7),
1666            (2, DragModel::G2),
1667            (3, DragModel::G5),
1668            (4, DragModel::G6),
1669            (5, DragModel::G8),
1670            (6, DragModel::GI),
1671            (7, DragModel::GS),
1672        ];
1673        for (code, model) in expected {
1674            let mut inputs = valid_trajectory_inputs();
1675            inputs.bc_type = code;
1676            assert_eq!(convert_inputs(&inputs).bc_type, model, "code {code}");
1677        }
1678
1679        // Unrecognized codes (including ones above the new 8) still fall back to G1.
1680        for code in [9, 42, -1] {
1681            let mut inputs = valid_trajectory_inputs();
1682            inputs.bc_type = code;
1683            assert_eq!(convert_inputs(&inputs).bc_type, DragModel::G1, "code {code}");
1684        }
1685    }
1686
1687    #[test]
1688    fn null_pointer_contracts_return_sentinels_and_free_safely() {
1689        unsafe {
1690            assert!(ballistics_calculate_trajectory(
1691                std::ptr::null(),
1692                std::ptr::null(),
1693                std::ptr::null(),
1694                1_000.0,
1695                1.0,
1696            )
1697            .is_null());
1698            assert!(ballistics_calculate_zero_angle(
1699                std::ptr::null(),
1700                std::ptr::null(),
1701                std::ptr::null(),
1702                100.0,
1703            )
1704            .is_nan());
1705            assert!(ballistics_calculate_trajectory_with_drag_table(
1706                std::ptr::null(),
1707                std::ptr::null(),
1708                std::ptr::null(),
1709                1_000.0,
1710                1.0,
1711                DECK_MACH.as_ptr(),
1712                DECK_CD_LOW.as_ptr(),
1713                DECK_MACH.len() as c_int,
1714            )
1715            .is_null());
1716            assert!(ballistics_calculate_zero_angle_with_drag_table(
1717                std::ptr::null(),
1718                std::ptr::null(),
1719                std::ptr::null(),
1720                100.0,
1721                DECK_MACH.as_ptr(),
1722                DECK_CD_LOW.as_ptr(),
1723                DECK_MACH.len() as c_int,
1724            )
1725            .is_nan());
1726            assert!(
1727                ballistics_monte_carlo(std::ptr::null(), std::ptr::null(), std::ptr::null(),)
1728                    .is_null()
1729            );
1730            assert!(ballistics_monte_carlo_with_direction_std_dev(
1731                std::ptr::null(),
1732                std::ptr::null(),
1733                std::ptr::null(),
1734                0.1,
1735            )
1736            .is_null());
1737
1738            ballistics_free_trajectory_result(std::ptr::null_mut());
1739            ballistics_free_monte_carlo_results(std::ptr::null_mut());
1740        }
1741    }
1742
1743    #[test]
1744    fn mba1283_ffi_enforces_step_floor_for_every_solver_mode() {
1745        for (mode, use_rk4, use_adaptive_rk45) in [("Euler", 0, 0), ("RK4", 1, 0), ("RK45", 1, 1)] {
1746            for step_size in [
1747                f64::NAN,
1748                f64::INFINITY,
1749                f64::NEG_INFINITY,
1750                -1.0,
1751                -0.0,
1752                0.0,
1753                0.001,
1754                MIN_FFI_STEP_SIZE_MS - 0.001,
1755            ] {
1756                let mut inputs = valid_trajectory_inputs();
1757                inputs.use_rk4 = use_rk4;
1758                inputs.use_adaptive_rk45 = use_adaptive_rk45;
1759                let result = unsafe {
1760                    ballistics_calculate_trajectory(
1761                        &inputs,
1762                        std::ptr::null(),
1763                        std::ptr::null(),
1764                        0.01,
1765                        step_size,
1766                    )
1767                };
1768                assert!(
1769                    result.is_null(),
1770                    "{mode} step_size={step_size:?} bypassed the FFI floor"
1771                );
1772            }
1773
1774            let mut inputs = valid_trajectory_inputs();
1775            inputs.use_rk4 = use_rk4;
1776            inputs.use_adaptive_rk45 = use_adaptive_rk45;
1777            let result = unsafe {
1778                ballistics_calculate_trajectory(
1779                    &inputs,
1780                    std::ptr::null(),
1781                    std::ptr::null(),
1782                    0.01,
1783                    MIN_FFI_STEP_SIZE_MS,
1784                )
1785            };
1786            assert!(
1787                !result.is_null(),
1788                "the documented minimum step must remain usable in {mode}"
1789            );
1790            unsafe {
1791                assert!((*result).point_count >= 0);
1792                assert!((*result).point_count as usize <= crate::MAX_TRAJECTORY_POINTS);
1793                ballistics_free_trajectory_result(result);
1794            }
1795        }
1796    }
1797
1798    /// A tiny valid deck: strictly ascending Mach, positive Cd.
1799    const DECK_MACH: [f64; 4] = [0.5, 1.0, 2.0, 3.0];
1800    /// Deliberately LOW drag so the deck measurably increases impact velocity vs G1.
1801    const DECK_CD_LOW: [f64; 4] = [0.05, 0.08, 0.06, 0.05];
1802
1803    #[test]
1804    fn trajectory_with_drag_table_applies_the_deck() {
1805        let inputs = valid_trajectory_inputs();
1806        unsafe {
1807            let plain = ballistics_calculate_trajectory(
1808                &inputs,
1809                std::ptr::null(),
1810                std::ptr::null(),
1811                300.0,
1812                1.0,
1813            );
1814            let decked = ballistics_calculate_trajectory_with_drag_table(
1815                &inputs,
1816                std::ptr::null(),
1817                std::ptr::null(),
1818                300.0,
1819                1.0,
1820                DECK_MACH.as_ptr(),
1821                DECK_CD_LOW.as_ptr(),
1822                DECK_MACH.len() as c_int,
1823            );
1824            assert!(!plain.is_null() && !decked.is_null());
1825            // The low-drag deck must retain materially more velocity than the G-model.
1826            assert!(
1827                (*decked).impact_velocity > (*plain).impact_velocity + 1.0,
1828                "deck did not change the solve: plain={} decked={}",
1829                (*plain).impact_velocity,
1830                (*decked).impact_velocity
1831            );
1832            ballistics_free_trajectory_result(plain);
1833            ballistics_free_trajectory_result(decked);
1834        }
1835    }
1836
1837    #[test]
1838    fn trajectory_with_drag_table_rejects_invalid_decks() {
1839        let inputs = valid_trajectory_inputs();
1840        let descending = [3.0, 2.0, 1.0, 0.5];
1841        let negative_cd = [0.05, -0.08, 0.06, 0.05];
1842        unsafe {
1843            // null arrays
1844            assert!(ballistics_calculate_trajectory_with_drag_table(
1845                &inputs,
1846                std::ptr::null(),
1847                std::ptr::null(),
1848                300.0,
1849                1.0,
1850                std::ptr::null(),
1851                DECK_CD_LOW.as_ptr(),
1852                4,
1853            )
1854            .is_null());
1855            assert!(ballistics_calculate_trajectory_with_drag_table(
1856                &inputs,
1857                std::ptr::null(),
1858                std::ptr::null(),
1859                300.0,
1860                1.0,
1861                DECK_MACH.as_ptr(),
1862                std::ptr::null(),
1863                4,
1864            )
1865            .is_null());
1866            // too few points
1867            assert!(ballistics_calculate_trajectory_with_drag_table(
1868                &inputs,
1869                std::ptr::null(),
1870                std::ptr::null(),
1871                300.0,
1872                1.0,
1873                DECK_MACH.as_ptr(),
1874                DECK_CD_LOW.as_ptr(),
1875                1,
1876            )
1877            .is_null());
1878            // non-ascending Mach
1879            assert!(ballistics_calculate_trajectory_with_drag_table(
1880                &inputs,
1881                std::ptr::null(),
1882                std::ptr::null(),
1883                300.0,
1884                1.0,
1885                descending.as_ptr(),
1886                DECK_CD_LOW.as_ptr(),
1887                4,
1888            )
1889            .is_null());
1890            // non-positive Cd
1891            assert!(ballistics_calculate_trajectory_with_drag_table(
1892                &inputs,
1893                std::ptr::null(),
1894                std::ptr::null(),
1895                300.0,
1896                1.0,
1897                DECK_MACH.as_ptr(),
1898                negative_cd.as_ptr(),
1899                4,
1900            )
1901            .is_null());
1902            // null inputs still rejected
1903            assert!(ballistics_calculate_trajectory_with_drag_table(
1904                std::ptr::null(),
1905                std::ptr::null(),
1906                std::ptr::null(),
1907                300.0,
1908                1.0,
1909                DECK_MACH.as_ptr(),
1910                DECK_CD_LOW.as_ptr(),
1911                4,
1912            )
1913            .is_null());
1914        }
1915    }
1916
1917    #[test]
1918    fn zero_angle_with_drag_table_applies_the_deck() {
1919        // A realistic zeroing setup: 100 m zero.
1920        let inputs = valid_trajectory_inputs();
1921        unsafe {
1922            let plain =
1923                ballistics_calculate_zero_angle(&inputs, std::ptr::null(), std::ptr::null(), 100.0);
1924            let decked = ballistics_calculate_zero_angle_with_drag_table(
1925                &inputs,
1926                std::ptr::null(),
1927                std::ptr::null(),
1928                100.0,
1929                DECK_MACH.as_ptr(),
1930                DECK_CD_LOW.as_ptr(),
1931                DECK_MACH.len() as c_int,
1932            );
1933            assert!(plain.is_finite() && decked.is_finite());
1934            // A much lower-drag deck needs a flatter (smaller) zero angle; at minimum it
1935            // must differ measurably from the G-model zero.
1936            assert!(
1937                (plain - decked).abs() > 1e-6,
1938                "deck did not change the zero: plain={plain} decked={decked}"
1939            );
1940        }
1941    }
1942
1943    #[test]
1944    fn zero_angle_with_drag_table_rejects_invalid_decks() {
1945        let inputs = valid_trajectory_inputs();
1946        let descending = [3.0, 2.0, 1.0, 0.5];
1947        unsafe {
1948            assert!(ballistics_calculate_zero_angle_with_drag_table(
1949                &inputs,
1950                std::ptr::null(),
1951                std::ptr::null(),
1952                100.0,
1953                std::ptr::null(),
1954                DECK_CD_LOW.as_ptr(),
1955                4,
1956            )
1957            .is_nan());
1958            assert!(ballistics_calculate_zero_angle_with_drag_table(
1959                &inputs,
1960                std::ptr::null(),
1961                std::ptr::null(),
1962                100.0,
1963                DECK_MACH.as_ptr(),
1964                DECK_CD_LOW.as_ptr(),
1965                0,
1966            )
1967            .is_nan());
1968            assert!(ballistics_calculate_zero_angle_with_drag_table(
1969                &inputs,
1970                std::ptr::null(),
1971                std::ptr::null(),
1972                100.0,
1973                descending.as_ptr(),
1974                DECK_CD_LOW.as_ptr(),
1975                4,
1976            )
1977            .is_nan());
1978            // null inputs still rejected
1979            assert!(ballistics_calculate_zero_angle_with_drag_table(
1980                std::ptr::null(),
1981                std::ptr::null(),
1982                std::ptr::null(),
1983                100.0,
1984                DECK_MACH.as_ptr(),
1985                DECK_CD_LOW.as_ptr(),
1986                4,
1987            )
1988            .is_nan());
1989        }
1990    }
1991
1992    #[test]
1993    fn zero_then_fly_with_same_deck_is_consistent() {
1994        // The pair-use case the two exports exist for: zero with the deck, fly with the
1995        // deck at the solved angle; the trajectory must cross near sight height at the
1996        // zero distance (i.e. the two functions share identical deck semantics).
1997        let mut inputs = valid_trajectory_inputs();
1998        unsafe {
1999            let angle = ballistics_calculate_zero_angle_with_drag_table(
2000                &inputs,
2001                std::ptr::null(),
2002                std::ptr::null(),
2003                100.0,
2004                DECK_MACH.as_ptr(),
2005                DECK_CD_LOW.as_ptr(),
2006                DECK_MACH.len() as c_int,
2007            );
2008            assert!(angle.is_finite());
2009            inputs.muzzle_angle = angle;
2010            let result = ballistics_calculate_trajectory_with_drag_table(
2011                &inputs,
2012                std::ptr::null(),
2013                std::ptr::null(),
2014                150.0,
2015                1.0,
2016                DECK_MACH.as_ptr(),
2017                DECK_CD_LOW.as_ptr(),
2018                DECK_MACH.len() as c_int,
2019            );
2020            assert!(!result.is_null());
2021            // Interpolate y at exactly the zero distance (100 m) rather than snapping to the
2022            // nearest raw trajectory sample, so the residual reflects only zero-solver
2023            // convergence, not the sampling grid's x-offset from 100 m.
2024            let zero_distance = 100.0;
2025            let pts = std::slice::from_raw_parts((*result).points, (*result).point_count as usize);
2026            let bracket = pts
2027                .windows(2)
2028                .find(|w| w[0].position_x <= zero_distance && w[1].position_x >= zero_distance)
2029                .expect("trajectory brackets the zero distance");
2030            let (lo, hi) = (&bracket[0], &bracket[1]);
2031            let y_at_zero = if hi.position_x > lo.position_x {
2032                let t = (zero_distance - lo.position_x) / (hi.position_x - lo.position_x);
2033                lo.position_y + t * (hi.position_y - lo.position_y)
2034            } else {
2035                lo.position_y
2036            };
2037            assert!(
2038                (y_at_zero - inputs.sight_height).abs() < 0.002,
2039                "zeroed flight missed the line of sight at 100 m: y={} (sight_height={})",
2040                y_at_zero,
2041                inputs.sight_height
2042            );
2043            ballistics_free_trajectory_result(result);
2044        }
2045    }
2046
2047    #[test]
2048    fn drag_table_len_above_cap_is_rejected() {
2049        // A valid, monotonically increasing deck that is simply too long: the cap
2050        // must reject it BEFORE the to_vec() copies, returning the null sentinel.
2051        let n = (MAX_FFI_DRAG_TABLE_LEN + 1) as usize;
2052        let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
2053        let cd: Vec<f64> = vec![0.3; n];
2054        let inputs = valid_trajectory_inputs();
2055        unsafe {
2056            let r = ballistics_calculate_trajectory_with_drag_table(
2057                &inputs,
2058                std::ptr::null(),
2059                std::ptr::null(),
2060                300.0,
2061                1.0,
2062                mach.as_ptr(),
2063                cd.as_ptr(),
2064                n as c_int,
2065            );
2066            assert!(r.is_null(), "len {n} must be rejected by the cap");
2067        }
2068    }
2069
2070    #[test]
2071    fn drag_table_len_at_cap_is_accepted() {
2072        let n = MAX_FFI_DRAG_TABLE_LEN as usize;
2073        let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
2074        let cd: Vec<f64> = vec![0.3; n];
2075        let inputs = valid_trajectory_inputs();
2076        unsafe {
2077            let r = ballistics_calculate_trajectory_with_drag_table(
2078                &inputs,
2079                std::ptr::null(),
2080                std::ptr::null(),
2081                300.0,
2082                1.0,
2083                mach.as_ptr(),
2084                cd.as_ptr(),
2085                n as c_int,
2086            );
2087            assert!(!r.is_null(), "len == cap must be accepted");
2088            ballistics_free_trajectory_result(r);
2089        }
2090    }
2091
2092    #[test]
2093    fn ffi_cant_angle_deflects_laterally() {
2094        let mut level = valid_trajectory_inputs();
2095        level.muzzle_angle = 0.003;
2096        let mut canted = valid_trajectory_inputs();
2097        canted.muzzle_angle = 0.003;
2098        canted.cant_angle = 10f64.to_radians();
2099        unsafe {
2100            let a = ballistics_calculate_trajectory(&level, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
2101            let b = ballistics_calculate_trajectory(&canted, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
2102            assert!(!a.is_null() && !b.is_null());
2103            let za = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_z;
2104            let zb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_z;
2105            assert!(zb > za + 0.005, "FFI cant must deflect right: level={za} canted={zb}");
2106            ballistics_free_trajectory_result(a);
2107            ballistics_free_trajectory_result(b);
2108        }
2109    }
2110
2111    #[test]
2112    fn ffi_vertical_wind_raises_trajectory() {
2113        let inputs = valid_trajectory_inputs();
2114        let no_wind = FFIWindConditions {
2115            speed: 0.0,
2116            direction: 0.0,
2117            vertical_speed: 0.0,
2118        };
2119        let updraft = FFIWindConditions {
2120            speed: 0.0,
2121            direction: 0.0,
2122            vertical_speed: 5.0,
2123        };
2124        unsafe {
2125            let a = ballistics_calculate_trajectory(&inputs, &no_wind, std::ptr::null(), 400.0, 1.0);
2126            let b = ballistics_calculate_trajectory(&inputs, &updraft, std::ptr::null(), 400.0, 1.0);
2127            assert!(!a.is_null() && !b.is_null());
2128            let ya = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_y;
2129            let yb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_y;
2130            assert!(yb > ya + 0.01, "FFI updraft must raise the trajectory: no_wind={ya} updraft={yb}");
2131            ballistics_free_trajectory_result(a);
2132            ballistics_free_trajectory_result(b);
2133        }
2134    }
2135
2136    // --- MBA-1356: cd_scale `_scaled` FFI variants ---
2137
2138    #[test]
2139    fn trajectory_scaled_at_one_matches_unscaled_export() {
2140        let inputs = valid_trajectory_inputs();
2141        unsafe {
2142            let unscaled = ballistics_calculate_trajectory_with_drag_table(
2143                &inputs,
2144                std::ptr::null(),
2145                std::ptr::null(),
2146                300.0,
2147                1.0,
2148                DECK_MACH.as_ptr(),
2149                DECK_CD_LOW.as_ptr(),
2150                DECK_MACH.len() as c_int,
2151            );
2152            let scaled = ballistics_calculate_trajectory_with_drag_table_scaled(
2153                &inputs,
2154                std::ptr::null(),
2155                std::ptr::null(),
2156                300.0,
2157                1.0,
2158                DECK_MACH.as_ptr(),
2159                DECK_CD_LOW.as_ptr(),
2160                DECK_MACH.len() as c_int,
2161                1.0,
2162            );
2163            assert!(!unscaled.is_null() && !scaled.is_null());
2164            assert_eq!(
2165                (*unscaled).impact_velocity.to_bits(),
2166                (*scaled).impact_velocity.to_bits(),
2167                "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={} scaled={}",
2168                (*unscaled).impact_velocity,
2169                (*scaled).impact_velocity
2170            );
2171            ballistics_free_trajectory_result(unscaled);
2172            ballistics_free_trajectory_result(scaled);
2173        }
2174    }
2175
2176    #[test]
2177    fn trajectory_scaled_at_1_10_lowers_impact_velocity() {
2178        let inputs = valid_trajectory_inputs();
2179        unsafe {
2180            let baseline = ballistics_calculate_trajectory_with_drag_table_scaled(
2181                &inputs,
2182                std::ptr::null(),
2183                std::ptr::null(),
2184                300.0,
2185                1.0,
2186                DECK_MACH.as_ptr(),
2187                DECK_CD_LOW.as_ptr(),
2188                DECK_MACH.len() as c_int,
2189                1.0,
2190            );
2191            let scaled_up = ballistics_calculate_trajectory_with_drag_table_scaled(
2192                &inputs,
2193                std::ptr::null(),
2194                std::ptr::null(),
2195                300.0,
2196                1.0,
2197                DECK_MACH.as_ptr(),
2198                DECK_CD_LOW.as_ptr(),
2199                DECK_MACH.len() as c_int,
2200                1.10,
2201            );
2202            assert!(!baseline.is_null() && !scaled_up.is_null());
2203            assert!(
2204                (*scaled_up).impact_velocity < (*baseline).impact_velocity,
2205                "cd_scale=1.10 must increase drag -> lower impact velocity: base={} scaled={}",
2206                (*baseline).impact_velocity,
2207                (*scaled_up).impact_velocity
2208            );
2209            ballistics_free_trajectory_result(baseline);
2210            ballistics_free_trajectory_result(scaled_up);
2211        }
2212    }
2213
2214    #[test]
2215    fn trajectory_scaled_rejects_invalid_cd_scale() {
2216        let inputs = valid_trajectory_inputs();
2217        unsafe {
2218            for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2219                let r = ballistics_calculate_trajectory_with_drag_table_scaled(
2220                    &inputs,
2221                    std::ptr::null(),
2222                    std::ptr::null(),
2223                    300.0,
2224                    1.0,
2225                    DECK_MACH.as_ptr(),
2226                    DECK_CD_LOW.as_ptr(),
2227                    DECK_MACH.len() as c_int,
2228                    bad,
2229                );
2230                assert!(r.is_null(), "cd_scale={bad} must be rejected (null sentinel)");
2231            }
2232        }
2233    }
2234
2235    #[test]
2236    fn zero_angle_scaled_at_one_matches_unscaled_export() {
2237        let inputs = valid_trajectory_inputs();
2238        unsafe {
2239            let unscaled = ballistics_calculate_zero_angle_with_drag_table(
2240                &inputs,
2241                std::ptr::null(),
2242                std::ptr::null(),
2243                100.0,
2244                DECK_MACH.as_ptr(),
2245                DECK_CD_LOW.as_ptr(),
2246                DECK_MACH.len() as c_int,
2247            );
2248            let scaled = ballistics_calculate_zero_angle_with_drag_table_scaled(
2249                &inputs,
2250                std::ptr::null(),
2251                std::ptr::null(),
2252                100.0,
2253                DECK_MACH.as_ptr(),
2254                DECK_CD_LOW.as_ptr(),
2255                DECK_MACH.len() as c_int,
2256                1.0,
2257            );
2258            assert!(unscaled.is_finite() && scaled.is_finite());
2259            assert_eq!(
2260                unscaled.to_bits(),
2261                scaled.to_bits(),
2262                "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={unscaled} scaled={scaled}"
2263            );
2264        }
2265    }
2266
2267    #[test]
2268    fn zero_angle_scaled_at_1_10_differs_from_baseline() {
2269        let inputs = valid_trajectory_inputs();
2270        unsafe {
2271            let baseline = ballistics_calculate_zero_angle_with_drag_table_scaled(
2272                &inputs,
2273                std::ptr::null(),
2274                std::ptr::null(),
2275                100.0,
2276                DECK_MACH.as_ptr(),
2277                DECK_CD_LOW.as_ptr(),
2278                DECK_MACH.len() as c_int,
2279                1.0,
2280            );
2281            let scaled_up = ballistics_calculate_zero_angle_with_drag_table_scaled(
2282                &inputs,
2283                std::ptr::null(),
2284                std::ptr::null(),
2285                100.0,
2286                DECK_MACH.as_ptr(),
2287                DECK_CD_LOW.as_ptr(),
2288                DECK_MACH.len() as c_int,
2289                1.10,
2290            );
2291            assert!(baseline.is_finite() && scaled_up.is_finite());
2292            // More drag needs a steeper (larger) zero angle to still reach 100 m.
2293            assert!(
2294                scaled_up > baseline,
2295                "cd_scale=1.10 must need a larger zero angle: base={baseline} scaled={scaled_up}"
2296            );
2297        }
2298    }
2299
2300    #[test]
2301    fn zero_angle_scaled_rejects_invalid_cd_scale() {
2302        let inputs = valid_trajectory_inputs();
2303        unsafe {
2304            for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2305                let angle = ballistics_calculate_zero_angle_with_drag_table_scaled(
2306                    &inputs,
2307                    std::ptr::null(),
2308                    std::ptr::null(),
2309                    100.0,
2310                    DECK_MACH.as_ptr(),
2311                    DECK_CD_LOW.as_ptr(),
2312                    DECK_MACH.len() as c_int,
2313                    bad,
2314                );
2315                assert!(angle.is_nan(), "cd_scale={bad} must be rejected (NaN sentinel)");
2316            }
2317        }
2318    }
2319
2320    /// Legacy exports must remain byte-identical: the same deck through the unscaled
2321    /// export must be unaffected by the new cd_scale plumbing (a regression guard
2322    /// alongside the pre-existing, untouched drag-table tests above).
2323    #[test]
2324    fn legacy_drag_table_exports_unaffected_by_cd_scale_plumbing() {
2325        let inputs = valid_trajectory_inputs();
2326        unsafe {
2327            let a = ballistics_calculate_trajectory_with_drag_table(
2328                &inputs,
2329                std::ptr::null(),
2330                std::ptr::null(),
2331                300.0,
2332                1.0,
2333                DECK_MACH.as_ptr(),
2334                DECK_CD_LOW.as_ptr(),
2335                DECK_MACH.len() as c_int,
2336            );
2337            let b = ballistics_calculate_trajectory_with_drag_table(
2338                &inputs,
2339                std::ptr::null(),
2340                std::ptr::null(),
2341                300.0,
2342                1.0,
2343                DECK_MACH.as_ptr(),
2344                DECK_CD_LOW.as_ptr(),
2345                DECK_MACH.len() as c_int,
2346            );
2347            assert!(!a.is_null() && !b.is_null());
2348            assert_eq!((*a).impact_velocity.to_bits(), (*b).impact_velocity.to_bits());
2349            ballistics_free_trajectory_result(a);
2350            ballistics_free_trajectory_result(b);
2351
2352            let za = ballistics_calculate_zero_angle_with_drag_table(
2353                &inputs,
2354                std::ptr::null(),
2355                std::ptr::null(),
2356                100.0,
2357                DECK_MACH.as_ptr(),
2358                DECK_CD_LOW.as_ptr(),
2359                DECK_MACH.len() as c_int,
2360            );
2361            let zb = ballistics_calculate_zero_angle_with_drag_table(
2362                &inputs,
2363                std::ptr::null(),
2364                std::ptr::null(),
2365                100.0,
2366                DECK_MACH.as_ptr(),
2367                DECK_CD_LOW.as_ptr(),
2368                DECK_MACH.len() as c_int,
2369            );
2370            assert!(za.is_finite() && zb.is_finite());
2371            assert_eq!(za.to_bits(), zb.to_bits());
2372        }
2373    }
2374
2375    // ---- MBA-1365: ballistics_bc_for_reference_standard --------------------------------
2376
2377    #[test]
2378    fn bc_for_reference_standard_icao_is_a_byte_identical_no_op() {
2379        let bc = 0.475;
2380        assert_eq!(
2381            ballistics_bc_for_reference_standard(bc, FFI_BC_REFERENCE_ICAO).to_bits(),
2382            bc.to_bits()
2383        );
2384    }
2385
2386    #[test]
2387    fn bc_for_reference_standard_unrecognized_value_falls_back_to_icao() {
2388        // Mirrors convert_inputs' permissive unrecognized-bc_type convention: an unknown
2389        // reference_standard is treated as ICAO (0), not rejected.
2390        let bc = 0.475;
2391        assert_eq!(
2392            ballistics_bc_for_reference_standard(bc, 99).to_bits(),
2393            bc.to_bits()
2394        );
2395    }
2396
2397    #[test]
2398    fn bc_for_reference_standard_army_standard_metro_applies_the_documented_ratio() {
2399        let bc = 0.475;
2400        let converted =
2401            ballistics_bc_for_reference_standard(bc, FFI_BC_REFERENCE_ARMY_STANDARD_METRO);
2402        assert_eq!(converted, bc * crate::constants::ASM_TO_ICAO_BC);
2403        // Smaller BC == more drag under this engine's ICAO-calibrated retardation math.
2404        assert!(converted < bc);
2405    }
2406
2407    // ---- MBA-1397: ballistics_reduce_qnh_pressure + FFIAtmosphericConditions.pressure ---
2408
2409    #[test]
2410    fn reduce_qnh_pressure_matches_the_library_function_and_lowers_pressure() {
2411        let reduced = ballistics_reduce_qnh_pressure(1030.0, 1500.0);
2412        assert_eq!(
2413            reduced,
2414            crate::atmosphere::reduce_qnh_to_station_pressure(1030.0, 1500.0)
2415        );
2416        assert!(reduced < 1030.0);
2417    }
2418
2419    #[test]
2420    fn reduce_qnh_pressure_passes_through_non_finite_inputs() {
2421        assert!(ballistics_reduce_qnh_pressure(f64::NAN, 1500.0).is_nan());
2422        assert_eq!(ballistics_reduce_qnh_pressure(1030.0, f64::INFINITY), 1030.0);
2423    }
2424
2425    /// The FFI trajectory/Monte Carlo exports have always treated
2426    /// `FFIAtmosphericConditions.pressure` as absolute station pressure. A caller declaring a
2427    /// QNH (sea-level-corrected altimeter setting) reading MUST reduce it with
2428    /// `ballistics_reduce_qnh_pressure` before writing `pressure` -- this proves the reduced
2429    /// value actually reaches the solve (a materially different, and correct -- flatter,
2430    /// less-drop -- trajectory than feeding the raw, unreduced QNH straight through, which
2431    /// would silently over-state air density).
2432    #[test]
2433    fn ffi_trajectory_uses_the_reduced_pressure_not_the_raw_qnh() {
2434        let inputs = valid_trajectory_inputs();
2435        let altitude_m = 1500.0;
2436        let qnh_hpa = 1030.0;
2437        let reduced = ballistics_reduce_qnh_pressure(qnh_hpa, altitude_m);
2438        assert!(reduced < qnh_hpa);
2439
2440        let atmo_reduced = FFIAtmosphericConditions {
2441            temperature: 15.0,
2442            pressure: reduced,
2443            humidity: 50.0,
2444            altitude: altitude_m,
2445        };
2446        let atmo_raw_qnh = FFIAtmosphericConditions {
2447            temperature: 15.0,
2448            pressure: qnh_hpa,
2449            humidity: 50.0,
2450            altitude: altitude_m,
2451        };
2452
2453        unsafe {
2454            let a = ballistics_calculate_trajectory(
2455                &inputs,
2456                std::ptr::null(),
2457                &atmo_reduced,
2458                400.0,
2459                1.0,
2460            );
2461            let b = ballistics_calculate_trajectory(
2462                &inputs,
2463                std::ptr::null(),
2464                &atmo_raw_qnh,
2465                400.0,
2466                1.0,
2467            );
2468            assert!(!a.is_null() && !b.is_null());
2469            let drop_a = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2470                .last()
2471                .unwrap()
2472                .position_y;
2473            let drop_b = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2474                .last()
2475                .unwrap()
2476                .position_y;
2477            assert!(
2478                (drop_a - drop_b).abs() > 1e-6,
2479                "reduced vs. raw-QNH pressure must produce materially different trajectories: \
2480                 {drop_a} vs {drop_b}"
2481            );
2482            ballistics_free_trajectory_result(a);
2483            ballistics_free_trajectory_result(b);
2484        }
2485    }
2486
2487    /// Same proof as above, for the Monte Carlo FFI path (`ballistics_monte_carlo`), which
2488    /// reads `FFIAtmosphericConditions.pressure` into `BallisticInputs.pressure` directly
2489    /// (`ballistics_monte_carlo_impl`) before running the shared `run_monte_carlo_*` core.
2490    #[test]
2491    fn ffi_monte_carlo_uses_the_reduced_pressure_not_the_raw_qnh() {
2492        let inputs = valid_trajectory_inputs();
2493        let altitude_m = 1500.0;
2494        let qnh_hpa = 1030.0;
2495        let reduced = ballistics_reduce_qnh_pressure(qnh_hpa, altitude_m);
2496
2497        let atmo_reduced = FFIAtmosphericConditions {
2498            temperature: 15.0,
2499            pressure: reduced,
2500            humidity: 50.0,
2501            altitude: altitude_m,
2502        };
2503        let atmo_raw_qnh = FFIAtmosphericConditions {
2504            temperature: 15.0,
2505            pressure: qnh_hpa,
2506            humidity: 50.0,
2507            altitude: altitude_m,
2508        };
2509        let params = FFIMonteCarloParams {
2510            num_simulations: 200,
2511            velocity_std_dev: 1.0,
2512            angle_std_dev: 0.0,
2513            bc_std_dev: 0.0,
2514            wind_speed_std_dev: 0.0,
2515            target_distance: f64::NAN,
2516            base_wind_speed: 0.0,
2517            base_wind_direction: 0.0,
2518            azimuth_std_dev: 0.0,
2519        };
2520
2521        unsafe {
2522            let a = ballistics_monte_carlo(&inputs, &atmo_reduced, &params);
2523            let b = ballistics_monte_carlo(&inputs, &atmo_raw_qnh, &params);
2524            assert!(!a.is_null() && !b.is_null());
2525            assert!(
2526                ((*a).mean_range - (*b).mean_range).abs() > 0.5,
2527                "reduced vs. raw-QNH pressure must change MC mean range materially: \
2528                 {} vs {}",
2529                (*a).mean_range,
2530                (*b).mean_range
2531            );
2532            ballistics_free_monte_carlo_results(a);
2533            ballistics_free_monte_carlo_results(b);
2534        }
2535    }
2536
2537    // ---- MBA-1366: ballistics_density_altitude_* + FFIAtmosphericConditions parity --------
2538
2539    #[test]
2540    fn density_altitude_ffi_exports_match_the_library_function() {
2541        let da_m = 1000.0 * 0.3048;
2542        let expected = crate::atmosphere::resolve_atmosphere_for_density_altitude(da_m, None);
2543        assert_eq!(
2544            ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2545            expected.0
2546        );
2547        assert_eq!(
2548            ballistics_density_altitude_temperature_c(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2549            expected.1
2550        );
2551        assert_eq!(
2552            ballistics_density_altitude_pressure_hpa(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2553            expected.2
2554        );
2555
2556        // With no explicit temperature the resolved altitude must equal the input exactly.
2557        assert!((ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE) - da_m).abs() < 1e-6);
2558    }
2559
2560    #[test]
2561    fn density_altitude_ffi_explicit_temperature_is_honored_exactly() {
2562        let da_m = 500.0;
2563        let explicit_temp_c = 30.0;
2564        let expected =
2565            crate::atmosphere::resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
2566        assert_eq!(
2567            ballistics_density_altitude_temperature_c(da_m, explicit_temp_c),
2568            explicit_temp_c
2569        );
2570        assert_eq!(
2571            ballistics_density_altitude_temperature_c(da_m, explicit_temp_c),
2572            expected.1
2573        );
2574        assert_eq!(
2575            ballistics_density_altitude_pressure_hpa(da_m, explicit_temp_c),
2576            expected.2
2577        );
2578        assert_eq!(
2579            ballistics_density_altitude_altitude_m(da_m, explicit_temp_c),
2580            expected.0
2581        );
2582    }
2583
2584    #[test]
2585    fn density_altitude_ffi_non_finite_input_returns_nan() {
2586        assert!(
2587            ballistics_density_altitude_temperature_c(f64::INFINITY, FFI_NO_EXPLICIT_TEMPERATURE)
2588                .is_nan()
2589        );
2590        assert!(
2591            ballistics_density_altitude_pressure_hpa(f64::NAN, FFI_NO_EXPLICIT_TEMPERATURE).is_nan()
2592        );
2593        assert!(
2594            ballistics_density_altitude_altitude_m(f64::NEG_INFINITY, FFI_NO_EXPLICIT_TEMPERATURE)
2595                .is_nan()
2596        );
2597    }
2598
2599    /// Same proof shape as the QNH FFI tests above: writing the density-altitude-derived
2600    /// station values into `FFIAtmosphericConditions` before an existing trajectory export
2601    /// reaches the solve (a materially different, lower-density trajectory at a higher DA than
2602    /// at sea level, exactly like the QNH-vs-absolute divergence proof).
2603    #[test]
2604    fn ffi_trajectory_uses_the_density_altitude_derived_station_values() {
2605        let inputs = valid_trajectory_inputs();
2606        let da_m = 3000.0 * 0.3048; // 3000 ft density altitude
2607        let altitude_m =
2608            ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2609        let temperature_c =
2610            ballistics_density_altitude_temperature_c(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2611        let pressure_hpa =
2612            ballistics_density_altitude_pressure_hpa(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2613
2614        let atmo_da = FFIAtmosphericConditions {
2615            temperature: temperature_c,
2616            pressure: pressure_hpa,
2617            humidity: 50.0,
2618            altitude: altitude_m,
2619        };
2620        let atmo_sea_level = FFIAtmosphericConditions {
2621            temperature: 15.0,
2622            pressure: 1013.25,
2623            humidity: 50.0,
2624            altitude: 0.0,
2625        };
2626
2627        unsafe {
2628            let a = ballistics_calculate_trajectory(
2629                &inputs,
2630                std::ptr::null(),
2631                &atmo_da,
2632                400.0,
2633                1.0,
2634            );
2635            let b = ballistics_calculate_trajectory(
2636                &inputs,
2637                std::ptr::null(),
2638                &atmo_sea_level,
2639                400.0,
2640                1.0,
2641            );
2642            assert!(!a.is_null() && !b.is_null());
2643            let drop_a = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2644                .last()
2645                .unwrap()
2646                .position_y;
2647            let drop_b = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2648                .last()
2649                .unwrap()
2650                .position_y;
2651            assert!(
2652                (drop_a - drop_b).abs() > 1e-6,
2653                "density-altitude-derived vs sea-level atmosphere must produce materially \
2654                 different trajectories: {drop_a} vs {drop_b}"
2655            );
2656            ballistics_free_trajectory_result(a);
2657            ballistics_free_trajectory_result(b);
2658        }
2659    }
2660}