1use super::md5::md5_hex;
10use super::wire::{
11 collect_repeated_i32, parse_message, varint_to_i32, WireError, WireValue,
12};
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!(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
181pub 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 => {} 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 Ok(A7pDocument {
311 profile: p,
312 envelope,
313 unknown_fields,
314 })
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 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 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); enc_i32(10, 1000, &mut p); enc_i32(11, 7920, &mut p); enc_i32(12, 15, &mut p);
360 enc_i32(13, 1000, &mut p); enc_i32(14, 1, &mut p); enc_i32(15, 15, &mut p);
363 enc_i32(16, 10000, &mut p); enc_i32(17, 50, &mut p);
365 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();
372 enc_varint(10_000, &mut packed);
373 enc_varint(20_000, &mut packed);
374 enc_bytes(26, &packed, &mut p);
375 let mut row1 = Vec::new();
377 enc_i32(1, 3810, &mut row1); enc_i32(2, 7920, &mut row1); 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 enc_i32(63, 42, &mut p);
387
388 let mut payload = Vec::new();
389 enc_bytes(1, &p, &mut payload); 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); 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 assert_eq!(p.zero_distance_m, Some(200.0));
416 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 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); 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 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 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); let mut row1 = Vec::new();
468 enc_i32(1, 5000, &mut row1); enc_i32(2, 5000, &mut row1); enc_bytes(27, &row1, &mut p);
471 let mut row2 = Vec::new();
472 enc_i32(1, 2300, &mut row2); enc_i32(2, 30000, &mut row2); 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); assert!((rows[0].1 - 0.5).abs() < 1e-9); assert!((rows[1].0 - 0.23).abs() < 1e-9);
493 assert!((rows[1].1 - 3.0).abs() < 1e-9); 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); 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); 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}