From fef422bf018b7109b653678ae8e3c260871b44ba Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 08:21:07 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(simulation):=20T-1125/T-1126=20coastli?= =?UTF-8?q?ne=20invention=20=E2=80=94=20two-tier=20character=20model,=20sh?= =?UTF-8?q?ared=20invent=5Fprimitives,=20Marine=20vegetation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-1125: new atlas/coast_invention.rs — BodyCoastEnvelope (tier 1: erosion maturity derived hydro×atmo, tectonic energy; D-240 stands, no orbital inputs) + coast_character_at (tier 2: latitude, glaciation fjord response, local wetness, seeded 100-400 km heterogeneity field). C1 multi-octave warp ~16-262 km, sub-pixel cap 0.75 px, salted stream isolated from terrain scatter and climate edge-fuzz. district_profile: shared invent_primitives front-half (warped envelope sampling + slope-independent scatter floor + shoreline carving) wired into BOTH derive_district and derive_district_profile — the batch path's scatter-free area-mean aggregate is replaced by an invented centre-point sample of the same continuous field, making silent path divergence structurally impossible. T-1126: VegetationClass::Marine appended at discriminant 6, verdict morphology-derived (open_water from OpenOcean|Lake — no fourth ocean threshold); airless precedence preserved. voxel.rs exhaustive-match arm documented unreachable (WaterBody owns ocean districts). Co-Authored-By: Claude Fable 5 --- server/src/atlas/coast_invention.rs | 388 +++++++++++++++++++++++++++ server/src/atlas/detail_scatter.rs | 4 +- server/src/atlas/district_profile.rs | 359 +++++++++++++++++++------ server/src/atlas/mod.rs | 1 + server/src/atlas/voxel.rs | 5 + 5 files changed, 673 insertions(+), 84 deletions(-) create mode 100644 server/src/atlas/coast_invention.rs diff --git a/server/src/atlas/coast_invention.rs b/server/src/atlas/coast_invention.rs new file mode 100644 index 000000000..006bc2d87 --- /dev/null +++ b/server/src/atlas/coast_invention.rs @@ -0,0 +1,388 @@ +//! Coastline / terrain-detail invention character model (T-1125, D-227). +//! +//! The D-227 promise — terrain finer than the heightmap is *invented +//! deterministically* — ended at ~500 km until T-1125: district classification +//! consumed the raw bilinear envelope, so coasts were pixel-smooth and the +//! detail-scatter amplitude was slaved to the coarse heightmap slope (≈0 on +//! exactly the low-relief coasts where coastlines live). This module carries +//! the *character* half of the fix: **where the invention is jagged, where it +//! is smooth, and how strongly it perturbs**, per Jeroen's 2026-07-17 rulings: +//! +//! - **Crinkle varies.** Never one global roughness constant. Two driver tiers: +//! - **Tier 1 — body personality envelope** ([`BodyCoastEnvelope`]): derived +//! from the *existing* authored body params only — `planet_class` (via +//! `TectonicClass`), `hydrosphere`, `atmosphere`, `body_radius_km` via the +//! pixel⇄metre seam. D-240 stands: **no axial tilt, no orbital data**. No +//! erosion field exists — erosion-proneness is *derived*: more ocean → wetter, +//! rainier → higher erosion → smoother mature coasts; dry / thin-atmosphere → +//! sharp young coasts. +//! - **Tier 2 — position character** ([`coast_character_at`]): latitude, +//! driver-tier `GlaciationGrade` (high-latitude glaciated coasts go fjordy), +//! local wetness, and a seeded long-wavelength heterogeneity field +//! ([`character_field`], the T-1084 `voxel_mosaic` pattern at 100–400 km +//! octaves) so stretches of the *same* coast differ. Longitude participates +//! for free: everything is keyed on absolute world-metre coordinates. +//! +//! ## Determinism & continuity +//! +//! Pure functions of `(seed, body, position)` (D-227/D-010). The warp and the +//! character field are C¹ multi-octave value noise — character never steps on a +//! district/region line (the D-243 edge-fuzz discipline), and is identical for +//! any window size or derivation path that asks about the same world position. +//! +//! ## Distinct hash path +//! +//! The warp stream is salted ([`COAST_WARP_SALT`]) so it is never correlated +//! with the terrain detail-scatter or the climate edge-fuzz warp — the same +//! isolation convention as `region_profile::climate_edge_warp`. + +use crate::atlas::detail_scatter::value_noise; +use crate::atlas::district_profile::{BodyParams, GlaciationGrade, TectonicClass}; +use crate::seed::splitmix64; + +/// Distinct hash-path salt for the coastline warp stream (see module docs). +const COAST_WARP_SALT: u64 = 0xC0A5_71E1_1BAD_5EED; + +/// Salt separating the warp's y-channel from its x-channel. +const COAST_WARP_Y_SALT: u64 = 0xD1F7_0CEA_2B0A_D515; + +/// Salt for the tier-2 heterogeneity field (same-coast stretches differ). +const CHARACTER_FIELD_SALT: u64 = 0x0C0A_57C4_A24C_7E12; + +/// Coast-warp octave wavelengths in metres (≈16–262 km): capes and gulfs at the +/// top, coves and inlets at the bottom. All above the 4–33 km detail-scatter +/// band, and all below ~2 heightmap pixels — the warp perturbs the coast, it +/// does not rewrite continents (the heightmap stays the truth at its own scale). +const WARP_OCTAVE_WAVELENGTHS_M: [f64; 5] = [262_144.0, 131_072.0, 65_536.0, 32_768.0, 16_384.0]; + +/// Heterogeneity-field octave wavelengths in metres (≈100–400 km): the scale on +/// which one planet's coastline personality drifts from stretch to stretch. +const CHARACTER_OCTAVE_WAVELENGTHS_M: [f64; 3] = [409_600.0, 204_800.0, 102_400.0]; + +/// Hard cap on the coast-warp displacement, in working-grid pixel units. Keeps +/// the invention strictly sub-pixel so planetary-scale reads are unchanged. +const WARP_AMPLITUDE_CAP_PX: f64 = 0.75; + +// --------------------------------------------------------------------------- +// Tier 1 — body personality envelope +// --------------------------------------------------------------------------- + +/// The body-level coastline/relief personality (T-1125 tier 1) — one per body, +/// derived from authored [`BodyParams`] only (D-240 discipline; see module docs). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BodyCoastEnvelope { + /// 0–1 — Jeroen's erosion chain: `hydro_wetness × atmo_density`. Erosion + /// needs both water and an atmosphere to rain it back down. High = mature, + /// smoothed coasts; low = sharp young coasts. + pub erosion_maturity: f64, + /// 0–1 — from [`TectonicClass`] (planet_class-derived): relief energy. + pub tectonic_energy: f64, + /// Base octave-falloff roughness (0–1) before tier-2 modulation. + pub roughness: f64, + /// Base coast-warp amplitude in working-grid pixel units. + pub warp_amplitude_px: f64, + /// Base slope-independent detail-scatter floor, in `elev_pct` units (0–1). + pub scatter_floor: f64, +} + +/// Derive the tier-1 envelope. `tectonic` is passed in (the caller hoists +/// `derive_tectonic_class`, which needs only `BodyParams`). +/// +/// The `hydrosphere` buckets mirror `derive_moisture_q`'s T-1034 vocabulary +/// grouping — keep the two in sync when the DB vocabulary grows. +pub fn body_coast_envelope(params: &BodyParams, tectonic: TectonicClass) -> BodyCoastEnvelope { + let hydro_wetness: f64 = match params.hydrosphere.as_deref().unwrap_or("none") { + "liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 1.0, + "rivers" | "rivers-lakes" | "moderate" => 0.65, + "ice" => 0.30, + "subsurface_liquid" => 0.20, + "subsurface" | "subsurface_ice" => 0.15, + "minimal" | "trace" => 0.10, + "none" => 0.0, + _ => 0.40, + }; + let atmo_density: f64 = match params.atmosphere.as_deref().unwrap_or("none") { + "dense" => 1.0, + "standard" | "breathable" => 0.85, + "toxic" => 0.70, + "thin" => 0.35, + "none" => 0.0, + _ => 0.50, + }; + let erosion_maturity = hydro_wetness * atmo_density; + let tectonic_energy: f64 = match tectonic { + TectonicClass::Volcanic => 1.0, + TectonicClass::Active | TectonicClass::TidallyForced => 0.70, + TectonicClass::Stable => 0.25, + }; + BodyCoastEnvelope { + erosion_maturity, + tectonic_energy, + // Young (uneroded) + tectonic worlds read jagged; mature wet worlds smooth. + roughness: (0.20 + 0.50 * tectonic_energy + 0.35 * (1.0 - erosion_maturity)) + .clamp(0.0, 1.0), + // Every body gets embayments; erosion widens/rounds them (rias, estuaries), + // tectonic energy adds displacement of its own. (Tuned against the T-1125 + // zoom ladder: at ~0.2 px effective amplitude the w64 view still read as + // wavy stripes — bays need to displace a meaningful fraction of a pixel.) + warp_amplitude_px: 0.30 + 0.25 * erosion_maturity + 0.20 * tectonic_energy, + // Invented relief floor: young worlds carry more uneroded relief; even + // mature worlds keep gentle rolling texture (eroded ≠ billiard-flat). + scatter_floor: 0.10 + 0.15 * tectonic_energy + 0.05 * (1.0 - erosion_maturity), + } +} + +// --------------------------------------------------------------------------- +// Tier 2 — position character +// --------------------------------------------------------------------------- + +/// The resolved invention character at one world position (T-1125 tier 2). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CoastCharacter { + /// Coast-warp displacement amplitude, working-grid pixel units (≤ cap). + pub warp_amplitude_px: f64, + /// 0–1 octave falloff: high keeps small-wavelength energy (jagged). + pub roughness: f64, + /// Slope-independent detail-scatter floor (`elev_pct` units). + pub scatter_floor: f64, + /// 0–1 ridged-folding blend for the scatter (fjord/tectonic sharpness). + pub ridge: f64, +} + +/// Resolve the invention character at `(wx, wy)` world metres. +/// +/// `driver_glaciation` / `driver_moisture_q` are the **one-step-stale** climate +/// drivers computed from the *unwarped* raw-bilinear primitives (the T-1125 +/// circularity ruling — see the call site in `district_profile`). +pub fn coast_character_at( + env: &BodyCoastEnvelope, + seed: u64, + wx: f64, + wy: f64, + lat_deg: f64, + driver_glaciation: GlaciationGrade, + driver_moisture_q: i32, +) -> CoastCharacter { + // Seeded heterogeneity in [0,1]: same-coast stretches differ (Jeroen). + let hetero = character_field(seed, wx, wy); + let fjord: f64 = match driver_glaciation { + GlaciationGrade::None => 0.0, + GlaciationGrade::Light => 0.30, + GlaciationGrade::Moderate => 0.70, + GlaciationGrade::Heavy => 1.0, + // Under a permanent cap the fjord carving is there but partly buried. + GlaciationGrade::IceCap => 0.90, + }; + let lat_frac = (lat_deg.abs() / 90.0).clamp(0.0, 1.0); + // Local wetness echoes the tier-1 erosion chain at position scale: the wet + // stretches of a body erode smoother than its dry stretches. + let wet_local = (driver_moisture_q.clamp(0, 100) as f64) / 100.0; + + CoastCharacter { + warp_amplitude_px: (env.warp_amplitude_px * (0.75 + 0.60 * hetero) * (1.0 + 0.50 * fjord)) + .min(WARP_AMPLITUDE_CAP_PX), + roughness: (env.roughness + 0.35 * fjord + 0.10 * lat_frac + 0.25 * (hetero - 0.5) + - 0.15 * wet_local) + .clamp(0.05, 1.0), + scatter_floor: (env.scatter_floor * (0.70 + 0.60 * hetero) + 0.10 * fjord).clamp(0.0, 0.40), + ridge: (0.15 + 0.55 * fjord + 0.35 * env.tectonic_energy).clamp(0.0, 1.0), + } +} + +// --------------------------------------------------------------------------- +// Fields +// --------------------------------------------------------------------------- + +/// Long-wavelength heterogeneity field in `[0, 1]` (the T-1084 `voxel_mosaic` +/// pattern at coast scale): drives *where along a coast* the character shifts. +pub fn character_field(seed: u64, wx: f64, wy: f64) -> f64 { + let seed = splitmix64(seed ^ CHARACTER_FIELD_SALT); + let mut sum = 0.0; + let mut amp = 1.0; + let mut norm = 0.0; + for (i, &wl) in CHARACTER_OCTAVE_WAVELENGTHS_M.iter().enumerate() { + sum += value_noise( + seed.wrapping_add((i as u64).wrapping_mul(0x1000)), + wx, + wy, + wl, + ) * amp; + norm += amp; + amp *= 0.5; + } + (((sum / norm) + 1.0) * 0.5).clamp(0.0, 1.0) +} + +/// The coastline domain-warp displacement in **working-grid pixel units**. +/// +/// Two independent C¹ fBm channels (x/y) on the salted stream, shaped by the +/// position character: `roughness` controls octave falloff (mature coasts keep +/// only the broad bays; young/fjordy coasts keep the jagged inlets) and `ridge` +/// blends ridged folding in (crease-cornered incursions — fjord arms), exactly +/// the `detail_scatter::enveloped_fbm` modulation convention. +/// +/// Apply the same offset to *every* envelope field sampled at the position +/// (elevation, slope, ocean mask) so the invented terrain moves coherently — +/// a warped-in bay carries its sea-level elevation with it. +pub fn coast_warp_px(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> (f64, f64) { + let sx = splitmix64(seed ^ COAST_WARP_SALT); + let sy = splitmix64(sx ^ COAST_WARP_Y_SALT); + ( + warp_fbm(sx, wx, wy, ch) * ch.warp_amplitude_px, + warp_fbm(sy, wx, wy, ch) * ch.warp_amplitude_px, + ) +} + +/// Roughness/ridge-shaped fBm in ≈`[-1, 1]` over the coast-warp octave band. +fn warp_fbm(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> f64 { + let mut sum = 0.0; + let mut amp = 1.0; + let mut norm = 0.0; + for (i, &wl) in WARP_OCTAVE_WAVELENGTHS_M.iter().enumerate() { + let mut n = value_noise( + seed.wrapping_add((i as u64).wrapping_mul(0x1000)), + wx, + wy, + wl, + ); + if ch.ridge > 0.0 { + let ridged = 1.0 - 2.0 * n.abs(); + n = n * (1.0 - ch.ridge) + ridged * ch.ridge; + } + sum += n * amp; + norm += amp; + // Falloff: smooth coasts damp high frequencies (0.45 — rounded rias, + // never featureless); jagged coasts keep them (0.80). Tuned on the + // ladder: at 0.35 base a mature coast lost its 16–65 km headlands + // entirely and still read as stripes at w64. + amp *= 0.45 + 0.35 * ch.roughness; + } + sum / norm +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(hydro: &str, atmo: &str, class: &str) -> BodyParams { + BodyParams { + hydrosphere: Some(hydro.to_string()), + atmosphere: Some(atmo.to_string()), + planet_class: Some(class.to_string()), + tectonic_activity: None, + latitude_deg: 0.0, + elevation_km: 0.0, + body_radius_km: Some(6000.0), + } + } + + #[test] + fn envelope_differentiates_wet_from_dry_bodies() { + // Jeroen's erosion chain: ocean+breathable erodes smooth; dry+thin stays sharp. + let wet = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let dry = body_coast_envelope(¶ms("minimal", "thin", "arid"), TectonicClass::Stable); + assert!(wet.erosion_maturity > dry.erosion_maturity); + assert!( + wet.roughness < dry.roughness, + "mature coasts must be smoother" + ); + assert!( + wet.warp_amplitude_px > dry.warp_amplitude_px, + "erosion widens embayments (rias) — wet worlds warp broader" + ); + } + + #[test] + fn envelope_tectonics_raise_roughness_and_relief_floor() { + let stable = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let volcanic = body_coast_envelope( + ¶ms("ocean", "breathable", "volcanic"), + TectonicClass::Volcanic, + ); + assert!(volcanic.roughness > stable.roughness); + assert!(volcanic.scatter_floor > stable.scatter_floor); + } + + #[test] + fn character_fjord_raises_roughness_amplitude_and_ridge() { + let env = body_coast_envelope(¶ms("ice", "thin", "frozen"), TectonicClass::Stable); + let temperate = coast_character_at(&env, 42, 1e6, 2e6, 40.0, GlaciationGrade::None, 40); + let fjordy = coast_character_at(&env, 42, 1e6, 2e6, 70.0, GlaciationGrade::Heavy, 40); + assert!(fjordy.roughness > temperate.roughness); + assert!(fjordy.warp_amplitude_px > temperate.warp_amplitude_px); + assert!(fjordy.ridge > temperate.ridge); + } + + #[test] + fn character_heterogeneity_varies_along_a_coast() { + // Same body, same latitude, positions ~600 km apart: the seeded field must + // move the character so same-coast stretches differ (Jeroen's ruling). + let env = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let chars: Vec = (0..8) + .map(|i| { + coast_character_at( + &env, + 7, + i as f64 * 600_000.0, + 500_000.0, + 30.0, + GlaciationGrade::None, + 50, + ) + .warp_amplitude_px + }) + .collect(); + let min = chars.iter().cloned().fold(f64::INFINITY, f64::min); + let max = chars.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + assert!( + max - min > 0.02, + "character must drift along the coast (got range {})", + max - min + ); + } + + #[test] + fn warp_is_deterministic_bounded_and_continuous() { + let env = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55); + let a = coast_warp_px(42, 3e6, 1e6, &ch); + let b = coast_warp_px(42, 3e6, 1e6, &ch); + assert_eq!(a, b, "warp must be deterministic"); + for i in 0..400 { + let (dx, dy) = coast_warp_px(42, i as f64 * 9_137.0, i as f64 * -7_211.0, &ch); + assert!(dx.abs() <= WARP_AMPLITUDE_CAP_PX + 1e-9); + assert!(dy.abs() <= WARP_AMPLITUDE_CAP_PX + 1e-9); + } + // C¹ continuity: a 10 m step is a tiny displacement change — the coast + // character never steps on a line (D-243 edge-fuzz discipline). + let (x0, y0) = coast_warp_px(42, 5e6, 5e6, &ch); + let (x1, y1) = coast_warp_px(42, 5e6 + 10.0, 5e6, &ch); + assert!((x0 - x1).abs() < 0.01 && (y0 - y1).abs() < 0.01); + } + + #[test] + fn warp_stream_uncorrelated_with_scatter_stream() { + // Distinct hash path: the warp at a position must not track the terrain + // detail-scatter at the same position (salted stream isolation). + let env = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let ch = coast_character_at(&env, 42, 1e6, 1e6, 20.0, GlaciationGrade::None, 60); + let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch); + let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5); + assert_ne!(wdx, scatter); + } +} diff --git a/server/src/atlas/detail_scatter.rs b/server/src/atlas/detail_scatter.rs index 207ccffb0..54d9280f2 100644 --- a/server/src/atlas/detail_scatter.rs +++ b/server/src/atlas/detail_scatter.rs @@ -49,7 +49,9 @@ fn lattice(seed: u64, ix: i64, iy: i64) -> f64 { /// Smooth (C¹) value-noise sample in `[-1, 1]` at world `(wx, wy)` for one /// wavelength. Smoothstep interpolation keeps lattice cell boundaries crease-free. -fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64 { +/// `pub(crate)`: also the noise substrate of the T-1125 coastline invention +/// ([`crate::atlas::coast_invention`]), which salts its own seed stream. +pub(crate) fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64 { let fx = wx / wavelength_m; let fy = wy / wavelength_m; let (x0, y0) = (fx.floor(), fy.floor()); diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index 2ab9b3045..fe9887738 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -22,6 +22,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use crate::atlas::coast_invention; use crate::atlas::features::TerrainAnalysis; use crate::atlas::region_profile::{self, RegionProfile}; use crate::atlas::scale::{self, BasinDirection, RegionPos}; @@ -117,6 +118,13 @@ pub enum VegetationClass { /// Riparian Thicket — 1–3 tile band along perennial waterways in Forest zones. /// Takes precedence over Forest when adjacent to a perennial river. RiparianThicket = 5, + /// Open water (T-1126): the district's morphology verdict is OpenOcean/Lake — + /// no land vegetation. Set from the already-derived `MorphologyZone`, never + /// from a water threshold of its own (vegetation and morphology can never + /// disagree by construction). NOTE: the `Ord` position is NON-SEMANTIC — + /// the D-239 §8 Forest>Scrub>Barren density-ladder reading does not extend + /// to `Marine`; the discriminant is append-only serialisation order. + Marine = 6, } // --------------------------------------------------------------------------- @@ -377,17 +385,31 @@ pub fn derive_glaciation_grade_from_climate( /// `near_perennial_water` signals whether this district is within the riparian /// band (i.e. the district or an adjacent district carries a perennial waterway). /// When true the riparian sub-variant is returned: `RiparianThicket` over -/// Forest, `RiparianScrub` over Scrub/Barren. +/// Forest, `RiparianScrub` over Scrub/Barren. It is a RIVER-proximity signal — +/// unrelated to `open_water`, which is the ocean/lake verdict. +/// +/// `open_water` (T-1126) is the **morphology-derived** water verdict — `true` +/// when the district's already-derived `MorphologyZone` is OpenOcean/Lake. +/// Callers pass the verdict in; this function must NEVER re-derive it from an +/// ocean-fraction threshold of its own (no fourth threshold — vegetation and +/// morphology can never disagree by construction). Airless takes precedence: +/// an airless body has no climate/vegetation branch at all (D-239 §2), so its +/// seas read `Absent`, not `Marine`. pub fn derive_vegetation( temperature_c: Option, moisture_q: i32, elev_q: i32, near_perennial_water: bool, + open_water: bool, ) -> VegetationClass { // Airless: entire climate/vegetation branch absent. let Some(temp_c) = temperature_c else { return VegetationClass::Absent; }; + // Open water (T-1126): the morphology verdict, passed in — see doc above. + if open_water { + return VegetationClass::Marine; + } let temp_i = temp_c as i32; // Extreme cold → Barren regardless of water proximity: below ~−50 °C mean @@ -1032,6 +1054,140 @@ pub fn derive_moisture_q( (ceiling - lat_penalty - elev_penalty - cont_penalty).clamp(0, 100) } +// --------------------------------------------------------------------------- +// T-1125 — invented primitives (shared by both derivation paths) +// --------------------------------------------------------------------------- + +/// The invented `(slope_q, elev_q, ocean_fraction_q)` triple (T-1125, D-227). +struct InventedPrimitives { + slope_q: i32, + elev_q: i32, + ocean_fraction_q: i32, +} + +/// Compose the invented district primitives at one position — the shared front +/// half of BOTH derivation paths ([`derive_district`] on-demand and +/// [`derive_district_profile`] batch), so the D-227 invention can never diverge +/// between them again (the pre-T-1125 failure: the batch path was scatter-free). +/// +/// Steps: +/// 1. **Driver tier (one-step-stale, T-1125 circularity ruling):** the raw +/// *unwarped* bilinear envelope feeds a provisional moisture/temperature → +/// [`derive_glaciation_grade_from_climate`] — the coast character reads +/// these stale drivers; it never reads the warped values it produces. The +/// raw inputs are the coarse ~40–160 km/pixel envelope anyway, so one step +/// of staleness is far below the signal's own resolution. +/// 2. **Character:** tier-1 body envelope ([`coast_invention::body_coast_envelope`]; +/// `derive_tectonic_class` hoisted here — it needs only `BodyParams`) + +/// tier-2 position character ([`coast_invention::coast_character_at`]). +/// 3. **Invented coastline:** every envelope field (elevation, slope, ocean +/// mask) is sampled at the SAME warped position, so terrain moves coherently +/// — a warped-in bay carries its sea-level elevation with it. +/// 4. **Slope-independent scatter:** the character's `scatter_floor` breaks the +/// old amplitude-slaved-to-coarse-slope collapse (invention ≈ 0 exactly on +/// low-relief coasts); `ridge` carries fjord/tectonic sharpness. +/// +/// `body_params` must already carry the district's `latitude_deg`. +#[allow(clippy::too_many_arguments)] +fn invent_primitives( + seed: SeedChain, + body_params: &BodyParams, + climate: &ClimateConstants, + ta: &TerrainAnalysis, + px: f64, + py: f64, + world_x_m: f64, + world_y_m: f64, + region_baseline_c: Option, +) -> InventedPrimitives { + // ── 1. Driver tier: UNWARPED raw-bilinear climate (one-step-stale). ───── + let raw_elev_q = + ((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100); + let raw_ocean_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0).round() + as i32) + .clamp(0, 100); + let driver_params = BodyParams { + elevation_km: (raw_elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM, + ..body_params.clone() + }; + let driver_temp = match region_baseline_c { + Some(b) => derive_district_temperature_c(Some(b), &driver_params, climate, 0.0), + None => derive_temperature_c(&driver_params, climate, seed.seed()), + }; + let driver_moisture = derive_moisture_q(&driver_params, raw_elev_q, raw_ocean_q, climate); + let driver_glaciation = derive_glaciation_grade_from_climate(driver_temp, driver_moisture); + + // ── 2. Character: body personality envelope + position modulation. ────── + let tectonic = derive_tectonic_class(body_params); + let envelope = coast_invention::body_coast_envelope(body_params, tectonic); + let ch = coast_invention::coast_character_at( + &envelope, + seed.seed(), + world_x_m, + world_y_m, + body_params.latitude_deg, + driver_glaciation, + driver_moisture, + ); + + // ── 3. Invented coastline: warp the whole envelope sampling. ──────────── + let (wdx, wdy) = coast_invention::coast_warp_px(seed.seed(), world_x_m, world_y_m, &ch); + let (spx, spy) = (px + wdx, py + wdy); + let elev_pct = bilinear(&ta.elev_pct, ta.w, ta.h, spx, spy) as f64; + let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, spx, spy) as f64; + let ocean_frac = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, spx, spy) as f64; + + // ── 4. Slope-independent scatter (floor + ridge character). ───────────── + let local_slope = (slope_deg / 45.0).clamp(0.0, 1.0); + let env_amp = (local_slope + ch.scatter_floor).clamp(0.0, 1.0); + let ruggedness = (0.6 * local_slope + 0.5 * ch.ridge).clamp(0.0, 1.0); + let scatter = crate::atlas::detail_scatter::terrain_detail( + seed.seed(), + world_x_m, + world_y_m, + env_amp, + ruggedness, + ); + + // Shoreline carving (T-1125): glacial / tectonically-young SHORES are cut + // steep — fjord walls and cliff coasts. The gentle scatter floor alone can + // never voice the D-239 steep coastal families (Fjord gates at slope_q ≥ 40, + // CliffCoast at ≥ 55), so where the invented position is coastal + // (`shoreline` peaks at the ocean-fraction midpoint) and the character is + // ridged (fjord/tectonic), the normalized scatter contributes real slope — + // patchy by construction (it reuses the scatter noise), so a glaciated + // coast grows fjord SEGMENTS, not a uniform fjord stripe. + let shoreline = (ocean_frac * (1.0 - ocean_frac) * 4.0).clamp(0.0, 1.0); + let carve = 0.55 * ch.ridge * shoreline * (scatter.abs() / env_amp.max(0.05)).clamp(0.0, 1.0); + + InventedPrimitives { + elev_q: (((elev_pct + scatter) * 100.0).round() as i32).clamp(0, 100), + slope_q: (((local_slope + ruggedness * scatter.abs() + carve) * 100.0).round() as i32) + .clamp(0, 100), + ocean_fraction_q: ((ocean_frac * 100.0).round() as i32).clamp(0, 100), + } +} + +/// Fractional working-grid position → absolute world metres — the inverse of +/// [`derive_district`]'s district→pixel mapping (D-204 elastic seam), used by +/// the batch path so both derivation paths key the invention noise fields on +/// the same world-metre convention. Radius-less bodies fall back to the +/// 1-working-pixel = 1-district convention `derive_district` uses. +fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option) -> (f64, f64) { + match radius_km { + Some(r) if r > 0.0 => { + let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0); + let lat_frac = if h > 1 { + py / (h - 1) as f64 - 0.5 + } else { + 0.0 + }; + (wx, lat_frac * (std::f64::consts::PI * r * 1000.0)) + } + _ => (px * scale::DISTRICT_M as f64, py * scale::DISTRICT_M as f64), + } +} + // --------------------------------------------------------------------------- // Public derivation function // --------------------------------------------------------------------------- @@ -1075,43 +1231,27 @@ pub fn derive_district_profile( let h = ta.h; let gcpr = grid_cells_per_district.max(1); - // Compute aggregate terrain statistics over the cells in this district. - // All arithmetic is integer or quantized-integer (D-010). - let mut slope_sum: i64 = 0; - let mut elev_sum: i64 = 0; - let mut ocean_count: i64 = 0; - let mut cell_count: i64 = 0; - + // T-1125: the batch path runs the SAME invention as the on-demand path — an + // invented centre-point sample of the continuous field (warped coastline + + // slope-independent scatter via `invent_primitives`) instead of the former + // scatter-free cell-aggregate mean. One invention path, shared with + // [`derive_district`], so the two can never silently diverge again; the + // pseudo-grid samples the same truth the 2 km carrier does. (The former mean + // smoothed away exactly the variance the believability contrast metrics + // measure — and carried no invention at all.) let row_start = (ry as usize).saturating_mul(gcpr).min(h); let row_end = row_start.saturating_add(gcpr).min(h); let col_start = (rx as usize).saturating_mul(gcpr).min(w); let col_end = col_start.saturating_add(gcpr).min(w); - - for r in row_start..row_end { - for c in col_start..col_end { - let i = r * w + c; - // Quantize slope to 0–100: slope_deg is ~[0, 45°]; divide by 45 × 100. - // This is a float-to-int boundary inside the aggregation; downstream - // decisions use `slope_q` (integer). - slope_sum += ((ta.slope_deg[i] / 45.0).clamp(0.0, 1.0) * 100.0) as i64; - elev_sum += (ta.elev_pct[i] * 100.0) as i64; - ocean_count += ta.ocean_mask[i] as i64; - cell_count += 1; - } - } - - let (slope_q, elev_q, ocean_fraction_q) = if cell_count > 0 { - let sq = (slope_sum / cell_count) as i32; - let eq = (elev_sum / cell_count) as i32; - let oq = (ocean_count * 100 / cell_count) as i32; - (sq, eq, oq) - } else { - (0, 0, 0) - }; + let px = (col_start as f64 + col_end.saturating_sub(1).max(col_start) as f64) / 2.0; + let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0; + let (world_x_m, world_y_m) = pixel_to_world_m(px, py, w, h, body_params.body_radius_km); // D-243 §3/§4: compute the edge-fuzz-blended region baseline for this district, // then pass it through build_district_profile so the two-phase derivation path runs. // The warp uses `seed.seed()` (the body-scoped seed) for domain separation. + // Hoisted above the primitives (T-1125): the invention's driver tier needs + // the baseline for its one-step-stale climate estimate. let region_baseline_c = region_profile::region_baseline_at_district( seed.seed(), body_id, @@ -1122,13 +1262,25 @@ pub fn derive_district_profile( Some(region_cache), ); + let prims = invent_primitives( + seed, + body_params, + climate, + ta, + px, + py, + world_x_m, + world_y_m, + region_baseline_c, + ); + build_district_profile( seed, body_params, climate, - slope_q, - elev_q, - ocean_fraction_q, + prims.slope_q, + prims.elev_q, + prims.ocean_fraction_q, region_baseline_c, basin_direction, ) @@ -1200,11 +1352,8 @@ fn build_district_profile( let glaciation_grade = derive_glaciation_grade_from_climate(temperature_c, moisture_q); let river_threshold = derive_river_threshold(tectonic_class, precipitation_class); - // Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet - // (requires perennial waterway map from L2+); default to false for now. - // L2 ChunkContext will override per-tile once drainage data is threaded through. - let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false); - + // Morphology precedes vegetation (T-1126): the vegetation call consumes the + // morphology water verdict, so the two can never disagree by construction. let morphology_zone = derive_morphology_zone( tectonic_class, glaciation_grade, @@ -1214,6 +1363,16 @@ fn build_district_profile( moisture_q, ); + // Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet + // (requires perennial waterway map from L2+); default to false for now. + // L2 ChunkContext will override per-tile once drainage data is threaded through. + // open_water = the morphology verdict (T-1126) — never a threshold of its own. + let open_water = matches!( + morphology_zone, + MorphologyZone::OpenOcean | MorphologyZone::Lake + ); + let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false, open_water); + DistrictProfile { morphology_zone, tectonic_class, @@ -1291,32 +1450,6 @@ pub fn derive_district( } }; - // Bilinear-interpolated Layer-1 envelope at the district position. - let elev_pct = bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64; - let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, px, py) as f64; - let ocean_frac = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64; - - // Envelope + adaptive ruggedness from the local heightmap slope (steep - // heightmap ⇒ relief headroom ⇒ rugged). Both 0–1. - let local_slope = (slope_deg / 45.0).clamp(0.0, 1.0); - let envelope = local_slope; - let ruggedness = local_slope; - let scatter = crate::atlas::detail_scatter::terrain_detail( - seed.seed(), - world_x_m, - world_y_m, - envelope, - ruggedness, - ); - - // Compose the primitives. Scatter perturbs elevation (mid-scale relief); slope - // gains a ruggedness-weighted bump so the morphology classifier responds - // (adaptive: rugged highs → mountain/pass, flats → plains). - let elev_q = (((elev_pct + scatter) * 100.0).round() as i32).clamp(0, 100); - let slope_q = - (((local_slope + ruggedness * scatter.abs()) * 100.0).round() as i32).clamp(0, 100); - let ocean_fraction_q = ((ocean_frac * 100.0).round() as i32).clamp(0, 100); - let params = BodyParams { latitude_deg: lat_deg, ..body_params.clone() @@ -1326,6 +1459,8 @@ pub fn derive_district( // this district. No pre-built cache here — the on-demand path derives the four // surrounding region baselines directly. Pure, deterministic, cheap. // `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation. + // Hoisted above the primitives (T-1125): the invention's driver tier needs + // the baseline for its one-step-stale climate estimate. let region_baseline_c = region_profile::region_baseline_at_district( seed.seed(), body_id, @@ -1336,6 +1471,21 @@ pub fn derive_district( None, // no pre-built cache; derive on-the-fly ); + // T-1125: invented primitives — warped coastline (invented bays/capes) + + // slope-independent character-driven scatter. Shared with the batch path + // (`derive_district_profile`) via `invent_primitives`. + let prims = invent_primitives( + seed, + ¶ms, + climate, + ta, + px, + py, + world_x_m, + world_y_m, + region_baseline_c, + ); + // derive_district is the on-demand path (arbitrary DistrictPos, no L1 working // grid). basin_direction is an ACCEPTED LIMITATION here: it defaults to North // (a fallback, not a computed value) because the D8 thalweg is only available @@ -1347,9 +1497,9 @@ pub fn derive_district( seed, ¶ms, climate, - slope_q, - elev_q, - ocean_fraction_q, + prims.slope_q, + prims.elev_q, + prims.ocean_fraction_q, region_baseline_c, BasinDirection::default(), ) @@ -1647,9 +1797,14 @@ mod tests { } #[test] - fn derive_district_flat_envelope_invents_no_relief() { - // A perfectly flat heightmap (no slope) → envelope 0 → scatter invents - // nothing → elev_q equals the interpolated base (the envelope rule). + fn derive_district_flat_envelope_bounded_invention() { + // Pre-T-1125 this pinned "flat heightmap → ZERO invented relief" — the + // envelope-as-amplitude reading that collapsed the D-227 invention on + // exactly the low-relief coasts where coastlines live. T-1125 (Jeroen's + // crinkle-varies ruling) replaces the zero with a bounded FLOOR: a flat + // world gains gentle invented relief that varies from place to place, + // never mountains, and the invention cannot conjure water that is not + // in the ocean mask. let (w, h) = (64u32, 32u32); let flat = BodyHeightmap { body_id: "flat".into(), @@ -1661,13 +1816,27 @@ mod tests { let ta = test_ta(&flat); let climate = ClimateConstants::default(); let p = earth_params(); - let d = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate); - // Flat land everywhere → slope 0 → no invented relief, no ocean. - assert_eq!( - d.slope_q, 0, - "flat heightmap must yield zero district slope" + let a = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate); + let b = derive_district(test_seed(), "flat", &p, &ta, (900, 40), &climate); + // No ocean can be invented on a fully-landlocked flat world: the coast + // warp displaces sampling, it never fabricates mask content. + assert_eq!(a.ocean_fraction_q, 0, "above sea level → no ocean"); + assert_eq!(b.ocean_fraction_q, 0, "above sea level → no ocean"); + // Bounded invention: gentle relief only — an authored plain must never + // sprout mountain-grade slopes (the envelope rule survives as a ceiling). + assert!(a.slope_q <= 25, "invented slope too steep: {}", a.slope_q); + assert!(b.slope_q <= 25, "invented slope too steep: {}", b.slope_q); + // And the invention must actually vary between distant districts — the + // old zero rule left every flat district identical (the T-1125 finding). + assert!( + a.elev_q != b.elev_q || a.slope_q != b.slope_q, + "flat-world districts must differ under the invention floor \ + (a: elev_q={} slope_q={}, b: elev_q={} slope_q={})", + a.elev_q, + a.slope_q, + b.elev_q, + b.slope_q ); - assert_eq!(d.ocean_fraction_q, 0, "above sea level → no ocean"); } #[test] @@ -2325,7 +2494,7 @@ mod tests { fn vegetation_airless_is_absent() { // D-239 §2: airless body → vegetation branch absent. assert_eq!( - derive_vegetation(None, 50, 20, false), + derive_vegetation(None, 50, 20, false, false), VegetationClass::Absent ); } @@ -2334,7 +2503,7 @@ mod tests { fn vegetation_warm_moist_low_elevation_is_forest() { // Warm + moist + low elevation → Forest. assert_eq!( - derive_vegetation(Some(20.0), 60, 10, false), + derive_vegetation(Some(20.0), 60, 10, false, false), VegetationClass::Forest ); } @@ -2343,7 +2512,7 @@ mod tests { fn vegetation_high_elevation_is_barren() { // Warm + moist but very high elevation → Barren (above treeline). assert_eq!( - derive_vegetation(Some(20.0), 60, 90, false), + derive_vegetation(Some(20.0), 60, 90, false, false), VegetationClass::Barren ); } @@ -2356,7 +2525,7 @@ mod tests { let moisture = 60_i32; let mut prev: Option = None; for elev_q in (0..=100).step_by(5) { - let v = derive_vegetation(temp, moisture, elev_q, false); + let v = derive_vegetation(temp, moisture, elev_q, false, false); if let Some(p) = prev { // Absent not reachable here (has atmosphere); skip riparian variants. let is_base = matches!( @@ -2383,14 +2552,14 @@ mod tests { #[test] fn vegetation_riparian_upgrades_scrub_to_riparian_scrub() { // A scrub-zone district near perennial water → RiparianScrub. - let v = derive_vegetation(Some(20.0), 60, 85, true); // high elev = scrub zone + let v = derive_vegetation(Some(20.0), 60, 85, true, false); // high elev = scrub zone assert_eq!(v, VegetationClass::RiparianScrub); } #[test] fn vegetation_riparian_upgrades_forest_to_riparian_thicket() { // A forest-zone district near perennial water → RiparianThicket. - let v = derive_vegetation(Some(20.0), 60, 10, true); // low elev = forest zone + let v = derive_vegetation(Some(20.0), 60, 10, true, false); // low elev = forest zone assert_eq!(v, VegetationClass::RiparianThicket); } @@ -2403,13 +2572,37 @@ mod tests { assert_eq!(VegetationClass::Forest as u8, 3); assert_eq!(VegetationClass::RiparianScrub as u8, 4); assert_eq!(VegetationClass::RiparianThicket as u8, 5); + // T-1126: Marine appended (open-water verdict). Ord position is + // NON-SEMANTIC — the density ladder does not extend to Marine. + assert_eq!(VegetationClass::Marine as u8, 6); + } + + #[test] + fn vegetation_open_water_is_marine_and_airless_wins() { + // T-1126: the morphology water verdict → Marine… + assert_eq!( + derive_vegetation(Some(15.0), 80, 5, false, true), + VegetationClass::Marine + ); + // …but airless takes precedence: no climate/vegetation branch at all + // (D-239 §2) — an airless sea is Absent, not Marine. + assert_eq!( + derive_vegetation(None, 80, 5, false, true), + VegetationClass::Absent + ); + // The riparian flag is river-proximity, unrelated to open water: a + // riparian river district on land is NOT Marine. + assert_eq!( + derive_vegetation(Some(15.0), 80, 5, true, false), + VegetationClass::RiparianThicket + ); } #[test] fn vegetation_hyper_arid_is_barren_not_absent() { // With atmosphere but zero moisture → Barren (not Absent). assert_eq!( - derive_vegetation(Some(20.0), 0, 10, false), + derive_vegetation(Some(20.0), 0, 10, false, false), VegetationClass::Barren ); } diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index b75751b90..7b3e206cd 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -12,6 +12,7 @@ pub mod body_world_state; pub mod cascade; pub mod chunk_context; pub mod city_context_reader; +pub mod coast_invention; pub mod detail_scatter; pub mod district_mix; pub mod district_profile; diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index 36da38804..3240d7ecf 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -160,6 +160,11 @@ impl Vegetation { VegetationClass::Forest => Vegetation::Forest, VegetationClass::RiparianScrub => Vegetation::Thicket, VegetationClass::RiparianThicket => Vegetation::Thicket, + // T-1126: open-water districts route to the WaterBody generator + // upstream (D-239/T-1082) and never reach the land-vegetation + // baseline; the arm exists for exhaustiveness and maps to bare + // ground exactly like Absent if one ever slips through. + VegetationClass::Marine => Vegetation::Barren, } } } From 6aafa7758359e768ef77000e88700fa8f0e4637a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 08:21:27 +0200 Subject: [PATCH 2/4] feat(simulation): T-1127 glaciation ice tint + Marine color in zoom-ladder probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aliveness_probe --render gains a 6th glaciation panel; glaciated districts get an ice tint keyed on GlaciationGrade, and Marine vegetation renders [0,96,172] — distinct from the morphology water tones so district-carrier vs voxel-generator disagreement stays visible in ladders. Co-Authored-By: Claude Fable 5 --- server/src/bin/aliveness_probe.rs | 65 ++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/server/src/bin/aliveness_probe.rs b/server/src/bin/aliveness_probe.rs index 74d5e1cad..d073364c5 100644 --- a/server/src/bin/aliveness_probe.rs +++ b/server/src/bin/aliveness_probe.rs @@ -37,7 +37,8 @@ use settled_reach_server::atlas::believability::{ use settled_reach_server::atlas::body_world_state::BodyWorldState; use settled_reach_server::atlas::chunk_context::derive_chunk_context; use settled_reach_server::atlas::district_profile::{ - derive_district, BodyParams, ClimateConstants, DistrictProfile, VegetationClass, + derive_district, BodyParams, ClimateConstants, DistrictProfile, GlaciationGrade, + VegetationClass, }; use settled_reach_server::atlas::features::TerrainAnalysis; use settled_reach_server::atlas::scale::{ @@ -335,8 +336,17 @@ const MORPHOLOGY_RGB: [[u8; 3]; 17] = [ [64, 115, 102], // 16 Wetland ]; -/// The five attribute panels, in output order. -const PANEL_NAMES: [&str; 5] = ["morphology", "elev", "temp", "moisture", "veg"]; +/// The attribute panels, in output order. `glaciation` is T-1127's dedicated +/// panel; the morphology panel additionally carries a per-pixel ice tint keyed +/// on the same field (see [`apply_ice_tint`]). +const PANEL_NAMES: [&str; 6] = [ + "morphology", + "elev", + "temp", + "moisture", + "veg", + "glaciation", +]; /// Render the district window around the principal settlement and write one PNG /// per panel to `out_dir`, plus a one-line manifest per image and a determinism @@ -447,7 +457,7 @@ fn render_window_panels( ) -> Vec<(&'static str, Vec)> { let n_i = n as i32; let half = n_i / 2; - let mut bufs: [Vec; 5] = std::array::from_fn(|_| vec![0u8; (n * n) as usize * 3]); + let mut bufs: [Vec; 6] = std::array::from_fn(|_| vec![0u8; (n * n) as usize * 3]); for row in 0..n_i { for col in 0..n_i { // Row 0 = northmost: smaller dy = further north (derive_district maps @@ -455,11 +465,19 @@ fn render_window_panels( let dp = (centre.0 - half + col, centre.1 - half + row); let prof = derive_district(seed, body_id, params, ta, dp, climate); let i = ((row * n_i + col) * 3) as usize; - set_rgb(&mut bufs[0], i, morphology_rgb(prof.morphology_zone)); + // T-1127: the morphology color carries a per-pixel ice tint keyed on + // glaciation_grade (a render modifier — the frozen D-239 §6 zone + // vocabulary is untouched; tint = classifier-tuning territory). + set_rgb( + &mut bufs[0], + i, + apply_ice_tint(morphology_rgb(prof.morphology_zone), prof.glaciation_grade), + ); set_rgb(&mut bufs[1], i, gray_rgb(prof.elev_q)); set_rgb(&mut bufs[2], i, temperature_rgb(prof.temperature_c)); set_rgb(&mut bufs[3], i, moisture_rgb(prof.moisture_q)); set_rgb(&mut bufs[4], i, vegetation_rgb(prof.vegetation_class)); + set_rgb(&mut bufs[5], i, glaciation_rgb(prof.glaciation_grade)); } } for buf in &mut bufs { @@ -566,6 +584,43 @@ fn vegetation_rgb(v: VegetationClass) -> [u8; 3] { VegetationClass::Forest => [30, 110, 40], // closed canopy VegetationClass::RiparianScrub => [70, 170, 120], // waterway band (open) VegetationClass::RiparianThicket => [10, 130, 90], // waterway band (dense) + // T-1126: open water — an ocean blue deliberately DISTINCT from the + // morphology water tones (OpenOcean 26,51,115 / Lake 51,102,166) so a + // veg-panel Marine cell is never mistaken for a morphology overlay. + VegetationClass::Marine => [0, 96, 172], + } +} + +/// T-1127: blend the morphology color toward glacial ice-white by +/// `glaciation_grade` — a RENDER modifier only (the D-239 §6 frozen 17-zone +/// vocabulary is untouched; an actual ice zone would need its own D-amendment). +/// +/// `Light` deliberately does NOT tint: grade 1 is glacial-erosion *signatures* +/// (U-valleys, moraines — landform history on coasts as warm as +5 °C mean), +/// not ice cover; visible ice starts where D-239 §5 gates glacial forms, +/// grade ≥ Moderate. The dedicated glaciation panel still shows Light. +fn apply_ice_tint(base: [u8; 3], grade: GlaciationGrade) -> [u8; 3] { + let t = match grade { + GlaciationGrade::None | GlaciationGrade::Light => return base, + GlaciationGrade::Moderate => 0.30, + GlaciationGrade::Heavy => 0.50, + GlaciationGrade::IceCap => 0.70, + }; + lerp_rgb( + [base[0] as f32, base[1] as f32, base[2] as f32], + [228.0, 240.0, 250.0], // glacial ice white-blue + t, + ) +} + +/// T-1127: dedicated glaciation panel — categorical dark→ice ramp. +fn glaciation_rgb(grade: GlaciationGrade) -> [u8; 3] { + match grade { + GlaciationGrade::None => [30, 30, 30], + GlaciationGrade::Light => [96, 120, 150], + GlaciationGrade::Moderate => [150, 180, 210], + GlaciationGrade::Heavy => [200, 222, 240], + GlaciationGrade::IceCap => [240, 248, 255], } } From aa1471069b0a4b62f64ad6de83ca37a77cb2d5ec Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 08:21:57 +0200 Subject: [PATCH 3/4] test(simulation): derivation-harness call sites + believability golden regen for coastline invention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical call-site updates (7) for the invent_primitives signature; believability golden regenerated via UPDATE_GOLDEN=1. Deltas are deliberate: slope_q/elev_q spreads widen, vegetation_classes +1 (Marine), water-districts-wet coherence reaches 46/46 and 19/19 (T-1082 class closed — classification and coastline are the same line). Reductions (ocean_fraction distinct, morphology_zones on GJ244Ad) are the eager pseudo-grid point-sampling coasts honestly instead of area-averaging them into synthetic intermediates; believability coast-sampling resolution is Q-123 calibration territory. All 37 derivation-harness law checks unchanged. Co-Authored-By: Claude Fable 5 --- server/tests/derivation_harness.rs | 14 ++++---- server/tests/golden/believability.json | 48 +++++++++++++------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index 582483b2c..e409687da 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -841,7 +841,7 @@ fn law_climate_vegetation_no_skip_temperature_sweep() { let mut last: Option = None; for t_i in (-60i32..=30).rev().step_by(5) { let temp = Some(t_i as f32); - let vc = derive_vegetation(temp, moisture_q, elev_q, false); + let vc = derive_vegetation(temp, moisture_q, elev_q, false, false); if let Some(prev) = last { if prev == VegetationClass::Forest && vc == VegetationClass::Barren { panic!( @@ -861,7 +861,7 @@ fn law_climate_vegetation_no_skip_elevation_sweep() { let moisture_q = 55; let mut last: Option = None; for elev_q in (0i32..=100).step_by(5) { - let vc = derive_vegetation(temp, moisture_q, elev_q, false); + let vc = derive_vegetation(temp, moisture_q, elev_q, false, false); if let Some(prev) = last { if prev == VegetationClass::Forest && vc == VegetationClass::Barren { panic!( @@ -898,7 +898,7 @@ fn law_climate_vegetation_full_grid_no_skip() { for elev_q in (0i32..=100).step_by(10) { let mut last: Option = None; for &temp in &temps_desc { - let vc = derive_vegetation(temp, moisture_q, elev_q, false); + let vc = derive_vegetation(temp, moisture_q, elev_q, false, false); if let Some(prev) = last { if prev == VegetationClass::Forest && vc == VegetationClass::Barren { panic!( @@ -919,7 +919,7 @@ fn law_climate_vegetation_riparian_near_perennial_water() { // near_perennial_water=true must produce a Riparian variant in viable zones. // Forest zone → RiparianThicket. - let vc_forest = derive_vegetation(Some(18.0), 60, 20, true); + let vc_forest = derive_vegetation(Some(18.0), 60, 20, true, false); assert!( matches!( vc_forest, @@ -930,7 +930,7 @@ fn law_climate_vegetation_riparian_near_perennial_water() { ); // Scrub zone → RiparianScrub. - let vc_scrub = derive_vegetation(Some(5.0), 30, 50, true); + let vc_scrub = derive_vegetation(Some(5.0), 30, 50, true, false); assert!( matches!( vc_scrub, @@ -941,7 +941,7 @@ fn law_climate_vegetation_riparian_near_perennial_water() { ); // Hyper-arid Barren zone + perennial water → RiparianScrub oasis (D-239 §8). - let vc_arid = derive_vegetation(Some(20.0), 3, 10, true); + let vc_arid = derive_vegetation(Some(20.0), 3, 10, true, false); assert_eq!( vc_arid, VegetationClass::RiparianScrub, @@ -957,7 +957,7 @@ fn law_climate_vegetation_airless_always_absent() { for moisture_q in [0, 30, 70, 100] { for elev_q in [0, 50, 100] { for near_water in [false, true] { - let vc = derive_vegetation(None, moisture_q, elev_q, near_water); + let vc = derive_vegetation(None, moisture_q, elev_q, near_water, false); assert_eq!( vc, VegetationClass::Absent, diff --git a/server/tests/golden/believability.json b/server/tests/golden/believability.json index da2796ca3..0e2f02e82 100644 --- a/server/tests/golden/believability.json +++ b/server/tests/golden/believability.json @@ -6,38 +6,38 @@ "voxel_sampled_districts": 64, "contrast": { "moisture_q": { - "min": 32, + "min": 31, "max": 80, - "distinct": 49 + "distinct": 48 }, "elev_q": { "min": 0, - "max": 98, - "distinct": 99 + "max": 100, + "distinct": 101 }, "slope_q": { "min": 0, - "max": 13, - "distinct": 14 + "max": 30, + "distinct": 30 }, "ocean_fraction_q": { "min": 0, "max": 100, - "distinct": 65 + "distinct": 44 }, "morphology_zones": 9, - "vegetation_classes": 3, + "vegetation_classes": 4, "terrain_materials": 4, - "voxel_relief_m": 20, + "voxel_relief_m": 24, "micro_habitat_distinct": 2 }, "coherence": { - "water_districts": 49, - "water_districts_wet": 44, + "water_districts": 46, + "water_districts_wet": 46, "drainage_samples": 0, "drainage_monotonic": 0, "vegetation_samples": 64, - "vegetated_districts": 8 + "vegetated_districts": 7 } }, { @@ -49,32 +49,32 @@ "moisture_q": { "min": 0, "max": 20, - "distinct": 21 + "distinct": 20 }, "elev_q": { "min": 0, - "max": 99, - "distinct": 100 + "max": 100, + "distinct": 101 }, "slope_q": { "min": 0, - "max": 6, - "distinct": 7 + "max": 15, + "distinct": 14 }, "ocean_fraction_q": { "min": 0, "max": 100, - "distinct": 65 + "distinct": 38 }, - "morphology_zones": 9, - "vegetation_classes": 1, - "terrain_materials": 4, - "voxel_relief_m": 19, + "morphology_zones": 6, + "vegetation_classes": 2, + "terrain_materials": 2, + "voxel_relief_m": 21, "micro_habitat_distinct": 0 }, "coherence": { - "water_districts": 25, - "water_districts_wet": 22, + "water_districts": 19, + "water_districts_wet": 19, "drainage_samples": 0, "drainage_monotonic": 0, "vegetation_samples": 64, From a86dab37567bd549c5d2060b25cb44921a3a79c8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 08:22:20 +0200 Subject: [PATCH 4/4] docs(meta): D-227 T-1125 invention-character amendment + D-239 T-1126 Marine amendment D-227: records the two-tier driver model (body envelope from authored params only, D-240 tilt exclusion upheld; position tier with fjord/ latitude/wetness/heterogeneity), the shared invent_primitives path, the scatter floor-vs-ceiling reading, shoreline carving, and the one-step-stale circularity ruling. D-239: Marine appended at discriminant 6, morphology-derived verdict, airless precedence, non-semantic Ord position. Co-Authored-By: Claude Fable 5 --- governance/decisions/architecture.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 6af6617a0..4ec699bc5 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1618,6 +1618,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Rationale:** A world that stores its tiles cannot scale to body-sized 3-D volumes and bloats saves; a pure-function world with a transient cache + a sparse mutator log scales to any size, makes saves trivially small, and is the only model under which "dig anywhere, to any depth" is free (the subsurface was always computable — digging just reveals it). It also forces the determinism discipline the whole cascade needs anyway. The downward floor cap fell because it was solving a problem — per-layer storage cost — that derive-don't-store eliminates. - **Open sub-questions:** the geology-model fidelity (simple depth-horizon stack vs tectonic-grade folding/faults) and how far `FloorMaterial` is derived now vs deferred to the city layers (both tracked in D-228 / Q-101); the mutator op schema (Q-103). - **Implementation:** Phase 4+ (epic T-750). The caching substrate exists at the atlas level (`BodyWorldStateCache`, D-203/D-225); the tile/voxel cache, mutator overlay, and geology derivation land as the cascade reaches the tile layers. No migration needed pre-save (D-202 `schema_version` lineage covers future drift once saves exist). +- **Amended 2026-07-17 (T-1125 — the invention carries geographic content into district classification; two-tier driver model):** the "invented deterministically (interpolation + domain warp + detail-scatter)" clause is now implemented *with character* at the district tier, closing the T-1123 finding that classification consumed only the raw bilinear envelope (pixel-smooth coasts; scatter amplitude slaved to coarse heightmap slope ≈ 0 exactly on low-relief coasts). Mechanism (`atlas/coast_invention.rs` + `district_profile::invent_primitives`, shared by the on-demand `derive_district` AND batch `derive_district_profile` paths so they can never silently diverge): **(a) coastline domain-warp** — every envelope field (elevation, slope, ocean mask) is bilinearly sampled at the same warp-displaced position (C¹ multi-octave value noise, ≈16–262 km band, sub-pixel amplitude cap 0.75 px, distinct salted hash stream — never correlated with the terrain scatter or climate edge-fuzz), inventing bays/capes/fjord inlets while the heightmap stays the truth at its own scale; **(b) slope-independent scatter floor** — the detail-scatter envelope gains a character-driven floor (invention no longer collapses on flat coasts; the old "flat envelope → zero invention" reading is superseded — the envelope rule survives as a *ceiling*: gentle bounded relief, never mountains on an authored plain); **(c) shoreline carving** — ridged character contributes real slope in shoreline patches so the steep coastal families (Fjord/CliffCoast) can fire where glacially/tectonically justified. **Crinkle varies (Jeroen's ruling): two driver tiers, zero new authored data.** Tier 1 (body personality envelope): `planet_class` (via `TectonicClass`) + `hydrosphere` + `atmosphere` + `body_radius_km` (via the pixel⇄metre seam) only — **D-240 stands: no orbital/tilt inputs**; erosion-proneness is *derived* (more ocean → wetter/rainier → higher erosion → smoother mature coasts; dry/thin-atmosphere → sharp young coasts). Tier 2 (position): latitude, driver-tier `GlaciationGrade` (fjordy high-latitude glaciated coasts), local wetness, and a seeded ~100–400 km heterogeneity field so stretches of the same coast differ; longitude participates via absolute world-metre noise keying. **Circularity ruling:** the driver-tier climate (glaciation/moisture) reads the *unwarped* raw-bilinear primitives — one-step-stale by design, documented at the call site. All pure `(seed, body, position)` (D-010); character never steps on a district/region line (D-243 edge-fuzz discipline). - **Raised by:** Jeroen (derive-don't-store, volumetric, drop-the-floor-cap directives) + Claude, atlas-derivation workshop, 2026-05-25. - **Cross-reference:** [D-010](#d-010) (determinism — now save-critical), [D-222](#d-222) (subtile/tile/chunk hierarchy), [D-110](#d-110) (signed z-levels), [D-225](#d-225) (layer-stream proxy + cache pattern), [D-203](#d-203) (LRU cache tier), [D-224](#d-224) (SeedChain — feeds `derive`), [D-228](#d-228) (composite tile schema — the derived value type), [Q-101](../questions/architecture.md#q-101) (refinement contract), [Q-103](../questions/architecture.md#q-103) (mutator op schema), [Q-104](../questions/architecture.md#q-104) (floor↔voxel-z mapping) - **Dissent:** None @@ -1830,6 +1831,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Implementation note (T-1025/T-1027, 2026-06-08):** §6's frozen 17-zone `MorphologyZone` enum is realised in `server/src/simulation/generator.rs` (`repr(u8)`, discriminant-pinned). The §5 8-family gated classifier + §7 compatibility-matrix invariants live in `derive_morphology_zone` (`region_profile.rs`). **16 of the 17 zones are reachable at RegionProfile scale; `BraidedPlain` is the exception** — distinguishing it from `Delta` needs a lithology signal (§8 Gravel→braided) that `RegionProfile` does not carry, so `BraidedPlain` is **deferred to ChunkContext** sub-classification. The §7 compatibility invariant is enforced as a **classifier-gate-ordering** property (a build-time test that a single region cannot yield a forbidden pair), per §7's "build-time test" language; genuine cross-region sharp seams (cliff↔fjord, lithology faults) remain permitted. §2 climate-derived fields (`precipitation_class`, `glaciation_grade`, `vegetation_class`) all derive from the temperature(+moisture) primitive (T-1025). - **Amendment (T-1082, 2026-06-28):** §5's family set grows from **8 to 9** — a `WaterBody` generator family is added for the water zones **OpenOcean / Lake / TidalFlat**, which previously fell through to the dry-land `AlluvialPlain` fallback (the D-245 believability bug: oceans rendered as dry forested land — `water=Dry` + `Forest` on the sea). `WaterBody` lays `Water::Deep`/`Shallow` (a Shallow shoal band on the district-anchored coast line `coast_anchor_m`, Deep beyond; TidalFlat is all-Shallow), a seabed `TerrainMaterial` (`Rock` on steep districts / `Sand` on gentle / `Wetland` for tidal mud), `Vegetation::Barren`, the water surface at `elevation_m = 0` (the §8 mouths-at-sea-level convention), and seasonal `Ice` via `derive_cover` (frozen seas). This **refines §6**: open ocean / lake / tidal flat are still derived zone *labels*, but their *generator* is now `WaterBody`, not `AlluvialPlain` — so §6's "tidal flat … not [a] distinct generator famil[y]" is superseded for the dispatch of those three zones. `Wetland` stays a land zone on `AlluvialPlain` (a marsh — saturated ground, not open water). Lives in `server/src/atlas/voxel.rs` (`MorphologyFamily::WaterBody` + `generate_water_body`, dispatched by `zone_to_family`); verified by the T-1083 believability harness (water-renders-wet flips PASS for Arbour + Edict). The §5 8-family decision-tree classifier (`derive_morphology_zone`) is unchanged — this is a voxel-tier *generator* family, selected by zone, not a new RegionProfile classifier branch. - **Amendment (T-1080, 2026-06-28):** §2's **moisture** primitive is now a **body ceiling × per-district spatial gradient**, not a single body constant. The hydrosphere+atmosphere value (`derive_moisture_q`) is the *wettest* a district on the body can be; per-district **latitude** (equator wet → pole dry), **elevation** (orographic / rain-shadow), and **continentality** (`100 − ocean_fraction_q`, coast wet → interior dry) subtract from it. Integer (D-010); coefficients in `ClimateConstants` / `climate_constants.toml` `[moisture_gradient]` (provisional, Q-123 calibrates). This mirrors D-240's latitude-graded *temperature* model and fixes the T-1080 believability bug (`moisture_q = 80` for all 2 048 districts → uniform vegetation/terrain — "nothing to fuzz"). It **propagates**: `precipitation_class`, `vegetation_class`, and the `morphology_zone` Wetland gate all read `moisture_q`, so those diversify for free. Lives in `district_profile.rs::derive_moisture_q`; verified by the T-1083 believability harness (moisture-gradient flips PASS, `distinct` 1 → ~49 on Arbour). The body-scale ceiling keeps each world's character; the gradient varies it within. +- **Amendment (T-1126, 2026-07-17):** §8's climate→vegetation law predates a water class — `derive_vegetation` was ocean-blind, so district-resolution maps painted Forest across open ocean (T-1123 finding; the voxel tier masked it in-world via the T-1082 `WaterBody` generator, but the district carrier itself disagreed with its own morphology). `VegetationClass` gains **`Marine`, appended at discriminant 6** (after `RiparianThicket = 5`; the load-bearing discriminant-pin test is extended). The verdict is **morphology-derived**: `build_district_profile` derives `morphology_zone` first and passes `open_water = (OpenOcean | Lake)` into the vegetation call — vegetation and morphology can never disagree by construction, and **no fourth ocean threshold exists**. Precedence: airless (`temperature_c == None`) still wins — an airless body has no climate/vegetation branch at all (§2), so its seas read `Absent`, not `Marine`. The `Ord` position of `Marine` is **non-semantic**: the §8 Forest→Scrub→Barren density-ladder reading does not extend to it (discriminants are append-only serialisation order). `near_perennial_water` (the riparian river-proximity signal) is unrelated and unchanged. - **Amendment (T-1081, 2026-06-28):** the voxel tier now carries **mid-scale relief** (D-243 §2's invented terrain). Before, the family generators set `elevation_m` from `elev_q` at a compressed scale plus only ±4 m per-voxel micro-scatter — the walkable surface read flat (the D-245 "0–3 m, no hills to navigate by" bug). A `voxel_relief` pass (`detail_scatter.rs`, the `terrain_detail` fBm at a **0.13–1 km sub-district octave band** — all finer than the 2 km district, so it never competes with `elev_q`'s district-scale role — rather than the district 4–40 km band; body-global `SeedDomain::VoxelRelief` seed, position-keyed; the `f64` perturbation truncated to integer metres before assignment — D-010) is added post-dispatch in `derive_voxel_column`. Two load-bearing choices: **(a) the relief envelope is `slope_q·3 + elev_q`, not slope alone** — the coarse heightmap (~40–78 km/px) yields `slope_q ≈ 0` even on high ground (observed max 13 on Arbour), so gating on slope would invent nothing; folding elevation in makes high terrain read rugged and coastal flats stay gentle. **(b) Only the flat families** (AlluvialPlain / LavaField / BraidedDelta / DuneStrand / MeanderReach) take it — CliffCoast / FjordWall / IncisedGorge already generate strong internal relief and a position-varying field would warp those features (e.g. drown a gorge's wall-to-floor drop); WaterBody stays at sea level. The relief **span** (`VOXEL_RELIEF_SPAN_M = 100`) and the believability voxel-relief threshold (≥ 8 m) are provisional (Q-123 calibrates, like the moisture coefficients). The span is held modest deliberately: relief sits on the compressed `elev_q/N` base (max ~50 m) and `elevation_m` clamps at 0 (sea level), so an oversized span clamps away on low ground (drowning the relief, biasing it positive) — a bigger span belongs with the deferred absolute-elevation model. Verified by the T-1083 harness — a new `voxel_relief` contrast metric (mean within-district elevation range across a district-spanning transect, since a single 64 m sample chunk is narrower than the relief band) flips PASS (≈22 m on Arbour + Edict). **Deferred:** the *absolute* elevation span is still the compressed `elev_q/N` base — a per-body hypsometric relief model (so a body's true max relief sets the ceiling AND gives the relief headroom to grow without clamping) is a later refinement; the mid-scale relief gives navigable hills now without it. - **Cross-reference:** [D-227](#d-227) (derive-don't-store voxel model), [D-228](#d-228) (composite tile axes / cohesion / seasonal state), [D-210](#d-210) (temperature proxy — formalised), [D-203](#d-203) (BodyWorldState cache), [D-206](#d-206) (background analysis pass), [D-208](#d-208) (drainage / D8), [D-010](#d-010) (determinism), [D-234](#d-234) (street/footprint geometry — consumes morphology), [D-142](content.md#d-142) (zone types), [D-217](#d-217) (tile condition), [Q-102](../questions/architecture.md#q-102) (cohesion = the warp), [Q-103](../questions/architecture.md#q-103) (mutator schema — open), [Q-105](../questions/architecture.md#q-105) (seasonal/clock state — temperature/ElevationDelta forward contract)