Files
settled-reach/server/src/atlas/step_canvas.rs
T
jpmschweitzerandClaude 145e3c8b11 style(simulation): cargo fmt the extent-inversion tests
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>
2026-07-27 01:38:42 +02:00

2359 lines
104 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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",
&params,
&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",
&params,
&ta,
&rn,
std::slice::from_ref(&placement),
StepCanvasRung::Global,
(0, 0),
(4, 4),
&climate,
0,
);
assert!(raw.settlement_id.iter().all(|&v| v == 0));
}
}