Skip to main content

ballistics_engine/
ffi.rs

1//! FFI bindings for iOS/Swift integration
2
3use crate::{
4    calculate_zero_angle_with_conditions, run_monte_carlo, AtmosphericConditions, BallisticInputs,
5    DragModel, MonteCarloParams, TrajectorySolver, WindConditions,
6};
7use std::os::raw::{c_char, c_double, c_int};
8use std::ptr;
9
10// FFI-safe structures with C-compatible layouts
11
12#[repr(C)]
13pub struct FFIBallisticInputs {
14    pub muzzle_velocity: c_double,         // m/s
15    pub muzzle_angle: c_double,            // radians (launch angle)
16    pub bc_value: c_double,                // ballistic coefficient
17    pub bullet_mass: c_double,             // kg
18    pub bullet_diameter: c_double,         // meters
19    pub bc_type: c_int,                    // 0=G1, 1=G7, etc.
20    pub sight_height: c_double,            // meters
21    pub target_distance: c_double,         // meters
22    pub temperature: c_double,             // Celsius
23    pub twist_rate: c_double,              // inches per turn
24    pub is_twist_right: c_int,             // 0=false, 1=true
25    pub shooting_angle: c_double,          // uphill/downhill angle in radians
26    pub altitude: c_double,                // meters
27    pub latitude: c_double,                // degrees (use NAN if not provided)
28    pub azimuth_angle: c_double,           // horizontal aiming angle in radians
29    pub use_rk4: c_int,                    // 0=Euler, 1=RK4
30    pub use_adaptive_rk45: c_int,          // 0=false, 1=true (adaptive RK45)
31    pub enable_wind_shear: c_int,          // 0=false, 1=true
32    pub enable_trajectory_sampling: c_int, // 0=false, 1=true
33    pub sample_interval: c_double,         // meters
34    pub enable_pitch_damping: c_int,       // 0=false, 1=true
35    pub enable_precession_nutation: c_int, // 0=false, 1=true
36    pub enable_spin_drift: c_int,          // 0=false, 1=true
37    pub enable_magnus: c_int,              // 0=false, 1=true
38    pub enable_coriolis: c_int,            // 0=false, 1=true
39    // Appended (keeps existing field offsets): compass bearing the shot is fired
40    // along, radians, 0=North, PI/2=East. Drives the Coriolis Eötvös/drift azimuth.
41    // Distinct from azimuth_angle (the small aiming offset). 0.0 if unset.
42    pub shot_azimuth: c_double,
43}
44
45#[repr(C)]
46pub struct FFIWindConditions {
47    pub speed: c_double, // m/s
48    // radians, wind-FROM convention: 0 = headwind, PI/2 = from the right,
49    // PI = tailwind, 3*PI/2 = from the left (matches WindConditions / WindSock).
50    pub direction: c_double,
51}
52
53#[repr(C)]
54pub struct FFIAtmosphericConditions {
55    pub temperature: c_double, // Celsius
56    pub pressure: c_double,    // hPa
57    pub humidity: c_double,    // percentage (0-100)
58    pub altitude: c_double,    // meters
59}
60
61#[repr(C)]
62pub struct FFITrajectorySample {
63    pub distance: c_double,       // meters
64    pub time: c_double,           // seconds
65    pub velocity_mps: c_double,   // meters per second
66    pub energy_joules: c_double,  // joules
67    pub drop_meters: c_double,    // meters
68    pub windage_meters: c_double, // meters
69    pub mach: c_double,           // Mach number
70    pub spin_rate_rps: c_double,  // revolutions per second
71}
72
73#[repr(C)]
74pub struct FFITrajectoryPoint {
75    pub time: c_double,
76    pub position_x: c_double,
77    pub position_y: c_double,
78    pub position_z: c_double,
79    pub velocity_magnitude: c_double,
80    pub kinetic_energy: c_double,
81}
82
83#[repr(C)]
84pub struct FFITrajectoryResult {
85    pub max_range: c_double,
86    pub max_height: c_double,
87    pub time_of_flight: c_double,
88    pub impact_velocity: c_double,
89    pub impact_energy: c_double,
90    pub points: *mut FFITrajectoryPoint,
91    pub point_count: c_int,
92    pub sampled_points: *mut FFITrajectorySample,
93    pub sampled_point_count: c_int,
94    pub min_pitch_damping: c_double,    // NAN if not calculated
95    pub transonic_mach: c_double,       // NAN if not reached
96    pub final_pitch_angle: c_double,    // NAN if not calculated
97    pub final_yaw_angle: c_double,      // NAN if not calculated
98    pub max_yaw_angle: c_double,        // NAN if not calculated
99    pub max_precession_angle: c_double, // NAN if not calculated
100}
101
102// Monte Carlo simulation parameters
103#[repr(C)]
104pub struct FFIMonteCarloParams {
105    pub num_simulations: c_int,
106    pub velocity_std_dev: c_double,
107    pub angle_std_dev: c_double,
108    pub bc_std_dev: c_double,
109    pub wind_speed_std_dev: c_double,
110    pub target_distance: c_double,     // Use NAN if not specified
111    pub base_wind_speed: c_double,     // Base wind speed in m/s
112    pub base_wind_direction: c_double, // Base wind direction in radians
113    pub azimuth_std_dev: c_double,     // Horizontal aiming variation in radians
114}
115
116// Monte Carlo simulation results
117#[repr(C)]
118pub struct FFIMonteCarloResults {
119    pub ranges: *mut c_double,
120    pub impact_velocities: *mut c_double,
121    pub impact_positions_x: *mut c_double,
122    pub impact_positions_y: *mut c_double,
123    pub impact_positions_z: *mut c_double,
124    pub num_results: c_int,
125    pub mean_range: c_double,
126    pub std_dev_range: c_double,
127    pub mean_impact_velocity: c_double,
128    pub std_dev_impact_velocity: c_double,
129    pub hit_probability: c_double, // If target_distance was specified
130}
131
132// Helper function to convert FFI inputs to internal types
133fn convert_inputs(inputs: &FFIBallisticInputs) -> BallisticInputs {
134    let mut ballistic_inputs = BallisticInputs::default();
135
136    ballistic_inputs.muzzle_velocity = inputs.muzzle_velocity;
137    ballistic_inputs.muzzle_angle = inputs.muzzle_angle;
138    ballistic_inputs.azimuth_angle = inputs.azimuth_angle;
139    ballistic_inputs.shot_azimuth = inputs.shot_azimuth;
140    ballistic_inputs.use_rk4 = inputs.use_rk4 != 0;
141    ballistic_inputs.use_adaptive_rk45 = inputs.use_adaptive_rk45 != 0;
142    ballistic_inputs.bc_value = inputs.bc_value;
143    ballistic_inputs.bullet_mass = inputs.bullet_mass;
144    ballistic_inputs.bullet_diameter = inputs.bullet_diameter;
145    ballistic_inputs.bc_type = match inputs.bc_type {
146        1 => DragModel::G7,
147        2 => DragModel::G2,
148        3 => DragModel::G5,
149        4 => DragModel::G6,
150        5 => DragModel::G8,
151        6 => DragModel::GI,
152        7 => DragModel::GS,
153        _ => DragModel::G1,
154    };
155    ballistic_inputs.sight_height = inputs.sight_height;
156    ballistic_inputs.target_distance = inputs.target_distance;
157    ballistic_inputs.temperature = inputs.temperature;
158    ballistic_inputs.twist_rate = inputs.twist_rate;
159    ballistic_inputs.is_twist_right = inputs.is_twist_right != 0;
160    ballistic_inputs.shooting_angle = inputs.shooting_angle;
161    ballistic_inputs.altitude = inputs.altitude;
162
163    if !inputs.latitude.is_nan() {
164        ballistic_inputs.latitude = Some(inputs.latitude);
165    }
166
167    // Set derived values
168    ballistic_inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
169    ballistic_inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
170    // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default). The C ABI does
171    // not carry a bullet length, so derive it from diameter + mass; fall back to 4.5-cal if mass<=0.
172    ballistic_inputs.bullet_length = {
173        let est = crate::stability::estimate_bullet_length_m(
174            ballistic_inputs.bullet_diameter,
175            ballistic_inputs.bullet_mass,
176        );
177        if est > 0.0 {
178            est
179        } else {
180            ballistic_inputs.bullet_diameter * 4.5
181        }
182    };
183
184    // New advanced physics flags
185    ballistic_inputs.enable_wind_shear = inputs.enable_wind_shear != 0;
186    ballistic_inputs.enable_trajectory_sampling = inputs.enable_trajectory_sampling != 0;
187    ballistic_inputs.sample_interval = inputs.sample_interval;
188    ballistic_inputs.enable_pitch_damping = inputs.enable_pitch_damping != 0;
189    ballistic_inputs.enable_precession_nutation = inputs.enable_precession_nutation != 0;
190    ballistic_inputs.use_enhanced_spin_drift = inputs.enable_spin_drift != 0;
191    ballistic_inputs.enable_advanced_effects =
192        inputs.enable_magnus != 0 || inputs.enable_coriolis != 0;
193    // Gate Magnus and Coriolis independently so enabling one does not enable the other.
194    ballistic_inputs.enable_magnus = inputs.enable_magnus != 0;
195    ballistic_inputs.enable_coriolis = inputs.enable_coriolis != 0;
196
197    ballistic_inputs
198}
199
200// Main trajectory calculation function for FFI
201#[no_mangle]
202pub extern "C" fn ballistics_calculate_trajectory(
203    inputs: *const FFIBallisticInputs,
204    wind: *const FFIWindConditions,
205    atmosphere: *const FFIAtmosphericConditions,
206    max_range: c_double,
207    step_size: c_double,
208) -> *mut FFITrajectoryResult {
209    if inputs.is_null() {
210        return ptr::null_mut();
211    }
212
213    let inputs = unsafe { &*inputs };
214    let ballistic_inputs = convert_inputs(inputs);
215    let twist_rate_in = ballistic_inputs.twist_rate;
216
217    let wind_conditions = if wind.is_null() {
218        WindConditions::default()
219    } else {
220        let wind = unsafe { &*wind };
221        WindConditions {
222            speed: wind.speed,
223            direction: wind.direction,
224        }
225    };
226
227    let atmospheric_conditions = if atmosphere.is_null() {
228        AtmosphericConditions::default()
229    } else {
230        let atmo = unsafe { &*atmosphere };
231        AtmosphericConditions {
232            temperature: atmo.temperature,
233            pressure: atmo.pressure,
234            humidity: atmo.humidity,
235            altitude: atmo.altitude,
236        }
237    };
238
239    // Create solver and calculate trajectory
240    let (sample_temp_c, sample_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
241        atmospheric_conditions.temperature,
242        atmospheric_conditions.pressure,
243        atmospheric_conditions.altitude,
244    );
245    let (_, sample_speed_of_sound) = crate::atmosphere::calculate_atmosphere(
246        atmospheric_conditions.altitude,
247        Some(sample_temp_c),
248        Some(sample_pressure_hpa),
249        atmospheric_conditions.humidity,
250    );
251
252    let mut solver =
253        TrajectorySolver::new(ballistic_inputs, wind_conditions, atmospheric_conditions);
254
255    // Set max range and time step
256    solver.set_max_range(max_range);
257    solver.set_time_step(step_size / 1000.0); // Convert to time step
258
259    match solver.solve() {
260        Ok(result) => {
261            // Convert trajectory points to FFI format
262            let point_count = result.points.len();
263            let points = if point_count > 0 {
264                let mut ffi_points = Vec::with_capacity(point_count);
265                for (i, point) in result.points.iter().enumerate() {
266                    ffi_points.push(FFITrajectoryPoint {
267                        time: point.time,
268                        position_x: point.position[0],
269                        position_y: point.position[1],
270                        position_z: point.position[2],
271                        velocity_magnitude: point.velocity_magnitude,
272                        kinetic_energy: point.kinetic_energy,
273                    });
274
275                    // Debug: Log first, last, and every 100th point.
276                    // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral.
277                    // Raw position_x/_y/_z exported above are McCoy-ordered (X=downrange).
278                    #[cfg(debug_assertions)]
279                    if i == 0 || i == result.points.len() - 1 || i % 100 == 0 {
280                        eprintln!(
281                            "FFI point {}: lateral={:.2}m, vertical={:.2}m, downrange={:.2}m",
282                            i, point.position[2], point.position[1], point.position[0]
283                        );
284                    }
285                }
286                let points_ptr = ffi_points.as_mut_ptr();
287                std::mem::forget(ffi_points); // Prevent deallocation
288                points_ptr
289            } else {
290                ptr::null_mut()
291            };
292
293            // Convert sampled points if available
294            let (sampled_points, sampled_point_count) =
295                if let Some(ref samples) = result.sampled_points {
296                    let mut ffi_samples = Vec::with_capacity(samples.len());
297                    for sample in samples {
298                        ffi_samples.push(FFITrajectorySample {
299                            distance: sample.distance_m,
300                            time: sample.time_s,
301                            velocity_mps: sample.velocity_mps,
302                            energy_joules: sample.energy_j,
303                            drop_meters: sample.drop_m,
304                            windage_meters: sample.wind_drift_m,
305                            mach: if sample_speed_of_sound > 0.0 {
306                                sample.velocity_mps / sample_speed_of_sound
307                            } else {
308                                0.0
309                            },
310                            spin_rate_rps: if twist_rate_in > 0.0 {
311                                sample.velocity_mps / (twist_rate_in * 0.0254)
312                            } else {
313                                0.0
314                            },
315                        });
316                    }
317                    let count = ffi_samples.len() as c_int;
318                    let samples_ptr = ffi_samples.as_mut_ptr();
319                    std::mem::forget(ffi_samples);
320                    (samples_ptr, count)
321                } else {
322                    (ptr::null_mut(), 0)
323                };
324
325            // Extract angular state values if available
326            let (final_pitch, final_yaw, max_yaw, max_prec) =
327                if let Some(ref angular) = result.angular_state {
328                    (
329                        angular.pitch_angle,
330                        angular.yaw_angle,
331                        result.max_yaw_angle.unwrap_or(std::f64::NAN),
332                        result.max_precession_angle.unwrap_or(std::f64::NAN),
333                    )
334                } else {
335                    (std::f64::NAN, std::f64::NAN, std::f64::NAN, std::f64::NAN)
336                };
337
338            // Create result on heap
339            let ffi_result = Box::new(FFITrajectoryResult {
340                max_range: result.max_range,
341                max_height: result.max_height,
342                time_of_flight: result.time_of_flight,
343                impact_velocity: result.impact_velocity,
344                impact_energy: result.impact_energy,
345                points,
346                point_count: point_count as c_int,
347                sampled_points,
348                sampled_point_count,
349                min_pitch_damping: result.min_pitch_damping.unwrap_or(std::f64::NAN),
350                transonic_mach: result.transonic_mach.unwrap_or(std::f64::NAN),
351                final_pitch_angle: final_pitch,
352                final_yaw_angle: final_yaw,
353                max_yaw_angle: max_yaw,
354                max_precession_angle: max_prec,
355            });
356
357            Box::into_raw(ffi_result)
358        }
359        Err(_) => ptr::null_mut(),
360    }
361}
362
363// Free allocated trajectory result
364#[no_mangle]
365pub extern "C" fn ballistics_free_trajectory_result(result: *mut FFITrajectoryResult) {
366    if !result.is_null() {
367        unsafe {
368            let result = Box::from_raw(result);
369            if !result.points.is_null() && result.point_count > 0 {
370                let points = Vec::from_raw_parts(
371                    result.points,
372                    result.point_count as usize,
373                    result.point_count as usize,
374                );
375                drop(points);
376            }
377            if !result.sampled_points.is_null() && result.sampled_point_count > 0 {
378                let samples = Vec::from_raw_parts(
379                    result.sampled_points,
380                    result.sampled_point_count as usize,
381                    result.sampled_point_count as usize,
382                );
383                drop(samples);
384            }
385            drop(result);
386        }
387    }
388}
389
390// Calculate zero angle for a given target distance
391#[no_mangle]
392pub extern "C" fn ballistics_calculate_zero_angle(
393    inputs: *const FFIBallisticInputs,
394    wind: *const FFIWindConditions,
395    atmosphere: *const FFIAtmosphericConditions,
396    zero_distance: c_double,
397) -> c_double {
398    if inputs.is_null() {
399        return f64::NAN;
400    }
401
402    let inputs = unsafe { &*inputs };
403    let ballistic_inputs = convert_inputs(inputs);
404
405    let wind_conditions = if wind.is_null() {
406        WindConditions::default()
407    } else {
408        let wind = unsafe { &*wind };
409        WindConditions {
410            speed: wind.speed,
411            direction: wind.direction,
412        }
413    };
414
415    let atmospheric_conditions = if atmosphere.is_null() {
416        AtmosphericConditions::default()
417    } else {
418        let atmo = unsafe { &*atmosphere };
419        AtmosphericConditions {
420            temperature: atmo.temperature,
421            pressure: atmo.pressure,
422            humidity: atmo.humidity,
423            altitude: atmo.altitude,
424        }
425    };
426
427    // For zero angle, we want the bullet to hit at sight height at the zero distance
428    // This means the bullet crosses the line of sight at the zero distance
429    let target_height = ballistic_inputs.sight_height;
430
431    #[cfg(debug_assertions)]
432    {
433        eprintln!("FFI: Calculating zero angle for:");
434        eprintln!("  Zero distance: {} m", zero_distance);
435        eprintln!("  Target height: {} m", target_height);
436        eprintln!("  Sight height: {} m", ballistic_inputs.sight_height);
437        eprintln!("  Wind speed: {} m/s", wind_conditions.speed);
438        eprintln!("  Temperature: {} C", atmospheric_conditions.temperature);
439    }
440
441    match calculate_zero_angle_with_conditions(
442        ballistic_inputs,
443        zero_distance,
444        target_height,
445        wind_conditions,
446        atmospheric_conditions,
447    ) {
448        Ok(angle) => {
449            #[cfg(debug_assertions)]
450            eprintln!(
451                "  Calculated angle: {} rad ({} deg)",
452                angle,
453                angle * 180.0 / std::f64::consts::PI
454            );
455            angle
456        }
457        Err(e) => {
458            #[cfg(debug_assertions)]
459            eprintln!("  Error: {:?}", e);
460            f64::NAN
461        }
462    }
463}
464
465// Simple trajectory calculation for quick results
466#[no_mangle]
467pub extern "C" fn ballistics_quick_trajectory(
468    muzzle_velocity: c_double,
469    bc: c_double,
470    sight_height: c_double,
471    zero_distance: c_double,
472    target_distance: c_double,
473) -> c_double {
474    // This provides a simple drop calculation at target distance
475    // Using simplified ballistic calculations
476
477    let mut inputs = BallisticInputs::default();
478    inputs.muzzle_velocity = muzzle_velocity;
479    inputs.bc_value = bc;
480    inputs.sight_height = sight_height;
481    inputs.target_distance = target_distance;
482
483    let wind = WindConditions::default();
484    let atmo = AtmosphericConditions::default();
485
486    // First calculate zero angle
487    let zero_angle = match calculate_zero_angle_with_conditions(
488        inputs.clone(),
489        zero_distance,
490        sight_height,
491        wind.clone(),
492        atmo.clone(),
493    ) {
494        Ok(angle) => angle,
495        Err(_) => return f64::NAN,
496    };
497
498    // Now calculate trajectory with that zero angle
499    inputs.muzzle_angle = zero_angle;
500
501    let mut solver = TrajectorySolver::new(inputs, wind, atmo);
502    solver.set_max_range(target_distance * 1.1);
503
504    match solver.solve() {
505        Ok(result) => {
506            // Find the drop at target distance
507            for point in result.points {
508                if point.position[0] >= target_distance {
509                    return sight_height - point.position[1];
510                }
511            }
512            f64::NAN
513        }
514        Err(_) => f64::NAN,
515    }
516}
517
518// Monte Carlo simulation
519#[no_mangle]
520pub extern "C" fn ballistics_monte_carlo(
521    inputs: *const FFIBallisticInputs,
522    atmosphere: *const FFIAtmosphericConditions,
523    params: *const FFIMonteCarloParams,
524) -> *mut FFIMonteCarloResults {
525    if inputs.is_null() || params.is_null() {
526        return ptr::null_mut();
527    }
528
529    let inputs = unsafe { &*inputs };
530    let params = unsafe { &*params };
531
532    // Reject an out-of-range simulation count. num_simulations is a c_int (i32) cast straight to
533    // usize: a negative value would wrap to a near-max usize, and even a large positive value (up
534    // to i32::MAX ~ 2.1e9) would drive billions of iterations with the result arrays scaling to
535    // match — an unbounded-loop / OOM DoS from a single FFI call. Bound it to a sane maximum.
536    // (n == 0 also yields NaN stats and a zero-size allocation.)
537    const MAX_SIMULATIONS: c_int = 1_000_000;
538    if params.num_simulations <= 0 || params.num_simulations > MAX_SIMULATIONS {
539        return ptr::null_mut();
540    }
541
542    // Convert FFI inputs to internal types
543    let mut ballistic_inputs = convert_inputs(inputs);
544    ballistic_inputs.muzzle_height = 1.5;
545    ballistic_inputs.ground_threshold = 0.0;
546    if !atmosphere.is_null() {
547        let atmo = unsafe { &*atmosphere };
548        ballistic_inputs.temperature = atmo.temperature;
549        ballistic_inputs.pressure = atmo.pressure;
550        ballistic_inputs.humidity = (atmo.humidity / 100.0).clamp(0.0, 1.0);
551        ballistic_inputs.altitude = atmo.altitude;
552    }
553
554    // Create Monte Carlo parameters
555    let mc_params = MonteCarloParams {
556        num_simulations: params.num_simulations as usize,
557        velocity_std_dev: params.velocity_std_dev,
558        angle_std_dev: params.angle_std_dev,
559        bc_std_dev: params.bc_std_dev,
560        wind_speed_std_dev: params.wind_speed_std_dev,
561        target_distance: if params.target_distance.is_nan() {
562            None
563        } else {
564            Some(params.target_distance)
565        },
566        base_wind_speed: params.base_wind_speed,
567        base_wind_direction: params.base_wind_direction,
568        azimuth_std_dev: params.azimuth_std_dev,
569    };
570
571    // Run Monte Carlo simulation
572    match run_monte_carlo(ballistic_inputs, mc_params) {
573        Ok(results) => {
574            let num_results = results.ranges.len() as c_int;
575
576            // Calculate statistics
577            let mean_range: f64 = results.ranges.iter().sum::<f64>() / num_results as f64;
578            let variance_range: f64 = results
579                .ranges
580                .iter()
581                .map(|r| (r - mean_range).powi(2))
582                .sum::<f64>()
583                / num_results as f64;
584            let std_dev_range = variance_range.sqrt();
585
586            let mean_velocity: f64 =
587                results.impact_velocities.iter().sum::<f64>() / num_results as f64;
588            let variance_velocity: f64 = results
589                .impact_velocities
590                .iter()
591                .map(|v| (v - mean_velocity).powi(2))
592                .sum::<f64>()
593                / num_results as f64;
594            let std_dev_velocity = variance_velocity.sqrt();
595
596            // Calculate hit probability if target distance was specified. MBA-971: use the shared
597            // position-based criterion (fraction within DEFAULT_HIT_RADIUS_M of the point of aim
598            // at the target plane). The old inline version had a redundant `distance < target`
599            // clause comparing a ~meter deviation to the ~hundreds-of-meters target distance
600            // (effectively always true), and the CLI used a different range-based notion entirely.
601            let hit_probability = if params.target_distance.is_nan() {
602                0.0
603            } else {
604                results.hit_probability(crate::DEFAULT_HIT_RADIUS_M)
605            };
606
607            // Allocate memory for arrays
608            let ranges_ptr = unsafe {
609                let ptr = std::alloc::alloc(
610                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
611                ) as *mut c_double;
612                for (i, &range) in results.ranges.iter().enumerate() {
613                    *ptr.add(i) = range;
614                }
615                ptr
616            };
617
618            let velocities_ptr = unsafe {
619                let ptr = std::alloc::alloc(
620                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
621                ) as *mut c_double;
622                for (i, &vel) in results.impact_velocities.iter().enumerate() {
623                    *ptr.add(i) = vel;
624                }
625                ptr
626            };
627
628            let pos_x_ptr = unsafe {
629                let ptr = std::alloc::alloc(
630                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
631                ) as *mut c_double;
632                for (i, pos) in results.impact_positions.iter().enumerate() {
633                    *ptr.add(i) = pos.x;
634                }
635                ptr
636            };
637
638            let pos_y_ptr = unsafe {
639                let ptr = std::alloc::alloc(
640                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
641                ) as *mut c_double;
642                for (i, pos) in results.impact_positions.iter().enumerate() {
643                    *ptr.add(i) = pos.y;
644                }
645                ptr
646            };
647
648            let pos_z_ptr = unsafe {
649                let ptr = std::alloc::alloc(
650                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
651                ) as *mut c_double;
652                for (i, pos) in results.impact_positions.iter().enumerate() {
653                    *ptr.add(i) = pos.z;
654                }
655                ptr
656            };
657
658            // Create result structure
659            let result = Box::new(FFIMonteCarloResults {
660                ranges: ranges_ptr,
661                impact_velocities: velocities_ptr,
662                impact_positions_x: pos_x_ptr,
663                impact_positions_y: pos_y_ptr,
664                impact_positions_z: pos_z_ptr,
665                num_results,
666                mean_range,
667                std_dev_range,
668                mean_impact_velocity: mean_velocity,
669                std_dev_impact_velocity: std_dev_velocity,
670                hit_probability,
671            });
672
673            Box::into_raw(result)
674        }
675        Err(_) => ptr::null_mut(),
676    }
677}
678
679// Free Monte Carlo results
680#[no_mangle]
681pub extern "C" fn ballistics_free_monte_carlo_results(results: *mut FFIMonteCarloResults) {
682    if results.is_null() {
683        return;
684    }
685
686    unsafe {
687        let results = Box::from_raw(results);
688        let num = results.num_results as usize;
689
690        // Free arrays
691        if !results.ranges.is_null() {
692            std::alloc::dealloc(
693                results.ranges as *mut u8,
694                std::alloc::Layout::array::<c_double>(num).unwrap(),
695            );
696        }
697
698        if !results.impact_velocities.is_null() {
699            std::alloc::dealloc(
700                results.impact_velocities as *mut u8,
701                std::alloc::Layout::array::<c_double>(num).unwrap(),
702            );
703        }
704
705        if !results.impact_positions_x.is_null() {
706            std::alloc::dealloc(
707                results.impact_positions_x as *mut u8,
708                std::alloc::Layout::array::<c_double>(num).unwrap(),
709            );
710        }
711
712        if !results.impact_positions_y.is_null() {
713            std::alloc::dealloc(
714                results.impact_positions_y as *mut u8,
715                std::alloc::Layout::array::<c_double>(num).unwrap(),
716            );
717        }
718
719        if !results.impact_positions_z.is_null() {
720            std::alloc::dealloc(
721                results.impact_positions_z as *mut u8,
722                std::alloc::Layout::array::<c_double>(num).unwrap(),
723            );
724        }
725
726        // Box automatically deallocates the result structure
727    }
728}
729
730// Get library version
731#[no_mangle]
732pub extern "C" fn ballistics_get_version() -> *const c_char {
733    // Return a pointer to a static NUL-terminated string (the caller must NOT free it).
734    // Previously this leaked a freshly-allocated CString on every call and reported a
735    // stale hardcoded "0.3.0"; use the real crate version with no allocation.
736    concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
737}