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
410 lines
24 KiB
C#
410 lines
24 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|