1use super::md5::md5_hex;
10use super::wire::{collect_repeated_i32, parse_message, varint_to_i32, WireError, WireValue};
11
12const 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)]
27pub enum A7pBcType {
28 G1,
29 G7,
30 Custom,
31 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 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, 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 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 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 TooShort,
142 BadPrefix,
144 MissingProfile,
146 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
182pub 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 => {} 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 Ok(A7pDocument {
312 profile: p,
313 envelope,
314 unknown_fields,
315 })
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 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 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); enc_i32(10, 1000, &mut p); enc_i32(11, 7920, &mut p); enc_i32(12, 15, &mut p);
361 enc_i32(13, 1000, &mut p); enc_i32(14, 1, &mut p); enc_i32(15, 15, &mut p);
364 enc_i32(16, 10000, &mut p); enc_i32(17, 50, &mut p);
366 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();
373 enc_varint(10_000, &mut packed);
374 enc_varint(20_000, &mut packed);
375 enc_bytes(26, &packed, &mut p);
376 let mut row1 = Vec::new();
378 enc_i32(1, 3810, &mut row1); enc_i32(2, 7920, &mut row1); 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 enc_i32(63, 42, &mut p);
388
389 let mut payload = Vec::new();
390 enc_bytes(1, &p, &mut payload); 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); 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 assert_eq!(p.zero_distance_m, Some(200.0));
417 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 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); 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 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 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); let mut row1 = Vec::new();
469 enc_i32(1, 5000, &mut row1); enc_i32(2, 5000, &mut row1); enc_bytes(27, &row1, &mut p);
472 let mut row2 = Vec::new();
473 enc_i32(1, 2300, &mut row2); enc_i32(2, 30000, &mut row2); 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); assert!((rows[0].1 - 0.5).abs() < 1e-9); assert!((rows[1].0 - 0.23).abs() < 1e-9);
494 assert!((rows[1].1 - 3.0).abs() < 1e-9); 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); 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); 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}