feat(simulation): T-1125/T-1126 coastline invention — two-tier character model, shared invent_primitives, Marine vegetation

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:21:07 +02:00
co-authored by Claude Fable 5
parent ee0dd6df25
commit fef422bf01
5 changed files with 673 additions and 84 deletions
+388
View File
@@ -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 100400 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 (≈16262 km): capes and gulfs at the
/// top, coves and inlets at the bottom. All above the 433 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 (≈100400 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 {
/// 01 — 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,
/// 01 — from [`TectonicClass`] (planet_class-derived): relief energy.
pub tectonic_energy: f64,
/// Base octave-falloff roughness (01) 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 (01).
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,
/// 01 octave falloff: high keeps small-wavelength energy (jagged).
pub roughness: f64,
/// Slope-independent detail-scatter floor (`elev_pct` units).
pub scatter_floor: f64,
/// 01 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 1665 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(
&params("ocean", "breathable", "temperate"),
TectonicClass::Stable,
);
let dry = body_coast_envelope(&params("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(
&params("ocean", "breathable", "temperate"),
TectonicClass::Stable,
);
let volcanic = body_coast_envelope(
&params("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(&params("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(
&params("ocean", "breathable", "temperate"),
TectonicClass::Stable,
);
let chars: Vec<f64> = (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(
&params("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(
&params("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);
}
}
+3 -1
View File
@@ -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());
+276 -83
View File
@@ -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 — 13 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<f32>,
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 ~40160 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<f32>,
) -> 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, 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 0100: 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 01.
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,
&params,
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,
&params,
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<VegetationClass> = 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
);
}
+1
View File
@@ -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;
+5
View File
@@ -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,
}
}
}