islaApocalypse-v2/Tools/Scripts/BasinGraphTool.cs
beezm df286ffb3c rivers/04: lake-basin layer — the basin graph (spill + lake-identity + downstream edge), data only
Per terminal basin of DrainageAnalysis (reused, untouched): the spill cell/height on the
render flow surface (boundary minimum of FullFilled, cross-checked bit-for-bit against
BitDecrement(min inside)), lake-identity on classify (in-basin non-ocean classify water
>= ISLA_LAKE_MIN_PX, 20000), and the downstream edge (the reference's FullFilled descent
started at the spill, cross-checked against Plan.Dir). Seabed pits — terminal basins entirely
under the classify sea — tagged and excluded from statistics. Per-lake ownership table.
BasinGraphTool: chain → analysis → layer → CSVs → plate, height digests asserted around it.
DrainageRenderer: five primitives private→internal. No height mutated, no water filled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EppUMXNhSeuA5Mu51UnTyP
2026-08-25 00:45:23 -04:00

599 lines
36 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ THE LAKE-BASIN LAYER (rivers/04) — build the basin graph and show it, so the developer can
/// trust the spills before anything is routed on them.
///
/// ═══ WHAT THIS TASK IS FOR ═══
///
/// The flow-through routing model (the next task) needs lakes to be NODES in the drainage graph,
/// not free-floating heightmap classifications the router bumps into. This builds that layer from
/// what `DrainageAnalysis` already computed — the sinks (`BasinId`) and the overflow surface
/// (`FullFilled`) — enriching each terminal basin with its SPILL, its LAKE-IDENTITY and its
/// DOWNSTREAM EDGE. → <see cref="BasinGraph"/>.
///
/// ═══ ⛔ THE RED LINE — A DATA LAYER, NOT A TERRAIN WRITE ═══
///
/// **No height is written. No water is filled or created. `DrainageAnalysis` is reused, not
/// rewritten.** Spills and lake-identity are computed and recorded, never stamped. Both height
/// fields are digested before the layer is built and after the plate is drawn, and the tool REFUSES
/// to continue if either changed — the flood-guard discipline every task since erosion has kept.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/BasinGraphTool.tscn
///
/// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
/// ISLA_LAKE_MIN_PX the significance floor for a basin's in-basin classify water (default 20000 — a KNOB)
/// ISLA_CAP_PREVIEW_M comma list of rim caps to preview connectivity at (default "15,30,60")
/// </summary>
public partial class BasinGraphTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 };
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 8192;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class SeedResult
{
public int Seed;
public BasinGraph Graph;
public int TerminalBasinCount;
public long LandCells, EndorheicCells;
public ulong RenderDigest, ClassifyDigest;
public int WaterBodiesKept, WaterBodiesTotal; public long WaterCellsKept, LargestWaterPx;
public float SpillMinM, SpillP25M, SpillMedM, SpillP75M, SpillMaxM;
public float ClimbMinM, ClimbP25M, ClimbMedM, ClimbP75M, ClimbMaxM;
public int[] ClimbBands; // <=5, 5-15, 15-30, 30-60, >60
public Dictionary<float, (int all, int lake, int dry, int direct)> Cap = new();
public int LakeAnyOnly; // HasAnyLake && !IsLake — where this layer's label differs from the routing sort
public int MaxHops;
public float GraphSeconds, RenderSeconds; public ulong Ms;
public float GMin, GMax;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
int task = EnvInt("ISLA_TASK", 4);
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", "");
string descr = EnvStr("ISLA_BATCH", "lake_basin_layer");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f });
TerrainShapeV1.Assert("BasinGraph");
TerrainShapeV1.AssertErosionDefaultOn("BasinGraph");
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
// ⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are the defaults — they
// define which depressions ARE terminal basins, i.e. the nodes of this graph. Reporting caps
// are irrelevant here: the layer reads BasinId / FullFilled / Dir, not the promoted lists.
var dp = new DrainageAnalysis.Params { SeaLevel = sea };
GD.Print("==================================================================");
GD.Print(" THE LAKE-BASIN LAYER (rivers/04) — the basin graph: spill + lake-identity + downstream edge, per terminal basin");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
GD.Print($"spill : {BasinGraph.SpillDatum}");
GD.Print($"lake : {BasinGraph.LakeDatum} floor = {lakeMinPx:N0} px (ISLA_LAKE_MIN_PX, a knob)");
GD.Print($"edge : {BasinGraph.DownstreamMethod}");
GD.Print($"D-046 : spill geometry/height on RENDER (the flow surface, as RouteTo routes); lake presence + OCEAN on CLASSIFY (the water surface, as the terminus tests). No cross-surface comparison anywhere.");
GD.Print($"cap view : unbroken spill-chain to the ocean previewed at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m (every link's floor→spill climb ≤ cap; clamped at sea as RouteTo clamps)");
GD.Print($"⛔ RED LINE : DATA LAYER ONLY — no height mutated, no water filled, DrainageAnalysis untouched. ASSERTED per seed around build + render.");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
if (mapSize != 8192)
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the terminal-basin gates are ABSOLUTE PIXEL COUNTS tuned at 8192; a smaller " +
"map under-produces terminal basins. This run checks the PLUMBING (spill extraction, graph, render), not the character.");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = "basins",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
var results = new List<SeedResult>();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
GD.Print($"\n--- seed {seed} ---");
var cfg = Cfg(mapSize, seed);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result shaped = Shaping.Shape(p1, cfg);
var ero = ErosionPass.Apply(shaped, cfg);
Pass2Result p2 = ero.Shaped;
// ⭐ THE OCEAN IDENTITY and the water surface — CLASSIFY (→ D-066 / D-046).
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
var isClassifyWater = new bool[mapSize * mapSize];
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true;
// ⭐ THE FLOW SURFACE — the analysis on the eroded RENDER height, exactly as every task since chat2/12.
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " +
$"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %); terminal basins {plan.TerminalBasinCount}; pits filled through {plan.PitsFilledCount:N0}");
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize,
lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest {largestPx:N0} px)");
// ═══ ⛔ THE RED-LINE GUARD — both fields digested BEFORE the layer ═══
ulong hRenderBefore = Digest(p2.Height, mapSize);
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
ulong tg0 = Time.GetTicksMsec();
var g = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx);
float graphSec = (Time.GetTicksMsec() - tg0) / 1000f;
if (g.Nodes.Count != plan.TerminalBasinCount)
throw new InvalidOperationException($"[BasinGraph] node count {g.Nodes.Count} != Plan.TerminalBasinCount {plan.TerminalBasinCount} — the layer did not enumerate the analysis's basins exactly.");
var r = new SeedResult
{
Seed = seed, Graph = g, TerminalBasinCount = plan.TerminalBasinCount,
LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies, WaterCellsKept = keptCells, LargestWaterPx = largestPx,
GraphSeconds = graphSec,
};
Summarise(r, caps);
GD.Print($" ⚠ SEAM: {g.Seabed} of {g.Nodes.Count} terminal basins are SEABED pits (every cell ocean on classify, inflow 0) — excluded from the graph statistics below; {g.Coastal} straddle the shoreline (kept as land).");
GD.Print($" ⭐ GRAPH (land): {g.LandNodes.Count} basins — {g.LakeBasins} LAKE / {g.DryBasins} DRY (floor {lakeMinPx:N0} px; {r.LakeAnyOnly} hold only a sub-floor puddle)");
GD.Print($" spills → ocean {g.ToOcean} / → another basin {g.ToBasin} / closed {g.Closed}; longest chain {r.MaxHops} hops");
GD.Print($" spill height above sea (m): min {r.SpillMinM:F1} p25 {r.SpillP25M:F1} median {r.SpillMedM:F1} p75 {r.SpillP75M:F1} max {r.SpillMaxM:F1}");
GD.Print($" floor→spill climb (m): min {r.ClimbMinM:F1} p25 {r.ClimbP25M:F1} median {r.ClimbMedM:F1} p75 {r.ClimbP75M:F1} max {r.ClimbMaxM:F1} bands ≤5/515/1530/3060/>60: {string.Join("/", r.ClimbBands)}");
foreach (float cap in caps)
{
var c = r.Cap[cap];
GD.Print($" cap {cap,3:F0} m: {c.all,3} of {g.LandNodes.Count} land basins chain to the ocean ({c.lake} lake / {c.dry} dry; {c.direct} of them directly)");
}
GD.Print($" ✅ invariants: spill cross-check (fill-level vs rim-walk) failures {g.SpillCrossCheckFailures}; spill-not-on-terrain {g.SpillNotOnTerrain}; " +
$"Dir-walk disagreements {g.DirWalkDisagreements}; seabed {g.Seabed} / coastal {g.Coastal}");
if (g.SpillCrossCheckFailures > 0 || g.SpillNotOnTerrain > 0)
GD.PrintErr(" ⚠⚠ A SPILL INVARIANT FAILED — the spill datum is not exact on this seed. Reported, not hidden; see the CSV.");
if (g.DirWalkDisagreements > 0)
GD.PrintErr(" ⚠ The FullFilled walk and the Dir walk disagree on some basin's downstream — listed in the CSV (dir_walk_agrees).");
GD.Print($" ⭐ RECONCILIATION: {g.LakeBodies.Count} significant classify bodies — {g.LakeBodiesOwned} owned by a basin (≥ half inside one), " +
$"{g.LakeBodiesSplit} split across basins, {g.LakeBodiesFree} FREE (in no terminal basin at all); " +
$"{g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} non-ocean classify-water cells lie outside every basin");
GD.Print($" graph built in {graphSec:F2}s");
WriteBasinCsv(batchRoot, r, caps);
WriteLakeCsv(batchRoot, r);
ulong tr0 = Time.GetTicksMsec();
RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, skipRaw);
r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000f;
// ═══ ⛔ …and asserted byte-identical AFTER build + render ═══
ulong hRenderAfter = Digest(p2.Height, mapSize);
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
throw new InvalidOperationException(
"[BasinGraph] RED-LINE VIOLATION: a height field CHANGED across the layer build / render.\n" +
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
"This task builds a DATA layer — it must never mutate a height or fill water. Refusing to continue.");
r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore;
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across build + render — no height mutated, no water filled.");
r.Ms = Time.GetTicksMsec() - t0;
results.Add(r);
}
WriteIndex(batchRoot, mapSize, seeds, results, lakeMinPx, caps, dp, skipRaw);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print(" ⛔ TASTE GATE: the graph is PRESENTED, not routed on. Nothing locked, nothing routed, nothing graduated.");
GD.Print(" ⛔ DATA LAYER ONLY: no height mutated, no water filled — asserted per seed.");
GD.Print("==================================================================");
GetTree().Quit(0);
}
private static void Summarise(SeedResult r, float[] caps)
{
var g = r.Graph;
var spill = new List<float>(); var climb = new List<float>();
r.ClimbBands = new int[5];
foreach (var b in g.LandNodes)
{
spill.Add(b.SpillAboveSeaM); climb.Add(b.SpillClimbM);
r.ClimbBands[b.SpillClimbM <= 5f ? 0 : b.SpillClimbM <= 15f ? 1 : b.SpillClimbM <= 30f ? 2 : b.SpillClimbM <= 60f ? 3 : 4]++;
if (b.HasAnyLake && !b.IsLake) r.LakeAnyOnly++;
int hops = g.HopsToOcean(b.Id);
if (hops > r.MaxHops) r.MaxHops = hops;
}
spill.Sort(); climb.Sort();
(r.SpillMinM, r.SpillP25M, r.SpillMedM, r.SpillP75M, r.SpillMaxM) = Quantiles(spill);
(r.ClimbMinM, r.ClimbP25M, r.ClimbMedM, r.ClimbP75M, r.ClimbMaxM) = Quantiles(climb);
foreach (float cap in caps)
{
bool[] ok = g.ConnectedAtCap(cap, out int connected);
int lake = 0, dry = 0, direct = 0;
connected = 0;
for (int i = 0; i < g.Nodes.Count; i++)
{
if (!ok[i] || !g.Nodes[i].IsLand) continue; // land basins only — a seabed pit "chains" trivially
connected++;
if (g.Nodes[i].IsLake) lake++; else dry++;
if (g.Nodes[i].Downstream == DownstreamKind.Ocean) direct++;
}
r.Cap[cap] = (connected, lake, dry, direct);
}
}
private static (float, float, float, float, float) Quantiles(List<float> sorted)
{
if (sorted.Count == 0) return (0, 0, 0, 0, 0);
float Q(double q) => sorted[Math.Clamp((int)Math.Round(q * (sorted.Count - 1)), 0, sorted.Count - 1)];
return (sorted[0], Q(0.25), Q(0.5), Q(0.75), sorted[^1]);
}
/// <summary>FNV-1a over the raw float bits — "byte-identical", not "numerically close".</summary>
private static ulong Digest(float[,] f, int n)
{
ulong h = 14695981039346656037UL;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
for (int b = 0; b < 4; b++)
{
h ^= (byte)(bits >> (b * 8));
h *= 1099511628211UL;
}
}
return h;
}
private static string Kind(DownstreamKind k) => k switch
{
DownstreamKind.Ocean => "OCEAN",
DownstreamKind.Basin => "BASIN",
_ => "NONE",
};
private static void WriteBasinCsv(string batchRoot, SeedResult r, float[] caps)
{
var g = r.Graph; int n = g.MapSize;
var capOk = new Dictionary<float, bool[]>();
foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
var sb = new StringBuilder();
sb.Append("id,class,area_px,inflow_px,is_lake,has_any_lake,lake_cells,lake_cells_significant,ocean_cells," +
"floor_x,floor_y,floor_raw,spill_x,spill_y,spill_raw,spill_above_sea_m,depth_to_spill_m,spill_climb_m,spill_ties," +
"spill_crosscheck_ok,spill_on_terrain,downstream,downstream_id,downstream_x,downstream_y,spill_path_cells,dir_walk_agrees,dir_walk_kind,dir_walk_id,hops_to_ocean");
foreach (float cap in caps) sb.Append($",chain_ok_cap{cap:F0}");
sb.AppendLine();
for (int i = 0; i < g.Nodes.Count; i++)
{
var b = g.Nodes[i];
sb.Append($"{b.Id},{(b.IsSeabed ? "seabed" : b.IsCoastal ? "coastal" : "inland")},{b.AreaPx},{b.InflowPx},{(b.IsLake ? "yes" : "no")},{(b.HasAnyLake ? "yes" : "no")},{b.LakeCells},{b.LakeCellsSignificant},{b.OceanCells}," +
$"{b.FloorCell / n},{b.FloorCell % n},{b.FloorHeightRaw:R},{b.SpillCell / n},{b.SpillCell % n},{b.SpillHeightRaw:R}," +
$"{b.SpillAboveSeaM:F2},{b.DepthToSpillM:F2},{b.SpillClimbM:F2},{b.SpillTies}," +
$"{(b.SpillCrossCheckOk ? "yes" : "NO")},{(b.SpillOnTerrain ? "yes" : "NO")},{Kind(b.Downstream)},{b.DownstreamId}," +
$"{b.DownstreamEntryCell / n},{b.DownstreamEntryCell % n},{b.SpillPath.Count},{(b.DirWalkAgrees ? "yes" : "NO")},{Kind(b.DirWalkKind)},{b.DirWalkId},{g.HopsToOcean(b.Id)}");
foreach (float cap in caps) sb.Append($",{(capOk[cap][i] ? "yes" : "no")}");
sb.AppendLine();
}
WriteText(Path.Combine(batchRoot, $"basins_{r.Seed}.csv"), sb.ToString());
}
private static void WriteLakeCsv(string batchRoot, SeedResult r)
{
var g = r.Graph;
var sb = new StringBuilder();
sb.AppendLine("body,size_px,cells_in_basins,basins_touched,dominant_basin_id,dominant_cells,dominant_basin_is_lake,status");
foreach (var l in g.LakeBodies)
{
var b = l.DominantBasinId != 0 ? g.Of(l.DominantBasinId) : null;
sb.AppendLine($"{l.Index},{l.SizePx},{l.CellsInBasins},{l.BasinsTouched},{l.DominantBasinId},{l.DominantCells}," +
$"{(b == null ? "" : b.IsLake ? "yes" : "no")},{(l.Free ? "FREE" : l.Owned ? "owned" : "split")}");
}
WriteText(Path.Combine(batchRoot, $"lakes_{r.Seed}.csv"), sb.ToString());
}
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean,
bool[] isClassifyWater, Pass2Result p2, int n, float sea, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
var g = r.Graph;
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
string caps = "";
foreach (var kv in r.Cap) caps += $"{kv.Key:F0}M:{kv.Value.all} ";
BasinGraphRenderer.Plate(g, plan, baseImg, n, isOcean, isClassifyWater,
$"SEED {r.Seed} - THE BASIN GRAPH: {g.LandNodes.Count} LAND BASINS, {g.LakeBasins} LAKE / {g.DryBasins} DRY (FLOOR {g.LakeMinPx} PX) [+{g.Seabed} SEABED PITS, OUTLINED ONLY]",
$"SPILLS: {g.ToOcean} TO OCEAN / {g.ToBasin} INTO ANOTHER BASIN / {g.Closed} CLOSED. LONGEST CHAIN {r.MaxHops} HOPS. BASINS CHAINING TO THE OCEAN AT CAP {caps.Trim()}",
$"SPILL HEIGHT ABOVE SEA: MEDIAN {r.SpillMedM:F0} M (RANGE {r.SpillMinM:F0}..{r.SpillMaxM:F0}). FLOOR-TO-SPILL CLIMB: MEDIAN {r.ClimbMedM:F0} M (RANGE {r.ClimbMinM:F0}..{r.ClimbMaxM:F0}). TASTE GATE - NOTHING ROUTED, NOTHING LOCKED")
.SavePng(Path.Combine(dir, $"basin_graph_{r.Seed}.png"));
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
r.GMin = gmin; r.GMax = gmax;
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
}
private static void WriteIndex(string batchRoot, int mapSize, int[] seeds, List<SeedResult> rows,
int lakeMinPx, float[] caps, DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = seeds.Length > 0 ? seeds[0] : 0;
string capHdr = string.Join(" | ", Array.ConvertAll(caps, c => $"chain→ocean @ {c:F0} m"));
sb.AppendLine("# Batch 04 — the lake-basin layer: the basin graph (spill + lake-identity + downstream edge)");
sb.AppendLine();
sb.AppendLine("**⛔ TASTE GATE ON THE FOUNDATION. Nothing is routed, nothing is locked, nothing is graduated.** This is the");
sb.AppendLine("data layer the flow-through routing model (piece 2) will traverse; piece 2 is authored only once the spills");
sb.AppendLine("and the graph read right.");
sb.AppendLine();
sb.AppendLine("**⛔ DATA LAYER ONLY. No height was mutated, no water was filled or created, `DrainageAnalysis` was not");
sb.AppendLine("edited** — asserted per seed by an FNV digest of both height fields taken before the layer was built and");
sb.AppendLine("after the plate was drawn.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick");
sb.AppendLine();
sb.AppendLine($"Open **`{primary}/basin_graph_{primary}.png`**. Then the other three: " +
string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
sb.AppendLine();
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
sb.AppendLine("> **Do the spills (yellow rings) sit where water would actually overflow — the true low rim? Are the");
sb.AppendLine("> lake/dry labels right (blue = a significant classify lake sits in the basin, amber = dry sink)? And does");
sb.AppendLine("> \"who drains to whom\" (cyan arrow → ocean, white arrow → another basin, red = closed) look like a real");
sb.AppendLine("> drainage network?** If the spills are wrong, piece 2 must not be built on this.");
sb.AppendLine(">");
sb.AppendLine("> Each spill is labelled `23M/7`: **23 m above the sea datum**, and **7 m of climb from the basin floor**");
sb.AppendLine("> to overtop — the second number is what a rim cap is judged against. Both read on the RENDER surface.");
sb.AppendLine("> The black dot is the basin floor, with its `#id` (the id `BasinId` carries — sparse, as the analysis leaves it).");
sb.AppendLine();
sb.AppendLine("## ⭐ The graph, per seed");
sb.AppendLine();
sb.AppendLine($"| Seed | terminal basins | ⚠ seabed (excluded) | coastal | **land basins** | **lake** | dry | (sub-floor puddle only) | spill → ocean | → basin | closed | longest chain | {capHdr} |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
foreach (var r in rows)
{
var g = r.Graph;
sb.Append($"| `{r.Seed}` | {g.Nodes.Count} | {g.Seabed} | {g.Coastal} | **{g.LandNodes.Count}** | **{g.LakeBasins}** | {g.DryBasins} | {r.LakeAnyOnly} | {g.ToOcean} | {g.ToBasin} | {g.Closed} | {r.MaxHops} hops |");
foreach (float cap in caps) { var c = r.Cap[cap]; sb.Append($" **{c.all}** ({c.lake} lake / {c.dry} dry) |"); }
sb.AppendLine();
}
sb.AppendLine();
sb.AppendLine("> ### ⚠⚠ SEABED PITS — half the analysis's \"terminal basins\" are not on land");
sb.AppendLine("> The priority-flood runs on the whole RENDER surface, ocean floor included, so a deep-enough, large-enough");
sb.AppendLine("> depression UNDER the classify sea qualifies as a terminal basin exactly like a land one. Every cell of such");
sb.AppendLine("> a basin is `OceanMask`, its cells are `D_NONE`, its inflow is 0 — it is hydrologically inert, and it is the");
sb.AppendLine("> D-046 seam (render vs classify) made visible. They are kept in the layer and the CSV (`class = seabed`),");
sb.AppendLine("> drawn as a faint outline only, and **excluded from every statistic on this page.** The `land basins` column is");
sb.AppendLine("> the graph; `coastal` basins (some ocean cells, some land) are counted as land and flagged.");
sb.AppendLine();
sb.AppendLine("> **Reading the cap columns.** A basin \"chains to the ocean at cap C\" when every link from it to the sea — its");
sb.AppendLine("> own spill and every downstream basin's spill — climbs ≤ C m from that basin's floor (elevation clamped at sea,");
sb.AppendLine("> exactly as `RouteTo` clamps). This previews what the flow-through model will trade at a given cap; it decides");
sb.AppendLine("> nothing. Piece 2 routes; this only says how many basins *could* connect.");
sb.AppendLine();
sb.AppendLine("## ⭐ Spill-height distributions (metres, render surface)");
sb.AppendLine();
sb.AppendLine("| Seed | spill above sea: min / p25 / median / p75 / max | floor→spill climb: min / p25 / median / p75 / max | climb bands ≤5 / 515 / 1530 / 3060 / >60 |");
sb.AppendLine("|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | {r.SpillMinM:F1} / {r.SpillP25M:F1} / {r.SpillMedM:F1} / {r.SpillP75M:F1} / {r.SpillMaxM:F1} | " +
$"{r.ClimbMinM:F1} / {r.ClimbP25M:F1} / {r.ClimbMedM:F1} / {r.ClimbP75M:F1} / {r.ClimbMaxM:F1} | {string.Join(" / ", r.ClimbBands)} |");
sb.AppendLine();
sb.AppendLine("## ✅ The invariants, per seed — the spill datum is exact, or it says so");
sb.AppendLine();
sb.AppendLine("| Seed | spill cross-check failures (fill-level vs rim-walk) | spill not on terrain | Dir-walk disagreements | seabed / coastal | render digest | classify digest |");
sb.AppendLine("|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var g = r.Graph;
sb.AppendLine($"| `{r.Seed}` | {(g.SpillCrossCheckFailures == 0 ? "**0** " : $"**{g.SpillCrossCheckFailures}** ")} | " +
$"{(g.SpillNotOnTerrain == 0 ? "**0** " : $"**{g.SpillNotOnTerrain}** ")} | " +
$"{(g.DirWalkDisagreements == 0 ? "**0** " : $"**{g.DirWalkDisagreements}** ")} | {g.Seabed} / {g.Coastal} | `{r.RenderDigest:X16}` | `{r.ClassifyDigest:X16}` |");
}
sb.AppendLine();
sb.AppendLine("*Cross-check: the basin's minimum on `FullFilled` is the spill + one ulp (the flood's own epsilon), so");
sb.AppendLine("`BitDecrement(min inside) == min over the rim` must hold exactly. \"On terrain\": `FullFilled == render` at the spill");
sb.AppendLine("cell — the rim was never raised by the flood. \"Dir-walk\": following `Plan.Dir` from the first cell past the spill");
sb.AppendLine("reaches the same node as the `FullFilled` descent. A basin holding OCEAN cells is a render depression under");
sb.AppendLine("classify-sea — the D-046 seam, counted rather than hidden.*");
sb.AppendLine();
sb.AppendLine("## ⭐ The reconciliation — does each significant heightmap lake sit in a terminal basin?");
sb.AppendLine();
sb.AppendLine("| Seed | significant bodies (≥ floor) | **owned** (≥ half inside one basin) | split across basins | **FREE** (in no basin) | non-ocean classify-water cells outside every basin |");
sb.AppendLine("|---|---|---|---|---|---|");
foreach (var r in rows)
{
var g = r.Graph;
sb.AppendLine($"| `{r.Seed}` | {g.LakeBodies.Count} | **{g.LakeBodiesOwned}** | {g.LakeBodiesSplit} | **{g.LakeBodiesFree}** | {g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} ({100.0 * g.ClassifyWaterCellsOutsideBasins / Math.Max(1, g.ClassifyWaterCellsTotal):F1} %) |");
}
sb.AppendLine();
sb.AppendLine("*A FREE body is a classify lake that is not a depression ≥ 2 m / 10,000 px on the RENDER surface (or filled through as a");
sb.AppendLine("pit) — heightmap water the hydrology never pooled into. On the plate these are the dark-teal patches with no tint. They are");
sb.AppendLine("exactly the \"free-floating heightmap lakes\" this layer exists to reconcile; per body detail in `lakes_<seed>.csv`.*");
sb.AppendLine();
foreach (var r in rows)
{
var g = r.Graph; int n = g.MapSize;
sb.AppendLine($"### `{r.Seed}` — every LAND basin, largest first ({g.Seabed} seabed pits omitted; see the CSV)");
sb.AppendLine();
sb.Append("| id | area px | inflow px | lake? | lake cells | spill (x,y) | spill +m | climb m | → | hops |");
foreach (float cap in caps) sb.Append($" @{cap:F0} |");
sb.AppendLine();
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
var order = new List<BasinNode>(g.LandNodes);
order.Sort((a, b) => b.AreaPx.CompareTo(a.AreaPx));
var capOk = new Dictionary<float, bool[]>();
foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
foreach (var b in order)
{
int idx = g.Nodes.IndexOf(b);
string to = b.Downstream switch
{
DownstreamKind.Ocean => "**OCEAN**",
DownstreamKind.Basin => $"#{b.DownstreamId}",
_ => "⚠ closed",
};
int hops = g.HopsToOcean(b.Id);
sb.Append($"| #{b.Id}{(b.IsCoastal ? " coastal" : "")} | {b.AreaPx:N0} | {b.InflowPx:N0} | {(b.IsLake ? "**lake**" : b.HasAnyLake ? "puddle" : "dry")} | {b.LakeCells:N0} | " +
$"({b.SpillCell / n},{b.SpillCell % n}) | {b.SpillAboveSeaM:F1} | {b.SpillClimbM:F1} | {to} | {(hops < 0 ? "" : hops.ToString())} |");
foreach (float cap in caps) sb.Append(capOk[cap][idx] ? " ✅ |" : " — |");
sb.AppendLine();
}
sb.AppendLine();
sb.AppendLine($"*Land {r.LandCells:N0}, endorheic {r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %) · " +
$"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px ({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · " +
$"graph {r.GraphSeconds:F2}s, plate {r.RenderSeconds:F1}s, seed total {r.Ms / 1000.0:F0}s · grayscale range {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*");
sb.AppendLine();
}
sb.AppendLine("## The two \"lakes\" this layer reconciles — and the datum each is read on (D-046)");
sb.AppendLine();
sb.AppendLine("| Quantity | Surface | Why |");
sb.AppendLine("|---|---|---|");
sb.AppendLine("| spill cell, spill height, floor, climb | **RENDER** (`Plan.FullFilled`, the flood of the eroded render height) | where water GOES — the surface `RouteTo` routes on, so a climb here is the same number as the router's `RimClimbM` |");
sb.AppendLine("| downstream walk | **RENDER** (`FullFilled` descent) | the reference's provisional-route machinery, started at the spill |");
sb.AppendLine("| lake presence (`IsLake`, lake cells) | **CLASSIFY** (`classify < sea` and not `OceanMask`) | what is VISIBLY water — the surface the terminus tests use |");
sb.AppendLine("| the OCEAN terminus of a walk | **CLASSIFY** (`OceanMask`) | as routing: route on render, ocean on classify |");
sb.AppendLine();
sb.AppendLine("**No field compares a classify height to a render height.** The surfaces meet only as membership tests");
sb.AppendLine("(is this basin cell classify-water? is this walk cell ocean?) — the split the routing already lives by. No new seam.");
sb.AppendLine();
sb.AppendLine("## What was run");
sb.AppendLine();
sb.AppendLine($"Chain + drainage analysis + the layer at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered.");
sb.AppendLine($"`ISLA_LAKE_MIN_PX={lakeMinPx:N0}` (the significance floor — a knob); cap preview at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused — the layer reads `BasinId`, `FullFilled`, `Dir`, `BasinInflow`); " +
$"`EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} (they define which depressions ARE the nodes).");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `<seed>/basin_graph_<seed>.png` | the graph over the faint terrain: basins tinted lake/dry, spill rings labelled, arrows to the downstream node |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `basins_<seed>.csv` | every `BasinNode`: class (inland/coastal/seabed), area, inflow, lake cells, floor, spill (cell + raw + metres), climb, downstream kind/id/entry, invariants, hops, chain-ok per cap |");
sb.AppendLine("| `lakes_<seed>.csv` | every significant classify body: size, cells inside basins, dominant basin, owned / split / FREE |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. |");
sb.AppendLine();
sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
sb.AppendLine();
sb.AppendLine("→ `XX_Human/output/rivers/04_lake_basin_layer.report.md`");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
// ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
}.WithFamilyOff();
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
}
// ---- env / io -----------------------------------------------------------------------------
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
private static float[] EnvFloats(string k, float[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<float>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}