fix(simulation): one absolute-metre derive core (D-256, T-1174)
derive_district_profile is now a thin wrapper over the shared derive_at_metres_with_riparian core at survey-cell-centre world metres — one derive core, two position sets. The batch pseudo-grid and the true D-243 district grid were two coordinate systems sharing one bare (i32,i32) type; the new SurveyCellPos newtype re-keys every batch product (BodyWorldState.districts, Layer1Output.survey_basin_dirs) so the compiler rejects cross-namespace passing. Fixes two latent same-position divergences the T-1174 investigation surfaced: three inconsistent latitude conventions collapse into the core's single inverse mapping, and the region-climate baseline now floor-divides true world metres instead of collapsing the whole body onto region (0,0)'s baseline — batch climate becomes latitude/region graded (D-245 direction: every changed believability metric increased). Binding preservations per D-256(c): basin_direction rides a post-call override with the true L1 D8 survey-cell aggregate (layer1's map re-keyed to SurveyCellPos, identity lookup — a floor-divide lookup against the pseudo-keyed map would have silently defaulted every cell North); the riparian verdict comes from near_perennial_water_at, never the empty-slice default (which would have flipped riverside vegetation_class). Quarter-skeleton morphology_zone now resolves at the settlement's exact world position via derive_at_metres at work-item execution (where TerrainAnalysisCache lives), replacing the survey-cell-centre map lookup (D-256(d)); settlement_district_pos fixed to true-district floor-division in passing (same doc/impl mismatch class). Second pixel-vs-metre conflation fixed in aliveness_probe's anchor-walk math. Window path byte-unchanged (window_derivation_golden 6/6 byte- identical); derivation_harness golden untouched; believability golden regenerated. Full lib + integration suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+47
-37
@@ -27,7 +27,7 @@ use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
|
||||
use crate::atlas::drainage::{self, DrainageResult};
|
||||
use crate::atlas::features::{self, TerrainAnalysis};
|
||||
use crate::atlas::heightmap::BodyHeightmap;
|
||||
use crate::atlas::scale::{BasinDirection, DistrictPos, HEIGHTMAP_CELLS_PER_DISTRICT};
|
||||
use crate::atlas::scale::{BasinDirection, HEIGHTMAP_CELLS_PER_DISTRICT, SurveyCellPos};
|
||||
use crate::atlas::subbiome;
|
||||
use crate::simulation::generator::{AttractorType, GeographicAttractor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -47,23 +47,30 @@ pub struct Layer1Output {
|
||||
/// so the overlay scale stays correct for any source resolution (mod-safe).
|
||||
pub grid_w: u32,
|
||||
pub grid_h: u32,
|
||||
/// Dominant D8 thalweg direction per district, aggregated from the `fdir`
|
||||
/// Dominant D8 thalweg direction per SURVEY CELL, aggregated from the `fdir`
|
||||
/// grid during the Layer-1 drainage pass (T-1047, D-239 §8). Each entry
|
||||
/// holds the cardinal direction with the most votes among non-ocean cells in
|
||||
/// that district. Keyed by `DistrictPos` using `HEIGHTMAP_CELLS_PER_DISTRICT`
|
||||
/// as the grid-to-district mapping.
|
||||
/// that cell's covering 8×8 working-grid pixel block. Keyed by
|
||||
/// [`SurveyCellPos`] (D-256(b)) using `HEIGHTMAP_CELLS_PER_DISTRICT` as the
|
||||
/// grid-to-cell mapping — the SAME survey raster `derive_all_districts`
|
||||
/// builds `DistrictProfile`s over, not the true D-243 `DistrictPos` grid
|
||||
/// (D-256: this field predates the newtype and escaped the initial sweep;
|
||||
/// the aggregate is honestly a survey-cell aggregate — it votes over
|
||||
/// exactly the pixel block one `DistrictProfile` summarizes — so
|
||||
/// `SurveyCellPos` is its correct, not just convenient, key).
|
||||
///
|
||||
/// This is the **true D8-computed direction** — not a seed-bit proxy — so
|
||||
/// `DistrictProfile.basin_direction` (and downstream `ChunkContext`) respect
|
||||
/// drainage monotonicity (D-239 §8: respect the D8 thalweg).
|
||||
/// The VALUES are the **true D8-computed direction** — not a seed-bit
|
||||
/// proxy — so `DistrictProfile.basin_direction` (and downstream
|
||||
/// `ChunkContext`) respect drainage monotonicity (D-239 §8: respect the D8
|
||||
/// thalweg). Only the KEY space is the coarse survey raster.
|
||||
///
|
||||
/// **Transient:** skipped in serialization (`#[serde(skip)]`) — this field is
|
||||
/// a cascade-internal transport from `run_layer1` to `derive_all_districts`
|
||||
/// and is re-derived on each `run_layer1` call. The per-district direction is
|
||||
/// persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
|
||||
/// and is re-derived on each `run_layer1` call. The per-survey-cell direction
|
||||
/// is persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
|
||||
/// after the cascade consumes it.
|
||||
#[serde(skip)]
|
||||
pub district_basin_dirs: BTreeMap<DistrictPos, BasinDirection>,
|
||||
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
|
||||
}
|
||||
|
||||
/// Run the Layer-1 topography pipeline for a single body.
|
||||
@@ -95,9 +102,10 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Aggregate per-district dominant D8 direction from the fdir grid (T-1047,
|
||||
// D-239 §8). fdir is available here before it is discarded — do NOT expose
|
||||
// the full grid on DrainageResult externally. The compact per-district map
|
||||
// Aggregate per-survey-cell dominant D8 direction from the fdir grid
|
||||
// (T-1047, D-239 §8; D-256(b) survey raster — NOT the true D-243 district
|
||||
// grid). fdir is available here before it is discarded — do NOT expose
|
||||
// the full grid on DrainageResult externally. The compact per-cell map
|
||||
// (~6 000 entries) is what propagates into Layer1Output and DistrictProfile.
|
||||
//
|
||||
// Mapping fdir index → 4-way cardinal (D-010 integer; matches D8 table):
|
||||
@@ -107,8 +115,8 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
|
||||
// 6 SE → S
|
||||
// 7 SW → S
|
||||
// -1 → skip (no outflow: edge, flat peak, ocean)
|
||||
let district_basin_dirs =
|
||||
aggregate_district_basin_dirs(&drainage.fdir, hm.width, hm.height, &ta.ocean_mask);
|
||||
let survey_basin_dirs =
|
||||
aggregate_survey_basin_dirs(&drainage.fdir, hm.width, hm.height, &ta.ocean_mask);
|
||||
|
||||
let l1 = Layer1Output {
|
||||
body_id: hm.body_id.clone(),
|
||||
@@ -117,32 +125,34 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
|
||||
attractors,
|
||||
grid_w: hm.width,
|
||||
grid_h: hm.height,
|
||||
district_basin_dirs,
|
||||
survey_basin_dirs,
|
||||
};
|
||||
(l1, ta)
|
||||
}
|
||||
|
||||
/// Aggregate a per-district dominant D8 flow direction from the full-grid `fdir`
|
||||
/// (index into the D8 table, -1 = no outflow). Ocean-masked cells are excluded
|
||||
/// from voting so coastal districts do not skew toward the ocean sink direction.
|
||||
/// Aggregate a per-survey-cell dominant D8 flow direction from the full-grid
|
||||
/// `fdir` (index into the D8 table, -1 = no outflow). Ocean-masked cells are
|
||||
/// excluded from voting so coastal cells do not skew toward the ocean sink
|
||||
/// direction.
|
||||
///
|
||||
/// Each non-ocean, non-sink cell casts one vote for its cardinal direction
|
||||
/// (diagonals NE/NW fold to N, SE/SW fold to S). Ties broken by cardinal
|
||||
/// precedence (N > S > E > W). Districts with no valid votes default to `North`.
|
||||
/// precedence (N > S > E > W). Survey cells with no valid votes default to
|
||||
/// `North`.
|
||||
///
|
||||
/// Integer arithmetic throughout (D-010).
|
||||
fn aggregate_district_basin_dirs(
|
||||
fn aggregate_survey_basin_dirs(
|
||||
fdir: &[i8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
ocean_mask: &[bool],
|
||||
) -> BTreeMap<DistrictPos, BasinDirection> {
|
||||
) -> BTreeMap<SurveyCellPos, BasinDirection> {
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let gcpd = HEIGHTMAP_CELLS_PER_DISTRICT;
|
||||
|
||||
// Per-district vote counts: [N, S, E, W].
|
||||
let mut votes: BTreeMap<DistrictPos, [i32; 4]> = BTreeMap::new();
|
||||
// Per-survey-cell vote counts: [N, S, E, W].
|
||||
let mut votes: BTreeMap<SurveyCellPos, [i32; 4]> = BTreeMap::new();
|
||||
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
@@ -163,19 +173,19 @@ fn aggregate_district_basin_dirs(
|
||||
7 => 1, // SW → S
|
||||
_ => continue,
|
||||
};
|
||||
let district_pos: DistrictPos = ((c / gcpd) as i32, (r / gcpd) as i32);
|
||||
votes.entry(district_pos).or_insert([0i32; 4])[vote] += 1;
|
||||
let cell_pos = SurveyCellPos((c / gcpd) as i32, (r / gcpd) as i32);
|
||||
votes.entry(cell_pos).or_insert([0i32; 4])[vote] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// For each district, pick the cardinal with the most votes.
|
||||
// For each survey cell, pick the cardinal with the most votes.
|
||||
// Tie-breaking order: N > S > E > W (matches D8 priority).
|
||||
let district_cols = w.div_ceil(gcpd) as i32;
|
||||
let district_rows = h.div_ceil(gcpd) as i32;
|
||||
let survey_cols = w.div_ceil(gcpd) as i32;
|
||||
let survey_rows = h.div_ceil(gcpd) as i32;
|
||||
let mut out = BTreeMap::new();
|
||||
for dy in 0..district_rows {
|
||||
for dx in 0..district_cols {
|
||||
let pos: DistrictPos = (dx, dy);
|
||||
for dy in 0..survey_rows {
|
||||
for dx in 0..survey_cols {
|
||||
let pos = SurveyCellPos(dx, dy);
|
||||
let dir = if let Some(v) = votes.get(&pos) {
|
||||
// N=0, S=1, E=2, W=3 in descending priority for tie-breaking.
|
||||
let mut best_votes = -1i32;
|
||||
@@ -195,7 +205,7 @@ fn aggregate_district_basin_dirs(
|
||||
}
|
||||
best_dir
|
||||
} else {
|
||||
BasinDirection::North // ocean-only or empty district: default
|
||||
BasinDirection::North // ocean-only or empty cell: default
|
||||
};
|
||||
out.insert(pos, dir);
|
||||
}
|
||||
@@ -300,11 +310,11 @@ mod tests {
|
||||
assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost);
|
||||
}
|
||||
assert_eq!(o1.river_network.river_cells, o2.river_network.river_cells);
|
||||
// district_basin_dirs is deterministic and non-empty on a slope grid.
|
||||
assert_eq!(o1.district_basin_dirs, o2.district_basin_dirs);
|
||||
// survey_basin_dirs is deterministic and non-empty on a slope grid.
|
||||
assert_eq!(o1.survey_basin_dirs, o2.survey_basin_dirs);
|
||||
assert!(
|
||||
!o1.district_basin_dirs.is_empty(),
|
||||
"slope grid must produce district basin directions"
|
||||
!o1.survey_basin_dirs.is_empty(),
|
||||
"slope grid must produce survey-cell basin directions"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user