From ed7380245d42e4984ff6f031f9f56bed58f6ba97 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 17:15:07 +0200 Subject: [PATCH 1/2] feat(simulation): static scattered freeze/snow model (T-1030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-239 §3 — the static mean-state freeze/snow cover. New SeasonalCover {None,Snow,Ice} overlay axis on VoxelColumn (D-228 cover, NOT TerrainMaterial), derived after the family generator in derive_voxel_column. - Spatially-COHERENT cluster scatter (never per-tile dice): coarse 6m cluster cells (div_euclid) -> Cantor pair -> splitmix64 ^ sub_chunk_seed, so voxels in a cell share the freeze decision -> ragged patches. All integer (D-010). - Bands by surface (classify_surface from morphology_zone + water): freshwater +5..-10C; salt water (OpenOcean) -2C onset / -14C, 3x-coarser sheets+leads; land snow +2..-10C, moisture-gated (moisture_q<30 -> None). Linear frozen fraction across the band; permanent below the band (poles/glaciers). - Airless (temperature_c None) -> cover None. - Transient/clock-bound layer (dawn frost melting by noon) doc'd as a Q-105 forward contract; only the static model is built. 16 new cover tests incl real spatial-coherence (>=90% intra-cluster agreement), band edges per surface, moisture gating, determinism. cargo test passes, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/voxel.rs | 725 +++++++++++++++++++++++++++++++++++++- 1 file changed, 722 insertions(+), 3 deletions(-) diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index 7a4884cd8..3f177cf4a 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -12,9 +12,13 @@ //! - `vegetation`: `Vegetation` — ground cover (from `VegetationClass`) //! - `water`: `Water` — local depth state (Dry/Shallow/Deep) //! - `elevation_m`: scalar metres +//! - `cover`: `SeasonalCover` — seasonal cover overlay (Snow/Ice, D-228/D-239 §3) //! //! **Snow/Ice are NOT `TerrainMaterial`** — they are a seasonal cover overlay -//! (Q-105), out of scope here. +//! per D-228. `derive_cover` (T-1030) computes the static mean-state cover from +//! the region's integer temperature + water/terrain type + spatially-coherent +//! cluster scatter (D-239 §3). The transient/clock-bound part (cover forms in the +//! cold phase, melts in the warm phase) is deferred to Q-105. //! //! ## Domain warp (D-239 §4) //! @@ -162,6 +166,41 @@ pub enum Water { Deep = 2, } +/// Seasonal cover overlay (D-228 / D-239 §3, T-1030). +/// +/// Snow and Ice are **not** `TerrainMaterial` — they are a derived overlay that +/// sits on top of the permanent ground material. This axis captures the +/// **static mean-state** cover: whether the region's mean-annual temperature +/// puts this voxel firmly in the frozen zone. The **transient, clock-bound** part +/// (cover forms in the cold diurnal/seasonal phase, melts in the warm phase → +/// time-of-day passability shifts) is out of scope here. +/// +/// ## Q-105 forward contract +/// +/// D-239 §3 states: "Transient: because temperature is clock-bound (season + +/// day/night), in the marginal band ice/snow forms in the cold phase and melts in +/// the warm phase — dawn frost burns off, a stream iced at dawn is crossable by +/// noon. Passability is therefore time-of-day dynamic." This dynamic behaviour +/// requires the cheap region seasonal/clock state described in Q-105 — the +/// `cover` field here captures only the mean-state freeze. When Q-105 is +/// implemented, consumers must additionally consult the regional clock-phase +/// before treating `cover` as a passability gate. The static `SeasonalCover` +/// output of `derive_cover` remains valid as the base state; Q-105 applies a +/// time-dependent modifier on top of it. +/// +/// Integer-discriminant, append-only (D-010). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum SeasonalCover { + /// No seasonal cover — ground is bare. + #[default] + None = 0, + /// Snow — cold + wet land surface; reduces mobility, provides concealment. + Snow = 1, + /// Ice — frozen water surface or permanent ice; alters passability. + Ice = 2, +} + // --------------------------------------------------------------------------- // VoxelColumn // --------------------------------------------------------------------------- @@ -182,6 +221,12 @@ pub struct VoxelColumn { pub water: Water, /// Surface elevation in integer metres (D-010; no f64 in the stored value). pub elevation_m: i32, + /// Seasonal cover overlay (D-228 / D-239 §3, T-1030). + /// + /// Static mean-state: whether mean-annual temperature puts this voxel in + /// the frozen zone, gated by surface type + spatially-coherent cluster + /// scatter. The transient clock-bound layer (Q-105) is not represented here. + pub cover: SeasonalCover, } // --------------------------------------------------------------------------- @@ -317,7 +362,7 @@ pub fn derive_voxel_column( // ── 3. Family dispatch (D-239 §5) ───────────────────────────────────── let family = zone_to_family(®ion.morphology_zone); - match family { + let mut column = match family { MorphologyFamily::AlluvialPlain => { generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed) } @@ -340,7 +385,16 @@ pub fn derive_voxel_column( MorphologyFamily::MeanderReach => { generate_meander_reach(region, chunk, voxel_pos, sub_chunk_seed) } - } + }; + + // ── 4. Seasonal cover overlay (D-239 §3, T-1030) ────────────────────── + // Applied AFTER family dispatch: the 8 family generators produce the base + // axes (terrain/water/vegetation/elevation); cover is a separate orthogonal + // axis derived from the region's mean temperature + water/terrain + coherent + // cluster scatter. One site, set here — no family generator needs changing. + column.cover = derive_cover(region, &column, voxel_pos, sub_chunk_seed); + + column } // --------------------------------------------------------------------------- @@ -436,6 +490,7 @@ fn generate_alluvial_plain( vegetation, water, elevation_m, + cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column } } @@ -496,6 +551,7 @@ fn generate_lava_field( vegetation: Vegetation::Barren, water, elevation_m, + cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column } } @@ -627,6 +683,7 @@ fn generate_fjord_wall( vegetation, water, elevation_m, + cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column } } @@ -723,6 +780,7 @@ fn generate_cliff_coast( vegetation, water, elevation_m, + cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column } } @@ -857,6 +915,7 @@ fn generate_braided_delta( vegetation, water, elevation_m, + cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column } } @@ -960,6 +1019,7 @@ fn generate_dune_strand( vegetation, water, elevation_m, + cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column } } @@ -1059,6 +1119,7 @@ fn generate_incised_gorge( vegetation, water, elevation_m, + cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column } } @@ -1156,6 +1217,281 @@ fn generate_meander_reach( vegetation, water, elevation_m, + cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column + } +} + +// --------------------------------------------------------------------------- +// SeasonalCover derivation (D-239 §3, T-1030) +// --------------------------------------------------------------------------- + +/// Cluster size for spatially-coherent freeze scatter (D-239 §3). +/// +/// Neighbouring voxels within the same CLUSTER_M × CLUSTER_M cell share a +/// per-cluster scatter offset, producing ragged clustered patches rather than +/// per-tile salt-and-pepper. The spec calls for ~4–8 m; we use 6 m. +const CLUSTER_M: i32 = 6; + +/// Freshwater freeze band upper edge (district mean °C, D-239 §3). +/// +/// Night-frost can ice-over the coldest/most-exposed freshwater tiles even when +/// the district mean is still positive (diurnal swing). +5 °C is the onset of +/// the scatter band. +const FRESH_BAND_HIGH_C: i32 = 5; + +/// Freshwater freeze band lower edge (D-239 §3). +/// +/// Below −10 °C the entire freshwater surface is frozen across the full +/// day/night cycle. No scatter below this threshold. +const FRESH_BAND_LOW_C: i32 = -10; + +/// Salt-water (sea ice) freeze onset temperature (D-239 §3). +/// +/// Seawater freezes at ≈ −2 °C due to dissolved salt. +const SALT_ONSET_C: i32 = -2; + +/// Salt-water freeze band width in integer °C (D-239 §3). +/// +/// The sea-ice band is lower and wider than the freshwater band. We model it +/// as [SALT_ONSET_C, SALT_ONSET_C − SALT_BAND_WIDTH]. Below that, permanent +/// pack ice everywhere (no scatter). +const SALT_BAND_WIDTH: i32 = 12; + +/// Snow (land) onset temperature — same scattered band as freshwater (D-239 §3). +/// +/// On land, cover transitions from bare frozen ground to Snow as temp descends +/// through this band, but only when moisture_q is above `SNOW_MOISTURE_GATE`. +const SNOW_BAND_HIGH_C: i32 = 2; + +/// Snow moisture gate — moisture_q threshold below which cold land stays bare. +/// +/// Cold + dry → no snow accumulation (D-239 §3 "gated on moisture"). +const SNOW_MOISTURE_GATE: i32 = 30; + +/// Classify a `MorphologyZone` as salt (ocean/sea) vs fresh water vs land. +/// +/// D-239 §3: "salt vs fresh: derive from `morphology_zone`". +/// - OpenOcean / Sea zones → salt water. +/// - Lake / RiverBank / MeanderReach / Delta / BraidedPlain / Estuarine / +/// TidalFlat → fresh water. +/// - All other zones → land (no water-freeze branch). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SurfaceClass { + SaltWater, + FreshWater, + Land, +} + +fn classify_surface(zone: &MorphologyZone, water: Water) -> SurfaceClass { + match zone { + // Salt water: open ocean. + MorphologyZone::OpenOcean => SurfaceClass::SaltWater, + // Fresh surface water: lakes and all river/wetland zones with standing water. + // We additionally check Water state — a RiverBank tile that happens to be + // Dry at this voxel is functionally land from a freeze perspective. + MorphologyZone::Lake => SurfaceClass::FreshWater, + MorphologyZone::RiverBank + | MorphologyZone::MeanderReach + | MorphologyZone::Delta + | MorphologyZone::BraidedPlain + | MorphologyZone::Estuarine + | MorphologyZone::TidalFlat => { + if water != Water::Dry { + SurfaceClass::FreshWater + } else { + SurfaceClass::Land + } + } + // Everything else is land (including Fjord walls, DuneStrand, etc.). + _ => SurfaceClass::Land, + } +} + +/// Per-cluster scatter offset (D-239 §3). +/// +/// Hash a coarse cluster cell `(cx, cy)` into a u64 scatter offset. All voxels +/// that share the same cluster cell get the same offset, producing spatially- +/// coherent ragged patches rather than per-tile salt-and-pepper noise. +/// +/// Uses the project's canonical `splitmix64` mixer (D-010; same as SeedChain). +/// The world seed is mixed in so different worlds have different freeze patterns. +#[inline] +fn cluster_scatter(world_seed: u64, voxel_x: i32, voxel_y: i32) -> u64 { + use crate::seed::splitmix64; + // Coarse cluster cell: integer division (Euclidean, so negative coords map + // correctly). Two voxels 1 m apart that share a cluster cell get the same hash. + let cx = voxel_x.div_euclid(CLUSTER_M) as u64; + let cy = voxel_y.div_euclid(CLUSTER_M) as u64; + // Cantor-style pairing to fold (cx, cy) into a single u64 without collision. + // We use zigzag encode + additive mixing to keep the avalanche properties. + let s = cx.wrapping_add(cy); + let paired = s + .wrapping_mul(s.wrapping_add(1)) + .wrapping_div(2) + .wrapping_add(cy); + splitmix64(world_seed ^ splitmix64(paired)) +} + +/// Derive the `SeasonalCover` overlay for a single voxel (D-239 §3, T-1030). +/// +/// ## Rules (D-239 §3 verbatim mapping) +/// +/// ### Airless body +/// `temperature_c == None` → `SeasonalCover::None`. No climate branch. +/// +/// ### Fresh water (lakes / rivers) +/// Scatter band **+5 °C → −10 °C** (district mean temperature_c as i32). +/// - Above +5 °C: `None` (open water). +/// - Within the band [−10, +5]: cluster-scatter decides `Ice` vs `None`. +/// The scatter is driven by a per-cluster hash; more of the band → more Ice. +/// - Below −10 °C: `Ice` everywhere (permanent). +/// +/// ### Salt water (sea ice) +/// Band onset ≈ −2 °C, width 12 °C (so solid below −14 °C). +/// - Above −2 °C: `None`. +/// - Within the band [−14, −2]: cluster-scatter → `Ice` vs `None`. +/// Pack-ice pattern: larger coherent sheets + occasional open "lead" gaps. +/// Leads use a separate coarser cluster size (3× CLUSTER_M) to produce +/// sheet-scale coherence rather than fine-grained scatter. +/// - Below −14 °C: `Ice` everywhere (permanent pack ice). +/// +/// ### Land (snow) +/// Moisture-gated: `moisture_q >= SNOW_MOISTURE_GATE` (30) required. +/// Band **+2 °C → −10 °C** (same low edge as freshwater). +/// - Above +2 °C, or dry (moisture_q < 30): `None` (bare frozen ground if cold). +/// - Within the band [−10, +2] and moist: cluster-scatter → `Snow` vs `None`. +/// - Below −10 °C and moist: `Snow` everywhere (permanent). +/// +/// ## Spatial coherence (D-239 §3) +/// +/// Coherence is implemented by hashing the **coarse cluster cell** +/// `(voxel_x.div_euclid(CLUSTER_M), voxel_y.div_euclid(CLUSTER_M))` into a +/// per-cluster scatter offset. All voxels in the same 6 m × 6 m cluster share +/// the same scatter decision, producing ragged but spatially coherent freeze +/// patches — "clustered patches, NEVER per-tile dice" (D-239 §3). +/// +/// ## Q-105 forward contract +/// +/// This function models the **static mean-state** only. The transient +/// (clock-bound) cover — dawn frost that burns off by noon, a stream iced at +/// dawn that is crossable by midday — requires the cheap regional clock state +/// described in Q-105. When Q-105 is implemented, callers should treat the +/// `cover` field as the base state and apply the Q-105 modifier on top. +/// +/// ## D-010 compliance +/// +/// All decisions are integer. `temperature_c` is cast to `i32` once (positional +/// quantisation, not a structural float gate). All scatter is hash/integer only. +pub fn derive_cover( + region: &RegionProfile, + column: &VoxelColumn, + voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> SeasonalCover { + // ── Airless body (D-239 §2) ─────────────────────────────────────────── + // No atmosphere → no climate branch → no seasonal cover. + let Some(temp_f) = region.temperature_c else { + return SeasonalCover::None; + }; + + // Integer cast: all gating is on i32 (D-010; f32→i32 cast is positional + // quantisation, not a structural float comparison). + let temp_i: i32 = temp_f as i32; + + let (voxel_x, voxel_y) = voxel_pos; + + // Derive the world seed from sub_chunk_seed for cluster coherence. We use + // the sub_chunk_seed itself (already body-scoped and position-keyed by + // SeedChain) as the basis, avoiding a separate parameter. + // cluster_scatter extracts a shared per-cluster value from this. + let scatter = cluster_scatter(sub_chunk_seed, voxel_x, voxel_y); + + let surface = classify_surface(®ion.morphology_zone, column.water); + + match surface { + // ── Fresh water (lakes / rivers) ────────────────────────────────── + SurfaceClass::FreshWater => { + if temp_i >= FRESH_BAND_HIGH_C { + // Above band — open water. + SeasonalCover::None + } else if temp_i < FRESH_BAND_LOW_C { + // Below band — permanently frozen. + SeasonalCover::Ice + } else { + // Within the scatter band [FRESH_BAND_LOW_C, FRESH_BAND_HIGH_C). + // Map temp into [0, band_width]: 0 = warm edge (rare freeze), + // band_width = cold edge (almost always frozen). + let band_width = (FRESH_BAND_HIGH_C - FRESH_BAND_LOW_C) as u64; // 15 + let depth_into_band = (FRESH_BAND_HIGH_C - temp_i) as u64; // 0..=15 + // Threshold: voxels with scatter % band_width < depth_into_band → Ice. + // At depth 1 (just below +5°C) ≈ 1/15 ≈ 7% frozen. + // At depth 14 (just above −10°C) ≈ 14/15 ≈ 93% frozen. + if scatter % band_width < depth_into_band { + SeasonalCover::Ice + } else { + SeasonalCover::None + } + } + } + + // ── Salt water (sea ice) ────────────────────────────────────────── + SurfaceClass::SaltWater => { + let salt_band_low = SALT_ONSET_C - SALT_BAND_WIDTH; // −14 °C + if temp_i >= SALT_ONSET_C { + // Above onset — open ocean. + SeasonalCover::None + } else if temp_i < salt_band_low { + // Below band — permanent pack ice. + SeasonalCover::Ice + } else { + // Within the sea-ice scatter band. + // Pack-ice pattern: larger coherent sheets + open lead gaps. + // We use a 3× coarser cluster for the sheet-scale coherence, + // giving km-scale ice sheets rather than the CLUSTER_M metre patches. + let lead_scatter = cluster_scatter( + sub_chunk_seed, + voxel_x.div_euclid(CLUSTER_M * 3) * CLUSTER_M * 3, + voxel_y.div_euclid(CLUSTER_M * 3) * CLUSTER_M * 3, + ); + // Depth into the band (0 = warm edge, SALT_BAND_WIDTH = cold edge). + let depth_into_band = (SALT_ONSET_C - temp_i) as u64; + let band_width = SALT_BAND_WIDTH as u64; // 12 + // Ice unless this is an open "lead" gap (rare, ~15% probability). + // The deeper into the band, the smaller the lead probability. + let lead_probability = + (band_width.saturating_sub(depth_into_band) * 15) / band_width.max(1); // 0..=15 (percent) + let is_lead = lead_scatter % 100 < lead_probability; + if depth_into_band == 0 || is_lead { + SeasonalCover::None + } else { + SeasonalCover::Ice + } + } + } + + // ── Land (snow) ─────────────────────────────────────────────────── + SurfaceClass::Land => { + // Moisture gate: cold + dry → bare frozen ground (no snow). + if region.moisture_q < SNOW_MOISTURE_GATE { + return SeasonalCover::None; + } + if temp_i >= SNOW_BAND_HIGH_C { + // Above snow band — too warm for snow accumulation. + SeasonalCover::None + } else if temp_i < FRESH_BAND_LOW_C { + // Below snow band — permanent snow (same low edge as freshwater). + SeasonalCover::Snow + } else { + // Within the scatter band [FRESH_BAND_LOW_C, SNOW_BAND_HIGH_C). + let band_width = (SNOW_BAND_HIGH_C - FRESH_BAND_LOW_C) as u64; // 12 + let depth_into_band = (SNOW_BAND_HIGH_C - temp_i) as u64; // 0..=12 + if scatter % band_width < depth_into_band { + SeasonalCover::Snow + } else { + SeasonalCover::None + } + } + } } } @@ -2744,4 +3080,387 @@ mod tests { ); } } + + // ----------------------------------------------------------------------- + // T-1030 — SeasonalCover: discriminant pins and derive_cover tests + // ----------------------------------------------------------------------- + + #[test] + fn seasonal_cover_discriminants_pinned() { + // Append-only invariant (D-010). + assert_eq!(SeasonalCover::None as u8, 0); + assert_eq!(SeasonalCover::Snow as u8, 1); + assert_eq!(SeasonalCover::Ice as u8, 2); + } + + // ── Helpers for derive_cover tests ────────────────────────────────────── + + /// Build a minimal VoxelColumn with a given Water state for cover testing. + /// The cover field is irrelevant (derive_cover is called externally in tests). + fn make_column(water: Water) -> VoxelColumn { + VoxelColumn { + terrain: TerrainMaterial::Soil, + floor: FloorMaterial::None, + vegetation: Vegetation::Grass, + water, + elevation_m: 5, + cover: SeasonalCover::None, + } + } + + /// Build a RegionProfile for cover tests with explicit temperature and moisture. + fn cover_region(zone: MorphologyZone, temp_c: f32, moisture_q: i32) -> RegionProfile { + RegionProfile { + morphology_zone: zone, + temperature_c: Some(temp_c), + moisture_q, + ..alluvial_region() + } + } + + // ── Airless body ───────────────────────────────────────────────────────── + + #[test] + fn cover_airless_body_is_none() { + // D-239 §2: no atmosphere → temperature_c == None → cover None always. + let mut region = alluvial_region(); + region.temperature_c = None; + let col = make_column(Water::Dry); + let cover = derive_cover(®ion, &col, (0, 0), 12345); + assert_eq!( + cover, + SeasonalCover::None, + "airless body must always produce None cover" + ); + } + + #[test] + fn cover_airless_body_even_with_water() { + let mut region = cover_region(MorphologyZone::Lake, 15.0, 80); + region.temperature_c = None; + let col = make_column(Water::Deep); + let cover = derive_cover(®ion, &col, (50, 50), 99999); + assert_eq!( + cover, + SeasonalCover::None, + "airless + lake must still be None" + ); + } + + // ── Freshwater band edges ───────────────────────────────────────────────── + + #[test] + fn cover_freshwater_above_band_is_none() { + // Above +5 °C: always None (open water), regardless of scatter. + let region = cover_region(MorphologyZone::Lake, 6.0, 60); + let col = make_column(Water::Deep); + // Sample many positions — all must be None above the band. + for i in 0..50i32 { + let cover = derive_cover(®ion, &col, (i * 7, i * 3), i as u64 * 17 + 1); + assert_eq!( + cover, + SeasonalCover::None, + "freshwater at temp +6°C must be None (above band) at pos ({},{})", + i * 7, + i * 3 + ); + } + } + + #[test] + fn cover_freshwater_below_band_is_all_ice() { + // Below −10 °C: always Ice (no scatter). + let region = cover_region(MorphologyZone::Lake, -11.0, 60); + let col = make_column(Water::Deep); + for i in 0..50i32 { + let cover = derive_cover(®ion, &col, (i * 11, i * 5), i as u64 * 31 + 7); + assert_eq!( + cover, + SeasonalCover::Ice, + "freshwater at temp −11°C must be Ice (below band) at pos ({},{})", + i * 11, + i * 5 + ); + } + } + + #[test] + fn cover_freshwater_within_band_has_mix_of_ice_and_none() { + // Within the scatter band [−10, +5]: should produce a mix of Ice and None + // across different positions. Not all-Ice, not all-None. + let region = cover_region(MorphologyZone::Lake, -3.0, 60); + let col = make_column(Water::Deep); + let mut ice_count = 0; + let mut none_count = 0; + for i in 0..200i32 { + // Spread across a large area to span many cluster cells. + let pos = (i * CLUSTER_M * 2, i * CLUSTER_M); + let cover = derive_cover(®ion, &col, pos, 42); + match cover { + SeasonalCover::Ice => ice_count += 1, + SeasonalCover::None => none_count += 1, + SeasonalCover::Snow => panic!("freshwater should not produce Snow"), + } + } + assert!( + ice_count > 0, + "scatter band at −3°C must produce some Ice tiles; got 0 Ice in 200 samples" + ); + assert!( + none_count > 0, + "scatter band at −3°C must produce some None tiles; got 0 None in 200 samples" + ); + } + + // ── Sea ice (salt water) ───────────────────────────────────────────────── + + #[test] + fn cover_salt_above_onset_is_none() { + // Above −2 °C: no sea ice. + let region = cover_region(MorphologyZone::OpenOcean, 0.0, 50); + let col = make_column(Water::Deep); + for i in 0..30i32 { + let cover = derive_cover(®ion, &col, (i * 13, i * 7), i as u64 * 23); + assert_eq!( + cover, + SeasonalCover::None, + "salt water at 0°C must be None (above onset −2°C)" + ); + } + } + + #[test] + fn cover_salt_below_band_is_all_ice() { + // Below −14 °C: permanent pack ice everywhere. + let region = cover_region(MorphologyZone::OpenOcean, -15.0, 50); + let col = make_column(Water::Deep); + for i in 0..30i32 { + let cover = derive_cover(®ion, &col, (i * 17, i * 9), i as u64 * 41); + assert_eq!( + cover, + SeasonalCover::Ice, + "salt water at −15°C must be Ice (below band)" + ); + } + } + + #[test] + fn cover_freshwater_not_triggered_for_salt_zone() { + // OpenOcean should use the salt-water band (onset −2°C), NOT the + // freshwater band (onset +5°C). At temp = +3°C, ocean must be None. + let region = cover_region(MorphologyZone::OpenOcean, 3.0, 50); + let col = make_column(Water::Deep); + for i in 0..20i32 { + let cover = derive_cover(®ion, &col, (i * 7, i * 5), i as u64 + 1); + assert_eq!( + cover, + SeasonalCover::None, + "ocean at +3°C must be None (salt onset is −2°C, not +5°C)" + ); + } + } + + // ── Snow (land) — moisture gating ──────────────────────────────────────── + + #[test] + fn cover_snow_cold_wet_land_produces_snow() { + // Cold + wet land must produce Snow in the permanent zone (below −10°C). + let region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 60); + let col = make_column(Water::Dry); + for i in 0..30i32 { + let cover = derive_cover(®ion, &col, (i * 9, i * 3), i as u64 * 11); + assert_eq!( + cover, + SeasonalCover::Snow, + "cold (−12°C) + moist land must produce Snow (permanent zone)" + ); + } + } + + #[test] + fn cover_snow_cold_dry_land_produces_none() { + // Cold + dry (moisture_q < SNOW_MOISTURE_GATE) → bare frozen ground, not Snow. + let region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 20); + let col = make_column(Water::Dry); + for i in 0..30i32 { + let cover = derive_cover(®ion, &col, (i * 9, i * 3), i as u64 * 11); + assert_eq!( + cover, + SeasonalCover::None, + "cold (−12°C) + dry land must produce None (bare frozen ground, not Snow)" + ); + } + } + + #[test] + fn cover_snow_warm_land_produces_none() { + // Warm land (above snow band): no snow. + let region = cover_region(MorphologyZone::AlluvialPlain, 10.0, 80); + let col = make_column(Water::Dry); + for i in 0..30i32 { + let cover = derive_cover(®ion, &col, (i * 5, i * 2), i as u64 * 7); + assert_eq!( + cover, + SeasonalCover::None, + "warm (+10°C) land must produce None even with high moisture" + ); + } + } + + #[test] + fn cover_snow_within_band_has_mix() { + // Within the snow scatter band (e.g. −5°C) + moist: mix of Snow and None. + let region = cover_region(MorphologyZone::AlluvialPlain, -5.0, 60); + let col = make_column(Water::Dry); + let mut snow_count = 0; + let mut none_count = 0; + for i in 0..200i32 { + let pos = (i * CLUSTER_M * 2, i * CLUSTER_M); + let cover = derive_cover(®ion, &col, pos, 42); + match cover { + SeasonalCover::Snow => snow_count += 1, + SeasonalCover::None => none_count += 1, + SeasonalCover::Ice => panic!("land should not produce Ice"), + } + } + assert!( + snow_count > 0, + "scatter band at −5°C moist land must produce some Snow; got 0 Snow" + ); + assert!( + none_count > 0, + "scatter band at −5°C moist land must produce some None; got 0 None" + ); + } + + // ── Land water-state: Dry riverbank should be land, not freshwater ──────── + + #[test] + fn cover_dry_riverbank_is_land_not_freshwater() { + // A RiverBank tile that is Water::Dry should be treated as land (snow), + // not freshwater (ice). D-239 §3: fresh water only when the voxel is wet. + let region = cover_region(MorphologyZone::RiverBank, -12.0, 60); + let dry_col = make_column(Water::Dry); + let wet_col = make_column(Water::Shallow); + + let dry_cover = derive_cover(®ion, &dry_col, (0, 0), 12345); + let wet_cover = derive_cover(®ion, &wet_col, (0, 0), 12345); + + assert_eq!( + dry_cover, + SeasonalCover::Snow, + "dry RiverBank at −12°C + moist must be Snow (land branch)" + ); + assert_eq!( + wet_cover, + SeasonalCover::Ice, + "wet RiverBank at −12°C must be Ice (freshwater branch)" + ); + } + + // ── Spatial coherence ──────────────────────────────────────────────────── + + #[test] + fn cover_cluster_coherence_not_per_tile() { + // Neighbours within the same cluster cell must agree more than random. + // We check that adjacent voxels within a CLUSTER_M block have the SAME + // cover value at least 90% of the time — because they share the cluster hash. + // + // Strategy: for each cluster origin (cx*CLUSTER_M, cy*CLUSTER_M), sample + // 4 voxels inside it and verify they all agree. + let region = cover_region(MorphologyZone::Lake, -3.0, 60); + let col = make_column(Water::Shallow); + let mut cluster_agreements = 0; + let mut cluster_total = 0; + for cx in 0..20i32 { + for cy in 0..20i32 { + let base_x = cx * CLUSTER_M; + let base_y = cy * CLUSTER_M; + // Sample 4 voxels inside the cluster cell. + let covers: Vec = (0..4) + .map(|offset| { + let px = base_x + offset % 2; + let py = base_y + offset / 2; + derive_cover(®ion, &col, (px, py), 42) + }) + .collect(); + // All 4 should agree (same cluster cell → same scatter hash). + if covers.windows(2).all(|w| w[0] == w[1]) { + cluster_agreements += 1; + } + cluster_total += 1; + } + } + let agreement_rate = cluster_agreements * 100 / cluster_total.max(1); + assert!( + agreement_rate >= 90, + "voxels within the same cluster cell must agree ≥90% of the time; got {}% ({}/{})", + agreement_rate, + cluster_agreements, + cluster_total + ); + } + + #[test] + fn cover_different_cluster_cells_produce_variety() { + // Different cluster cells should produce different outcomes — confirming + // the spatial pattern is clustered (not solid or per-tile random). + // Sample one representative from each of many distinct cluster cells. + let region = cover_region(MorphologyZone::Lake, -3.0, 60); + let col = make_column(Water::Shallow); + let mut ice_cells = 0; + let mut none_cells = 0; + // Walk across many cluster cells, one sample per cell. + for cx in 0..50i32 { + let px = cx * CLUSTER_M; // one canonical voxel per cluster + let cover = derive_cover(®ion, &col, (px, 0), 42); + match cover { + SeasonalCover::Ice => ice_cells += 1, + SeasonalCover::None => none_cells += 1, + SeasonalCover::Snow => {} + } + } + assert!( + ice_cells > 0 && none_cells > 0, + "different cluster cells must produce both Ice and None in the scatter band; \ + got Ice:{ice_cells} None:{none_cells}" + ); + } + + // ── Determinism ───────────────────────────────────────────────────────── + + #[test] + fn cover_derivation_is_deterministic() { + // Same inputs → same cover, always. + let region = cover_region(MorphologyZone::Lake, -3.0, 60); + let col = make_column(Water::Shallow); + for i in 0..50i32 { + let pos = (i * CLUSTER_M, i * 3); + let seed = (i as u64 * 1234567) ^ 0xdeadbeef; + let a = derive_cover(®ion, &col, pos, seed); + let b = derive_cover(®ion, &col, pos, seed); + assert_eq!( + a, + b, + "derive_cover must be deterministic at pos ({},{})", + i * CLUSTER_M, + i * 3 + ); + } + } + + #[test] + fn cover_end_to_end_via_derive_voxel_column_is_deterministic() { + // Cover must be deterministic through the full derive_voxel_column path. + let region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 60); + let chunk = derive_chunk_context(42, "cold_body", ®ion, (0, 0)); + for (tx, ty) in [(0, 0), (50, 100), (-20, 30), (200, -10)] { + let a = derive_voxel_column(42, "cold_body", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(42, "cold_body", ®ion, &chunk, tx, ty); + assert_eq!( + a.cover, b.cover, + "cover must be deterministic at ({tx},{ty})" + ); + } + } } From 88b4323585fb797f773b57763f5edd2e56e22d59 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 17:30:48 +0200 Subject: [PATCH 2/2] fix(simulation): address PR #163 review (freeze/snow model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe found a CRITICAL coherence defect (Tyre APPROVE): - CRITICAL: cluster_scatter received the per-voxel sub_chunk_seed as its cluster seed, so voxels in the same 6m cell got different scatter -> salt-and-pepper, not the §3-mandated clustered patches. Re-key on (world, body, cluster-cell): add SeedDomain::Cover=10 (pinned); cluster_scatter(world_seed, body_id, x, y) derives for_body(world_seed,body_id).derive(Cover, cluster_pair_id). derive_cover now takes (world_seed, body_id, ...). (Hoshe #1) - The coherence test was tautological (constant seed bypassed the broken path). Rewritten to the production derive_cover signature in a cold-lake scatter band; it FAILED at 20% on the old code, passes 100% now. (Hoshe #2) - Zigzag-encode cluster (cx,cy) before Cantor pairing (negatives were cast as u64 directly); fix the misleading 'no collision' comment. (Hoshe #3, Tyre) - Sea-ice band was inverted (~85% ice at the -2 onset). Now monotonic: ice grows ~8% (onset) -> ~92% (cold edge); leads (open gaps) layered only in the cold half via a 3x super-cell, passing the super-cell index directly. Dead depth==0 guard removed. (Hoshe #4, Tyre #1/#3) cargo test passes, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/voxel.rs | 287 ++++++++++++++++++++++++-------------- server/src/seed.rs | 6 + 2 files changed, 185 insertions(+), 108 deletions(-) diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index 3f177cf4a..5a5d647a2 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -392,7 +392,7 @@ pub fn derive_voxel_column( // axes (terrain/water/vegetation/elevation); cover is a separate orthogonal // axis derived from the region's mean temperature + water/terrain + coherent // cluster scatter. One site, set here — no family generator needs changing. - column.cover = derive_cover(region, &column, voxel_pos, sub_chunk_seed); + column.cover = derive_cover(world_seed, body_id, region, &column, voxel_pos); column } @@ -1309,27 +1309,52 @@ fn classify_surface(zone: &MorphologyZone, water: Water) -> SurfaceClass { /// Per-cluster scatter offset (D-239 §3). /// -/// Hash a coarse cluster cell `(cx, cy)` into a u64 scatter offset. All voxels -/// that share the same cluster cell get the same offset, producing spatially- -/// coherent ragged patches rather than per-tile salt-and-pepper noise. +/// Hash a coarse cluster cell `(cx, cy)` — derived from `(voxel_x, voxel_y)` +/// by `div_euclid(CLUSTER_M)` — into a body-scoped u64 scatter value via +/// `SeedChain`. All voxels that share the same cluster cell get the **same** +/// scatter value (coherent patches). Different world seeds and body ids yield +/// different freeze patterns. /// -/// Uses the project's canonical `splitmix64` mixer (D-010; same as SeedChain). -/// The world seed is mixed in so different worlds have different freeze patterns. +/// ## Key correctness properties +/// +/// - The cluster-cell coordinates may be negative (voxels at negative positions). +/// We zigzag-encode each signed `cx`/`cy` to a non-negative `u64` before +/// Cantor-pairing, giving a collision-free bijection for the full signed range. +/// (The old cast-to-u64 was wrong for negative inputs — huge two's-complement +/// values broke the "no collision" claim.) +/// - The seed is derived through `SeedChain::for_body(world_seed, body_id) +/// .derive(SeedDomain::Cover, cluster_pair_id)` — a body-scoped, domain- +/// separated derivation, not a raw `splitmix64` on the per-voxel sub_chunk_seed. +/// This is the root fix for the coherence defect: `sub_chunk_seed` is per-voxel, +/// so passing it here gave each voxel its own scatter value → salt-and-pepper. +/// `SeedChain::for_body` is body-scoped and the cluster id is a cluster-cell +/// property — all voxels in the same cell derive the same seed. #[inline] -fn cluster_scatter(world_seed: u64, voxel_x: i32, voxel_y: i32) -> u64 { - use crate::seed::splitmix64; - // Coarse cluster cell: integer division (Euclidean, so negative coords map - // correctly). Two voxels 1 m apart that share a cluster cell get the same hash. - let cx = voxel_x.div_euclid(CLUSTER_M) as u64; - let cy = voxel_y.div_euclid(CLUSTER_M) as u64; - // Cantor-style pairing to fold (cx, cy) into a single u64 without collision. - // We use zigzag encode + additive mixing to keep the avalanche properties. +fn cluster_scatter(world_seed: u64, body_id: &str, voxel_x: i32, voxel_y: i32) -> u64 { + use crate::seed::{SeedChain, SeedDomain}; + // Coarse cluster cell index (Euclidean div so negatives map cleanly). + let cx_i = voxel_x.div_euclid(CLUSTER_M); + let cy_i = voxel_y.div_euclid(CLUSTER_M); + // Zigzag encode signed → unsigned (same pattern as voxel_pos_to_id and + // domain_warp::pos_to_id — bijective over the full i32 range, no collisions + // between positive and negative cluster coords). + let zz = |v: i32| -> u64 { + let v = v as i64; + ((v << 1) ^ (v >> 63)) as u64 + }; + let cx = zz(cx_i); + let cy = zz(cy_i); + // Cantor-pairing (now collision-free because both inputs are non-negative u64). let s = cx.wrapping_add(cy); - let paired = s + let cluster_pair_id = s .wrapping_mul(s.wrapping_add(1)) .wrapping_div(2) .wrapping_add(cy); - splitmix64(world_seed ^ splitmix64(paired)) + // Body-scoped, domain-separated derivation via SeedChain (D-224 / D-010). + // All voxels sharing this cluster cell → same cluster_pair_id → same seed. + SeedChain::for_body(world_seed, body_id) + .derive(SeedDomain::Cover, cluster_pair_id) + .seed() } /// Derive the `SeasonalCover` overlay for a single voxel (D-239 §3, T-1030). @@ -1383,10 +1408,11 @@ fn cluster_scatter(world_seed: u64, voxel_x: i32, voxel_y: i32) -> u64 { /// All decisions are integer. `temperature_c` is cast to `i32` once (positional /// quantisation, not a structural float gate). All scatter is hash/integer only. pub fn derive_cover( + world_seed: u64, + body_id: &str, region: &RegionProfile, column: &VoxelColumn, voxel_pos: VoxelPos, - sub_chunk_seed: u64, ) -> SeasonalCover { // ── Airless body (D-239 §2) ─────────────────────────────────────────── // No atmosphere → no climate branch → no seasonal cover. @@ -1400,11 +1426,10 @@ pub fn derive_cover( let (voxel_x, voxel_y) = voxel_pos; - // Derive the world seed from sub_chunk_seed for cluster coherence. We use - // the sub_chunk_seed itself (already body-scoped and position-keyed by - // SeedChain) as the basis, avoiding a separate parameter. - // cluster_scatter extracts a shared per-cluster value from this. - let scatter = cluster_scatter(sub_chunk_seed, voxel_x, voxel_y); + // Per-cluster scatter: keyed ONLY on (world, body, cluster-cell) — never + // on the per-voxel sub_chunk_seed. All voxels in the same 6×6 m cell share + // this value, producing ragged clustered patches (D-239 §3). + let scatter = cluster_scatter(world_seed, body_id, voxel_x, voxel_y); let surface = classify_surface(®ion.morphology_zone, column.water); @@ -1419,13 +1444,12 @@ pub fn derive_cover( SeasonalCover::Ice } else { // Within the scatter band [FRESH_BAND_LOW_C, FRESH_BAND_HIGH_C). - // Map temp into [0, band_width]: 0 = warm edge (rare freeze), - // band_width = cold edge (almost always frozen). + // Threshold: scatter % band_width < depth_into_band → Ice. + // depth 1 (just below +5°C) ≈ 1/15 ≈ 7% Ice. + // depth 14 (just above −10°C) ≈ 14/15 ≈ 93% Ice. + // Ice-fraction is monotonically increasing as temp drops. let band_width = (FRESH_BAND_HIGH_C - FRESH_BAND_LOW_C) as u64; // 15 - let depth_into_band = (FRESH_BAND_HIGH_C - temp_i) as u64; // 0..=15 - // Threshold: voxels with scatter % band_width < depth_into_band → Ice. - // At depth 1 (just below +5°C) ≈ 1/15 ≈ 7% frozen. - // At depth 14 (just above −10°C) ≈ 14/15 ≈ 93% frozen. + let depth_into_band = (FRESH_BAND_HIGH_C - temp_i) as u64; // 1..=14 if scatter % band_width < depth_into_band { SeasonalCover::Ice } else { @@ -1444,27 +1468,55 @@ pub fn derive_cover( // Below band — permanent pack ice. SeasonalCover::Ice } else { - // Within the sea-ice scatter band. - // Pack-ice pattern: larger coherent sheets + open lead gaps. - // We use a 3× coarser cluster for the sheet-scale coherence, - // giving km-scale ice sheets rather than the CLUSTER_M metre patches. - let lead_scatter = cluster_scatter( - sub_chunk_seed, - voxel_x.div_euclid(CLUSTER_M * 3) * CLUSTER_M * 3, - voxel_y.div_euclid(CLUSTER_M * 3) * CLUSTER_M * 3, - ); - // Depth into the band (0 = warm edge, SALT_BAND_WIDTH = cold edge). - let depth_into_band = (SALT_ONSET_C - temp_i) as u64; + // Within the sea-ice scatter band [−14, −2). + // + // Ice-fraction is monotonically increasing as temp drops + // (same linear model as freshwater: scatter % band_width < + // depth_into_band). At onset (depth 1) ≈ 8% ice; at depth 11 + // (just above −14°C) ≈ 92% ice. + // + // Pack-ice sheets + leads structure: on the cold, mostly-frozen + // end we apply a 3× coarser cluster (super-cell = 3×CLUSTER_M) + // to carve occasional open "lead" gaps within what would otherwise + // be solid ice. A lead forms when the super-cell scatter indicates + // a gap AND the voxel is in the cold zone where ice would normally + // be solid. This gives sheet-scale coherence (large contiguous ice + // panels) with a small fraction of narrow persistent leads. + let depth_into_band = (SALT_ONSET_C - temp_i) as u64; // 1..=11 let band_width = SALT_BAND_WIDTH as u64; // 12 - // Ice unless this is an open "lead" gap (rare, ~15% probability). - // The deeper into the band, the smaller the lead probability. - let lead_probability = - (band_width.saturating_sub(depth_into_band) * 15) / band_width.max(1); // 0..=15 (percent) - let is_lead = lead_scatter % 100 < lead_probability; - if depth_into_band == 0 || is_lead { - SeasonalCover::None + + // Primary ice decision (monotonic, same logic as freshwater). + let is_ice = scatter % band_width < depth_into_band; + + if is_ice { + // In the cold half of the band (depth > band_width/2), apply + // the pack-ice lead pattern using a coarser super-cell cluster. + // The lead cluster is keyed on the super-cell index directly, + // not on pre-quantized coordinates, so the intent is clear. + let cold_threshold = band_width / 2; + if depth_into_band > cold_threshold { + // Super-cell index: one step per 3×CLUSTER_M metres. + let super_cell_size = CLUSTER_M * 3; + let scx = voxel_x.div_euclid(super_cell_size); + let scy = voxel_y.div_euclid(super_cell_size); + let sheet_scatter = cluster_scatter( + world_seed, + body_id, + scx * super_cell_size, + scy * super_cell_size, + ); + // ~8% open leads in the cold zone (persistent gaps in pack ice). + let is_lead = sheet_scatter % 100 < 8; + if is_lead { + SeasonalCover::None + } else { + SeasonalCover::Ice + } + } else { + SeasonalCover::Ice + } } else { - SeasonalCover::Ice + SeasonalCover::None } } } @@ -1483,8 +1535,9 @@ pub fn derive_cover( SeasonalCover::Snow } else { // Within the scatter band [FRESH_BAND_LOW_C, SNOW_BAND_HIGH_C). + // Same monotonic model: ice-fraction increases as temp drops. let band_width = (SNOW_BAND_HIGH_C - FRESH_BAND_LOW_C) as u64; // 12 - let depth_into_band = (SNOW_BAND_HIGH_C - temp_i) as u64; // 0..=12 + let depth_into_band = (SNOW_BAND_HIGH_C - temp_i) as u64; // 1..=11 if scatter % band_width < depth_into_band { SeasonalCover::Snow } else { @@ -3126,7 +3179,7 @@ mod tests { let mut region = alluvial_region(); region.temperature_c = None; let col = make_column(Water::Dry); - let cover = derive_cover(®ion, &col, (0, 0), 12345); + let cover = derive_cover(42, "test_body", ®ion, &col, (0, 0)); assert_eq!( cover, SeasonalCover::None, @@ -3139,7 +3192,7 @@ mod tests { let mut region = cover_region(MorphologyZone::Lake, 15.0, 80); region.temperature_c = None; let col = make_column(Water::Deep); - let cover = derive_cover(®ion, &col, (50, 50), 99999); + let cover = derive_cover(42, "test_body", ®ion, &col, (50, 50)); assert_eq!( cover, SeasonalCover::None, @@ -3156,7 +3209,7 @@ mod tests { let col = make_column(Water::Deep); // Sample many positions — all must be None above the band. for i in 0..50i32 { - let cover = derive_cover(®ion, &col, (i * 7, i * 3), i as u64 * 17 + 1); + let cover = derive_cover(42, "test_body", ®ion, &col, (i * 7, i * 3)); assert_eq!( cover, SeasonalCover::None, @@ -3173,7 +3226,7 @@ mod tests { let region = cover_region(MorphologyZone::Lake, -11.0, 60); let col = make_column(Water::Deep); for i in 0..50i32 { - let cover = derive_cover(®ion, &col, (i * 11, i * 5), i as u64 * 31 + 7); + let cover = derive_cover(42, "test_body", ®ion, &col, (i * 11, i * 5)); assert_eq!( cover, SeasonalCover::Ice, @@ -3195,7 +3248,7 @@ mod tests { for i in 0..200i32 { // Spread across a large area to span many cluster cells. let pos = (i * CLUSTER_M * 2, i * CLUSTER_M); - let cover = derive_cover(®ion, &col, pos, 42); + let cover = derive_cover(42, "test_body", ®ion, &col, pos); match cover { SeasonalCover::Ice => ice_count += 1, SeasonalCover::None => none_count += 1, @@ -3220,7 +3273,7 @@ mod tests { let region = cover_region(MorphologyZone::OpenOcean, 0.0, 50); let col = make_column(Water::Deep); for i in 0..30i32 { - let cover = derive_cover(®ion, &col, (i * 13, i * 7), i as u64 * 23); + let cover = derive_cover(42, "test_body", ®ion, &col, (i * 13, i * 7)); assert_eq!( cover, SeasonalCover::None, @@ -3235,7 +3288,7 @@ mod tests { let region = cover_region(MorphologyZone::OpenOcean, -15.0, 50); let col = make_column(Water::Deep); for i in 0..30i32 { - let cover = derive_cover(®ion, &col, (i * 17, i * 9), i as u64 * 41); + let cover = derive_cover(42, "test_body", ®ion, &col, (i * 17, i * 9)); assert_eq!( cover, SeasonalCover::Ice, @@ -3251,7 +3304,7 @@ mod tests { let region = cover_region(MorphologyZone::OpenOcean, 3.0, 50); let col = make_column(Water::Deep); for i in 0..20i32 { - let cover = derive_cover(®ion, &col, (i * 7, i * 5), i as u64 + 1); + let cover = derive_cover(42, "test_body", ®ion, &col, (i * 7, i * 5)); assert_eq!( cover, SeasonalCover::None, @@ -3268,7 +3321,7 @@ mod tests { let region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 60); let col = make_column(Water::Dry); for i in 0..30i32 { - let cover = derive_cover(®ion, &col, (i * 9, i * 3), i as u64 * 11); + let cover = derive_cover(42, "test_body", ®ion, &col, (i * 9, i * 3)); assert_eq!( cover, SeasonalCover::Snow, @@ -3283,7 +3336,7 @@ mod tests { let region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 20); let col = make_column(Water::Dry); for i in 0..30i32 { - let cover = derive_cover(®ion, &col, (i * 9, i * 3), i as u64 * 11); + let cover = derive_cover(42, "test_body", ®ion, &col, (i * 9, i * 3)); assert_eq!( cover, SeasonalCover::None, @@ -3298,7 +3351,7 @@ mod tests { let region = cover_region(MorphologyZone::AlluvialPlain, 10.0, 80); let col = make_column(Water::Dry); for i in 0..30i32 { - let cover = derive_cover(®ion, &col, (i * 5, i * 2), i as u64 * 7); + let cover = derive_cover(42, "test_body", ®ion, &col, (i * 5, i * 2)); assert_eq!( cover, SeasonalCover::None, @@ -3316,7 +3369,7 @@ mod tests { let mut none_count = 0; for i in 0..200i32 { let pos = (i * CLUSTER_M * 2, i * CLUSTER_M); - let cover = derive_cover(®ion, &col, pos, 42); + let cover = derive_cover(42, "test_body", ®ion, &col, pos); match cover { SeasonalCover::Snow => snow_count += 1, SeasonalCover::None => none_count += 1, @@ -3343,8 +3396,8 @@ mod tests { let dry_col = make_column(Water::Dry); let wet_col = make_column(Water::Shallow); - let dry_cover = derive_cover(®ion, &dry_col, (0, 0), 12345); - let wet_cover = derive_cover(®ion, &wet_col, (0, 0), 12345); + let dry_cover = derive_cover(42, "test_body", ®ion, &dry_col, (0, 0)); + let wet_cover = derive_cover(42, "test_body", ®ion, &wet_col, (0, 0)); assert_eq!( dry_cover, @@ -3360,70 +3413,89 @@ mod tests { // ── Spatial coherence ──────────────────────────────────────────────────── + /// Coherence test: exercises the REAL `derive_cover` production signature + /// (with `world_seed` + `body_id` — the fixed API) to catch the defect from + /// PR #163. + /// + /// The defect: in the broken code, `cluster_scatter` received `sub_chunk_seed` + /// (a per-voxel value), so every voxel had a distinct scatter hash → salt-and- + /// pepper noise, NOT clustered patches. This test would FAIL against that code + /// because intra-cluster agreement would be ~50% (random coin flip) instead of + /// 100%. It PASSES only with the fix: `cluster_scatter` uses + /// `SeedChain::for_body(world_seed, body_id).derive(Cover, cluster_cell_id)`, + /// keyed on (world, body, cluster-cell) so every voxel in the same cell gets + /// the same hash. + /// + /// ## Why we test `derive_cover` directly rather than via `derive_voxel_column` + /// + /// `derive_voxel_column` applies domain warp (±8 m) before computing the voxel + /// address. Two logically-adjacent tiles (1 m apart) may warp to positions that + /// fall in *different* 6 m cluster cells — which is correct behavior, not a + /// coherence failure. Testing coherence at the `derive_voxel_column` level would + /// require knowing the post-warp cluster-cell boundaries, making the test + /// fragile. Testing `derive_cover` directly with positions that are explicitly + /// within the same cluster cell is the clean contract-level test. #[test] fn cover_cluster_coherence_not_per_tile() { - // Neighbours within the same cluster cell must agree more than random. - // We check that adjacent voxels within a CLUSTER_M block have the SAME - // cover value at least 90% of the time — because they share the cluster hash. - // - // Strategy: for each cluster origin (cx*CLUSTER_M, cy*CLUSTER_M), sample - // 4 voxels inside it and verify they all agree. + // Use a cold lake region so cover is in the scatter band (mix of Ice/None) + // — solid-frozen would pass trivially regardless of coherence. let region = cover_region(MorphologyZone::Lake, -3.0, 60); let col = make_column(Water::Shallow); + let world_seed: u64 = 42; + let body_id = "coherence_body"; + let mut cluster_agreements = 0; let mut cluster_total = 0; - for cx in 0..20i32 { - for cy in 0..20i32 { + + // Walk a grid of cluster cells. For each cell, sample 4 voxels that are + // explicitly inside that cell: offsets (0,0), (1,0), (0,1), (CLUSTER_M-1, CLUSTER_M-1). + // All 4 must produce identical cover — same cluster-cell → same SeedChain hash. + for cx in -10..10i32 { + for cy in -10..10i32 { let base_x = cx * CLUSTER_M; let base_y = cy * CLUSTER_M; - // Sample 4 voxels inside the cluster cell. - let covers: Vec = (0..4) - .map(|offset| { - let px = base_x + offset % 2; - let py = base_y + offset / 2; - derive_cover(®ion, &col, (px, py), 42) + let offsets = [(0, 0), (1, 0), (0, 1), (CLUSTER_M - 1, CLUSTER_M - 1)]; + let covers: Vec = offsets + .iter() + .map(|&(dx, dy)| { + derive_cover( + world_seed, + body_id, + ®ion, + &col, + (base_x + dx, base_y + dy), + ) }) .collect(); - // All 4 should agree (same cluster cell → same scatter hash). + // All 4 must agree. if covers.windows(2).all(|w| w[0] == w[1]) { cluster_agreements += 1; } cluster_total += 1; } } - let agreement_rate = cluster_agreements * 100 / cluster_total.max(1); - assert!( - agreement_rate >= 90, - "voxels within the same cluster cell must agree ≥90% of the time; got {}% ({}/{})", - agreement_rate, + + assert_eq!( cluster_agreements, + cluster_total, + "ALL voxels within the same cluster cell must agree (same cluster-cell hash); \ + {}/{} cells disagreed — this means cluster_scatter is NOT keyed solely on the \ + cluster-cell coordinates (salt-and-pepper bug)", + cluster_total - cluster_agreements, cluster_total ); - } - #[test] - fn cover_different_cluster_cells_produce_variety() { - // Different cluster cells should produce different outcomes — confirming - // the spatial pattern is clustered (not solid or per-tile random). - // Sample one representative from each of many distinct cluster cells. - let region = cover_region(MorphologyZone::Lake, -3.0, 60); - let col = make_column(Water::Shallow); - let mut ice_cells = 0; - let mut none_cells = 0; - // Walk across many cluster cells, one sample per cell. - for cx in 0..50i32 { - let px = cx * CLUSTER_M; // one canonical voxel per cluster - let cover = derive_cover(®ion, &col, (px, 0), 42); - match cover { - SeasonalCover::Ice => ice_cells += 1, - SeasonalCover::None => none_cells += 1, - SeasonalCover::Snow => {} - } - } + // Also verify that cover varies across different cluster cells in the scatter + // band — confirming the hash is not degenerate (not all-Ice or all-None). + let cell_covers: Vec = (-25..25i32) + .map(|cx| derive_cover(world_seed, body_id, ®ion, &col, (cx * CLUSTER_M, 0))) + .collect(); + let has_ice = cell_covers.contains(&SeasonalCover::Ice); + let has_none = cell_covers.contains(&SeasonalCover::None); assert!( - ice_cells > 0 && none_cells > 0, - "different cluster cells must produce both Ice and None in the scatter band; \ - got Ice:{ice_cells} None:{none_cells}" + has_ice && has_none, + "cover must vary across cluster cells (both Ice and None expected in scatter \ + band −3°C); got has_ice={has_ice} has_none={has_none} — hash may be degenerate" ); } @@ -3436,9 +3508,8 @@ mod tests { let col = make_column(Water::Shallow); for i in 0..50i32 { let pos = (i * CLUSTER_M, i * 3); - let seed = (i as u64 * 1234567) ^ 0xdeadbeef; - let a = derive_cover(®ion, &col, pos, seed); - let b = derive_cover(®ion, &col, pos, seed); + let a = derive_cover(42, "test_body", ®ion, &col, pos); + let b = derive_cover(42, "test_body", ®ion, &col, pos); assert_eq!( a, b, diff --git a/server/src/seed.rs b/server/src/seed.rs index bb8bb2267..27955ee3b 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -105,6 +105,11 @@ pub enum SeedDomain { /// voxel stream can never collide with the region-scale meander seed (D-224 /// domain separation). Voxel = 9, + /// Seasonal cover cluster scatter (D-239 §3, T-1030). Keyed by the coarse + /// cluster-cell id (zigzag-encoded and Cantor-paired). Distinct from `Voxel` + /// so the per-cluster cover hash can never collide with the per-voxel terrain + /// stream — domain separation guarantees no freeze-to-terrain correlation. + Cover = 10, } /// A position in the deterministic seed tree (D-224). @@ -263,6 +268,7 @@ mod tests { assert_eq!(SeedDomain::DomainWarp as u64, 7); assert_eq!(SeedDomain::ChunkContext as u64, 8); assert_eq!(SeedDomain::Voxel as u64, 9); + assert_eq!(SeedDomain::Cover as u64, 10); } #[test]