Hand-written test bodies in step_canvas.rs did not match rustfmt. Caught by the pre-push gate, which is exactly its job — team-patterns.md's note that fmt auto-fixes and clippy is a quick lead patch, rather than something agents should pre-emptively duplicate. No behaviour change. Co-Authored-By: Claude <noreply@anthropic.com>
2359 lines
104 KiB
Rust
2359 lines
104 KiB
Rust
//! 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 spacing (D-243 `QUARTER_M`).
|
||
Quarter,
|
||
/// Rung 4 — 128 m spacing (D-243 `BLOCK_M`).
|
||
Block,
|
||
/// Rung 5 — 64 m spacing (D-243 `CHUNK_M`), the deepest Atlas rung
|
||
/// (D-255(a): tile/voxel is Phase-5 in-world content, never an Atlas
|
||
/// rung).
|
||
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<f64> {
|
||
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`] and [`Self::Region`] both ride `derive_orbital_at_metres`
|
||
/// (the coarse envelope-only derivation — no invented terrain, D-243
|
||
/// region/orbital spacing sits below `detail_scatter`'s own octave floor);
|
||
/// every fixed sub-region rung rides `derive_at_metres` (full
|
||
/// classification, courses-aware). This mirrors
|
||
/// `layer_proxy::derive_window_cell`'s existing `WindowGranularity::Region`
|
||
/// vs `District | Quarter` split, extended down through Block/Chunk.
|
||
pub fn uses_orbital_derive(self) -> bool {
|
||
matches!(self, StepCanvasRung::Global | StepCanvasRung::Region)
|
||
}
|
||
|
||
/// 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<u8>,
|
||
}
|
||
|
||
/// `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<i16>,
|
||
}
|
||
|
||
/// `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<u32>,
|
||
}
|
||
|
||
/// 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<RiverCourse>,
|
||
/// Carved-gorge segments (Tyre round-2 §(a.7)), sparse MessagePack-native.
|
||
#[serde(default)]
|
||
pub cliffs: Vec<CliffSegment>,
|
||
}
|
||
|
||
/// 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<EncodedStepCanvas>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// (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<u8>,
|
||
pub elev_q: Vec<u8>,
|
||
pub temp_dc: Vec<i16>,
|
||
pub moisture_q: Vec<u8>,
|
||
pub vegetation: Vec<u8>,
|
||
/// `0` = no settlement; see [`build_step_canvas`]'s settlement-coverage
|
||
/// doc.
|
||
pub settlement_id: Vec<u32>,
|
||
pub glaciation: Vec<u8>,
|
||
pub flooded_q: Vec<u8>,
|
||
/// 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<u8>,
|
||
pub courses: Vec<RiverCourse>,
|
||
pub cliffs: Vec<CliffSegment>,
|
||
}
|
||
|
||
/// 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<f64>,
|
||
center_world_m: (f64, f64),
|
||
half_w: i32,
|
||
half_h: i32,
|
||
width: u32,
|
||
height: u32,
|
||
step_m: f64,
|
||
) -> Vec<u32> {
|
||
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<u32> = 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(
|
||
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<InventedCourse> {
|
||
if rung.uses_orbital_derive() {
|
||
return Vec::new();
|
||
}
|
||
// 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 station_spacing_m = step_m;
|
||
let (win_x0, win_y0, win_x1, win_y1) = canvas_rect;
|
||
|
||
let edges = river_course::build_edges(river_network);
|
||
let mut courses = Vec::new();
|
||
for edge in &edges {
|
||
let anchor_a = crate::atlas::district_profile::pixel_to_world_m(
|
||
edge.upstream.1 as f64,
|
||
edge.upstream.0 as f64,
|
||
ta.w,
|
||
ta.h,
|
||
params.body_radius_km,
|
||
);
|
||
let anchor_b = crate::atlas::district_profile::pixel_to_world_m(
|
||
edge.downstream.1 as f64,
|
||
edge.downstream.0 as f64,
|
||
ta.w,
|
||
ta.h,
|
||
params.body_radius_km,
|
||
);
|
||
let chord_m =
|
||
((anchor_a.0 - anchor_b.0).powi(2) + (anchor_a.1 - anchor_b.1).powi(2)).sqrt();
|
||
// Same inflation fraction layer_proxy::COURSE_BBOX_INFLATION_FRACTION
|
||
// uses — mirrors the Stage-B peak-amplitude bound exactly (0.08 =
|
||
// STAGE_B_PEAK_FRACTION_OF_CHORD, pinned equal by a const assert in
|
||
// layer_proxy.rs).
|
||
let inflate_m = chord_m * 0.08;
|
||
let (bx0, bx1) = (
|
||
anchor_a.0.min(anchor_b.0) - inflate_m,
|
||
anchor_a.0.max(anchor_b.0) + inflate_m,
|
||
);
|
||
let (by0, by1) = (
|
||
anchor_a.1.min(anchor_b.1) - inflate_m,
|
||
anchor_a.1.max(anchor_b.1) + inflate_m,
|
||
);
|
||
if bx1 < win_x0 || bx0 > win_x1 || by1 < win_y0 || by0 > win_y1 {
|
||
continue;
|
||
}
|
||
courses.push(river_course::invent_course(
|
||
seed,
|
||
edge,
|
||
ta,
|
||
params,
|
||
station_spacing_m,
|
||
min_wavelength_m,
|
||
));
|
||
}
|
||
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<RiverCourse> {
|
||
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<usize> = None;
|
||
let mut last_in: Option<usize> = 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,
|
||
);
|
||
let invented_courses = if rung.is_global() {
|
||
Vec::new()
|
||
} else {
|
||
invent_courses_for_canvas(
|
||
seed,
|
||
params,
|
||
ta,
|
||
river_network,
|
||
canvas_rect,
|
||
rung,
|
||
step_m,
|
||
min_wavelength_m,
|
||
)
|
||
};
|
||
|
||
let rows: Vec<Vec<StepCanvasCell>> = (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<RiverCourse> = 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<CliffSegment> = 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<u8> {
|
||
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<String, EncodedStepCanvas>,
|
||
}
|
||
|
||
impl GlobalTierCache {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
pub fn get(&self, body_id: &str) -> Option<&EncodedStepCanvas> {
|
||
self.entries.get(body_id)
|
||
}
|
||
|
||
/// 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<StepCanvasKey, StepCanvasEntry>,
|
||
order: std::collections::VecDeque<StepCanvasKey>,
|
||
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<u8>`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<EncodedStepCanvas> {
|
||
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<BodyParams, String> {
|
||
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.
|
||
let extent = if req.rung.is_global() {
|
||
(0, 0)
|
||
} else {
|
||
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() {
|
||
if let Some(canvas) = global_cache.get(&req.body_id) {
|
||
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");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn only_global_and_region_use_orbital_derive() {
|
||
assert!(StepCanvasRung::Global.uses_orbital_derive());
|
||
assert!(StepCanvasRung::Region.uses_orbital_derive());
|
||
for rung in [
|
||
StepCanvasRung::District,
|
||
StepCanvasRung::Quarter,
|
||
StepCanvasRung::Block,
|
||
StepCanvasRung::Chunk,
|
||
] {
|
||
assert!(
|
||
!rung.uses_orbital_derive(),
|
||
"{rung:?} must use the full classification derive, not orbital"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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").is_some());
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// 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));
|
||
}
|
||
}
|