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