Skip to main content

ballistics_engine/
ffi.rs

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