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