fix(simulation): region-anchor channels and linear landforms (T-1040, T-1041)
derive_chunk_context now derives channel_anchor_m and coast_anchor_m from the Region-or-higher seed path (D-239 §10); all five voxel placement sites measure distance to these anchors in continuous world coordinates: - T-1040: compute_channel_state / compute_meander_reach_channel / in_levee_band subtract the region anchor instead of measuring from the world origin axis — active channels now exist in their chunks anywhere on the body, continuous across chunk boundaries. has_active_channel is honest in both directions (band-crossing gate; floor covers the BraidedDelta belt at short wavelengths). - T-1041: fjord/cliff/gorge/delta drop their rem_euclid(64) chunk-frame folds — one valley/coastline/fan per region instead of one per chunk. D-239 §9 chokepoint widths preserved; two i32 wall-rise overflows on region-scale distances fixed by clamping before multiply. Harness: 6 new believability tests (channel presence + gate honesty at chunk (1000,-750), cross-boundary continuity, one-landform-per-region, braid-belt confinement); drainage sweeps strengthened with anchor-band chunks. Voxel golden regenerated deliberately: case A now pins a genuinely in-channel Shallow voxel on the anchor, case C a Deep trough voxel matching its label; case B byte-identical and L0/L1 cascade golden untouched (no upstream leak). Review: approved, three minors addressed (truncation-convention docs, delta gate floor, case C relocation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,11 +18,20 @@
|
||||
//! `RegionProfile`), not from the chunk-local seed. This is enforced by structure:
|
||||
//! the chunk seed is only used for sub-chunk (<64 m) scatter.
|
||||
//!
|
||||
//! The same rule places the feature axes themselves (T-1040/T-1041): the channel
|
||||
//! centreline and landform axis (`channel_anchor_m`) and the coast-face line
|
||||
//! (`coast_anchor_m`) are **world-metre coordinates derived once per region**
|
||||
//! from the region-scale seed. Voxel generators measure distance to these
|
||||
//! anchors in continuous world coordinates — never from the world origin and
|
||||
//! never folded into the 64 m chunk frame.
|
||||
//!
|
||||
//! ## D-010 compliance
|
||||
//!
|
||||
//! All derivation is integer arithmetic. The only f64 in this module is
|
||||
//! `meander_wavelength_m` (positional physics, not a gate comparison), consistent
|
||||
//! with D-239 §4 ("the warp is position math, not a structural decision").
|
||||
//! `meander_wavelength_m` (positional physics, D-239 §4). Structural decisions
|
||||
//! consume it only via deterministic i32 truncation (the `has_active_channel`
|
||||
//! band reach), mirroring the established voxel.rs truncate-before-decision
|
||||
//! convention — only correctly-rounded f64 +,-,*,/ feed the truncation.
|
||||
//!
|
||||
//! ## SeedDomain extension
|
||||
//!
|
||||
@@ -66,6 +75,32 @@ pub enum BasinDirection {
|
||||
/// `BTreeMap` key — implements `Ord` for D-010 determinism.
|
||||
pub type ChunkPos = (i32, i32);
|
||||
|
||||
/// Chunk edge length in metres.
|
||||
pub const CHUNK_M: i32 = 64;
|
||||
|
||||
/// Region edge length in chunks (region ≈ 1 km = 16 chunks). Must match the
|
||||
/// `>> REGION_CHUNKS_SHIFT` region-index mapping used for the region-scale seed.
|
||||
const REGION_CHUNKS_SHIFT: u32 = 4;
|
||||
|
||||
/// Region edge length in metres (1 024 m).
|
||||
const REGION_M: i32 = CHUNK_M << REGION_CHUNKS_SHIFT;
|
||||
|
||||
/// Margin keeping a region's feature anchor away from the region edge, so the
|
||||
/// channel's full swept band (max meander amplitude wavelength/4 ≈ 162 m +
|
||||
/// channel edge + levee band + warp bound ≈ 187 m) stays inside the region.
|
||||
/// Cross-region feature continuity is the stage-2 Voronoi model (T-1040).
|
||||
const ANCHOR_MARGIN_M: i32 = 192;
|
||||
|
||||
/// Seed-addressable anchor span within a region (REGION_M − 2 × margin).
|
||||
const ANCHOR_SPAN_M: i32 = REGION_M - 2 * ANCHOR_MARGIN_M;
|
||||
|
||||
/// Maximum levee band width in metres (`voxel::in_levee_band`: 4 + 3 jitter).
|
||||
const LEVEE_BAND_MAX_M: i32 = 7;
|
||||
|
||||
/// Domain-warp displacement bound in metres (mirrors `domain_warp::WARP_BOUND`,
|
||||
/// D-239 §4 ±8 m). Integer here — used only to widen the channel gate band.
|
||||
const WARP_BOUND_M: i32 = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChunkContext
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -82,11 +117,15 @@ pub type ChunkPos = (i32, i32);
|
||||
/// Used by MeanderReach and AlluvialPlain voxel generators to place the channel.
|
||||
/// - `meander_wavelength_m` — meander wavelength in metres. Derived from
|
||||
/// region-level morphology (slope, moisture), seeded at region scale (> 64 m).
|
||||
/// f64 for positional physics (D-239 §4); not used in any gate comparison.
|
||||
/// - `has_active_channel` — whether a water channel is present in this chunk,
|
||||
/// derived from the region's `ocean_fraction_q` and `river_threshold`.
|
||||
/// f64 for positional physics (D-239 §4); structural decisions consume it
|
||||
/// only via deterministic i32 truncation (the `has_active_channel` band).
|
||||
/// - `has_active_channel` — whether a water channel is present in this chunk:
|
||||
/// the region has water presence AND the channel's swept band around
|
||||
/// `channel_anchor_m` crosses this chunk (T-1040).
|
||||
/// - `channel_width_m` — channel width in metres (integer; D-010). 0 if no
|
||||
/// active channel.
|
||||
/// - `channel_anchor_m` / `coast_anchor_m` — region-anchored feature axes in
|
||||
/// world metres (T-1040/T-1041, D-239 §10).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChunkContext {
|
||||
/// Dominant basin-flow direction for this chunk's drainage catchment.
|
||||
@@ -101,13 +140,28 @@ pub struct ChunkContext {
|
||||
pub meander_wavelength_m: f64,
|
||||
|
||||
/// Whether this chunk contains an active water channel.
|
||||
/// True when the region's `ocean_fraction_q` × `river_threshold` signal
|
||||
/// indicates a perennial waterway crosses this chunk.
|
||||
/// True when the region has water presence (`ocean_fraction_q` ≥ 10) AND
|
||||
/// the channel's swept band around `channel_anchor_m` crosses this chunk's
|
||||
/// cross-axis range (T-1040 — channels exist where the region says, not
|
||||
/// region-wide and not only at the world origin).
|
||||
pub has_active_channel: bool,
|
||||
|
||||
/// Active channel width in metres (integer; D-010). 0 if no active channel.
|
||||
/// Derived from region morphology and slope.
|
||||
pub channel_width_m: i32,
|
||||
|
||||
/// Cross-axis world-metre coordinate of the region's feature centreline:
|
||||
/// channel/meander axis, fjord trough, gorge floor, braid-fan axis.
|
||||
/// Derived once per region from the region-scale seed (T-1040/T-1041,
|
||||
/// D-239 §10) — constant across all chunks of a region, so the feature is
|
||||
/// continuous across chunk boundaries. Cross axis = x for N/S basins,
|
||||
/// y for E/W basins.
|
||||
pub channel_anchor_m: i32,
|
||||
|
||||
/// Along-axis (basin-axis) world-metre coordinate of the CliffCoast face
|
||||
/// line. Region-scale (T-1041): one continuous coast per region, not a
|
||||
/// 64 m sawtooth. Along axis = y for N/S basins, x for E/W basins.
|
||||
pub coast_anchor_m: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -155,21 +209,44 @@ pub fn derive_chunk_context(
|
||||
// is positional physics (D-239 §4, not a gate comparison).
|
||||
let meander_wavelength_m = derive_meander_wavelength(region);
|
||||
|
||||
// Active channel — present when the region has meaningful water presence
|
||||
// (ocean_fraction_q >= 10 indicates a perennial waterway or water body
|
||||
// covers at least 10% of the region). The river_threshold governs D8
|
||||
// drainage accumulation at a finer scale; at the chunk level we use
|
||||
// ocean_fraction_q as the direct proxy for water presence (D-239 §10:
|
||||
// the chunk carries one basin-direction, not a per-tile flow grid).
|
||||
let has_active_channel = region.ocean_fraction_q >= 10;
|
||||
|
||||
// Channel width — derived from ocean_fraction_q (proxy for water presence
|
||||
// at region scale); integer metres; 0 when no active channel.
|
||||
let channel_width_m = if has_active_channel {
|
||||
derive_channel_width(region)
|
||||
} else {
|
||||
0
|
||||
// Region-anchored feature axes (T-1040/T-1041, D-239 §10): channel and
|
||||
// landform centrelines have wavelength > 64 m, so their position derives
|
||||
// from the region-scale seed — never the chunk frame or the world origin.
|
||||
// Cross axis ⊥ basin_direction (x for N/S, y for E/W); along axis ∥ basin.
|
||||
let (cross_chunk, along_chunk) = match basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (chunk_pos.0, chunk_pos.1),
|
||||
BasinDirection::East | BasinDirection::West => (chunk_pos.1, chunk_pos.0),
|
||||
};
|
||||
let channel_anchor_m = derive_region_anchor(cross_chunk, (region_seed.seed() >> 16) & 0xFFFF);
|
||||
let coast_anchor_m = derive_region_anchor(along_chunk, (region_seed.seed() >> 32) & 0xFFFF);
|
||||
|
||||
// Active channel — water presence (ocean_fraction_q >= 10 indicates a
|
||||
// perennial waterway or water body covers at least 10% of the region) AND
|
||||
// the channel's swept band around the region anchor crosses this chunk
|
||||
// (T-1040). The river_threshold governs D8 drainage accumulation at a finer
|
||||
// scale; at the chunk level ocean_fraction_q is the direct proxy for water
|
||||
// presence (D-239 §10: one basin-direction, not a per-tile flow grid).
|
||||
//
|
||||
// The band is generous (it must cover every chunk that can contain channel,
|
||||
// levee, or warped-channel voxels — a gate-off chunk renders dry), using
|
||||
// the larger MeanderReach amplitude (wavelength/4) for both channel families.
|
||||
let channel_width = derive_channel_width(region);
|
||||
let has_active_channel = region.ocean_fraction_q >= 10 && {
|
||||
let wavelength_i = (meander_wavelength_m as i32).max(10);
|
||||
let amplitude_max = (wavelength_i / 4).max(3);
|
||||
let edge_max = (channel_width / 2).max(2) + 3; // half-width + max edge jitter
|
||||
// Floor: the BraidedDelta belt reaches anchor ±(32 thread-centre + 4
|
||||
// thread-half) before warp regardless of wavelength — the band must
|
||||
// cover it even at the short-wavelength extreme (costs ≤4 m of extra
|
||||
// gate generosity for the other families).
|
||||
let reach = (amplitude_max + edge_max + LEVEE_BAND_MAX_M).max(32 + 4) + WARP_BOUND_M;
|
||||
let cross_lo = cross_chunk * CHUNK_M;
|
||||
let cross_hi = cross_lo + CHUNK_M - 1;
|
||||
cross_lo <= channel_anchor_m + reach && cross_hi >= channel_anchor_m - reach
|
||||
};
|
||||
|
||||
// Channel width — integer metres; 0 when no active channel in this chunk.
|
||||
let channel_width_m = if has_active_channel { channel_width } else { 0 };
|
||||
|
||||
ChunkContext {
|
||||
basin_direction,
|
||||
@@ -177,9 +254,26 @@ pub fn derive_chunk_context(
|
||||
meander_wavelength_m,
|
||||
has_active_channel,
|
||||
channel_width_m,
|
||||
channel_anchor_m,
|
||||
coast_anchor_m,
|
||||
}
|
||||
}
|
||||
|
||||
/// World-metre anchor coordinate for a region-scale feature axis on one axis.
|
||||
///
|
||||
/// The anchor sits in `[region_origin + ANCHOR_MARGIN_M, region_origin +
|
||||
/// REGION_M − ANCHOR_MARGIN_M)` so the feature's full swept band stays inside
|
||||
/// its region (no cross-region band spill; region-seam continuity is the
|
||||
/// stage-2 Voronoi model, T-1040). Same value for every chunk of the region:
|
||||
/// the region index is `axis_chunk >> REGION_CHUNKS_SHIFT` (arithmetic shift =
|
||||
/// floor division, correct for negative chunks) and `seed_bits` comes from the
|
||||
/// shared region-scale seed. Integer arithmetic (D-010).
|
||||
fn derive_region_anchor(axis_chunk: i32, seed_bits: u64) -> i32 {
|
||||
let region_idx = axis_chunk >> REGION_CHUNKS_SHIFT;
|
||||
let origin_m = region_idx * REGION_M;
|
||||
origin_m + ANCHOR_MARGIN_M + (seed_bits % ANCHOR_SPAN_M as u64) as i32
|
||||
}
|
||||
|
||||
/// Fold `(x, y)` chunk coordinates into a single u64 id for seed derivation.
|
||||
///
|
||||
/// Mirrors `domain_warp::pos_to_id` — zigzag-encode + Cantor pairing.
|
||||
@@ -294,6 +388,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The chunk of region (0, 0) whose cross-range contains the region's
|
||||
/// channel anchor — guaranteed inside the T-1040 channel band.
|
||||
fn anchor_chunk_pos(world_seed: u64, body_id: &str, region: &RegionProfile) -> ChunkPos {
|
||||
let probe = derive_chunk_context(world_seed, body_id, region, (0, 0));
|
||||
let idx = probe.channel_anchor_m.div_euclid(CHUNK_M);
|
||||
match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, idx),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_chunk_context_is_deterministic() {
|
||||
let region = alluvial_region();
|
||||
@@ -321,14 +426,80 @@ mod tests {
|
||||
#[test]
|
||||
fn alluvial_region_has_active_channel() {
|
||||
let region = alluvial_region();
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5));
|
||||
// ocean_fraction_q=15 + temperate precip → should have active channel.
|
||||
// T-1040: the channel is region-anchored — the chunk under the anchor
|
||||
// must claim it (ocean_fraction_q=15 → water present).
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert!(
|
||||
ctx.has_active_channel,
|
||||
"alluvial plain with ocean_fraction_q=15 should have active channel"
|
||||
"anchor-covering chunk of a watered region must have active channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_outside_channel_band_has_no_active_channel() {
|
||||
// T-1040: channels are region-anchored, not region-wide. A chunk whose
|
||||
// cross-range lies outside the channel's swept band (max reach < 192 m
|
||||
// = 3 chunks) must not claim a channel — pre-fix every chunk of a
|
||||
// watered region did, while voxels rendered dry floodplain.
|
||||
let region = alluvial_region();
|
||||
let (anchor_pos, probe) = {
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
(pos, derive_chunk_context(42, "GJ1c", ®ion, pos))
|
||||
};
|
||||
// 8 cross-chunks away (512 m) is past any band reach but still inside
|
||||
// region (0, 0) — the anchor margin keeps the anchor chunk in [3, 12].
|
||||
let anchor_idx = anchor_pos.0.max(anchor_pos.1);
|
||||
let far_idx = if anchor_idx < 8 {
|
||||
anchor_idx + 8
|
||||
} else {
|
||||
anchor_idx - 8
|
||||
};
|
||||
let far_pos = match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (far_idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, far_idx),
|
||||
};
|
||||
let far_ctx = derive_chunk_context(42, "GJ1c", ®ion, far_pos);
|
||||
assert!(
|
||||
!far_ctx.has_active_channel,
|
||||
"chunk {far_pos:?} outside the channel band must not claim a channel"
|
||||
);
|
||||
assert_eq!(
|
||||
far_ctx.channel_width_m, 0,
|
||||
"no active channel → channel_width_m must be 0"
|
||||
);
|
||||
// Region-scale params stay constant across the region's chunks.
|
||||
assert_eq!(far_ctx.channel_anchor_m, probe.channel_anchor_m);
|
||||
assert_eq!(far_ctx.meander_phase, probe.meander_phase);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_constant_within_region_and_inside_it() {
|
||||
// T-1040/T-1041: feature anchors are a region property — identical for
|
||||
// every chunk of the region, and positioned inside the region's extent.
|
||||
let region = alluvial_region();
|
||||
let base = derive_chunk_context(42, "GJ1c", ®ion, (0, 0));
|
||||
for pos in [(1, 0), (0, 1), (15, 15), (7, 12)] {
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert_eq!(
|
||||
ctx.channel_anchor_m, base.channel_anchor_m,
|
||||
"channel anchor must be region-constant (chunk {pos:?})"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.coast_anchor_m, base.coast_anchor_m,
|
||||
"coast anchor must be region-constant (chunk {pos:?})"
|
||||
);
|
||||
}
|
||||
// Region (0, 0) spans [0, 1024) m on both axes.
|
||||
assert!((0..1024).contains(&base.channel_anchor_m));
|
||||
assert!((0..1024).contains(&base.coast_anchor_m));
|
||||
// A different region derives its anchors inside its own extent.
|
||||
let far = derive_chunk_context(42, "GJ1c", ®ion, (1000, -750));
|
||||
assert!((62 * 1024..63 * 1024).contains(&far.channel_anchor_m) || (-47 * 1024..-46 * 1024).contains(&far.channel_anchor_m),
|
||||
"far region anchor {} must lie inside its region extent (cross axis depends on basin direction)",
|
||||
far.channel_anchor_m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_region_has_no_active_channel() {
|
||||
let region = RegionProfile {
|
||||
@@ -353,9 +524,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn channel_width_in_game_feel_range() {
|
||||
// D-239 §9: river crossings 3–15 m.
|
||||
// D-239 §9: river crossings 3–15 m. Measured on a chunk that carries
|
||||
// the channel (T-1040 gating zeroes the width elsewhere).
|
||||
let region = alluvial_region();
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5));
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert!(
|
||||
(3..=15).contains(&ctx.channel_width_m),
|
||||
"channel_width_m {} out of game-feel range [3, 15]",
|
||||
|
||||
Reference in New Issue
Block a user