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