Skip to main content

ballistics_engine/bridge/
mod.rs

1//! Versioned JSON command bridge for embedded (mobile/FFI) consumers.
2//!
3//! One entry point, [`bridge_call`], accepts a JSON envelope and returns a JSON
4//! envelope. Request semantics live in the transport-free library services
5//! (starting with [`crate::solve_v1()`]); this module contains only the envelope
6//! contract, command dispatch, and panic containment. The C ABI wrapper lives in
7//! [`crate::bridge::ffi`] (feature `ffi`).
8//!
9//! ## Envelope contract (v1)
10//!
11//! Request:
12//! ```json
13//! { "api_version": 1, "command": "solve", "request": { ... } }
14//! ```
15//!
16//! Success response:
17//! ```json
18//! { "ok": true, "api_version": 1, "engine_version": "0.33.1",
19//!   "command": "solve", "result": { ... } }
20//! ```
21//!
22//! Error response (always in-band; the bridge never signals failure any other way):
23//! ```json
24//! { "ok": false, "api_version": 1, "engine_version": "0.33.1",
25//!   "error": { "code": "command_failed", "message": "...", "details": { ... } } }
26//! ```
27//!
28//! Compatibility policy: the envelope itself rejects unknown fields (a caller that
29//! misspells `command` should hear about it), while inner `request` payloads follow
30//! each command's own schema discipline (e.g. `solve` uses the solve-json v1
31//! decoder, which also rejects unknown fields with location info). New commands
32//! and new OPTIONAL response fields may appear within api_version 1; anything that
33//! would break an existing well-formed caller bumps `BRIDGE_API_VERSION`. Callers
34//! feature-detect with `meta.capabilities` instead of sniffing versions.
35//!
36//! ## The `atmosphere.*` family
37//!
38//! `atmosphere.density_altitude` describes the air a solve would fly through, without
39//! solving anything (`crate::atmosphere_service`). Its request is `{"atmosphere": {...}}`
40//! where the inner object is EXACTLY solve-json v1's `atmosphere` — SI units, humidity as a
41//! fraction, optional `pressure_reference` — decoded by the solve's own shape validator and
42//! resolved by the solve's own `resolve_atmosphere`. Validation errors, ICAO defaults
43//! (announced in `assumptions`) and the QNH reduction are therefore the solve's: the air
44//! described is the air solved through. On top of that it refuses air the solve does not yet
45//! refuse — a temperature outside −100..100 °C, a pressure or density altitude outside the
46//! troposphere, a humidity beyond the total pressure — which is what °C sent as kelvin or hPa
47//! sent as pascals looks like.
48//!
49//! The result reports pressure altitude, the CIPM-2007 air density the solver uses, and
50//! density altitude under TWO names, never one: `faa_rule` (NWS pressure altitude plus the
51//! FAA 120 ft/°C rule; humidity-free, the formula the DOPE card header uses, the exact
52//! inverse of density-altitude entry) and `density_matched` (the ISA altitude of equal actual
53//! density, humidity included — what humidity-aware tools, the NWS's own calculator among
54//! them, report). They differ for reasons other than humidity — the FAA rule is a straight
55//! line — so an app must pick one by name and must not label their difference a humidity
56//! correction. Every altitude carries both `m` and `ft`.
57//!
58//! Errors are shaped like `solve`'s: a missing payload is `invalid_request`; anything wrong
59//! with the payload — a malformed or unit-suffixed field, an out-of-range value, impossible
60//! air — is `command_failed` with the solve-json error envelope (`$.atmosphere.*` path) in
61//! `error.details`. Unconditional — present on every target including wasm32 — and additive
62//! within api_version 1.
63//!
64//! ## `.a7p` interop: `profile.import_a7p` and `profile.export_a7p`
65//!
66//! Both directions of the ArcherBC2 format, and both are LOSSY in a way the caller is
67//! required to surface. Import reports `unmapped` — what the file held that a
68//! `ProfileData` cannot. Export (MBA-1556) reports `not_carried` — every `ProfileData`
69//! field the file cannot hold, listed unconditionally with a `populated` flag, plus the
70//! pre-filtered `dropped_fields` for the ones this profile really did lose.
71//!
72//! The asymmetry is deliberate. On import the user is gaining a profile and can see what
73//! arrived; on export they are handing a rifle to somebody else and cannot see what
74//! arrives at the far end. An app that renders "exported" without rendering the drop list
75//! has told a shooter something untrue, so the list travels in the result rather than
76//! being something a caller has to ask for.
77//!
78//! Export never extends the format to make room: `.a7p` belongs to somebody else, and
79//! smuggling our fields into unused field numbers would produce files Archer's own tools
80//! misread.
81//!
82//! ## The `reticle.*` family
83//!
84//! `reticle.describe`, `reticle.hold` and `reticle.import` put the engine's reticle stack
85//! (MBA-1361, MBA-1440, MBA-1544) in reach of an app. Before MBA-1558 none of it was: the
86//! bridge named no reticle command, and since the apps vendor only `ballistics_bridge_call`
87//! and friends, a capability the bridge does not name does not exist for them.
88//!
89//! - `reticle.hold` is the one the family exists for — given a firing solution ALREADY
90//!   reduced to angles and the optic's current magnification, it reports which mark to hold
91//!   on. It runs no physics: the angles are inputs, and `crate::reticle` keeps its
92//!   no-physics property precisely because nothing here re-derives them.
93//! - `reticle.describe` resolves a reticle, supplied inline or built from a generator, and
94//!   optionally reports every mark's TRUE angular position at a magnification. This is what
95//!   a picker and a drawing are built on. It carries `focal_plane` and
96//!   `magnification_dependent` so a UI knows whether a magnification control changes
97//!   anything.
98//! - `reticle.import` reads the two third-party formats the crate already parses (Ventum
99//!   JSON, `.reticle` XML), each with its report. Both are TEXT and travel inline, so
100//!   unlike `profile.import_a7p` there is no base64 step.
101//! - `reticle.catalog` lists the NAMED reticles this build can produce
102//!   (`crate::reticle_catalog`, MBA-1545), which is what an app picker is populated from —
103//!   hardcoding ids in an app means a picker that offers what a pinned engine refuses. Each
104//!   entry carries its `source`, and that provenance reaches the wire on purpose: a wrong
105//!   subtension is a hold wrong by a mark and looks entirely normal, so what makes it
106//!   traceable belongs where the person reading the reticle can see it.
107//!
108//! Two shapes are shared across the family and are worth stating once. A reticle is named
109//! by EXACTLY ONE of `reticle` (a full description), `generator`, or `catalog` (an id from
110//! `reticle.catalog`) — a request naming more than one is refused rather than resolved by
111//! precedence, because supplying more than one is a caller bug and picking a winner hides
112//! it. An unknown catalog id is refused for the same reason and never substituted: a
113//! shooter handed a different reticle is being shown holds for glass they are not looking
114//! through. And because every generator returns FFP,
115//! `focal_plane` / `reference_magnification` may be supplied ALONGSIDE a generator to make
116//! an SFP reticle; the same keys beside a full `reticle` are refused, since that
117//! description already carries its own.
118//!
119//! Errors follow `true.dsf`'s convention: `error.code` stays `command_failed` and a stable
120//! `reason` rides in `error.details` (one per `ReticleError` variant) with the offending
121//! numbers beside it. Additive within api_version 1, and listed by `meta.capabilities`.
122//!
123//! ## The `true.*` truing family
124//!
125//! `true.fit`, `true.wind`, `true.tall_target`, `true.dsf`, `true.plan`, and `true.dial_plan`
126//! expose the engine's truing methods. All six are unconditional (no filesystem access, so
127//! all six are present on wasm32), but they are not otherwise uniform:
128//!
129//! - `true.fit` (joint MV+BC truing) is backed by the uncertainty solver, so its result
130//!   always carries `approximation` — a required enum that is either `Available` with
131//!   intervals for both muzzle velocity and BC, or `Unavailable` with a reason, never simply
132//!   absent. There is deliberately no command that returns a bare truing point estimate.
133//! - `true.wind` (effective crosswind from an observed miss) is the one exception to that
134//!   guarantee: `solve_wind_truing` has no uncertainty model, so its result is a bare point
135//!   value with no interval. Callers must not present it with `true.fit`'s confidence.
136//!   `true.wind` is also the one command whose wire shape is SI throughout (`range_m`,
137//!   `miss_right_m`, `sigma_m`) while every other command, including the rest of this
138//!   family, is imperial; apps convert at the boundary for `true.wind` specifically.
139//! - `true.tall_target` returns a scope's tracking correction factor from a tall-target
140//!   test.
141//! - `true.dsf` derives a single Mach-keyed drop-scale-factor point from an observed
142//!   transonic drop; it never persists into a profile's DSF table, which is the caller's
143//!   job. It established this family's structured-`error.details` convention: a
144//!   machine-readable `reason` (`invalid_input`, `supersonic`, `out_of_range`,
145//!   `degenerate_drop`, `forward_model`) alongside the message; `error.code` stays
146//!   `command_failed` for all commands, so existing callers are unaffected.
147//! - `true.plan` recommends which candidate ranges to shoot for a joint MV/BC truing
148//!   experiment (`crate::truing_plan::plan_truing_experiment_v1`, wired directly — no new
149//!   service function). Its error also carries structured `error.details`, under the same
150//!   `reason` key as every other command in this family (`invalid_request`,
151//!   `insufficient_reachable_candidates`, `no_feasible_design`) plus the `rejected_candidates`
152//!   diagnostics the typed error itself carries.
153//! - `true.dial_plan` turns a TRUE angular correction into ranked dial/hold/hybrid
154//!   execution plans for an INLINE optic (`crate::truing_service::dial_plan_v1`, wrapping
155//!   `crate::optic::plan_corrections`). Unlike the CLI's `dial-plan --profile` mode, there
156//!   is no profile-loading path here — the optic is supplied inline in the request, since a
157//!   saved-profile filesystem read must not enter this bridge. Its error also carries
158//!   structured `error.details`: a stable `reason` per `OpticError` variant.
159//!
160//! None of this needed a `BRIDGE_API_VERSION` bump: the six commands are additive within
161//! api_version 1, and `meta.capabilities` lists all six for feature detection.
162
163#[cfg(feature = "ffi")]
164pub mod ffi;
165
166use serde::{Deserialize, Serialize};
167use serde_json::{json, Value};
168use std::panic::{catch_unwind, AssertUnwindSafe};
169
170/// Bridge envelope version. Bumped only for breaking envelope changes.
171pub const BRIDGE_API_VERSION: u32 = 1;
172
173/// Hard cap on request size, matching the solve-json transport.
174pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
175
176const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
177
178/// Commands available in this build, in dispatch order.
179/// `meta.capabilities` reports exactly this list so apps can feature-detect.
180fn command_names() -> Vec<&'static str> {
181    let mut names = vec![
182        "meta.capabilities",
183        "meta.version",
184        "solve",
185        "atmosphere.density_altitude",
186        "card.come_ups",
187        "card.range_table",
188        "card.wind",
189    ];
190    // Listed ONLY when compiled in (mirroring compiled_features) so apps feature-detect
191    // the command list instead of probing for unknown_command. Each conditional push sits
192    // at its dispatch position so this list stays in dispatch order, as documented above.
193    #[cfg(feature = "pdf")]
194    names.push("card.pdf");
195    names.extend(["profile.validate", "profile.normalize"]);
196    #[cfg(feature = "profile-import")]
197    names.push("profile.import_a7p");
198    #[cfg(feature = "profile-export")]
199    names.push("profile.export_a7p");
200    // Unconditional: the reticle stack is pure geometry with no filesystem access, so all
201    // three are present on wasm32 like the `true.*` family.
202    names.extend([
203        "reticle.catalog",
204        "reticle.describe",
205        "reticle.hold",
206        "reticle.holds",
207        "reticle.import",
208    ]);
209    names.extend([
210        "true.fit",
211        "true.wind",
212        "true.tall_target",
213        "true.dsf",
214        "true.plan",
215        "true.dial_plan",
216    ]);
217    // Filesystem-backed (BC5D tables are loaded from caller-supplied paths), so absent on
218    // wasm32 — the same "list only what this build can run" rule as profile.import_a7p.
219    #[cfg(not(target_arch = "wasm32"))]
220    names.push("bc5d.info");
221    names
222}
223
224fn compiled_features() -> Vec<&'static str> {
225    [
226        ("pdf", cfg!(feature = "pdf")),
227        ("profile-import", cfg!(feature = "profile-import")),
228        ("profile-export", cfg!(feature = "profile-export")),
229        ("online", cfg!(feature = "online")),
230    ]
231    .iter()
232    .filter(|(_, enabled)| *enabled)
233    .map(|(name, _)| *name)
234    .collect()
235}
236
237#[derive(Debug, Deserialize)]
238#[serde(deny_unknown_fields)]
239struct BridgeRequest {
240    api_version: u32,
241    command: String,
242    #[serde(default)]
243    request: Value,
244}
245
246/// Machine-readable bridge error codes. Distinct from any command's own error
247/// vocabulary: a `command_failed` carries the command's typed error in `details`.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
249#[serde(rename_all = "snake_case")]
250pub enum BridgeErrorCode {
251    InvalidJson,
252    UnsupportedApiVersion,
253    UnknownCommand,
254    InvalidRequest,
255    ResourceLimit,
256    CommandFailed,
257    InternalError,
258}
259
260fn success(command: &str, result: Value) -> String {
261    serialize_envelope(&json!({
262        "ok": true,
263        "api_version": BRIDGE_API_VERSION,
264        "engine_version": ENGINE_VERSION,
265        "command": command,
266        "result": result,
267    }))
268}
269
270fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
271    let mut error = json!({
272        "code": code,
273        "message": message.into(),
274    });
275    if let Some(details) = details {
276        error["details"] = details;
277    }
278    serialize_envelope(&json!({
279        "ok": false,
280        "api_version": BRIDGE_API_VERSION,
281        "engine_version": ENGINE_VERSION,
282        "error": error,
283    }))
284}
285
286/// Serialization of the envelope itself must not be able to fail the bridge:
287/// fall back to a hand-written internal_error document.
288fn serialize_envelope(value: &Value) -> String {
289    serde_json::to_string(value).unwrap_or_else(|_| {
290        format!(
291            r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
292        )
293    })
294}
295
296/// Process one bridge exchange. Never panics; every failure mode is an in-band
297/// error envelope. This is the function the C ABI wraps.
298pub fn bridge_call(request_json: &str) -> String {
299    let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
300    guarded.unwrap_or_else(|_| {
301        error(
302            BridgeErrorCode::InternalError,
303            "bridge command failed unexpectedly",
304            None,
305        )
306    })
307}
308
309fn dispatch(request_json: &str) -> String {
310    if request_json.len() > MAX_REQUEST_BYTES {
311        return error(
312            BridgeErrorCode::ResourceLimit,
313            format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
314            None,
315        );
316    }
317
318    let request: BridgeRequest = match serde_json::from_str(request_json) {
319        Ok(request) => request,
320        Err(err) => {
321            return error(
322                BridgeErrorCode::InvalidJson,
323                format!("bridge request is not a valid envelope: {err}"),
324                None,
325            )
326        }
327    };
328
329    if request.api_version != BRIDGE_API_VERSION {
330        return error(
331            BridgeErrorCode::UnsupportedApiVersion,
332            format!(
333                "unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
334                request.api_version
335            ),
336            None,
337        );
338    }
339
340    match request.command.as_str() {
341        "meta.capabilities" => success(
342            "meta.capabilities",
343            json!({
344                "engine_version": ENGINE_VERSION,
345                "bridge_api_version": BRIDGE_API_VERSION,
346                "commands": command_names(),
347                "features": compiled_features(),
348                "solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
349            }),
350        ),
351        "meta.version" => success("meta.version", json!({ "engine_version": ENGINE_VERSION })),
352        "solve" => run_solve(&request.request),
353        "atmosphere.density_altitude" => run_atmosphere_density_altitude(&request.request),
354        "card.come_ups" => run_service(
355            &request.request,
356            "card.come_ups",
357            crate::card_service::come_ups_v1,
358        ),
359        "card.range_table" => run_service(
360            &request.request,
361            "card.range_table",
362            crate::card_service::range_table_v1,
363        ),
364        "card.wind" => run_service(
365            &request.request,
366            "card.wind",
367            crate::card_service::wind_card_v1,
368        ),
369        #[cfg(feature = "pdf")]
370        "card.pdf" => run_card_pdf(&request.request),
371        "profile.validate" => run_profile_validate(&request.request),
372        "profile.normalize" => run_profile_normalize(&request.request),
373        #[cfg(feature = "profile-import")]
374        "profile.import_a7p" => run_profile_import_a7p(&request.request),
375        #[cfg(feature = "profile-export")]
376        "profile.export_a7p" => run_profile_export_a7p(&request.request),
377        "reticle.catalog" => run_reticle_catalog(),
378        "reticle.describe" => run_reticle_describe(&request.request),
379        "reticle.hold" => run_reticle_hold(&request.request),
380        "reticle.holds" => run_reticle_holds(&request.request),
381        "reticle.import" => run_reticle_import(&request.request),
382        "true.fit" => run_service(
383            &request.request,
384            "true.fit",
385            crate::truing_uncertainty::run_uncertainty_truing_v1,
386        ),
387        "true.wind" => run_service(
388            &request.request,
389            "true.wind",
390            crate::truing_wind::solve_wind_truing,
391        ),
392        "true.tall_target" => run_service(
393            &request.request,
394            "true.tall_target",
395            crate::truing_service::tall_target_v1,
396        ),
397        "true.dsf" => run_service_detailed(
398            &request.request,
399            "true.dsf",
400            crate::truing_service::derive_dsf_point_v1,
401            crate::truing_service::DsfServiceErrorV1::failure_details,
402        ),
403        "true.plan" => run_service_detailed(
404            &request.request,
405            "true.plan",
406            crate::truing_plan::plan_truing_experiment_v1,
407            crate::truing_plan::TruingPlanErrorV1::failure_details,
408        ),
409        "true.dial_plan" => run_service_detailed(
410            &request.request,
411            "true.dial_plan",
412            crate::truing_service::dial_plan_v1,
413            crate::optic::OpticError::failure_details,
414        ),
415        #[cfg(not(target_arch = "wasm32"))]
416        "bc5d.info" => run_bc5d_info(&request.request),
417        other => error(
418            BridgeErrorCode::UnknownCommand,
419            format!(
420                "unknown command '{other}'; this build supports: {}",
421                command_names().join(", ")
422            ),
423            None,
424        ),
425    }
426}
427
428/// `solve` delegates verbatim to the solve-json v1 service. The inner request is
429/// re-serialized and run through [`crate::solve_json::decode_solve_request_v1`] so
430/// callers get the exact same schema validation (unknown-field rejection, explicit
431/// SI units, typed error locations) as the CLI `solve-json` transport.
432fn run_solve(inner: &Value) -> String {
433    if inner.is_null() {
434        return error(
435            BridgeErrorCode::InvalidRequest,
436            "'solve' requires a request payload (solve-json v1 document)",
437            None,
438        );
439    }
440    let inner_text = match serde_json::to_string(inner) {
441        Ok(text) => text,
442        Err(err) => {
443            return error(
444                BridgeErrorCode::InternalError,
445                format!("failed to re-serialize solve request: {err}"),
446                None,
447            )
448        }
449    };
450
451    let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
452        Ok(request) => request,
453        Err(envelope) => return command_error("solve request rejected", &envelope),
454    };
455
456    match crate::solve_v1(request) {
457        Ok(successful) => match serde_json::to_value(&successful) {
458            Ok(result) => success("solve", result),
459            Err(err) => error(
460                BridgeErrorCode::InternalError,
461                format!("failed to serialize solve result: {err}"),
462                None,
463            ),
464        },
465        Err(envelope) => command_error("solve failed", &envelope),
466    }
467}
468
469/// `atmosphere.density_altitude`: decode and resolve an atmosphere exactly as `solve` does and
470/// report its pressure altitude, both density altitudes, and its air density
471/// (`crate::atmosphere_service`). Shaped like `run_solve` rather than `run_service` so every
472/// rejection of the payload comes back as the solve's own path-located envelope in `details`,
473/// under the same `command_failed` code `solve` uses.
474fn run_atmosphere_density_altitude(inner: &Value) -> String {
475    const COMMAND: &str = "atmosphere.density_altitude";
476    if inner.is_null() {
477        return error(
478            BridgeErrorCode::InvalidRequest,
479            "'atmosphere.density_altitude' requires a request payload ({\"atmosphere\": {...}})",
480            None,
481        );
482    }
483    let outcome = crate::atmosphere_service::decode_density_altitude_request_v1(inner)
484        .and_then(|request| crate::atmosphere_service::density_altitude_v1(&request));
485    match outcome {
486        Ok(response) => match serde_json::to_value(&response) {
487            Ok(result) => success(COMMAND, result),
488            Err(err) => error(
489                BridgeErrorCode::InternalError,
490                format!("failed to serialize {COMMAND} result: {err}"),
491                None,
492            ),
493        },
494        Err(envelope) => command_error("atmosphere.density_altitude request rejected", &envelope),
495    }
496}
497
498/// Shared adapter for every command backed by a transport-free service: null-check the
499/// payload, deserialize the request, call the service, serialize the response. The error
500/// mapping is fixed so all commands report failures identically.
501fn run_service<Req, Resp, E, F>(inner: &Value, command: &'static str, service: F) -> String
502where
503    Req: serde::de::DeserializeOwned,
504    Resp: serde::Serialize,
505    E: std::fmt::Display,
506    F: FnOnce(&Req) -> Result<Resp, E>,
507{
508    if inner.is_null() {
509        return error(
510            BridgeErrorCode::InvalidRequest,
511            format!("'{command}' requires a request payload"),
512            None,
513        );
514    }
515    let request: Req = match serde_json::from_value(inner.clone()) {
516        Ok(request) => request,
517        Err(err) => {
518            return error(
519                BridgeErrorCode::InvalidRequest,
520                format!("{command} request rejected: {err}"),
521                None,
522            )
523        }
524    };
525    match service(&request) {
526        Ok(response) => match serde_json::to_value(&response) {
527            Ok(result) => success(command, result),
528            Err(err) => error(
529                BridgeErrorCode::InternalError,
530                format!("failed to serialize {command} result: {err}"),
531                None,
532            ),
533        },
534        Err(err) => error(
535            BridgeErrorCode::CommandFailed,
536            format!("{command} failed: {err}"),
537            None,
538        ),
539    }
540}
541
542/// [`run_service`] for a service whose error carries a machine-readable reason.
543///
544/// `details` lands in `error.details` so a wizard can branch on "supersonic" or
545/// "out_of_range" instead of pattern-matching prose. `code` stays `command_failed`, so
546/// existing callers are unaffected.
547///
548/// Callers: `true.dsf`, `true.plan`, and `true.dial_plan`, each of whose service error
549/// carries a machine-readable reason worth surfacing in `error.details`.
550fn run_service_detailed<Req, Resp, E, F, D>(
551    inner: &Value,
552    command: &'static str,
553    service: F,
554    details: D,
555) -> String
556where
557    Req: serde::de::DeserializeOwned,
558    Resp: serde::Serialize,
559    E: std::fmt::Display,
560    F: FnOnce(&Req) -> Result<Resp, E>,
561    D: FnOnce(&E) -> Option<Value>,
562{
563    if inner.is_null() {
564        return error(
565            BridgeErrorCode::InvalidRequest,
566            format!("'{command}' requires a request payload"),
567            None,
568        );
569    }
570    let request: Req = match serde_json::from_value(inner.clone()) {
571        Ok(request) => request,
572        Err(err) => {
573            return error(
574                BridgeErrorCode::InvalidRequest,
575                format!("{command} request rejected: {err}"),
576                None,
577            )
578        }
579    };
580    match service(&request) {
581        Ok(response) => match serde_json::to_value(&response) {
582            Ok(result) => success(command, result),
583            Err(err) => error(
584                BridgeErrorCode::InternalError,
585                format!("failed to serialize {command} result: {err}"),
586                None,
587            ),
588        },
589        Err(err) => {
590            let d = details(&err);
591            error(
592                BridgeErrorCode::CommandFailed,
593                format!("{command} failed: {err}"),
594                d,
595            )
596        }
597    }
598}
599
600/// Hard cap on the PDF `card.pdf` will hand back, measured on the RAW document (the
601/// base64 text in the response is ~4/3 of it, so this bounds a ~5.6 MiB response body).
602///
603/// Every dope card carries a ~815 KiB floor: the two Liberation Sans faces
604/// `pdf_dope_card` embeds. Rows are cheap on top of that (~0.5 KiB each — a 300-row,
605/// 4-page card is ~950 KiB).
606///
607/// This is the BACKSTOP, not the first line: the row set is refused on its own row and page
608/// count before any document exists (`card_service::MAX_PDF_ROWS` / `MAX_PDF_PAGES`), because
609/// measuring bytes means having already built and paginated them. What survives that check
610/// and still lands here is a card made huge by its LABELS — the `pdf` block's strings are
611/// drawn verbatim on every page — and for those a typed refusal the caller can act on beats
612/// pushing a multi-megabyte base64 string through an embedded FFI hop.
613#[cfg(feature = "pdf")]
614pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
615
616/// The over-cap envelope for a generated PDF, or `None` when it fits. Split out so the
617/// boundary itself is unit-testable at exactly [`MAX_PDF_BYTES`] and one byte past it.
618///
619/// The message states what is true of the document — its size, and how many rows and pages
620/// it holds. It deliberately does NOT advise "coarsen the step" or "shorten the range
621/// domain": for a saved card those are immutable (there is no editor for a snapshot's
622/// domain), so naming them told the one user who ever sees this message to do something
623/// impossible.
624#[cfg(feature = "pdf")]
625fn pdf_over_cap_error(byte_length: usize, row_count: usize, page_count: usize) -> Option<String> {
626    (byte_length > MAX_PDF_BYTES).then(|| {
627        error(
628            BridgeErrorCode::ResourceLimit,
629            format!(
630                "generated dope card is {byte_length} bytes; the limit is {MAX_PDF_BYTES} \
631                 ({row_count} rows, {page_count} pages)"
632            ),
633            None,
634        )
635    })
636}
637
638/// The one `card.pdf`-only key on the request: the rows to print, instead of solving. Not a
639/// field on [`crate::card_service::CardRequestV1`], because it is not part of a saved card —
640/// it is the card's stored RESPONSE, attached at export time — and a stored request must stay
641/// replayable against `card.range_table` unchanged. Removed from the payload before the card
642/// request is decoded, so `deny_unknown_fields` still governs everything else (a
643/// `stored_cards` typo is an honest `invalid_request`).
644#[cfg(feature = "pdf")]
645const STORED_CARD_KEY: &str = "stored_card";
646
647/// `card.pdf`: the printable dope card, as base64. The request is the SAME
648/// [`crate::card_service::CardRequestV1`] the on-screen card commands take — an app stores
649/// one request per saved card and replays it here — with the optional presentation-only
650/// `pdf` block for the header/footer labels, font size, and the Lead column's target speed,
651/// plus one `card.pdf`-only key:
652///
653/// * `stored_card` (optional): `{ "card": <a stored card.range_table result, verbatim>,
654///   "engine_version": "0.34.1", "bc5d_table_version": "2.5.0" }`. Supply it and this
655///   command PRINTS THOSE ROWS: no zero solve, no trajectory, and `bc5d_table_path` is never
656///   opened, so a saved card reprints identically after an engine bump, after the correction
657///   table at that path is overwritten in place, and even after it is deleted. The footer's
658///   `BC:` is the stored card's own `bc_for_solve`, and its `Engine:`/`Table:` are the two
659///   provenance strings, so paper and screen can be reconciled afterwards.
660/// * Omit it (or send `null`, which means the same thing) and the rows are solved here, from
661///   the same `card_service::range_table_rows` call `card.range_table` makes — the
662///   pre-existing behaviour, unchanged.
663///
664/// This surface prints a range-table card and says so. A request carrying a wind card's
665/// `wind_speeds`/`wind_angles_deg`, or a `stored_card` of another kind, is REFUSED: an `ok`
666/// response whose defining field was silently ignored is worse than no response.
667///
668/// Result: `{ "pdf_base64": ..., "byte_length": <raw PDF bytes>, "page_count": ...,
669/// "row_count": ..., "kind": "range_table", "source": "solve" | "stored_rows",
670/// "unprintable_title_chars": "" }`.
671/// `byte_length` describes the DECODED document, not the base64 text; `source` lets a caller
672/// verify it got a reprint rather than a re-solve. `unprintable_title_chars` is normally
673/// empty and names the characters of `pdf.title` the card font could not draw when it is not
674/// — the card still printed, with a visible stand-in for each of them, but a caller that
675/// accepts any card name should warn rather than hand over an untitled card. A card too big
676/// to print is refused with
677/// `resource_limit` — on its row/page count first (`card_service::MAX_PDF_ROWS` /
678/// `MAX_PDF_PAGES`), and on [`MAX_PDF_BYTES`] as the backstop.
679///
680/// Other errors follow the sibling card commands exactly: a malformed payload is
681/// `invalid_request`, anything the service rejects (including an out-of-band
682/// `pdf.font_scale`) is `command_failed` with the service's own message.
683#[cfg(feature = "pdf")]
684fn run_card_pdf(inner: &Value) -> String {
685    use crate::card_service::CardServiceError;
686
687    if inner.is_null() {
688        return error(
689            BridgeErrorCode::InvalidRequest,
690            "'card.pdf' requires a request payload (card v1 document)",
691            None,
692        );
693    }
694    let mut payload = inner.clone();
695    let stored_value = payload
696        .as_object_mut()
697        .and_then(|object| object.remove(STORED_CARD_KEY))
698        .filter(|value| !value.is_null());
699    let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
700        Ok(request) => request,
701        Err(err) => {
702            return error(
703                BridgeErrorCode::InvalidRequest,
704                format!("card.pdf request rejected: {err}"),
705                None,
706            )
707        }
708    };
709    let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
710        Some(value) => match serde_json::from_value(value) {
711            Ok(stored) => Some(stored),
712            Err(err) => {
713                return error(
714                    BridgeErrorCode::InvalidRequest,
715                    format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
716                    None,
717                )
718            }
719        },
720        None => None,
721    };
722
723    let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
724        Ok(card) => card,
725        // A card too large to print is a resource refusal, not a command failure: same code
726        // the byte cap below reports, so a caller has one condition to handle.
727        Err(err @ CardServiceError::TooLarge(_)) => {
728            return error(
729                BridgeErrorCode::ResourceLimit,
730                format!("card.pdf refused: {err}"),
731                None,
732            )
733        }
734        Err(err) => {
735            return error(
736                BridgeErrorCode::CommandFailed,
737                format!("card.pdf failed: {err}"),
738                None,
739            )
740        }
741    };
742    let byte_length = card.pdf_bytes.len();
743    if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
744        return envelope;
745    }
746    let mut response = json!({
747        "pdf_base64": encode_base64(&card.pdf_bytes),
748        "byte_length": byte_length,
749        "page_count": card.page_count,
750        "row_count": card.row_count,
751        "kind": crate::card_service::PDF_CARD_KIND,
752        "source": card.source.as_str(),
753        "unprintable_title_chars": card.unprintable_title_chars,
754    });
755    // MBA-1477: additive, and spelled exactly as `card.range_table`'s own block, so an app
756    // that already reads a truncation off the on-screen card reads the printed one the same
757    // way. Absent on a card that reached every row it asked for, which is every card the
758    // previous shape could describe at all — a truncated document used to come back here
759    // indistinguishable from a complete one, with only a smaller `row_count` to notice.
760    if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
761        object.insert(
762            "truncation".to_string(),
763            json!({
764                "requested_end": truncation.requested_end,
765                "last_row": truncation.last_row,
766                "reach": truncation.reach,
767            }),
768        );
769    }
770    success("card.pdf", response)
771}
772
773/// RFC 4648 standard-alphabet base64 encoder with padding, for `card.pdf` and
774/// `profile.export_a7p` — hence the two-feature gate, which must list every feature that
775/// has a binary to hand out or the function goes missing from exactly the build that
776/// needs it.
777///
778/// Hand-rolled for the same reason as `decode_base64` below (plain text, not a doc link:
779/// that function is gated on `profile-import`, which a pdf-only build need not enable): no
780/// direct base64 dependency
781/// exists in `Cargo.toml`, and adding one for twenty lines of arithmetic would ride along on
782/// all thirteen release targets.
783#[cfg(any(feature = "pdf", feature = "profile-export"))]
784fn encode_base64(bytes: &[u8]) -> String {
785    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
786    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
787    for chunk in bytes.chunks(3) {
788        let triple = (u32::from(chunk[0]) << 16)
789            | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
790            | u32::from(chunk.get(2).copied().unwrap_or(0));
791        out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
792        out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
793        // A 1- or 2-byte tail pads rather than encoding the zero bits it never carried.
794        out.push(if chunk.len() > 1 {
795            char::from(ALPHABET[(triple >> 6) as usize & 63])
796        } else {
797            '='
798        });
799        out.push(if chunk.len() > 2 {
800            char::from(ALPHABET[triple as usize & 63])
801        } else {
802            '='
803        });
804    }
805    out
806}
807
808/// Wrap a command's own typed error envelope losslessly in `details`.
809fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
810    let details = serde_json::to_value(typed).ok();
811    error(BridgeErrorCode::CommandFailed, message, details)
812}
813
814/// Shared decode for the two profile document commands: the inner request IS a
815/// [`crate::profile::ProfileData`] JSON document (the exact schema of
816/// `~/.ballistics/profiles/*.json` — same field names, same defaults, unknown keys
817/// tolerated, exactly as the CLI loads it).
818fn decode_profile_document(
819    inner: &Value,
820    command: &'static str,
821) -> Result<crate::profile::ProfileData, String> {
822    if inner.is_null() {
823        return Err(error(
824            BridgeErrorCode::InvalidRequest,
825            format!("'{command}' requires a request payload (a ProfileData JSON document)"),
826            None,
827        ));
828    }
829    serde_json::from_value(inner.clone()).map_err(|err| {
830        error(
831            BridgeErrorCode::InvalidRequest,
832            format!("{command} request is not a ProfileData document: {err}"),
833            None,
834        )
835    })
836}
837
838/// `profile.validate`: parse a ProfileData document and run the cheap invariants the CLI
839/// applies when loading/saving a profile (units string, MBA-1358 tracking-CF band,
840/// MBA-1348 turret/optic assembly + validation including click-value parse) — see
841/// [`crate::profile::ProfileData::validation_warnings`]. No new physics checks. Result:
842/// `{ "valid": bool, "warnings": [..], "normalized": <the profile re-serialized by this
843/// engine> }` — `valid` is simply `warnings.is_empty()`.
844fn run_profile_validate(inner: &Value) -> String {
845    let profile = match decode_profile_document(inner, "profile.validate") {
846        Ok(profile) => profile,
847        Err(envelope) => return envelope,
848    };
849    let warnings = profile.validation_warnings();
850    match serde_json::to_value(&profile) {
851        Ok(normalized) => success(
852            "profile.validate",
853            json!({
854                "valid": warnings.is_empty(),
855                "warnings": warnings,
856                "normalized": normalized,
857            }),
858        ),
859        Err(err) => error(
860            BridgeErrorCode::InternalError,
861            format!("failed to serialize normalized profile: {err}"),
862            None,
863        ),
864    }
865}
866
867/// `profile.normalize`: parse a ProfileData document and hand it back re-serialized by
868/// THIS engine — the supported way for an app to round-trip a stored blob through a newer
869/// engine version (unknown keys are tolerated on input and dropped on output; defaults
870/// fill in; `skip_serializing_if` keys stay absent — the same round-trip a CLI
871/// load-then-save performs). Result: `{ "profile": <re-serialized ProfileData> }`.
872fn run_profile_normalize(inner: &Value) -> String {
873    let profile = match decode_profile_document(inner, "profile.normalize") {
874        Ok(profile) => profile,
875        Err(envelope) => return envelope,
876    };
877    match serde_json::to_value(&profile) {
878        Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
879        Err(err) => error(
880            BridgeErrorCode::InternalError,
881            format!("failed to serialize normalized profile: {err}"),
882            None,
883        ),
884    }
885}
886
887/// `bc5d.info` request payload: the filesystem path of a downloaded BC5D table.
888#[cfg(not(target_arch = "wasm32"))]
889#[derive(Debug, Deserialize)]
890#[serde(deny_unknown_fields)]
891struct Bc5dInfoRequest {
892    path: String,
893}
894
895/// `bc5d.info`: open, parse, and CRC-verify a BC5D correction table at a caller-supplied
896/// path — the exact same load-with-verification (`bc_table_5d::path_cache::load_verified`)
897/// the `solve`/card `bc5d_table_path` fields use, so "info says valid" and "the solve will
898/// accept it" cannot drift apart. Lets an app validate a table right after downloading it.
899///
900/// Result on success: `{ "valid": true, "crc_ok": true, ... }` with the identifying
901/// metadata the header carries (format version, caliber, generator API version,
902/// generation timestamp, per-axis bin counts, total cells, weight/velocity coverage).
903/// A missing, unreadable, corrupt, or non-BC5D file is a `command_failed` envelope with
904/// a human-readable message (`invalid_request` when the payload itself is malformed).
905///
906/// `caliber` is the raw header value (an `f32`, so a .308 table reports 0.30799998) and
907/// `caliber_key` is that same value as the 3-digit BC5D key — EXACTLY the integer the
908/// solve/card caliber guard compares (`Bc5dTable::ensure_caliber_matches`). An app can
909/// therefore pre-check a downloaded table itself with
910/// `round(bullet_diameter_inches * 1000) == caliber_key` and show its own friendly
911/// message instead of provoking the `command_failed`. This command deliberately does NOT
912/// take a caliber: it describes a file, and only the surfaces that have a shot in hand
913/// enforce the match.
914#[cfg(not(target_arch = "wasm32"))]
915fn run_bc5d_info(inner: &Value) -> String {
916    if inner.is_null() {
917        return error(
918            BridgeErrorCode::InvalidRequest,
919            "'bc5d.info' requires a request payload ({\"path\": ...})",
920            None,
921        );
922    }
923    let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
924        Ok(request) => request,
925        Err(err) => {
926            return error(
927                BridgeErrorCode::InvalidRequest,
928                format!("bc5d.info request rejected: {err}"),
929                None,
930            )
931        }
932    };
933
934    let table =
935        match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(&request.path)) {
936            Ok(table) => table,
937            Err(err) => {
938                return error(
939                    BridgeErrorCode::CommandFailed,
940                    format!("bc5d.info: not a usable BC5D table: {err}"),
941                    None,
942                )
943            }
944        };
945
946    let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
947    let (weight_lo, weight_hi) = table.weight_range();
948    let (vel_lo, vel_hi) = table.velocity_range();
949    success(
950        "bc5d.info",
951        json!({
952            // Reaching here means the magic, format version, dimensions, AND the stored
953            // CRC32 all checked out — crc_ok is not a separate weaker probe.
954            "valid": true,
955            "crc_ok": true,
956            "format_version": table.version(),
957            "caliber": table.caliber(),
958            // The integer the caliber guard actually compares (see the doc comment).
959            "caliber_key": table.caliber_key(),
960            "api_version": table.api_version(),
961            "generated_timestamp": table.timestamp(),
962            // Axis order matches the on-disk layout: [drag_type][weight][bc][mv][cv].
963            "bins": {
964                "weight": weight,
965                "bc": bc,
966                "muzzle_velocity": muzzle_vel,
967                "current_velocity": current_vel,
968                "drag_types": drag_types,
969            },
970            "total_cells": table.total_cells(),
971            "weight_range_grains": [weight_lo, weight_hi],
972            "velocity_range_fps": [vel_lo, vel_hi],
973        }),
974    )
975}
976
977/// Hard cap on the DECODED `.a7p` payload accepted by `profile.import_a7p`. Real files
978/// are a few KiB; this exists purely as a resource bound (the request envelope's own
979/// [`MAX_REQUEST_BYTES`] already caps the base64 text).
980#[cfg(feature = "profile-import")]
981pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
982
983/// `profile.import_a7p` request payload. `zero_click` mirrors the CLI's `--zero-click`
984/// (the source device's click graduation, e.g. `"0.1mil"`), enabling the same optional
985/// zero_x/zero_y click-count conversion; omitted keeps the CLI's default behavior (the
986/// counts are reported as unmapped). `strict` mirrors `--strict`: reject the file on an
987/// MD5 envelope mismatch instead of importing with a warning.
988#[cfg(feature = "profile-import")]
989#[derive(Debug, Deserialize)]
990#[serde(deny_unknown_fields)]
991struct ProfileImportA7pRequest {
992    a7p_base64: String,
993    #[serde(default)]
994    zero_click: Option<String>,
995    #[serde(default)]
996    strict: bool,
997}
998
999/// `profile.import_a7p`: run the cleanroom `.a7p` parser + the SAME
1000/// [`crate::profile_import::map_a7p_to_profile`] mapping the CLI's `profile import` uses,
1001/// on a base64-supplied file. Result: `{ "profile": <ProfileData>, "warnings": [..],
1002/// "mapped": [[source, raw, converted, destination], ..], "unmapped": [[field, why], ..],
1003/// "unknown_fields": [{context, number}, ..] }` — the full import report, nothing
1004/// silently dropped (`unmapped` includes the unknown-field entries too, exactly as the
1005/// CLI prints them; `unknown_fields` additionally lists the parser-level unknowns in
1006/// structured form). The profile name is derived from the file (sanitized); renaming is
1007/// the caller's business — there is no name override here.
1008#[cfg(feature = "profile-import")]
1009fn run_profile_import_a7p(inner: &Value) -> String {
1010    use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
1011
1012    if inner.is_null() {
1013        return error(
1014            BridgeErrorCode::InvalidRequest,
1015            "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
1016            None,
1017        );
1018    }
1019    let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
1020        Ok(request) => request,
1021        Err(err) => {
1022            return error(
1023                BridgeErrorCode::InvalidRequest,
1024                format!("profile.import_a7p request rejected: {err}"),
1025                None,
1026            )
1027        }
1028    };
1029
1030    let zero_click = match request.zero_click.as_deref() {
1031        Some(raw) => match crate::adjustment::parse_click_value(raw) {
1032            Ok(click) => Some(click),
1033            Err(err) => {
1034                return error(
1035                    BridgeErrorCode::InvalidRequest,
1036                    format!("profile.import_a7p zero_click: {err}"),
1037                    None,
1038                )
1039            }
1040        },
1041        None => None,
1042    };
1043
1044    let bytes = match decode_base64(&request.a7p_base64) {
1045        Ok(bytes) => bytes,
1046        Err(err) => {
1047            return error(
1048                BridgeErrorCode::InvalidRequest,
1049                format!("profile.import_a7p a7p_base64: {err}"),
1050                None,
1051            )
1052        }
1053    };
1054    if bytes.len() > MAX_A7P_DECODED_BYTES {
1055        return error(
1056            BridgeErrorCode::ResourceLimit,
1057            format!(
1058                "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
1059                bytes.len()
1060            ),
1061            None,
1062        );
1063    }
1064
1065    let doc = match parse_a7p(&bytes) {
1066        Ok(doc) => doc,
1067        Err(err) => {
1068            return error(
1069                BridgeErrorCode::CommandFailed,
1070                format!("not a usable .a7p file: {err}"),
1071                None,
1072            )
1073        }
1074    };
1075    // Same refusal (and message) as the CLI's --strict; without it the mismatch becomes
1076    // a warning in the report, also exactly as the CLI behaves.
1077    if request.strict {
1078        if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
1079            return error(
1080                BridgeErrorCode::CommandFailed,
1081                format!(
1082                    "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
1083                ),
1084                None,
1085            );
1086        }
1087    }
1088
1089    let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
1090        Ok(outcome) => outcome,
1091        Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
1092    };
1093    let unknown_fields: Vec<Value> = doc
1094        .unknown_fields
1095        .iter()
1096        .map(|u| json!({ "context": u.context, "number": u.number }))
1097        .collect();
1098    match serde_json::to_value(&outcome.profile) {
1099        Ok(profile) => success(
1100            "profile.import_a7p",
1101            json!({
1102                "profile": profile,
1103                "warnings": outcome.report.warnings,
1104                "mapped": outcome.report.mapped,
1105                "unmapped": outcome.report.unmapped,
1106                "unknown_fields": unknown_fields,
1107            }),
1108        ),
1109        Err(err) => error(
1110            BridgeErrorCode::InternalError,
1111            format!("failed to serialize imported profile: {err}"),
1112            None,
1113        ),
1114    }
1115}
1116
1117/// `profile.export_a7p`: encode a ProfileData document as an ArcherBC2 `.a7p` file,
1118/// with the full account of what could not travel. Request payload is the ProfileData
1119/// document itself, exactly like `profile.validate`/`profile.normalize`.
1120///
1121/// Result: `{ "a7p_base64": "..", "byte_length": N, "carried_fields": [..],
1122/// "not_carried": [{field, populated, value, reason}, ..], "dropped_fields": [..],
1123/// "warnings": [..] }`.
1124///
1125/// `not_carried` is the reason this command has a shape of its own rather than
1126/// returning bare bytes, and it is NOT optional output. `.a7p` is a third-party format
1127/// built around one rifle, one load and one device; a saved profile holds a good deal it
1128/// has no slot for, and an app that shows a shooter "exported" without showing them what
1129/// their friend will not receive has told them something untrue. `not_carried` lists
1130/// every unsupported field unconditionally, with `populated` saying whether THIS profile
1131/// actually lost anything there, and `dropped_fields` is the pre-filtered subset that
1132/// did — the list to put in front of a person. `carried_fields` is the other half of the
1133/// same partition, so a caller can account for the whole document.
1134///
1135/// Errors carry a structured `reason` in `error.details` under the `true.*` convention
1136/// (`error.code` stays `command_failed`): `units`, `drag_model`, or `field` plus the
1137/// offending field name.
1138#[cfg(feature = "profile-export")]
1139fn run_profile_export_a7p(inner: &Value) -> String {
1140    use crate::profile_export::{export_a7p, A7pExportError, CARRIED_FIELDS};
1141
1142    let profile = match decode_profile_document(inner, "profile.export_a7p") {
1143        Ok(profile) => profile,
1144        Err(envelope) => return envelope,
1145    };
1146    let export = match export_a7p(&profile) {
1147        Ok(export) => export,
1148        Err(err) => {
1149            let details = match &err {
1150                A7pExportError::Units(_) => json!({ "reason": "units" }),
1151                A7pExportError::DragModel(model) => {
1152                    json!({ "reason": "drag_model", "drag_model": model })
1153                }
1154                A7pExportError::Field { field, .. } => {
1155                    json!({ "reason": "field", "field": field })
1156                }
1157            };
1158            return error(
1159                BridgeErrorCode::CommandFailed,
1160                err.to_string(),
1161                Some(details),
1162            );
1163        }
1164    };
1165    let not_carried = match serde_json::to_value(&export.not_carried) {
1166        Ok(value) => value,
1167        Err(err) => {
1168            return error(
1169                BridgeErrorCode::InternalError,
1170                format!("failed to serialize the not-carried list: {err}"),
1171                None,
1172            )
1173        }
1174    };
1175    success(
1176        "profile.export_a7p",
1177        json!({
1178            "a7p_base64": encode_base64(&export.bytes),
1179            "byte_length": export.bytes.len(),
1180            "carried_fields": CARRIED_FIELDS,
1181            "not_carried": not_carried,
1182            "dropped_fields": export.dropped_fields(),
1183            "warnings": export.warnings,
1184        }),
1185    )
1186}
1187
1188/// Minimal strict RFC 4648 standard-alphabet base64 decoder for `profile.import_a7p`.
1189///
1190/// Hand-rolled rather than a new dependency, deliberately: the crate already carries its
1191/// own cleanroom MD5 (`profile_import::md5`) and statistical constants for the same
1192/// thirteen-platform reasons, `Cargo.toml` has no direct base64 dependency today, and the
1193/// input here is a few KiB. Strict: rejects any character outside `A-Za-z0-9+/`, `=`
1194/// anywhere but as final padding, and lengths of form 4n+1.
1195#[cfg(feature = "profile-import")]
1196fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
1197    fn sextet(c: u8) -> Result<u32, String> {
1198        match c {
1199            b'A'..=b'Z' => Ok(u32::from(c - b'A')),
1200            b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
1201            b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
1202            b'+' => Ok(62),
1203            b'/' => Ok(63),
1204            _ => Err(format!("invalid base64 character {:?}", char::from(c))),
1205        }
1206    }
1207    let bytes = input.as_bytes();
1208    let data = match bytes {
1209        [rest @ .., b'=', b'='] => rest,
1210        [rest @ .., b'='] => rest,
1211        _ => bytes,
1212    };
1213    if data.contains(&b'=') {
1214        return Err("'=' is only valid as trailing padding".to_string());
1215    }
1216    if data.len() % 4 == 1 {
1217        return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1218    }
1219    let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1220    let mut acc: u32 = 0;
1221    let mut bits: u32 = 0;
1222    for &c in data {
1223        acc = (acc << 6) | sextet(c)?;
1224        bits += 6;
1225        if bits >= 8 {
1226            bits -= 8;
1227            out.push((acc >> bits) as u8);
1228        }
1229    }
1230    Ok(out)
1231}
1232
1233// ---------------------------------------------------------------------------
1234// reticle.* — MBA-1558
1235// ---------------------------------------------------------------------------
1236
1237/// How a request names the reticle it is asking about: either the full
1238/// [`crate::reticle::ReticleDescription`] inline, or a generator to build one from.
1239///
1240/// Exactly one, and the requests below check that rather than silently preferring one —
1241/// a caller that sends both has a bug, and picking a winner for them hides it.
1242#[derive(Debug, Deserialize)]
1243#[serde(tag = "kind", rename_all = "snake_case")]
1244enum ReticleGenerator {
1245    /// [`crate::reticle::ReticleDescription::mil_grid`].
1246    MilGrid { spacing_mil: f64, extent_mil: f64 },
1247    /// [`crate::reticle::ReticleDescription::tree`].
1248    Tree {
1249        rows: usize,
1250        row_spacing_mil: f64,
1251        spread_step_mil: f64,
1252    },
1253    /// [`crate::reticle::ReticleDescription::bdc_from_drops`]. Pairs are
1254    /// `[range_metres, drop_mil]`, and the generator labels each mark `"<range> m"` —
1255    /// the range unit is the wire's, not the shooter's, and an app showing those labels
1256    /// converts them itself.
1257    Bdc { drops: Vec<(f64, f64)> },
1258}
1259
1260/// Optional focal-plane overrides for a GENERATED reticle.
1261///
1262/// Every generator returns FFP with `reference_magnification` 1.0, and the generator docs
1263/// say callers wanting SFP "set those two fields afterwards". On the bridge there is no
1264/// afterwards — the caller never holds the struct — so the overrides have to travel with
1265/// the request or the generator path could only ever produce FFP reticles, which is half
1266/// the optics on the market.
1267#[derive(Debug, Default, Deserialize)]
1268#[serde(deny_unknown_fields)]
1269struct ReticlePlaneOverride {
1270    #[serde(default)]
1271    focal_plane: Option<crate::reticle::FocalPlane>,
1272    #[serde(default)]
1273    reference_magnification: Option<f64>,
1274}
1275
1276/// Build the description a request is about, from whichever source it supplied.
1277///
1278/// `Err` is a finished error envelope, matching how the other commands' helpers report.
1279fn resolve_reticle(
1280    command: &'static str,
1281    reticle: Option<crate::reticle::ReticleDescription>,
1282    generator: Option<ReticleGenerator>,
1283    catalog: Option<String>,
1284    plane: &ReticlePlaneOverride,
1285) -> Result<crate::reticle::ReticleDescription, String> {
1286    use crate::reticle::ReticleDescription;
1287
1288    // Exactly one source. Counted rather than matched pair-by-pair, so adding a fourth
1289    // source later cannot quietly reintroduce a precedence rule.
1290    let named = [reticle.is_some(), generator.is_some(), catalog.is_some()]
1291        .iter()
1292        .filter(|named| **named)
1293        .count();
1294    if named > 1 {
1295        return Err(error(
1296            BridgeErrorCode::InvalidRequest,
1297            format!(
1298                "{command} takes exactly one of 'reticle', 'generator' or 'catalog' — \
1299                 supplying more than one is a caller bug rather than a preference"
1300            ),
1301            None,
1302        ));
1303    }
1304
1305    // Resolved before the match below so a catalog id reaches it as a full description.
1306    let reticle = match (reticle, catalog) {
1307        (reticle, None) => reticle,
1308        (_, Some(id)) => match crate::reticle_catalog::by_id(&id) {
1309            Some(Ok(built)) => Some(built),
1310            Some(Err(err)) => return Err(reticle_error_envelope(command, &err)),
1311            // Not a default: a caller asking for a specific reticle and silently getting a
1312            // different one would be shown holds for glass they are not looking through.
1313            None => {
1314                return Err(error(
1315                    BridgeErrorCode::InvalidRequest,
1316                    format!(
1317                        "{command}: no catalog reticle with id {id:?}; \
1318                         'reticle.catalog' lists what this build has"
1319                    ),
1320                    None,
1321                ))
1322            }
1323        },
1324    };
1325
1326    let mut described = match (reticle, generator) {
1327        (Some(_), Some(_)) => unreachable!("the count above rejected more than one source"),
1328        (None, None) => {
1329            return Err(error(
1330                BridgeErrorCode::InvalidRequest,
1331                format!(
1332                    "{command} requires one of 'reticle' (a full description), \
1333                     'generator' or 'catalog'"
1334                ),
1335                None,
1336            ))
1337        }
1338        (Some(reticle), None) => {
1339            if plane.focal_plane.is_some() || plane.reference_magnification.is_some() {
1340                return Err(error(
1341                    BridgeErrorCode::InvalidRequest,
1342                    format!(
1343                        "{command}: 'focal_plane' and 'reference_magnification' override a \
1344                         GENERATED reticle; a 'reticle' or 'catalog' one already carries its own"
1345                    ),
1346                    None,
1347                ));
1348            }
1349            reticle
1350        }
1351        (None, Some(generator)) => {
1352            let built = match generator {
1353                ReticleGenerator::MilGrid {
1354                    spacing_mil,
1355                    extent_mil,
1356                } => ReticleDescription::mil_grid(spacing_mil, extent_mil),
1357                ReticleGenerator::Tree {
1358                    rows,
1359                    row_spacing_mil,
1360                    spread_step_mil,
1361                } => ReticleDescription::tree(rows, row_spacing_mil, spread_step_mil),
1362                ReticleGenerator::Bdc { drops } => ReticleDescription::bdc_from_drops(&drops),
1363            };
1364            match built {
1365                Ok(built) => built,
1366                Err(err) => return Err(reticle_error_envelope(command, &err)),
1367            }
1368        }
1369    };
1370
1371    if let Some(focal_plane) = plane.focal_plane {
1372        described.focal_plane = focal_plane;
1373    }
1374    if let Some(reference_magnification) = plane.reference_magnification {
1375        described.reference_magnification = reference_magnification;
1376    }
1377    Ok(described)
1378}
1379
1380/// A [`crate::reticle::ReticleError`] as this family's structured error envelope.
1381///
1382/// Follows the `true.*` convention established by `true.dsf`: `error.code` stays
1383/// `command_failed`, and a stable machine-readable `reason` rides in `error.details`
1384/// alongside the human message, so a front end can render its own wording without
1385/// parsing prose. The reasons are the error's own variants, which is what makes them
1386/// stable — a new variant is a new reason, never a re-spelling of an old one.
1387fn reticle_error_envelope(command: &'static str, err: &crate::reticle::ReticleError) -> String {
1388    use crate::reticle::ReticleError;
1389
1390    let mut details = json!({ "reason": match err {
1391        ReticleError::NonPositiveMagnification { .. } => "non_positive_magnification",
1392        ReticleError::NonPositiveReferenceMagnification { .. } => {
1393            "non_positive_reference_magnification"
1394        }
1395        ReticleError::NoMarks => "no_marks",
1396        ReticleError::TooManyMarks { .. } => "too_many_marks",
1397        ReticleError::NonFiniteMark { .. } => "non_finite_mark",
1398        ReticleError::NonFiniteHold { .. } => "non_finite_hold",
1399        ReticleError::InvalidGeneratorParameter { .. } => "invalid_generator_parameter",
1400        ReticleError::InvalidSpec(_) => "invalid_spec",
1401    }});
1402
1403    // The numbers the variant carries, so a caller can point at the offending input
1404    // without re-parsing the sentence.
1405    match err {
1406        ReticleError::NonPositiveMagnification { magnification } => {
1407            details["magnification"] = json!(magnification);
1408        }
1409        ReticleError::NonPositiveReferenceMagnification {
1410            reference_magnification,
1411        } => {
1412            details["reference_magnification"] = json!(reference_magnification);
1413        }
1414        ReticleError::TooManyMarks { count, max } => {
1415            details["count"] = json!(count);
1416            details["max"] = json!(max);
1417        }
1418        ReticleError::NonFiniteMark { index } => {
1419            details["index"] = json!(index);
1420        }
1421        ReticleError::InvalidGeneratorParameter {
1422            parameter,
1423            value,
1424            rule,
1425        } => {
1426            details["parameter"] = json!(parameter);
1427            details["value"] = json!(value);
1428            details["rule"] = json!(rule);
1429        }
1430        ReticleError::NoMarks
1431        | ReticleError::NonFiniteHold { .. }
1432        | ReticleError::InvalidSpec(_) => {}
1433    }
1434    details["command"] = json!(command);
1435
1436    error(
1437        BridgeErrorCode::CommandFailed,
1438        err.to_string(),
1439        Some(details),
1440    )
1441}
1442
1443/// The nearest mark a hold landed by, reported so a caller does not index back into the
1444/// description and re-apply the scale itself.
1445///
1446/// BOTH positions are given because they answer different questions and are not the same
1447/// number on an SFP optic: `nominal` is the mark as authored (what a reticle diagram
1448/// prints), `true_angular` is where it actually sits at this magnification (what the hold
1449/// is measured against).
1450fn nearest_mark_value(
1451    reticle: &crate::reticle::ReticleDescription,
1452    hold: &crate::reticle::ReticleHold,
1453) -> Value {
1454    match hold
1455        .nearest_mark
1456        .and_then(|index| reticle.marks.get(index).map(|mark| (index, mark)))
1457    {
1458        Some((index, mark)) => json!({
1459            "index": index,
1460            "kind": mark.kind.as_str(),
1461            "label": mark.label,
1462            "nominal": { "down_mil": mark.down_mil, "right_mil": mark.right_mil },
1463            "true_angular": {
1464                "down_mil": mark.down_mil * hold.mark_scale,
1465                "right_mil": mark.right_mil * hold.mark_scale,
1466            },
1467        }),
1468        None => Value::Null,
1469    }
1470}
1471
1472/// `reticle.catalog`: the named reticles this build can produce.
1473///
1474/// Result: `{ "reticles": [{ "id", "display_name", "source", "notes", "mark_count" }, ..] }`.
1475/// An app populates a picker from this rather than hardcoding ids, so a reticle added to a
1476/// later engine appears without an app release — and one REMOVED does not leave a picker
1477/// offering something the engine will refuse.
1478///
1479/// `source` is carried onto the wire deliberately. A wrong subtension is a hold that is
1480/// wrong by a mark and looks entirely normal, so the provenance that makes it traceable
1481/// belongs where the person looking at the reticle can see it, not only in the crate.
1482fn run_reticle_catalog() -> String {
1483    let reticles: Vec<Value> = crate::reticle_catalog::catalog()
1484        .into_iter()
1485        .map(|entry| {
1486            // Listed entries are built by `by_id` under test, so this cannot be the place a
1487            // caller learns an entry is broken; report what is known rather than failing the
1488            // whole listing over one bad row.
1489            let mark_count = crate::reticle_catalog::by_id(entry.id)
1490                .and_then(|built| built.ok())
1491                .map(|reticle| reticle.marks.len());
1492            json!({
1493                "id": entry.id,
1494                "display_name": entry.display_name,
1495                "source": entry.source,
1496                "notes": entry.notes,
1497                "mark_count": mark_count,
1498            })
1499        })
1500        .collect();
1501    success("reticle.catalog", json!({ "reticles": reticles }))
1502}
1503
1504#[derive(Debug, Deserialize)]
1505#[serde(deny_unknown_fields)]
1506struct ReticleDescribeRequest {
1507    #[serde(default)]
1508    reticle: Option<crate::reticle::ReticleDescription>,
1509    #[serde(default)]
1510    generator: Option<ReticleGenerator>,
1511    /// A `reticle.catalog` id. The third and simplest way to name a reticle,
1512    /// and the one an app picker uses.
1513    #[serde(default)]
1514    catalog: Option<String>,
1515    #[serde(default)]
1516    focal_plane: Option<crate::reticle::FocalPlane>,
1517    #[serde(default)]
1518    reference_magnification: Option<f64>,
1519    /// When present, the result also carries every mark's TRUE angular position at this
1520    /// magnification. Absent means "just the description".
1521    #[serde(default)]
1522    magnification: Option<f64>,
1523}
1524
1525/// `reticle.describe`: resolve a reticle — supplied or generated — and report it.
1526///
1527/// Result: `{ "reticle": <ReticleDescription>, "focal_plane": "FFP"|"SFP",
1528/// "magnification_dependent": bool, "mark_count": n, "scaled": {...}|null }`.
1529///
1530/// This is the command a picker and a drawing are built on. `scaled` is present only when
1531/// the request supplied a magnification, and carries `mark_scale` plus the marks in true
1532/// angular space — for an FFP reticle that is the nominal marks and a scale of exactly
1533/// 1.0, which is worth returning anyway so a caller has one code path.
1534fn run_reticle_describe(inner: &Value) -> String {
1535    if inner.is_null() {
1536        return error(
1537            BridgeErrorCode::InvalidRequest,
1538            "'reticle.describe' requires a request payload ({\"reticle\": ...} or {\"generator\": ...})",
1539            None,
1540        );
1541    }
1542    let request: ReticleDescribeRequest = match serde_json::from_value(inner.clone()) {
1543        Ok(request) => request,
1544        Err(err) => {
1545            return error(
1546                BridgeErrorCode::InvalidRequest,
1547                format!("reticle.describe request rejected: {err}"),
1548                None,
1549            )
1550        }
1551    };
1552
1553    let plane = ReticlePlaneOverride {
1554        focal_plane: request.focal_plane,
1555        reference_magnification: request.reference_magnification,
1556    };
1557    let reticle = match resolve_reticle(
1558        "reticle.describe",
1559        request.reticle,
1560        request.generator,
1561        request.catalog,
1562        &plane,
1563    ) {
1564        Ok(reticle) => reticle,
1565        Err(envelope) => return envelope,
1566    };
1567
1568    // Validate even when no magnification was asked for: a description that cannot be
1569    // held on is not a description worth handing back as if it were usable.
1570    if let Err(err) = reticle.validate() {
1571        return reticle_error_envelope("reticle.describe", &err);
1572    }
1573
1574    let scaled = match request.magnification {
1575        Some(magnification) => match reticle.scaled_marks(magnification) {
1576            Ok(marks) => json!({
1577                "magnification": magnification,
1578                "mark_scale": reticle.mark_scale(magnification),
1579                "marks": marks
1580                    .iter()
1581                    .map(|mark| json!({
1582                        "down_mil": mark.down_mil,
1583                        "right_mil": mark.right_mil,
1584                    }))
1585                    .collect::<Vec<_>>(),
1586            }),
1587            Err(err) => return reticle_error_envelope("reticle.describe", &err),
1588        },
1589        None => Value::Null,
1590    };
1591
1592    match serde_json::to_value(&reticle) {
1593        Ok(described) => success(
1594            "reticle.describe",
1595            json!({
1596                "reticle": described,
1597                "focal_plane": reticle.focal_plane.label(),
1598                "magnification_dependent": reticle.focal_plane.is_magnification_dependent(),
1599                "mark_count": reticle.marks.len(),
1600                "scaled": scaled,
1601            }),
1602        ),
1603        Err(err) => error(
1604            BridgeErrorCode::InternalError,
1605            format!("failed to serialize reticle description: {err}"),
1606            None,
1607        ),
1608    }
1609}
1610
1611#[derive(Debug, Deserialize)]
1612#[serde(deny_unknown_fields)]
1613struct ReticleHoldRequest {
1614    #[serde(default)]
1615    reticle: Option<crate::reticle::ReticleDescription>,
1616    #[serde(default)]
1617    generator: Option<ReticleGenerator>,
1618    /// A `reticle.catalog` id. The third and simplest way to name a reticle,
1619    /// and the one an app picker uses.
1620    #[serde(default)]
1621    catalog: Option<String>,
1622    #[serde(default)]
1623    focal_plane: Option<crate::reticle::FocalPlane>,
1624    #[serde(default)]
1625    reference_magnification: Option<f64>,
1626    /// Angular drop below the line of sight, positive = below: the come-up the shooter
1627    /// would otherwise dial.
1628    drop_mil: f64,
1629    /// Angular wind deflection, positive = the bullet goes RIGHT.
1630    #[serde(default)]
1631    wind_mil: f64,
1632    /// The optic's CURRENT magnification. Required on every focal plane — an FFP hold does
1633    /// not depend on it, but zero magnification is not a physical optic and
1634    /// `hold_point_in_reticle` rejects it on both planes rather than masking a caller bug.
1635    magnification: f64,
1636}
1637
1638/// `reticle.hold`: which mark to hold on.
1639///
1640/// The command this family exists for. Given a firing solution already reduced to angles
1641/// (`drop_mil` / `wind_mil`) and the optic's current magnification, place it in the
1642/// reticle and report the nearest mark.
1643///
1644/// ⚠️ THE ANGLES ARE INPUTS, NOT A SOLVE. This command runs no physics — it is the mark
1645/// search around [`crate::reticle::hold_point_in_reticle`], which is why the module can
1646/// keep its no-physics property. A caller gets `drop_mil` and `wind_mil` from `solve` (or
1647/// a card row) and passes them in; nothing here re-derives them, and a stale angle in
1648/// produces a confident hold out.
1649///
1650/// Result: `{ "hold": <ReticleHold>, "nearest_mark": {...}|null }`. `hold.off_reticle`
1651/// is the field a UI must not ignore: true means the solution has run off the marked part
1652/// of the reticle and there is nothing honest to hold on.
1653fn run_reticle_hold(inner: &Value) -> String {
1654    if inner.is_null() {
1655        return error(
1656            BridgeErrorCode::InvalidRequest,
1657            "'reticle.hold' requires a request payload ({reticle|generator, drop_mil, magnification})",
1658            None,
1659        );
1660    }
1661    let request: ReticleHoldRequest = match serde_json::from_value(inner.clone()) {
1662        Ok(request) => request,
1663        Err(err) => {
1664            return error(
1665                BridgeErrorCode::InvalidRequest,
1666                format!("reticle.hold request rejected: {err}"),
1667                None,
1668            )
1669        }
1670    };
1671
1672    let plane = ReticlePlaneOverride {
1673        focal_plane: request.focal_plane,
1674        reference_magnification: request.reference_magnification,
1675    };
1676    let reticle = match resolve_reticle(
1677        "reticle.hold",
1678        request.reticle,
1679        request.generator,
1680        request.catalog,
1681        &plane,
1682    ) {
1683        Ok(reticle) => reticle,
1684        Err(envelope) => return envelope,
1685    };
1686
1687    let hold = match crate::reticle::hold_point_in_reticle(
1688        request.drop_mil,
1689        request.wind_mil,
1690        request.magnification,
1691        &reticle,
1692    ) {
1693        Ok(hold) => hold,
1694        Err(err) => return reticle_error_envelope("reticle.hold", &err),
1695    };
1696
1697    let nearest = nearest_mark_value(&reticle, &hold);
1698    match serde_json::to_value(&hold) {
1699        Ok(hold_value) => success(
1700            "reticle.hold",
1701            json!({
1702                "hold": hold_value,
1703                "nearest_mark": nearest,
1704            }),
1705        ),
1706        Err(err) => error(
1707            BridgeErrorCode::InternalError,
1708            format!("failed to serialize reticle hold: {err}"),
1709            None,
1710        ),
1711    }
1712}
1713
1714/// Most holds `reticle.holds` will place in one call.
1715///
1716/// A sampled trajectory is the thing this exists for and runs to a hundred-odd rows; the
1717/// cap is far above that and exists so a malformed request cannot ask for unbounded work.
1718pub const MAX_RETICLE_HOLDS: usize = 4096;
1719
1720#[derive(Debug, Deserialize)]
1721#[serde(deny_unknown_fields)]
1722struct ReticleHoldsEntry {
1723    drop_mil: f64,
1724    #[serde(default)]
1725    wind_mil: f64,
1726}
1727
1728#[derive(Debug, Deserialize)]
1729#[serde(deny_unknown_fields)]
1730struct ReticleHoldsRequest {
1731    #[serde(default)]
1732    reticle: Option<crate::reticle::ReticleDescription>,
1733    #[serde(default)]
1734    generator: Option<ReticleGenerator>,
1735    #[serde(default)]
1736    catalog: Option<String>,
1737    #[serde(default)]
1738    focal_plane: Option<crate::reticle::FocalPlane>,
1739    #[serde(default)]
1740    reference_magnification: Option<f64>,
1741    magnification: f64,
1742    /// The firing solutions to place, in the caller's own order, which the result
1743    /// preserves.
1744    holds: Vec<ReticleHoldsEntry>,
1745}
1746
1747/// `reticle.holds`: [`run_reticle_hold`] for a whole table, in one call.
1748///
1749/// A holdover COLUMN needs a hold per row, and a sampled trajectory is a hundred-odd rows.
1750/// Done one at a time that is a hundred round trips through the FFI with a JSON encode and
1751/// decode each; done in the app it is the nearest-mark search re-implemented on two
1752/// platforms, which is the second spelling `AdjustmentConversion` exists to argue against.
1753/// So it is one call: the reticle is resolved and validated ONCE, and the search runs per
1754/// row inside the engine.
1755///
1756/// Result: `{ "mark_scale": s, "holds": [ <the same shape reticle.hold returns>, .. ] }`,
1757/// in request order. `mark_scale` is hoisted because it is a property of the reticle and
1758/// the magnification, identical for every row, and repeating it per row would invite a
1759/// caller to wonder when it might differ.
1760///
1761/// The whole request fails on the first unusable hold rather than returning a list with a
1762/// hole in it: a column that silently skips a row is worse than one that does not draw.
1763fn run_reticle_holds(inner: &Value) -> String {
1764    if inner.is_null() {
1765        return error(
1766            BridgeErrorCode::InvalidRequest,
1767            "'reticle.holds' requires a request payload ({reticle|generator|catalog, magnification, holds})",
1768            None,
1769        );
1770    }
1771    let request: ReticleHoldsRequest = match serde_json::from_value(inner.clone()) {
1772        Ok(request) => request,
1773        Err(err) => {
1774            return error(
1775                BridgeErrorCode::InvalidRequest,
1776                format!("reticle.holds request rejected: {err}"),
1777                None,
1778            )
1779        }
1780    };
1781
1782    if request.holds.len() > MAX_RETICLE_HOLDS {
1783        return error(
1784            BridgeErrorCode::ResourceLimit,
1785            format!(
1786                "reticle.holds was given {} holds; the limit is {MAX_RETICLE_HOLDS}",
1787                request.holds.len()
1788            ),
1789            None,
1790        );
1791    }
1792
1793    let plane = ReticlePlaneOverride {
1794        focal_plane: request.focal_plane,
1795        reference_magnification: request.reference_magnification,
1796    };
1797    let reticle = match resolve_reticle(
1798        "reticle.holds",
1799        request.reticle,
1800        request.generator,
1801        request.catalog,
1802        &plane,
1803    ) {
1804        Ok(reticle) => reticle,
1805        Err(envelope) => return envelope,
1806    };
1807
1808    let mut placed = Vec::with_capacity(request.holds.len());
1809    let mut scale = 1.0;
1810    for entry in &request.holds {
1811        let hold = match crate::reticle::hold_point_in_reticle(
1812            entry.drop_mil,
1813            entry.wind_mil,
1814            request.magnification,
1815            &reticle,
1816        ) {
1817            Ok(hold) => hold,
1818            // Defensive, and unreachable from the wire: a bad reticle and a non-positive
1819            // magnification are both settled before this loop, and the only error left is
1820            // a non-finite hold, which JSON cannot carry (serde_json refuses an
1821            // out-of-range literal, and infinity encodes as null). Kept because this is
1822            // also reachable from the Rust API, and because a silently skipped row would
1823            // be worse than a refused request.
1824            Err(err) => return reticle_error_envelope("reticle.holds", &err),
1825        };
1826        scale = hold.mark_scale;
1827        let nearest = nearest_mark_value(&reticle, &hold);
1828        match serde_json::to_value(&hold) {
1829            Ok(mut value) => {
1830                // Hoisted to the top level; see the doc comment.
1831                if let Some(object) = value.as_object_mut() {
1832                    object.remove("mark_scale");
1833                }
1834                placed.push(json!({ "hold": value, "nearest_mark": nearest }));
1835            }
1836            Err(err) => {
1837                return error(
1838                    BridgeErrorCode::InternalError,
1839                    format!("failed to serialize reticle hold: {err}"),
1840                    None,
1841                )
1842            }
1843        }
1844    }
1845
1846    success(
1847        "reticle.holds",
1848        json!({ "mark_scale": scale, "holds": placed }),
1849    )
1850}
1851
1852/// Largest reticle document `reticle.import` will parse.
1853///
1854/// The envelope cap ([`MAX_REQUEST_BYTES`]) already bounds the whole request, but a
1855/// document-specific cap gives a caller a message naming the document rather than the
1856/// envelope, and bounds the parse before it starts. Both formats are TEXT and travel
1857/// inline — unlike `.a7p`, which is binary and needs base64 — so there is no inflation
1858/// factor to leave headroom for.
1859pub const MAX_RETICLE_DOCUMENT_BYTES: usize = 512 * 1024;
1860
1861#[derive(Debug, Deserialize)]
1862#[serde(rename_all = "snake_case")]
1863enum ReticleDocumentFormat {
1864    /// The Ventum JSON reticle spec (MBA-1440).
1865    Ventum,
1866    /// The third-party `.reticle` XML drawing format (MBA-1544).
1867    ReticleXml,
1868}
1869
1870#[derive(Debug, Deserialize)]
1871#[serde(deny_unknown_fields)]
1872struct ReticleImportRequest {
1873    format: ReticleDocumentFormat,
1874    /// The document itself, as text.
1875    document: String,
1876}
1877
1878/// `reticle.import`: turn a third-party reticle document into a
1879/// [`crate::reticle::ReticleDescription`].
1880///
1881/// Result: `{ "reticle": <ReticleDescription>, "format": "ventum"|"reticle_xml",
1882/// "report": {...} }`. The report is format-specific and is NOT decoration: both importers
1883/// drop elements they cannot turn into hold points, and a caller that ignores the report
1884/// shows a shooter a reticle missing marks their drawing had. The same obligation
1885/// `profile.import_a7p` discharges with its `unmapped` list.
1886fn run_reticle_import(inner: &Value) -> String {
1887    if inner.is_null() {
1888        return error(
1889            BridgeErrorCode::InvalidRequest,
1890            "'reticle.import' requires a request payload ({\"format\": ..., \"document\": ...})",
1891            None,
1892        );
1893    }
1894    let request: ReticleImportRequest = match serde_json::from_value(inner.clone()) {
1895        Ok(request) => request,
1896        Err(err) => {
1897            return error(
1898                BridgeErrorCode::InvalidRequest,
1899                format!("reticle.import request rejected: {err}"),
1900                None,
1901            )
1902        }
1903    };
1904
1905    if request.document.len() > MAX_RETICLE_DOCUMENT_BYTES {
1906        return error(
1907            BridgeErrorCode::ResourceLimit,
1908            format!(
1909                "reticle document is {} bytes; the limit is {MAX_RETICLE_DOCUMENT_BYTES}",
1910                request.document.len()
1911            ),
1912            None,
1913        );
1914    }
1915
1916    let (reticle, report, format) = match request.format {
1917        ReticleDocumentFormat::Ventum => {
1918            match crate::reticle_import::import_ventum_reticle_with_report(&request.document) {
1919                Ok((reticle, report)) => (
1920                    reticle,
1921                    json!({
1922                        "dropped_elements": report.dropped_elements,
1923                        "dropped_element_types": report
1924                            .dropped_element_types
1925                            .iter()
1926                            .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1927                            .collect::<Vec<_>>(),
1928                        // Arcs are REPORTED, never imported as marks: a horseshoe is a
1929                        // shape a shooter indexes on, and which of its points counts as an
1930                        // aiming point is the caller's decision, not this bridge's. Apex
1931                        // and the two tips are the points the importer resolves.
1932                        "arcs": report
1933                            .arcs
1934                            .iter()
1935                            .map(|arc| json!({
1936                                "center": { "down_mil": arc.center.down_mil, "right_mil": arc.center.right_mil },
1937                                "radius_mil": arc.radius_mil,
1938                                "start_degrees": arc.start_degrees,
1939                                "end_degrees": arc.end_degrees,
1940                                "sweep_degrees": arc.sweep_degrees,
1941                                "start_tip": { "down_mil": arc.start_tip.down_mil, "right_mil": arc.start_tip.right_mil },
1942                                "apex": { "down_mil": arc.apex.down_mil, "right_mil": arc.apex.right_mil },
1943                                "end_tip": { "down_mil": arc.end_tip.down_mil, "right_mil": arc.end_tip.right_mil },
1944                            }))
1945                            .collect::<Vec<_>>(),
1946                        // Both shortfalls the report declares about itself, carried rather
1947                        // than dropped: `arcs.len() + arcs_unresolved` is the arc tally, and
1948                        // `circle_repeats_unreadable` says the element count is low by an
1949                        // unknown amount.
1950                        "arcs_unresolved": report.arcs_unresolved,
1951                        "circle_repeats_unreadable": report.circle_repeats_unreadable,
1952                    }),
1953                    "ventum",
1954                ),
1955                Err(err) => return reticle_error_envelope("reticle.import", &err),
1956            }
1957        }
1958        ReticleDocumentFormat::ReticleXml => {
1959            match crate::reticle_document_import::import_reticle_document_with_report(
1960                &request.document,
1961            ) {
1962                Ok((reticle, report)) => (
1963                    reticle,
1964                    json!({
1965                        "drawing_elements_dropped": report.drawing_elements_dropped,
1966                        "dropped_element_tags": report
1967                            .dropped_element_tags
1968                            .iter()
1969                            .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1970                            .collect::<Vec<_>>(),
1971                        "canvas_mil": report
1972                            .canvas_mil
1973                            .map(|(x, y)| json!({ "size_x_mil": x, "size_y_mil": y })),
1974                        "zero_in_canvas_mil": report
1975                            .zero_in_canvas_mil
1976                            .map(|(x, y)| json!({ "zero_x_mil": x, "zero_y_mil": y })),
1977                    }),
1978                    "reticle_xml",
1979                ),
1980                Err(err) => return reticle_error_envelope("reticle.import", &err),
1981            }
1982        }
1983    };
1984
1985    match serde_json::to_value(&reticle) {
1986        Ok(imported) => success(
1987            "reticle.import",
1988            json!({
1989                "reticle": imported,
1990                "format": format,
1991                "mark_count": reticle.marks.len(),
1992                "report": report,
1993            }),
1994        ),
1995        Err(err) => error(
1996            BridgeErrorCode::InternalError,
1997            format!("failed to serialize imported reticle: {err}"),
1998            None,
1999        ),
2000    }
2001}
2002
2003#[cfg(test)]
2004mod tests {
2005    use super::*;
2006
2007    fn call(value: Value) -> Value {
2008        let raw = bridge_call(&value.to_string());
2009        serde_json::from_str(&raw).expect("bridge output must be valid JSON")
2010    }
2011
2012    #[test]
2013    fn capabilities_reports_commands_and_versions() {
2014        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2015        assert_eq!(out["ok"], true);
2016        assert_eq!(out["api_version"], 1);
2017        assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
2018        let commands: Vec<String> =
2019            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2020        assert!(commands.contains(&"solve".to_string()));
2021        assert!(commands.contains(&"meta.capabilities".to_string()));
2022    }
2023
2024    #[test]
2025    fn density_altitude_is_listed_right_after_solve() {
2026        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2027        let commands: Vec<String> =
2028            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2029        let solve = commands.iter().position(|c| c == "solve").unwrap();
2030        assert_eq!(
2031            commands.get(solve + 1).map(String::as_str),
2032            Some("atmosphere.density_altitude"),
2033            "{commands:?}"
2034        );
2035    }
2036
2037    #[test]
2038    fn density_altitude_reports_both_definitions_at_icao_standard_humid_air() {
2039        let out = call(json!({
2040            "api_version": 1,
2041            "command": "atmosphere.density_altitude",
2042            "request": {"atmosphere": {
2043                "altitude_m": 0.0,
2044                "temperature_k": 288.15,
2045                "pressure_pa": 101325.0,
2046                "relative_humidity": 0.5,
2047            }},
2048        }));
2049        assert_eq!(out["ok"], true, "{out}");
2050        assert_eq!(out["command"], "atmosphere.density_altitude");
2051        let result = &out["result"];
2052        let faa = result["density_altitude"]["faa_rule"]["m"]
2053            .as_f64()
2054            .unwrap();
2055        let matched = result["density_altitude"]["density_matched"]["m"]
2056            .as_f64()
2057            .unwrap();
2058        assert!(faa.abs() < 1e-9, "{result}");
2059        assert!((matched - 33.10).abs() < 0.2, "{result}");
2060        let ft = result["density_altitude"]["density_matched"]["ft"]
2061            .as_f64()
2062            .unwrap();
2063        assert!((ft * 0.3048 - matched).abs() < 1e-9, "{result}");
2064        assert!(result["air_density_kg_m3"].as_f64().unwrap() > 1.2);
2065        // Everything was supplied, so nothing was assumed.
2066        assert_eq!(result["assumptions"], json!([]), "{result}");
2067        assert_eq!(result["atmosphere"]["pressure_pa"], 101325.0);
2068    }
2069
2070    #[test]
2071    fn density_altitude_empty_atmosphere_resolves_to_announced_icao_defaults() {
2072        let out = call(json!({
2073            "api_version": 1,
2074            "command": "atmosphere.density_altitude",
2075            "request": {"atmosphere": {}},
2076        }));
2077        assert_eq!(out["ok"], true, "{out}");
2078        let assumptions = out["result"]["assumptions"].as_array().unwrap();
2079        assert!(!assumptions.is_empty(), "{out}");
2080        for notice in assumptions {
2081            assert!(
2082                notice["path"]
2083                    .as_str()
2084                    .unwrap_or_default()
2085                    .starts_with("$.atmosphere."),
2086                "{notice}"
2087            );
2088        }
2089    }
2090
2091    #[test]
2092    fn density_altitude_without_payload_is_invalid_request() {
2093        let out = call(json!({"api_version": 1, "command": "atmosphere.density_altitude"}));
2094        assert_eq!(out["ok"], false);
2095        assert_eq!(out["error"]["code"], "invalid_request");
2096    }
2097
2098    #[test]
2099    fn density_altitude_rejects_a_unit_suffixed_field_at_its_path() {
2100        // `temperature_c` is the likeliest mistake from a caller used to the imperial/metric
2101        // card commands; the SI schema must refuse it rather than silently default to 15 C,
2102        // and say where, exactly as `solve` would.
2103        let out = call(json!({
2104            "api_version": 1,
2105            "command": "atmosphere.density_altitude",
2106            "request": {"atmosphere": {"temperature_c": 30.0}},
2107        }));
2108        assert_eq!(out["ok"], false, "{out}");
2109        assert_eq!(out["error"]["code"], "command_failed");
2110        assert_eq!(out["error"]["details"]["error"]["code"], "unknown_field");
2111        assert_eq!(
2112            out["error"]["details"]["error"]["path"],
2113            "$.atmosphere.temperature_c"
2114        );
2115    }
2116
2117    #[test]
2118    fn density_altitude_refuses_celsius_sent_as_kelvin() {
2119        let out = call(json!({
2120            "api_version": 1,
2121            "command": "atmosphere.density_altitude",
2122            "request": {"atmosphere": {"temperature_k": 15.0}},
2123        }));
2124        assert_eq!(out["ok"], false, "{out}");
2125        assert_eq!(out["error"]["code"], "command_failed");
2126        assert_eq!(
2127            out["error"]["details"]["error"]["path"],
2128            "$.atmosphere.temperature_k"
2129        );
2130    }
2131
2132    #[test]
2133    fn density_altitude_humidity_as_percent_is_located_in_the_solve_envelope() {
2134        let out = call(json!({
2135            "api_version": 1,
2136            "command": "atmosphere.density_altitude",
2137            "request": {"atmosphere": {"relative_humidity": 50.0}},
2138        }));
2139        assert_eq!(out["ok"], false, "{out}");
2140        assert_eq!(out["error"]["code"], "command_failed");
2141        assert!(
2142            out["error"]["details"]
2143                .to_string()
2144                .contains("$.atmosphere.relative_humidity"),
2145            "{out}"
2146        );
2147    }
2148
2149    #[test]
2150    fn invalid_json_is_an_envelope_not_a_panic() {
2151        let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
2152        assert_eq!(out["ok"], false);
2153        assert_eq!(out["error"]["code"], "invalid_json");
2154    }
2155
2156    #[test]
2157    fn unknown_envelope_field_is_rejected() {
2158        let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
2159        assert_eq!(out["ok"], false);
2160        assert_eq!(out["error"]["code"], "invalid_json");
2161    }
2162
2163    #[test]
2164    fn unknown_command_lists_supported_ones() {
2165        // Was `card.pdf` until that became a real (pdf-gated) command; use a name no build
2166        // can ever dispatch so this test means the same thing in every feature set.
2167        let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
2168        assert_eq!(out["error"]["code"], "unknown_command");
2169        assert!(out["error"]["message"]
2170            .as_str()
2171            .unwrap()
2172            .contains("meta.capabilities"));
2173    }
2174
2175    #[test]
2176    fn wrong_api_version_is_rejected() {
2177        let out = call(json!({"api_version": 99, "command": "meta.version"}));
2178        assert_eq!(out["error"]["code"], "unsupported_api_version");
2179    }
2180
2181    #[test]
2182    fn oversize_request_is_a_resource_limit() {
2183        let big = format!(
2184            r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
2185            "x".repeat(MAX_REQUEST_BYTES)
2186        );
2187        let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
2188        assert_eq!(out["error"]["code"], "resource_limit");
2189    }
2190
2191    #[test]
2192    fn solve_without_payload_is_invalid_request() {
2193        let out = call(json!({"api_version": 1, "command": "solve"}));
2194        assert_eq!(out["error"]["code"], "invalid_request");
2195    }
2196
2197    #[test]
2198    fn profile_commands_without_payload_are_invalid_requests() {
2199        for command in ["profile.validate", "profile.normalize"] {
2200            let out = call(json!({"api_version": 1, "command": command}));
2201            assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
2202            assert!(
2203                out["error"]["message"]
2204                    .as_str()
2205                    .unwrap()
2206                    .contains("ProfileData"),
2207                "{command}: {out}"
2208            );
2209        }
2210    }
2211
2212    #[test]
2213    fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
2214        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2215        let commands: Vec<String> =
2216            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2217        assert!(commands.contains(&"profile.validate".to_string()));
2218        assert!(commands.contains(&"profile.normalize".to_string()));
2219        assert_eq!(
2220            commands.contains(&"profile.import_a7p".to_string()),
2221            cfg!(feature = "profile-import"),
2222            "profile.import_a7p must be listed exactly when compiled in"
2223        );
2224        assert_eq!(
2225            commands.contains(&"profile.export_a7p".to_string()),
2226            cfg!(feature = "profile-export"),
2227            "profile.export_a7p must be listed exactly when compiled in"
2228        );
2229        assert_eq!(
2230            commands.contains(&"bc5d.info".to_string()),
2231            cfg!(not(target_arch = "wasm32")),
2232            "bc5d.info must be listed exactly when the build has filesystem access"
2233        );
2234    }
2235
2236    #[cfg(not(target_arch = "wasm32"))]
2237    #[test]
2238    fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
2239        let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
2240        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2241
2242        let out = call(json!({
2243            "api_version": 1,
2244            "command": "bc5d.info",
2245            "request": {"path": "/nonexistent/bc5d_308.bin"}
2246        }));
2247        assert_eq!(out["error"]["code"], "command_failed", "{out}");
2248        assert!(
2249            out["error"]["message"]
2250                .as_str()
2251                .unwrap()
2252                .contains("not a usable BC5D table"),
2253            "{out}"
2254        );
2255    }
2256
2257    #[cfg(feature = "profile-import")]
2258    #[test]
2259    fn base64_decoder_round_trips_and_rejects_garbage() {
2260        // RFC 4648 test vectors.
2261        for (text, bytes) in [
2262            ("", &b""[..]),
2263            ("Zg==", b"f"),
2264            ("Zm8=", b"fo"),
2265            ("Zm9v", b"foo"),
2266            ("Zm9vYg==", b"foob"),
2267            ("Zm9vYmE=", b"fooba"),
2268            ("Zm9vYmFy", b"foobar"),
2269        ] {
2270            assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
2271        }
2272        assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
2273        assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
2274        assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
2275        assert!(
2276            decode_base64("Zm9v!").is_err(),
2277            "non-alphabet byte is rejected"
2278        );
2279    }
2280
2281    /// `card.pdf` must be listed exactly when the `pdf` feature is compiled in, and be an
2282    /// honest `unknown_command` otherwise — the same rule `profile.import_a7p` follows. The
2283    /// pdf-absent half of this only runs under `--no-default-features --features bridge`.
2284    #[test]
2285    fn capabilities_gates_card_pdf_on_the_pdf_feature() {
2286        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2287        let commands: Vec<String> =
2288            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2289        assert_eq!(
2290            commands.contains(&"card.pdf".to_string()),
2291            cfg!(feature = "pdf"),
2292            "card.pdf must be listed exactly when compiled in: {out}"
2293        );
2294        let features: Vec<String> =
2295            serde_json::from_value(out["result"]["features"].clone()).unwrap();
2296        assert_eq!(
2297            features.contains(&"pdf".to_string()),
2298            cfg!(feature = "pdf"),
2299            "the command list and the feature list must agree: {out}"
2300        );
2301    }
2302
2303    #[cfg(not(feature = "pdf"))]
2304    #[test]
2305    fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
2306        let out = call(json!({
2307            "api_version": 1,
2308            "command": "card.pdf",
2309            "request": {
2310                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2311                "mass": 175.0, "diameter": 0.308,
2312                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
2313            }
2314        }));
2315        assert_eq!(out["error"]["code"], "unknown_command", "{out}");
2316    }
2317
2318    /// The `pdf` presentation block must survive a build that cannot render it: an app
2319    /// stores one request per card and replays it against `card.range_table` too, so a
2320    /// pdf-less engine has to ACCEPT the field rather than reject it as unknown.
2321    #[test]
2322    fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
2323        let out = call(json!({
2324            "api_version": 1,
2325            "command": "card.range_table",
2326            "request": {
2327                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2328                "mass": 175.0, "diameter": 0.308,
2329                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
2330                "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
2331            }
2332        }));
2333        assert_eq!(out["ok"], true, "{out}");
2334        assert_eq!(out["result"]["kind"], "range_table", "{out}");
2335    }
2336
2337    #[cfg(feature = "pdf")]
2338    #[test]
2339    fn card_pdf_without_payload_is_invalid_request() {
2340        let out = call(json!({"api_version": 1, "command": "card.pdf"}));
2341        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2342        assert!(
2343            out["error"]["message"]
2344                .as_str()
2345                .unwrap()
2346                .contains("card v1 document"),
2347            "{out}"
2348        );
2349    }
2350
2351    /// The output cap's boundary, both sides. Generating a genuinely over-cap dope card
2352    /// would take tens of thousands of rows, so the predicate is tested directly — see
2353    /// `pdf_over_cap_error`'s own comment.
2354    #[cfg(feature = "pdf")]
2355    #[test]
2356    fn pdf_output_cap_refuses_only_over_the_limit() {
2357        assert!(pdf_over_cap_error(0, 0, 0).is_none());
2358        assert!(
2359            pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
2360            "a document exactly at the cap fits"
2361        );
2362        let envelope: Value =
2363            serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
2364                .unwrap();
2365        assert_eq!(envelope["ok"], false);
2366        assert_eq!(envelope["error"]["code"], "resource_limit");
2367        let message = envelope["error"]["message"].as_str().unwrap();
2368        assert!(message.contains("dope card"), "{envelope}");
2369        // What is true of the document, not advice about controls a saved card lacks.
2370        assert!(message.contains("6 rows"), "{envelope}");
2371        assert!(message.contains("1 pages"), "{envelope}");
2372        for absent in ["coarsen", "shorten"] {
2373            assert!(!message.contains(absent), "{envelope}");
2374        }
2375    }
2376
2377    #[cfg(feature = "pdf")]
2378    #[test]
2379    fn base64_encoder_matches_the_rfc_4648_vectors() {
2380        for (bytes, text) in [
2381            (&b""[..], ""),
2382            (b"f", "Zg=="),
2383            (b"fo", "Zm8="),
2384            (b"foo", "Zm9v"),
2385            (b"foob", "Zm9vYg=="),
2386            (b"fooba", "Zm9vYmE="),
2387            (b"foobar", "Zm9vYmFy"),
2388        ] {
2389            assert_eq!(encode_base64(bytes), text, "{bytes:?}");
2390        }
2391        // Full-byte-range coverage: the >> 18 / >> 12 / >> 6 masking must not sign- or
2392        // width-mangle a high byte, which is most of a PDF's content.
2393        assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
2394        assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
2395        assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
2396    }
2397
2398    /// The encoder and the (profile-import) decoder must be inverses — the property that
2399    /// makes `pdf_base64` a lossless transport for a binary document.
2400    #[cfg(all(feature = "pdf", feature = "profile-import"))]
2401    #[test]
2402    fn base64_encode_decode_round_trips_arbitrary_bytes() {
2403        for len in 0..=32usize {
2404            let bytes: Vec<u8> = (0..len)
2405                .map(|i| (i as u8).wrapping_mul(37).wrapping_add(11))
2406                .collect();
2407            let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
2408            assert_eq!(decoded, bytes, "len {len}");
2409        }
2410    }
2411
2412    #[test]
2413    fn solve_with_bad_schema_carries_typed_details() {
2414        let out = call(json!({
2415            "api_version": 1,
2416            "command": "solve",
2417            "request": {"schema_version": 1, "unknown_field": true}
2418        }));
2419        assert_eq!(out["error"]["code"], "command_failed");
2420        // The solve-json envelope rides along losslessly.
2421        assert_eq!(out["error"]["details"]["status"], "error");
2422    }
2423
2424    // -----------------------------------------------------------------------
2425    // reticle.* — MBA-1558
2426    // -----------------------------------------------------------------------
2427
2428    /// A 0.5 mil grid out to 5 mil, as a full description rather than a generator, so a
2429    /// test can assert against marks it wrote itself.
2430    fn hash_reticle() -> Value {
2431        json!({
2432            "name": "test cross",
2433            "focal_plane": "ffp",
2434            "reference_magnification": 1.0,
2435            "marks": [
2436                {"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
2437                {"down_mil": 2.0, "right_mil": 0.0, "kind": "hash", "label": "2 mil"},
2438                {"down_mil": 4.0, "right_mil": 0.0, "kind": "hash"},
2439                {"down_mil": 0.0, "right_mil": 2.0, "kind": "hash"},
2440            ],
2441        })
2442    }
2443
2444    #[test]
2445    fn capabilities_lists_the_reticle_family() {
2446        // The whole point of MBA-1558: an app feature-detects through this list, so a
2447        // command that dispatches but is not listed is a command no app will call.
2448        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2449        let commands: Vec<String> =
2450            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2451        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2452            assert!(
2453                commands.contains(&command.to_string()),
2454                "{command} dispatches but meta.capabilities does not list it"
2455            );
2456        }
2457    }
2458
2459    #[test]
2460    fn hold_reports_the_nearest_mark_and_its_label() {
2461        let out = call(json!({
2462            "api_version": 1,
2463            "command": "reticle.hold",
2464            "request": {
2465                "reticle": hash_reticle(),
2466                "drop_mil": 2.1,
2467                "wind_mil": 0.0,
2468                "magnification": 10.0,
2469            },
2470        }));
2471        assert_eq!(out["ok"], true, "{out}");
2472        // The hold coordinates are the inputs: this command places a solution, it does
2473        // not compute one.
2474        assert_eq!(out["result"]["hold"]["down_mil"], 2.1);
2475        assert_eq!(out["result"]["hold"]["right_mil"], 0.0);
2476        assert_eq!(out["result"]["hold"]["off_reticle"], false);
2477        assert_eq!(out["result"]["nearest_mark"]["index"], 1);
2478        assert_eq!(out["result"]["nearest_mark"]["label"], "2 mil");
2479        assert_eq!(out["result"]["nearest_mark"]["kind"], "hash");
2480    }
2481
2482    #[test]
2483    fn an_sfp_reticle_moves_its_marks_and_the_hold_follows() {
2484        // The reason `magnification` is required on every plane. The same solution finds a
2485        // DIFFERENT mark on an SFP optic depending on the zoom ring, and an app that
2486        // ignored this would draw a confident hold on the wrong hash.
2487        let mut sfp = hash_reticle();
2488        sfp["focal_plane"] = json!("sfp");
2489        sfp["reference_magnification"] = json!(10.0);
2490
2491        let at_reference = call(json!({
2492            "api_version": 1, "command": "reticle.hold",
2493            "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 10.0},
2494        }));
2495        assert_eq!(at_reference["result"]["hold"]["mark_scale"], 1.0);
2496        assert_eq!(at_reference["result"]["nearest_mark"]["index"], 1);
2497        assert_eq!(
2498            at_reference["result"]["nearest_mark"]["true_angular"]["down_mil"],
2499            2.0
2500        );
2501
2502        // Halve the magnification and every subtension doubles, so the 2 mil hash now
2503        // sits at 4 mil true and the 4 mil hash at 8.
2504        let zoomed_out = call(json!({
2505            "api_version": 1, "command": "reticle.hold",
2506            "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 5.0},
2507        }));
2508        assert_eq!(zoomed_out["result"]["hold"]["mark_scale"], 2.0);
2509        assert_eq!(
2510            zoomed_out["result"]["nearest_mark"]["true_angular"]["down_mil"], 0.0,
2511            "at 5x the nearest mark to a 2 mil hold is now CENTER, not the 2 mil hash"
2512        );
2513        assert_eq!(zoomed_out["result"]["nearest_mark"]["index"], 0);
2514        // And the nominal position is unchanged, which is why both are reported.
2515        assert_eq!(
2516            zoomed_out["result"]["nearest_mark"]["nominal"]["down_mil"],
2517            0.0
2518        );
2519
2520        // A mark whose coordinates are NOT zero, because the assertions above cannot tell
2521        // a scaled position from an unscaled one: 0.0 * 2.0 is 0.0. Proved by mutation --
2522        // dropping the scale from `true_angular` left every case above green.
2523        //
2524        // At 5x on a 10x-reference SFP reticle the 2 mil hash sits at 4 mil true, so a
2525        // 4 mil hold lands exactly on it. The two positions differ by the scale, which is
2526        // the whole reason both are reported.
2527        let on_the_scaled_hash = call(json!({
2528            "api_version": 1, "command": "reticle.hold",
2529            "request": {"reticle": sfp, "drop_mil": 4.0, "magnification": 5.0},
2530        }));
2531        assert_eq!(
2532            on_the_scaled_hash["result"]["nearest_mark"]["nominal"]["down_mil"], 2.0,
2533            "as authored, the mark is at 2 mil"
2534        );
2535        assert_eq!(
2536            on_the_scaled_hash["result"]["nearest_mark"]["true_angular"]["down_mil"], 4.0,
2537            "at 5x it subtends 4 mil, which is what the hold is measured against"
2538        );
2539        assert_eq!(
2540            on_the_scaled_hash["result"]["hold"]["nearest_mark_distance_mil"], 0.0,
2541            "the hold lands exactly on it"
2542        );
2543    }
2544
2545    #[test]
2546    fn a_hold_off_the_marked_part_says_so() {
2547        // The field a UI must not ignore. 40 mil is far outside a reticle whose lowest
2548        // mark is at 4.
2549        let out = call(json!({
2550            "api_version": 1, "command": "reticle.hold",
2551            "request": {"reticle": hash_reticle(), "drop_mil": 40.0, "magnification": 10.0},
2552        }));
2553        assert_eq!(out["ok"], true);
2554        assert_eq!(out["result"]["hold"]["off_reticle"], true);
2555    }
2556
2557    #[test]
2558    fn describe_builds_from_a_generator_and_reports_the_plane() {
2559        let out = call(json!({
2560            "api_version": 1, "command": "reticle.describe",
2561            "request": {"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0}},
2562        }));
2563        assert_eq!(out["ok"], true, "{out}");
2564        assert_eq!(out["result"]["focal_plane"], "FFP");
2565        assert_eq!(out["result"]["magnification_dependent"], false);
2566        assert!(out["result"]["mark_count"].as_u64().unwrap() > 1);
2567        // No magnification asked for, so no scaled block — not an empty one.
2568        assert!(out["result"]["scaled"].is_null());
2569    }
2570
2571    #[test]
2572    fn a_generated_reticle_can_be_made_sfp() {
2573        // Every generator returns FFP and the library's answer is "set those two fields
2574        // afterwards". On the bridge there is no afterwards, so the overrides travel with
2575        // the request — without them the generator path could only ever make FFP.
2576        let out = call(json!({
2577            "api_version": 1, "command": "reticle.describe",
2578            "request": {
2579                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2580                "focal_plane": "sfp",
2581                "reference_magnification": 12.0,
2582                "magnification": 6.0,
2583            },
2584        }));
2585        assert_eq!(out["ok"], true, "{out}");
2586        assert_eq!(out["result"]["focal_plane"], "SFP");
2587        assert_eq!(out["result"]["magnification_dependent"], true);
2588        assert_eq!(out["result"]["scaled"]["mark_scale"], 2.0);
2589    }
2590
2591    #[test]
2592    fn a_bdc_ladder_carries_its_range_labels() {
2593        let out = call(json!({
2594            "api_version": 1, "command": "reticle.describe",
2595            "request": {"generator": {"kind": "bdc", "drops": [[300.0, 1.2], [400.0, 2.4]]}},
2596        }));
2597        assert_eq!(out["ok"], true, "{out}");
2598        let marks = out["result"]["reticle"]["marks"]
2599            .as_array()
2600            .unwrap()
2601            .clone();
2602        let labels: Vec<&str> = marks.iter().filter_map(|m| m["label"].as_str()).collect();
2603        // The generator labels in METRES, which is the wire's unit and not the shooter's.
2604        assert_eq!(labels, vec!["300 m", "400 m"]);
2605    }
2606
2607    #[test]
2608    fn naming_a_reticle_twice_is_refused_rather_than_resolved() {
2609        // Supplying both is a caller bug. Picking a winner would hide it.
2610        let out = call(json!({
2611            "api_version": 1, "command": "reticle.hold",
2612            "request": {
2613                "reticle": hash_reticle(),
2614                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2615                "drop_mil": 1.0,
2616                "magnification": 10.0,
2617            },
2618        }));
2619        assert_eq!(out["ok"], false);
2620        assert_eq!(out["error"]["code"], "invalid_request");
2621        assert!(out["error"]["message"]
2622            .as_str()
2623            .unwrap()
2624            .contains("exactly one"));
2625    }
2626
2627    #[test]
2628    fn a_catalog_id_is_the_third_way_to_name_a_reticle() {
2629        let out = call(json!({
2630            "api_version": 1, "command": "reticle.hold",
2631            "request": {"catalog": "mil-dot", "drop_mil": 3.0, "magnification": 10.0},
2632        }));
2633        assert_eq!(out["ok"], true, "{out}");
2634        // A mil-dot has a dot exactly 3 mil down, so the hold lands on it.
2635        assert_eq!(out["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2636        assert_eq!(out["result"]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2637        assert_eq!(out["result"]["nearest_mark"]["label"], "3 mil down");
2638    }
2639
2640    #[test]
2641    fn an_unknown_catalog_id_is_refused_rather_than_substituted() {
2642        // Handing back a different reticle would show a shooter holds for glass they are
2643        // not looking through.
2644        let out = call(json!({
2645            "api_version": 1, "command": "reticle.hold",
2646            "request": {"catalog": "tremor-9000", "drop_mil": 3.0, "magnification": 10.0},
2647        }));
2648        assert_eq!(out["ok"], false);
2649        assert_eq!(out["error"]["code"], "invalid_request");
2650        assert!(out["error"]["message"]
2651            .as_str()
2652            .unwrap()
2653            .contains("reticle.catalog"));
2654    }
2655
2656    #[test]
2657    fn naming_a_reticle_three_ways_is_refused_too() {
2658        // The count-based check exists so a third source could not reintroduce precedence.
2659        let out = call(json!({
2660            "api_version": 1, "command": "reticle.describe",
2661            "request": {
2662                "catalog": "mil-dot",
2663                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2664            },
2665        }));
2666        assert_eq!(out["ok"], false);
2667        assert_eq!(out["error"]["code"], "invalid_request");
2668    }
2669
2670    #[test]
2671    fn the_catalog_lists_what_the_other_commands_will_accept() {
2672        // The contract an app picker is built on: every listed id must resolve, or the
2673        // picker offers something the engine refuses.
2674        let out = call(json!({"api_version": 1, "command": "reticle.catalog"}));
2675        assert_eq!(out["ok"], true, "{out}");
2676        let reticles = out["result"]["reticles"].as_array().unwrap().clone();
2677        assert!(!reticles.is_empty());
2678        for entry in reticles {
2679            let id = entry["id"].as_str().unwrap();
2680            // Provenance travels to the wire on purpose — see reticle_catalog's header.
2681            assert!(
2682                !entry["source"].as_str().unwrap().is_empty(),
2683                "{id} has no source"
2684            );
2685            assert!(
2686                !entry["notes"].as_str().unwrap().is_empty(),
2687                "{id} has no notes"
2688            );
2689            assert!(
2690                entry["mark_count"].as_u64().unwrap() > 0,
2691                "{id} has no marks"
2692            );
2693
2694            let held = call(json!({
2695                "api_version": 1, "command": "reticle.hold",
2696                "request": {"catalog": id, "drop_mil": 1.0, "magnification": 10.0},
2697            }));
2698            assert_eq!(held["ok"], true, "listed id {id} does not resolve: {held}");
2699        }
2700    }
2701
2702    #[test]
2703    fn a_batch_of_holds_comes_back_in_request_order() {
2704        // The column this exists for: one call per table, not one per row.
2705        let out = call(json!({
2706            "api_version": 1, "command": "reticle.holds",
2707            "request": {
2708                "catalog": "mil-dot",
2709                "magnification": 10.0,
2710                "holds": [
2711                    {"drop_mil": 1.0},
2712                    {"drop_mil": 3.0, "wind_mil": 0.0},
2713                    {"drop_mil": 2.0},
2714                ],
2715            },
2716        }));
2717        assert_eq!(out["ok"], true, "{out}");
2718        let holds = out["holds"].clone();
2719        assert!(holds.is_null(), "holds live under result, not the envelope");
2720        let holds = out["result"]["holds"].as_array().unwrap().clone();
2721        assert_eq!(holds.len(), 3);
2722        // Order is the caller's, NOT sorted — a table row must line up with its hold.
2723        assert_eq!(holds[0]["nearest_mark"]["nominal"]["down_mil"], 1.0);
2724        assert_eq!(holds[1]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2725        assert_eq!(holds[2]["nearest_mark"]["nominal"]["down_mil"], 2.0);
2726        // mark_scale is hoisted: one reticle at one magnification has exactly one.
2727        assert_eq!(out["result"]["mark_scale"], 1.0);
2728        assert!(holds[0]["hold"]["mark_scale"].is_null());
2729    }
2730
2731    #[test]
2732    fn a_batch_agrees_with_the_single_hold_command() {
2733        // The batch must not be a second implementation. If these ever disagree, one of
2734        // them is doing its own mark search.
2735        let single = call(json!({
2736            "api_version": 1, "command": "reticle.hold",
2737            "request": {"catalog": "mil-dot", "drop_mil": 2.4, "wind_mil": 0.7, "magnification": 10.0},
2738        }));
2739        let batch = call(json!({
2740            "api_version": 1, "command": "reticle.holds",
2741            "request": {
2742                "catalog": "mil-dot", "magnification": 10.0,
2743                "holds": [{"drop_mil": 2.4, "wind_mil": 0.7}],
2744            },
2745        }));
2746        assert_eq!(single["ok"], true);
2747        assert_eq!(batch["ok"], true);
2748        assert_eq!(
2749            single["result"]["nearest_mark"],
2750            batch["result"]["holds"][0]["nearest_mark"]
2751        );
2752        assert_eq!(
2753            single["result"]["hold"]["off_reticle"],
2754            batch["result"]["holds"][0]["hold"]["off_reticle"]
2755        );
2756        assert_eq!(
2757            single["result"]["hold"]["nearest_mark_distance_mil"],
2758            batch["result"]["holds"][0]["hold"]["nearest_mark_distance_mil"]
2759        );
2760    }
2761
2762    #[test]
2763    fn a_malformed_hold_entry_refuses_the_whole_batch() {
2764        // A column that silently skips a row is worse than one that does not draw, so the
2765        // request fails rather than returning a list with a hole in it.
2766        //
2767        // Note WHICH failure this is. The per-row arm in run_reticle_holds is defensive
2768        // and unreachable from the wire: `hold_point_in_reticle` rejects a bad reticle and
2769        // a non-positive magnification, both of which are settled before the loop, and its
2770        // only remaining error is a non-finite hold — which JSON cannot deliver.
2771        // serde_json refuses an out-of-range literal outright ("number out of range" for
2772        // 1e999) and `json!(f64::INFINITY)` encodes as null, so a non-finite drop is
2773        // stopped by the transport. `NonFiniteHold` is reachable from the Rust API and not
2774        // from here; an earlier version of this test asserted that reason and failed,
2775        // which is how the unreachability was found.
2776        for bad in [
2777            json!(null),
2778            json!("3.0"),
2779            json!({"drop_mil": 1.0, "nonsense": 2}),
2780        ] {
2781            let out = call(json!({
2782                "api_version": 1, "command": "reticle.holds",
2783                "request": {
2784                    "catalog": "mil-dot", "magnification": 10.0,
2785                    "holds": [{"drop_mil": 1.0}, bad],
2786                },
2787            }));
2788            assert_eq!(out["ok"], false, "accepted a malformed entry: {out}");
2789            assert_eq!(out["error"]["code"], "invalid_request");
2790        }
2791    }
2792
2793    #[test]
2794    fn a_bad_magnification_fails_the_batch_before_any_row() {
2795        // The reachable whole-batch failure: settled once, up front, not per row.
2796        let out = call(json!({
2797            "api_version": 1, "command": "reticle.holds",
2798            "request": {
2799                "catalog": "mil-dot", "magnification": 0.0,
2800                "holds": [{"drop_mil": 1.0}, {"drop_mil": 2.0}],
2801            },
2802        }));
2803        assert_eq!(out["ok"], false);
2804        assert_eq!(
2805            out["error"]["details"]["reason"],
2806            "non_positive_magnification"
2807        );
2808    }
2809
2810    #[test]
2811    fn an_empty_batch_is_an_empty_list_not_an_error() {
2812        // A trajectory with no samples is a legitimate thing to ask about.
2813        let out = call(json!({
2814            "api_version": 1, "command": "reticle.holds",
2815            "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": []},
2816        }));
2817        assert_eq!(out["ok"], true, "{out}");
2818        assert_eq!(out["result"]["holds"].as_array().unwrap().len(), 0);
2819    }
2820
2821    #[test]
2822    fn too_many_holds_is_a_resource_limit() {
2823        let holds: Vec<Value> = (0..MAX_RETICLE_HOLDS + 1)
2824            .map(|i| json!({"drop_mil": (i % 5) as f64}))
2825            .collect();
2826        let out = call(json!({
2827            "api_version": 1, "command": "reticle.holds",
2828            "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": holds},
2829        }));
2830        assert_eq!(out["ok"], false);
2831        assert_eq!(out["error"]["code"], "resource_limit");
2832    }
2833
2834    #[test]
2835    fn capabilities_lists_the_catalog_command() {
2836        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2837        let commands: Vec<String> =
2838            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2839        assert!(commands.contains(&"reticle.catalog".to_string()));
2840        assert!(commands.contains(&"reticle.holds".to_string()));
2841    }
2842
2843    #[test]
2844    fn naming_no_reticle_at_all_is_refused() {
2845        let out = call(json!({
2846            "api_version": 1, "command": "reticle.hold",
2847            "request": {"drop_mil": 1.0, "magnification": 10.0},
2848        }));
2849        assert_eq!(out["ok"], false);
2850        assert_eq!(out["error"]["code"], "invalid_request");
2851    }
2852
2853    #[test]
2854    fn plane_overrides_beside_a_full_reticle_are_refused() {
2855        // A supplied description already carries its own plane; silently overriding it
2856        // would let a request contradict the document it sent.
2857        let out = call(json!({
2858            "api_version": 1, "command": "reticle.describe",
2859            "request": {"reticle": hash_reticle(), "focal_plane": "sfp"},
2860        }));
2861        assert_eq!(out["ok"], false);
2862        assert_eq!(out["error"]["code"], "invalid_request");
2863    }
2864
2865    #[test]
2866    fn a_reticle_error_carries_a_stable_reason_and_its_numbers() {
2867        // The `true.dsf` convention: code stays command_failed, the machine-readable
2868        // reason is in details, and the offending value rides along so a caller can point
2869        // at the input without re-parsing the sentence.
2870        let out = call(json!({
2871            "api_version": 1, "command": "reticle.hold",
2872            "request": {"reticle": hash_reticle(), "drop_mil": 1.0, "magnification": 0.0},
2873        }));
2874        assert_eq!(out["ok"], false);
2875        assert_eq!(out["error"]["code"], "command_failed");
2876        assert_eq!(
2877            out["error"]["details"]["reason"],
2878            "non_positive_magnification"
2879        );
2880        assert_eq!(out["error"]["details"]["magnification"], 0.0);
2881        assert_eq!(out["error"]["details"]["command"], "reticle.hold");
2882    }
2883
2884    #[test]
2885    fn a_reticle_with_no_marks_is_refused_by_describe_too() {
2886        // Validation is not deferred to the hold: handing back an unusable description as
2887        // though it were usable is the failure this guards.
2888        let mut empty = hash_reticle();
2889        empty["marks"] = json!([]);
2890        let out = call(json!({
2891            "api_version": 1, "command": "reticle.describe",
2892            "request": {"reticle": empty},
2893        }));
2894        assert_eq!(out["ok"], false);
2895        assert_eq!(out["error"]["details"]["reason"], "no_marks");
2896    }
2897
2898    #[test]
2899    fn an_unreadable_generator_parameter_names_the_parameter() {
2900        let out = call(json!({
2901            "api_version": 1, "command": "reticle.describe",
2902            "request": {"generator": {"kind": "mil_grid", "spacing_mil": 0.0, "extent_mil": 5.0}},
2903        }));
2904        assert_eq!(out["ok"], false);
2905        assert_eq!(
2906            out["error"]["details"]["reason"],
2907            "invalid_generator_parameter"
2908        );
2909        assert_eq!(out["error"]["details"]["parameter"], "spacing");
2910    }
2911
2912    #[test]
2913    fn importing_a_ventum_document_returns_the_reticle_and_its_report() {
2914        // Bero's own MBR dot-tree spec, the reticle that produced MBA-1440 — three rows
2915        // each stamped by a mirrored repeat, expanding to 78 holdable marks. Using the
2916        // real thing rather than a toy is deliberate: an earlier version of this test sent
2917        // a document with an invented key, and because it accepted either outcome it
2918        // passed while reaching nothing.
2919        let document = r#"{"name":"MBR","plane":"ffp","unit":"mil","spec":[
2920            {"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},
2921            {"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},
2922            {"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]}"#;
2923
2924        let out = call(json!({
2925            "api_version": 1, "command": "reticle.import",
2926            "request": {"format": "ventum", "document": document},
2927        }));
2928        assert_eq!(out["ok"], true, "{out}");
2929        assert_eq!(out["result"]["format"], "ventum");
2930        assert_eq!(out["result"]["mark_count"], 78);
2931        assert_eq!(out["result"]["reticle"]["name"], "MBR");
2932        assert_eq!(out["result"]["reticle"]["focal_plane"], "ffp");
2933        // Nothing was dropped, and the report says so rather than being absent.
2934        assert_eq!(out["result"]["report"]["dropped_elements"], 0);
2935        assert_eq!(out["result"]["report"]["arcs_unresolved"], 0);
2936
2937        // And the imported reticle is immediately usable by the command it exists for.
2938        let held = call(json!({
2939            "api_version": 1, "command": "reticle.hold",
2940            "request": {
2941                "reticle": out["result"]["reticle"],
2942                "drop_mil": 8.0,
2943                "wind_mil": 2.0,
2944                "magnification": 10.0,
2945            },
2946        }));
2947        assert_eq!(held["ok"], true, "{held}");
2948        assert_eq!(held["result"]["hold"]["off_reticle"], false);
2949        assert_eq!(held["result"]["nearest_mark"]["nominal"]["down_mil"], 8.0);
2950        assert_eq!(held["result"]["nearest_mark"]["nominal"]["right_mil"], 2.0);
2951        assert_eq!(held["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2952    }
2953
2954    #[test]
2955    fn a_document_that_is_not_the_named_format_fails_as_a_command_failure() {
2956        let out = call(json!({
2957            "api_version": 1, "command": "reticle.import",
2958            "request": {"format": "reticle_xml", "document": "{\"this\": \"is json\"}"},
2959        }));
2960        assert_eq!(out["ok"], false);
2961        assert_eq!(out["error"]["code"], "command_failed");
2962    }
2963
2964    #[test]
2965    fn an_oversize_reticle_document_is_a_resource_limit_not_a_parse_failure() {
2966        // Named as the document's limit rather than the envelope's, and refused before
2967        // the parse rather than during it.
2968        let out = call(json!({
2969            "api_version": 1, "command": "reticle.import",
2970            "request": {
2971                "format": "ventum",
2972                "document": "x".repeat(MAX_RETICLE_DOCUMENT_BYTES + 1),
2973            },
2974        }));
2975        assert_eq!(out["ok"], false);
2976        assert_eq!(out["error"]["code"], "resource_limit");
2977        assert!(out["error"]["message"]
2978            .as_str()
2979            .unwrap()
2980            .contains("reticle document"));
2981    }
2982
2983    #[test]
2984    fn every_reticle_command_rejects_an_unknown_request_field() {
2985        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2986            let out = call(json!({
2987                "api_version": 1,
2988                "command": command,
2989                "request": {"nonsense": 1},
2990            }));
2991            assert_eq!(out["ok"], false, "{command} accepted an unknown field");
2992            assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2993        }
2994    }
2995
2996    #[test]
2997    fn every_reticle_command_rejects_a_missing_payload() {
2998        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2999            let out = call(json!({"api_version": 1, "command": command}));
3000            assert_eq!(out["ok"], false, "{command} accepted a null payload");
3001            assert_eq!(out["error"]["code"], "invalid_request", "{command}");
3002        }
3003    }
3004
3005    /// The bridge's own end-to-end proof: a ProfileData in, a real `.a7p` out, and the
3006    /// file it hands back is one `profile.import_a7p` accepts. Round-trip fidelity of the
3007    /// individual fields is the encoder's own test; what is proved here is that the two
3008    /// bridge commands are actually inverse over the wire, base64 and all.
3009    #[cfg(all(feature = "profile-export", feature = "profile-import"))]
3010    #[test]
3011    fn export_a7p_produces_a_file_import_a7p_reads_back() {
3012        let profile = json!({
3013            "name": "bridge-export",
3014            "velocity": 792.0,
3015            "bc": 0.381,
3016            "mass": 19.439673,
3017            "diameter": 8.5852,
3018            "drag_model": "G7",
3019            "twist_rate": 254.0,
3020            "sight_height": 90.0,
3021            "zero_distance": 100.0,
3022            "units": "metric",
3023            "temperature": 15.0,
3024            "pressure": 1000.0,
3025            "humidity": 50.0,
3026            "altitude": 0.0,
3027            "bullet_name": "300GR OTM",
3028            "twist_right": false,
3029            "bullet_length": 45.72,
3030            "elevation_cf": 0.97,
3031            "dsf_points": [{"mach": 0.9, "dsf": 1.04}]
3032        });
3033
3034        let out = call(json!({
3035            "api_version": 1, "command": "profile.export_a7p", "request": profile
3036        }));
3037        assert_eq!(out["ok"], true, "{out}");
3038        let result = &out["result"];
3039        assert!(result["byte_length"].as_u64().unwrap() > 32);
3040
3041        // The honesty contract: the two fields this profile set that .a7p cannot take
3042        // are named, and nothing that DID travel is named alongside them.
3043        let dropped: Vec<String> =
3044            serde_json::from_value(result["dropped_fields"].clone()).unwrap();
3045        assert!(dropped.contains(&"elevation_cf".to_string()), "{out}");
3046        assert!(dropped.contains(&"dsf_points".to_string()), "{out}");
3047        assert!(!dropped.contains(&"velocity".to_string()), "{out}");
3048        // ...and the full list names the unsupported fields this profile left empty too,
3049        // so a caller can tell "had none" from "lost it".
3050        let not_carried = result["not_carried"].as_array().unwrap();
3051        let reticle = not_carried
3052            .iter()
3053            .find(|e| e["field"] == "reticle")
3054            .expect("reticle is listed even though this profile has none");
3055        assert_eq!(reticle["populated"], false, "{out}");
3056        assert!(reticle["reason"].as_str().unwrap().len() > 10, "{out}");
3057
3058        let back = call(json!({
3059            "api_version": 1,
3060            "command": "profile.import_a7p",
3061            "request": { "a7p_base64": result["a7p_base64"].clone(), "strict": true }
3062        }));
3063        // `strict` refuses on an envelope mismatch, so this passing is also the proof
3064        // that the MD5 prefix the encoder wrote is correct.
3065        assert_eq!(back["ok"], true, "{back}");
3066        let reimported = &back["result"]["profile"];
3067        assert_eq!(reimported["name"], "bridge-export", "{back}");
3068        assert_eq!(reimported["drag_model"], "G7", "{back}");
3069        assert_eq!(reimported["twist_right"], false, "{back}");
3070    }
3071
3072    /// A drag model the format cannot name is refused with the structured `reason` the
3073    /// `true.*` family established, not written out as something else.
3074    #[cfg(feature = "profile-export")]
3075    #[test]
3076    fn export_a7p_refuses_an_unrepresentable_drag_model_with_a_reason() {
3077        let out = call(json!({
3078            "api_version": 1,
3079            "command": "profile.export_a7p",
3080            // Complete in every other respect, so the refusal can only be about the
3081            // drag model: the format also REQUIRES a bullet length, a zero distance
3082            // and a sight height, and those refusals are checked in the encoder's
3083            // own tests.
3084            "request": {
3085                "name": "g5-load", "velocity": 792.0, "bc": 0.3, "mass": 19.4,
3086                "diameter": 8.5852, "drag_model": "G5", "units": "metric",
3087                "bullet_length": 45.72, "zero_distance": 100.0, "sight_height": 90.0,
3088                "temperature": 15.0, "pressure": 1000.0, "humidity": 50.0
3089            }
3090        }));
3091        assert_eq!(out["error"]["code"], "command_failed", "{out}");
3092        assert_eq!(out["error"]["details"]["reason"], "drag_model", "{out}");
3093        assert_eq!(out["error"]["details"]["drag_model"], "G5", "{out}");
3094    }
3095
3096    #[cfg(feature = "profile-export")]
3097    #[test]
3098    fn export_a7p_without_a_payload_is_an_invalid_request() {
3099        let out = call(json!({"api_version": 1, "command": "profile.export_a7p"}));
3100        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
3101        assert!(
3102            out["error"]["message"]
3103                .as_str()
3104                .unwrap()
3105                .contains("ProfileData"),
3106            "{out}"
3107        );
3108    }
3109}