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