Skip to main content

ballistics_engine/profile_import/
a7p.rs

1//! ArcherBC2 `.a7p` file parser.
2//!
3//! File layout: 32 ASCII hex characters (MD5 of the remainder) followed by a
4//! proto3 `Payload { Profile profile = 1; }` message. Field numbers and
5//! fixed-point scale factors below were confirmed empirically against a real
6//! ArcherBC2 export; they are interoperability facts, not vendored schema
7//! (the upstream a7p project is LGPL-3.0; nothing from it is copied here).
8
9use super::md5::md5_hex;
10use super::wire::{
11    collect_repeated_i32, parse_message, varint_to_i32, WireError, WireValue,
12};
13
14// Fixed-point scale factors (raw integer -> physical value = raw / SCALE).
15const SCALE_TWIST: f64 = 100.0; // r_twist -> inches/turn
16const SCALE_VELOCITY: f64 = 10.0; // c_muzzle_velocity, coef mv -> m/s
17const SCALE_DIMENSION: f64 = 1000.0; // b_diameter, b_length -> inches
18const SCALE_WEIGHT: f64 = 10.0; // b_weight -> grains
19const SCALE_PRESSURE: f64 = 10.0; // c_zero_air_pressure -> hPa
20const SCALE_COEF: f64 = 10_000.0; // coef bc_cd -> BC or Cd
21const SCALE_TCOEFF: f64 = 1000.0; // c_t_coeff -> % per 15 C
22const SCALE_DISTANCE: f64 = 100.0; // distances -> meters
23// CUSTOM coef rows reuse the `bc_cd` field for Cd (same SCALE_COEF as BC/Cd) but the `mv`
24// field means Mach number, not m/s, and uses its own scale (empirically confirmed, same
25// footing as the other scale factors above — see the module doc).
26const SCALE_MACH: f64 = 10_000.0; // coef mv (CUSTOM only) -> Mach number
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum A7pBcType {
30    G1,
31    G7,
32    Custom,
33    /// Forward compatibility: an enum value this build does not know.
34    Other(i32),
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub enum EnvelopeStatus {
39    Verified,
40    Mismatch { expected: String, actual: String },
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct UnknownField {
45    pub context: &'static str,
46    pub number: u32,
47}
48
49#[derive(Debug, Clone)]
50pub struct A7pProfile {
51    pub profile_name: String,
52    pub cartridge_name: String,
53    pub bullet_name: String,
54    pub short_name_top: String,
55    pub short_name_bot: String,
56    pub user_note: String,
57    pub caliber: String,
58    pub device_uuid: String,
59    pub zero_x_raw: i32,
60    pub zero_y_raw: i32,
61    pub w_pitch_raw: i32,
62    pub sight_height_mm: f64,
63    pub twist_in_per_turn: f64,
64    pub twist_right: bool,
65    pub muzzle_velocity_mps: f64,
66    pub zero_temperature_c: f64,
67    pub temp_coeff_pct_per_15c: f64,
68    pub powder_temperature_c: f64,
69    pub air_temperature_c: f64,
70    pub air_pressure_hpa: f64,
71    pub air_humidity_pct: f64,
72    pub bullet_diameter_in: f64,
73    pub bullet_weight_gr: f64,
74    pub bullet_length_in: f64,
75    pub bc_type: A7pBcType,
76    /// Raw (bc_cd, mv) integer pairs; scaling depends on bc_type (see
77    /// `bc_rows()` / `custom_rows()`).
78    pub coef_rows_raw: Vec<(i32, i32)>,
79    pub distances_m: Vec<f64>,
80    pub zero_distance_m: Option<f64>,
81    pub switches_count: usize,
82}
83
84impl Default for A7pProfile {
85    fn default() -> Self {
86        A7pProfile {
87            profile_name: String::new(),
88            cartridge_name: String::new(),
89            bullet_name: String::new(),
90            short_name_top: String::new(),
91            short_name_bot: String::new(),
92            user_note: String::new(),
93            caliber: String::new(),
94            device_uuid: String::new(),
95            zero_x_raw: 0,
96            zero_y_raw: 0,
97            w_pitch_raw: 0,
98            sight_height_mm: 0.0,
99            twist_in_per_turn: 0.0,
100            twist_right: true, // proto3 default TwistDir::RIGHT = 0
101            muzzle_velocity_mps: 0.0,
102            zero_temperature_c: 0.0,
103            temp_coeff_pct_per_15c: 0.0,
104            powder_temperature_c: 0.0,
105            air_temperature_c: 0.0,
106            air_pressure_hpa: 0.0,
107            air_humidity_pct: 0.0,
108            bullet_diameter_in: 0.0,
109            bullet_weight_gr: 0.0,
110            bullet_length_in: 0.0,
111            bc_type: A7pBcType::G1,
112            coef_rows_raw: Vec::new(),
113            distances_m: Vec::new(),
114            zero_distance_m: None,
115            switches_count: 0,
116        }
117    }
118}
119
120impl A7pProfile {
121    /// G1/G7 interpretation: (BC, velocity m/s), file order preserved.
122    pub fn bc_rows(&self) -> Vec<(f64, f64)> {
123        self.coef_rows_raw
124            .iter()
125            .map(|&(bc, mv)| (f64::from(bc) / SCALE_COEF, f64::from(mv) / SCALE_VELOCITY))
126            .collect()
127    }
128
129    /// CUSTOM interpretation: (Cd, Mach), file order preserved. Only meaningful when
130    /// `bc_type == A7pBcType::Custom` — for G1/G7 files the same raw rows mean
131    /// (BC, velocity m/s), see `bc_rows()`.
132    pub fn custom_rows(&self) -> Vec<(f64, f64)> {
133        self.coef_rows_raw
134            .iter()
135            .map(|&(cd, mv)| (f64::from(cd) / SCALE_COEF, f64::from(mv) / SCALE_MACH))
136            .collect()
137    }
138}
139
140#[derive(Debug)]
141pub enum A7pError {
142    /// Shorter than the 32-byte envelope prefix.
143    TooShort,
144    /// Envelope prefix is not ASCII hex.
145    BadPrefix,
146    /// Payload did not contain a Profile message.
147    MissingProfile,
148    /// Malformed protobuf payload. Carries a pre-formatted message rather
149    /// than the crate-internal `WireError` type, which is `pub(crate)` to
150    /// the `profile_import` subtree and must not leak into this public enum
151    /// (would otherwise trip `private_interfaces`).
152    Wire(String),
153}
154
155impl std::fmt::Display for A7pError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            A7pError::TooShort => write!(f, "file too short to be a .a7p (needs 32-byte checksum prefix)"),
159            A7pError::BadPrefix => write!(f, "checksum prefix is not ASCII hex — not a .a7p file"),
160            A7pError::MissingProfile => write!(f, "payload contains no profile message"),
161            A7pError::Wire(e) => write!(f, "malformed protobuf payload: {e}"),
162        }
163    }
164}
165
166impl std::error::Error for A7pError {}
167
168impl From<WireError> for A7pError {
169    fn from(e: WireError) -> Self {
170        A7pError::Wire(e.to_string())
171    }
172}
173
174#[derive(Debug)]
175pub struct A7pDocument {
176    pub profile: A7pProfile,
177    pub envelope: EnvelopeStatus,
178    pub unknown_fields: Vec<UnknownField>,
179}
180
181/// Wrap a serialized Payload in the .a7p envelope (MD5 hex prefix). The
182/// inverse of the envelope check in [`parse_a7p`]; used by tests today and by
183/// .a7p export later.
184pub fn wrap_payload(payload: &[u8]) -> Vec<u8> {
185    let mut out = md5_hex(payload).into_bytes();
186    out.extend_from_slice(payload);
187    out
188}
189
190pub fn parse_a7p(bytes: &[u8]) -> Result<A7pDocument, A7pError> {
191    if bytes.len() < 32 {
192        return Err(A7pError::TooShort);
193    }
194    let (prefix, payload) = bytes.split_at(32);
195    let expected = std::str::from_utf8(prefix)
196        .ok()
197        .filter(|s| s.chars().all(|c| c.is_ascii_hexdigit()))
198        .ok_or(A7pError::BadPrefix)?
199        .to_ascii_lowercase();
200    let actual = md5_hex(payload);
201    let envelope = if expected == actual {
202        EnvelopeStatus::Verified
203    } else {
204        EnvelopeStatus::Mismatch { expected, actual }
205    };
206
207    let mut unknown_fields = Vec::new();
208    let payload_fields = parse_message(payload)?;
209    let mut profile_bytes: Option<&[u8]> = None;
210    for field in &payload_fields {
211        match (field.number, &field.value) {
212            (1, WireValue::Bytes(b)) => profile_bytes = Some(b),
213            _ => unknown_fields.push(UnknownField {
214                context: "Payload",
215                number: field.number,
216            }),
217        }
218    }
219    let profile_bytes = profile_bytes.ok_or(A7pError::MissingProfile)?;
220
221    let fields = parse_message(profile_bytes)?;
222    let mut p = A7pProfile::default();
223    let mut zero_idx: i32 = 0;
224
225    let get_str = |v: &WireValue| -> String {
226        match v {
227            WireValue::Bytes(b) => String::from_utf8_lossy(b).into_owned(),
228            _ => String::new(),
229        }
230    };
231
232    for field in &fields {
233        let int = |v: &WireValue| -> i32 {
234            match v {
235                WireValue::Varint(raw) => varint_to_i32(*raw),
236                _ => 0,
237            }
238        };
239        match field.number {
240            1 => p.profile_name = get_str(&field.value),
241            2 => p.cartridge_name = get_str(&field.value),
242            3 => p.bullet_name = get_str(&field.value),
243            4 => p.short_name_top = get_str(&field.value),
244            5 => p.short_name_bot = get_str(&field.value),
245            6 => p.user_note = get_str(&field.value),
246            7 => p.zero_x_raw = int(&field.value),
247            8 => p.zero_y_raw = int(&field.value),
248            9 => p.sight_height_mm = f64::from(int(&field.value)),
249            10 => p.twist_in_per_turn = f64::from(int(&field.value)) / SCALE_TWIST,
250            11 => p.muzzle_velocity_mps = f64::from(int(&field.value)) / SCALE_VELOCITY,
251            12 => p.zero_temperature_c = f64::from(int(&field.value)),
252            13 => p.temp_coeff_pct_per_15c = f64::from(int(&field.value)) / SCALE_TCOEFF,
253            14 => zero_idx = int(&field.value),
254            15 => p.air_temperature_c = f64::from(int(&field.value)),
255            16 => p.air_pressure_hpa = f64::from(int(&field.value)) / SCALE_PRESSURE,
256            17 => p.air_humidity_pct = f64::from(int(&field.value)),
257            18 => p.w_pitch_raw = int(&field.value),
258            19 => p.powder_temperature_c = f64::from(int(&field.value)),
259            20 => p.bullet_diameter_in = f64::from(int(&field.value)) / SCALE_DIMENSION,
260            21 => p.bullet_weight_gr = f64::from(int(&field.value)) / SCALE_WEIGHT,
261            22 => p.bullet_length_in = f64::from(int(&field.value)) / SCALE_DIMENSION,
262            23 => p.twist_right = int(&field.value) == 0,
263            24 => {
264                p.bc_type = match int(&field.value) {
265                    0 => A7pBcType::G1,
266                    1 => A7pBcType::G7,
267                    2 => A7pBcType::Custom,
268                    other => A7pBcType::Other(other),
269                }
270            }
271            25 => p.switches_count += 1,
272            26 => {} // handled below via collect_repeated_i32 (packed or not)
273            27 => {
274                if let WireValue::Bytes(b) = &field.value {
275                    let row = parse_message(b)?;
276                    let mut bc_cd = 0i32;
277                    let mut mv = 0i32;
278                    for rf in &row {
279                        match (rf.number, &rf.value) {
280                            (1, WireValue::Varint(raw)) => bc_cd = varint_to_i32(*raw),
281                            (2, WireValue::Varint(raw)) => mv = varint_to_i32(*raw),
282                            _ => unknown_fields.push(UnknownField {
283                                context: "CoefRow",
284                                number: rf.number,
285                            }),
286                        }
287                    }
288                    p.coef_rows_raw.push((bc_cd, mv));
289                }
290            }
291            28 => p.caliber = get_str(&field.value),
292            29 => p.device_uuid = get_str(&field.value),
293            other => unknown_fields.push(UnknownField {
294                context: "Profile",
295                number: other,
296            }),
297        }
298    }
299
300    p.distances_m = collect_repeated_i32(&fields, 26)?
301        .into_iter()
302        .map(|d| f64::from(d) / SCALE_DISTANCE)
303        .collect();
304    p.zero_distance_m = usize::try_from(zero_idx)
305        .ok()
306        .and_then(|idx| p.distances_m.get(idx).copied());
307    // proto3: absent c_zero_distance_idx means 0 == first distance, but an
308    // empty distances list means there is nothing to point at.
309
310    Ok(A7pDocument {
311        profile: p,
312        envelope,
313        unknown_fields,
314    })
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    // Test-only encoders, spec-derived (same rationale as wire::tests).
322    fn enc_varint(mut v: u64, out: &mut Vec<u8>) {
323        loop {
324            let byte = (v & 0x7f) as u8;
325            v >>= 7;
326            if v == 0 {
327                out.push(byte);
328                return;
329            }
330            out.push(byte | 0x80);
331        }
332    }
333    fn enc_i32(number: u32, value: i64, out: &mut Vec<u8>) {
334        enc_varint(u64::from(number) << 3, out);
335        enc_varint(value as u64, out);
336    }
337    fn enc_str(number: u32, s: &str, out: &mut Vec<u8>) {
338        enc_varint((u64::from(number) << 3) | 2, out);
339        enc_varint(s.len() as u64, out);
340        out.extend_from_slice(s.as_bytes());
341    }
342    fn enc_bytes(number: u32, payload: &[u8], out: &mut Vec<u8>) {
343        enc_varint((u64::from(number) << 3) | 2, out);
344        enc_varint(payload.len() as u64, out);
345        out.extend_from_slice(payload);
346    }
347
348    /// A synthetic profile using the values of a real-world .338 LM export
349    /// (300 gr OTM) — the values are facts about the FORMAT's scaling, not
350    /// copied file bytes.
351    fn synthetic_profile_bytes() -> Vec<u8> {
352        let mut p = Vec::new();
353        enc_str(1, "338LM 300GR", &mut p);
354        enc_str(2, "LAPUA MAGNUM", &mut p);
355        enc_str(3, "300GR OTM", &mut p);
356        enc_i32(9, 90, &mut p); // sc_height 90 mm
357        enc_i32(10, 1000, &mut p); // r_twist 10.00 in
358        enc_i32(11, 7920, &mut p); // 792.0 m/s
359        enc_i32(12, 15, &mut p);
360        enc_i32(13, 1000, &mut p); // 1.000 %/15C
361        enc_i32(14, 1, &mut p); // zero at distances[1]
362        enc_i32(15, 15, &mut p);
363        enc_i32(16, 10000, &mut p); // 1000.0 hPa
364        enc_i32(17, 50, &mut p);
365        enc_i32(20, 338, &mut p); // 0.338 in
366        enc_i32(21, 3000, &mut p); // 300.0 gr
367        enc_i32(22, 1800, &mut p); // 1.800 in
368        enc_i32(23, 1, &mut p); // LEFT twist (non-default on purpose)
369        enc_i32(24, 1, &mut p); // G7 (non-default on purpose)
370        // distances, packed: 100.00 m and 200.00 m
371        let mut packed = Vec::new();
372        enc_varint(10_000, &mut packed);
373        enc_varint(20_000, &mut packed);
374        enc_bytes(26, &packed, &mut p);
375        // two BC rows
376        let mut row1 = Vec::new();
377        enc_i32(1, 3810, &mut row1); // bc 0.381
378        enc_i32(2, 7920, &mut row1); // at 792.0 m/s
379        enc_bytes(27, &row1, &mut p);
380        let mut row2 = Vec::new();
381        enc_i32(1, 3600, &mut row2);
382        enc_i32(2, 5000, &mut row2);
383        enc_bytes(27, &row2, &mut p);
384        enc_str(28, ".338 Lapua Magnum", &mut p);
385        // an unknown future field the parser must survive AND report
386        enc_i32(63, 42, &mut p);
387
388        let mut payload = Vec::new();
389        enc_bytes(1, &p, &mut payload); // Payload.profile = 1
390        payload
391    }
392
393    #[test]
394    fn parses_synthetic_profile_with_verified_envelope() {
395        let file = wrap_payload(&synthetic_profile_bytes());
396        let doc = parse_a7p(&file).expect("parse");
397        assert!(matches!(doc.envelope, EnvelopeStatus::Verified));
398        let p = &doc.profile;
399        assert_eq!(p.profile_name, "338LM 300GR");
400        assert_eq!(p.bullet_name, "300GR OTM");
401        assert!((p.sight_height_mm - 90.0).abs() < 1e-9);
402        assert!((p.twist_in_per_turn - 10.0).abs() < 1e-9);
403        assert!(!p.twist_right); // LEFT
404        assert!((p.muzzle_velocity_mps - 792.0).abs() < 1e-9);
405        assert!((p.temp_coeff_pct_per_15c - 1.0).abs() < 1e-9);
406        assert!((p.air_pressure_hpa - 1000.0).abs() < 1e-9);
407        assert!((p.air_humidity_pct - 50.0).abs() < 1e-9);
408        assert!((p.bullet_diameter_in - 0.338).abs() < 1e-9);
409        assert!((p.bullet_weight_gr - 300.0).abs() < 1e-9);
410        assert!((p.bullet_length_in - 1.8).abs() < 1e-9);
411        assert!(matches!(p.bc_type, A7pBcType::G7));
412        assert_eq!(p.coef_rows_raw, vec![(3810, 7920), (3600, 5000)]);
413        assert_eq!(p.distances_m, vec![100.0, 200.0]);
414        // zero index 1 -> 200 m
415        assert_eq!(p.zero_distance_m, Some(200.0));
416        // the unknown field 63 was reported, not silently dropped
417        assert!(doc
418            .unknown_fields
419            .iter()
420            .any(|u| u.context == "Profile" && u.number == 63));
421    }
422
423    #[test]
424    fn proto3_defaults_apply_when_fields_are_absent() {
425        // Empty profile message: everything defaults (G1, RIGHT twist, zeros).
426        let mut payload = Vec::new();
427        enc_bytes(1, &[], &mut payload);
428        let doc = parse_a7p(&wrap_payload(&payload)).expect("parse");
429        let p = &doc.profile;
430        assert!(matches!(p.bc_type, A7pBcType::G1));
431        assert!(p.twist_right);
432        assert_eq!(p.muzzle_velocity_mps, 0.0);
433        assert_eq!(p.zero_distance_m, None); // no distances to index into
434        assert!(p.coef_rows_raw.is_empty());
435    }
436
437    #[test]
438    fn corrupted_envelope_is_reported_not_fatal() {
439        let mut file = wrap_payload(&{
440            let mut payload = Vec::new();
441            enc_bytes(1, &[], &mut payload);
442            payload
443        });
444        file[0] = if file[0] == b'0' { b'1' } else { b'0' };
445        let doc = parse_a7p(&file).expect("parse succeeds with warning");
446        assert!(matches!(doc.envelope, EnvelopeStatus::Mismatch { .. }));
447    }
448
449    #[test]
450    fn short_files_and_garbage_are_clean_errors() {
451        assert!(matches!(parse_a7p(&[]), Err(A7pError::TooShort)));
452        assert!(matches!(parse_a7p(&[0u8; 20]), Err(A7pError::TooShort)));
453        // 32-byte prefix of non-hex garbage + non-proto payload
454        let mut junk = vec![b'z'; 32];
455        junk.extend_from_slice(&[0xff, 0xff, 0xff]);
456        assert!(parse_a7p(&junk).is_err());
457    }
458
459    /// A synthetic CUSTOM (bc_type=2) profile: two coef rows whose raw ints are chosen so
460    /// the G1/G7 scale (mv / 10) and the CUSTOM scale (mv / 10_000) would decode to visibly
461    /// different numbers — catching a copy-paste of `bc_rows()`'s scale factor into
462    /// `custom_rows()`.
463    fn synthetic_custom_profile_bytes() -> Vec<u8> {
464        let mut p = Vec::new();
465        enc_str(1, "CUSTOM CURVE", &mut p);
466        enc_i32(24, 2, &mut p); // CUSTOM
467        let mut row1 = Vec::new();
468        enc_i32(1, 5000, &mut row1); // Cd 0.5000
469        enc_i32(2, 5000, &mut row1); // Mach 0.5000
470        enc_bytes(27, &row1, &mut p);
471        let mut row2 = Vec::new();
472        enc_i32(1, 2300, &mut row2); // Cd 0.2300
473        enc_i32(2, 30000, &mut row2); // Mach 3.0000
474        enc_bytes(27, &row2, &mut p);
475        let mut payload = Vec::new();
476        enc_bytes(1, &p, &mut payload);
477        payload
478    }
479
480    #[test]
481    fn custom_rows_uses_the_mach_scale_not_the_velocity_scale() {
482        let file = wrap_payload(&synthetic_custom_profile_bytes());
483        let doc = parse_a7p(&file).expect("parse");
484        let p = &doc.profile;
485        assert!(matches!(p.bc_type, A7pBcType::Custom));
486        assert_eq!(p.coef_rows_raw, vec![(5000, 5000), (2300, 30000)]);
487
488        let rows = p.custom_rows();
489        assert_eq!(rows.len(), 2);
490        assert!((rows[0].0 - 0.5).abs() < 1e-9); // Cd
491        assert!((rows[0].1 - 0.5).abs() < 1e-9); // Mach 0.5, NOT 500.0 m/s
492        assert!((rows[1].0 - 0.23).abs() < 1e-9);
493        assert!((rows[1].1 - 3.0).abs() < 1e-9); // Mach 3.0, NOT 3000.0 m/s
494
495        // bc_rows() on the same raw data uses the OTHER scale (m/s /10, not Mach /10_000) —
496        // demonstrates the two interpretations are genuinely distinct, not aliases.
497        let bc_interpretation = p.bc_rows();
498        assert!((bc_interpretation[0].1 - 500.0).abs() < 1e-9);
499        assert!((bc_interpretation[1].1 - 3000.0).abs() < 1e-9);
500    }
501
502    #[test]
503    fn custom_rows_is_empty_when_no_coef_rows_present() {
504        let mut p = Vec::new();
505        enc_i32(24, 2, &mut p); // CUSTOM, no rows
506        let mut payload = Vec::new();
507        enc_bytes(1, &p, &mut payload);
508        let doc = parse_a7p(&wrap_payload(&payload)).expect("parse");
509        assert!(doc.profile.custom_rows().is_empty());
510    }
511
512    #[test]
513    fn out_of_range_zero_index_yields_none() {
514        let mut p = Vec::new();
515        enc_i32(14, 7, &mut p); // index 7, but only 1 distance
516        let mut packed = Vec::new();
517        enc_varint(10_000, &mut packed);
518        enc_bytes(26, &packed, &mut p);
519        let mut payload = Vec::new();
520        enc_bytes(1, &p, &mut payload);
521        let doc = parse_a7p(&wrap_payload(&payload)).expect("parse");
522        assert_eq!(doc.profile.zero_distance_m, None);
523        assert_eq!(doc.profile.distances_m, vec![100.0]);
524    }
525}