1use super::md5::md5_hex;
12use super::wire::{collect_repeated_i32, parse_message, varint_to_i32, WireError, WireValue};
13
14const SCALE_TWIST: f64 = 100.0; const SCALE_VELOCITY: f64 = 10.0; const SCALE_DIMENSION: f64 = 1000.0; const SCALE_WEIGHT: f64 = 10.0; const SCALE_PRESSURE: f64 = 10.0; const SCALE_COEF: f64 = 10_000.0; const SCALE_TCOEFF: f64 = 1000.0; const SCALE_DISTANCE: f64 = 100.0; const SCALE_MACH: f64 = 10_000.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum A7pBcType {
30 G1,
31 G7,
32 Custom,
33 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 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, 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 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 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 TooShort,
144 BadPrefix,
146 MissingProfile,
148 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
184pub 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 => {} 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 Ok(A7pDocument {
314 profile: p,
315 envelope,
316 unknown_fields,
317 })
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 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 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); enc_i32(10, 1000, &mut p); enc_i32(11, 7920, &mut p); enc_i32(12, 15, &mut p);
363 enc_i32(13, 1000, &mut p); enc_i32(14, 1, &mut p); enc_i32(15, 15, &mut p);
366 enc_i32(16, 10000, &mut p); enc_i32(17, 50, &mut p);
368 enc_i32(20, 338, &mut p); enc_i32(21, 3000, &mut p); enc_i32(22, 1800, &mut p); enc_i32(23, 1, &mut p); enc_i32(24, 1, &mut p); 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 let mut row1 = Vec::new();
380 enc_i32(1, 3810, &mut row1); enc_i32(2, 7920, &mut row1); 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 enc_i32(63, 42, &mut p);
390
391 let mut payload = Vec::new();
392 enc_bytes(1, &p, &mut payload); 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); 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 assert_eq!(p.zero_distance_m, Some(200.0));
419 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 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); 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 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 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); let mut row1 = Vec::new();
471 enc_i32(1, 5000, &mut row1); enc_i32(2, 5000, &mut row1); enc_bytes(27, &row1, &mut p);
474 let mut row2 = Vec::new();
475 enc_i32(1, 2300, &mut row2); enc_i32(2, 30000, &mut row2); 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); assert!((rows[0].1 - 0.5).abs() < 1e-9); assert!((rows[1].0 - 0.23).abs() < 1e-9);
496 assert!((rows[1].1 - 3.0).abs() < 1e-9); 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); 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); 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}