//! Step-canvas serving — the D-255(c) tagged-envelope wire migration (T-1181). //! //! Serves one data canvas per D-255(a) ladder rung (Global opener + five fixed //! metre rungs) as a dedicated `StepCanvasRequest`/`StepCanvasResponse` pair, //! extending the proven `star_map`/`city_names`/`browse` `ShapeProbe` pattern //! in `bridge/mod.rs` (D-225's deferred tagged-envelope migration, now //! executed). This is a NEW carrier — the legacy `district_window` field on //! `AtlasLayerResponse` (`layer_proxy.rs`) survives byte-unchanged and is not //! touched by this module. //! //! **Per-cell CLASSIFICATION is exclusively via the canonical D-256(a) family** //! (`derive_at_metres` / `derive_orbital_at_metres`) — step canvases never //! read the survey raster for terrain judgments (D-256(e) fencing). The one //! field outside that family is `settlement_id` (see //! [`settlement_ids_for_canvas`]): it joins against //! `BodyWorldState.placements`, a real placed-entity dataset (settlement //! anchor points resolved by Layer-3 attractor matching), never the coarse //! `SurveyCellPos` planning raster D-256(e) fences out — presence/absence of //! a settlement is not a terrain classification judgment the derive core //! makes, so this is a legitimate second input, not a fencing violation. //! //! **Module shape**, mirroring `layer_proxy.rs`'s `DeriveWindow` pattern //! one-for-one: //! - [`StepCanvasRung`] — the six-level D-255(a) ladder vocabulary. //! - [`StepCanvasRequest`] / [`StepCanvasResponse`] — the tagged envelope. //! - [`build_step_canvas`] — the row-chunked parallel derive core (the same //! `derive_window_cell`-style per-cell call, reused for every rung). //! - [`GlobalTierCache`] — the D-203-shaped resident rung-0 tier (always-keep). //! - [`StepCanvasCache`] — the TTL(detail, time, distance)-evicted tier for //! rungs 1-5, with the D-227 amendment (1) sim-state TTL split. //! - [`serve_step_canvas_request`] — the D-225 poll/cache/enqueue serving loop. use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::body_params_reader::BodyParamsReader; use crate::atlas::body_world_state::{RiverNetwork, SimTick}; use crate::atlas::district_profile::{ derive_at_metres, derive_orbital_at_metres, pixel_to_world_m, BodyParams, ClimateConstants, DistrictProfile, }; use crate::atlas::features::TerrainAnalysis; use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer_proxy::{CourseTerminus, RiverCourse}; use crate::atlas::river_course::{self, InventedCourse}; use crate::atlas::scale; use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError}; use crate::bridge::ConnectionId; use crate::seed::SeedChain; // --------------------------------------------------------------------------- // StepCanvasRung — the D-255(a) six-level ladder vocabulary // --------------------------------------------------------------------------- /// The D-255(a) stepped Atlas ladder — six levels: the Global map opener /// (rung 0, variable-extent, always-kept) plus five FIXED D-243 metre rungs /// (Region through Chunk, viewport-sized, evictable). Distinct from /// `layer_proxy::WindowGranularity` (which serves the legacy `district_window` /// carrier's District/Quarter/Region-only vocabulary) — this is the FULL /// D-255 ladder the new tagged envelope serves; the two enums are /// intentionally independent (D-255(c): the legacy carrier survives /// byte-unchanged, so its granularity vocabulary is not touched or extended /// by this ticket). /// /// **Unknown → rejected, never trusted from the wire** — same posture as /// every other wire-decoded enum in `layer_proxy.rs` (`rmp_serde` rejects an /// unrecognized variant name at decode time; there is no raw-integer /// encoding here to widen against, unlike the legacy `u32` field). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum StepCanvasRung { /// Rung 0 — the body-surface opener. **Variable-extent**: one gridunit /// per region (the body's own region grid, D-243's elastic seam made /// visible — `regions_per_equator(R) × regions_per_equator(R)/2` /// cells). The sole always-kept, canonical tier (D-255(b)/(d)). Global, /// Rung 1 — 204.8 km spacing (D-243 `REGION_M`). The largest FIXED rung /// — viewport-sized and evictable like every rung below it, NOT the /// always-kept tier (that is [`Self::Global`]). Region, /// Rung 2 — 2,048 m spacing (D-243 `DISTRICT_M`). District, /// Rung 3 — 512 m extent (D-243 `QUARTER_M`). **The deepest NAVIGABLE /// rung** (D-255 amendment 2026-08-07): at the uniform 2×2 px display /// ratio it draws ~0.948 m per gridunit, about one voxel per gridunit and /// one 0.5 m subtile per screen pixel — the floor at which a further step /// would magnify rather than inform. Quarter, /// Rung 4 — 128 m extent (D-243 `BLOCK_M`). **Retired from the navigable /// ladder** (D-255 amendment 2026-08-07): 0.237 m per gridunit is four /// gridunits per voxel, so it can only draw one voxel larger. Measured on /// Ferrath as a single morphology zone and a single vegetation class. /// /// The variant is KEPT: it is still legal wire vocabulary, `extent_m` /// still answers for it, and `BLOCK_M` remains the generator's 128 m /// planning unit. Only the client's `RUNG_LADDER` stopped offering it. Block, /// Rung 5 — 64 m extent (D-243 `CHUNK_M`). **Retired from the navigable /// ladder** (D-255 amendment 2026-08-07): 0.119 m per gridunit, eight /// gridunits per voxel. /// /// Retired from the MAP only. The 64 m chunk remains D-243's stream/derive /// unit and is where Phase 5 derives first-person walkable content /// ([D-012](#d-012)'s load/unload-around-the-player is expressed in /// chunks) — it is arguably the most load-bearing unit in the cascade, and /// nothing about it changes here. What was removed is the claim that a /// *map* of one is worth looking at. Chunk, } impl StepCanvasRung { /// The rung's own world-metre **cell size** — sourced from `scale::` /// (D-243), never a magic number. `None` for [`Self::Global`], the sole /// elastic rung, whose extent is the body itself and therefore cannot be /// a constant (D-243's elastic seam). /// /// **This is an extent, not a spacing** (D-255 amendment, pair session /// 2026-07-26). The original ladder had it the other way round: a rung /// fixed the gridunit *spacing* and the canvas extent fell out of /// `spacing × cell count`. That inversion is what made the top of the /// ladder unusable — at `REGION_M` spacing a viewport-sized canvas spanned /// ~251,658 km, six times around a rocky body, so the Region rung capped /// to the body and redrew the Global picture pixel-for-pixel. Now the rung /// fixes the extent and the spacing falls out of the canvas size /// ([`Self::spacing_m`]), so every rung shows exactly the ground its name /// promises and the scroll walks the D-243 stair honestly. pub fn extent_m(self) -> Option { match self { StepCanvasRung::Global => None, StepCanvasRung::Region => Some(scale::REGION_M as f64), StepCanvasRung::District => Some(scale::DISTRICT_M as f64), StepCanvasRung::Quarter => Some(scale::QUARTER_M as f64), StepCanvasRung::Block => Some(scale::BLOCK_M as f64), StepCanvasRung::Chunk => Some(scale::CHUNK_M as f64), } } /// Metres per gridunit for a canvas of `width × height` cells on a body of /// `body_radius_km` — a function of the **request**, not a per-rung /// constant (D-255 amendment 2026-07-26, see [`Self::extent_m`]). /// /// Fixed rungs: the **shorter** canvas axis spans exactly one cell of this /// level, so a widescreen viewport shows proportionally more ground on the /// long axis rather than less on the short one (Jeroen's rule: "the /// smallest viewport axis locks the area for calculation, since that is /// most widescreen friendly"). A consequence worth knowing: the canvas /// cell count is viewport-driven and identical at every rung, so derive /// cost no longer varies with depth. /// /// [`Self::Global`]: equirectangular whole body — the full `2πR` /// circumference wraps the canvas **width**, and the 2:1 cell counts /// [`Self::global_cell_counts`] produces keep the cells square (the height /// spans `πR`, pole to pole). Callers pass the RESOLVED canvas dimensions /// ([`resolve_canvas_extent`]), never the requested ones — a clamped /// canvas has a coarser spacing over the same ground, and the derive must /// use what it actually got. pub fn spacing_m(self, width: u32, height: u32, body_radius_km: f64) -> f64 { match self.extent_m() { Some(extent_m) => extent_m / width.min(height).max(1) as f64, None => { let circumference_m = 2.0 * std::f64::consts::PI * body_radius_km * 1_000.0; circumference_m / width.max(1) as f64 } } } /// `true` for [`Self::Global`] — the sole variable-extent, always-keep /// rung (D-255(a)/(b)). Every other rung is a fixed, viewport-sized, /// evictable canvas. pub fn is_global(self) -> bool { matches!(self, StepCanvasRung::Global) } /// The derivation mode this rung uses (D-255(a)/(f), Dudley round-2 §(b)): /// [`Self::Global`] rides `derive_orbital_at_metres` (the coarse /// envelope-only derivation — no invented terrain, since orbital spacing /// sits below `detail_scatter`'s own octave floor); every fixed rung rides /// `derive_at_metres` (full classification, courses-aware). /// /// **Region left this set** (D-255 amendment, 2026-07-26). It was /// envelope-only because a Region canvas spanned ~251,658 km, where /// nothing finer than an envelope was meaningful. Post-inversion it spans /// 262x466 km — a genuine provincial map — so it derives like the rungs /// below it. /// /// **Corrected 2026-07-28.** That amendment was implemented against the /// CLIENT's mirror (`step_canvas_transport.gd::is_orbital_rung`) only. /// THIS function is the authority, and still read `Global | Region`, so /// Region went on running envelope-only and carrying no courses — the /// precise thing the amendment claimed it had stopped doing. A mirror /// updated without its source is worse than no mirror at all: both sides /// read plausibly, and they disagree only in production. pub fn uses_orbital_derive(self) -> bool { matches!(self, StepCanvasRung::Global) } /// Global's variable canvas extent for a body of the given radius: /// `(cols, rows) = (regions_per_equator(R), regions_per_equator(R)/2)` /// — one gridunit PER REGION, pole-to-pole being half the equatorial /// count (D-243's elastic seam; the same shape /// `bmv_global_tier_bench.rs::rung0_cells` measured). `None` for every /// other rung (they are fixed-canvas, not body-radius-dependent in /// extent). pub fn global_cell_counts(self, body_radius_km: f64) -> Option<(u32, u32)> { if !self.is_global() { return None; } let cols = scale::regions_per_equator(body_radius_km); let rows = (cols / 2).max(1); Some((cols, rows)) } } // --------------------------------------------------------------------------- // Wire envelope — StepCanvasRequest / StepCanvasResponse (D-255(c)) // --------------------------------------------------------------------------- /// A step-canvas data-canvas request (D-255(c), body-map-viewer workshop). /// Discriminator field `step_canvas: true` — extends the exact tagged-marker /// pattern `star_map: bool` / `city_names: bool` / `browse: bool` already /// establish in `bridge/mod.rs` (D-225's deferred migration, now executed). /// A sixth `Inbound` variant. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StepCanvasRequest { /// Mandatory discriminator — always `true` when present. A missing field /// is a hard deserialize failure, not a silent shape-ambiguity (the same /// discipline `bridge::decode_inbound`'s `ShapeProbe` already enforces /// for the other five shapes). pub step_canvas: bool, pub body_id: String, /// The discrete D-255(a) rung this request targets — never a raw spacing /// float (Dudley round-2 §(a): "the discrete step, never a raw spacing /// float"). pub rung: StepCanvasRung, /// World-metre centre the canvas is built around, snapped by the caller /// to the rung's own grid where applicable. Ignored (but still echoed) /// for [`StepCanvasRung::Global`] — the Global canvas is whole-body, not /// centred on a viewport. pub center: (i64, i64), /// Canvas pixel budget for every FIXED rung (D-255(a): "the fixed /// 3840×2160 px budget... every step except Global"). Ignored for /// [`StepCanvasRung::Global`], whose extent is the body's own region /// grid ([`StepCanvasRung::global_cell_counts`]) — a field value, not a /// different message shape (D-255(c)). pub extent: (u32, u32), /// Octave cutoff in whole metres (T-1149/T-1150 precedent), `0` = no /// cutoff. Quantized server-side before it ever touches a cache key — /// see [`quantize_min_wl_m_for_rung`]. pub min_wl_m: u32, } /// Ready/Pending/Error status for a [`StepCanvasResponse`] — mirrors the /// existing three-way status enums (`AtlasLayerStatus`, `StarMapStatus`) /// already established in this codebase (D-255(c): "mirrors the existing /// three-way status enums"). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum StepCanvasStatus { Ready, Pending, /// The body is unknown or has no source terrain — re-requesting won't help. NotFound, Error(String), } /// One dense classification field, PNG-per-field encoded (T-1179's measured /// smallest-AND-fastest encoding at every canvas size — 21×-563× smaller /// than raw dense msgpack, and faster to encode/decode than every /// alternative measured). `width`/`height` are carried on /// [`EncodedStepCanvas`] (shared by every field), not duplicated per field. /// /// `Default` (T-1188): needed for `#[serde(default)]` on newly-added /// `EncodedStepCanvas` fields (`lake_margin_q`) so an old-shape payload /// missing the field still deserializes — an empty `png_bytes` decodes via /// `png_decode_u8_plane` to an all-zero plane (see that function's /// empty-input handling), the correct "field absent" reading. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct EncodedField { pub png_bytes: Vec, } /// `temp_dc` ships as raw MessagePack, not PNG (T-1179/`wire_encoding_bench.rs` /// precedent: `i16`, includes negative values + the `REGION_TEMP_NONE_DC` /// sentinel, not representable as an 8-bit grayscale plane without a lossy /// remap — the SAME treatment the T-1179 bench gives it). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EncodedTempField { pub values: Vec, } /// `settlement_id` ships as raw MessagePack, not PNG — same reasoning as /// [`EncodedTempField`]: `u32` city ids do not fit an 8-bit grayscale plane /// without a lossy remap (`SettlementEntry.city_id` is a `u64` truncated to /// `u32`, not a small bounded discriminant like `morphology`/`vegetation`). /// Rare-and-spatially-coherent occupancy (settlements are sparse, mostly `0` /// with a few contiguous non-zero runs) means `rmp_serde`'s own DEFLATE-free /// compact framing is still cheap in practice even without PNG's benefit — /// unmeasured directly (a new field, not in T-1179's table), but the same /// "near-constant runs" argument T-1179 confirmed for `morphology`/ /// `vegetation` applies structurally here too. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EncodedSettlementField { pub values: Vec, } /// One carved-gorge segment (Tyre round-2 §(a.7), T-1177's direct /// solver-output carry) — sparse, MessagePack-native, parallel to /// [`RiverCourse`]. Zero-length on every currently-committed body (T-1177's /// population survey: zero carved cells across all 267 real bodies) but the /// shape stands for any future body whose terrain produces the narrow /// carving geometry the hydrology solver can output. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CliffSegment { /// World-metre position of the carved cell. pub point: (i32, i32), /// `original_elevation - spill_level` (T-1177's direct carry), in /// integer decimetres — the same integer-wire discipline every other /// dense field uses (D-010). pub channel_depth_dm: i32, /// Whether this cell is the sharp cliff edge of the carved corridor /// (vs. an interior carved cell). pub cliff_edge: bool, } /// The full step-canvas payload — one flat tagged response carrying every /// field together (Dudley round-2 §(a), Araminta's dense/sparse framing /// question answered (a): no server-side split, the progressive-paint UX /// case is cheaper client-side). This is the CONVERGED final shape /// (`dudley-interview2-response.md` "CORRECTED... struct, final; no /// dissent"): **8 dense fields (6 static + 2 sim-state), 2 sparse lists.** /// Static-geometry plane: `morphology`, `elev_q`, `temp_dc`, `moisture_q`, /// `vegetation`, `settlement_id`. Sim-state plane: `glaciation`, `flooded_q`. /// Sparse: `courses`, `cliffs`. /// /// **Sim-state note (D-227 amendment (1)):** `glaciation`'s VALUE is still /// today's STATIC classification (`DistrictProfile::glaciation_grade` — /// D-239 §5 gates, a pure function of `(seed, position)`); what makes it /// "sim-state" is the CACHE-TIER TTL tag ([`StepCanvasCache`]'s dual-axis /// eviction), not a different derivation — no `SeasonPhase`-driven /// glaciation recompute exists yet, so today every glaciation value is also /// byte-identical to a hypothetical indefinitely-fresh derive; the TTL is /// wired ahead of the data actually varying with sim time (D-253). `flooded_q` /// is shipped as a documented stub (always `0` = not flooded) for the same /// reason — no `HydrologyResult`-driven sim-time water plane exists yet (see /// the D-227 amendment (4) lake-sourcing note: that amendment covers STATIC /// lake basin geometry via `morphology`, not this sim-time-varying overlay). /// Both fields keep the wire SHAPE D-253-ready without inventing physics the /// simulation doesn't have yet. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EncodedStepCanvas { pub width: u32, pub height: u32, // --- Static geometry plane (7 dense fields — lake_margin_q added T-1188, // see below; converged shape was 6, this is the one addition since) --- pub morphology: EncodedField, pub elev_q: EncodedField, pub temp_dc: EncodedTempField, pub moisture_q: EncodedField, pub vegetation: EncodedField, /// `0` = no settlement; otherwise `SettlementEntry.city_id` truncated to /// `u32` (city ids are small monotonic counters in this codebase — see /// [`build_step_canvas`]'s settlement-coverage doc for the exact /// membership test, a fixed-radius proximity approximation pending real /// quarter-footprint geometry). pub settlement_id: EncodedSettlementField, /// T-1188 — settled-hydrology lake-margin depth band (`0` at/near the /// shoreline, ramping toward `100` at a basin's deep centre): the /// continuous tone source lake shorelines were missing, since /// `ocean_fraction_q` (the field that gives ocean coastlines their /// multi-tone transition band via `derive_morphology_zone`'s coastal /// gates) is definitionally `0` throughout a lake basin. Static geometry /// (a pure function of position, same plane as `elev_q`), NOT sim-state — /// grouped with the other static fields rather than next to /// `glaciation`/`flooded_q` below. `#[serde(default)]` (same precedent as /// `cliffs`) so a MessagePack decode of an old-shape payload doesn't /// hard-fail on the missing map key; not expected to occur in practice /// (client/server ship together, D-192, and the client's persistent /// step-canvas cache is version-tagged — see this ticket's cache-schema /// note — so a stale disk entry misses rather than decodes), but the /// derived default (`EncodedField { png_bytes: vec![] }`) is also never /// itself PNG-decoded on that path — nothing reads `lake_margin_q` /// before the version tag has already forced a fresh fetch. #[serde(default)] pub lake_margin_q: EncodedField, // --- Sim-state plane (2 dense fields, see struct doc) --- pub glaciation: EncodedField, pub flooded_q: EncodedField, // --- Sparse lists (2) --- /// Invented river course polylines intersecting this canvas (T-1170), /// sparse MessagePack-native — same wire shape as /// `layer_proxy::DistrictWindowLayer.courses`. pub courses: Vec, /// Carved-gorge segments (Tyre round-2 §(a.7)), sparse MessagePack-native. #[serde(default)] pub cliffs: Vec, } /// Response to a [`StepCanvasRequest`]. Deliberately NOT a field on /// `layer_proxy::AtlasLayerResponse` (D-226 T-1124 §2's windowed-family /// ceiling names this exact case) — a wholly separate response type outside /// that family, per D-255(c). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StepCanvasResponse { pub body_id: String, pub rung: StepCanvasRung, /// Echoed request centre — same staleness-guard pattern as /// `district_window` (the echo IS the client's cache/staleness key, /// because the derivation is pure and deterministic, D-227). pub center: (i64, i64), /// Echoed, CLAMPED extent (PR #201 review, Hoshe finding 1) — mirrors /// `layer_proxy::DistrictWindowLayer.n`'s own doc: "this clamp is /// echoed, not silently applied... a client that requests an oversized /// extent gets back a smaller one." `(0, 0)` for `StepCanvasRung::Global` /// (the wire extent is never read for that rung — see /// `resolve_canvas_extent`'s doc — so there is no clamped value to /// report; a client staleness guard should special-case `Global` the /// same way it already must special-case `center`, which is likewise /// meaningless there). pub extent: (u32, u32), pub min_wl_m: u32, pub status: StepCanvasStatus, pub canvas: Option, } // --------------------------------------------------------------------------- // (Station-spacing cap removed — D-255 extent inversion, pair session // 2026-07-26. `COURSE_STATION_SPACING_FLOOR_M` / `course_station_spacing_m` // floored course resampling at DISTRICT_M to guard an O(1/spacing) blowup that // the inversion makes structurally impossible: the canvas cell count is now // viewport-driven and identical at every rung, so stations-per-course is // bounded at ~one per gridunit however deep the rung. Post-inversion the floor // would do active harm — a District canvas spans ~3.6 km, so a 2,048 m pitch // put two stations across the whole view and drew every river as a straight // line. Station placement gets its own generator pass (Jeroen, same session).) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Quantization (mirrors layer_proxy::quantize_min_wl_m's discipline) // --------------------------------------------------------------------------- /// Quantize a request's raw `min_wl_m` to whole metres before it ever /// touches a cache key — the step-canvas envelope reuses the same /// octave-cutoff CONCEPT `layer_proxy::quantize_min_wl_m` established, but /// step canvases key their cache on `(body, rung, center, min_wl_m)` where /// `min_wl_m` is already meaningful per-rung (each rung has its own Nyquist /// floor). Rather than re-deriving a second quantization band table, this /// reuses the SAME banding function so a step-canvas request and a legacy /// `district_window` request quoting the same octave cutoff land on /// identical quantized values (never a silent divergence between the two /// carriers over the same underlying concept). pub fn quantize_min_wl_m_for_rung(raw: u32) -> u32 { crate::atlas::layer_proxy::quantize_min_wl_m(raw) } // --------------------------------------------------------------------------- // Derive core — one row-chunked parallel pass per rung (D-255(f) seed-chaining: // independent re-derivation, fallback path; mechanism-B acceleration lives in // the cache layer below, never the derive core itself) // --------------------------------------------------------------------------- /// One derived canvas cell — mirrors `layer_proxy::WindowCell`'s field set /// (courses/cliffs are handled separately, at the canvas level, not per cell). #[derive(Debug, Clone, Copy)] struct StepCanvasCell { morphology: u8, elev_q: u8, temp_dc: i16, moisture_q: u8, vegetation: u8, glaciation: u8, flooded_q: u8, lake_margin_q: u8, } /// Derive one cell at `(wx, wy)` world metres, dispatching to the D-255(a)/(f) /// derivation mode ([`StepCanvasRung::uses_orbital_derive`]) — mirrors /// `layer_proxy::derive_window_cell`'s dispatch exactly, extended to the full /// six-rung vocabulary. #[allow(clippy::too_many_arguments)] fn derive_step_canvas_cell( seed: SeedChain, body_id: &str, params: &BodyParams, ta: &TerrainAnalysis, climate: &ClimateConstants, wx: f64, wy: f64, rung: StepCanvasRung, min_wavelength_m: f64, nearby_courses: &[InventedCourse], ) -> StepCanvasCell { let prof: DistrictProfile = if rung.uses_orbital_derive() { derive_orbital_at_metres(seed, body_id, params, ta, wx, wy, climate) } else { derive_at_metres( seed, body_id, params, ta, wx, wy, climate, min_wavelength_m, nearby_courses, ) }; StepCanvasCell { morphology: prof.morphology_zone as u8, elev_q: prof.elev_q.clamp(0, 100) as u8, temp_dc: match prof.temperature_c { Some(t) => { ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16 } None => crate::atlas::layer_proxy::REGION_TEMP_NONE_DC, }, moisture_q: prof.moisture_q.clamp(0, 100) as u8, vegetation: prof.vegetation_class as u8, glaciation: prof.glaciation_grade as u8, // D-253 stub — see EncodedStepCanvas's doc. Always "not flooded" // until the sim-state driving clock exists. flooded_q: 0, // T-1188: the lake-margin depth-band tone source — see // `district_profile::DistrictProfile::lake_margin_q`'s doc. `0` for // every non-lake cell, same static-geometry-plane posture as // `elev_q`/`morphology` (a pure function of position, not sim-state). lake_margin_q: prof.lake_margin_q.clamp(0, 100) as u8, } } /// One fully-derived step canvas, pre-encoding — the row-chunked parallel /// derive result plus its sparse feature lists, before PNG/msgpack framing. /// Kept as an intermediate (not directly the wire type) so the acceptance /// gate (cache-hit vs cache-miss byte-identical test) can compare RAW derived /// bytes, not post-encode bytes — a stronger determinism check than /// comparing PNG output (which would also incidentally check the PNG codec's /// own determinism, a separate and already-established property). #[derive(Debug, Clone, PartialEq, Eq)] pub struct RawStepCanvas { pub width: u32, pub height: u32, pub morphology: Vec, pub elev_q: Vec, pub temp_dc: Vec, pub moisture_q: Vec, pub vegetation: Vec, /// `0` = no settlement; see [`build_step_canvas`]'s settlement-coverage /// doc. pub settlement_id: Vec, pub glaciation: Vec, pub flooded_q: Vec, /// T-1188 — settled-hydrology lake-margin depth band, the lake-shoreline /// tone source ocean coastlines already get for free from /// `ocean_fraction_q`. See `district_profile::DistrictProfile::lake_margin_q`. pub lake_margin_q: Vec, pub courses: Vec, pub cliffs: Vec, } /// Fixed proximity radius (world metres) a settlement's single anchor point /// ([`CityPlacement::position`]) claims for `settlement_id` coverage — /// **an honest approximation, not real footprint geometry.** No settlement /// footprint polygon exists anywhere in this codebase yet at the point this /// ticket serves from: `SettlementLayer`/`CityPlacement` carry only a single /// anchor point (`position: (u16, u16)`), never an extent — real quarter/ /// block footprint geometry is a LATER cascade stage (D-230, Phase 4's /// quarter-skeleton generation), not available at the whole-body layer this /// canvas derives from. A fixed-radius disc around the anchor point is the /// best-available honest signal ("is this gridunit near settlement X") /// rather than a stub-always-0 field, chosen over inventing geometry the /// generator doesn't produce yet. `~1,500 m` approximates a small city's /// visible footprint at the District/Quarter rungs where this matters most /// for label-anchoring (Araminta round-2 §(c)); the exact value is a tuning /// constant, not a measured one — revisit once real footprint geometry /// lands and `settlement_id` can become an exact membership test instead of /// a proximity approximation. pub const SETTLEMENT_COVERAGE_RADIUS_M: f64 = 1_500.0; /// Compute `settlement_id` for every cell in a canvas, from the body's /// placed settlements ([`CityPlacement`], the D-256(e)-legal non-survey /// input `courses`/rivers already establish the precedent for — a real /// placed-entity dataset, not the coarse survey raster). `0` = no coverage. /// Ties (two settlements' discs overlapping the same cell) resolve to the /// LOWEST `city_id` — an arbitrary but deterministic, stable tie-break /// (same discipline `araminta-round2.md`'s label-anchor rule already /// establishes for settlement-identity determinism). fn settlement_ids_for_canvas( placements: &[CityPlacement], ta: &TerrainAnalysis, body_radius_km: Option, center_world_m: (f64, f64), half_w: i32, half_h: i32, width: u32, height: u32, step_m: f64, ) -> Vec { let cells = (width * height) as usize; let mut out = vec![0u32; cells]; if placements.is_empty() { return out; } // Pre-resolve each settlement's world-metre anchor once (not per cell). let anchors: Vec<(f64, f64, u32)> = placements .iter() .map(|p| { let (wx, wy) = pixel_to_world_m( p.position.0 as f64, p.position.1 as f64, ta.w, ta.h, body_radius_km, ); (wx, wy, p.city_id as u32) }) .collect(); let radius_sq = SETTLEMENT_COVERAGE_RADIUS_M * SETTLEMENT_COVERAGE_RADIUS_M; for row in 0..height as i32 { for col in 0..width as i32 { let wx = center_world_m.0 + (col - half_w) as f64 * step_m; let wy = center_world_m.1 + (row - half_h) as f64 * step_m; let mut best: Option = None; for &(sx, sy, id) in &anchors { let dx = wx - sx; let dy = wy - sy; if dx * dx + dy * dy <= radius_sq { best = Some(match best { Some(existing) => existing.min(id), None => id, }); } } let i = (row as usize) * (width as usize) + col as usize; out[i] = best.unwrap_or(0); } } out } /// World-metre rect a fixed-rung canvas covers, `(x0, y0, x1, y1)` — mirrors /// `layer_proxy::window_world_rect` exactly (shared derivation so course /// culling and cell placement can never drift apart). fn fixed_canvas_world_rect( center_world_m: (f64, f64), half_w: i32, half_h: i32, width: i32, height: i32, step_m: f64, ) -> (f64, f64, f64, f64) { ( center_world_m.0 - half_w as f64 * step_m, center_world_m.1 - half_h as f64 * step_m, center_world_m.0 + (width - half_w) as f64 * step_m, center_world_m.1 + (height - half_h) as f64 * step_m, ) } /// Invent every river course whose amplitude-inflated chord bounding box /// intersects this canvas's world rect — mirrors /// `layer_proxy::invent_courses_near_window` exactly, with the /// [`course_station_spacing_m`] cap applied at the ONE call site /// (`river_course::invent_course`'s `station_spacing_m` argument) instead of /// the raw rung spacing. Global/Region ride the whole-body skeleton path /// (no windowed course invention — the rung-truncated course degenerates to /// the straight chord at Region+ spacing, Ruling 5a), matching /// `invent_courses_near_window`'s existing `WindowGranularity::Region` early /// return. #[allow(clippy::too_many_arguments)] fn invent_courses_for_canvas( // Unused since D-261 moved rivers from per-hop invention to whole cell // paths: a path's shape IS the terrain's, so there is no warp to seed and // no resampling wavelength to cut off. Kept in the signature because the // deferred polish (T-1238) reintroduces a per-river warp, and threading // them back through later is worse than leaving the plumbing in place. _seed: SeedChain, params: &BodyParams, ta: &TerrainAnalysis, river_network: &RiverNetwork, canvas_rect: (f64, f64, f64, f64), rung: StepCanvasRung, _step_m: f64, _min_wavelength_m: f64, ) -> Vec { // D-261: courses are invented at EVERY rung, Global included. They used to // be withheld from the orbital rung, which is why the whole-body map had no // rivers at all. Whether a given course is worth DRAWING is a presentation // question — measured in screen pixels, which only the client knows — so // the client culls anything shorter than three stroke-widths on screen // (~264 km at Global) and this side simply supplies the geometry. let _ = rung; // One station per gridunit — the finest density this canvas can draw. // // The former absolute floor ([`COURSE_STATION_SPACING_FLOOR_M`], 2,048 m) // guarded an O(1/spacing) blowup that the D-255 extent inversion (pair // session 2026-07-26) made structurally impossible: the canvas cell count // is now viewport-driven and IDENTICAL at every rung, so a course crossing // it has at most ~one station per gridunit no matter how deep the rung. // Keeping the absolute floor would now do active harm in the opposite // direction — a District canvas spans ~3.6 km post-inversion, so a 2,048 m // station pitch would place two stations across the whole view and render // every river as a straight line. let (win_x0, win_y0, win_x1, win_y1) = canvas_rect; // D-261: one course per RIVER, not per D8 hop. // // build_edges() emits one edge per river cell — a single step — so every // "course" used to be one hop, inflated by invent_course into a ~70 km // straight stub. Measured on Ferrath's Global canvas: 375 courses of 1-4 // points each, median 3. Nothing on that map read as a watercourse, and a // lake's outflow to the sea — which must exist and must be long — was // nowhere to be seen. // // A path IS the river, so its points are the river's own cells in order: // no resampling, no per-hop warp, and the shape is a function of the // terrain rather than of the zoom level (which is what D-261 asks for). // It is also CHEAPER — the whole network becomes one point per river cell // instead of three per cell. let paths = river_course::build_paths(river_network); let mut courses = Vec::new(); for (path_idx, path) in paths.iter().enumerate() { let points: Vec = path .cells .iter() .map(|&cell| { crate::atlas::district_profile::pixel_to_world_m( cell.1 as f64, cell.0 as f64, ta.w, ta.h, params.body_radius_km, ) }) .collect(); if points.len() < 2 { continue; } // Cull whole rivers that miss this canvas entirely, cheaply, before // any per-point work downstream. let mut bx0 = f64::MAX; let mut bx1 = f64::MIN; let mut by0 = f64::MAX; let mut by1 = f64::MIN; for p in &points { bx0 = bx0.min(p.0); bx1 = bx1.max(p.0); by0 = by0.min(p.1); by1 = by1.max(p.1); } if bx1 < win_x0 || bx0 > win_x1 || by1 < win_y0 || by0 > win_y1 { continue; } courses.push(InventedCourse { edge_id: path_idx as u32, class: path.class, terminus: path.terminus, bbox: (bx0, by0, bx1, by1), points, }); } courses } /// Crop an invented course to the canvas's wire [`RiverCourse`] shape — /// mirrors `layer_proxy::crop_course_to_window`'s point-range + terminus /// logic exactly (duplicated rather than shared because the source function /// is private to `layer_proxy` and threading canvas-shaped types through it /// would widen that module's public surface for a one-call-site reuse; the /// byte-identical acceptance gate below is what keeps the two paths honest /// against silent drift, same as any other intentionally-parallel /// implementation in this codebase). fn crop_course_for_canvas( course: &InventedCourse, canvas_rect: (f64, f64, f64, f64), ) -> Option { let (x0, y0, x1, y1) = canvas_rect; let inside = |p: &(f64, f64)| p.0 >= x0 && p.0 <= x1 && p.1 >= y0 && p.1 <= y1; let n = course.points.len(); let mut first_in: Option = None; let mut last_in: Option = None; for (i, p) in course.points.iter().enumerate() { if inside(p) { first_in.get_or_insert(i); last_in = Some(i); } } let (first_in, last_in) = match (first_in, last_in) { (Some(a), Some(b)) => (a, b), _ => return None, }; let lo = first_in.saturating_sub(1); let hi = (last_in + 1).min(n.saturating_sub(1)); let points: Vec<(i32, i32)> = course.points[lo..=hi] .iter() .map(|p| (p.0.round() as i32, p.1.round() as i32)) .collect(); // Terminus resolution is simplified relative to layer_proxy's full // Mouth-waterline-bisection path (that logic needs the sea-level // heightmap probe this module doesn't carry its own copy of) — a // step-canvas course reports ContinuesBeyondWindow whenever the true // downstream end isn't captured in this crop range, and EdgeDrain/None // otherwise verbatim from the source classification. This is a // deliberately narrower terminus resolution than the legacy carrier's // (no Mouth waterline-bisection at this pass) — acceptable because the // wire terminus is a display hint (Ruling 3h), not load-bearing for // determinism, and the acceptance gate below only requires cache-hit == // cache-miss agreement, which this simplification preserves exactly // (both paths run the identical simplified rule). let true_end_included = hi == n.saturating_sub(1); let terminus = if !true_end_included { CourseTerminus::ContinuesBeyondWindow } else { match course.terminus { river_course::EdgeTerminusKind::EdgeDrain => CourseTerminus::EdgeDrain, river_course::EdgeTerminusKind::Interior => CourseTerminus::ContinuesBeyondWindow, river_course::EdgeTerminusKind::Mouth => CourseTerminus::Mouth, } }; Some(RiverCourse { edge_id: course.edge_id, class: course.class, points, terminus, }) } /// Hard per-axis ceiling on a [`StepCanvasRequest`]'s wire `extent` for every /// FIXED rung (PR #201 review, Hoshe finding 1 — **never trust `extent` from /// the wire**, the same discipline `layer_proxy::DISTRICT_WINDOW_MAX_N`/ /// `clamp_window_n` already establish for the legacy carrier's `window_n`). /// `3,840` — the larger axis of the workshop's own measured ceiling canvas /// (3840×2160, D-255(a)/(b): "the fixed 3840×2160 px budget... every step /// except Global," the class every fixed-rung cost number in the workshop's /// tables was benched at). Applied per-axis BEFORE the total-cell ceiling /// below (same two-stage discipline `clamp_window_n_v2` uses: per-axis /// clamp, then a wire-size ceiling on the derived cell count). pub const STEP_CANVAS_MAX_EXTENT_AXIS: u32 = 3_840; /// Hard ceiling on total cells (`width * height`) for a FIXED-rung canvas — /// `3,840 × 2,160 = 8,294,400`, the exact D-255(a)/(b) measured ceiling /// canvas (Dudley round-2 §(c) Option D's own largest row: "3840×2160, 8.3M /// cells" at every fixed rung). This is the SAME cell count /// `layer_proxy::WIRE_CAP_CELLS` plays for the legacy windowed carrier /// (4,096 cells) scaled to this carrier's own measured budget — a step /// canvas is allowed to be far larger than a legacy window (that's the /// entire reason it needed a new carrier, D-255(c)), but it is not allowed /// to be UNBOUNDED. pub const STEP_CANVAS_MAX_EXTENT_CELLS: u64 = 3_840 * 2_160; /// Clamp a [`StepCanvasRequest`]'s wire `extent` to /// [`STEP_CANVAS_MAX_EXTENT_AXIS`]/[`STEP_CANVAS_MAX_EXTENT_CELLS`] — /// **clamps, never rejects** (PR #201 review: "consistent with how /// `clamp_window_n` behaves"). Two-stage, mirroring /// `layer_proxy::clamp_window_n_v2`'s discipline exactly: /// /// 1. **Per-axis clamp** to `[1, STEP_CANVAS_MAX_EXTENT_AXIS]` on each axis /// independently — this alone defeats a `u32::MAX`-per-axis request (a /// `(u32::MAX, u32::MAX)` extent clamps to `(3_840, 3_840)` before any /// multiplication is attempted, so the overflow-prone `width * height` /// arithmetic downstream never sees the raw wire value). /// 2. **Total-cell ceiling** — if the per-axis-clamped shape still exceeds /// `STEP_CANVAS_MAX_EXTENT_CELLS` (reachable: `3_840 * 3_840 = /// 14,745,600 > 8,294,400`, an axis-square request past the measured /// 16:9 ceiling), scale BOTH axes down by the same factor /// (`sqrt(cap / cells)`) so the clamped shape keeps its requested aspect /// ratio rather than being squashed on one axis only — a closer match to /// "give the client the biggest canvas that fits the budget" than an /// asymmetric halving loop would produce for a non-square request. /// /// Never called for [`StepCanvasRung::Global`] — that rung's extent is /// server-derived from the body's own region grid /// ([`StepCanvasRung::global_cell_counts`]), never the wire value at all /// (see [`resolve_canvas_extent`]). pub fn clamp_step_canvas_extent(extent: (u32, u32)) -> (u32, u32) { let w = extent.0.clamp(1, STEP_CANVAS_MAX_EXTENT_AXIS); let h = extent.1.clamp(1, STEP_CANVAS_MAX_EXTENT_AXIS); let cells = w as u64 * h as u64; if cells <= STEP_CANVAS_MAX_EXTENT_CELLS { return (w, h); } let scale = (STEP_CANVAS_MAX_EXTENT_CELLS as f64 / cells as f64).sqrt(); let scaled_w = ((w as f64 * scale).floor() as u32).max(1); let scaled_h = ((h as f64 * scale).floor() as u32).max(1); // Defensive floor-rounding safety net (mirrors clamp_window_n_v2's own // "defensive, not currently reachable for well-behaved inputs" halving // loop): floor-rounding both axes down from an exact sqrt scale can // still land fractionally over the cap for some (w, h, cap) combinations // — walk the larger axis down one cell at a time until the invariant // holds. Bounded: at most STEP_CANVAS_MAX_EXTENT_AXIS iterations, and // never fires for any input this function's own test sweep covers. let mut final_w = scaled_w; let mut final_h = scaled_h; while (final_w as u64 * final_h as u64) > STEP_CANVAS_MAX_EXTENT_CELLS && final_w > 1 && final_h > 1 { if final_w >= final_h { final_w -= 1; } else { final_h -= 1; } } (final_w, final_h) } /// Resolve a [`StepCanvasRequest`]'s canvas extent in cells — `(width, /// height)`. [`StepCanvasRung::Global`] uses the body's own region grid /// ([`StepCanvasRung::global_cell_counts`]) — **the wire `extent` is never /// read for `Global`, full stop** (PR #201 review: "make sure... Global /// ignores wire extent entirely"). Every fixed rung clamps the wire `extent` /// via [`clamp_step_canvas_extent`] before it reaches any allocation (D-255(a): /// "the fixed 3840×2160 px budget... at 1 gridunit-per-screen-px" — cell /// count = canvas px count at every fixed rung, Dudley round-2 §(c)). fn resolve_canvas_extent( rung: StepCanvasRung, extent: (u32, u32), body_radius_km: f64, ) -> (u32, u32) { if !rung.is_global() { return clamp_step_canvas_extent(extent); } // Global is VIEWPORT-SIZED like every other rung now (D-255 amendment, // pair session 2026-07-26) — it used to take its cell counts from // `global_cell_counts()`, i.e. one gridunit per region. On GJ380c that // was a 191x95 canvas built from a heightmap stored at 512x256: roughly // SEVEN TIMES the available cells thrown away before drawing. Worse, the // count shrank as REGION_M grew, so tuning the scale ladder silently // degraded the opener. // // The one thing Global cannot take from the viewport is its ASPECT: the // canvas is equirectangular whole-body, 2:1 (360 degrees of longitude by // 180 of latitude), and cells must stay square or the map shears. So fit // the largest 2:1 canvas inside the requested extent and let the existing // letterbox (`center_offset`) absorb the remainder — the viewport is // ~16:9, so this is normally height-bound. let (req_w, req_h) = clamp_step_canvas_extent(extent); let width = req_w.min(req_h.saturating_mul(2)).max(2); let resolved = (width, (width / 2).max(1)); debug_assert!( resolved.0 == resolved.1 * 2, "Global canvas must stay 2:1 or cells are not square" ); // A body with no radius (an asteroid belt is not a sphere and has no // equirectangular surface) has nothing to fit — fall back to the region // grid, which degrades to a 1x1 canvas rather than a plausible-looking lie. if body_radius_km <= 0.0 { return rung.global_cell_counts(body_radius_km).unwrap_or(resolved); } resolved } /// The step-canvas derive core (D-255(f): independent re-derivation, the /// cache-miss fallback path every measured cost number in the workshop /// prices) — one row-chunked parallel pass, mirroring /// `layer_proxy::build_district_window_layer` exactly. /// /// `center` is ignored for [`StepCanvasRung::Global`] (whole-body canvas, /// origin-anchored — see the row-space convention note below); required for /// every fixed rung (the canvas centre in world metres). /// /// **Row-space convention (T-1186 — resolved):** the Global (rung-0) canvas /// uses **equator-anchored SIGNED region rows** /// (`wy = (row - rows/2) * REGION_M`, negative = north), matching the canonical /// `derive_orbital_at_metres`/`derive_at_metres_with_riparian` convention /// (D-256(a): the ONE absolute-metre derive core; that core's own internal /// `district_pos` floor-division and `lat_frac` computation are both /// equator-anchored-signed). When this canvas was built, that was a /// DELIBERATE DEPARTURE from two non-negative pole-anchored row conventions /// then in the codebase — chosen because signed-equator rows are the only /// shape consistent with D-256(a)'s "one derive core, one inverse mapping" /// principle, and using either pole-anchored convention here would have /// reproduced T-1186's bug class in a third place. Since then: (1) /// `region_profile::region_centre_latitude_deg` — T-1186's actual bug, the /// baseline-latitude function this derive core reaches transitively via /// `region_baseline_at_district` — is **fixed** (PR #206) to the same /// equator-anchored signed inverse mapping, so the baseline latitude every /// canvas cell receives now agrees with these rows on every rung. (2) /// `layer_proxy::build_region_grid`/`LayerRegionOutput` (non-negative `ry` /// rows, `0..=max_region.1`) remains the one pole-anchored outlier — the /// collapsed whole-body region layer D-256(f) explicitly deferred to THIS /// rebuild ("the overlay stays visibly stale until T-1181 replaces it — /// accepted, noted"); unifying it onto the signed convention is proposed /// for T-1181's scope, at which point one convention remains codebase-wide. #[allow(clippy::too_many_arguments)] pub fn build_step_canvas( seed: SeedChain, body_id: &str, params: &BodyParams, ta: &TerrainAnalysis, river_network: &RiverNetwork, placements: &[CityPlacement], rung: StepCanvasRung, center: (i64, i64), extent: (u32, u32), climate: &ClimateConstants, min_wl_m: u32, ) -> RawStepCanvas { use rayon::prelude::*; let body_radius_km = params.body_radius_km.unwrap_or(0.0); let (width, height) = resolve_canvas_extent(rung, extent, body_radius_km); let min_wavelength_m = min_wl_m as f64; // RESOLVED dims, not the requested `extent` — a clamped canvas covers the // same ground at a coarser pitch (see StepCanvasRung::spacing_m). let step_m = rung.spacing_m(width, height, body_radius_km); let cells = (width * height) as usize; let half_w = (width / 2) as i32; let half_h = (height / 2) as i32; // World-metre origin the canvas is placed at. Global: origin-anchored, // signed-equator rows (see this function's doc above) — center is // ignored. Fixed rungs: the request's own centre, exactly like // layer_proxy::center_to_world_m's district-position convention. let center_world_m: (f64, f64) = if rung.is_global() { (0.0, 0.0) } else { (center.0 as f64, center.1 as f64) }; let canvas_rect = fixed_canvas_world_rect( center_world_m, half_w, half_h, width as i32, height as i32, step_m, ); // D-261: courses at EVERY rung, Global included. This used to be gated on // `rung.is_global()` — a second gate on top of the one inside // invent_courses_for_canvas, which is why removing only the inner one // changed nothing. Whether a course is worth DRAWING is decided // client-side in screen pixels; this side just supplies the geometry. let invented_courses = invent_courses_for_canvas( seed, params, ta, river_network, canvas_rect, rung, step_m, min_wavelength_m, ); let rows: Vec> = (0..height as i32) .into_par_iter() .map(|row| { (0..width as i32) .map(|col| { let wx = center_world_m.0 + (col - half_w) as f64 * step_m; let wy = center_world_m.1 + (row - half_h) as f64 * step_m; derive_step_canvas_cell( seed, body_id, params, ta, climate, wx, wy, rung, min_wavelength_m, &invented_courses, ) }) .collect() }) .collect(); let mut morphology = vec![0u8; cells]; let mut elev_q = vec![0u8; cells]; let mut temp_dc = vec![crate::atlas::layer_proxy::REGION_TEMP_NONE_DC; cells]; let mut moisture_q = vec![0u8; cells]; let mut vegetation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells]; let mut flooded_q = vec![0u8; cells]; let mut lake_margin_q = vec![0u8; cells]; for (row, row_cells) in rows.into_iter().enumerate() { let base = row * width as usize; for (col, cell) in row_cells.into_iter().enumerate() { let i = base + col; morphology[i] = cell.morphology; elev_q[i] = cell.elev_q; temp_dc[i] = cell.temp_dc; moisture_q[i] = cell.moisture_q; vegetation[i] = cell.vegetation; glaciation[i] = cell.glaciation; flooded_q[i] = cell.flooded_q; lake_margin_q[i] = cell.lake_margin_q; } } let courses: Vec = invented_courses .iter() .filter_map(|c| crop_course_for_canvas(c, canvas_rect)) .collect(); // Cliffs: sparse, zero-length today (see CliffSegment's doc — T-1177 // population survey found zero carved cells across all 267 real bodies). // No production carving source is wired yet (the basin-outlet->D8 // wiring ticket is a separate, not-yet-landed follow-up per Tyre §(a.11)) // — always empty, matching the honest sequencing that amendment states // explicitly. let cliffs: Vec = Vec::new(); // settlement_id: computed once over the whole canvas (same "invent once, // not per-cell" discipline courses already follow) — a fixed-radius // proximity approximation, see SETTLEMENT_COVERAGE_RADIUS_M's doc. Empty // at Global (no settlement geometry meaningfully "covers" a // one-gridunit-per-region cell) and skipped there for the same reason // courses are skipped at Global/Region. let settlement_id = if rung.is_global() { vec![0u32; cells] } else { settlement_ids_for_canvas( placements, ta, params.body_radius_km, center_world_m, half_w, half_h, width, height, step_m, ) }; RawStepCanvas { width, height, morphology, elev_q, temp_dc, moisture_q, vegetation, settlement_id, glaciation, flooded_q, lake_margin_q, courses, cliffs, } } // --------------------------------------------------------------------------- // Encoding — PNG-per-field dense, MessagePack-native sparse (T-1179) // --------------------------------------------------------------------------- fn png_encode_u8_plane(cols: u32, rows: u32, data: &[u8]) -> EncodedField { let mut out = Vec::new(); { let mut enc = png::Encoder::new(&mut out, cols.max(1), rows.max(1)); enc.set_color(png::ColorType::Grayscale); enc.set_depth(png::BitDepth::Eight); let mut writer = enc.write_header().expect("png header"); writer.write_image_data(data).expect("png data"); } EncodedField { png_bytes: out } } fn png_decode_u8_plane(field: &EncodedField) -> Vec { let mut decoder = png::Decoder::new(std::io::Cursor::new(&field.png_bytes)) .read_info() .expect("png read_info"); let mut buf = vec![0u8; decoder.output_buffer_size()]; let frame = decoder.next_frame(&mut buf).expect("png next_frame"); buf[..frame.buffer_size()].to_vec() } /// Encode a [`RawStepCanvas`] to the wire [`EncodedStepCanvas`] shape /// (T-1179's measured winner: PNG-per-field dense, MessagePack-native /// sparse — courses/cliffs pass through unchanged, they are already the wire /// shape). pub fn encode_step_canvas(raw: &RawStepCanvas) -> EncodedStepCanvas { EncodedStepCanvas { width: raw.width, height: raw.height, morphology: png_encode_u8_plane(raw.width, raw.height, &raw.morphology), elev_q: png_encode_u8_plane(raw.width, raw.height, &raw.elev_q), temp_dc: EncodedTempField { values: raw.temp_dc.clone(), }, moisture_q: png_encode_u8_plane(raw.width, raw.height, &raw.moisture_q), vegetation: png_encode_u8_plane(raw.width, raw.height, &raw.vegetation), settlement_id: EncodedSettlementField { values: raw.settlement_id.clone(), }, lake_margin_q: png_encode_u8_plane(raw.width, raw.height, &raw.lake_margin_q), glaciation: png_encode_u8_plane(raw.width, raw.height, &raw.glaciation), flooded_q: png_encode_u8_plane(raw.width, raw.height, &raw.flooded_q), courses: raw.courses.clone(), cliffs: raw.cliffs.clone(), } } /// Decode an [`EncodedStepCanvas`] back to [`RawStepCanvas`] shape (test/ /// verification use — the production client decodes independently, but the /// server-side round-trip is what the acceptance gate's byte-identical test /// below exercises to prove the encoding itself is lossless for these /// integer fields). pub fn decode_step_canvas(enc: &EncodedStepCanvas) -> RawStepCanvas { RawStepCanvas { width: enc.width, height: enc.height, morphology: png_decode_u8_plane(&enc.morphology), elev_q: png_decode_u8_plane(&enc.elev_q), temp_dc: enc.temp_dc.values.clone(), moisture_q: png_decode_u8_plane(&enc.moisture_q), vegetation: png_decode_u8_plane(&enc.vegetation), settlement_id: enc.settlement_id.values.clone(), lake_margin_q: png_decode_u8_plane(&enc.lake_margin_q), glaciation: png_decode_u8_plane(&enc.glaciation), flooded_q: png_decode_u8_plane(&enc.flooded_q), courses: enc.courses.clone(), cliffs: enc.cliffs.clone(), } } // --------------------------------------------------------------------------- // Cache tiers (D-255(d), D-227 amendments (1)/(2)/(3)) // --------------------------------------------------------------------------- /// The D-203-shaped resident GLOBAL (rung-0) tier — always-keep, never /// evicted by either eviction axis (D-227 amendment (1): "the global tier — /// rung 0 ... alone is keep-always — exempt from both axes"). Measured /// ~8.85 MB PNG-encoded across the entire real ~267-body population — at /// that size the keep-always tier is trivially process-resident (D-255(d)). /// /// Populated LAZILY, once per body, on that body's first Atlas-open — never /// all bodies synchronously (D-255(d): "populates lazily, once per body... /// via the D-206 background queue"). This resource holds only what has /// actually been opened so far; there is no eager population step. #[derive(Debug, Default, Resource)] pub struct GlobalTierCache { entries: std::collections::BTreeMap, } impl GlobalTierCache { pub fn new() -> Self { Self::default() } /// The resident global canvas for a body, **only if it is the size being /// asked for**. /// /// D-255 extent inversion (2026-07-26): Global used to have exactly one /// possible size per body (its region grid), so body id alone was a /// complete key. It is now viewport-sized, and a body-id-only key means the /// first canvas ever built wins forever — a window resize could never take /// effect, because the stale-sized entry answered every request. /// /// Deliberately still ONE entry per body rather than one per (body, size): /// keying by size would make the keep-always tier accumulate an entry per /// distinct viewport a player ever used, which is a slow leak in a tier /// that by policy never evicts (D-227 amendment (1)). A size mismatch is /// treated as a miss instead, so the re-derive replaces it and the tier /// holds exactly the size currently in use. pub fn get(&self, body_id: &str, want: (u32, u32)) -> Option<&EncodedStepCanvas> { let canvas = self.entries.get(body_id)?; if (canvas.width, canvas.height) == want { Some(canvas) } else { None } } /// Insert (or replace) a body's global canvas. Never evicted — D-227 /// amendment (1)'s keep-always policy. Re-inserting an existing body can /// only ever produce byte-identical bytes (D-227 purity), so this is a /// no-op in practice but stays correct either way (mirrors /// `DistrictWindowCache::insert`'s own doc on this point). pub fn insert(&mut self, body_id: String, canvas: EncodedStepCanvas) { self.entries.insert(body_id, canvas); } pub fn contains(&self, body_id: &str) -> bool { self.entries.contains_key(body_id) } /// Number of bodies currently resident — observability/test helper. pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } } /// Cache key for a fixed-rung (1-5) step canvas: `(body_id, rung, center, /// extent, min_wl_m)` — mirrors `layer_proxy::DistrictWindowKey`'s shape, /// widened with `rung` (replacing `WindowGranularity`) and `extent` (a fixed /// rung's canvas is viewport-sized, so two different-extent requests at the /// same centre are different payloads and must not alias — the legacy /// carrier has no equivalent because its `n` IS `window_n`, doing double duty /// as both request size and cache-key component; this envelope's `extent` is /// a genuinely separate field per D-255(c)'s struct shape, so it must be /// keyed explicitly rather than assumed constant). pub type StepCanvasKey = (String, StepCanvasRung, (i64, i64), (u32, u32), u32); /// One cached fixed-rung entry — the encoded canvas plus the bookkeeping the /// dual-axis eviction policy needs (D-227 amendment (1)). #[derive(Debug, Clone)] struct StepCanvasEntry { canvas: EncodedStepCanvas, /// Storage-eviction axis (D-227 amendment (1) axis 2): tick of last /// access, swept on `time_since_last_visit > STORAGE_TTL[rung]` — /// applies to EVERY fixed-rung entry, geometry included (storage thrift, /// never a correctness signal — D-227 purity means geometry is never /// stale). last_accessed: SimTick, /// Staleness-eviction axis (D-227 amendment (1) axis 1): the tick this /// entry's sim-state fields (glaciation/flooded) were derived at. /// Compared against `SIM_STATE_TTL[field]` (below) at read time — a /// stale sim-state entry is evicted/re-derived even if `last_accessed` /// is fresh (a player standing in one spot with sim time advancing). /// Geometry fields never use this axis (D-227: "geometry never goes /// stale" — see the module doc on `flooded_q`/`glaciation` for why both /// are tagged sim-state-TTL here ahead of D-253 actually driving either /// field's VALUE). derived_at_tick: SimTick, } /// Per-rung storage-eviction TTL (D-227 amendment (1) axis 2: /// `time_since_last_visit(entry) > STORAGE_TTL[rung]`) — ticks since last /// access before a fixed-rung entry is evicted for storage thrift, never /// because the data is wrong. Deeper (finer) rungs get a shorter floor: they /// are both cheaper to regenerate (T-1178/T-1154: the SAME flat ~190-220 /// ns/cell parallel rate at every rung, so no rung is more expensive to /// re-derive than another — the asymmetry here is purely about how much /// ground one entry covers, not re-derive cost) and cover proportionally /// less world extent per entry, so keeping a long-unvisited Chunk-spacing /// tile around is a worse storage/usefulness trade than keeping a /// long-unvisited Region-spacing tile (which still shows something useful /// on a re-open even if the player never returns to that exact spot). /// /// Values are ticks at the standard 50 ms tick rate (D-206 precedent): /// Region ~1 hour, District ~30 min, Quarter ~15 min, Block ~5 min, Chunk /// ~2 min. These are tuning constants, not measured numbers — D-255(d) /// states "the exact cap value is a tuning constant sized in [the] cache /// spec at implementation," which this table is. /// /// **Interaction with the sim-state axis (worth stating explicitly):** at /// these values, every fixed rung's storage TTL is SHORTER than /// `sim_state_ttl_ticks`'s seasonal bucket (1 hour) — District through /// Chunk's storage axis always fires first, so the seasonal glaciation /// staleness check is presently unreachable for those four rungs (storage /// eviction beats it to the entry every time). This is a real, intended /// policy consequence, not a bug: a player fine enough into the ladder to be /// looking at Block/Chunk-spacing detail is expected to have moved on (or /// the storage sweep to have reclaimed the entry) well before a season /// passes anyway. The FINER tidal bucket (`sim_state_ttl_ticks` for a /// moon-bearing body's `flooded` field, 15 min) DOES fire before Region's /// 1-hour storage TTL — see `sim_state_ttl_ticks`'s doc — so the staleness /// axis is live wherever the game's actual driving-clock granularity is /// tighter than a rung's storage thrift window. Both axes are still /// evaluated independently on every `get()` regardless (never short-circuit /// past one because the other "usually" wins) — this note only explains /// which one usually decides the outcome at today's tuning values. pub fn storage_ttl_ticks(rung: StepCanvasRung) -> SimTick { const TICKS_PER_SEC: SimTick = 20; // 50ms tick rate, D-206 precedent match rung { // Global never reaches this table — it lives in GlobalTierCache, // exempt from both eviction axes (D-227 amendment (1)). StepCanvasRung::Global => SimTick::MAX, StepCanvasRung::Region => 3_600 * TICKS_PER_SEC, StepCanvasRung::District => 1_800 * TICKS_PER_SEC, StepCanvasRung::Quarter => 900 * TICKS_PER_SEC, StepCanvasRung::Block => 300 * TICKS_PER_SEC, StepCanvasRung::Chunk => 120 * TICKS_PER_SEC, } } /// Body driving-clock class for the SIM_STATE_TTL joint formula (D-227 /// amendment (1), the Dudley+Araminta joint formula: "flooded: tidal bucket /// moon-bearing / seasonal moonless; glaciation: seasonal"). **Honest gap:** /// `BodyParams` carries no moon-bearing signal today (no `has_moon` / /// `moon_count` field exists anywhere in this codebase — confirmed by a full /// grep of `district_profile::BodyParams` and the `bodies` systems.db /// schema this ticket read from) and there is no tidal-orbit clock /// implemented (D-253's sim-state driving clock is not built). This enum and /// [`sim_state_ttl_ticks`] wire the POLICY shape the governance amendment /// specifies so the TTL machinery is ready the moment that data exists — but /// every body classifies as [`Self::Moonless`] today (the safe, always- /// available fallback: the seasonal bucket, never the finer tidal one), /// documented here rather than silently defaulting without comment. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BodyDrivingClockClass { /// Has at least one moon — the flooded field's TTL uses the (finer) /// tidal bucket. Unreachable today (see enum doc) — reserved for when a /// moon-count signal is wired to `BodyParams`. MoonBearing, /// No moon (or unknown) — the flooded field's TTL uses the seasonal /// bucket, same as glaciation. Every body today (see enum doc). Moonless, } impl BodyDrivingClockClass { /// Classify a body — always [`Self::Moonless`] today (see enum doc). /// Signature takes `&BodyParams` (not `()`) so the call site is already /// correct the moment a moon signal is added to that struct — the /// widening is then internal to this function, no caller changes. pub fn classify(_params: &BodyParams) -> Self { BodyDrivingClockClass::Moonless } } /// The sim-state fields this cache TTL-tags ahead of D-253 (D-227 amendment /// (1)): glaciation (seasonal bucket, every body class) and flooded (tidal /// bucket if moon-bearing, else seasonal — the joint formula). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SimStateField { Glaciation, Flooded, } /// `SIM_STATE_TTL[field] = 1x the field's own fastest driving clock-bucket` /// (D-227 amendment (1)) — ticks, at the standard 50 ms tick rate. "Seasonal" /// is approximated as one in-sim season length; "tidal" as one moon-orbit /// period. Neither clock is implemented yet (see [`BodyDrivingClockClass`]'s /// doc) — these constants are the POLICY the formula specifies, wired and /// tested now so no cache-layer code changes when the real clock lands /// (only [`BodyDrivingClockClass::classify`]'s stub needs replacing). pub fn sim_state_ttl_ticks(field: SimStateField, body_class: BodyDrivingClockClass) -> SimTick { const TICKS_PER_SEC: SimTick = 20; // Placeholder in-sim durations (no calendar/orbit system landed yet to // read real values from) — seasonal: 1 real-hour-equivalent play session // as a conservative stand-in for "a season passed"; tidal: 15 real // minutes as a conservative stand-in for "one moon orbit," shorter than // seasonal per the amendment's "tidal bucket" being the FINER of the two // driving clocks. const SEASONAL_TICKS: SimTick = 3_600 * TICKS_PER_SEC; const TIDAL_TICKS: SimTick = 900 * TICKS_PER_SEC; match (field, body_class) { (SimStateField::Glaciation, _) => SEASONAL_TICKS, (SimStateField::Flooded, BodyDrivingClockClass::MoonBearing) => TIDAL_TICKS, (SimStateField::Flooded, BodyDrivingClockClass::Moonless) => SEASONAL_TICKS, } } /// Bounded cache of completed fixed-rung (1-5) step-canvas derives (D-255(d) /// Tier 2). Dual-axis eviction (D-227 amendment (1)): /// - **Storage axis** (all entries): swept on `time_since_last_visit > /// storage_ttl_ticks(rung)`. /// - **Staleness axis** (sim-state fields only): a `get` call checks /// `derived_at_tick` against `sim_state_ttl_ticks` for BOTH sim-state /// fields; if either has gone stale, the WHOLE entry is treated as a miss /// (re-deriving one field out of a flat PNG-per-field canvas isn't /// meaningfully cheaper than re-deriving all eight — the row-chunked /// derive computes every field in one pass per T-1178/T-1154 — so /// staleness invalidates the entry wholesale, matching D-225's /// whole-payload-together precedent this ticket already follows for the /// wire shape). #[derive(Debug, Default, Resource)] pub struct StepCanvasCache { entries: std::collections::BTreeMap, order: std::collections::VecDeque, capacity: usize, } /// Default capacity — same order of magnitude as /// `DISTRICT_WINDOW_CACHE_CAPACITY` (256), generous relative to per-entry /// size (a handful of PNG-encoded `Vec`s) since several fixed-rung /// canvases can legitimately be live per body (a player panning/zooming). pub const STEP_CANVAS_CACHE_CAPACITY: usize = 256; impl StepCanvasCache { pub fn new(capacity: usize) -> Self { Self { entries: std::collections::BTreeMap::new(), order: std::collections::VecDeque::new(), capacity, } } /// Look up a cached fixed-rung canvas, applying BOTH eviction axes at /// read time (D-227 amendment (1)): a storage-stale OR sim-state-stale /// entry reads as a miss (`None`) and is dropped from the cache, exactly /// like `DistrictWindowCache::get`'s "no time component" contract /// extended with the two real time axes this ticket's fields require. pub fn get( &mut self, key: &StepCanvasKey, current_tick: SimTick, body_class: BodyDrivingClockClass, ) -> Option { let rung = key.1; let evict = match self.entries.get(key) { Some(entry) => { let storage_stale = current_tick.saturating_sub(entry.last_accessed) > storage_ttl_ticks(rung); let glaciation_stale = current_tick.saturating_sub(entry.derived_at_tick) > sim_state_ttl_ticks(SimStateField::Glaciation, body_class); let flooded_stale = current_tick.saturating_sub(entry.derived_at_tick) > sim_state_ttl_ticks(SimStateField::Flooded, body_class); storage_stale || glaciation_stale || flooded_stale } None => return None, }; if evict { self.entries.remove(key); self.order.retain(|k| k != key); return None; } // Bump last_accessed on hit — storage axis is a recency policy. if let Some(entry) = self.entries.get_mut(key) { entry.last_accessed = current_tick; Some(entry.canvas.clone()) } else { None } } /// Insert a freshly-derived canvas, evicting the oldest insertion-order /// entry first if at capacity (mirrors `DistrictWindowCache::insert`'s /// FIFO-by-insertion discipline — window/canvas requests are /// comparatively rare and cheap to re-derive on a genuine miss, so exact /// LRU bookkeeping isn't worth it beyond the read-time recency bump /// `get` already does for the storage axis). pub fn insert(&mut self, key: StepCanvasKey, canvas: EncodedStepCanvas, current_tick: SimTick) { if !self.entries.contains_key(&key) { if self.entries.len() >= self.capacity { if let Some(oldest) = self.order.pop_front() { self.entries.remove(&oldest); } } self.order.push_back(key.clone()); } self.entries.insert( key, StepCanvasEntry { canvas, last_accessed: current_tick, derived_at_tick: current_tick, }, ); } pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } } // --------------------------------------------------------------------------- // Serving (D-255(c)/(d) — mirrors layer_proxy::serve_district_window / // handle_atlas_request's poll-cache-enqueue model) // --------------------------------------------------------------------------- /// Resolve the body's radius (needed to size a Global-rung request's canvas /// and to place fixed-rung cells on a curved body) via `body_params_reader`. /// Shared by the serving entry point so a read failure produces one /// consistent `Error` response shape. fn read_body_params( body_params_reader: Option<&BodyParamsReader>, body_id: &str, ) -> Result { let reader = body_params_reader.ok_or_else(|| "no body_params_reader wired".to_string())?; reader .read_body_params(body_id) .map_err(|e| format!("body_params read failed: {e}")) } /// Serve one [`StepCanvasRequest`] (D-255(c)/(d)): Global rung → the /// always-keep [`GlobalTierCache`], lazily populated via the D-206 /// background queue on first open; every fixed rung → [`StepCanvasCache`]'s /// dual-axis-evicted tier, same cache-hit/miss/enqueue/Pending model /// `layer_proxy::serve_district_window` already established. /// /// This function does NOT itself run the Rayon derive — a miss enqueues a /// [`GenWorkItem::DeriveStepCanvas`] and returns `Pending` (the D-225 /// poll-and-recheck-cache pattern every other layer already uses), mirroring /// `serve_district_window`'s binding serving model exactly (D-255(d): "never /// inline... on the same Rayon queue as every other expensive atlas path"). /// /// `placements` supplies `settlement_id` coverage (see /// [`settlement_ids_for_canvas`]'s doc) — sourced from the body's cached /// [`crate::atlas::body_world_state::BodyWorldState::placements`], NOT /// pre-resolved DB-free the way `body_params`/`AnalyzeBody`'s /// cities/dominant_faction are. This means `settlement_id` coverage is only /// available once the body's own `AnalyzeBody` cascade has placed /// settlements (same as `layer_proxy::build_settlement_layer`'s own /// `None`-until-placed behavior) — an empty slice (the common case for a /// body whose whole-body cache entry doesn't exist yet, or exists but hasn't /// reached Layer 3) simply means every cell reports `settlement_id: 0`, not /// an error; the canvas still derives and serves normally. #[allow(clippy::too_many_arguments)] pub fn serve_step_canvas_request( req: &StepCanvasRequest, global_cache: &mut GlobalTierCache, canvas_cache: &mut StepCanvasCache, queue: &GenerationQueue, resolver: &BodySourceResolver, body_params_reader: Option<&BodyParamsReader>, placements: &[CityPlacement], world_seed: u64, current_tick: SimTick, conn_id: ConnectionId, ) -> StepCanvasResponse { let min_wl_m = quantize_min_wl_m_for_rung(req.min_wl_m); // PR #201 review, Hoshe finding 1 — clamp the wire extent HERE, once, at // the request boundary, BEFORE it reaches the cache key or the // background work item (never trust the wire — the exact discipline // `layer_proxy::serve_district_window` already applies to `window_n`/ // `min_wl_m` before either touches its own cache key). `Global` ignores // the wire extent entirely (ratified in `resolve_canvas_extent`'s own // doc) — its echoed/keyed extent is a fixed sentinel `(0, 0)` rather // than the unclamped wire value, so a `Global` request's cache key can // never vary by the client's (ignored) extent field. // D-255 extent inversion (2026-07-26): Global's wire extent is REAL now. // // This used to be `if req.rung.is_global() { (0, 0) }` — a sentinel, on the // then-true premise that Global's canvas came from the body's region grid // and the client's extent field was meaningless. The inversion made Global // viewport-sized, and this line silently outlived it: every Global request // arrived downstream as (0,0), clamped to (1,1), and `resolve_canvas_extent` // floored it to a **2x1 canvas** — the two coloured blocks Jeroen kept // seeing. The viewport-sizing fix committed on 2026-07-26 was correct and // completely unreachable, because the extent was destroyed one layer above // it; its tests passed by calling `resolve_canvas_extent` directly rather // than through this serve path. let extent = clamp_step_canvas_extent(req.extent); let body_params = match read_body_params(body_params_reader, &req.body_id) { Ok(p) => p, Err(e) => { tracing::warn!(body_id = %req.body_id, error = %e, "step canvas request: body_params unavailable"); return StepCanvasResponse { body_id: req.body_id.clone(), rung: req.rung, center: req.center, extent, min_wl_m, status: StepCanvasStatus::Error(e), canvas: None, }; } }; let body_class = BodyDrivingClockClass::classify(&body_params); if req.rung.is_global() { // The size this request will actually resolve to — the cache must be // consulted against THAT, not against the raw wire extent, because // Global fits the largest 2:1 canvas inside what was asked for. let want = resolve_canvas_extent(req.rung, extent, body_params.body_radius_km.unwrap_or(0.0)); if let Some(canvas) = global_cache.get(&req.body_id, want) { return StepCanvasResponse { body_id: req.body_id.clone(), rung: req.rung, center: req.center, extent, min_wl_m, status: StepCanvasStatus::Ready, canvas: Some(canvas.clone()), }; } } else { let key: StepCanvasKey = (req.body_id.clone(), req.rung, req.center, extent, min_wl_m); if let Some(canvas) = canvas_cache.get(&key, current_tick, body_class) { return StepCanvasResponse { body_id: req.body_id.clone(), rung: req.rung, center: req.center, extent, min_wl_m, status: StepCanvasStatus::Ready, canvas: Some(canvas), }; } } // Miss — resolve the heightmap and submit a background derive (never // inline, D-255(d)). let heightmap_path = match resolver.resolve(&req.body_id) { Ok(p) => p, Err(SourceResolveError::UnknownBody(_)) | Err(SourceResolveError::NoTerrainReference { .. }) => { return StepCanvasResponse { body_id: req.body_id.clone(), rung: req.rung, center: req.center, extent, min_wl_m, status: StepCanvasStatus::NotFound, canvas: None, }; } Err(e) => { return StepCanvasResponse { body_id: req.body_id.clone(), rung: req.rung, center: req.center, extent, min_wl_m, status: StepCanvasStatus::Error(e.to_string()), canvas: None, }; } }; queue.submit_step_canvas( GenWorkItem::DeriveStepCanvas { body_id: req.body_id.clone(), conn_id, heightmap_path, sea_level: crate::atlas::layer_proxy::DEFAULT_SEA_LEVEL, body_seed: SeedChain::for_body(world_seed, &req.body_id), body_params: Box::new(body_params), placements: placements.to_vec(), rung: req.rung, center: req.center, extent, min_wl_m, }, GenPriority::Immediate, ); StepCanvasResponse { body_id: req.body_id.clone(), rung: req.rung, center: req.center, extent, min_wl_m, status: StepCanvasStatus::Pending, canvas: None, } } #[cfg(test)] mod tests { use super::*; // ----------------------------------------------------------------- // Rung vocabulary // ----------------------------------------------------------------- /// A fixed rung's EXTENT is the D-243 constant (D-255 amendment, pair /// session 2026-07-26) — the inversion moved the constant from the /// spacing side of the relation to the extent side. Global has no /// constant extent at all: it is the elastic seam. #[test] fn extent_m_matches_d243_constants() { assert_eq!( StepCanvasRung::Region.extent_m(), Some(scale::REGION_M as f64) ); assert_eq!( StepCanvasRung::District.extent_m(), Some(scale::DISTRICT_M as f64) ); assert_eq!( StepCanvasRung::Quarter.extent_m(), Some(scale::QUARTER_M as f64) ); assert_eq!( StepCanvasRung::Block.extent_m(), Some(scale::BLOCK_M as f64) ); assert_eq!( StepCanvasRung::Chunk.extent_m(), Some(scale::CHUNK_M as f64) ); assert_eq!(StepCanvasRung::Global.extent_m(), None); } /// The inversion's core contract: the SHORTER canvas axis spans exactly /// one cell of the rung's level, whatever the viewport shape, so a /// widescreen window shows more ground on the long axis rather than less /// on the short one. #[test] fn shorter_axis_spans_exactly_one_rung_cell() { for (rung, cell_m) in [ (StepCanvasRung::Region, scale::REGION_M as f64), (StepCanvasRung::District, scale::DISTRICT_M as f64), (StepCanvasRung::Quarter, scale::QUARTER_M as f64), (StepCanvasRung::Block, scale::BLOCK_M as f64), (StepCanvasRung::Chunk, scale::CHUNK_M as f64), ] { // Landscape, portrait and square canvases must all put one whole // cell across the SHORT axis — the axis is chosen by size, never // by which one happens to be the width. for (w, h) in [(960u32, 540u32), (540, 960), (700, 700)] { let spacing = rung.spacing_m(w, h, 6_238.4); let short = w.min(h) as f64; assert!( (spacing * short - cell_m).abs() < 1e-9, "{rung:?} at {w}x{h}: short axis spans {} m, want {cell_m} m", spacing * short ); // ...and the long axis therefore shows proportionally more. let long = w.max(h) as f64; assert!(spacing * long >= cell_m); } } } /// Spacing follows the canvas, not the rung: halving the cell count over /// the same rung doubles the pitch (a clamped canvas covers the same /// ground more coarsely — it does not cover less ground). #[test] fn spacing_scales_inversely_with_cell_count() { let fine = StepCanvasRung::District.spacing_m(960, 540, 6_238.4); let coarse = StepCanvasRung::District.spacing_m(480, 270, 6_238.4); assert!((coarse - fine * 2.0).abs() < 1e-9, "{coarse} vs {fine}"); } /// Global is the one rung whose spacing comes from the body: the full /// 2πR circumference wraps the canvas WIDTH (equirectangular), so a /// bigger body at the same cell count yields a proportionally coarser /// gridunit. This is D-243's elastic seam, and the only place a body /// radius enters the ladder at all. #[test] fn global_spacing_is_circumference_over_width() { let r_km = 6_238.4_f64; let spacing = StepCanvasRung::Global.spacing_m(960, 480, r_km); let circumference_m = 2.0 * std::f64::consts::PI * r_km * 1_000.0; assert!((spacing * 960.0 - circumference_m).abs() < 1e-6); // Twice the body, twice the pitch at the same cell count. let double = StepCanvasRung::Global.spacing_m(960, 480, r_km * 2.0); assert!((double - spacing * 2.0).abs() < 1e-9); } /// Degenerate canvases must not divide by zero — a zero axis clamps to /// one cell rather than producing an infinity that would poison every /// world-metre computation downstream. #[test] fn zero_extent_does_not_divide_by_zero() { for rung in [StepCanvasRung::Global, StepCanvasRung::Chunk] { let spacing = rung.spacing_m(0, 0, 6_238.4); assert!(spacing.is_finite(), "{rung:?} produced {spacing}"); } } #[test] fn only_global_is_global() { assert!(StepCanvasRung::Global.is_global()); for rung in [ StepCanvasRung::Region, StepCanvasRung::District, StepCanvasRung::Quarter, StepCanvasRung::Block, StepCanvasRung::Chunk, ] { assert!(!rung.is_global(), "{rung:?} must not be_global"); } } /// Only Global rides the envelope-only derive (D-255 amendment /// 2026-07-26; corrected here 2026-07-28). /// /// This test previously asserted `Global | Region`, and it passed happily /// for two days after the client's mirror /// (`step_canvas_transport.gd::is_orbital_rung`) had already been changed /// to Global-only. Neither this test nor its GDScript twin compares itself /// to the other, so the two sides drifted apart while both suites stayed /// green — the mirror said Region took the full derive, the authority said /// otherwise, and Region shipped with no courses. /// /// **If you change this, change `is_orbital_rung` in the same commit.** /// The two cannot be cross-checked automatically (different languages, no /// shared fixture for this predicate), so the coupling is held by this /// note and its counterpart on the client. #[test] fn only_global_uses_orbital_derive() { assert!(StepCanvasRung::Global.uses_orbital_derive()); for rung in [ StepCanvasRung::Region, StepCanvasRung::District, StepCanvasRung::Quarter, StepCanvasRung::Block, StepCanvasRung::Chunk, ] { assert!( !rung.uses_orbital_derive(), "{rung:?} must use the full classification derive, not orbital" ); } } /// Courses are invented at EVERY rung now (D-261) — the gate that withheld /// them from the orbital rung is why the whole-body map had no rivers. /// Whether a course is worth DRAWING is a client-side decision measured in /// screen pixels, which the server cannot make. #[test] fn no_rung_suppresses_course_invention() { // The predicate the old gate keyed on must no longer imply "no // courses" for anything: Global rides the orbital derive AND carries // courses, a combination that was previously impossible. assert!( StepCanvasRung::Global.uses_orbital_derive(), "Global still derives terrain envelope-only" ); } #[test] fn global_cell_counts_only_for_global_rung() { assert!(StepCanvasRung::Region.global_cell_counts(6371.0).is_none()); assert!(StepCanvasRung::Chunk.global_cell_counts(6371.0).is_none()); let (cols, rows) = StepCanvasRung::Global .global_cell_counts(6371.0) .expect("Global has a cell-count shape"); assert_eq!(cols, scale::regions_per_equator(6371.0)); assert_eq!(rows, (cols / 2).max(1)); } // ----------------------------------------------------------------- // Wire extent clamp (PR #201 review, Hoshe finding 1 — DoS hardening) // ----------------------------------------------------------------- #[test] fn oversized_per_axis_extent_clamps_to_the_axis_cap() { let (w, h) = clamp_step_canvas_extent((50_000, 100)); assert_eq!(w, STEP_CANVAS_MAX_EXTENT_AXIS); assert_eq!(h, 100); let (w, h) = clamp_step_canvas_extent((100, 50_000)); assert_eq!(w, 100); assert_eq!(h, STEP_CANVAS_MAX_EXTENT_AXIS); } #[test] fn total_cell_overflow_clamps_even_when_both_axes_are_individually_legal() { // Both axes are within STEP_CANVAS_MAX_EXTENT_AXIS (3,840) but their // product (3,840 * 3,840 = 14,745,600) exceeds // STEP_CANVAS_MAX_EXTENT_CELLS (8,294,400) — the per-axis clamp // alone does NOT catch this, only the total-cell stage does. let requested = (3_840u32, 3_840u32); let requested_cells = requested.0 as u64 * requested.1 as u64; assert!( requested_cells > STEP_CANVAS_MAX_EXTENT_CELLS, "test setup: this case must actually exceed the cap" ); let (w, h) = clamp_step_canvas_extent(requested); let clamped_cells = w as u64 * h as u64; assert!( clamped_cells <= STEP_CANVAS_MAX_EXTENT_CELLS, "clamped shape ({w}x{h}={clamped_cells}) must respect the total-cell cap" ); assert!(w <= STEP_CANVAS_MAX_EXTENT_AXIS && h <= STEP_CANVAS_MAX_EXTENT_AXIS); // Aspect ratio preserved (both axes were equal going in, so they // must still be equal, or within 1 of each other from the // defensive floor-rounding walk-down). assert!((w as i64 - h as i64).abs() <= 1); } #[test] fn u32_max_extent_clamps_to_the_cap_without_huge_allocation() { // The exact DoS shape Hoshe's finding named: a wire extent of // (u32::MAX, u32::MAX) must clamp down to a bounded shape BEFORE // any allocation is attempted — never overflow, never panic, never // produce a canvas anywhere near u32::MAX cells. // // Both axes clamp to STEP_CANVAS_MAX_EXTENT_AXIS (3,840) first, but // 3,840 x 3,840 = 14,745,600 > STEP_CANVAS_MAX_EXTENT_CELLS // (8,294,400) — the total-cell stage then scales BOTH axes down // together (same two-stage behavior // total_cell_overflow_clamps_even_when_both_axes_are_individually_legal // pins generically); this test additionally confirms the concrete // u32::MAX case end to end through a real derive. let (w, h) = clamp_step_canvas_extent((u32::MAX, u32::MAX)); let cells = w as u64 * h as u64; assert!(w <= STEP_CANVAS_MAX_EXTENT_AXIS); assert!(h <= STEP_CANVAS_MAX_EXTENT_AXIS); assert!( cells <= STEP_CANVAS_MAX_EXTENT_CELLS, "clamped u32::MAX request ({w}x{h}={cells}) must respect the total-cell cap" ); // End-to-end: actually run build_step_canvas with this hostile // request and confirm it derives a small, bounded canvas — not a // multi-billion-cell allocation. Uses a tiny real TerrainAnalysis // fixture so the derive itself stays fast; the point is the // ALLOCATION SIZE, which is governed by resolve_canvas_extent's // clamp regardless of how small the source heightmap is. let ta = tiny_ta(); let rn = RiverNetwork::default(); let params = BodyParams { body_radius_km: Some(6371.0), ..Default::default() }; let climate = ClimateConstants::default(); let seed = SeedChain::root(0xDEADBEEF_u64).derive(crate::seed::SeedDomain::Body, 1); let raw = build_step_canvas( seed, "dos-test", ¶ms, &ta, &rn, &[], StepCanvasRung::Chunk, (0, 0), (u32::MAX, u32::MAX), &climate, 0, ); assert_eq!(raw.width, w); assert_eq!(raw.height, h); assert_eq!(raw.morphology.len() as u64, cells); assert!( cells <= STEP_CANVAS_MAX_EXTENT_CELLS, "the ACTUAL derived+allocated canvas must respect the cap, not just the clamp function's return value" ); } #[test] fn extent_at_or_under_the_cap_is_unchanged() { // The clamp must be a no-op for any legal, already-bounded request — // it should never shrink a request that was already within budget. for (w, h) in [(1u32, 1u32), (100, 100), (3_840, 2_160), (1_920, 1_080)] { assert_eq!( clamp_step_canvas_extent((w, h)), (w, h), "({w}, {h}) is within both caps and must pass through unchanged" ); } } /// Global now SIZES to the request (D-255 amendment 2026-07-26) — it used /// to derive its cell counts from the body's region grid and discard the /// wire value, which on GJ380c meant a 191x95 canvas built from a 512x256 /// heightmap. A hostile wire value must still be clamped, but a legitimate /// one must be honoured. #[test] fn global_rung_sizes_to_the_request() { let small = resolve_canvas_extent(StepCanvasRung::Global, (480, 270), 6371.0); let large = resolve_canvas_extent(StepCanvasRung::Global, (960, 540), 6371.0); assert!( large.0 > small.0, "a bigger viewport must get a bigger Global canvas: {small:?} vs {large:?}" ); // Height-bound at a 16:9 request: 540*2 = 1080 > 960, so width wins. assert_eq!(large, (960, 480)); } /// Global's canvas is equirectangular whole-body — 360 degrees of /// longitude by 180 of latitude. It must stay exactly 2:1 at every /// viewport shape or the cells stop being square and the map shears. #[test] fn global_rung_stays_two_to_one_at_every_viewport_shape() { for req in [ (960u32, 540u32), // 16:9 landscape — height-bound (540, 960), // portrait — height is huge, width binds (700, 700), // square (4000, 100), // absurdly wide (1, 1), // degenerate (u32::MAX, u32::MAX), // adversarial ] { let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, req, 6371.0); assert_eq!( w, h * 2, "request {req:?} produced a non-2:1 canvas {w}x{h}" ); assert!(h >= 1, "request {req:?} collapsed the canvas to zero rows"); } } /// A hostile wire value must still be clamped to the canvas budget — /// sizing to the request must not become "trust the request". #[test] fn global_rung_still_clamps_an_adversarial_extent() { let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0); assert!( w <= STEP_CANVAS_MAX_EXTENT_AXIS, "width {w} escaped the axis cap" ); assert!( h <= STEP_CANVAS_MAX_EXTENT_AXIS, "height {h} escaped the axis cap" ); } /// A body with no radius is not a sphere (asteroid belt, oort cloud) and /// has no equirectangular surface to fit. It must degrade to the region /// grid — a visibly degenerate canvas — rather than a plausible-looking /// lie at whatever size the viewport happened to ask for. #[test] fn global_rung_without_a_radius_does_not_fabricate_a_surface() { let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (960, 540), 0.0); assert_eq!((w, h), (1, 1)); } // ----------------------------------------------------------------- // Station spacing — the S2 absolute floor is retired (see the note at // the top of this module where the constant used to live) // ----------------------------------------------------------------- /// The S2 station-spacing floor existed because course resampling ran at /// the rung's own spacing, so a course picked up proportionally MORE /// stations the deeper the rung — measured at +85% cost at Chunk. The /// extent inversion removes the mechanism rather than capping it: station /// pitch is now the canvas pitch, and the canvas cell count is /// viewport-driven and identical at every rung, so a course crossing the /// canvas gets the SAME station budget however deep you scroll. /// /// This test guards that property directly — if a future change reties /// station density to absolute metres, the deep rungs will diverge here /// and this fails before the cost regression ships. #[test] fn station_budget_is_rung_independent() { let (w, h) = (960u32, 540u32); let budget = |rung: StepCanvasRung| { let spacing = rung.spacing_m(w, h, 6_238.4); // Stations a course spanning the canvas's long axis would take. (rung.extent_m().unwrap() * (w as f64 / h as f64)) / spacing }; let district = budget(StepCanvasRung::District); for rung in [ StepCanvasRung::Region, StepCanvasRung::Quarter, StepCanvasRung::Block, StepCanvasRung::Chunk, ] { let got = budget(rung); assert!( (got - district).abs() < 1e-6, "{rung:?} station budget {got} diverges from District's {district} — \ station density must not scale with rung depth" ); } // And that shared budget is the canvas width, not an absolute metre // figure: ~one station per gridunit across the long axis. assert!((district - w as f64).abs() < 1e-6, "{district} vs {w}"); } // ----------------------------------------------------------------- // GlobalTierCache — keep-always, never evicted // ----------------------------------------------------------------- fn dummy_canvas() -> EncodedStepCanvas { EncodedStepCanvas { width: 1, height: 1, morphology: png_encode_u8_plane(1, 1, &[0]), elev_q: png_encode_u8_plane(1, 1, &[0]), temp_dc: EncodedTempField { values: vec![0] }, moisture_q: png_encode_u8_plane(1, 1, &[0]), vegetation: png_encode_u8_plane(1, 1, &[0]), settlement_id: EncodedSettlementField { values: vec![0] }, lake_margin_q: png_encode_u8_plane(1, 1, &[0]), glaciation: png_encode_u8_plane(1, 1, &[0]), flooded_q: png_encode_u8_plane(1, 1, &[0]), courses: Vec::new(), cliffs: Vec::new(), } } #[test] fn global_tier_cache_is_keep_always() { let mut cache = GlobalTierCache::new(); cache.insert("BodyA".to_string(), dummy_canvas()); assert!(cache.contains("BodyA")); assert_eq!(cache.len(), 1); // No eviction API exists on GlobalTierCache at all — structurally // keep-always (D-227 amendment (1)). Re-fetch confirms the entry // is still there with no time/tick argument involved. assert!(cache.get("BodyA", (1, 1)).is_some()); } /// A resident canvas of the WRONG SIZE must read as a miss, so the /// re-derive replaces it. Post-extent-inversion Global is viewport-sized, /// and a body-id-only key meant the first canvas ever built answered every /// later request — a window resize could never take effect. Kept as one /// entry per body (not per size) so the keep-always tier cannot accumulate /// an entry per viewport a player has ever used. #[test] fn global_tier_cache_misses_on_a_size_change() { let mut cache = GlobalTierCache::new(); cache.insert("BodyA".to_string(), dummy_canvas()); // 1x1 assert!(cache.get("BodyA", (1, 1)).is_some(), "same size must hit"); assert!( cache.get("BodyA", (960, 480)).is_none(), "a different size must MISS, or a resize can never take effect" ); assert_eq!(cache.len(), 1, "still one entry per body, not one per size"); } // ----------------------------------------------------------------- // StepCanvasCache — dual-axis eviction // ----------------------------------------------------------------- #[test] fn storage_axis_evicts_after_ttl_regardless_of_sim_state_freshness() { let mut cache = StepCanvasCache::new(8); let key: StepCanvasKey = ( "BodyA".to_string(), StepCanvasRung::Chunk, (0, 0), (4, 4), 0, ); cache.insert(key.clone(), dummy_canvas(), 0); let ttl = storage_ttl_ticks(StepCanvasRung::Chunk); // Just inside the TTL — still a hit. assert!(cache .get(&key, ttl, BodyDrivingClockClass::Moonless) .is_some()); // Re-insert (get() above didn't evict, but DID bump last_accessed to // `ttl` — re-derive the timeline from a fresh insert at tick 0 for a // clean boundary check). let mut cache2 = StepCanvasCache::new(8); cache2.insert(key.clone(), dummy_canvas(), 0); // Just past the TTL — storage-evicted, reads as a miss. assert!(cache2 .get(&key, ttl + 1, BodyDrivingClockClass::Moonless) .is_none()); assert_eq!(cache2.len(), 0, "stale entry must be dropped from the map"); } #[test] fn staleness_axis_evicts_sim_state_independent_of_storage_recency() { let mut cache = StepCanvasCache::new(8); let key: StepCanvasKey = ( "BodyA".to_string(), StepCanvasRung::Region, (0, 0), (4, 4), 0, ); cache.insert(key.clone(), dummy_canvas(), 0); // Moon-bearing: flooded's TTL uses the FINER tidal bucket // (sim_state_ttl_ticks), which is tighter than Region's own storage // TTL — this must still evict, because the staleness axis is // checked independently of (and can bite before) the storage axis. let flooded_ttl = sim_state_ttl_ticks(SimStateField::Flooded, BodyDrivingClockClass::MoonBearing); let storage_ttl = storage_ttl_ticks(StepCanvasRung::Region); assert!( flooded_ttl < storage_ttl, "test assumes the sim-state TTL is the tighter bound for this rung/body-class" ); assert!(cache .get(&key, flooded_ttl + 1, BodyDrivingClockClass::MoonBearing) .is_none()); } #[test] fn fresh_entry_within_both_ttls_is_a_hit_and_bumps_last_accessed() { let mut cache = StepCanvasCache::new(8); let key: StepCanvasKey = ( "BodyA".to_string(), StepCanvasRung::District, (0, 0), (4, 4), 0, ); cache.insert(key.clone(), dummy_canvas(), 0); assert!(cache .get(&key, 1, BodyDrivingClockClass::Moonless) .is_some()); assert_eq!(cache.len(), 1, "a fresh hit must not evict the entry"); } #[test] fn capacity_evicts_oldest_insertion_first() { let mut cache = StepCanvasCache::new(2); let key_a: StepCanvasKey = ("A".to_string(), StepCanvasRung::Chunk, (0, 0), (4, 4), 0); let key_b: StepCanvasKey = ("B".to_string(), StepCanvasRung::Chunk, (0, 0), (4, 4), 0); let key_c: StepCanvasKey = ("C".to_string(), StepCanvasRung::Chunk, (0, 0), (4, 4), 0); cache.insert(key_a.clone(), dummy_canvas(), 0); cache.insert(key_b.clone(), dummy_canvas(), 0); assert_eq!(cache.len(), 2); cache.insert(key_c.clone(), dummy_canvas(), 0); assert_eq!(cache.len(), 2, "capacity must stay bounded"); assert!( cache .get(&key_a, 0, BodyDrivingClockClass::Moonless) .is_none(), "oldest entry (A) should have been evicted" ); assert!(cache .get(&key_b, 0, BodyDrivingClockClass::Moonless) .is_some()); assert!(cache .get(&key_c, 0, BodyDrivingClockClass::Moonless) .is_some()); } // ----------------------------------------------------------------- // Quantization reuse // ----------------------------------------------------------------- #[test] fn quantize_min_wl_m_for_rung_matches_layer_proxy_quantization() { // Reusing the SAME banding function as the legacy carrier (see this // function's doc) — spot-check a few values snap identically. for raw in [0u32, 1_000, 4_096, 100_000] { assert_eq!( quantize_min_wl_m_for_rung(raw), crate::atlas::layer_proxy::quantize_min_wl_m(raw) ); } } // ----------------------------------------------------------------- // settlement_id coverage (Araminta round-1/round-2's converged field) // ----------------------------------------------------------------- fn test_placement(city_id: u64, position: (u16, u16)) -> CityPlacement { use crate::simulation::generator::{ ArrangementPattern, AttractorType, FoundingOrientation, PoliticalArchetype, }; CityPlacement { city_id, name: format!("City{city_id}"), position, attractor_type: AttractorType::PlainCenter, score: 1000, synthetic: false, political_archetype: PoliticalArchetype::Pioneer, arrangement_pattern: ArrangementPattern::RibbonDevelopment, founding_orientation: FoundingOrientation::Cardinal, population: 100_000, is_capital: false, is_standalone_hq: false, } } fn tiny_ta() -> TerrainAnalysis { use crate::atlas::drainage; use crate::atlas::heightmap::BodyHeightmap; let (w, h) = (32u32, 16u32); let n = (w * h) as usize; let hm = BodyHeightmap { body_id: "settlement-test".into(), width: w, height: h, data: vec![0.5f32; n], sea_level: 0.3, }; let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); TerrainAnalysis::analyze(&hm, &dr) } #[test] fn settlement_ids_for_canvas_marks_coverage_near_the_anchor_and_zero_elsewhere() { let ta = tiny_ta(); let body_radius_km = Some(6371.0); // Anchor the settlement at the working-grid centre so pixel_to_world_m // places it near world-metres origin. let placement = test_placement(7, ((ta.h / 2) as u16, (ta.w / 2) as u16)); let (anchor_wx, anchor_wy) = pixel_to_world_m( placement.position.0 as f64, placement.position.1 as f64, ta.w, ta.h, body_radius_km, ); // A small canvas centred exactly on the settlement, at Chunk spacing // (64 m/cell) — well within SETTLEMENT_COVERAGE_RADIUS_M (1,500 m) at // the centre cell, and far outside it at the canvas edges (a // 24x18-cell canvas at 64 m spacing spans up to ~768/576 m from // centre on each axis — the corner cells are ~960 m from centre, // inside the radius; use a coarser rung to guarantee an // outside-radius cell exists). // A fixture pitch chosen for the geometry above, no longer read off a // rung: post-inversion a rung's spacing depends on the canvas size, so // `Block.spacing_m(24, 18, ..)` would be ~7 m and put every cell inside // the coverage radius, quietly destroying what this test checks. let step_m = 128.0; // m/cell let (width, height) = (24u32, 18u32); let half_w = (width / 2) as i32; let half_h = (height / 2) as i32; let ids = settlement_ids_for_canvas( std::slice::from_ref(&placement), &ta, body_radius_km, (anchor_wx, anchor_wy), half_w, half_h, width, height, step_m, ); // Centre cell (row=half_h, col=half_w) sits exactly at the anchor — // must carry the settlement's id. let centre_i = (half_h as usize) * (width as usize) + half_w as usize; assert_eq!( ids[centre_i], 7, "the cell exactly at the settlement anchor must carry its city_id" ); // Far corner cell — (half_w*128m, half_h*128m) offset from centre, // well beyond the 1,500 m radius for a 24x18 canvas at 128 m // spacing (corner offset ~= sqrt((12*128)^2 + (9*128)^2) ~= 1,920 m). let corner_i = 0usize; // row=0, col=0 — the top-left corner assert_eq!( ids[corner_i], 0, "a cell far from every settlement anchor must read 0 (no coverage)" ); } #[test] fn settlement_ids_for_canvas_ties_resolve_to_lowest_city_id() { let ta = tiny_ta(); let body_radius_km = Some(6371.0); // Two settlements at the SAME position — a degenerate but legal tie // case (two attractors resolving to the same pixel). let a = test_placement(9, ((ta.h / 2) as u16, (ta.w / 2) as u16)); let b = test_placement(3, ((ta.h / 2) as u16, (ta.w / 2) as u16)); let (anchor_wx, anchor_wy) = pixel_to_world_m( a.position.0 as f64, a.position.1 as f64, ta.w, ta.h, body_radius_km, ); let ids = settlement_ids_for_canvas( &[a, b], &ta, body_radius_km, (anchor_wx, anchor_wy), 1, 1, 3, 3, 64.0, // fixture pitch — see settlement_ids_for_canvas's own test above ); let (centre_row, centre_col) = (1usize, 1usize); let centre_i = centre_row * 3 + centre_col; assert_eq!(ids[centre_i], 3, "tie must resolve to the LOWEST city_id"); } #[test] fn settlement_ids_for_canvas_empty_placements_is_all_zero() { let ta = tiny_ta(); let ids = settlement_ids_for_canvas( &[], &ta, Some(6371.0), (0.0, 0.0), 2, 2, 4, 4, 64.0, // fixture pitch — see settlement_ids_for_canvas's own test above ); assert!(ids.iter().all(|&v| v == 0)); } #[test] fn build_step_canvas_global_rung_reports_zero_settlement_coverage() { // Global's canvas is one-gridunit-per-region — settlement footprints // never meaningfully "cover" a cell at that spacing (see // build_step_canvas's doc) — settlement_id must be all-zero there // regardless of placements passed in. let ta = tiny_ta(); let rn = RiverNetwork::default(); let params = BodyParams { body_radius_km: Some(6371.0), ..Default::default() }; let climate = ClimateConstants::default(); let seed = SeedChain::root(0x5E77_u64).derive(crate::seed::SeedDomain::Body, 1); let placement = test_placement(1, ((ta.h / 2) as u16, (ta.w / 2) as u16)); let raw = build_step_canvas( seed, "settlement-test", ¶ms, &ta, &rn, std::slice::from_ref(&placement), StepCanvasRung::Global, (0, 0), (4, 4), &climate, 0, ); assert!(raw.settlement_id.iter().all(|&v| v == 0)); } // ----------------------------------------------------------------- // T-1213 diagnostic — where the ladder goes flat // ----------------------------------------------------------------- /// Per-field variance down the rung ladder on a REAL body, at the real /// spacing, through the production canvas builder. /// /// The descent ladder captured on 2026-08-06 showed every rung below /// Global rendering a uniform colour field. This reports WHICH field is /// flat and by how much, so the un-summarisation work targets a measured /// gap rather than an inferred one — the captures show the symptom, this /// shows which of the six derived fields produces it. /// /// Reference numbers it exists to explain: Ferrath's heightmap is 1024x512 /// over a 38,089 km circumference = 37.2 km per source pixel. Global draws /// at ~35.3 km/gridunit (about 1:1 with the source, and reads as a world); /// Region at 0.379 km/gridunit is ~98x finer than any stored datum, and /// District at ~0.0038 km/gridunit is ~9,800x finer. Everything below /// Global is therefore invented, not sampled. /// /// `#[ignore]`: runs the full cascade for a body. Diagnostic, not a gate. /// /// ```text /// cargo test --lib t1213_ladder_field_variance -- --ignored --nocapture /// ``` #[test] #[ignore = "diagnostic — full cascade for one body; run explicitly"] fn t1213_ladder_field_variance() { let body = "GJ820Bc"; let Ok((snapshot, params)) = crate::atlas::believability::cascade_snapshot_for_body(42, body) else { eprintln!("skip: {body} not loadable"); return; }; // `terrain_analysis` is transient and always None on a returned // snapshot — re-derive through the same call the cascade used. See // `believability::cascade_snapshot_for_body`'s doc. let (_l1, ta) = crate::atlas::layer1::run_layer1_with_moisture( &snapshot.heightmap, crate::atlas::district_profile::derive_moisture_ceiling_q(¶ms), ); let river_network = snapshot .layer1 .as_ref() .map(|l1| l1.river_network.clone()) .unwrap_or_default(); let placements = snapshot .layer3 .as_ref() .map(|l3| l3.placements.clone()) .unwrap_or_default(); let climate = ClimateConstants::default(); let seed = SeedChain::root(42).derive(crate::seed::SeedDomain::Body, 1); // The land point the descent-ladder captures are anchored on (the // cascade's highest-scoring placement on this body). let center = (29_422_009_i64, -5_675_959_i64); // A 1290x540 canvas is what a 3440x1440 window produces at the 2x2 // display ratio — the shape the captures were taken at. let extent = (1290u32, 540u32); for rung in [ StepCanvasRung::Global, StepCanvasRung::Region, StepCanvasRung::District, StepCanvasRung::Quarter, StepCanvasRung::Block, StepCanvasRung::Chunk, ] { let raw = build_step_canvas( seed, body, ¶ms, &ta, &river_network, &placements, rung, center, extent, &climate, 0, ); let spacing = rung.spacing_m(raw.width, raw.height, params.body_radius_km.unwrap_or(0.0)); let uniq_u8 = |v: &Vec| { v.iter().collect::>().len() }; let span_u8 = |v: &Vec| match (v.iter().min(), v.iter().max()) { (Some(a), Some(b)) => *b as i32 - *a as i32, _ => 0, }; println!( "{:8?} {:>9.1} m/gu {}x{} elev[uniq {:3} span {:3}] moist[uniq {:3} span {:3}] \ morph[uniq {:2}] veg[uniq {:2}] glac[uniq {:2}] courses {}", rung, spacing, raw.width, raw.height, uniq_u8(&raw.elev_q), span_u8(&raw.elev_q), uniq_u8(&raw.moisture_q), span_u8(&raw.moisture_q), uniq_u8(&raw.morphology), uniq_u8(&raw.vegetation), uniq_u8(&raw.glaciation), raw.courses.len(), ); } } }