1use serde::{
8 de::{self, Visitor},
9 ser::SerializeStruct,
10 Deserialize, Deserializer, Serialize, Serializer,
11};
12use serde_json::{Map, Value};
13use std::{fmt, num::NonZeroUsize};
14
15pub const SOLVE_JSON_SCHEMA_VERSION_V1: u32 = 1;
17
18pub const DRAG_MODEL_WIRE_NAMES_V1: [&str; 9] =
23 ["G1", "G2", "G5", "G6", "G7", "G8", "GI", "GS", "RA4"];
24
25pub const MAX_SOLVE_JSON_SAMPLES_V1: usize = 10_000;
30
31fn deserialize_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
37where
38 D: Deserializer<'de>,
39 T: Deserialize<'de>,
40{
41 T::deserialize(deserializer).map(Some)
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
49pub struct SchemaVersionV1;
50
51impl SchemaVersionV1 {
52 pub const fn get(self) -> u32 {
54 SOLVE_JSON_SCHEMA_VERSION_V1
55 }
56}
57
58impl Serialize for SchemaVersionV1 {
59 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60 where
61 S: Serializer,
62 {
63 serializer.serialize_u32(SOLVE_JSON_SCHEMA_VERSION_V1)
64 }
65}
66
67impl<'de> Deserialize<'de> for SchemaVersionV1 {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: Deserializer<'de>,
71 {
72 struct SchemaVersionVisitor;
73
74 impl<'de> Visitor<'de> for SchemaVersionVisitor {
75 type Value = SchemaVersionV1;
76
77 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter.write_str("the integer 1")
79 }
80
81 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
82 where
83 E: de::Error,
84 {
85 if value == u64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
86 Ok(SchemaVersionV1)
87 } else {
88 Err(E::custom(format!(
89 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
90 )))
91 }
92 }
93
94 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
95 where
96 E: de::Error,
97 {
98 if value == i64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
99 Ok(SchemaVersionV1)
100 } else {
101 Err(E::custom(format!(
102 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
103 )))
104 }
105 }
106 }
107
108 deserializer.deserialize_any(SchemaVersionVisitor)
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct SolveRequestV1 {
119 pub schema_version: SchemaVersionV1,
121 pub projectile: ProjectileV1,
122 pub rifle: RifleV1,
123 pub shot: ShotV1,
124 pub atmosphere: AtmosphereV1,
125 pub wind: WindV1,
126 pub solver: SolverV1,
127 pub effects: EffectsV1,
128 pub sampling: SamplingV1,
129 #[serde(
136 default,
137 skip_serializing_if = "Option::is_none",
138 deserialize_with = "deserialize_present"
139 )]
140 pub reticle: Option<ReticleRequestV1>,
141 #[serde(
147 default,
148 skip_serializing_if = "Option::is_none",
149 deserialize_with = "deserialize_present"
150 )]
151 pub corrections: Option<CorrectionsV1>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct CorrectionsV1 {
169 #[serde(
171 default,
172 skip_serializing_if = "Option::is_none",
173 deserialize_with = "deserialize_present"
174 )]
175 pub bc5d_table_path: Option<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct ReticleRequestV1 {
188 pub range_m: f64,
190 pub magnification: f64,
193 pub description: crate::reticle::ReticleDescription,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ReticleHoldV1 {
203 pub range_m: f64,
205 pub magnification: f64,
207 pub down_mil: f64,
208 pub right_mil: f64,
209 pub mark_scale: f64,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub nearest_mark_index: Option<usize>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub nearest_mark_label: Option<String>,
219 pub nearest_mark_distance_mil: f64,
220 pub off_reticle: bool,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct ProjectileV1 {
229 pub mass_kg: f64,
230 pub diameter_m: f64,
231 #[serde(
232 default,
233 skip_serializing_if = "Option::is_none",
234 deserialize_with = "deserialize_present"
235 )]
236 pub length_m: Option<f64>,
237 pub drag_model: DragModelV1,
238 pub ballistic_coefficient: f64,
239 #[serde(
241 default,
242 skip_serializing_if = "Option::is_none",
243 deserialize_with = "deserialize_present"
244 )]
245 pub drag_table: Option<DragTableV1>,
246}
247
248pub const MIN_DRAG_TABLE_POINTS_V1: usize = 2;
250
251pub const MAX_DRAG_TABLE_POINTS_V1: usize = 4096;
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct DragTableV1 {
264 pub kind: DragTableKindV1,
265 pub points: Vec<DragPointV1>,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum DragTableKindV1 {
274 Reference,
277 Projectile,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct DragPointV1 {
286 pub mach: f64,
287 pub cd: f64,
288}
289
290pub(crate) fn validate_drag_table_v1(table: &DragTableV1) -> Result<(), SolveErrorEnvelopeV1> {
296 let points_path = "$.projectile.drag_table.points";
297 let count = table.points.len();
298 if !(MIN_DRAG_TABLE_POINTS_V1..=MAX_DRAG_TABLE_POINTS_V1).contains(&count) {
299 return Err(protocol_error(
300 SolveErrorCodeV1::InvalidValue,
301 format!(
302 "drag_table must have {MIN_DRAG_TABLE_POINTS_V1} to \
303 {MAX_DRAG_TABLE_POINTS_V1} points, got {count}"
304 ),
305 points_path,
306 ));
307 }
308 let mut previous_mach: Option<f64> = None;
309 for (index, point) in table.points.iter().enumerate() {
310 let mach_path = format!("{points_path}[{index}].mach");
311 if !point.mach.is_finite() || point.mach < 0.0 {
312 return Err(protocol_error(
313 SolveErrorCodeV1::InvalidValue,
314 format!("mach must be finite and >= 0, got {}", point.mach),
315 mach_path,
316 ));
317 }
318 if let Some(previous) = previous_mach.filter(|previous| point.mach <= *previous) {
319 return Err(protocol_error(
320 SolveErrorCodeV1::InvalidValue,
321 format!(
322 "mach must strictly ascend; {} follows {previous} at points[{}]",
323 point.mach,
324 index - 1
325 ),
326 mach_path,
327 ));
328 }
329 if !point.cd.is_finite() || point.cd <= 0.0 {
330 return Err(protocol_error(
331 SolveErrorCodeV1::InvalidValue,
332 format!("cd must be finite and > 0, got {}", point.cd),
333 format!("{points_path}[{index}].cd"),
334 ));
335 }
336 previous_mach = Some(point.mach);
337 }
338 Ok(())
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
343pub enum DragModelV1 {
344 #[serde(rename = "G1")]
345 G1,
346 #[serde(rename = "G6")]
347 G6,
348 #[serde(rename = "G7")]
349 G7,
350 #[serde(rename = "G8")]
351 G8,
352 #[serde(rename = "G2")]
353 G2,
354 #[serde(rename = "G5")]
355 G5,
356 #[serde(rename = "GI")]
357 GI,
358 #[serde(rename = "GS")]
359 GS,
360 #[serde(rename = "RA4")]
361 RA4,
362}
363
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366#[serde(deny_unknown_fields)]
367pub struct RifleV1 {
368 pub muzzle_velocity_mps: f64,
369 #[serde(
370 default,
371 skip_serializing_if = "Option::is_none",
372 deserialize_with = "deserialize_present"
373 )]
374 pub sight_height_m: Option<f64>,
375 #[serde(
376 default,
377 skip_serializing_if = "Option::is_none",
378 deserialize_with = "deserialize_present"
379 )]
380 pub muzzle_height_m: Option<f64>,
381 #[serde(
400 default,
401 skip_serializing_if = "Option::is_none",
402 deserialize_with = "deserialize_present"
403 )]
404 pub twist_rate_m_per_turn: Option<f64>,
405 #[serde(
410 default,
411 skip_serializing_if = "Option::is_none",
412 deserialize_with = "deserialize_present"
413 )]
414 pub twist_direction: Option<TwistDirectionV1>,
415 #[serde(
423 default,
424 skip_serializing_if = "Option::is_none",
425 deserialize_with = "deserialize_present"
426 )]
427 pub sight_offset_lateral_m: Option<f64>,
428}
429
430#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "snake_case")]
433pub enum TwistDirectionV1 {
434 Left,
435 #[default]
436 Right,
437}
438
439#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
441#[serde(deny_unknown_fields)]
442pub struct ShotV1 {
443 pub max_range_m: f64,
444 #[serde(
445 default,
446 skip_serializing_if = "Option::is_none",
447 deserialize_with = "deserialize_present"
448 )]
449 pub zero_distance_m: Option<f64>,
450 #[serde(
451 default,
452 skip_serializing_if = "Option::is_none",
453 deserialize_with = "deserialize_present"
454 )]
455 pub muzzle_angle_rad: Option<f64>,
456 #[serde(
457 default,
458 skip_serializing_if = "Option::is_none",
459 deserialize_with = "deserialize_present"
460 )]
461 pub aim_azimuth_rad: Option<f64>,
462 #[serde(
463 default,
464 skip_serializing_if = "Option::is_none",
465 deserialize_with = "deserialize_present"
466 )]
467 pub shot_azimuth_rad: Option<f64>,
468 #[serde(
469 default,
470 skip_serializing_if = "Option::is_none",
471 deserialize_with = "deserialize_present"
472 )]
473 pub shooting_angle_rad: Option<f64>,
474 #[serde(
475 default,
476 skip_serializing_if = "Option::is_none",
477 deserialize_with = "deserialize_present"
478 )]
479 pub cant_angle_rad: Option<f64>,
480 #[serde(
498 default,
499 skip_serializing_if = "Option::is_none",
500 deserialize_with = "deserialize_present"
501 )]
502 pub target_height_m: Option<f64>,
503 #[serde(
504 default,
505 skip_serializing_if = "Option::is_none",
506 deserialize_with = "deserialize_present"
507 )]
508 pub ground_threshold_m: Option<f64>,
509 #[serde(
515 default,
516 skip_serializing_if = "Option::is_none",
517 deserialize_with = "deserialize_present"
518 )]
519 pub zero_poi_up_m: Option<f64>,
520 #[serde(
524 default,
525 skip_serializing_if = "Option::is_none",
526 deserialize_with = "deserialize_present"
527 )]
528 pub zero_poi_right_m: Option<f64>,
529 #[serde(
537 default,
538 skip_serializing_if = "Option::is_none",
539 deserialize_with = "deserialize_present"
540 )]
541 pub drops_reference: Option<DropsReferenceV1>,
542}
543
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
546#[serde(rename_all = "snake_case")]
547pub enum DropsReferenceV1 {
548 #[default]
550 Los,
551 Target,
554}
555
556#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
571#[serde(deny_unknown_fields)]
572pub struct AtmosphereV1 {
573 #[serde(
574 default,
575 skip_serializing_if = "Option::is_none",
576 deserialize_with = "deserialize_present"
577 )]
578 pub altitude_m: Option<f64>,
579 #[serde(
582 default,
583 skip_serializing_if = "Option::is_none",
584 deserialize_with = "deserialize_present"
585 )]
586 pub temperature_k: Option<f64>,
587 #[serde(
593 default,
594 skip_serializing_if = "Option::is_none",
595 deserialize_with = "deserialize_present"
596 )]
597 pub pressure_pa: Option<f64>,
598 #[serde(
604 default,
605 skip_serializing_if = "Option::is_none",
606 deserialize_with = "deserialize_present"
607 )]
608 pub pressure_reference: Option<PressureReferenceV1>,
609 #[serde(
610 default,
611 skip_serializing_if = "Option::is_none",
612 deserialize_with = "deserialize_present"
613 )]
614 pub relative_humidity: Option<f64>,
615 #[serde(
616 default,
617 skip_serializing_if = "Option::is_none",
618 deserialize_with = "deserialize_present"
619 )]
620 pub latitude_rad: Option<f64>,
621}
622
623#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(rename_all = "snake_case")]
628pub enum PressureReferenceV1 {
629 #[default]
630 Absolute,
631 Qnh,
632}
633
634#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
639#[serde(deny_unknown_fields)]
640pub struct WindV1 {
641 #[serde(
642 default,
643 skip_serializing_if = "Option::is_none",
644 deserialize_with = "deserialize_present"
645 )]
646 pub speed_mps: Option<f64>,
647 #[serde(
648 default,
649 skip_serializing_if = "Option::is_none",
650 deserialize_with = "deserialize_present"
651 )]
652 pub direction_from_rad: Option<f64>,
653 #[serde(
654 default,
655 skip_serializing_if = "Option::is_none",
656 deserialize_with = "deserialize_present"
657 )]
658 pub vertical_speed_mps: Option<f64>,
659 #[serde(
660 default,
661 skip_serializing_if = "Option::is_none",
662 deserialize_with = "deserialize_present"
663 )]
664 pub segments: Option<Vec<WindSegmentV1>>,
665 #[serde(
678 default,
679 skip_serializing_if = "Option::is_none",
680 deserialize_with = "deserialize_present"
681 )]
682 pub wind_reference: Option<WindReferenceV1>,
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
687#[serde(rename_all = "snake_case")]
688pub enum WindReferenceV1 {
689 #[default]
691 Shooter,
692 Compass,
694}
695
696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
698#[serde(deny_unknown_fields)]
699pub struct WindSegmentV1 {
700 pub until_distance_m: f64,
701 pub speed_mps: f64,
702 pub direction_from_rad: f64,
703 #[serde(
704 default,
705 skip_serializing_if = "Option::is_none",
706 deserialize_with = "deserialize_present"
707 )]
708 pub vertical_speed_mps: Option<f64>,
709}
710
711#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
713#[serde(deny_unknown_fields)]
714pub struct SolverV1 {
715 #[serde(
716 default,
717 skip_serializing_if = "Option::is_none",
718 deserialize_with = "deserialize_present"
719 )]
720 pub method: Option<SolverMethodV1>,
721 #[serde(
723 default,
724 skip_serializing_if = "Option::is_none",
725 deserialize_with = "deserialize_present"
726 )]
727 pub time_step_s: Option<f64>,
728}
729
730#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(rename_all = "snake_case")]
733pub enum SolverMethodV1 {
734 Euler,
735 Rk4,
736 #[default]
737 Rk45,
738}
739
740#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
742#[serde(deny_unknown_fields)]
743pub struct EffectsV1 {
744 #[serde(
756 default,
757 skip_serializing_if = "Option::is_none",
758 deserialize_with = "deserialize_present"
759 )]
760 pub magnus: Option<bool>,
761 #[serde(
765 default,
766 skip_serializing_if = "Option::is_none",
767 deserialize_with = "deserialize_present"
768 )]
769 pub coriolis: Option<bool>,
770 #[serde(
783 default,
784 skip_serializing_if = "Option::is_none",
785 deserialize_with = "deserialize_present"
786 )]
787 pub enhanced_spin_drift: Option<bool>,
788 #[serde(
798 default,
799 skip_serializing_if = "Option::is_none",
800 deserialize_with = "deserialize_present"
801 )]
802 pub wind_shear_model: Option<WindShearModelV1>,
803 #[serde(
820 default,
821 skip_serializing_if = "Option::is_none",
822 deserialize_with = "deserialize_present"
823 )]
824 pub aerodynamic_jump: Option<bool>,
825}
826
827#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
835#[serde(rename_all = "snake_case")]
836pub enum WindShearModelV1 {
837 #[default]
840 None,
841 Logarithmic,
843 PowerLaw,
845 #[serde(alias = "ekman")]
850 EkmanSpiral,
851}
852
853impl WindShearModelV1 {
854 pub fn as_engine_str(self) -> &'static str {
859 match self {
860 WindShearModelV1::None => "none",
861 WindShearModelV1::Logarithmic => "logarithmic",
862 WindShearModelV1::PowerLaw => "power_law",
863 WindShearModelV1::EkmanSpiral => "ekman_spiral",
864 }
865 }
866
867 pub fn is_enabled(self) -> bool {
874 !matches!(self, WindShearModelV1::None)
875 }
876}
877
878#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
880#[serde(deny_unknown_fields)]
881pub struct SamplingV1 {
882 #[serde(
883 default,
884 skip_serializing_if = "Option::is_none",
885 deserialize_with = "deserialize_present"
886 )]
887 pub interval_m: Option<f64>,
888}
889
890#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
896#[serde(deny_unknown_fields)]
897pub struct ResolvedSolveRequestV1 {
898 pub schema_version: SchemaVersionV1,
899 pub projectile: ResolvedProjectileV1,
900 pub rifle: ResolvedRifleV1,
901 pub shot: ResolvedShotV1,
902 pub atmosphere: ResolvedAtmosphereV1,
903 pub wind: ResolvedWindV1,
904 pub solver: ResolvedSolverV1,
905 pub effects: ResolvedEffectsV1,
906 pub sampling: ResolvedSamplingV1,
907 #[serde(default, skip_serializing_if = "Option::is_none")]
911 pub reticle: Option<ReticleRequestV1>,
912 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub corrections: Option<CorrectionsV1>,
919}
920
921#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
923#[serde(deny_unknown_fields)]
924pub struct ResolvedProjectileV1 {
925 pub mass_kg: f64,
926 pub diameter_m: f64,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub length_m: Option<f64>,
929 pub drag_model: DragModelV1,
930 pub ballistic_coefficient: f64,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
935 pub drag_table: Option<DragTableV1>,
936}
937
938#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
940#[serde(deny_unknown_fields)]
941pub struct ResolvedRifleV1 {
942 pub muzzle_velocity_mps: f64,
943 pub sight_height_m: f64,
944 pub muzzle_height_m: f64,
945 pub twist_rate_m_per_turn: f64,
946 pub twist_direction: TwistDirectionV1,
947 #[serde(default, skip_serializing_if = "Option::is_none")]
950 pub sight_offset_lateral_m: Option<f64>,
951}
952
953#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
965#[serde(deny_unknown_fields)]
966pub struct ResolvedShotV1 {
967 pub max_range_m: f64,
968 #[serde(default, skip_serializing_if = "Option::is_none")]
969 pub zero_distance_m: Option<f64>,
970 pub muzzle_angle_rad: f64,
971 pub aim_azimuth_rad: f64,
972 pub shot_azimuth_rad: f64,
973 pub shooting_angle_rad: f64,
974 pub cant_angle_rad: f64,
975 pub target_height_m: f64,
976 pub ground_threshold_m: f64,
977 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub zero_poi_up_m: Option<f64>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub zero_poi_right_m: Option<f64>,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub drops_reference: Option<DropsReferenceV1>,
989}
990
991#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
993#[serde(deny_unknown_fields)]
994pub struct ResolvedAtmosphereV1 {
995 pub altitude_m: f64,
996 pub temperature_k: f64,
997 pub pressure_pa: f64,
998 pub relative_humidity: f64,
999 #[serde(default, skip_serializing_if = "Option::is_none")]
1000 pub latitude_rad: Option<f64>,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub pressure_reference: Option<PressureReferenceV1>,
1005}
1006
1007#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1012#[serde(untagged)]
1013pub enum ResolvedWindV1 {
1014 Constant(ResolvedConstantWindV1),
1015 Segmented(ResolvedSegmentedWindV1),
1016}
1017
1018#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1020#[serde(deny_unknown_fields)]
1021pub struct ResolvedConstantWindV1 {
1022 pub speed_mps: f64,
1023 pub direction_from_rad: f64,
1024 pub vertical_speed_mps: f64,
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub wind_reference: Option<WindReferenceV1>,
1030}
1031
1032#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1034#[serde(deny_unknown_fields)]
1035pub struct ResolvedSegmentedWindV1 {
1036 pub segments: Vec<ResolvedWindSegmentV1>,
1037 #[serde(default, skip_serializing_if = "Option::is_none")]
1041 pub wind_reference: Option<WindReferenceV1>,
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1046#[serde(deny_unknown_fields)]
1047pub struct ResolvedWindSegmentV1 {
1048 pub until_distance_m: f64,
1049 pub speed_mps: f64,
1050 pub direction_from_rad: f64,
1051 pub vertical_speed_mps: f64,
1052}
1053
1054#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1056#[serde(deny_unknown_fields)]
1057pub struct ResolvedSolverV1 {
1058 pub method: SolverMethodV1,
1059 pub time_step_s: f64,
1060}
1061
1062#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1064#[serde(deny_unknown_fields)]
1065pub struct ResolvedEffectsV1 {
1066 pub magnus: bool,
1067 pub coriolis: bool,
1068 pub enhanced_spin_drift: bool,
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1073 pub wind_shear_model: Option<WindShearModelV1>,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub aerodynamic_jump: Option<bool>,
1085}
1086
1087#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1089#[serde(deny_unknown_fields)]
1090pub struct ResolvedSamplingV1 {
1091 pub interval_m: f64,
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1096#[serde(deny_unknown_fields)]
1097pub struct SolveSuccessV1 {
1098 pub schema_version: SchemaVersionV1,
1099 pub engine_version: String,
1100 pub status: SuccessStatusV1,
1101 pub resolved_request: ResolvedSolveRequestV1,
1102 #[serde(default)]
1103 pub assumptions: Vec<SolveNoticeV1>,
1104 #[serde(default)]
1105 pub warnings: Vec<SolveNoticeV1>,
1106 pub summary: SolveSummaryV1,
1107 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
1108 pub samples: Vec<TrajectorySampleV1>,
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1113 pub reticle_hold: Option<ReticleHoldV1>,
1114}
1115
1116fn serialize_solve_samples_v1<S>(
1117 samples: &[TrajectorySampleV1],
1118 serializer: S,
1119) -> Result<S::Ok, S::Error>
1120where
1121 S: Serializer,
1122{
1123 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
1124 return Err(serde::ser::Error::custom(format_args!(
1125 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1126 samples.len()
1127 )));
1128 }
1129 samples.serialize(serializer)
1130}
1131
1132impl SolveSuccessV1 {
1133 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
1139 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
1140 return Ok(());
1141 }
1142
1143 Err(SolveErrorEnvelopeV1::new(
1144 SolveErrorV1::new(
1145 SolveErrorCodeV1::ResourceLimit,
1146 format!(
1147 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1148 self.samples.len()
1149 ),
1150 )
1151 .at_path("$.sampling.interval_m"),
1152 ))
1153 }
1154}
1155
1156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1158#[serde(rename_all = "snake_case")]
1159pub enum SuccessStatusV1 {
1160 Ok,
1161}
1162
1163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1165#[serde(deny_unknown_fields)]
1166pub struct SolveNoticeV1 {
1167 pub code: String,
1168 pub message: String,
1169 #[serde(default, skip_serializing_if = "Option::is_none")]
1170 pub path: Option<String>,
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1175#[serde(deny_unknown_fields)]
1176pub struct SolveSummaryV1 {
1177 pub actual_range_m: f64,
1178 pub maximum_height_m: f64,
1181 pub time_of_flight_s: f64,
1182 pub terminal_speed_mps: f64,
1183 pub terminal_energy_j: f64,
1184 #[serde(default, skip_serializing_if = "Option::is_none")]
1187 pub stability_factor: Option<f64>,
1188 #[serde(default, skip_serializing_if = "Option::is_none")]
1192 pub spin_drift_m: Option<f64>,
1193 #[serde(default, skip_serializing_if = "Option::is_none")]
1201 pub equivalent_horizontal_range_m: Option<f64>,
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1214 pub aerodynamic_jump_moa: Option<f64>,
1215 pub termination: TerminationReasonV1,
1216}
1217
1218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1220#[serde(rename_all = "snake_case")]
1221pub enum TerminationReasonV1 {
1222 MaxRange,
1223 GroundThreshold,
1224 TimeLimit,
1225 VelocityFloor,
1226}
1227
1228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1230#[serde(deny_unknown_fields)]
1231pub struct TrajectorySampleV1 {
1232 pub distance_m: f64,
1233 pub time_s: f64,
1234 pub speed_mps: f64,
1235 pub energy_j: f64,
1236 pub drop_m: f64,
1238 pub windage_m: f64,
1240 pub mach: f64,
1241 #[serde(default)]
1242 pub flags: Vec<SampleFlagV1>,
1243}
1244
1245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1247#[serde(rename_all = "snake_case")]
1248pub enum SampleFlagV1 {
1249 Transonic,
1250 Subsonic,
1251 Terminal,
1252 GroundThreshold,
1253}
1254
1255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1257#[serde(deny_unknown_fields)]
1258pub struct SolveErrorEnvelopeV1 {
1259 pub schema_version: SchemaVersionV1,
1260 pub status: ErrorStatusV1,
1261 pub error: SolveErrorV1,
1262}
1263
1264impl SolveErrorEnvelopeV1 {
1265 pub fn new(error: SolveErrorV1) -> Self {
1267 Self {
1268 schema_version: SchemaVersionV1,
1269 status: ErrorStatusV1::Error,
1270 error,
1271 }
1272 }
1273}
1274
1275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1277#[serde(rename_all = "snake_case")]
1278pub enum ErrorStatusV1 {
1279 Error,
1280}
1281
1282#[derive(Debug, Clone, PartialEq, Eq)]
1288pub struct SolveErrorV1 {
1289 pub code: SolveErrorCodeV1,
1290 pub message: String,
1291 location: SolveErrorLocationV1,
1292}
1293
1294#[derive(Debug, Clone, PartialEq, Eq)]
1295enum SolveErrorLocationV1 {
1296 None,
1297 Path(String),
1298 Source {
1299 line: NonZeroUsize,
1300 column: NonZeroUsize,
1301 },
1302}
1303
1304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1306pub enum SolveErrorLocationErrorV1 {
1307 ZeroLine,
1308 ZeroColumn,
1309}
1310
1311impl fmt::Display for SolveErrorLocationErrorV1 {
1312 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1313 match self {
1314 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1315 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1316 }
1317 }
1318}
1319
1320impl std::error::Error for SolveErrorLocationErrorV1 {}
1321
1322impl SolveErrorV1 {
1323 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1325 Self {
1326 code,
1327 message: message.into(),
1328 location: SolveErrorLocationV1::None,
1329 }
1330 }
1331
1332 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1334 self.location = SolveErrorLocationV1::Path(path.into());
1335 self
1336 }
1337
1338 pub fn at_location(
1340 mut self,
1341 line: usize,
1342 column: usize,
1343 ) -> Result<Self, SolveErrorLocationErrorV1> {
1344 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1345 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1346 self.location = SolveErrorLocationV1::Source { line, column };
1347 Ok(self)
1348 }
1349
1350 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1355 self.location = SolveErrorLocationV1::Source {
1356 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1357 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1358 };
1359 self
1360 }
1361
1362 pub fn path(&self) -> Option<&str> {
1364 match &self.location {
1365 SolveErrorLocationV1::Path(path) => Some(path),
1366 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1367 }
1368 }
1369
1370 pub fn line(&self) -> Option<usize> {
1372 match &self.location {
1373 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1374 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1375 }
1376 }
1377
1378 pub fn column(&self) -> Option<usize> {
1380 match &self.location {
1381 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1382 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1383 }
1384 }
1385}
1386
1387impl Serialize for SolveErrorV1 {
1388 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1389 where
1390 S: Serializer,
1391 {
1392 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1393 state.serialize_field("code", &self.code)?;
1394 state.serialize_field("message", &self.message)?;
1395 state.serialize_field("path", &self.path())?;
1396 state.serialize_field("line", &self.line())?;
1397 state.serialize_field("column", &self.column())?;
1398 state.end()
1399 }
1400}
1401
1402impl<'de> Deserialize<'de> for SolveErrorV1 {
1403 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1404 where
1405 D: Deserializer<'de>,
1406 {
1407 #[derive(Deserialize)]
1408 #[serde(deny_unknown_fields)]
1409 struct SolveErrorWireV1 {
1410 code: SolveErrorCodeV1,
1411 message: String,
1412 path: Option<String>,
1413 line: Option<usize>,
1414 column: Option<usize>,
1415 }
1416
1417 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1418 let location = match (wire.path, wire.line, wire.column) {
1419 (None, None, None) => SolveErrorLocationV1::None,
1420 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1421 (None, Some(line), Some(column)) => {
1422 let line = NonZeroUsize::new(line)
1423 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1424 let column = NonZeroUsize::new(column)
1425 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1426 SolveErrorLocationV1::Source { line, column }
1427 }
1428 _ => {
1429 return Err(de::Error::custom(
1430 "error location must contain either path or both line and column",
1431 ));
1432 }
1433 };
1434
1435 Ok(Self {
1436 code: wire.code,
1437 message: wire.message,
1438 location,
1439 })
1440 }
1441}
1442
1443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1445#[serde(rename_all = "snake_case")]
1446pub enum SolveErrorCodeV1 {
1447 InvalidJson,
1448 UnsupportedSchemaVersion,
1449 UnknownField,
1450 MissingField,
1451 InvalidValue,
1452 ConflictingFields,
1453 ResourceLimit,
1454 SolveFailed,
1455 IoError,
1456 InternalError,
1457}
1458
1459pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1466 let value: Value = serde_json::from_str(input).map_err(|error| {
1467 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1468 .at_parser_location(error.line(), error.column());
1469 envelope(error)
1470 })?;
1471
1472 validate_request_shape(&value)?;
1473
1474 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1475 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1476 })?;
1477
1478 validate_request_ranges(&request)?;
1479
1480 Ok(request)
1481}
1482
1483mod limits {
1496 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1500 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1502 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1504 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1506
1507 }
1514
1515fn require_range(
1516 value: f64,
1517 (min, max): (f64, f64),
1518 path: &str,
1519) -> Result<(), SolveErrorEnvelopeV1> {
1520 if !value.is_finite() {
1521 return Err(protocol_error(
1522 SolveErrorCodeV1::InvalidValue,
1523 format!("{path} must be a finite number"),
1524 path,
1525 ));
1526 }
1527 if value < min || value > max {
1528 return Err(protocol_error(
1529 SolveErrorCodeV1::InvalidValue,
1530 format!("{path} must be between {min} and {max}, got {value}"),
1531 path,
1532 ));
1533 }
1534 Ok(())
1535}
1536
1537fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1542 require_range(
1543 request.projectile.mass_kg,
1544 limits::MASS_KG,
1545 "$.projectile.mass_kg",
1546 )?;
1547 require_range(
1548 request.projectile.diameter_m,
1549 limits::DIAMETER_M,
1550 "$.projectile.diameter_m",
1551 )?;
1552 require_range(
1553 request.projectile.ballistic_coefficient,
1554 limits::BALLISTIC_COEFFICIENT,
1555 "$.projectile.ballistic_coefficient",
1556 )?;
1557 if let Some(length_m) = request.projectile.length_m {
1558 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1559 }
1560 if let Some(drag_table) = &request.projectile.drag_table {
1561 validate_drag_table_v1(drag_table)?;
1562 }
1563 Ok(())
1564}
1565
1566fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1567 SolveErrorEnvelopeV1::new(error)
1568}
1569
1570fn protocol_error(
1571 code: SolveErrorCodeV1,
1572 message: impl Into<String>,
1573 path: impl Into<String>,
1574) -> SolveErrorEnvelopeV1 {
1575 envelope(SolveErrorV1::new(code, message).at_path(path))
1576}
1577
1578fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1579 let root = require_object(value, "$")?;
1580
1581 validate_schema_version(root)?;
1585 validate_members(
1586 root,
1587 "$",
1588 &[
1589 "schema_version",
1590 "projectile",
1591 "rifle",
1592 "shot",
1593 "atmosphere",
1594 "wind",
1595 "solver",
1596 "effects",
1597 "sampling",
1598 "reticle",
1600 "corrections",
1602 ],
1603 &[
1604 "schema_version",
1605 "projectile",
1606 "rifle",
1607 "shot",
1608 "atmosphere",
1609 "wind",
1610 "solver",
1611 "effects",
1612 "sampling",
1613 ],
1614 )?;
1615
1616 validate_projectile(required_value(root, "projectile", "$")?)?;
1617 validate_rifle(required_value(root, "rifle", "$")?)?;
1618 validate_shot(required_value(root, "shot", "$")?)?;
1619 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1620 validate_wind(required_value(root, "wind", "$")?)?;
1621 validate_solver(required_value(root, "solver", "$")?)?;
1622 validate_effects(required_value(root, "effects", "$")?)?;
1623 validate_sampling(required_value(root, "sampling", "$")?)?;
1624 if let Some(reticle) = root.get("reticle") {
1625 validate_reticle(reticle)?;
1626 }
1627 if let Some(corrections) = root.get("corrections") {
1628 validate_corrections(corrections)?;
1629 }
1630 Ok(())
1631}
1632
1633fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1638 let path = "$.corrections";
1639 let object = require_object(value, path)?;
1640 validate_members(object, path, &["bc5d_table_path"], &[])?;
1641 if let Some(table_path) = object.get("bc5d_table_path") {
1642 if !table_path.is_string() {
1643 return Err(protocol_error(
1644 SolveErrorCodeV1::InvalidValue,
1645 "expected a string",
1646 "$.corrections.bc5d_table_path",
1647 ));
1648 }
1649 }
1650 Ok(())
1651}
1652
1653fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1660 let path = "$.reticle";
1661 let object = require_object(value, path)?;
1662 validate_members(
1663 object,
1664 path,
1665 &["range_m", "magnification", "description"],
1666 &["range_m", "magnification", "description"],
1667 )?;
1668 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1669 require_object(
1670 required_value(object, "description", path)?,
1671 "$.reticle.description",
1672 )?;
1673 Ok(())
1674}
1675
1676fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1677 let value = required_value(root, "schema_version", "$")?;
1678 let version = if let Some(version) = value.as_i64() {
1679 i128::from(version)
1680 } else if let Some(version) = value.as_u64() {
1681 i128::from(version)
1682 } else {
1683 return Err(protocol_error(
1684 SolveErrorCodeV1::InvalidValue,
1685 "schema_version must be the integer 1",
1686 "$.schema_version",
1687 ));
1688 };
1689 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1690 return Err(protocol_error(
1691 SolveErrorCodeV1::UnsupportedSchemaVersion,
1692 format!(
1693 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1694 ),
1695 "$.schema_version",
1696 ));
1697 }
1698 Ok(())
1699}
1700
1701fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1702 let path = "$.projectile";
1703 let object = require_object(value, path)?;
1704 validate_members(
1705 object,
1706 path,
1707 &[
1708 "mass_kg",
1709 "diameter_m",
1710 "length_m",
1711 "drag_model",
1712 "ballistic_coefficient",
1713 "drag_table",
1715 ],
1716 &[
1717 "mass_kg",
1718 "diameter_m",
1719 "drag_model",
1720 "ballistic_coefficient",
1721 ],
1722 )?;
1723 validate_required_numbers(
1724 object,
1725 path,
1726 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1727 )?;
1728 validate_optional_number(object, path, "length_m")?;
1729 validate_string_enum(
1730 required_value(object, "drag_model", path)?,
1731 "$.projectile.drag_model",
1732 &DRAG_MODEL_WIRE_NAMES_V1,
1733 )?;
1734 if let Some(drag_table) = object.get("drag_table") {
1735 validate_drag_table_shape(drag_table)?;
1736 }
1737 Ok(())
1738}
1739
1740fn validate_drag_table_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1745 let path = "$.projectile.drag_table";
1746 let object = require_object(value, path)?;
1747 validate_members(object, path, &["kind", "points"], &["kind", "points"])?;
1748 validate_string_enum(
1749 required_value(object, "kind", path)?,
1750 "$.projectile.drag_table.kind",
1751 &["reference", "projectile"],
1752 )?;
1753 let points_path = "$.projectile.drag_table.points";
1754 let Some(points) = required_value(object, "points", path)?.as_array() else {
1755 return Err(protocol_error(
1756 SolveErrorCodeV1::InvalidValue,
1757 "points must be an array",
1758 points_path,
1759 ));
1760 };
1761 for (index, point) in points.iter().enumerate() {
1762 let point_path = format!("{points_path}[{index}]");
1763 let point = require_object(point, &point_path)?;
1764 validate_members(point, &point_path, &["mach", "cd"], &["mach", "cd"])?;
1765 validate_required_numbers(point, &point_path, &["mach", "cd"])?;
1766 }
1767 Ok(())
1768}
1769
1770fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1771 let path = "$.rifle";
1772 let object = require_object(value, path)?;
1773 validate_members(
1774 object,
1775 path,
1776 &[
1777 "muzzle_velocity_mps",
1778 "sight_height_m",
1779 "muzzle_height_m",
1780 "twist_rate_m_per_turn",
1781 "twist_direction",
1782 "sight_offset_lateral_m",
1783 ],
1784 &["muzzle_velocity_mps"],
1785 )?;
1786 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1787 validate_optional_numbers(
1788 object,
1789 path,
1790 &[
1791 "sight_height_m",
1792 "muzzle_height_m",
1793 "twist_rate_m_per_turn",
1794 "sight_offset_lateral_m",
1795 ],
1796 )?;
1797 if let Some(direction) = object.get("twist_direction") {
1798 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1799 }
1800 Ok(())
1801}
1802
1803fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1804 let path = "$.shot";
1805 let object = require_object(value, path)?;
1806 validate_members(
1807 object,
1808 path,
1809 &[
1810 "max_range_m",
1811 "zero_distance_m",
1812 "muzzle_angle_rad",
1813 "aim_azimuth_rad",
1814 "shot_azimuth_rad",
1815 "shooting_angle_rad",
1816 "cant_angle_rad",
1817 "target_height_m",
1818 "ground_threshold_m",
1819 "zero_poi_up_m",
1820 "zero_poi_right_m",
1821 "drops_reference",
1822 ],
1823 &["max_range_m"],
1824 )?;
1825 validate_required_numbers(object, path, &["max_range_m"])?;
1826 validate_optional_number(object, path, "zero_distance_m")?;
1827 validate_optional_number(object, path, "muzzle_angle_rad")?;
1828 validate_optional_numbers(
1829 object,
1830 path,
1831 &[
1832 "aim_azimuth_rad",
1833 "shot_azimuth_rad",
1834 "shooting_angle_rad",
1835 "cant_angle_rad",
1836 "target_height_m",
1837 "ground_threshold_m",
1838 "zero_poi_up_m",
1839 "zero_poi_right_m",
1840 ],
1841 )?;
1842 if let Some(reference) = object.get("drops_reference") {
1845 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1846 }
1847 Ok(())
1848}
1849
1850pub(crate) fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1851 let path = "$.atmosphere";
1852 let object = require_object(value, path)?;
1853 validate_members(
1854 object,
1855 path,
1856 &[
1857 "altitude_m",
1858 "temperature_k",
1859 "pressure_pa",
1860 "pressure_reference",
1861 "relative_humidity",
1862 "latitude_rad",
1863 ],
1864 &[],
1865 )?;
1866 validate_optional_numbers(
1867 object,
1868 path,
1869 &[
1870 "altitude_m",
1871 "temperature_k",
1872 "pressure_pa",
1873 "relative_humidity",
1874 "latitude_rad",
1875 ],
1876 )?;
1877 if let Some(reference) = object.get("pressure_reference") {
1883 validate_string_enum(
1884 reference,
1885 "$.atmosphere.pressure_reference",
1886 &["absolute", "qnh"],
1887 )?;
1888 }
1889 Ok(())
1890}
1891
1892fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1893 let path = "$.wind";
1894 let object = require_object(value, path)?;
1895 validate_members(
1896 object,
1897 path,
1898 &[
1899 "speed_mps",
1900 "direction_from_rad",
1901 "vertical_speed_mps",
1902 "segments",
1903 "wind_reference",
1904 ],
1905 &[],
1906 )?;
1907 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1908 validate_optional_number(object, path, field)?;
1909 }
1910 if let Some(reference) = object.get("wind_reference") {
1912 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1913 }
1914
1915 if let Some(segments) = object.get("segments") {
1916 let Some(segments) = segments.as_array() else {
1917 return Err(protocol_error(
1918 SolveErrorCodeV1::InvalidValue,
1919 "segments must be an array",
1920 "$.wind.segments",
1921 ));
1922 };
1923 for (index, segment) in segments.iter().enumerate() {
1924 let segment_path = format!("$.wind.segments[{index}]");
1925 let segment = require_object(segment, &segment_path)?;
1926 validate_members(
1927 segment,
1928 &segment_path,
1929 &[
1930 "until_distance_m",
1931 "speed_mps",
1932 "direction_from_rad",
1933 "vertical_speed_mps",
1934 ],
1935 &["until_distance_m", "speed_mps", "direction_from_rad"],
1936 )?;
1937 validate_required_numbers(
1938 segment,
1939 &segment_path,
1940 &["until_distance_m", "speed_mps", "direction_from_rad"],
1941 )?;
1942 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1943 }
1944 }
1945 Ok(())
1946}
1947
1948fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1949 let path = "$.solver";
1950 let object = require_object(value, path)?;
1951 validate_members(object, path, &["method", "time_step_s"], &[])?;
1952 validate_optional_number(object, path, "time_step_s")?;
1953 if let Some(method) = object.get("method") {
1954 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1955 }
1956 Ok(())
1957}
1958
1959pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
1968 "magnus",
1969 "coriolis",
1970 "enhanced_spin_drift",
1971 "wind_shear_model",
1972 "aerodynamic_jump",
1973];
1974
1975fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1976 let path = "$.effects";
1977 let object = require_object(value, path)?;
1978 validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
1979 validate_optional_booleans(
1980 object,
1981 path,
1982 &[
1983 "magnus",
1984 "coriolis",
1985 "enhanced_spin_drift",
1986 "aerodynamic_jump",
1987 ],
1988 )?;
1989
1990 if let Some(model) = object.get("wind_shear_model") {
1994 validate_string_enum(
1995 model,
1996 "$.effects.wind_shear_model",
1997 &[
1998 "none",
1999 "logarithmic",
2000 "power_law",
2001 "ekman_spiral",
2002 "ekman",
2004 ],
2005 )?;
2006 }
2007
2008 if object.get("magnus").and_then(Value::as_bool) == Some(true)
2009 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
2010 {
2011 return Err(protocol_error(
2012 SolveErrorCodeV1::ConflictingFields,
2013 "magnus and enhanced_spin_drift cannot both be enabled",
2014 "$.effects",
2015 ));
2016 }
2017
2018 Ok(())
2019}
2020
2021fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2022 let path = "$.sampling";
2023 let object = require_object(value, path)?;
2024 validate_members(object, path, &["interval_m"], &[])?;
2025 validate_optional_number(object, path, "interval_m")
2026}
2027
2028pub(crate) fn require_object<'a>(
2029 value: &'a Value,
2030 path: &str,
2031) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
2032 value
2033 .as_object()
2034 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
2035}
2036
2037fn required_value<'a>(
2038 object: &'a Map<String, Value>,
2039 field: &str,
2040 parent_path: &str,
2041) -> Result<&'a Value, SolveErrorEnvelopeV1> {
2042 object.get(field).ok_or_else(|| {
2043 protocol_error(
2044 SolveErrorCodeV1::MissingField,
2045 format!("missing required field `{field}`"),
2046 child_path(parent_path, field),
2047 )
2048 })
2049}
2050
2051pub(crate) fn validate_members(
2052 object: &Map<String, Value>,
2053 path: &str,
2054 allowed: &[&str],
2055 required: &[&str],
2056) -> Result<(), SolveErrorEnvelopeV1> {
2057 if let Some(field) = object
2058 .keys()
2059 .find(|field| !allowed.contains(&field.as_str()))
2060 {
2061 return Err(protocol_error(
2062 SolveErrorCodeV1::UnknownField,
2063 format!("unknown field `{field}`"),
2064 child_path(path, field),
2065 ));
2066 }
2067
2068 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
2069 return Err(protocol_error(
2070 SolveErrorCodeV1::MissingField,
2071 format!("missing required field `{field}`"),
2072 child_path(path, field),
2073 ));
2074 }
2075 Ok(())
2076}
2077
2078fn validate_string_enum(
2079 value: &Value,
2080 path: &str,
2081 allowed: &[&str],
2082) -> Result<(), SolveErrorEnvelopeV1> {
2083 let Some(value) = value.as_str() else {
2084 return Err(protocol_error(
2085 SolveErrorCodeV1::InvalidValue,
2086 "expected a string enum value",
2087 path,
2088 ));
2089 };
2090 if !allowed.contains(&value) {
2091 return Err(protocol_error(
2092 SolveErrorCodeV1::InvalidValue,
2093 format!(
2094 "invalid value `{value}`; expected one of {}",
2095 allowed.join(", ")
2096 ),
2097 path,
2098 ));
2099 }
2100 Ok(())
2101}
2102
2103fn validate_required_numbers(
2104 object: &Map<String, Value>,
2105 parent_path: &str,
2106 fields: &[&str],
2107) -> Result<(), SolveErrorEnvelopeV1> {
2108 for field in fields {
2109 let value = required_value(object, field, parent_path)?;
2110 validate_number(value, &child_path(parent_path, field))?;
2111 }
2112 Ok(())
2113}
2114
2115fn validate_optional_numbers(
2116 object: &Map<String, Value>,
2117 parent_path: &str,
2118 fields: &[&str],
2119) -> Result<(), SolveErrorEnvelopeV1> {
2120 for field in fields {
2121 validate_optional_number(object, parent_path, field)?;
2122 }
2123 Ok(())
2124}
2125
2126fn validate_optional_number(
2127 object: &Map<String, Value>,
2128 parent_path: &str,
2129 field: &str,
2130) -> Result<(), SolveErrorEnvelopeV1> {
2131 if let Some(value) = object.get(field) {
2132 validate_number(value, &child_path(parent_path, field))?;
2133 }
2134 Ok(())
2135}
2136
2137fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
2138 if value.is_number() {
2139 Ok(())
2140 } else {
2141 Err(protocol_error(
2142 SolveErrorCodeV1::InvalidValue,
2143 "expected a number",
2144 path,
2145 ))
2146 }
2147}
2148
2149fn validate_optional_booleans(
2150 object: &Map<String, Value>,
2151 parent_path: &str,
2152 fields: &[&str],
2153) -> Result<(), SolveErrorEnvelopeV1> {
2154 for field in fields {
2155 if let Some(value) = object.get(*field) {
2156 if !value.is_boolean() {
2157 return Err(protocol_error(
2158 SolveErrorCodeV1::InvalidValue,
2159 "expected a boolean",
2160 child_path(parent_path, field),
2161 ));
2162 }
2163 }
2164 }
2165 Ok(())
2166}
2167
2168fn child_path(parent: &str, field: &str) -> String {
2169 format!("{parent}.{field}")
2170}
2171
2172#[cfg(test)]
2174mod request_range_tests {
2175 use super::*;
2176
2177 fn valid_request_json() -> String {
2180 r#"{"schema_version":1,
2181 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
2182 "drag_model":"G7","ballistic_coefficient":0.243},
2183 "rifle":{"muzzle_velocity_mps":823.0},
2184 "shot":{"max_range_m":1000.0},
2185 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
2186 .to_string()
2187 }
2188
2189 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
2190 decode_solve_request_v1(json).expect_err("request should have been rejected")
2191 }
2192
2193 #[test]
2194 fn an_ordinary_request_still_decodes() {
2195 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2196 }
2197
2198 #[test]
2204 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2205 let reproducer = r#"{"schema_version":1,
2206 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2207 "drag_model":"G7","ballistic_coefficient":1.2e2},
2208 "rifle":{"muzzle_velocity_mps":823.0},
2209 "shot":{"max_range_m":100.0},
2210 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2211
2212 let envelope = decode_err(reproducer);
2213 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2214 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2215 }
2216
2217 #[test]
2220 fn the_rejection_envelope_round_trips() {
2221 let envelope = decode_err(
2222 r#"{"schema_version":1,
2223 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2224 "drag_model":"G7","ballistic_coefficient":0.243},
2225 "rifle":{"muzzle_velocity_mps":823.0},
2226 "shot":{"max_range_m":100.0},
2227 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2228 );
2229 let encoded = serde_json::to_string(&envelope).expect("serialize");
2230 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2231 assert_eq!(decoded, envelope);
2232 }
2233
2234 #[test]
2235 fn each_bounded_field_reports_its_own_path() {
2236 for (field, bad_value, path) in [
2237 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2238 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2239 (
2240 "ballistic_coefficient",
2241 "1.0e6",
2242 "$.projectile.ballistic_coefficient",
2243 ),
2244 ("length_m", "1.0e-9", "$.projectile.length_m"),
2245 ] {
2246 let json = valid_request_json().replace(
2247 &format!("\"{field}\":{}", default_for(field)),
2248 &format!("\"{field}\":{bad_value}"),
2249 );
2250 let envelope = decode_err(&json);
2251 assert_eq!(envelope.error.path(), Some(path), "wrong path for {field}");
2252 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2253 }
2254 }
2255
2256 fn default_for(field: &str) -> &'static str {
2257 match field {
2258 "mass_kg" => "0.01134",
2259 "diameter_m" => "0.00782",
2260 "ballistic_coefficient" => "0.243",
2261 "length_m" => "0.031",
2262 other => panic!("no default recorded for {other}"),
2263 }
2264 }
2265
2266 #[test]
2270 fn muzzle_velocity_is_deliberately_left_unbounded() {
2271 let json = valid_request_json().replace("823.0", "1.0e308");
2272 decode_solve_request_v1(&json)
2273 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2274 }
2275}