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 package as distributed is GPL-3.0 — its own bundled LICENSE is
8//! the GNU General Public License v3, not the LGPL an earlier version of this note
9//! claimed; nothing from it is copied here).
10
11use super::md5::md5_hex;
12use super::wire::{collect_repeated_i32, parse_message, varint_to_i32, WireError, WireValue};
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!(
159                f,
160                "file too short to be a .a7p (needs 32-byte checksum prefix)"
161            ),
162            A7pError::BadPrefix => write!(f, "checksum prefix is not ASCII hex — not a .a7p file"),
163            A7pError::MissingProfile => write!(f, "payload contains no profile message"),
164            A7pError::Wire(e) => write!(f, "malformed protobuf payload: {e}"),
165        }
166    }
167}
168
169impl std::error::Error for A7pError {}
170
171impl From<WireError> for A7pError {
172    fn from(e: WireError) -> Self {
173        A7pError::Wire(e.to_string())
174    }
175}
176
177#[derive(Debug)]
178pub struct A7pDocument {
179    pub profile: A7pProfile,
180    pub envelope: EnvelopeStatus,
181    pub unknown_fields: Vec<UnknownField>,
182}
183
184/// Wrap a serialized Payload in the .a7p envelope (MD5 hex prefix). The
185/// inverse of the envelope check in [`parse_a7p`]; used by tests today and by
186/// .a7p export later.
187pub fn wrap_payload(payload: &[u8]) -> Vec<u8> {
188    let mut out = md5_hex(payload).into_bytes();
189    out.extend_from_slice(payload);
190    out
191}
192
193pub fn parse_a7p(bytes: &[u8]) -> Result<A7pDocument, A7pError> {
194    if bytes.len() < 32 {
195        return Err(A7pError::TooShort);
196    }
197    let (prefix, payload) = bytes.split_at(32);
198    let expected = std::str::from_utf8(prefix)
199        .ok()
200        .filter(|s| s.chars().all(|c| c.is_ascii_hexdigit()))
201        .ok_or(A7pError::BadPrefix)?
202        .to_ascii_lowercase();
203    let actual = md5_hex(payload);
204    let envelope = if expected == actual {
205        EnvelopeStatus::Verified
206    } else {
207        EnvelopeStatus::Mismatch { expected, actual }
208    };
209
210    let mut unknown_fields = Vec::new();
211    let payload_fields = parse_message(payload)?;
212    let mut profile_bytes: Option<&[u8]> = None;
213    for field in &payload_fields {
214        match (field.number, &field.value) {
215            (1, WireValue::Bytes(b)) => profile_bytes = Some(b),
216            _ => unknown_fields.push(UnknownField {
217                context: "Payload",
218                number: field.number,
219            }),
220        }
221    }
222    let profile_bytes = profile_bytes.ok_or(A7pError::MissingProfile)?;
223
224    let fields = parse_message(profile_bytes)?;
225    let mut p = A7pProfile::default();
226    let mut zero_idx: i32 = 0;
227
228    let get_str = |v: &WireValue| -> String {
229        match v {
230            WireValue::Bytes(b) => String::from_utf8_lossy(b).into_owned(),
231            _ => String::new(),
232        }
233    };
234
235    for field in &fields {
236        let int = |v: &WireValue| -> i32 {
237            match v {
238                WireValue::Varint(raw) => varint_to_i32(*raw),
239                _ => 0,
240            }
241        };
242        match field.number {
243            1 => p.profile_name = get_str(&field.value),
244            2 => p.cartridge_name = get_str(&field.value),
245            3 => p.bullet_name = get_str(&field.value),
246            4 => p.short_name_top = get_str(&field.value),
247            5 => p.short_name_bot = get_str(&field.value),
248            6 => p.user_note = get_str(&field.value),
249            7 => p.zero_x_raw = int(&field.value),
250            8 => p.zero_y_raw = int(&field.value),
251            9 => p.sight_height_mm = f64::from(int(&field.value)),
252            10 => p.twist_in_per_turn = f64::from(int(&field.value)) / SCALE_TWIST,
253            11 => p.muzzle_velocity_mps = f64::from(int(&field.value)) / SCALE_VELOCITY,
254            12 => p.zero_temperature_c = f64::from(int(&field.value)),
255            13 => p.temp_coeff_pct_per_15c = f64::from(int(&field.value)) / SCALE_TCOEFF,
256            14 => zero_idx = int(&field.value),
257            15 => p.air_temperature_c = f64::from(int(&field.value)),
258            16 => p.air_pressure_hpa = f64::from(int(&field.value)) / SCALE_PRESSURE,
259            17 => p.air_humidity_pct = f64::from(int(&field.value)),
260            18 => p.w_pitch_raw = int(&field.value),
261            19 => p.powder_temperature_c = f64::from(int(&field.value)),
262            20 => p.bullet_diameter_in = f64::from(int(&field.value)) / SCALE_DIMENSION,
263            21 => p.bullet_weight_gr = f64::from(int(&field.value)) / SCALE_WEIGHT,
264            22 => p.bullet_length_in = f64::from(int(&field.value)) / SCALE_DIMENSION,
265            23 => p.twist_right = int(&field.value) == 0,
266            24 => {
267                p.bc_type = match int(&field.value) {
268                    0 => A7pBcType::G1,
269                    1 => A7pBcType::G7,
270                    2 => A7pBcType::Custom,
271                    other => A7pBcType::Other(other),
272                }
273            }
274            25 => p.switches_count += 1,
275            26 => {} // handled below via collect_repeated_i32 (packed or not)
276            27 => {
277                if let WireValue::Bytes(b) = &field.value {
278                    let row = parse_message(b)?;
279                    let mut bc_cd = 0i32;
280                    let mut mv = 0i32;
281                    for rf in &row {
282                        match (rf.number, &rf.value) {
283                            (1, WireValue::Varint(raw)) => bc_cd = varint_to_i32(*raw),
284                            (2, WireValue::Varint(raw)) => mv = varint_to_i32(*raw),
285                            _ => unknown_fields.push(UnknownField {
286                                context: "CoefRow",
287                                number: rf.number,
288                            }),
289                        }
290                    }
291                    p.coef_rows_raw.push((bc_cd, mv));
292                }
293            }
294            28 => p.caliber = get_str(&field.value),
295            29 => p.device_uuid = get_str(&field.value),
296            other => unknown_fields.push(UnknownField {
297                context: "Profile",
298                number: other,
299            }),
300        }
301    }
302
303    p.distances_m = collect_repeated_i32(&fields, 26)?
304        .into_iter()
305        .map(|d| f64::from(d) / SCALE_DISTANCE)
306        .collect();
307    p.zero_distance_m = usize::try_from(zero_idx)
308        .ok()
309        .and_then(|idx| p.distances_m.get(idx).copied());
310    // proto3: absent c_zero_distance_idx means 0 == first distance, but an
311    // empty distances list means there is nothing to point at.
312
313    Ok(A7pDocument {
314        profile: p,
315        envelope,
316        unknown_fields,
317    })
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    // Test-only encoders, spec-derived (same rationale as wire::tests).
325    fn enc_varint(mut v: u64, out: &mut Vec<u8>) {
326        loop {
327            let byte = (v & 0x7f) as u8;
328            v >>= 7;
329            if v == 0 {
330                out.push(byte);
331                return;
332            }
333            out.push(byte | 0x80);
334        }
335    }
336    fn enc_i32(number: u32, value: i64, out: &mut Vec<u8>) {
337        enc_varint(u64::from(number) << 3, out);
338        enc_varint(value as u64, out);
339    }
340    fn enc_str(number: u32, s: &str, out: &mut Vec<u8>) {
341        enc_varint((u64::from(number) << 3) | 2, out);
342        enc_varint(s.len() as u64, out);
343        out.extend_from_slice(s.as_bytes());
344    }
345    fn enc_bytes(number: u32, payload: &[u8], out: &mut Vec<u8>) {
346        enc_varint((u64::from(number) << 3) | 2, out);
347        enc_varint(payload.len() as u64, out);
348        out.extend_from_slice(payload);
349    }
350
351    /// A synthetic profile using the values of a real-world .338 LM export
352    /// (300 gr OTM) — the values are facts about the FORMAT's scaling, not
353    /// copied file bytes.
354    fn synthetic_profile_bytes() -> Vec<u8> {
355        let mut p = Vec::new();
356        enc_str(1, "338LM 300GR", &mut p);
357        enc_str(2, "LAPUA MAGNUM", &mut p);
358        enc_str(3, "300GR OTM", &mut p);
359        enc_i32(9, 90, &mut p); // sc_height 90 mm
360        enc_i32(10, 1000, &mut p); // r_twist 10.00 in
361        enc_i32(11, 7920, &mut p); // 792.0 m/s
362        enc_i32(12, 15, &mut p);
363        enc_i32(13, 1000, &mut p); // 1.000 %/15C
364        enc_i32(14, 1, &mut p); // zero at distances[1]
365        enc_i32(15, 15, &mut p);
366        enc_i32(16, 10000, &mut p); // 1000.0 hPa
367        enc_i32(17, 50, &mut p);
368        enc_i32(20, 338, &mut p); // 0.338 in
369        enc_i32(21, 3000, &mut p); // 300.0 gr
370        enc_i32(22, 1800, &mut p); // 1.800 in
371        enc_i32(23, 1, &mut p); // LEFT twist (non-default on purpose)
372        enc_i32(24, 1, &mut p); // G7 (non-default on purpose)
373                                // distances, packed: 100.00 m and 200.00 m
374        let mut packed = Vec::new();
375        enc_varint(10_000, &mut packed);
376        enc_varint(20_000, &mut packed);
377        enc_bytes(26, &packed, &mut p);
378        // two BC rows
379        let mut row1 = Vec::new();
380        enc_i32(1, 3810, &mut row1); // bc 0.381
381        enc_i32(2, 7920, &mut row1); // at 792.0 m/s
382        enc_bytes(27, &row1, &mut p);
383        let mut row2 = Vec::new();
384        enc_i32(1, 3600, &mut row2);
385        enc_i32(2, 5000, &mut row2);
386        enc_bytes(27, &row2, &mut p);
387        enc_str(28, ".338 Lapua Magnum", &mut p);
388        // an unknown future field the parser must survive AND report
389        enc_i32(63, 42, &mut p);
390
391        let mut payload = Vec::new();
392        enc_bytes(1, &p, &mut payload); // Payload.profile = 1
393        payload
394    }
395
396    #[test]
397    fn parses_synthetic_profile_with_verified_envelope() {
398        let file = wrap_payload(&synthetic_profile_bytes());
399        let doc = parse_a7p(&file).expect("parse");
400        assert!(matches!(doc.envelope, EnvelopeStatus::Verified));
401        let p = &doc.profile;
402        assert_eq!(p.profile_name, "338LM 300GR");
403        assert_eq!(p.bullet_name, "300GR OTM");
404        assert!((p.sight_height_mm - 90.0).abs() < 1e-9);
405        assert!((p.twist_in_per_turn - 10.0).abs() < 1e-9);
406        assert!(!p.twist_right); // LEFT
407        assert!((p.muzzle_velocity_mps - 792.0).abs() < 1e-9);
408        assert!((p.temp_coeff_pct_per_15c - 1.0).abs() < 1e-9);
409        assert!((p.air_pressure_hpa - 1000.0).abs() < 1e-9);
410        assert!((p.air_humidity_pct - 50.0).abs() < 1e-9);
411        assert!((p.bullet_diameter_in - 0.338).abs() < 1e-9);
412        assert!((p.bullet_weight_gr - 300.0).abs() < 1e-9);
413        assert!((p.bullet_length_in - 1.8).abs() < 1e-9);
414        assert!(matches!(p.bc_type, A7pBcType::G7));
415        assert_eq!(p.coef_rows_raw, vec![(3810, 7920), (3600, 5000)]);
416        assert_eq!(p.distances_m, vec![100.0, 200.0]);
417        // zero index 1 -> 200 m
418        assert_eq!(p.zero_distance_m, Some(200.0));
419        // the unknown field 63 was reported, not silently dropped
420        assert!(doc
421            .unknown_fields
422            .iter()
423            .any(|u| u.context == "Profile" && u.number == 63));
424    }
425
426    #[test]
427    fn proto3_defaults_apply_when_fields_are_absent() {
428        // Empty profile message: everything defaults (G1, RIGHT twist, zeros).
429        let mut payload = Vec::new();
430        enc_bytes(1, &[], &mut payload);
431        let doc = parse_a7p(&wrap_payload(&payload)).expect("parse");
432        let p = &doc.profile;
433        assert!(matches!(p.bc_type, A7pBcType::G1));
434        assert!(p.twist_right);
435        assert_eq!(p.muzzle_velocity_mps, 0.0);
436        assert_eq!(p.zero_distance_m, None); // no distances to index into
437        assert!(p.coef_rows_raw.is_empty());
438    }
439
440    #[test]
441    fn corrupted_envelope_is_reported_not_fatal() {
442        let mut file = wrap_payload(&{
443            let mut payload = Vec::new();
444            enc_bytes(1, &[], &mut payload);
445            payload
446        });
447        file[0] = if file[0] == b'0' { b'1' } else { b'0' };
448        let doc = parse_a7p(&file).expect("parse succeeds with warning");
449        assert!(matches!(doc.envelope, EnvelopeStatus::Mismatch { .. }));
450    }
451
452    #[test]
453    fn short_files_and_garbage_are_clean_errors() {
454        assert!(matches!(parse_a7p(&[]), Err(A7pError::TooShort)));
455        assert!(matches!(parse_a7p(&[0u8; 20]), Err(A7pError::TooShort)));
456        // 32-byte prefix of non-hex garbage + non-proto payload
457        let mut junk = vec![b'z'; 32];
458        junk.extend_from_slice(&[0xff, 0xff, 0xff]);
459        assert!(parse_a7p(&junk).is_err());
460    }
461
462    /// A synthetic CUSTOM (bc_type=2) profile: two coef rows whose raw ints are chosen so
463    /// the G1/G7 scale (mv / 10) and the CUSTOM scale (mv / 10_000) would decode to visibly
464    /// different numbers — catching a copy-paste of `bc_rows()`'s scale factor into
465    /// `custom_rows()`.
466    fn synthetic_custom_profile_bytes() -> Vec<u8> {
467        let mut p = Vec::new();
468        enc_str(1, "CUSTOM CURVE", &mut p);
469        enc_i32(24, 2, &mut p); // CUSTOM
470        let mut row1 = Vec::new();
471        enc_i32(1, 5000, &mut row1); // Cd 0.5000
472        enc_i32(2, 5000, &mut row1); // Mach 0.5000
473        enc_bytes(27, &row1, &mut p);
474        let mut row2 = Vec::new();
475        enc_i32(1, 2300, &mut row2); // Cd 0.2300
476        enc_i32(2, 30000, &mut row2); // Mach 3.0000
477        enc_bytes(27, &row2, &mut p);
478        let mut payload = Vec::new();
479        enc_bytes(1, &p, &mut payload);
480        payload
481    }
482
483    #[test]
484    fn custom_rows_uses_the_mach_scale_not_the_velocity_scale() {
485        let file = wrap_payload(&synthetic_custom_profile_bytes());
486        let doc = parse_a7p(&file).expect("parse");
487        let p = &doc.profile;
488        assert!(matches!(p.bc_type, A7pBcType::Custom));
489        assert_eq!(p.coef_rows_raw, vec![(5000, 5000), (2300, 30000)]);
490
491        let rows = p.custom_rows();
492        assert_eq!(rows.len(), 2);
493        assert!((rows[0].0 - 0.5).abs() < 1e-9); // Cd
494        assert!((rows[0].1 - 0.5).abs() < 1e-9); // Mach 0.5, NOT 500.0 m/s
495        assert!((rows[1].0 - 0.23).abs() < 1e-9);
496        assert!((rows[1].1 - 3.0).abs() < 1e-9); // Mach 3.0, NOT 3000.0 m/s
497
498        // bc_rows() on the same raw data uses the OTHER scale (m/s /10, not Mach /10_000) —
499        // demonstrates the two interpretations are genuinely distinct, not aliases.
500        let bc_interpretation = p.bc_rows();
501        assert!((bc_interpretation[0].1 - 500.0).abs() < 1e-9);
502        assert!((bc_interpretation[1].1 - 3000.0).abs() < 1e-9);
503    }
504
505    #[test]
506    fn custom_rows_is_empty_when_no_coef_rows_present() {
507        let mut p = Vec::new();
508        enc_i32(24, 2, &mut p); // CUSTOM, no rows
509        let mut payload = Vec::new();
510        enc_bytes(1, &p, &mut payload);
511        let doc = parse_a7p(&wrap_payload(&payload)).expect("parse");
512        assert!(doc.profile.custom_rows().is_empty());
513    }
514
515    #[test]
516    fn out_of_range_zero_index_yields_none() {
517        let mut p = Vec::new();
518        enc_i32(14, 7, &mut p); // index 7, but only 1 distance
519        let mut packed = Vec::new();
520        enc_varint(10_000, &mut packed);
521        enc_bytes(26, &packed, &mut p);
522        let mut payload = Vec::new();
523        enc_bytes(1, &p, &mut payload);
524        let doc = parse_a7p(&wrap_payload(&payload)).expect("parse");
525        assert_eq!(doc.profile.zero_distance_m, None);
526        assert_eq!(doc.profile.distances_m, vec![100.0]);
527    }
528}