islaApocalypse-v2/Tools/Scripts/DrainageTool.cs
beezm 89e0f85c9f chat2/12: drainage analysis (minimal-first) — the reference river plan ported, analysis only, on the eroded terrain
Core/Scripts/DrainageAnalysis.cs is the reference's DrainageAnalysis ported verbatim: the
Barnes priority-flood routing fill (8-connected, seeded from the four borders, index
tiebreak, pit fills ONE ULP above the parent so every filled cell keeps a strictly
descending path to its spill; the terrain heightmap itself is never written - the fill lives
in its own array), terminal-basin qualification (depth >= 2 m AND area >= 10,000 px; the rest
are pits filled through), D8 flow directions on the routing surface - FOR ANALYSIS ONLY, the
reverted-as-carving landmine stated in the file - Kahn accumulation, the memoised destination
walk crediting a terminal basin with TOTAL inflow, sea-reaching outlets ranked by drainage
area with the outlet separation, main stems by max accumulation, mountain exits from the
along-stem grade, lean tributaries, lean endorheic terminals, and the promoted giants
(provisional routes computed as the reference did, not drawn - routing is a later task).
WorldScale-denominated; Dir / Acc / Filled / FullFilled / BasinId / BasinInflow exposed so the
caller can prove the invariants.

"The sea" is the OCEAN body from the region layer: RegionLabeling.OceanMask - the 4-connected
water component touching the border, on the CLASSIFY field (the water-side complement of the
land contract). Enclosed lagoons, lake beds and island-fringe pockets are ordinary terrain to
the router.

DrainageTool (4 task-11 seeds at 8192, the eroded fields bit-identical to the 11 dumps)
renders the log-scaled accumulation map and the promoted-candidates overlay (trunks cyan,
endorheic giants orange, lean terminals red; nothing carved) and writes the accumulation
.f32. Oracle, all passing: render AND classify fields bit-identical before/after the analysis
(zero terrain cells written), no water added, full fill >= original everywhere with a
non-ascending path to the border from every cell, ocean mask all below sea and on the border,
dir/acc/candidates identical across two runs.

Endorheic basins are the expected first-class output: on every seed the top giants out-drain
the top trunks - the biggest drainages pool inland because erosion delivers the upland network
only and cannot cross the flats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 20:51:51 -04:00

410 lines
24 KiB
C#
Raw 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 DRAINAGE-ANALYSIS BATCH (chat2/12) — minimal-first: is the flow sane before rivers are built
/// on it? Runs <see cref="DrainageAnalysis"/> (pure analysis) on the ERODED render field of the locked
/// shape for 4 seeds from the task-11 batch, renders the log-accumulation map + the promoted-candidates
/// overlay, writes the accumulation as .f32, and proves: terrain bit-identical before/after (nothing
/// carved), no water added, the routing-fill invariants, determinism, ocean identity from the region
/// layer. ⚠ D8 is used to COMPUTE where water flows — never to carve.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/DrainageTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
/// ISLA_SEEDS (default the 4 task-11 seeds)
/// ISLA_SKIP_T11_CHECK=1 skip the bit-identity against the task-11 erosion_on dumps
/// </summary>
public partial class DrainageTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 17320508 };
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 Row
{
public int Seed; public DrainageAnalysis.Plan Plan; public long OceanCells, EnclosedWater, LandCells;
public ulong MsAnalysis; public bool Ok;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 12);
string descr = EnvStr("ISLA_BATCH", "drainage_analysis");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool skipT11 = EnvStr("ISLA_SKIP_T11_CHECK", "0") == "1";
string t11Source = EnvStr("ISLA_T11_SOURCE", "11_erosion");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
var dp = new DrainageAnalysis.Params { SeaLevel = sea };
GD.Print("==================================================================");
GD.Print(" DRAINAGE ANALYSIS (chat2/12) — minimal-first: is the flow sane? (analysis only, nothing carved)");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON (faithful tune) — the task-11 erosion_on field");
GD.Print($"params : endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx}, inflow ≥ {dp.EndorheicMinInflowPx}, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount} sep {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed)
{
var c = new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = "drainage",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
TerrainShapeV1.Apply(c);
c.Erosion = true; // the faithful tune — the defaults
return c;
}
var hard = new List<ShapingOracle.Check>();
var perSeed = new List<ShapingOracle.Check>();
var rows = new List<Row>();
for (int si = 0; si < seeds.Length; si++)
{
int seed = seeds[si];
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;
GD.Print($" terrain ready ({p1.ElapsedMs} ms pass 1, erosion {ero.Ms / 1000.0:F1} s)");
if (!skipT11)
{
string dump = Path.Combine(ToolingPaths.BatchesRoot, t11Source, $"{seed}_erosion_on", "height.f32");
if (File.Exists(dump) && mapSize == 8192)
{
var a11 = ShapingOracle.DumpRegression("a11", $"the eroded render field == the task-11 erosion_on dump (the terrain the developer saw) [{seed}]", p2.Height, HeightField.Load(dump, mapSize), mapSize, dump);
hard.Add(a11); GD.Print(" " + a11);
}
else GD.Print($" a11 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the 11 batch's 8192" : $"no dump at {dump}")}");
}
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field.
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
var isClassifyWater = new bool[mapSize * mapSize];
long waterPx = 0;
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; waterPx++; }
GD.Print($" ocean (region layer, classify): {oceanCells:N0} cells; enclosed non-ocean water: {enclosed:N0} cells; classify water total {waterPx:N0}");
// Snapshot both fields — the analysis must write ZERO terrain cells.
var renderBefore = (float[,])p2.Height.Clone();
var classifyBefore = (float[,])p2.HeightClassify.Clone();
long wetRenderBefore = ErosionPass.CountWaterPixels(p2.Height, mapSize, sea);
ulong tA = Time.GetTicksMsec();
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
ulong msA = Time.GetTicksMsec() - tA;
GD.Print($" analysis {msA / 1000.0:F1} s: 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} %), unrouted {plan.UnroutedCells:N0}; terminal basins {plan.TerminalBasinCount}, pits filled through {plan.PitsFilledCount:N0}");
foreach (var t in plan.Trunks) GD.Print($" trunk: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}) drainage {t.DrainageAreaPx:N0} px, stem {t.Course.Count * 4} px, exit {(t.ExitFound ? $"({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m" : "NOT FOUND")}, tributaries {t.Tributaries.Count}");
foreach (var g in plan.Giants) GD.Print($" giant: terminal ({g.Terminal.x:F0},{g.Terminal.y:F0}) inflow {g.DrainageAreaPx:N0} px, basin {g.BasinAreaPx:N0} px / {g.BasinDepthM:F1} m deep, kind {g.Kind}, exit {(g.ExitFound ? $"{g.MountainExitElevM:F0} m" : "NOT FOUND")}, tributaries {g.Tributaries.Count}");
foreach (var e in plan.Endorheics) GD.Print($" lean terminal: ({e.Terminal.x:F0},{e.Terminal.y:F0}) inflow {e.DrainageAreaPx:N0}, basin {e.BasinAreaPx:N0} px / {e.BasinDepthM:F1} m");
// ═══ THE ORACLE ═══
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.NorthLocked("t", "terrain untouched — render field bit-identical before/after the analysis", renderBefore, p2.Height, mapSize, mapSize),
ShapingOracle.NorthLocked("t2", "terrain untouched — classify field bit-identical before/after the analysis", classifyBefore, p2.HeightClassify, mapSize, mapSize),
WaterUnchanged(wetRenderBefore, p2, mapSize, sea, p1),
FillInvariants(plan, p2.Height, mapSize),
OceanFromRegionLayer(isOcean, p2.HeightClassify, mapSize, sea, oceanCells, enclosed),
};
if (si == 0)
{
var plan2 = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
checks.Add(Deterministic(plan, plan2));
}
foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); GD.Print(" " + c); }
bool ok = checks.TrueForAll(c => c.Passed);
WriteSeed(batchRoot, seed, plan, isOcean, p2, mapSize, sea, skipRaw);
rows.Add(new Row { Seed = seed, Plan = plan, OceanCells = oceanCells, EnclosedWater = enclosed, LandCells = plan.LandCells, MsAnalysis = msA, Ok = ok });
}
bool allOk = hard.TrueForAll(c => c.Passed) && perSeed.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
WriteIndex(batchRoot, mapSize, calibSize, seeds, rows, dp, hard, perSeed, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the checks -------------------------------------------------------
private static ShapingOracle.Check WaterUnchanged(long wetBefore, Pass2Result p2, int n, float sea, Pass1Result p1)
{
long wetAfter = ErosionPass.CountWaterPixels(p2.Height, n, sea);
var c = new ShapingOracle.Check { Id = "w", Name = "no water added — render water pixels unchanged; region labeling + island tag untouched" };
c.Passed = wetBefore == wetAfter && p1.Regions != null;
c.Detail = $"water pixels {wetBefore:N0} → {wetAfter:N0}; {p1.Regions?.IslandCount ?? 0} islands in the (untouched) region table";
return c;
}
/// <summary>The reference's two routing-fill diagnostics: filled ≥ original everywhere; every cell has a non-ascending path to the border on the full fill.</summary>
private static ShapingOracle.Check FillInvariants(DrainageAnalysis.Plan plan, float[,] height, int n)
{
var c = new ShapingOracle.Check { Id = "r", Name = "routing fill — full fill ≥ original everywhere; every cell has a non-ascending 8-path to the border" };
long below = 0, raised = 0; string first = null;
var ff = plan.FullFilled;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
int i = x * n + y; float h = height[x, y];
if (ff[i] < h) { below++; first ??= $"[{x},{y}] filled {ff[i]:G9} < original {h:G9}"; }
else if (ff[i] > h) raised++;
}
// Non-ascending path: follow the lowest neighbour; memoised. -1 unknown, 1 reaches border, 2 stuck.
var state = new sbyte[n * n]; long stuck = 0; var path = new List<int>(1 << 12);
int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 }, DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
for (int i = 0; i < n * n && stuck == 0; i++)
{
if (state[i] != 0) continue;
int cur = i; path.Clear(); sbyte result = 0;
while (true)
{
if (state[cur] != 0) { result = state[cur]; break; }
path.Add(cur);
int cx = cur / n, cy = cur % n;
if (cx == 0 || cy == 0 || cx == n - 1 || cy == n - 1) { result = 1; break; }
float best = ff[cur]; int bestN = -1;
for (int k = 0; k < 8; k++)
{
int ni = (cx + DX[k]) * n + (cy + DY[k]);
if (ff[ni] < best) { best = ff[ni]; bestN = ni; } // the strictly lowest neighbour
}
if (bestN < 0)
{
// no strictly lower neighbour: allow an EQUAL neighbour not yet on this path (flat), else stuck
for (int k = 0; k < 8 && bestN < 0; k++)
{
int ni = (cx + DX[k]) * n + (cy + DY[k]);
if (ff[ni] == ff[cur] && state[ni] == 1) bestN = ni;
}
if (bestN < 0) { result = 2; break; }
}
cur = bestN;
if (path.Count > 4 * n) { result = 2; break; }
}
foreach (int pc in path) state[pc] = result;
if (result == 2) { stuck++; first ??= $"cell {path[0] / n},{path[0] % n} has no non-ascending path to the border"; }
}
c.Passed = below == 0 && stuck == 0;
c.Detail = c.Passed ? $"filled ≥ original on all {(long)n * n:N0} cells ({raised:N0} raised); every cell drains to the border on the full fill"
: $"VIOLATION — {below:N0} cells filled below original, {stuck:N0} stuck — {first}";
return c;
}
private static ShapingOracle.Check OceanFromRegionLayer(bool[] isOcean, float[,] classify, int n, float sea, long oceanCells, long enclosed)
{
var c = new ShapingOracle.Check { Id = "s", Name = "\"the sea\" = the ocean body from the region layer (classify, 4-connected to the border) — not any below-sea cell" };
long oceanLand = 0, oceanTouchBorder = 0, count = 0;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
if (!isOcean[x * n + y]) continue;
count++;
if (classify[x, y] >= sea) oceanLand++;
if (x == 0 || y == 0 || x == n - 1 || y == n - 1) oceanTouchBorder++;
}
c.Passed = oceanLand == 0 && oceanTouchBorder > 0 && count == oceanCells;
c.Detail = $"{count:N0} ocean cells, all below sea, {oceanTouchBorder:N0} on the border; {enclosed:N0} below-sea cells are NOT ocean (enclosed water — ordinary terrain to the router)";
return c;
}
private static ShapingOracle.Check Deterministic(DrainageAnalysis.Plan a, DrainageAnalysis.Plan b)
{
var c = new ShapingOracle.Check { Id = "o", Name = "deterministic — flow field, accumulation and candidate set identical across two runs" };
long dirDiff = 0, accDiff = 0;
for (int i = 0; i < a.Dir.Length; i++) { if (a.Dir[i] != b.Dir[i]) dirDiff++; if (a.Acc[i] != b.Acc[i]) accDiff++; }
bool cand = a.Trunks.Count == b.Trunks.Count && a.Giants.Count == b.Giants.Count && a.Endorheics.Count == b.Endorheics.Count;
if (cand) for (int i = 0; i < a.Trunks.Count; i++) cand &= a.Trunks[i].DrainageAreaPx == b.Trunks[i].DrainageAreaPx && a.Trunks[i].Outlet == b.Trunks[i].Outlet;
if (cand) for (int i = 0; i < a.Giants.Count; i++) cand &= a.Giants[i].DrainageAreaPx == b.Giants[i].DrainageAreaPx && a.Giants[i].Terminal == b.Giants[i].Terminal;
c.Passed = dirDiff == 0 && accDiff == 0 && cand;
c.Detail = c.Passed ? $"dir and acc identical over {a.Dir.Length:N0} cells; {a.Trunks.Count} trunks / {a.Giants.Count} giants / {a.Endorheics.Count} lean terminals identical"
: $"DIFFER — dir {dirDiff:N0} cells, acc {accDiff:N0} cells, candidates {(cand ? "same" : "DIFFER")}";
return c;
}
// ---- the curve --------------------------------------------------------
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(new TerrainGenConfig { MapSize = calibSize, Seed = 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",
};
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]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
// ---- output -----------------------------------------------------------
private static void WriteSeed(string batchRoot, int seed, DrainageAnalysis.Plan plan, bool[] isOcean, Pass2Result p2, int n, float sea, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
DrainageRenderer.Accumulation(plan.Acc, isOcean, p2.Height, n, sea).SavePng(Path.Combine(dir, "accumulation.png"));
DrainageRenderer.Candidates(plan, isOcean, p2.Height, n, sea, p2.HMax, $"DRAINAGE PLAN SEED {seed} (ERODED TERRAIN, D8 ANALYSIS)").SavePng(Path.Combine(dir, "candidates.png"));
if (!skipRaw)
{
var accF = new float[n, n];
for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) accF[x, y] = plan.Acc[x * n + y];
HeightField.Save(accF, n, Path.Combine(dir, "accumulation.f32"));
}
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List<Row> rows, DrainageAnalysis.Params dp,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
{
var sb = new StringBuilder();
sb.AppendLine($"# Batch 12 — drainage analysis (minimal-first): is the flow sane? {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.AppendLine("**Analysis only — nothing carved, no water added.** The reference `DrainageAnalysis` (priority-flood routing fill with a");
sb.AppendLine("one-ulp epsilon, D8 flow directions FOR ANALYSIS, Kahn accumulation, drainage-area promotion) on the eroded render field of the");
sb.AppendLine("locked shape. **\"The sea\" is the OCEAN body from the region layer** (classify, 4-connected to the border); enclosed water is");
sb.AppendLine("ordinary terrain to the router. **⚠ Endorheic basins are EXPECTED here, not errors:** erosion delivers the upland network only and");
sb.AppendLine("cannot cross the flats, so the biggest drainages pool inland. A map full of orange terminals is the correct result.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{seeds[0]}/accumulation.png`** — log-scaled flow accumulation: dendritic uplands and trunks bright on dark hillslopes.");
sb.AppendLine($"2. **`{seeds[0]}/candidates.png`** — the promoted candidates over a faint terrain: cyan = sea-reaching trunks (square outlet, white ring = mountain exit), orange = endorheic giants (disc = pooling terminal), red rings = lean endorheic terminals.");
sb.AppendLine("3. The other three seeds, then the table.");
sb.AppendLine();
sb.AppendLine("## The summary table — sea-reaching vs endorheic (endorheic dominance is the expected finding)");
sb.AppendLine();
sb.AppendLine("| Seed | land cells | → ocean | → endorheic | unrouted | terminal basins / pits filled | trunks (drainage px; exit) | giants (inflow px; basin px / depth; kind) | largest endorheic giant vs largest trunk | lean terminals | ocean / enclosed water cells | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var p = r.Plan;
string trunks = p.Trunks.Count == 0 ? "—" : string.Join("<br>", p.Trunks.ConvertAll(t => $"({t.Outlet.x:F0},{t.Outlet.y:F0}) {t.DrainageAreaPx:N0}; exit {(t.ExitFound ? $"{t.MountainExitElevM:F0} m" : "none")}"));
string giants = p.Giants.Count == 0 ? "—" : string.Join("<br>", p.Giants.ConvertAll(g => $"({g.Terminal.x:F0},{g.Terminal.y:F0}) {g.DrainageAreaPx:N0}; {g.BasinAreaPx:N0} / {g.BasinDepthM:F1} m; {g.Kind}"));
long bigG = p.Giants.Count == 0 ? 0 : p.Giants[0].DrainageAreaPx, bigT = p.Trunks.Count == 0 ? 0 : p.Trunks[0].DrainageAreaPx;
sb.AppendLine($"| `{r.Seed}` | {p.LandCells:N0} | {p.SeaReachingCells:N0} ({100.0 * p.SeaReachingCells / Math.Max(1, p.LandCells):F1} %) | **{p.EndorheicCells:N0} ({100.0 * p.EndorheicCells / Math.Max(1, p.LandCells):F1} %)** | {p.UnroutedCells:N0} | {p.TerminalBasinCount} / {p.PitsFilledCount:N0} | {trunks} | {giants} | **{bigG:N0} vs {bigT:N0}** ({(bigT > 0 ? (double)bigG / bigT : 0):F1}×) | {p.Endorheics.Count} | {r.OceanCells:N0} / {r.EnclosedWater:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
}
sb.AppendLine();
sb.AppendLine($"Params: endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx:N0} px, inflow ≥ {dp.EndorheicMinInflowPx:N0} px, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount}, outlet separation {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx:N0} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px — the reference's declared defaults. Provisional routes are computed (as the reference did) but NOT drawn or promoted — routing is a later task.");
sb.AppendLine();
sb.AppendLine("## The oracle (analysis-only guarantees)");
sb.AppendLine();
sb.AppendLine(hard.Count == 0 ? "*(the task-11 bit-identity check was skipped)*\n" : ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per seed (terrain untouched t / t2 · no water added w · routing-fill invariants r · ocean from the region layer s · determinism o):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perSeed));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `accumulation.png`, `candidates.png`, `INDEX.md` | **keep** |");
sb.AppendLine("| `accumulation.f32` | ♻ regenerable (analysis of a regenerable field) — 256 MB each, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Analysis at {mapSize}, curve calibrated at {calibSize}. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
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;
}
}
}