feat(simulation): voxel-tier mid-scale relief — navigable hills (T-1081)

The voxel tier read flat (the D-245 "0–3 m, no hills to navigate by" bug):
family generators set elevation from elev_q at a compressed scale plus only ±4 m
micro-scatter, and detail-scatter's mid-scale relief (D-243 §2) only ever reached
the district tier (elev_q), never per-voxel elevation_m.

- detail_scatter.rs: extract the enveloped-fBm core; add `voxel_relief` at the
  0.25–2 km octave band (vs the district 4–40 km band).
- voxel.rs: add the mid-scale relief post-dispatch in derive_voxel_column, body-global
  SeedDomain::VoxelRelief seed (position-keyed), f64 truncated to integer metres (D-010).
  Envelope = slope_q*3 + elev_q — the coarse heightmap gives slope_q ≈ 0 even on high
  ground (Arbour max 13), so elevation must drive ruggedness. Flat families only
  (Alluvial/Lava/BraidedDelta/Dune/Meander); the dramatic families already carry strong
  internal relief and WaterBody stays at sea level.
- seed.rs: SeedDomain::VoxelRelief (D-224 domain separation).
- believability.rs: new `contrast.voxel_relief_m` metric — mean within-district elevation
  range over a 2 km transect (a single 64 m sample chunk is narrower than the relief
  band) + a "voxel relief" criterion.

Arbour @ yolo: voxel relief 5 m → 59 m, 6/8 → 7/8 criteria (remaining fail = the
Q-123 vegetation-denominator item). Edict 59 m. Golden regenerated; 1586 lib tests +
believability harness (determinism) pass; clippy -D warnings clean.

D-239 amended (T-1081); Q-123 item 4 updated. Deferred to Q-123/follow-up: the
provisional span + threshold, and a per-body hypsometric absolute-elevation model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 20:41:59 +02:00
co-authored by Claude Opus 4.8
parent dcc422f0d8
commit ab6e1413d9
9 changed files with 375 additions and 4 deletions
+56
View File
@@ -67,6 +67,11 @@ const WATER_PRESENCE_Q: i32 = 10;
const VOXEL_SAMPLE_DISTRICTS: usize = 64;
/// Stride over a district's representative 64 m chunk (8 → an 8×8 = 64-voxel sample).
const VOXEL_SAMPLE_STRIDE: usize = 8;
/// Chunk offsets within a district (×64 m ⇒ 128 m … 1920 m) for the mid-scale relief
/// transect (T-1081) — samples the elevation range a character crosses over the
/// district's ~2 km span (the voxel-relief band), which the single 64 m sample chunk
/// is too narrow to register.
const RELIEF_TRANSECT_CHUNK_OFFSETS: [i32; 6] = [2, 8, 14, 20, 26, 30];
// ---------------------------------------------------------------------------
// Report types (serde — golden-snapshot-able)
@@ -101,6 +106,12 @@ pub struct ContrastMetrics {
pub vegetation_classes: usize,
/// Distinct voxel `TerrainMaterial` values across the voxel sample.
pub terrain_materials: usize,
/// Mean within-district voxel elevation *range* (metres) across a district-spanning
/// transect of the sampled land districts (T-1081). The walkable-relief screen:
/// near 0 on flat terrain (the "03 m, no hills" symptom), tens of metres once the
/// voxel tier carries mid-scale relief. Measured over the 0.252 km band a character
/// traverses — not the single 64 m sample chunk, which is too narrow to register it.
pub voxel_relief_m: i32,
}
/// Coherence checks — does the world render as a *caused* place? Pass/fail counts
@@ -171,6 +182,7 @@ pub fn analyze(
.map(|d| format!("{:?}", d.vegetation_class)),
),
terrain_materials: 0, // filled from the voxel sample below
voxel_relief_m: 0, // filled from the relief transect below
};
// ── Voxel-derived metrics over a deterministic seeded spread of districts ──
@@ -181,6 +193,9 @@ pub fn analyze(
let mut terrain_set: BTreeSet<String> = BTreeSet::new();
let mut coh = CoherenceMetrics::default();
let mut sampled = 0usize;
// Mid-scale relief accumulators (T-1081): sum of per-district transect ranges over
// land districts, and the count contributing, → mean within-district relief.
let (mut relief_range_sum, mut relief_district_count) = (0i64, 0i64);
let keys: Vec<DistrictPos> = districts.keys().copied().collect();
for idx in sample_indices(world_seed, keys.len(), VOXEL_SAMPLE_DISTRICTS) {
@@ -243,8 +258,43 @@ pub fn analyze(
coh.drainage_monotonic += 1;
}
}
// Mid-scale relief transect (T-1081): the dry-ground elevation range across the
// district's ~2 km span — where the voxel tier's mid-scale relief lives. The
// single 64 m sample chunk above is narrower than the relief's 0.252 km band,
// so it would read flat even on rolling terrain; this transect is the screen.
// Only land-dominant districts count (≥3 of 6 transect points dry) — coastal
// districts with one stray dry point would otherwise contribute a 0 range and
// bias the mean toward "flat" on an ocean world (a measurement artifact, not
// flat land).
let (mut relief_lo, mut relief_hi, mut relief_dry) = (i32::MAX, i32::MIN, 0i32);
for &co in &RELIEF_TRANSECT_CHUNK_OFFSETS {
let cpos = (
dp.0 * CHUNKS_PER_DISTRICT + co,
dp.1 * CHUNKS_PER_DISTRICT + co,
);
let cctx = derive_chunk_context(world_seed, body_id, prof, cpos, None);
let tx = cpos.0 * CHUNK_M + CHUNK_M / 2;
let ty = cpos.1 * CHUNK_M + CHUNK_M / 2;
let col = derive_voxel_column(world_seed, body_id, prof, &cctx, tx, ty);
if col.water == Water::Dry {
relief_lo = relief_lo.min(col.elevation_m);
relief_hi = relief_hi.max(col.elevation_m);
relief_dry += 1;
}
}
if relief_dry >= 3 {
relief_range_sum += (relief_hi - relief_lo) as i64;
relief_district_count += 1;
}
}
let voxel_relief_m = if relief_district_count > 0 {
(relief_range_sum / relief_district_count) as i32
} else {
0
};
BelievabilityReport {
body_id: body_id.to_string(),
world_seed,
@@ -252,6 +302,7 @@ pub fn analyze(
voxel_sampled_districts: sampled,
contrast: ContrastMetrics {
terrain_materials: terrain_set.len(),
voxel_relief_m,
..contrast_scalar
},
coherence: coh,
@@ -280,6 +331,11 @@ pub fn evaluate_criteria(r: &BelievabilityReport) -> Vec<Criterion> {
pass: c.elev_q.spread() >= 10,
detail: format!("elev_q spread={}", c.elev_q.spread()),
},
Criterion {
name: "voxel relief",
pass: c.voxel_relief_m >= 8,
detail: format!("mean within-district relief {} m", c.voxel_relief_m),
},
Criterion {
name: "morphology variety",
pass: c.morphology_zones >= 2,
+75 -1
View File
@@ -24,6 +24,12 @@ use crate::seed::splitmix64;
/// coarsest (~33 km) the heightmap itself carries the shape.
const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, 4_096.0];
/// Voxel-tier octave wavelengths in metres — the ≈0.252 km band that the
/// district-tier [`terrain_detail`] (440 km) is too coarse to carry and the
/// per-voxel scatter (<64 m) is too fine to reach. This is the [`voxel_relief`]
/// band: the rolling/ridged hills a *walking character* navigates by (T-1081).
const VOXEL_OCTAVE_WAVELENGTHS_M: [f64; 4] = [2_048.0, 1_024.0, 512.0, 256.0];
/// Deterministic lattice value in `[-1, 1)` for an integer noise cell.
#[inline]
fn lattice(seed: u64, ix: i64, iy: i64) -> f64 {
@@ -64,6 +70,40 @@ fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64 {
/// Returns roughly `[-envelope, +envelope]`, smaller and smoother as `ruggedness`
/// drops toward 0 (gentle flats), larger and ridged as it rises toward 1.
pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 {
enveloped_fbm(seed, wx, wy, envelope, ruggedness, &OCTAVE_WAVELENGTHS_M)
}
/// The voxel-tier mid-scale relief perturbation in `[0,1]`-normalized units
/// (multiply by the body's relief span for metres) — the ≈0.252 km rolling/ridged
/// hills a walking character navigates by (T-1081). Identical envelope/ruggedness
/// contract as [`terrain_detail`]; only the octave band differs
/// ([`VOXEL_OCTAVE_WAVELENGTHS_M`] vs the district [`OCTAVE_WAVELENGTHS_M`]).
///
/// Seed must be **body-global** (constant across the body) — the position
/// `(wx, wy)` carries the variation. A per-voxel seed would make every voxel a
/// fresh lattice (white noise, not smooth hills).
pub fn voxel_relief(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 {
enveloped_fbm(
seed,
wx,
wy,
envelope,
ruggedness,
&VOXEL_OCTAVE_WAVELENGTHS_M,
)
}
/// Shared adaptive-fBm core for [`terrain_detail`] and [`voxel_relief`] — the only
/// difference between the two tiers is the octave wavelength band. Returns the
/// enveloped, ruggedness-modulated perturbation (roughly `[-envelope, +envelope]`).
fn enveloped_fbm(
seed: u64,
wx: f64,
wy: f64,
envelope: f64,
ruggedness: f64,
wavelengths: &[f64],
) -> f64 {
let env = envelope.clamp(0.0, 1.0);
let rug = ruggedness.clamp(0.0, 1.0);
if env == 0.0 {
@@ -73,7 +113,7 @@ pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f6
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
for (i, &wl) in OCTAVE_WAVELENGTHS_M.iter().enumerate() {
for (i, &wl) in wavelengths.iter().enumerate() {
let mut n = value_noise(
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
wx,
@@ -159,4 +199,38 @@ mod tests {
(base - near).abs()
);
}
// ── voxel_relief (T-1081): same contract, sub-district band ──────────────
#[test]
fn voxel_relief_deterministic_and_bounded() {
let a = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5);
let b = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5);
assert_eq!(a, b);
// Same envelope rule + amplitude ceiling as terrain_detail.
assert_eq!(voxel_relief(7, 1_000.0, 2_000.0, 0.0, 1.0), 0.0);
for i in 0..400 {
let v = voxel_relief(99, i as f64 * 137.0, i as f64 * -91.0, 0.5, 1.0);
assert!(v.abs() <= 0.5 + 1e-9, "v={v} exceeded envelope at {i}");
}
}
#[test]
fn voxel_relief_varies_at_sub_district_scale() {
// The voxel band must produce DISTINCT relief within a single 2 km district —
// exactly the variation terrain_detail's 440 km band cannot carry, and the
// flatness T-1081 fixes. Body-global seed; position carries the variation. Steps
// are deliberately non-aligned with the octave wavelengths to avoid aliasing.
let seed = 1234;
let vals: Vec<f64> = (0..16)
.map(|i| voxel_relief(seed, i as f64 * 137.0, i as f64 * 89.0, 0.7, 0.6))
.collect();
let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
max - min > 0.1,
"voxel relief is near-flat across a district: range {}",
max - min
);
}
}
+141
View File
@@ -333,6 +333,15 @@ fn zone_to_family(zone: &MorphologyZone) -> MorphologyFamily {
/// ## Returns
///
/// A fully derived `VoxelColumn` with all D-228 axes populated.
/// T-1081: the metre span of the voxel-tier mid-scale relief field — the amplitude
/// ceiling for the ≈0.252 km rolling/ridged hills added to the family base
/// elevation. Actual relief at a voxel is `voxel_relief(...) * SPAN`, which the
/// envelope (district ruggedness) scales down on gentle terrain, so this is the
/// steep-terrain ceiling, not a uniform amplitude. Provisional — tuned against the
/// believability probe (T-1079) like the moisture gradient; per-body *absolute*
/// elevation span is a later refinement (the T-1080 climate-fields lineage).
const VOXEL_RELIEF_SPAN_M: i32 = 300;
pub fn derive_voxel_column(
world_seed: u64,
body_id: &str,
@@ -427,6 +436,58 @@ pub fn derive_voxel_column(
}
};
// ── 3b. Voxel-tier mid-scale relief (T-1081, D-227 / D-243 §2) ────────
// The family generators set a coarse district-derived base elevation plus only
// ±4 m of per-voxel micro-scatter, leaving the walkable surface near-flat — no
// hills or landmarks to navigate by (the T-1081 symptom). Add the mid-scale
// (≈0.252 km) rolling/ridged relief the district→voxel seam does not otherwise
// reach (D-243 §2): detail-scatter enveloped by district ruggedness (slope_q) so
// an authored plain stays flat and rugged terrain gains real relief. The relief
// field is body-global (SeedDomain::VoxelRelief, position-keyed); the f64
// perturbation is truncated to integer metres before assignment (D-010,
// truncate-before-decision — the same sanctioned f64 path as the domain warp).
//
// Skipped for the WaterBody family: open ocean / lake / tidal-flat surfaces stay
// at sea level (elevation 0). River channels inside the land families ride up
// with their banks — value noise is smooth at 1 m steps, so a channel voxel and
// its bank get near-identical relief and the family's relative channel cut holds.
// Only the FLAT families take the mid-scale relief. The dramatic families
// (CliffCoast / FjordWall / IncisedGorge) already generate strong internal relief
// from their own geometry — layering a position-varying field over them would warp
// those features (e.g. drown a gorge's wall-to-floor drop under a relief swell).
// WaterBody stays at sea level. The flat families are the ones that read flat and
// need invented relief; their only feature is a 315 m channel, narrow enough that
// the smooth relief shifts it bodily with its banks.
let takes_mid_scale_relief = matches!(
family,
MorphologyFamily::AlluvialPlain
| MorphologyFamily::LavaField
| MorphologyFamily::BraidedDelta
| MorphologyFamily::DuneStrand
| MorphologyFamily::MeanderReach
);
if takes_mid_scale_relief {
let relief_seed = SeedChain::for_body(world_seed, body_id)
.derive(SeedDomain::VoxelRelief, 0)
.seed();
// Relief envelope (T-1081): the coarse heightmap (~4078 km/px) carries almost
// no slope at this tier (slope_q tops out near 0.1 on a real body), so gating on
// slope_q alone would kill the invented relief everywhere. Combine slope with
// elevation — high ground reads rugged, coastal lowlands stay gentle — so
// detail-scatter raises hills where the body has high terrain and keeps flats
// soft. (Tectonic-class ridge sharpening is a later refinement.)
let signal = (district_eff.slope_q * 3 + district_eff.elev_q).clamp(0, 100) as f64 / 100.0;
let relief = crate::atlas::detail_scatter::voxel_relief(
relief_seed,
voxel_x as f64,
voxel_y as f64,
signal,
signal,
);
let relief_m = (relief * VOXEL_RELIEF_SPAN_M as f64) as i32; // truncate (D-010)
column.elevation_m = (column.elevation_m + relief_m).max(0);
}
// ── 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
@@ -2235,6 +2296,86 @@ mod tests {
assert_eq!(a, b);
}
// ── Voxel-tier mid-scale relief (T-1081) ─────────────────────────────────
#[test]
fn voxel_relief_gives_rugged_land_navigable_hills() {
// T-1081: a rugged land district must produce mid-scale relief across a ~2 km
// span far exceeding the ±4 m micro-scatter (the "flat 03 m, no hills" symptom).
let mut district = alluvial_district();
district.slope_q = 60; // rugged → relief headroom
let chunk = alluvial_chunk(&district);
let mut elevs = Vec::new();
for i in 0..12 {
let p = i * 256; // step across the voxel-relief band (0.252 km)
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
if col.water == Water::Dry {
elevs.push(col.elevation_m);
}
}
assert!(elevs.len() >= 4, "expected mostly dry-land samples");
let min = *elevs.iter().min().unwrap();
let max = *elevs.iter().max().unwrap();
assert!(
max - min > 20,
"rugged district should have navigable mid-scale relief, got {min}..{max} m"
);
}
#[test]
fn voxel_relief_respects_low_flat_envelope() {
// No slope AND no elevation → no terrain signal → no invented relief (the
// envelope rule: don't sprout hills on a flat sea-level plain). Elevation stays
// the family base (elev_q/2 = 0 m) plus only the ±4 m micro-scatter / channel cut.
let mut district = alluvial_district();
district.slope_q = 0;
district.elev_q = 0;
let chunk = alluvial_chunk(&district);
for i in 0..12 {
let p = i * 256;
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
assert!(
col.elevation_m <= 4,
"low-flat district gained mid-scale relief: {} m",
col.elevation_m
);
}
}
#[test]
fn voxel_relief_from_elevation_even_with_zero_slope() {
// The T-1081 root cause: the coarse heightmap gives slope_q ≈ 0 even on high
// terrain, so ELEVATION must drive the relief envelope. A high district with
// slope_q == 0 must still gain navigable hills (else high ground reads flat).
let mut district = alluvial_district();
district.slope_q = 0;
district.elev_q = 90; // high ground, coarse-heightmap-flat
let chunk = alluvial_chunk(&district);
let mut elevs = Vec::new();
for i in 0..12 {
let p = i * 256;
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
if col.water == Water::Dry {
elevs.push(col.elevation_m);
}
}
let range = elevs.iter().max().unwrap() - elevs.iter().min().unwrap();
assert!(
range > 20,
"high terrain must gain relief despite slope_q=0, got range {range} m"
);
}
#[test]
fn voxel_relief_is_deterministic() {
let mut district = alluvial_district();
district.slope_q = 50;
let chunk = alluvial_chunk(&district);
let a = derive_voxel_column(42, "GJ1c", &district, &chunk, 777, 1234);
let b = derive_voxel_column(42, "GJ1c", &district, &chunk, 777, 1234);
assert_eq!(a.elevation_m, b.elevation_m);
}
// -----------------------------------------------------------------------
// TerrainMaterial discriminant pins
// -----------------------------------------------------------------------
+5
View File
@@ -110,6 +110,11 @@ pub enum SeedDomain {
/// 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,
/// Voxel-tier mid-scale relief noise field (T-1081, D-243 §2). A body-global,
/// position-keyed elevation perturbation at sub-district wavelengths (≈0.252 km),
/// keyed by a single constant id (one field per body). Distinct domain so the
/// relief lattice can never correlate with the per-voxel terrain or cover streams.
VoxelRelief = 11,
}
/// A position in the deterministic seed tree (D-224).