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's ROWS reprint identically after an engine bump, after the
657///   correction table at that path is overwritten in place, and even after it is deleted. The
658///   header is not stored: it states the air the stored request resolves to on this engine
659///   (MBA-1593), so a card saved before 0.49.0 at altitude without a pressure or temperature
660///   reprints with the corrected header over its unchanged rows. The footer's
661///   `BC:` is the stored card's ladder range when its `bc_ladder` is present (MBA-1601),
662///   otherwise its own `bc_for_solve`, and its `Engine:`/`Table:` are the two provenance
663///   strings, so paper and screen can be reconciled afterwards.
664/// * Omit it (or send `null`, which means the same thing) and the rows are solved here, from
665///   the same `card_service::range_table_rows` call `card.range_table` makes — the
666///   pre-existing behaviour, unchanged.
667///
668/// This surface prints a range-table card and says so. A request carrying a wind card's
669/// `wind_speeds`/`wind_angles_deg`, or a `stored_card` of another kind, is REFUSED: an `ok`
670/// response whose defining field was silently ignored is worse than no response.
671///
672/// Result: `{ "pdf_base64": ..., "byte_length": <raw PDF bytes>, "page_count": ...,
673/// "row_count": ..., "kind": "range_table", "source": "solve" | "stored_rows",
674/// "unprintable_title_chars": "" }`.
675/// `byte_length` describes the DECODED document, not the base64 text; `source` lets a caller
676/// verify it got a reprint rather than a re-solve. `unprintable_title_chars` is normally
677/// empty and names the characters of `pdf.title` the card font could not draw when it is not
678/// — the card still printed, with a visible stand-in for each of them, but a caller that
679/// accepts any card name should warn rather than hand over an untitled card. A card too big
680/// to print is refused with
681/// `resource_limit` — on its row/page count first (`card_service::MAX_PDF_ROWS` /
682/// `MAX_PDF_PAGES`), and on [`MAX_PDF_BYTES`] as the backstop.
683///
684/// Other errors follow the sibling card commands exactly: a malformed payload is
685/// `invalid_request`, anything the service rejects (including an out-of-band
686/// `pdf.font_scale`) is `command_failed` with the service's own message.
687#[cfg(feature = "pdf")]
688fn run_card_pdf(inner: &Value) -> String {
689    use crate::card_service::CardServiceError;
690
691    if inner.is_null() {
692        return error(
693            BridgeErrorCode::InvalidRequest,
694            "'card.pdf' requires a request payload (card v1 document)",
695            None,
696        );
697    }
698    let mut payload = inner.clone();
699    let stored_value = payload
700        .as_object_mut()
701        .and_then(|object| object.remove(STORED_CARD_KEY))
702        .filter(|value| !value.is_null());
703    let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
704        Ok(request) => request,
705        Err(err) => {
706            return error(
707                BridgeErrorCode::InvalidRequest,
708                format!("card.pdf request rejected: {err}"),
709                None,
710            )
711        }
712    };
713    let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
714        Some(value) => match serde_json::from_value(value) {
715            Ok(stored) => Some(stored),
716            Err(err) => {
717                return error(
718                    BridgeErrorCode::InvalidRequest,
719                    format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
720                    None,
721                )
722            }
723        },
724        None => None,
725    };
726
727    let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
728        Ok(card) => card,
729        // A card too large to print is a resource refusal, not a command failure: same code
730        // the byte cap below reports, so a caller has one condition to handle.
731        Err(err @ CardServiceError::TooLarge(_)) => {
732            return error(
733                BridgeErrorCode::ResourceLimit,
734                format!("card.pdf refused: {err}"),
735                None,
736            )
737        }
738        Err(err) => {
739            return error(
740                BridgeErrorCode::CommandFailed,
741                format!("card.pdf failed: {err}"),
742                None,
743            )
744        }
745    };
746    let byte_length = card.pdf_bytes.len();
747    if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
748        return envelope;
749    }
750    let mut response = json!({
751        "pdf_base64": encode_base64(&card.pdf_bytes),
752        "byte_length": byte_length,
753        "page_count": card.page_count,
754        "row_count": card.row_count,
755        "kind": crate::card_service::PDF_CARD_KIND,
756        "source": card.source.as_str(),
757        "unprintable_title_chars": card.unprintable_title_chars,
758    });
759    // MBA-1477: additive, and spelled exactly as `card.range_table`'s own block, so an app
760    // that already reads a truncation off the on-screen card reads the printed one the same
761    // way. Absent on a card that reached every row it asked for, which is every card the
762    // previous shape could describe at all — a truncated document used to come back here
763    // indistinguishable from a complete one, with only a smaller `row_count` to notice.
764    if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
765        object.insert(
766            "truncation".to_string(),
767            json!({
768                "requested_end": truncation.requested_end,
769                "last_row": truncation.last_row,
770                "reach": truncation.reach,
771            }),
772        );
773    }
774    success("card.pdf", response)
775}
776
777/// RFC 4648 standard-alphabet base64 encoder with padding, for `card.pdf` and
778/// `profile.export_a7p` — hence the two-feature gate, which must list every feature that
779/// has a binary to hand out or the function goes missing from exactly the build that
780/// needs it.
781///
782/// Hand-rolled for the same reason as `decode_base64` below (plain text, not a doc link:
783/// that function is gated on `profile-import`, which a pdf-only build need not enable): no
784/// direct base64 dependency
785/// exists in `Cargo.toml`, and adding one for twenty lines of arithmetic would ride along on
786/// all thirteen release targets.
787#[cfg(any(feature = "pdf", feature = "profile-export"))]
788fn encode_base64(bytes: &[u8]) -> String {
789    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
790    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
791    for chunk in bytes.chunks(3) {
792        let triple = (u32::from(chunk[0]) << 16)
793            | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
794            | u32::from(chunk.get(2).copied().unwrap_or(0));
795        out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
796        out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
797        // A 1- or 2-byte tail pads rather than encoding the zero bits it never carried.
798        out.push(if chunk.len() > 1 {
799            char::from(ALPHABET[(triple >> 6) as usize & 63])
800        } else {
801            '='
802        });
803        out.push(if chunk.len() > 2 {
804            char::from(ALPHABET[triple as usize & 63])
805        } else {
806            '='
807        });
808    }
809    out
810}
811
812/// Wrap a command's own typed error envelope losslessly in `details`.
813fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
814    let details = serde_json::to_value(typed).ok();
815    error(BridgeErrorCode::CommandFailed, message, details)
816}
817
818/// Shared decode for the two profile document commands: the inner request IS a
819/// [`crate::profile::ProfileData`] JSON document (the exact schema of
820/// `~/.ballistics/profiles/*.json` — same field names, same defaults, unknown keys
821/// tolerated, exactly as the CLI loads it).
822fn decode_profile_document(
823    inner: &Value,
824    command: &'static str,
825) -> Result<crate::profile::ProfileData, String> {
826    if inner.is_null() {
827        return Err(error(
828            BridgeErrorCode::InvalidRequest,
829            format!("'{command}' requires a request payload (a ProfileData JSON document)"),
830            None,
831        ));
832    }
833    serde_json::from_value(inner.clone()).map_err(|err| {
834        error(
835            BridgeErrorCode::InvalidRequest,
836            format!("{command} request is not a ProfileData document: {err}"),
837            None,
838        )
839    })
840}
841
842/// `profile.validate`: parse a ProfileData document and run the cheap invariants the CLI
843/// applies when loading/saving a profile (units string, MBA-1358 tracking-CF band,
844/// MBA-1348 turret/optic assembly + validation including click-value parse) — see
845/// [`crate::profile::ProfileData::validation_warnings`]. No new physics checks. Result:
846/// `{ "valid": bool, "warnings": [..], "normalized": <the profile re-serialized by this
847/// engine> }` — `valid` is simply `warnings.is_empty()`.
848fn run_profile_validate(inner: &Value) -> String {
849    let profile = match decode_profile_document(inner, "profile.validate") {
850        Ok(profile) => profile,
851        Err(envelope) => return envelope,
852    };
853    let warnings = profile.validation_warnings();
854    match serde_json::to_value(&profile) {
855        Ok(normalized) => success(
856            "profile.validate",
857            json!({
858                "valid": warnings.is_empty(),
859                "warnings": warnings,
860                "normalized": normalized,
861            }),
862        ),
863        Err(err) => error(
864            BridgeErrorCode::InternalError,
865            format!("failed to serialize normalized profile: {err}"),
866            None,
867        ),
868    }
869}
870
871/// `profile.normalize`: parse a ProfileData document and hand it back re-serialized by
872/// THIS engine — the supported way for an app to round-trip a stored blob through a newer
873/// engine version (unknown keys are tolerated on input and dropped on output; defaults
874/// fill in; `skip_serializing_if` keys stay absent — the same round-trip a CLI
875/// load-then-save performs). Result: `{ "profile": <re-serialized ProfileData> }`.
876fn run_profile_normalize(inner: &Value) -> String {
877    let profile = match decode_profile_document(inner, "profile.normalize") {
878        Ok(profile) => profile,
879        Err(envelope) => return envelope,
880    };
881    match serde_json::to_value(&profile) {
882        Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
883        Err(err) => error(
884            BridgeErrorCode::InternalError,
885            format!("failed to serialize normalized profile: {err}"),
886            None,
887        ),
888    }
889}
890
891/// `bc5d.info` request payload: the filesystem path of a downloaded BC5D table.
892#[cfg(not(target_arch = "wasm32"))]
893#[derive(Debug, Deserialize)]
894#[serde(deny_unknown_fields)]
895struct Bc5dInfoRequest {
896    path: String,
897}
898
899/// `bc5d.info`: open, parse, and CRC-verify a BC5D correction table at a caller-supplied
900/// path — the exact same load-with-verification (`bc_table_5d::path_cache::load_verified`)
901/// the `solve`/card `bc5d_table_path` fields use, so "info says valid" and "the solve will
902/// accept it" cannot drift apart. Lets an app validate a table right after downloading it.
903///
904/// Result on success: `{ "valid": true, "crc_ok": true, ... }` with the identifying
905/// metadata the header carries (format version, caliber, generator API version,
906/// generation timestamp, per-axis bin counts, total cells, weight/velocity coverage).
907/// A missing, unreadable, corrupt, or non-BC5D file is a `command_failed` envelope with
908/// a human-readable message (`invalid_request` when the payload itself is malformed).
909///
910/// `caliber` is the raw header value (an `f32`, so a .308 table reports 0.30799998) and
911/// `caliber_key` is that same value as the 3-digit BC5D key — EXACTLY the integer the
912/// solve/card caliber guard compares (`Bc5dTable::ensure_caliber_matches`). An app can
913/// therefore pre-check a downloaded table itself with
914/// `round(bullet_diameter_inches * 1000) == caliber_key` and show its own friendly
915/// message instead of provoking the `command_failed`. This command deliberately does NOT
916/// take a caliber: it describes a file, and only the surfaces that have a shot in hand
917/// enforce the match.
918#[cfg(not(target_arch = "wasm32"))]
919fn run_bc5d_info(inner: &Value) -> String {
920    if inner.is_null() {
921        return error(
922            BridgeErrorCode::InvalidRequest,
923            "'bc5d.info' requires a request payload ({\"path\": ...})",
924            None,
925        );
926    }
927    let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
928        Ok(request) => request,
929        Err(err) => {
930            return error(
931                BridgeErrorCode::InvalidRequest,
932                format!("bc5d.info request rejected: {err}"),
933                None,
934            )
935        }
936    };
937
938    let table =
939        match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(&request.path)) {
940            Ok(table) => table,
941            Err(err) => {
942                return error(
943                    BridgeErrorCode::CommandFailed,
944                    format!("bc5d.info: not a usable BC5D table: {err}"),
945                    None,
946                )
947            }
948        };
949
950    let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
951    let (weight_lo, weight_hi) = table.weight_range();
952    let (vel_lo, vel_hi) = table.velocity_range();
953    success(
954        "bc5d.info",
955        json!({
956            // Reaching here means the magic, format version, dimensions, AND the stored
957            // CRC32 all checked out — crc_ok is not a separate weaker probe.
958            "valid": true,
959            "crc_ok": true,
960            "format_version": table.version(),
961            "caliber": table.caliber(),
962            // The integer the caliber guard actually compares (see the doc comment).
963            "caliber_key": table.caliber_key(),
964            "api_version": table.api_version(),
965            "generated_timestamp": table.timestamp(),
966            // Axis order matches the on-disk layout: [drag_type][weight][bc][mv][cv].
967            "bins": {
968                "weight": weight,
969                "bc": bc,
970                "muzzle_velocity": muzzle_vel,
971                "current_velocity": current_vel,
972                "drag_types": drag_types,
973            },
974            "total_cells": table.total_cells(),
975            "weight_range_grains": [weight_lo, weight_hi],
976            "velocity_range_fps": [vel_lo, vel_hi],
977        }),
978    )
979}
980
981/// Hard cap on the DECODED `.a7p` payload accepted by `profile.import_a7p`. Real files
982/// are a few KiB; this exists purely as a resource bound (the request envelope's own
983/// [`MAX_REQUEST_BYTES`] already caps the base64 text).
984#[cfg(feature = "profile-import")]
985pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
986
987/// `profile.import_a7p` request payload. `zero_click` mirrors the CLI's `--zero-click`
988/// (the source device's click graduation, e.g. `"0.1mil"`), enabling the same optional
989/// zero_x/zero_y click-count conversion; omitted keeps the CLI's default behavior (the
990/// counts are reported as unmapped). `strict` mirrors `--strict`: reject the file on an
991/// MD5 envelope mismatch instead of importing with a warning.
992#[cfg(feature = "profile-import")]
993#[derive(Debug, Deserialize)]
994#[serde(deny_unknown_fields)]
995struct ProfileImportA7pRequest {
996    a7p_base64: String,
997    #[serde(default)]
998    zero_click: Option<String>,
999    #[serde(default)]
1000    strict: bool,
1001}
1002
1003/// `profile.import_a7p`: run the cleanroom `.a7p` parser + the SAME
1004/// [`crate::profile_import::map_a7p_to_profile`] mapping the CLI's `profile import` uses,
1005/// on a base64-supplied file. Result: `{ "profile": <ProfileData>, "warnings": [..],
1006/// "mapped": [[source, raw, converted, destination], ..], "unmapped": [[field, why], ..],
1007/// "unknown_fields": [{context, number}, ..] }` — the full import report, nothing
1008/// silently dropped (`unmapped` includes the unknown-field entries too, exactly as the
1009/// CLI prints them; `unknown_fields` additionally lists the parser-level unknowns in
1010/// structured form). The profile name is derived from the file (sanitized); renaming is
1011/// the caller's business — there is no name override here.
1012#[cfg(feature = "profile-import")]
1013fn run_profile_import_a7p(inner: &Value) -> String {
1014    use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
1015
1016    if inner.is_null() {
1017        return error(
1018            BridgeErrorCode::InvalidRequest,
1019            "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
1020            None,
1021        );
1022    }
1023    let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
1024        Ok(request) => request,
1025        Err(err) => {
1026            return error(
1027                BridgeErrorCode::InvalidRequest,
1028                format!("profile.import_a7p request rejected: {err}"),
1029                None,
1030            )
1031        }
1032    };
1033
1034    let zero_click = match request.zero_click.as_deref() {
1035        Some(raw) => match crate::adjustment::parse_click_value(raw) {
1036            Ok(click) => Some(click),
1037            Err(err) => {
1038                return error(
1039                    BridgeErrorCode::InvalidRequest,
1040                    format!("profile.import_a7p zero_click: {err}"),
1041                    None,
1042                )
1043            }
1044        },
1045        None => None,
1046    };
1047
1048    let bytes = match decode_base64(&request.a7p_base64) {
1049        Ok(bytes) => bytes,
1050        Err(err) => {
1051            return error(
1052                BridgeErrorCode::InvalidRequest,
1053                format!("profile.import_a7p a7p_base64: {err}"),
1054                None,
1055            )
1056        }
1057    };
1058    if bytes.len() > MAX_A7P_DECODED_BYTES {
1059        return error(
1060            BridgeErrorCode::ResourceLimit,
1061            format!(
1062                "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
1063                bytes.len()
1064            ),
1065            None,
1066        );
1067    }
1068
1069    let doc = match parse_a7p(&bytes) {
1070        Ok(doc) => doc,
1071        Err(err) => {
1072            return error(
1073                BridgeErrorCode::CommandFailed,
1074                format!("not a usable .a7p file: {err}"),
1075                None,
1076            )
1077        }
1078    };
1079    // Same refusal (and message) as the CLI's --strict; without it the mismatch becomes
1080    // a warning in the report, also exactly as the CLI behaves.
1081    if request.strict {
1082        if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
1083            return error(
1084                BridgeErrorCode::CommandFailed,
1085                format!(
1086                    "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
1087                ),
1088                None,
1089            );
1090        }
1091    }
1092
1093    let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
1094        Ok(outcome) => outcome,
1095        Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
1096    };
1097    let unknown_fields: Vec<Value> = doc
1098        .unknown_fields
1099        .iter()
1100        .map(|u| json!({ "context": u.context, "number": u.number }))
1101        .collect();
1102    match serde_json::to_value(&outcome.profile) {
1103        Ok(profile) => success(
1104            "profile.import_a7p",
1105            json!({
1106                "profile": profile,
1107                "warnings": outcome.report.warnings,
1108                "mapped": outcome.report.mapped,
1109                "unmapped": outcome.report.unmapped,
1110                "unknown_fields": unknown_fields,
1111            }),
1112        ),
1113        Err(err) => error(
1114            BridgeErrorCode::InternalError,
1115            format!("failed to serialize imported profile: {err}"),
1116            None,
1117        ),
1118    }
1119}
1120
1121/// `profile.export_a7p`: encode a ProfileData document as an ArcherBC2 `.a7p` file,
1122/// with the full account of what could not travel. Request payload is the ProfileData
1123/// document itself, exactly like `profile.validate`/`profile.normalize`.
1124///
1125/// Result: `{ "a7p_base64": "..", "byte_length": N, "carried_fields": [..],
1126/// "not_carried": [{field, populated, value, reason}, ..], "dropped_fields": [..],
1127/// "warnings": [..] }`.
1128///
1129/// `not_carried` is the reason this command has a shape of its own rather than
1130/// returning bare bytes, and it is NOT optional output. `.a7p` is a third-party format
1131/// built around one rifle, one load and one device; a saved profile holds a good deal it
1132/// has no slot for, and an app that shows a shooter "exported" without showing them what
1133/// their friend will not receive has told them something untrue. `not_carried` lists
1134/// every unsupported field unconditionally, with `populated` saying whether THIS profile
1135/// actually lost anything there, and `dropped_fields` is the pre-filtered subset that
1136/// did — the list to put in front of a person. `carried_fields` is the other half of the
1137/// same partition, so a caller can account for the whole document.
1138///
1139/// Errors carry a structured `reason` in `error.details` under the `true.*` convention
1140/// (`error.code` stays `command_failed`): `units`, `drag_model`, or `field` plus the
1141/// offending field name.
1142#[cfg(feature = "profile-export")]
1143fn run_profile_export_a7p(inner: &Value) -> String {
1144    use crate::profile_export::{export_a7p, A7pExportError, CARRIED_FIELDS};
1145
1146    let profile = match decode_profile_document(inner, "profile.export_a7p") {
1147        Ok(profile) => profile,
1148        Err(envelope) => return envelope,
1149    };
1150    let export = match export_a7p(&profile) {
1151        Ok(export) => export,
1152        Err(err) => {
1153            let details = match &err {
1154                A7pExportError::Units(_) => json!({ "reason": "units" }),
1155                A7pExportError::DragModel(model) => {
1156                    json!({ "reason": "drag_model", "drag_model": model })
1157                }
1158                A7pExportError::Field { field, .. } => {
1159                    json!({ "reason": "field", "field": field })
1160                }
1161            };
1162            return error(
1163                BridgeErrorCode::CommandFailed,
1164                err.to_string(),
1165                Some(details),
1166            );
1167        }
1168    };
1169    let not_carried = match serde_json::to_value(&export.not_carried) {
1170        Ok(value) => value,
1171        Err(err) => {
1172            return error(
1173                BridgeErrorCode::InternalError,
1174                format!("failed to serialize the not-carried list: {err}"),
1175                None,
1176            )
1177        }
1178    };
1179    success(
1180        "profile.export_a7p",
1181        json!({
1182            "a7p_base64": encode_base64(&export.bytes),
1183            "byte_length": export.bytes.len(),
1184            "carried_fields": CARRIED_FIELDS,
1185            "not_carried": not_carried,
1186            "dropped_fields": export.dropped_fields(),
1187            "warnings": export.warnings,
1188        }),
1189    )
1190}
1191
1192/// Minimal strict RFC 4648 standard-alphabet base64 decoder for `profile.import_a7p`.
1193///
1194/// Hand-rolled rather than a new dependency, deliberately: the crate already carries its
1195/// own cleanroom MD5 (`profile_import::md5`) and statistical constants for the same
1196/// thirteen-platform reasons, `Cargo.toml` has no direct base64 dependency today, and the
1197/// input here is a few KiB. Strict: rejects any character outside `A-Za-z0-9+/`, `=`
1198/// anywhere but as final padding, and lengths of form 4n+1.
1199#[cfg(feature = "profile-import")]
1200fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
1201    fn sextet(c: u8) -> Result<u32, String> {
1202        match c {
1203            b'A'..=b'Z' => Ok(u32::from(c - b'A')),
1204            b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
1205            b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
1206            b'+' => Ok(62),
1207            b'/' => Ok(63),
1208            _ => Err(format!("invalid base64 character {:?}", char::from(c))),
1209        }
1210    }
1211    let bytes = input.as_bytes();
1212    let data = match bytes {
1213        [rest @ .., b'=', b'='] => rest,
1214        [rest @ .., b'='] => rest,
1215        _ => bytes,
1216    };
1217    if data.contains(&b'=') {
1218        return Err("'=' is only valid as trailing padding".to_string());
1219    }
1220    if data.len() % 4 == 1 {
1221        return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1222    }
1223    let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1224    let mut acc: u32 = 0;
1225    let mut bits: u32 = 0;
1226    for &c in data {
1227        acc = (acc << 6) | sextet(c)?;
1228        bits += 6;
1229        if bits >= 8 {
1230            bits -= 8;
1231            out.push((acc >> bits) as u8);
1232        }
1233    }
1234    Ok(out)
1235}
1236
1237// ---------------------------------------------------------------------------
1238// reticle.* — MBA-1558
1239// ---------------------------------------------------------------------------
1240
1241/// How a request names the reticle it is asking about: either the full
1242/// [`crate::reticle::ReticleDescription`] inline, or a generator to build one from.
1243///
1244/// Exactly one, and the requests below check that rather than silently preferring one —
1245/// a caller that sends both has a bug, and picking a winner for them hides it.
1246#[derive(Debug, Deserialize)]
1247#[serde(tag = "kind", rename_all = "snake_case")]
1248enum ReticleGenerator {
1249    /// [`crate::reticle::ReticleDescription::mil_grid`].
1250    MilGrid { spacing_mil: f64, extent_mil: f64 },
1251    /// [`crate::reticle::ReticleDescription::tree`].
1252    Tree {
1253        rows: usize,
1254        row_spacing_mil: f64,
1255        spread_step_mil: f64,
1256    },
1257    /// [`crate::reticle::ReticleDescription::bdc_from_drops`]. Pairs are
1258    /// `[range_metres, drop_mil]`, and the generator labels each mark `"<range> m"` —
1259    /// the range unit is the wire's, not the shooter's, and an app showing those labels
1260    /// converts them itself.
1261    Bdc { drops: Vec<(f64, f64)> },
1262}
1263
1264/// Optional focal-plane overrides for a GENERATED reticle.
1265///
1266/// Every generator returns FFP with `reference_magnification` 1.0, and the generator docs
1267/// say callers wanting SFP "set those two fields afterwards". On the bridge there is no
1268/// afterwards — the caller never holds the struct — so the overrides have to travel with
1269/// the request or the generator path could only ever produce FFP reticles, which is half
1270/// the optics on the market.
1271#[derive(Debug, Default, Deserialize)]
1272#[serde(deny_unknown_fields)]
1273struct ReticlePlaneOverride {
1274    #[serde(default)]
1275    focal_plane: Option<crate::reticle::FocalPlane>,
1276    #[serde(default)]
1277    reference_magnification: Option<f64>,
1278}
1279
1280/// Build the description a request is about, from whichever source it supplied.
1281///
1282/// `Err` is a finished error envelope, matching how the other commands' helpers report.
1283fn resolve_reticle(
1284    command: &'static str,
1285    reticle: Option<crate::reticle::ReticleDescription>,
1286    generator: Option<ReticleGenerator>,
1287    catalog: Option<String>,
1288    plane: &ReticlePlaneOverride,
1289) -> Result<crate::reticle::ReticleDescription, String> {
1290    use crate::reticle::ReticleDescription;
1291
1292    // Exactly one source. Counted rather than matched pair-by-pair, so adding a fourth
1293    // source later cannot quietly reintroduce a precedence rule.
1294    let named = [reticle.is_some(), generator.is_some(), catalog.is_some()]
1295        .iter()
1296        .filter(|named| **named)
1297        .count();
1298    if named > 1 {
1299        return Err(error(
1300            BridgeErrorCode::InvalidRequest,
1301            format!(
1302                "{command} takes exactly one of 'reticle', 'generator' or 'catalog' — \
1303                 supplying more than one is a caller bug rather than a preference"
1304            ),
1305            None,
1306        ));
1307    }
1308
1309    // Resolved before the match below so a catalog id reaches it as a full description.
1310    let reticle = match (reticle, catalog) {
1311        (reticle, None) => reticle,
1312        (_, Some(id)) => match crate::reticle_catalog::by_id(&id) {
1313            Some(Ok(built)) => Some(built),
1314            Some(Err(err)) => return Err(reticle_error_envelope(command, &err)),
1315            // Not a default: a caller asking for a specific reticle and silently getting a
1316            // different one would be shown holds for glass they are not looking through.
1317            None => {
1318                return Err(error(
1319                    BridgeErrorCode::InvalidRequest,
1320                    format!(
1321                        "{command}: no catalog reticle with id {id:?}; \
1322                         'reticle.catalog' lists what this build has"
1323                    ),
1324                    None,
1325                ))
1326            }
1327        },
1328    };
1329
1330    let mut described = match (reticle, generator) {
1331        (Some(_), Some(_)) => unreachable!("the count above rejected more than one source"),
1332        (None, None) => {
1333            return Err(error(
1334                BridgeErrorCode::InvalidRequest,
1335                format!(
1336                    "{command} requires one of 'reticle' (a full description), \
1337                     'generator' or 'catalog'"
1338                ),
1339                None,
1340            ))
1341        }
1342        (Some(reticle), None) => {
1343            if plane.focal_plane.is_some() || plane.reference_magnification.is_some() {
1344                return Err(error(
1345                    BridgeErrorCode::InvalidRequest,
1346                    format!(
1347                        "{command}: 'focal_plane' and 'reference_magnification' override a \
1348                         GENERATED reticle; a 'reticle' or 'catalog' one already carries its own"
1349                    ),
1350                    None,
1351                ));
1352            }
1353            reticle
1354        }
1355        (None, Some(generator)) => {
1356            let built = match generator {
1357                ReticleGenerator::MilGrid {
1358                    spacing_mil,
1359                    extent_mil,
1360                } => ReticleDescription::mil_grid(spacing_mil, extent_mil),
1361                ReticleGenerator::Tree {
1362                    rows,
1363                    row_spacing_mil,
1364                    spread_step_mil,
1365                } => ReticleDescription::tree(rows, row_spacing_mil, spread_step_mil),
1366                ReticleGenerator::Bdc { drops } => ReticleDescription::bdc_from_drops(&drops),
1367            };
1368            match built {
1369                Ok(built) => built,
1370                Err(err) => return Err(reticle_error_envelope(command, &err)),
1371            }
1372        }
1373    };
1374
1375    if let Some(focal_plane) = plane.focal_plane {
1376        described.focal_plane = focal_plane;
1377    }
1378    if let Some(reference_magnification) = plane.reference_magnification {
1379        described.reference_magnification = reference_magnification;
1380    }
1381    Ok(described)
1382}
1383
1384/// A [`crate::reticle::ReticleError`] as this family's structured error envelope.
1385///
1386/// Follows the `true.*` convention established by `true.dsf`: `error.code` stays
1387/// `command_failed`, and a stable machine-readable `reason` rides in `error.details`
1388/// alongside the human message, so a front end can render its own wording without
1389/// parsing prose. The reasons are the error's own variants, which is what makes them
1390/// stable — a new variant is a new reason, never a re-spelling of an old one.
1391fn reticle_error_envelope(command: &'static str, err: &crate::reticle::ReticleError) -> String {
1392    use crate::reticle::ReticleError;
1393
1394    let mut details = json!({ "reason": match err {
1395        ReticleError::NonPositiveMagnification { .. } => "non_positive_magnification",
1396        ReticleError::NonPositiveReferenceMagnification { .. } => {
1397            "non_positive_reference_magnification"
1398        }
1399        ReticleError::NoMarks => "no_marks",
1400        ReticleError::TooManyMarks { .. } => "too_many_marks",
1401        ReticleError::NonFiniteMark { .. } => "non_finite_mark",
1402        ReticleError::NonFiniteHold { .. } => "non_finite_hold",
1403        ReticleError::InvalidGeneratorParameter { .. } => "invalid_generator_parameter",
1404        ReticleError::InvalidSpec(_) => "invalid_spec",
1405    }});
1406
1407    // The numbers the variant carries, so a caller can point at the offending input
1408    // without re-parsing the sentence.
1409    match err {
1410        ReticleError::NonPositiveMagnification { magnification } => {
1411            details["magnification"] = json!(magnification);
1412        }
1413        ReticleError::NonPositiveReferenceMagnification {
1414            reference_magnification,
1415        } => {
1416            details["reference_magnification"] = json!(reference_magnification);
1417        }
1418        ReticleError::TooManyMarks { count, max } => {
1419            details["count"] = json!(count);
1420            details["max"] = json!(max);
1421        }
1422        ReticleError::NonFiniteMark { index } => {
1423            details["index"] = json!(index);
1424        }
1425        ReticleError::InvalidGeneratorParameter {
1426            parameter,
1427            value,
1428            rule,
1429        } => {
1430            details["parameter"] = json!(parameter);
1431            details["value"] = json!(value);
1432            details["rule"] = json!(rule);
1433        }
1434        ReticleError::NoMarks
1435        | ReticleError::NonFiniteHold { .. }
1436        | ReticleError::InvalidSpec(_) => {}
1437    }
1438    details["command"] = json!(command);
1439
1440    error(
1441        BridgeErrorCode::CommandFailed,
1442        err.to_string(),
1443        Some(details),
1444    )
1445}
1446
1447/// The nearest mark a hold landed by, reported so a caller does not index back into the
1448/// description and re-apply the scale itself.
1449///
1450/// BOTH positions are given because they answer different questions and are not the same
1451/// number on an SFP optic: `nominal` is the mark as authored (what a reticle diagram
1452/// prints), `true_angular` is where it actually sits at this magnification (what the hold
1453/// is measured against).
1454fn nearest_mark_value(
1455    reticle: &crate::reticle::ReticleDescription,
1456    hold: &crate::reticle::ReticleHold,
1457) -> Value {
1458    match hold
1459        .nearest_mark
1460        .and_then(|index| reticle.marks.get(index).map(|mark| (index, mark)))
1461    {
1462        Some((index, mark)) => json!({
1463            "index": index,
1464            "kind": mark.kind.as_str(),
1465            "label": mark.label,
1466            "nominal": { "down_mil": mark.down_mil, "right_mil": mark.right_mil },
1467            "true_angular": {
1468                "down_mil": mark.down_mil * hold.mark_scale,
1469                "right_mil": mark.right_mil * hold.mark_scale,
1470            },
1471        }),
1472        None => Value::Null,
1473    }
1474}
1475
1476/// `reticle.catalog`: the named reticles this build can produce.
1477///
1478/// Result: `{ "reticles": [{ "id", "display_name", "source", "notes", "mark_count" }, ..] }`.
1479/// An app populates a picker from this rather than hardcoding ids, so a reticle added to a
1480/// later engine appears without an app release — and one REMOVED does not leave a picker
1481/// offering something the engine will refuse.
1482///
1483/// `source` is carried onto the wire deliberately. A wrong subtension is a hold that is
1484/// wrong by a mark and looks entirely normal, so the provenance that makes it traceable
1485/// belongs where the person looking at the reticle can see it, not only in the crate.
1486fn run_reticle_catalog() -> String {
1487    let reticles: Vec<Value> = crate::reticle_catalog::catalog()
1488        .into_iter()
1489        .map(|entry| {
1490            // Listed entries are built by `by_id` under test, so this cannot be the place a
1491            // caller learns an entry is broken; report what is known rather than failing the
1492            // whole listing over one bad row.
1493            let mark_count = crate::reticle_catalog::by_id(entry.id)
1494                .and_then(|built| built.ok())
1495                .map(|reticle| reticle.marks.len());
1496            json!({
1497                "id": entry.id,
1498                "display_name": entry.display_name,
1499                "source": entry.source,
1500                "notes": entry.notes,
1501                "mark_count": mark_count,
1502            })
1503        })
1504        .collect();
1505    success("reticle.catalog", json!({ "reticles": reticles }))
1506}
1507
1508#[derive(Debug, Deserialize)]
1509#[serde(deny_unknown_fields)]
1510struct ReticleDescribeRequest {
1511    #[serde(default)]
1512    reticle: Option<crate::reticle::ReticleDescription>,
1513    #[serde(default)]
1514    generator: Option<ReticleGenerator>,
1515    /// A `reticle.catalog` id. The third and simplest way to name a reticle,
1516    /// and the one an app picker uses.
1517    #[serde(default)]
1518    catalog: Option<String>,
1519    #[serde(default)]
1520    focal_plane: Option<crate::reticle::FocalPlane>,
1521    #[serde(default)]
1522    reference_magnification: Option<f64>,
1523    /// When present, the result also carries every mark's TRUE angular position at this
1524    /// magnification. Absent means "just the description".
1525    #[serde(default)]
1526    magnification: Option<f64>,
1527}
1528
1529/// `reticle.describe`: resolve a reticle — supplied or generated — and report it.
1530///
1531/// Result: `{ "reticle": <ReticleDescription>, "focal_plane": "FFP"|"SFP",
1532/// "magnification_dependent": bool, "mark_count": n, "scaled": {...}|null }`.
1533///
1534/// This is the command a picker and a drawing are built on. `scaled` is present only when
1535/// the request supplied a magnification, and carries `mark_scale` plus the marks in true
1536/// angular space — for an FFP reticle that is the nominal marks and a scale of exactly
1537/// 1.0, which is worth returning anyway so a caller has one code path.
1538fn run_reticle_describe(inner: &Value) -> String {
1539    if inner.is_null() {
1540        return error(
1541            BridgeErrorCode::InvalidRequest,
1542            "'reticle.describe' requires a request payload ({\"reticle\": ...} or {\"generator\": ...})",
1543            None,
1544        );
1545    }
1546    let request: ReticleDescribeRequest = match serde_json::from_value(inner.clone()) {
1547        Ok(request) => request,
1548        Err(err) => {
1549            return error(
1550                BridgeErrorCode::InvalidRequest,
1551                format!("reticle.describe request rejected: {err}"),
1552                None,
1553            )
1554        }
1555    };
1556
1557    let plane = ReticlePlaneOverride {
1558        focal_plane: request.focal_plane,
1559        reference_magnification: request.reference_magnification,
1560    };
1561    let reticle = match resolve_reticle(
1562        "reticle.describe",
1563        request.reticle,
1564        request.generator,
1565        request.catalog,
1566        &plane,
1567    ) {
1568        Ok(reticle) => reticle,
1569        Err(envelope) => return envelope,
1570    };
1571
1572    // Validate even when no magnification was asked for: a description that cannot be
1573    // held on is not a description worth handing back as if it were usable.
1574    if let Err(err) = reticle.validate() {
1575        return reticle_error_envelope("reticle.describe", &err);
1576    }
1577
1578    let scaled = match request.magnification {
1579        Some(magnification) => match reticle.scaled_marks(magnification) {
1580            Ok(marks) => json!({
1581                "magnification": magnification,
1582                "mark_scale": reticle.mark_scale(magnification),
1583                "marks": marks
1584                    .iter()
1585                    .map(|mark| json!({
1586                        "down_mil": mark.down_mil,
1587                        "right_mil": mark.right_mil,
1588                    }))
1589                    .collect::<Vec<_>>(),
1590            }),
1591            Err(err) => return reticle_error_envelope("reticle.describe", &err),
1592        },
1593        None => Value::Null,
1594    };
1595
1596    match serde_json::to_value(&reticle) {
1597        Ok(described) => success(
1598            "reticle.describe",
1599            json!({
1600                "reticle": described,
1601                "focal_plane": reticle.focal_plane.label(),
1602                "magnification_dependent": reticle.focal_plane.is_magnification_dependent(),
1603                "mark_count": reticle.marks.len(),
1604                "scaled": scaled,
1605            }),
1606        ),
1607        Err(err) => error(
1608            BridgeErrorCode::InternalError,
1609            format!("failed to serialize reticle description: {err}"),
1610            None,
1611        ),
1612    }
1613}
1614
1615#[derive(Debug, Deserialize)]
1616#[serde(deny_unknown_fields)]
1617struct ReticleHoldRequest {
1618    #[serde(default)]
1619    reticle: Option<crate::reticle::ReticleDescription>,
1620    #[serde(default)]
1621    generator: Option<ReticleGenerator>,
1622    /// A `reticle.catalog` id. The third and simplest way to name a reticle,
1623    /// and the one an app picker uses.
1624    #[serde(default)]
1625    catalog: Option<String>,
1626    #[serde(default)]
1627    focal_plane: Option<crate::reticle::FocalPlane>,
1628    #[serde(default)]
1629    reference_magnification: Option<f64>,
1630    /// Angular drop below the line of sight, positive = below: the come-up the shooter
1631    /// would otherwise dial.
1632    drop_mil: f64,
1633    /// Angular wind deflection, positive = the bullet goes RIGHT.
1634    #[serde(default)]
1635    wind_mil: f64,
1636    /// The optic's CURRENT magnification. Required on every focal plane — an FFP hold does
1637    /// not depend on it, but zero magnification is not a physical optic and
1638    /// `hold_point_in_reticle` rejects it on both planes rather than masking a caller bug.
1639    magnification: f64,
1640}
1641
1642/// `reticle.hold`: which mark to hold on.
1643///
1644/// The command this family exists for. Given a firing solution already reduced to angles
1645/// (`drop_mil` / `wind_mil`) and the optic's current magnification, place it in the
1646/// reticle and report the nearest mark.
1647///
1648/// ⚠️ THE ANGLES ARE INPUTS, NOT A SOLVE. This command runs no physics — it is the mark
1649/// search around [`crate::reticle::hold_point_in_reticle`], which is why the module can
1650/// keep its no-physics property. A caller gets `drop_mil` and `wind_mil` from `solve` (or
1651/// a card row) and passes them in; nothing here re-derives them, and a stale angle in
1652/// produces a confident hold out.
1653///
1654/// Result: `{ "hold": <ReticleHold>, "nearest_mark": {...}|null }`. `hold.off_reticle`
1655/// is the field a UI must not ignore: true means the solution has run off the marked part
1656/// of the reticle and there is nothing honest to hold on.
1657fn run_reticle_hold(inner: &Value) -> String {
1658    if inner.is_null() {
1659        return error(
1660            BridgeErrorCode::InvalidRequest,
1661            "'reticle.hold' requires a request payload ({reticle|generator, drop_mil, magnification})",
1662            None,
1663        );
1664    }
1665    let request: ReticleHoldRequest = match serde_json::from_value(inner.clone()) {
1666        Ok(request) => request,
1667        Err(err) => {
1668            return error(
1669                BridgeErrorCode::InvalidRequest,
1670                format!("reticle.hold request rejected: {err}"),
1671                None,
1672            )
1673        }
1674    };
1675
1676    let plane = ReticlePlaneOverride {
1677        focal_plane: request.focal_plane,
1678        reference_magnification: request.reference_magnification,
1679    };
1680    let reticle = match resolve_reticle(
1681        "reticle.hold",
1682        request.reticle,
1683        request.generator,
1684        request.catalog,
1685        &plane,
1686    ) {
1687        Ok(reticle) => reticle,
1688        Err(envelope) => return envelope,
1689    };
1690
1691    let hold = match crate::reticle::hold_point_in_reticle(
1692        request.drop_mil,
1693        request.wind_mil,
1694        request.magnification,
1695        &reticle,
1696    ) {
1697        Ok(hold) => hold,
1698        Err(err) => return reticle_error_envelope("reticle.hold", &err),
1699    };
1700
1701    let nearest = nearest_mark_value(&reticle, &hold);
1702    match serde_json::to_value(&hold) {
1703        Ok(hold_value) => success(
1704            "reticle.hold",
1705            json!({
1706                "hold": hold_value,
1707                "nearest_mark": nearest,
1708            }),
1709        ),
1710        Err(err) => error(
1711            BridgeErrorCode::InternalError,
1712            format!("failed to serialize reticle hold: {err}"),
1713            None,
1714        ),
1715    }
1716}
1717
1718/// Most holds `reticle.holds` will place in one call.
1719///
1720/// A sampled trajectory is the thing this exists for and runs to a hundred-odd rows; the
1721/// cap is far above that and exists so a malformed request cannot ask for unbounded work.
1722pub const MAX_RETICLE_HOLDS: usize = 4096;
1723
1724#[derive(Debug, Deserialize)]
1725#[serde(deny_unknown_fields)]
1726struct ReticleHoldsEntry {
1727    drop_mil: f64,
1728    #[serde(default)]
1729    wind_mil: f64,
1730}
1731
1732#[derive(Debug, Deserialize)]
1733#[serde(deny_unknown_fields)]
1734struct ReticleHoldsRequest {
1735    #[serde(default)]
1736    reticle: Option<crate::reticle::ReticleDescription>,
1737    #[serde(default)]
1738    generator: Option<ReticleGenerator>,
1739    #[serde(default)]
1740    catalog: Option<String>,
1741    #[serde(default)]
1742    focal_plane: Option<crate::reticle::FocalPlane>,
1743    #[serde(default)]
1744    reference_magnification: Option<f64>,
1745    magnification: f64,
1746    /// The firing solutions to place, in the caller's own order, which the result
1747    /// preserves.
1748    holds: Vec<ReticleHoldsEntry>,
1749}
1750
1751/// `reticle.holds`: [`run_reticle_hold`] for a whole table, in one call.
1752///
1753/// A holdover COLUMN needs a hold per row, and a sampled trajectory is a hundred-odd rows.
1754/// Done one at a time that is a hundred round trips through the FFI with a JSON encode and
1755/// decode each; done in the app it is the nearest-mark search re-implemented on two
1756/// platforms, which is the second spelling `AdjustmentConversion` exists to argue against.
1757/// So it is one call: the reticle is resolved and validated ONCE, and the search runs per
1758/// row inside the engine.
1759///
1760/// Result: `{ "mark_scale": s, "holds": [ <the same shape reticle.hold returns>, .. ] }`,
1761/// in request order. `mark_scale` is hoisted because it is a property of the reticle and
1762/// the magnification, identical for every row, and repeating it per row would invite a
1763/// caller to wonder when it might differ.
1764///
1765/// The whole request fails on the first unusable hold rather than returning a list with a
1766/// hole in it: a column that silently skips a row is worse than one that does not draw.
1767fn run_reticle_holds(inner: &Value) -> String {
1768    if inner.is_null() {
1769        return error(
1770            BridgeErrorCode::InvalidRequest,
1771            "'reticle.holds' requires a request payload ({reticle|generator|catalog, magnification, holds})",
1772            None,
1773        );
1774    }
1775    let request: ReticleHoldsRequest = match serde_json::from_value(inner.clone()) {
1776        Ok(request) => request,
1777        Err(err) => {
1778            return error(
1779                BridgeErrorCode::InvalidRequest,
1780                format!("reticle.holds request rejected: {err}"),
1781                None,
1782            )
1783        }
1784    };
1785
1786    if request.holds.len() > MAX_RETICLE_HOLDS {
1787        return error(
1788            BridgeErrorCode::ResourceLimit,
1789            format!(
1790                "reticle.holds was given {} holds; the limit is {MAX_RETICLE_HOLDS}",
1791                request.holds.len()
1792            ),
1793            None,
1794        );
1795    }
1796
1797    let plane = ReticlePlaneOverride {
1798        focal_plane: request.focal_plane,
1799        reference_magnification: request.reference_magnification,
1800    };
1801    let reticle = match resolve_reticle(
1802        "reticle.holds",
1803        request.reticle,
1804        request.generator,
1805        request.catalog,
1806        &plane,
1807    ) {
1808        Ok(reticle) => reticle,
1809        Err(envelope) => return envelope,
1810    };
1811
1812    let mut placed = Vec::with_capacity(request.holds.len());
1813    let mut scale = 1.0;
1814    for entry in &request.holds {
1815        let hold = match crate::reticle::hold_point_in_reticle(
1816            entry.drop_mil,
1817            entry.wind_mil,
1818            request.magnification,
1819            &reticle,
1820        ) {
1821            Ok(hold) => hold,
1822            // Defensive, and unreachable from the wire: a bad reticle and a non-positive
1823            // magnification are both settled before this loop, and the only error left is
1824            // a non-finite hold, which JSON cannot carry (serde_json refuses an
1825            // out-of-range literal, and infinity encodes as null). Kept because this is
1826            // also reachable from the Rust API, and because a silently skipped row would
1827            // be worse than a refused request.
1828            Err(err) => return reticle_error_envelope("reticle.holds", &err),
1829        };
1830        scale = hold.mark_scale;
1831        let nearest = nearest_mark_value(&reticle, &hold);
1832        match serde_json::to_value(&hold) {
1833            Ok(mut value) => {
1834                // Hoisted to the top level; see the doc comment.
1835                if let Some(object) = value.as_object_mut() {
1836                    object.remove("mark_scale");
1837                }
1838                placed.push(json!({ "hold": value, "nearest_mark": nearest }));
1839            }
1840            Err(err) => {
1841                return error(
1842                    BridgeErrorCode::InternalError,
1843                    format!("failed to serialize reticle hold: {err}"),
1844                    None,
1845                )
1846            }
1847        }
1848    }
1849
1850    success(
1851        "reticle.holds",
1852        json!({ "mark_scale": scale, "holds": placed }),
1853    )
1854}
1855
1856/// Largest reticle document `reticle.import` will parse.
1857///
1858/// The envelope cap ([`MAX_REQUEST_BYTES`]) already bounds the whole request, but a
1859/// document-specific cap gives a caller a message naming the document rather than the
1860/// envelope, and bounds the parse before it starts. Both formats are TEXT and travel
1861/// inline — unlike `.a7p`, which is binary and needs base64 — so there is no inflation
1862/// factor to leave headroom for.
1863pub const MAX_RETICLE_DOCUMENT_BYTES: usize = 512 * 1024;
1864
1865#[derive(Debug, Deserialize)]
1866#[serde(rename_all = "snake_case")]
1867enum ReticleDocumentFormat {
1868    /// The Ventum JSON reticle spec (MBA-1440).
1869    Ventum,
1870    /// The third-party `.reticle` XML drawing format (MBA-1544).
1871    ReticleXml,
1872}
1873
1874#[derive(Debug, Deserialize)]
1875#[serde(deny_unknown_fields)]
1876struct ReticleImportRequest {
1877    format: ReticleDocumentFormat,
1878    /// The document itself, as text.
1879    document: String,
1880}
1881
1882/// `reticle.import`: turn a third-party reticle document into a
1883/// [`crate::reticle::ReticleDescription`].
1884///
1885/// Result: `{ "reticle": <ReticleDescription>, "format": "ventum"|"reticle_xml",
1886/// "report": {...} }`. The report is format-specific and is NOT decoration: both importers
1887/// drop elements they cannot turn into hold points, and a caller that ignores the report
1888/// shows a shooter a reticle missing marks their drawing had. The same obligation
1889/// `profile.import_a7p` discharges with its `unmapped` list.
1890fn run_reticle_import(inner: &Value) -> String {
1891    if inner.is_null() {
1892        return error(
1893            BridgeErrorCode::InvalidRequest,
1894            "'reticle.import' requires a request payload ({\"format\": ..., \"document\": ...})",
1895            None,
1896        );
1897    }
1898    let request: ReticleImportRequest = match serde_json::from_value(inner.clone()) {
1899        Ok(request) => request,
1900        Err(err) => {
1901            return error(
1902                BridgeErrorCode::InvalidRequest,
1903                format!("reticle.import request rejected: {err}"),
1904                None,
1905            )
1906        }
1907    };
1908
1909    if request.document.len() > MAX_RETICLE_DOCUMENT_BYTES {
1910        return error(
1911            BridgeErrorCode::ResourceLimit,
1912            format!(
1913                "reticle document is {} bytes; the limit is {MAX_RETICLE_DOCUMENT_BYTES}",
1914                request.document.len()
1915            ),
1916            None,
1917        );
1918    }
1919
1920    let (reticle, report, format) = match request.format {
1921        ReticleDocumentFormat::Ventum => {
1922            match crate::reticle_import::import_ventum_reticle_with_report(&request.document) {
1923                Ok((reticle, report)) => (
1924                    reticle,
1925                    json!({
1926                        "dropped_elements": report.dropped_elements,
1927                        "dropped_element_types": report
1928                            .dropped_element_types
1929                            .iter()
1930                            .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1931                            .collect::<Vec<_>>(),
1932                        // Arcs are REPORTED, never imported as marks: a horseshoe is a
1933                        // shape a shooter indexes on, and which of its points counts as an
1934                        // aiming point is the caller's decision, not this bridge's. Apex
1935                        // and the two tips are the points the importer resolves.
1936                        "arcs": report
1937                            .arcs
1938                            .iter()
1939                            .map(|arc| json!({
1940                                "center": { "down_mil": arc.center.down_mil, "right_mil": arc.center.right_mil },
1941                                "radius_mil": arc.radius_mil,
1942                                "start_degrees": arc.start_degrees,
1943                                "end_degrees": arc.end_degrees,
1944                                "sweep_degrees": arc.sweep_degrees,
1945                                "start_tip": { "down_mil": arc.start_tip.down_mil, "right_mil": arc.start_tip.right_mil },
1946                                "apex": { "down_mil": arc.apex.down_mil, "right_mil": arc.apex.right_mil },
1947                                "end_tip": { "down_mil": arc.end_tip.down_mil, "right_mil": arc.end_tip.right_mil },
1948                            }))
1949                            .collect::<Vec<_>>(),
1950                        // Both shortfalls the report declares about itself, carried rather
1951                        // than dropped: `arcs.len() + arcs_unresolved` is the arc tally, and
1952                        // `circle_repeats_unreadable` says the element count is low by an
1953                        // unknown amount.
1954                        "arcs_unresolved": report.arcs_unresolved,
1955                        "circle_repeats_unreadable": report.circle_repeats_unreadable,
1956                    }),
1957                    "ventum",
1958                ),
1959                Err(err) => return reticle_error_envelope("reticle.import", &err),
1960            }
1961        }
1962        ReticleDocumentFormat::ReticleXml => {
1963            match crate::reticle_document_import::import_reticle_document_with_report(
1964                &request.document,
1965            ) {
1966                Ok((reticle, report)) => (
1967                    reticle,
1968                    json!({
1969                        "drawing_elements_dropped": report.drawing_elements_dropped,
1970                        "dropped_element_tags": report
1971                            .dropped_element_tags
1972                            .iter()
1973                            .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1974                            .collect::<Vec<_>>(),
1975                        "canvas_mil": report
1976                            .canvas_mil
1977                            .map(|(x, y)| json!({ "size_x_mil": x, "size_y_mil": y })),
1978                        "zero_in_canvas_mil": report
1979                            .zero_in_canvas_mil
1980                            .map(|(x, y)| json!({ "zero_x_mil": x, "zero_y_mil": y })),
1981                    }),
1982                    "reticle_xml",
1983                ),
1984                Err(err) => return reticle_error_envelope("reticle.import", &err),
1985            }
1986        }
1987    };
1988
1989    match serde_json::to_value(&reticle) {
1990        Ok(imported) => success(
1991            "reticle.import",
1992            json!({
1993                "reticle": imported,
1994                "format": format,
1995                "mark_count": reticle.marks.len(),
1996                "report": report,
1997            }),
1998        ),
1999        Err(err) => error(
2000            BridgeErrorCode::InternalError,
2001            format!("failed to serialize imported reticle: {err}"),
2002            None,
2003        ),
2004    }
2005}
2006
2007#[cfg(test)]
2008mod tests {
2009    use super::*;
2010
2011    fn call(value: Value) -> Value {
2012        let raw = bridge_call(&value.to_string());
2013        serde_json::from_str(&raw).expect("bridge output must be valid JSON")
2014    }
2015
2016    #[test]
2017    fn capabilities_reports_commands_and_versions() {
2018        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2019        assert_eq!(out["ok"], true);
2020        assert_eq!(out["api_version"], 1);
2021        assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
2022        let commands: Vec<String> =
2023            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2024        assert!(commands.contains(&"solve".to_string()));
2025        assert!(commands.contains(&"meta.capabilities".to_string()));
2026    }
2027
2028    #[test]
2029    fn density_altitude_is_listed_right_after_solve() {
2030        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2031        let commands: Vec<String> =
2032            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2033        let solve = commands.iter().position(|c| c == "solve").unwrap();
2034        assert_eq!(
2035            commands.get(solve + 1).map(String::as_str),
2036            Some("atmosphere.density_altitude"),
2037            "{commands:?}"
2038        );
2039    }
2040
2041    #[test]
2042    fn density_altitude_reports_both_definitions_at_icao_standard_humid_air() {
2043        let out = call(json!({
2044            "api_version": 1,
2045            "command": "atmosphere.density_altitude",
2046            "request": {"atmosphere": {
2047                "altitude_m": 0.0,
2048                "temperature_k": 288.15,
2049                "pressure_pa": 101325.0,
2050                "relative_humidity": 0.5,
2051            }},
2052        }));
2053        assert_eq!(out["ok"], true, "{out}");
2054        assert_eq!(out["command"], "atmosphere.density_altitude");
2055        let result = &out["result"];
2056        let faa = result["density_altitude"]["faa_rule"]["m"]
2057            .as_f64()
2058            .unwrap();
2059        let matched = result["density_altitude"]["density_matched"]["m"]
2060            .as_f64()
2061            .unwrap();
2062        assert!(faa.abs() < 1e-9, "{result}");
2063        assert!((matched - 33.10).abs() < 0.2, "{result}");
2064        let ft = result["density_altitude"]["density_matched"]["ft"]
2065            .as_f64()
2066            .unwrap();
2067        assert!((ft * 0.3048 - matched).abs() < 1e-9, "{result}");
2068        assert!(result["air_density_kg_m3"].as_f64().unwrap() > 1.2);
2069        // Everything was supplied, so nothing was assumed.
2070        assert_eq!(result["assumptions"], json!([]), "{result}");
2071        assert_eq!(result["atmosphere"]["pressure_pa"], 101325.0);
2072    }
2073
2074    #[test]
2075    fn density_altitude_empty_atmosphere_resolves_to_announced_icao_defaults() {
2076        let out = call(json!({
2077            "api_version": 1,
2078            "command": "atmosphere.density_altitude",
2079            "request": {"atmosphere": {}},
2080        }));
2081        assert_eq!(out["ok"], true, "{out}");
2082        let assumptions = out["result"]["assumptions"].as_array().unwrap();
2083        assert!(!assumptions.is_empty(), "{out}");
2084        for notice in assumptions {
2085            assert!(
2086                notice["path"]
2087                    .as_str()
2088                    .unwrap_or_default()
2089                    .starts_with("$.atmosphere."),
2090                "{notice}"
2091            );
2092        }
2093    }
2094
2095    #[test]
2096    fn density_altitude_without_payload_is_invalid_request() {
2097        let out = call(json!({"api_version": 1, "command": "atmosphere.density_altitude"}));
2098        assert_eq!(out["ok"], false);
2099        assert_eq!(out["error"]["code"], "invalid_request");
2100    }
2101
2102    #[test]
2103    fn density_altitude_rejects_a_unit_suffixed_field_at_its_path() {
2104        // `temperature_c` is the likeliest mistake from a caller used to the imperial/metric
2105        // card commands; the SI schema must refuse it rather than silently default to 15 C,
2106        // and say where, exactly as `solve` would.
2107        let out = call(json!({
2108            "api_version": 1,
2109            "command": "atmosphere.density_altitude",
2110            "request": {"atmosphere": {"temperature_c": 30.0}},
2111        }));
2112        assert_eq!(out["ok"], false, "{out}");
2113        assert_eq!(out["error"]["code"], "command_failed");
2114        assert_eq!(out["error"]["details"]["error"]["code"], "unknown_field");
2115        assert_eq!(
2116            out["error"]["details"]["error"]["path"],
2117            "$.atmosphere.temperature_c"
2118        );
2119    }
2120
2121    #[test]
2122    fn density_altitude_refuses_celsius_sent_as_kelvin() {
2123        let out = call(json!({
2124            "api_version": 1,
2125            "command": "atmosphere.density_altitude",
2126            "request": {"atmosphere": {"temperature_k": 15.0}},
2127        }));
2128        assert_eq!(out["ok"], false, "{out}");
2129        assert_eq!(out["error"]["code"], "command_failed");
2130        assert_eq!(
2131            out["error"]["details"]["error"]["path"],
2132            "$.atmosphere.temperature_k"
2133        );
2134    }
2135
2136    #[test]
2137    fn density_altitude_humidity_as_percent_is_located_in_the_solve_envelope() {
2138        let out = call(json!({
2139            "api_version": 1,
2140            "command": "atmosphere.density_altitude",
2141            "request": {"atmosphere": {"relative_humidity": 50.0}},
2142        }));
2143        assert_eq!(out["ok"], false, "{out}");
2144        assert_eq!(out["error"]["code"], "command_failed");
2145        assert!(
2146            out["error"]["details"]
2147                .to_string()
2148                .contains("$.atmosphere.relative_humidity"),
2149            "{out}"
2150        );
2151    }
2152
2153    #[test]
2154    fn invalid_json_is_an_envelope_not_a_panic() {
2155        let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
2156        assert_eq!(out["ok"], false);
2157        assert_eq!(out["error"]["code"], "invalid_json");
2158    }
2159
2160    #[test]
2161    fn unknown_envelope_field_is_rejected() {
2162        let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
2163        assert_eq!(out["ok"], false);
2164        assert_eq!(out["error"]["code"], "invalid_json");
2165    }
2166
2167    #[test]
2168    fn unknown_command_lists_supported_ones() {
2169        // Was `card.pdf` until that became a real (pdf-gated) command; use a name no build
2170        // can ever dispatch so this test means the same thing in every feature set.
2171        let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
2172        assert_eq!(out["error"]["code"], "unknown_command");
2173        assert!(out["error"]["message"]
2174            .as_str()
2175            .unwrap()
2176            .contains("meta.capabilities"));
2177    }
2178
2179    #[test]
2180    fn wrong_api_version_is_rejected() {
2181        let out = call(json!({"api_version": 99, "command": "meta.version"}));
2182        assert_eq!(out["error"]["code"], "unsupported_api_version");
2183    }
2184
2185    #[test]
2186    fn oversize_request_is_a_resource_limit() {
2187        let big = format!(
2188            r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
2189            "x".repeat(MAX_REQUEST_BYTES)
2190        );
2191        let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
2192        assert_eq!(out["error"]["code"], "resource_limit");
2193    }
2194
2195    #[test]
2196    fn solve_without_payload_is_invalid_request() {
2197        let out = call(json!({"api_version": 1, "command": "solve"}));
2198        assert_eq!(out["error"]["code"], "invalid_request");
2199    }
2200
2201    #[test]
2202    fn profile_commands_without_payload_are_invalid_requests() {
2203        for command in ["profile.validate", "profile.normalize"] {
2204            let out = call(json!({"api_version": 1, "command": command}));
2205            assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
2206            assert!(
2207                out["error"]["message"]
2208                    .as_str()
2209                    .unwrap()
2210                    .contains("ProfileData"),
2211                "{command}: {out}"
2212            );
2213        }
2214    }
2215
2216    #[test]
2217    fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
2218        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2219        let commands: Vec<String> =
2220            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2221        assert!(commands.contains(&"profile.validate".to_string()));
2222        assert!(commands.contains(&"profile.normalize".to_string()));
2223        assert_eq!(
2224            commands.contains(&"profile.import_a7p".to_string()),
2225            cfg!(feature = "profile-import"),
2226            "profile.import_a7p must be listed exactly when compiled in"
2227        );
2228        assert_eq!(
2229            commands.contains(&"profile.export_a7p".to_string()),
2230            cfg!(feature = "profile-export"),
2231            "profile.export_a7p must be listed exactly when compiled in"
2232        );
2233        assert_eq!(
2234            commands.contains(&"bc5d.info".to_string()),
2235            cfg!(not(target_arch = "wasm32")),
2236            "bc5d.info must be listed exactly when the build has filesystem access"
2237        );
2238    }
2239
2240    #[cfg(not(target_arch = "wasm32"))]
2241    #[test]
2242    fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
2243        let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
2244        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2245
2246        let out = call(json!({
2247            "api_version": 1,
2248            "command": "bc5d.info",
2249            "request": {"path": "/nonexistent/bc5d_308.bin"}
2250        }));
2251        assert_eq!(out["error"]["code"], "command_failed", "{out}");
2252        assert!(
2253            out["error"]["message"]
2254                .as_str()
2255                .unwrap()
2256                .contains("not a usable BC5D table"),
2257            "{out}"
2258        );
2259    }
2260
2261    #[cfg(feature = "profile-import")]
2262    #[test]
2263    fn base64_decoder_round_trips_and_rejects_garbage() {
2264        // RFC 4648 test vectors.
2265        for (text, bytes) in [
2266            ("", &b""[..]),
2267            ("Zg==", b"f"),
2268            ("Zm8=", b"fo"),
2269            ("Zm9v", b"foo"),
2270            ("Zm9vYg==", b"foob"),
2271            ("Zm9vYmE=", b"fooba"),
2272            ("Zm9vYmFy", b"foobar"),
2273        ] {
2274            assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
2275        }
2276        assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
2277        assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
2278        assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
2279        assert!(
2280            decode_base64("Zm9v!").is_err(),
2281            "non-alphabet byte is rejected"
2282        );
2283    }
2284
2285    /// `card.pdf` must be listed exactly when the `pdf` feature is compiled in, and be an
2286    /// honest `unknown_command` otherwise — the same rule `profile.import_a7p` follows. The
2287    /// pdf-absent half of this only runs under `--no-default-features --features bridge`.
2288    #[test]
2289    fn capabilities_gates_card_pdf_on_the_pdf_feature() {
2290        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2291        let commands: Vec<String> =
2292            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2293        assert_eq!(
2294            commands.contains(&"card.pdf".to_string()),
2295            cfg!(feature = "pdf"),
2296            "card.pdf must be listed exactly when compiled in: {out}"
2297        );
2298        let features: Vec<String> =
2299            serde_json::from_value(out["result"]["features"].clone()).unwrap();
2300        assert_eq!(
2301            features.contains(&"pdf".to_string()),
2302            cfg!(feature = "pdf"),
2303            "the command list and the feature list must agree: {out}"
2304        );
2305    }
2306
2307    #[cfg(not(feature = "pdf"))]
2308    #[test]
2309    fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
2310        let out = call(json!({
2311            "api_version": 1,
2312            "command": "card.pdf",
2313            "request": {
2314                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2315                "mass": 175.0, "diameter": 0.308,
2316                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
2317            }
2318        }));
2319        assert_eq!(out["error"]["code"], "unknown_command", "{out}");
2320    }
2321
2322    /// The `pdf` presentation block must survive a build that cannot render it: an app
2323    /// stores one request per card and replays it against `card.range_table` too, so a
2324    /// pdf-less engine has to ACCEPT the field rather than reject it as unknown.
2325    #[test]
2326    fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
2327        let out = call(json!({
2328            "api_version": 1,
2329            "command": "card.range_table",
2330            "request": {
2331                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2332                "mass": 175.0, "diameter": 0.308,
2333                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
2334                "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
2335            }
2336        }));
2337        assert_eq!(out["ok"], true, "{out}");
2338        assert_eq!(out["result"]["kind"], "range_table", "{out}");
2339    }
2340
2341    #[cfg(feature = "pdf")]
2342    #[test]
2343    fn card_pdf_without_payload_is_invalid_request() {
2344        let out = call(json!({"api_version": 1, "command": "card.pdf"}));
2345        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2346        assert!(
2347            out["error"]["message"]
2348                .as_str()
2349                .unwrap()
2350                .contains("card v1 document"),
2351            "{out}"
2352        );
2353    }
2354
2355    /// The output cap's boundary, both sides. Generating a genuinely over-cap dope card
2356    /// would take tens of thousands of rows, so the predicate is tested directly — see
2357    /// `pdf_over_cap_error`'s own comment.
2358    #[cfg(feature = "pdf")]
2359    #[test]
2360    fn pdf_output_cap_refuses_only_over_the_limit() {
2361        assert!(pdf_over_cap_error(0, 0, 0).is_none());
2362        assert!(
2363            pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
2364            "a document exactly at the cap fits"
2365        );
2366        let envelope: Value =
2367            serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
2368                .unwrap();
2369        assert_eq!(envelope["ok"], false);
2370        assert_eq!(envelope["error"]["code"], "resource_limit");
2371        let message = envelope["error"]["message"].as_str().unwrap();
2372        assert!(message.contains("dope card"), "{envelope}");
2373        // What is true of the document, not advice about controls a saved card lacks.
2374        assert!(message.contains("6 rows"), "{envelope}");
2375        assert!(message.contains("1 pages"), "{envelope}");
2376        for absent in ["coarsen", "shorten"] {
2377            assert!(!message.contains(absent), "{envelope}");
2378        }
2379    }
2380
2381    #[cfg(feature = "pdf")]
2382    #[test]
2383    fn base64_encoder_matches_the_rfc_4648_vectors() {
2384        for (bytes, text) in [
2385            (&b""[..], ""),
2386            (b"f", "Zg=="),
2387            (b"fo", "Zm8="),
2388            (b"foo", "Zm9v"),
2389            (b"foob", "Zm9vYg=="),
2390            (b"fooba", "Zm9vYmE="),
2391            (b"foobar", "Zm9vYmFy"),
2392        ] {
2393            assert_eq!(encode_base64(bytes), text, "{bytes:?}");
2394        }
2395        // Full-byte-range coverage: the >> 18 / >> 12 / >> 6 masking must not sign- or
2396        // width-mangle a high byte, which is most of a PDF's content.
2397        assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
2398        assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
2399        assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
2400    }
2401
2402    /// The encoder and the (profile-import) decoder must be inverses — the property that
2403    /// makes `pdf_base64` a lossless transport for a binary document.
2404    #[cfg(all(feature = "pdf", feature = "profile-import"))]
2405    #[test]
2406    fn base64_encode_decode_round_trips_arbitrary_bytes() {
2407        for len in 0..=32usize {
2408            let bytes: Vec<u8> = (0..len)
2409                .map(|i| (i as u8).wrapping_mul(37).wrapping_add(11))
2410                .collect();
2411            let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
2412            assert_eq!(decoded, bytes, "len {len}");
2413        }
2414    }
2415
2416    #[test]
2417    fn solve_with_bad_schema_carries_typed_details() {
2418        let out = call(json!({
2419            "api_version": 1,
2420            "command": "solve",
2421            "request": {"schema_version": 1, "unknown_field": true}
2422        }));
2423        assert_eq!(out["error"]["code"], "command_failed");
2424        // The solve-json envelope rides along losslessly.
2425        assert_eq!(out["error"]["details"]["status"], "error");
2426    }
2427
2428    // -----------------------------------------------------------------------
2429    // reticle.* — MBA-1558
2430    // -----------------------------------------------------------------------
2431
2432    /// A 0.5 mil grid out to 5 mil, as a full description rather than a generator, so a
2433    /// test can assert against marks it wrote itself.
2434    fn hash_reticle() -> Value {
2435        json!({
2436            "name": "test cross",
2437            "focal_plane": "ffp",
2438            "reference_magnification": 1.0,
2439            "marks": [
2440                {"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
2441                {"down_mil": 2.0, "right_mil": 0.0, "kind": "hash", "label": "2 mil"},
2442                {"down_mil": 4.0, "right_mil": 0.0, "kind": "hash"},
2443                {"down_mil": 0.0, "right_mil": 2.0, "kind": "hash"},
2444            ],
2445        })
2446    }
2447
2448    #[test]
2449    fn capabilities_lists_the_reticle_family() {
2450        // The whole point of MBA-1558: an app feature-detects through this list, so a
2451        // command that dispatches but is not listed is a command no app will call.
2452        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2453        let commands: Vec<String> =
2454            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2455        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2456            assert!(
2457                commands.contains(&command.to_string()),
2458                "{command} dispatches but meta.capabilities does not list it"
2459            );
2460        }
2461    }
2462
2463    #[test]
2464    fn hold_reports_the_nearest_mark_and_its_label() {
2465        let out = call(json!({
2466            "api_version": 1,
2467            "command": "reticle.hold",
2468            "request": {
2469                "reticle": hash_reticle(),
2470                "drop_mil": 2.1,
2471                "wind_mil": 0.0,
2472                "magnification": 10.0,
2473            },
2474        }));
2475        assert_eq!(out["ok"], true, "{out}");
2476        // The hold coordinates are the inputs: this command places a solution, it does
2477        // not compute one.
2478        assert_eq!(out["result"]["hold"]["down_mil"], 2.1);
2479        assert_eq!(out["result"]["hold"]["right_mil"], 0.0);
2480        assert_eq!(out["result"]["hold"]["off_reticle"], false);
2481        assert_eq!(out["result"]["nearest_mark"]["index"], 1);
2482        assert_eq!(out["result"]["nearest_mark"]["label"], "2 mil");
2483        assert_eq!(out["result"]["nearest_mark"]["kind"], "hash");
2484    }
2485
2486    #[test]
2487    fn an_sfp_reticle_moves_its_marks_and_the_hold_follows() {
2488        // The reason `magnification` is required on every plane. The same solution finds a
2489        // DIFFERENT mark on an SFP optic depending on the zoom ring, and an app that
2490        // ignored this would draw a confident hold on the wrong hash.
2491        let mut sfp = hash_reticle();
2492        sfp["focal_plane"] = json!("sfp");
2493        sfp["reference_magnification"] = json!(10.0);
2494
2495        let at_reference = call(json!({
2496            "api_version": 1, "command": "reticle.hold",
2497            "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 10.0},
2498        }));
2499        assert_eq!(at_reference["result"]["hold"]["mark_scale"], 1.0);
2500        assert_eq!(at_reference["result"]["nearest_mark"]["index"], 1);
2501        assert_eq!(
2502            at_reference["result"]["nearest_mark"]["true_angular"]["down_mil"],
2503            2.0
2504        );
2505
2506        // Halve the magnification and every subtension doubles, so the 2 mil hash now
2507        // sits at 4 mil true and the 4 mil hash at 8.
2508        let zoomed_out = call(json!({
2509            "api_version": 1, "command": "reticle.hold",
2510            "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 5.0},
2511        }));
2512        assert_eq!(zoomed_out["result"]["hold"]["mark_scale"], 2.0);
2513        assert_eq!(
2514            zoomed_out["result"]["nearest_mark"]["true_angular"]["down_mil"], 0.0,
2515            "at 5x the nearest mark to a 2 mil hold is now CENTER, not the 2 mil hash"
2516        );
2517        assert_eq!(zoomed_out["result"]["nearest_mark"]["index"], 0);
2518        // And the nominal position is unchanged, which is why both are reported.
2519        assert_eq!(
2520            zoomed_out["result"]["nearest_mark"]["nominal"]["down_mil"],
2521            0.0
2522        );
2523
2524        // A mark whose coordinates are NOT zero, because the assertions above cannot tell
2525        // a scaled position from an unscaled one: 0.0 * 2.0 is 0.0. Proved by mutation --
2526        // dropping the scale from `true_angular` left every case above green.
2527        //
2528        // At 5x on a 10x-reference SFP reticle the 2 mil hash sits at 4 mil true, so a
2529        // 4 mil hold lands exactly on it. The two positions differ by the scale, which is
2530        // the whole reason both are reported.
2531        let on_the_scaled_hash = call(json!({
2532            "api_version": 1, "command": "reticle.hold",
2533            "request": {"reticle": sfp, "drop_mil": 4.0, "magnification": 5.0},
2534        }));
2535        assert_eq!(
2536            on_the_scaled_hash["result"]["nearest_mark"]["nominal"]["down_mil"], 2.0,
2537            "as authored, the mark is at 2 mil"
2538        );
2539        assert_eq!(
2540            on_the_scaled_hash["result"]["nearest_mark"]["true_angular"]["down_mil"], 4.0,
2541            "at 5x it subtends 4 mil, which is what the hold is measured against"
2542        );
2543        assert_eq!(
2544            on_the_scaled_hash["result"]["hold"]["nearest_mark_distance_mil"], 0.0,
2545            "the hold lands exactly on it"
2546        );
2547    }
2548
2549    #[test]
2550    fn a_hold_off_the_marked_part_says_so() {
2551        // The field a UI must not ignore. 40 mil is far outside a reticle whose lowest
2552        // mark is at 4.
2553        let out = call(json!({
2554            "api_version": 1, "command": "reticle.hold",
2555            "request": {"reticle": hash_reticle(), "drop_mil": 40.0, "magnification": 10.0},
2556        }));
2557        assert_eq!(out["ok"], true);
2558        assert_eq!(out["result"]["hold"]["off_reticle"], true);
2559    }
2560
2561    #[test]
2562    fn describe_builds_from_a_generator_and_reports_the_plane() {
2563        let out = call(json!({
2564            "api_version": 1, "command": "reticle.describe",
2565            "request": {"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0}},
2566        }));
2567        assert_eq!(out["ok"], true, "{out}");
2568        assert_eq!(out["result"]["focal_plane"], "FFP");
2569        assert_eq!(out["result"]["magnification_dependent"], false);
2570        assert!(out["result"]["mark_count"].as_u64().unwrap() > 1);
2571        // No magnification asked for, so no scaled block — not an empty one.
2572        assert!(out["result"]["scaled"].is_null());
2573    }
2574
2575    #[test]
2576    fn a_generated_reticle_can_be_made_sfp() {
2577        // Every generator returns FFP and the library's answer is "set those two fields
2578        // afterwards". On the bridge there is no afterwards, so the overrides travel with
2579        // the request — without them the generator path could only ever make FFP.
2580        let out = call(json!({
2581            "api_version": 1, "command": "reticle.describe",
2582            "request": {
2583                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2584                "focal_plane": "sfp",
2585                "reference_magnification": 12.0,
2586                "magnification": 6.0,
2587            },
2588        }));
2589        assert_eq!(out["ok"], true, "{out}");
2590        assert_eq!(out["result"]["focal_plane"], "SFP");
2591        assert_eq!(out["result"]["magnification_dependent"], true);
2592        assert_eq!(out["result"]["scaled"]["mark_scale"], 2.0);
2593    }
2594
2595    #[test]
2596    fn a_bdc_ladder_carries_its_range_labels() {
2597        let out = call(json!({
2598            "api_version": 1, "command": "reticle.describe",
2599            "request": {"generator": {"kind": "bdc", "drops": [[300.0, 1.2], [400.0, 2.4]]}},
2600        }));
2601        assert_eq!(out["ok"], true, "{out}");
2602        let marks = out["result"]["reticle"]["marks"]
2603            .as_array()
2604            .unwrap()
2605            .clone();
2606        let labels: Vec<&str> = marks.iter().filter_map(|m| m["label"].as_str()).collect();
2607        // The generator labels in METRES, which is the wire's unit and not the shooter's.
2608        assert_eq!(labels, vec!["300 m", "400 m"]);
2609    }
2610
2611    #[test]
2612    fn naming_a_reticle_twice_is_refused_rather_than_resolved() {
2613        // Supplying both is a caller bug. Picking a winner would hide it.
2614        let out = call(json!({
2615            "api_version": 1, "command": "reticle.hold",
2616            "request": {
2617                "reticle": hash_reticle(),
2618                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2619                "drop_mil": 1.0,
2620                "magnification": 10.0,
2621            },
2622        }));
2623        assert_eq!(out["ok"], false);
2624        assert_eq!(out["error"]["code"], "invalid_request");
2625        assert!(out["error"]["message"]
2626            .as_str()
2627            .unwrap()
2628            .contains("exactly one"));
2629    }
2630
2631    #[test]
2632    fn a_catalog_id_is_the_third_way_to_name_a_reticle() {
2633        let out = call(json!({
2634            "api_version": 1, "command": "reticle.hold",
2635            "request": {"catalog": "mil-dot", "drop_mil": 3.0, "magnification": 10.0},
2636        }));
2637        assert_eq!(out["ok"], true, "{out}");
2638        // A mil-dot has a dot exactly 3 mil down, so the hold lands on it.
2639        assert_eq!(out["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2640        assert_eq!(out["result"]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2641        assert_eq!(out["result"]["nearest_mark"]["label"], "3 mil down");
2642    }
2643
2644    #[test]
2645    fn an_unknown_catalog_id_is_refused_rather_than_substituted() {
2646        // Handing back a different reticle would show a shooter holds for glass they are
2647        // not looking through.
2648        let out = call(json!({
2649            "api_version": 1, "command": "reticle.hold",
2650            "request": {"catalog": "tremor-9000", "drop_mil": 3.0, "magnification": 10.0},
2651        }));
2652        assert_eq!(out["ok"], false);
2653        assert_eq!(out["error"]["code"], "invalid_request");
2654        assert!(out["error"]["message"]
2655            .as_str()
2656            .unwrap()
2657            .contains("reticle.catalog"));
2658    }
2659
2660    #[test]
2661    fn naming_a_reticle_three_ways_is_refused_too() {
2662        // The count-based check exists so a third source could not reintroduce precedence.
2663        let out = call(json!({
2664            "api_version": 1, "command": "reticle.describe",
2665            "request": {
2666                "catalog": "mil-dot",
2667                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2668            },
2669        }));
2670        assert_eq!(out["ok"], false);
2671        assert_eq!(out["error"]["code"], "invalid_request");
2672    }
2673
2674    #[test]
2675    fn the_catalog_lists_what_the_other_commands_will_accept() {
2676        // The contract an app picker is built on: every listed id must resolve, or the
2677        // picker offers something the engine refuses.
2678        let out = call(json!({"api_version": 1, "command": "reticle.catalog"}));
2679        assert_eq!(out["ok"], true, "{out}");
2680        let reticles = out["result"]["reticles"].as_array().unwrap().clone();
2681        assert!(!reticles.is_empty());
2682        for entry in reticles {
2683            let id = entry["id"].as_str().unwrap();
2684            // Provenance travels to the wire on purpose — see reticle_catalog's header.
2685            assert!(
2686                !entry["source"].as_str().unwrap().is_empty(),
2687                "{id} has no source"
2688            );
2689            assert!(
2690                !entry["notes"].as_str().unwrap().is_empty(),
2691                "{id} has no notes"
2692            );
2693            assert!(
2694                entry["mark_count"].as_u64().unwrap() > 0,
2695                "{id} has no marks"
2696            );
2697
2698            let held = call(json!({
2699                "api_version": 1, "command": "reticle.hold",
2700                "request": {"catalog": id, "drop_mil": 1.0, "magnification": 10.0},
2701            }));
2702            assert_eq!(held["ok"], true, "listed id {id} does not resolve: {held}");
2703        }
2704    }
2705
2706    #[test]
2707    fn a_batch_of_holds_comes_back_in_request_order() {
2708        // The column this exists for: one call per table, not one per row.
2709        let out = call(json!({
2710            "api_version": 1, "command": "reticle.holds",
2711            "request": {
2712                "catalog": "mil-dot",
2713                "magnification": 10.0,
2714                "holds": [
2715                    {"drop_mil": 1.0},
2716                    {"drop_mil": 3.0, "wind_mil": 0.0},
2717                    {"drop_mil": 2.0},
2718                ],
2719            },
2720        }));
2721        assert_eq!(out["ok"], true, "{out}");
2722        let holds = out["holds"].clone();
2723        assert!(holds.is_null(), "holds live under result, not the envelope");
2724        let holds = out["result"]["holds"].as_array().unwrap().clone();
2725        assert_eq!(holds.len(), 3);
2726        // Order is the caller's, NOT sorted — a table row must line up with its hold.
2727        assert_eq!(holds[0]["nearest_mark"]["nominal"]["down_mil"], 1.0);
2728        assert_eq!(holds[1]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2729        assert_eq!(holds[2]["nearest_mark"]["nominal"]["down_mil"], 2.0);
2730        // mark_scale is hoisted: one reticle at one magnification has exactly one.
2731        assert_eq!(out["result"]["mark_scale"], 1.0);
2732        assert!(holds[0]["hold"]["mark_scale"].is_null());
2733    }
2734
2735    #[test]
2736    fn a_batch_agrees_with_the_single_hold_command() {
2737        // The batch must not be a second implementation. If these ever disagree, one of
2738        // them is doing its own mark search.
2739        let single = call(json!({
2740            "api_version": 1, "command": "reticle.hold",
2741            "request": {"catalog": "mil-dot", "drop_mil": 2.4, "wind_mil": 0.7, "magnification": 10.0},
2742        }));
2743        let batch = call(json!({
2744            "api_version": 1, "command": "reticle.holds",
2745            "request": {
2746                "catalog": "mil-dot", "magnification": 10.0,
2747                "holds": [{"drop_mil": 2.4, "wind_mil": 0.7}],
2748            },
2749        }));
2750        assert_eq!(single["ok"], true);
2751        assert_eq!(batch["ok"], true);
2752        assert_eq!(
2753            single["result"]["nearest_mark"],
2754            batch["result"]["holds"][0]["nearest_mark"]
2755        );
2756        assert_eq!(
2757            single["result"]["hold"]["off_reticle"],
2758            batch["result"]["holds"][0]["hold"]["off_reticle"]
2759        );
2760        assert_eq!(
2761            single["result"]["hold"]["nearest_mark_distance_mil"],
2762            batch["result"]["holds"][0]["hold"]["nearest_mark_distance_mil"]
2763        );
2764    }
2765
2766    #[test]
2767    fn a_malformed_hold_entry_refuses_the_whole_batch() {
2768        // A column that silently skips a row is worse than one that does not draw, so the
2769        // request fails rather than returning a list with a hole in it.
2770        //
2771        // Note WHICH failure this is. The per-row arm in run_reticle_holds is defensive
2772        // and unreachable from the wire: `hold_point_in_reticle` rejects a bad reticle and
2773        // a non-positive magnification, both of which are settled before the loop, and its
2774        // only remaining error is a non-finite hold — which JSON cannot deliver.
2775        // serde_json refuses an out-of-range literal outright ("number out of range" for
2776        // 1e999) and `json!(f64::INFINITY)` encodes as null, so a non-finite drop is
2777        // stopped by the transport. `NonFiniteHold` is reachable from the Rust API and not
2778        // from here; an earlier version of this test asserted that reason and failed,
2779        // which is how the unreachability was found.
2780        for bad in [
2781            json!(null),
2782            json!("3.0"),
2783            json!({"drop_mil": 1.0, "nonsense": 2}),
2784        ] {
2785            let out = call(json!({
2786                "api_version": 1, "command": "reticle.holds",
2787                "request": {
2788                    "catalog": "mil-dot", "magnification": 10.0,
2789                    "holds": [{"drop_mil": 1.0}, bad],
2790                },
2791            }));
2792            assert_eq!(out["ok"], false, "accepted a malformed entry: {out}");
2793            assert_eq!(out["error"]["code"], "invalid_request");
2794        }
2795    }
2796
2797    #[test]
2798    fn a_bad_magnification_fails_the_batch_before_any_row() {
2799        // The reachable whole-batch failure: settled once, up front, not per row.
2800        let out = call(json!({
2801            "api_version": 1, "command": "reticle.holds",
2802            "request": {
2803                "catalog": "mil-dot", "magnification": 0.0,
2804                "holds": [{"drop_mil": 1.0}, {"drop_mil": 2.0}],
2805            },
2806        }));
2807        assert_eq!(out["ok"], false);
2808        assert_eq!(
2809            out["error"]["details"]["reason"],
2810            "non_positive_magnification"
2811        );
2812    }
2813
2814    #[test]
2815    fn an_empty_batch_is_an_empty_list_not_an_error() {
2816        // A trajectory with no samples is a legitimate thing to ask about.
2817        let out = call(json!({
2818            "api_version": 1, "command": "reticle.holds",
2819            "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": []},
2820        }));
2821        assert_eq!(out["ok"], true, "{out}");
2822        assert_eq!(out["result"]["holds"].as_array().unwrap().len(), 0);
2823    }
2824
2825    #[test]
2826    fn too_many_holds_is_a_resource_limit() {
2827        let holds: Vec<Value> = (0..MAX_RETICLE_HOLDS + 1)
2828            .map(|i| json!({"drop_mil": (i % 5) as f64}))
2829            .collect();
2830        let out = call(json!({
2831            "api_version": 1, "command": "reticle.holds",
2832            "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": holds},
2833        }));
2834        assert_eq!(out["ok"], false);
2835        assert_eq!(out["error"]["code"], "resource_limit");
2836    }
2837
2838    #[test]
2839    fn capabilities_lists_the_catalog_command() {
2840        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2841        let commands: Vec<String> =
2842            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2843        assert!(commands.contains(&"reticle.catalog".to_string()));
2844        assert!(commands.contains(&"reticle.holds".to_string()));
2845    }
2846
2847    #[test]
2848    fn naming_no_reticle_at_all_is_refused() {
2849        let out = call(json!({
2850            "api_version": 1, "command": "reticle.hold",
2851            "request": {"drop_mil": 1.0, "magnification": 10.0},
2852        }));
2853        assert_eq!(out["ok"], false);
2854        assert_eq!(out["error"]["code"], "invalid_request");
2855    }
2856
2857    #[test]
2858    fn plane_overrides_beside_a_full_reticle_are_refused() {
2859        // A supplied description already carries its own plane; silently overriding it
2860        // would let a request contradict the document it sent.
2861        let out = call(json!({
2862            "api_version": 1, "command": "reticle.describe",
2863            "request": {"reticle": hash_reticle(), "focal_plane": "sfp"},
2864        }));
2865        assert_eq!(out["ok"], false);
2866        assert_eq!(out["error"]["code"], "invalid_request");
2867    }
2868
2869    #[test]
2870    fn a_reticle_error_carries_a_stable_reason_and_its_numbers() {
2871        // The `true.dsf` convention: code stays command_failed, the machine-readable
2872        // reason is in details, and the offending value rides along so a caller can point
2873        // at the input without re-parsing the sentence.
2874        let out = call(json!({
2875            "api_version": 1, "command": "reticle.hold",
2876            "request": {"reticle": hash_reticle(), "drop_mil": 1.0, "magnification": 0.0},
2877        }));
2878        assert_eq!(out["ok"], false);
2879        assert_eq!(out["error"]["code"], "command_failed");
2880        assert_eq!(
2881            out["error"]["details"]["reason"],
2882            "non_positive_magnification"
2883        );
2884        assert_eq!(out["error"]["details"]["magnification"], 0.0);
2885        assert_eq!(out["error"]["details"]["command"], "reticle.hold");
2886    }
2887
2888    #[test]
2889    fn a_reticle_with_no_marks_is_refused_by_describe_too() {
2890        // Validation is not deferred to the hold: handing back an unusable description as
2891        // though it were usable is the failure this guards.
2892        let mut empty = hash_reticle();
2893        empty["marks"] = json!([]);
2894        let out = call(json!({
2895            "api_version": 1, "command": "reticle.describe",
2896            "request": {"reticle": empty},
2897        }));
2898        assert_eq!(out["ok"], false);
2899        assert_eq!(out["error"]["details"]["reason"], "no_marks");
2900    }
2901
2902    #[test]
2903    fn an_unreadable_generator_parameter_names_the_parameter() {
2904        let out = call(json!({
2905            "api_version": 1, "command": "reticle.describe",
2906            "request": {"generator": {"kind": "mil_grid", "spacing_mil": 0.0, "extent_mil": 5.0}},
2907        }));
2908        assert_eq!(out["ok"], false);
2909        assert_eq!(
2910            out["error"]["details"]["reason"],
2911            "invalid_generator_parameter"
2912        );
2913        assert_eq!(out["error"]["details"]["parameter"], "spacing");
2914    }
2915
2916    #[test]
2917    fn importing_a_ventum_document_returns_the_reticle_and_its_report() {
2918        // Bero's own MBR dot-tree spec, the reticle that produced MBA-1440 — three rows
2919        // each stamped by a mirrored repeat, expanding to 78 holdable marks. Using the
2920        // real thing rather than a toy is deliberate: an earlier version of this test sent
2921        // a document with an invented key, and because it accepted either outcome it
2922        // passed while reaching nothing.
2923        let document = r#"{"name":"MBR","plane":"ffp","unit":"mil","spec":[
2924            {"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},
2925            {"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},
2926            {"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]}"#;
2927
2928        let out = call(json!({
2929            "api_version": 1, "command": "reticle.import",
2930            "request": {"format": "ventum", "document": document},
2931        }));
2932        assert_eq!(out["ok"], true, "{out}");
2933        assert_eq!(out["result"]["format"], "ventum");
2934        assert_eq!(out["result"]["mark_count"], 78);
2935        assert_eq!(out["result"]["reticle"]["name"], "MBR");
2936        assert_eq!(out["result"]["reticle"]["focal_plane"], "ffp");
2937        // Nothing was dropped, and the report says so rather than being absent.
2938        assert_eq!(out["result"]["report"]["dropped_elements"], 0);
2939        assert_eq!(out["result"]["report"]["arcs_unresolved"], 0);
2940
2941        // And the imported reticle is immediately usable by the command it exists for.
2942        let held = call(json!({
2943            "api_version": 1, "command": "reticle.hold",
2944            "request": {
2945                "reticle": out["result"]["reticle"],
2946                "drop_mil": 8.0,
2947                "wind_mil": 2.0,
2948                "magnification": 10.0,
2949            },
2950        }));
2951        assert_eq!(held["ok"], true, "{held}");
2952        assert_eq!(held["result"]["hold"]["off_reticle"], false);
2953        assert_eq!(held["result"]["nearest_mark"]["nominal"]["down_mil"], 8.0);
2954        assert_eq!(held["result"]["nearest_mark"]["nominal"]["right_mil"], 2.0);
2955        assert_eq!(held["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2956    }
2957
2958    #[test]
2959    fn a_document_that_is_not_the_named_format_fails_as_a_command_failure() {
2960        let out = call(json!({
2961            "api_version": 1, "command": "reticle.import",
2962            "request": {"format": "reticle_xml", "document": "{\"this\": \"is json\"}"},
2963        }));
2964        assert_eq!(out["ok"], false);
2965        assert_eq!(out["error"]["code"], "command_failed");
2966    }
2967
2968    #[test]
2969    fn an_oversize_reticle_document_is_a_resource_limit_not_a_parse_failure() {
2970        // Named as the document's limit rather than the envelope's, and refused before
2971        // the parse rather than during it.
2972        let out = call(json!({
2973            "api_version": 1, "command": "reticle.import",
2974            "request": {
2975                "format": "ventum",
2976                "document": "x".repeat(MAX_RETICLE_DOCUMENT_BYTES + 1),
2977            },
2978        }));
2979        assert_eq!(out["ok"], false);
2980        assert_eq!(out["error"]["code"], "resource_limit");
2981        assert!(out["error"]["message"]
2982            .as_str()
2983            .unwrap()
2984            .contains("reticle document"));
2985    }
2986
2987    #[test]
2988    fn every_reticle_command_rejects_an_unknown_request_field() {
2989        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2990            let out = call(json!({
2991                "api_version": 1,
2992                "command": command,
2993                "request": {"nonsense": 1},
2994            }));
2995            assert_eq!(out["ok"], false, "{command} accepted an unknown field");
2996            assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2997        }
2998    }
2999
3000    #[test]
3001    fn every_reticle_command_rejects_a_missing_payload() {
3002        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
3003            let out = call(json!({"api_version": 1, "command": command}));
3004            assert_eq!(out["ok"], false, "{command} accepted a null payload");
3005            assert_eq!(out["error"]["code"], "invalid_request", "{command}");
3006        }
3007    }
3008
3009    /// The bridge's own end-to-end proof: a ProfileData in, a real `.a7p` out, and the
3010    /// file it hands back is one `profile.import_a7p` accepts. Round-trip fidelity of the
3011    /// individual fields is the encoder's own test; what is proved here is that the two
3012    /// bridge commands are actually inverse over the wire, base64 and all.
3013    #[cfg(all(feature = "profile-export", feature = "profile-import"))]
3014    #[test]
3015    fn export_a7p_produces_a_file_import_a7p_reads_back() {
3016        let profile = json!({
3017            "name": "bridge-export",
3018            "velocity": 792.0,
3019            "bc": 0.381,
3020            "mass": 19.439673,
3021            "diameter": 8.5852,
3022            "drag_model": "G7",
3023            "twist_rate": 254.0,
3024            "sight_height": 90.0,
3025            "zero_distance": 100.0,
3026            "units": "metric",
3027            "temperature": 15.0,
3028            "pressure": 1000.0,
3029            "humidity": 50.0,
3030            "altitude": 0.0,
3031            "bullet_name": "300GR OTM",
3032            "twist_right": false,
3033            "bullet_length": 45.72,
3034            "elevation_cf": 0.97,
3035            "dsf_points": [{"mach": 0.9, "dsf": 1.04}]
3036        });
3037
3038        let out = call(json!({
3039            "api_version": 1, "command": "profile.export_a7p", "request": profile
3040        }));
3041        assert_eq!(out["ok"], true, "{out}");
3042        let result = &out["result"];
3043        assert!(result["byte_length"].as_u64().unwrap() > 32);
3044
3045        // The honesty contract: the two fields this profile set that .a7p cannot take
3046        // are named, and nothing that DID travel is named alongside them.
3047        let dropped: Vec<String> =
3048            serde_json::from_value(result["dropped_fields"].clone()).unwrap();
3049        assert!(dropped.contains(&"elevation_cf".to_string()), "{out}");
3050        assert!(dropped.contains(&"dsf_points".to_string()), "{out}");
3051        assert!(!dropped.contains(&"velocity".to_string()), "{out}");
3052        // ...and the full list names the unsupported fields this profile left empty too,
3053        // so a caller can tell "had none" from "lost it".
3054        let not_carried = result["not_carried"].as_array().unwrap();
3055        let reticle = not_carried
3056            .iter()
3057            .find(|e| e["field"] == "reticle")
3058            .expect("reticle is listed even though this profile has none");
3059        assert_eq!(reticle["populated"], false, "{out}");
3060        assert!(reticle["reason"].as_str().unwrap().len() > 10, "{out}");
3061
3062        let back = call(json!({
3063            "api_version": 1,
3064            "command": "profile.import_a7p",
3065            "request": { "a7p_base64": result["a7p_base64"].clone(), "strict": true }
3066        }));
3067        // `strict` refuses on an envelope mismatch, so this passing is also the proof
3068        // that the MD5 prefix the encoder wrote is correct.
3069        assert_eq!(back["ok"], true, "{back}");
3070        let reimported = &back["result"]["profile"];
3071        assert_eq!(reimported["name"], "bridge-export", "{back}");
3072        assert_eq!(reimported["drag_model"], "G7", "{back}");
3073        assert_eq!(reimported["twist_right"], false, "{back}");
3074    }
3075
3076    /// A drag model the format cannot name is refused with the structured `reason` the
3077    /// `true.*` family established, not written out as something else.
3078    #[cfg(feature = "profile-export")]
3079    #[test]
3080    fn export_a7p_refuses_an_unrepresentable_drag_model_with_a_reason() {
3081        let out = call(json!({
3082            "api_version": 1,
3083            "command": "profile.export_a7p",
3084            // Complete in every other respect, so the refusal can only be about the
3085            // drag model: the format also REQUIRES a bullet length, a zero distance
3086            // and a sight height, and those refusals are checked in the encoder's
3087            // own tests.
3088            "request": {
3089                "name": "g5-load", "velocity": 792.0, "bc": 0.3, "mass": 19.4,
3090                "diameter": 8.5852, "drag_model": "G5", "units": "metric",
3091                "bullet_length": 45.72, "zero_distance": 100.0, "sight_height": 90.0,
3092                "temperature": 15.0, "pressure": 1000.0, "humidity": 50.0
3093            }
3094        }));
3095        assert_eq!(out["error"]["code"], "command_failed", "{out}");
3096        assert_eq!(out["error"]["details"]["reason"], "drag_model", "{out}");
3097        assert_eq!(out["error"]["details"]["drag_model"], "G5", "{out}");
3098    }
3099
3100    #[cfg(feature = "profile-export")]
3101    #[test]
3102    fn export_a7p_without_a_payload_is_an_invalid_request() {
3103        let out = call(json!({"api_version": 1, "command": "profile.export_a7p"}));
3104        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
3105        assert!(
3106            out["error"]["message"]
3107                .as_str()
3108                .unwrap()
3109                .contains("ProfileData"),
3110            "{out}"
3111        );
3112    }
3113}