feat(simulation): cross-district parameter blending at chunk/voxel scale (T-1042)

Replaces the single-DistrictProfile walking-skeleton restriction so
continuous terrain parameters no longer step at the 2 km district pitch
(D-239 §4/§7/§8). ChunkContext gains secondary: Option<DistrictProfile> +
blend_weight: u8; derive_chunk_context detects a chunk within one chunk of
a district border, looks up the adjacent profile, and integer-blends
meander/channel params on the context. derive_voxel_column integer-blends
elev_q/moisture_q before material selection, reusing the same warp offset so
the seam cannot align with the chunk edge (Cow fast-path keeps interior
chunks bit-identical — golden seed unchanged).

Morphology FAMILY selection is never blended — stays sharp per D-239 §7
(family dispatch reads the primary district only). Climate feathering is the
separate T-1078 path. Adds cross_district_elevation_blend_reduces_seam_step
and cross_district_morphology_family_seams_stay_sharp to the derivation
harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 14:05:48 +02:00
co-authored by Claude Opus 4.8
parent 030e0907bc
commit 32010cc498
3 changed files with 529 additions and 96 deletions
+181 -28
View File
@@ -25,6 +25,18 @@
//! anchors in continuous world coordinates — never from the world origin and
//! never folded into the 64 m chunk frame.
//!
//! ## Cross-district blending (T-1042, D-239 §4/§7)
//!
//! Chunks within one chunk (64 m) of a district boundary carry a `secondary`
//! `DistrictProfile` and a `blend_weight` (255 = 100% primary; 128 = 50-50 blend).
//! The blend applies to **continuous positional params only**: `meander_wavelength_m`,
//! `channel_width_m`, `meander_phase` (all pre-blended here on the context) and
//! `elev_q` / `moisture_q` (blended in `derive_voxel_column` before family dispatch).
//!
//! **Morphology family seams stay sharp** (D-239 §7): `secondary` is carried for
//! elevation/moisture blending only; the primary district's `morphology_zone` is
//! never overridden by the secondary.
//!
//! ## D-010 compliance
//!
//! All derivation is integer arithmetic. The only f64 in this module is
@@ -107,17 +119,26 @@ const WARP_BOUND_M: i32 = 8;
/// - `basin_direction` — dominant drainage direction (cardinal) for this chunk.
/// - `meander_phase` — integer phase offset (0–255) for the meander curve.
/// Used by MeanderReach and AlluvialPlain voxel generators to place the channel.
/// When `blend_weight < 255`, this is already blended between the primary and
/// secondary district values (T-1042).
/// - `meander_wavelength_m` — meander wavelength in metres. Derived from
/// district-level morphology (slope, moisture), seeded at district scale (> 64 m).
/// f64 for positional physics (D-239 §4); structural decisions consume it
/// only via deterministic i32 truncation (the `has_active_channel` band).
/// When `blend_weight < 255`, this is already blended (T-1042).
/// - `has_active_channel` — whether a water channel is present in this chunk:
/// the district 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.
/// active channel. When `blend_weight < 255`, this is already blended (T-1042).
/// - `channel_anchor_m` / `coast_anchor_m` — district-anchored feature axes in
/// world metres (T-1040/T-1041, D-239 §10).
/// - `secondary` — adjacent district profile for cross-district blending (T-1042,
/// D-239 §4). `None` when the chunk is interior (≥ 1 chunk from any district edge).
/// Only continuous terrain params (`elev_q`, `moisture_q`) are blended from this
/// in `derive_voxel_column`; morphology family stays primary (D-239 §7).
/// - `blend_weight` — blend weight toward the primary district. 255 = fully primary
/// (no blend), 128 = 50-50 blend. Meaningful only when `secondary` is `Some`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkContext {
/// Dominant basin-flow direction for this chunk's drainage catchment.
@@ -125,10 +146,12 @@ pub struct ChunkContext {
/// Integer phase offset for the meander curve (0–255).
/// Derived at district scale (wavelength > 64 m), NOT from the chunk seed.
/// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042).
pub meander_phase: u8,
/// Meander wavelength in metres. Positional physics value (f64 — D-239 §4).
/// Derived at district scale; typically 80–500 m for AlluvialPlain.
/// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042).
pub meander_wavelength_m: f64,
/// Whether this chunk contains an active water channel.
@@ -140,6 +163,7 @@ pub struct ChunkContext {
/// Active channel width in metres (integer; D-010). 0 if no active channel.
/// Derived from district morphology and slope.
/// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042).
pub channel_width_m: i32,
/// Cross-axis world-metre coordinate of the district's feature centreline:
@@ -154,6 +178,26 @@ pub struct ChunkContext {
/// line. District-scale (T-1041): one continuous coast per district, not a
/// 64 m sawtooth. Along axis = y for N/S basins, x for E/W basins.
pub coast_anchor_m: i32,
/// Adjacent-district profile for cross-district terrain blending (T-1042,
/// D-239 §4/§7). `None` for interior chunks (≥ 1 chunk from any district edge).
///
/// Only continuous terrain params (`elev_q`, `moisture_q`) are blended from
/// this in `derive_voxel_column`. Morphology family selection always uses the
/// **primary** district's zone — seams stay sharp (D-239 §7).
/// `meander_wavelength_m`, `channel_width_m`, `meander_phase` are blended
/// here at context derivation time.
pub secondary: Option<DistrictProfile>,
/// Blend weight toward the primary district (D-010 integer arithmetic).
///
/// - `255` — fully primary; `secondary` is ignored (no blend).
/// - `128` — 50-50 blend (chunk at the district boundary).
///
/// Meaningful only when `secondary` is `Some`. Formula used throughout:
/// `blended = (primary * blend_weight as i32 + secondary * (255 - blend_weight) as i32 + 127) / 255`
/// (rounded integer lerp, symmetric for 128).
pub blend_weight: u8,
}
// ---------------------------------------------------------------------------
@@ -162,10 +206,20 @@ pub struct ChunkContext {
/// Derive a `ChunkContext` for the chunk at `chunk_pos` on the 64 m grid.
///
/// Pure function of `(seed, body_id, district, chunk_pos)`. Takes the covering
/// district's `DistrictProfile`; in the future a blend of adjacent profiles will
/// handle cross-district chunk seams, but for the walking skeleton one profile
/// is sufficient.
/// Pure function of `(seed, body_id, district, chunk_pos, secondary)`.
///
/// `secondary` supplies the adjacent `DistrictProfile` and a `blend_weight`
/// (255 = fully primary, 128 = 50-50) for cross-district terrain blending
/// (T-1042, D-239 §4/§7). Pass `None` for interior chunks. The caller is
/// responsible for detecting whether the chunk lies within one chunk (64 m)
/// of a district boundary and supplying the adjacent profile.
///
/// When `secondary` is `Some`, the context-level continuous params
/// (`meander_wavelength_m`, `channel_width_m`, `meander_phase`) are blended
/// here using integer arithmetic (D-010). The voxel-level params (`elev_q`,
/// `moisture_q`) are blended in `derive_voxel_column` before family dispatch.
/// Morphology family selection always uses the **primary** district's zone —
/// seams stay sharp (D-239 §7).
///
/// ## Seed usage
///
@@ -178,6 +232,7 @@ pub fn derive_chunk_context(
body_id: &str,
district: &DistrictProfile,
chunk_pos: ChunkPos,
secondary: Option<(&DistrictProfile, u8)>,
) -> ChunkContext {
// District-scale seed — features with wavelength > 64 m derive from here.
// Keyed on the chunk position mapped to district-scale units: `>>
@@ -199,12 +254,15 @@ pub fn derive_chunk_context(
// Meander phase — district-scale integer offset so the channel is consistent
// across all chunks in the same district. 0–255.
let meander_phase = (district_seed.seed() >> 8) as u8;
let primary_meander_phase = (district_seed.seed() >> 8) as u8;
// Meander wavelength — derived from slope and morphology, district-scale.
// Lower slope → longer wavelength (wider meanders); integer inputs, f64 result
// is positional physics (D-239 §4, not a gate comparison).
let meander_wavelength_m = derive_meander_wavelength(district);
let primary_wavelength_m = derive_meander_wavelength(district);
// Channel width from primary district — integer metres (D-010).
let primary_channel_width = derive_channel_width(district);
// District-anchored feature axes (T-1040/T-1041, D-239 §10): channel and
// landform centrelines have wavelength > 64 m, so their position derives
@@ -218,6 +276,66 @@ pub fn derive_chunk_context(
derive_district_anchor(cross_chunk, (district_seed.seed() >> 16) & 0xFFFF);
let coast_anchor_m = derive_district_anchor(along_chunk, (district_seed.seed() >> 32) & 0xFFFF);
// ── Cross-district blending of context-level continuous params (T-1042) ──
//
// When a secondary district is supplied, blend `meander_wavelength_m`,
// `channel_width_m`, and `meander_phase` between the primary and secondary
// district values. These are the context-level continuous positional params
// (D-239 §4; spec: "blend them on the context, do NOT re-read from the
// profile in the blend path"). All integer arithmetic (D-010).
//
// Morphology family, basin direction, feature anchors, and has_active_channel
// are NOT blended — they are structural decisions driven by the primary
// district only (D-239 §7: morphology seams stay sharp).
let (blend_weight, secondary_stored) = match secondary {
Some((sec, weight)) => {
let sec_wavelength = derive_meander_wavelength(sec);
let sec_channel_width = derive_channel_width(sec);
let sec_phase = {
// Secondary district's meander phase uses a secondary seed so it
// differs from the primary (different district-scale chunk id).
// We approximate: use the secondary's wavelength-derived phase
// as an integer representation of its channel geometry. For a
// well-defined phase, we re-derive from the secondary's slope and
// moisture as a deterministic position-independent proxy.
// Integer arithmetic (D-010).
(sec.slope_q.wrapping_add(sec.moisture_q) as u8)
.wrapping_add(primary_meander_phase / 2)
};
let w = weight as i32;
let w_sec = 255 - w;
// Integer lerp: `(a * w + b * w_sec + 127) / 255`.
// The +127 biases the division round to nearest (symmetric at w=128).
let blended_phase =
((primary_meander_phase as i32 * w + sec_phase as i32 * w_sec + 127) / 255) as u8;
let blended_wavelength =
(primary_wavelength_m * w as f64 + sec_wavelength * w_sec as f64) / 255.0;
let blended_channel_width =
(primary_channel_width * w + sec_channel_width * w_sec + 127) / 255;
(
weight,
Some((
blended_phase,
blended_wavelength,
blended_channel_width,
sec.clone(),
)),
)
}
None => (255u8, None),
};
let (meander_phase, meander_wavelength_m, primary_channel_width_final, secondary_profile) =
match secondary_stored {
Some((ph, wl, cw, prof)) => (ph, wl, cw, Some(prof)),
None => (
primary_meander_phase,
primary_wavelength_m,
primary_channel_width,
None,
),
};
// Active channel — water presence (ocean_fraction_q >= 10 indicates a
// perennial waterway or water body covers at least 10% of the district) AND
// the channel's swept band around the district anchor crosses this chunk
@@ -228,15 +346,14 @@ pub fn derive_chunk_context(
// 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(district);
let has_active_channel = district.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 edge_max = (primary_channel_width_final / 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;
@@ -244,7 +361,11 @@ pub fn derive_chunk_context(
};
// Channel width — integer metres; 0 when no active channel in this chunk.
let channel_width_m = if has_active_channel { channel_width } else { 0 };
let channel_width_m = if has_active_channel {
primary_channel_width_final
} else {
0
};
ChunkContext {
basin_direction,
@@ -254,6 +375,38 @@ pub fn derive_chunk_context(
channel_width_m,
channel_anchor_m,
coast_anchor_m,
secondary: secondary_profile,
blend_weight,
}
}
/// Compute the cross-district blend weight for a chunk position (T-1042).
///
/// Returns `(is_near_boundary, blend_weight)` for the given chunk's district
/// proximity on either axis. `blend_weight` is 255 when interior (no blend),
/// or 128 when the chunk is the outermost within its district on either axis
/// (one chunk from the district boundary). The `is_near_boundary` flag is `true`
/// only when `blend_weight < 255`.
///
/// The caller uses `is_near_boundary` to decide whether to look up the adjacent
/// `DistrictProfile` and supply it to `derive_chunk_context`. Only the last chunk
/// of a district (chunk index `CHUNKS_PER_DISTRICT - 1` = 31 within the district)
/// triggers a blend; the first chunk of the next district does not — this way the
/// blend seam is always on the outgoing side, and the incoming district's first
/// chunk reads clean from its own primary profile.
///
/// Integer arithmetic (D-010).
pub fn district_boundary_blend_weight(chunk_pos: ChunkPos) -> (bool, u8) {
// Chunk index within its district on each axis (0..32).
let cx = chunk_pos.0.rem_euclid(scale::CHUNKS_PER_DISTRICT);
let cy = chunk_pos.1.rem_euclid(scale::CHUNKS_PER_DISTRICT);
// The last chunk (index 31) is within 64 m of the district boundary.
let near_x = cx == scale::CHUNKS_PER_DISTRICT - 1;
let near_y = cy == scale::CHUNKS_PER_DISTRICT - 1;
if near_x || near_y {
(true, 128u8)
} else {
(false, 255u8)
}
}
@@ -389,7 +542,7 @@ mod tests {
/// The chunk of district (0, 0) whose cross-range contains the district's
/// channel anchor — guaranteed inside the T-1040 channel band.
fn anchor_chunk_pos(world_seed: u64, body_id: &str, district: &DistrictProfile) -> ChunkPos {
let probe = derive_chunk_context(world_seed, body_id, district, (0, 0));
let probe = derive_chunk_context(world_seed, body_id, district, (0, 0), None);
let idx = probe.channel_anchor_m.div_euclid(CHUNK_M);
match probe.basin_direction {
BasinDirection::North | BasinDirection::South => (idx, 0),
@@ -400,8 +553,8 @@ mod tests {
#[test]
fn derive_chunk_context_is_deterministic() {
let district = alluvial_district();
let a = derive_chunk_context(42, "GJ1c", &district, (10, 20));
let b = derive_chunk_context(42, "GJ1c", &district, (10, 20));
let a = derive_chunk_context(42, "GJ1c", &district, (10, 20), None);
let b = derive_chunk_context(42, "GJ1c", &district, (10, 20), None);
assert_eq!(a.basin_direction, b.basin_direction);
assert_eq!(a.meander_phase, b.meander_phase);
assert_eq!(a.meander_wavelength_m, b.meander_wavelength_m);
@@ -412,8 +565,8 @@ mod tests {
#[test]
fn different_positions_yield_different_phases() {
let district = alluvial_district();
let a = derive_chunk_context(42, "GJ1c", &district, (0, 0));
let b = derive_chunk_context(42, "GJ1c", &district, (200, 100));
let a = derive_chunk_context(42, "GJ1c", &district, (0, 0), None);
let b = derive_chunk_context(42, "GJ1c", &district, (200, 100), None);
// Different district-scale ids → different phases (high probability).
assert_ne!(
a.meander_phase, b.meander_phase,
@@ -427,7 +580,7 @@ mod tests {
// T-1040: the channel is district-anchored — the chunk under the anchor
// must claim it (ocean_fraction_q=15 → water present).
let pos = anchor_chunk_pos(42, "GJ1c", &district);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None);
assert!(
ctx.has_active_channel,
"anchor-covering chunk of a watered district must have active channel"
@@ -443,7 +596,7 @@ mod tests {
let district = alluvial_district();
let (anchor_pos, probe) = {
let pos = anchor_chunk_pos(42, "GJ1c", &district);
(pos, derive_chunk_context(42, "GJ1c", &district, pos))
(pos, derive_chunk_context(42, "GJ1c", &district, pos, None))
};
// 8 cross-chunks away (512 m) is past any band reach but still inside
// district (0, 0) — the anchor margin keeps the anchor chunk in [3, 12].
@@ -457,7 +610,7 @@ mod tests {
BasinDirection::North | BasinDirection::South => (far_idx, 0),
BasinDirection::East | BasinDirection::West => (0, far_idx),
};
let far_ctx = derive_chunk_context(42, "GJ1c", &district, far_pos);
let far_ctx = derive_chunk_context(42, "GJ1c", &district, far_pos, None);
assert!(
!far_ctx.has_active_channel,
"chunk {far_pos:?} outside the channel band must not claim a channel"
@@ -476,9 +629,9 @@ mod tests {
// T-1040/T-1041: feature anchors are a district property — identical for
// every chunk of the district, and positioned inside the district's extent.
let district = alluvial_district();
let base = derive_chunk_context(42, "GJ1c", &district, (0, 0));
let base = derive_chunk_context(42, "GJ1c", &district, (0, 0), None);
for pos in [(1, 0), (0, 1), (15, 15), (7, 12)] {
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None);
assert_eq!(
ctx.channel_anchor_m, base.channel_anchor_m,
"channel anchor must be district-constant (chunk {pos:?})"
@@ -495,7 +648,7 @@ mod tests {
// (1000, -750) → district (1000 >> 5, -750 >> 5) = (31, -24); the cross
// axis (and thus which district index the channel anchor sits in) depends
// on the basin direction.
let far = derive_chunk_context(42, "GJ1c", &district, (1000, -750));
let far = derive_chunk_context(42, "GJ1c", &district, (1000, -750), None);
let dm = scale::DISTRICT_M;
assert!(
(31 * dm..32 * dm).contains(&far.channel_anchor_m)
@@ -520,7 +673,7 @@ mod tests {
moisture_q: 5,
vegetation_class: VegetationClass::Barren,
};
let ctx = derive_chunk_context(42, "dry_body", &district, (5, 5));
let ctx = derive_chunk_context(42, "dry_body", &district, (5, 5), None);
assert!(
!ctx.has_active_channel,
"arid district with ocean_fraction_q=0 must not have active channel"
@@ -533,7 +686,7 @@ mod tests {
// the channel (T-1040 gating zeroes the width elsewhere).
let district = alluvial_district();
let pos = anchor_chunk_pos(42, "GJ1c", &district);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None);
assert!(
(3..=15).contains(&ctx.channel_width_m),
"channel_width_m {} out of game-feel range [3, 15]",
@@ -544,7 +697,7 @@ mod tests {
#[test]
fn meander_wavelength_within_physics_range() {
let district = alluvial_district();
let ctx = derive_chunk_context(42, "GJ1c", &district, (5, 5));
let ctx = derive_chunk_context(42, "GJ1c", &district, (5, 5), None);
// AlluvialPlain flat (slope_q=5): should be near max wavelength.
assert!(
ctx.meander_wavelength_m > 400.0 && ctx.meander_wavelength_m < 700.0,