diff --git a/Core/README.md b/Core/README.md
index aaa72c4..b69a535 100644
--- a/Core/README.md
+++ b/Core/README.md
@@ -38,6 +38,7 @@ resolution and the file-safety rails. Constants and contracts.
| `Scripts/CurveAnchors.cs` | The OUTPUT anchors — the storm-ladder elevations each band lands at. |
| `Scripts/TerrainDetailPass.cs` | Shelf micro-relief + the shelf-edge **knot warp**. Output-height only. |
| `Scripts/HydraulicErosion.cs` | ⭐⭐ **Droplet (hydraulic) erosion** (chat2/11) — the reference's pass ported VERBATIM: four governors (count, lifetime, carve cap, deposit cap) on a net-displacement ledger proven on exit, the sea clamp (below-sea read-only both ways), the cone brush shared by erode and deposit, the crater exclusion (inert until the carve exists). Engine-free, own PCG32, `WorldScale`-denominated (no literal 251). Render-map only — the caller (`Tools/ErosionPass`) owns the split and the flood guard. |
+| `Scripts/DrainageAnalysis.cs` | ⭐⭐ **Drainage analysis** (chat2/12) — the reference's river-PLAN pass ported verbatim: priority-flood routing fill (one ulp above the parent, terrain never written), **D8 flow directions FOR ANALYSIS ONLY** (D8 was reverted as a carving technique), Kahn accumulation, outlets ranked by drainage area, endorheic terminals credited TOTAL inflow, promoted giants. Engine-free, `WorldScale`-denominated. "The sea" = the ocean body from `RegionLabeling.OceanMask`. |
| `Scripts/RegionLabeling.cs` | ⭐⭐ **The region-labeling layer** (chat2/07) — shared infrastructure. 8-connected land components on the CLASSIFY field; mainland = the centre component; per component id / size / centroid / hemisphere (by centroid) / isMainland. Pure, engine-free, C++-candidate; a **contract** downstream phases consume (islands first; biomes, placement, rivers, the crater later). The hemisphere convention lives here. |
| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |
diff --git a/Core/Scripts/DrainageAnalysis.cs b/Core/Scripts/DrainageAnalysis.cs
new file mode 100644
index 0000000..528ec21
--- /dev/null
+++ b/Core/Scripts/DrainageAnalysis.cs
@@ -0,0 +1,698 @@
+using System;
+using System.Collections.Generic;
+
+namespace IslaApocalypse.Core
+{
+ ///
+ /// ⭐⭐ DRAINAGE ANALYSIS — THE FAITHFUL PORT (chat2/12). From the reference's
+ /// Tools/Scripts/DrainageAnalysis.cs at tag pre-rewrite-reference (ab78883), verbatim
+ /// in arithmetic and order (D-050). PURE ANALYSIS: it reads the ERODED render heightmap and produces a
+ /// river PLAN — it changes zero terrain and adds zero water.
+ ///
+ /// ═══ WHAT THE PORT CHANGES (and nothing else) ═══
+ ///
+ /// • Namespace + location: IslaApocalypse.Core — engine-free, a C++ candidate.
+ /// • The yardstick: metres via (the same × 251f; no literal).
+ /// • and are EXPOSED (the reference kept the
+ /// routing surfaces local) so the caller can prove the routing-fill invariants on them.
+ ///
+ /// ═══ ⚠⚠ THE D8 LANDMINE ═══
+ ///
+ /// D8 flow direction is CORRECT FOR ANALYSIS and is used here for exactly that. It was REVERTED as a
+ /// CARVING technique (the prototype's task 10 — straight, grid-aligned grooves; → `Design - Terrain -
+ /// D8 Incision Revert`). Use D8 to COMPUTE, never to CARVE. Nothing in this file writes terrain.
+ ///
+ /// ═══ ⚠ ENDORHEIC BASINS ARE EXPECTED, FIRST-CLASS OUTPUT ═══
+ ///
+ /// This terrain's biggest drainages pool inland: erosion delivers the upland network only and cannot
+ /// cross the flats. A screen full of endorheic basins is the CORRECT result, not a bug.
+ ///
+ /// ═══ THE REFERENCE'S CLASS DOC (verbatim) ═══
+ ///
+ /// Drainage-network promotion — C0b part 1 (terrain-water task 21). PURE ANALYSIS:
+ /// reads the ERODED render heightmap and produces a river PLAN — it changes zero
+ /// terrain and adds zero water. Standalone numeric (D-035 family; no Godot types).
+ ///
+ /// Pipeline, built on the task-03 priority-flood family:
+ /// 1. Priority-flood the eroded surface from the map border (Barnes heap+pit
+ /// variant, 8-connected, same as RunPriorityFloodDiagnostics) — but with a
+ /// one-ulp epsilon on pit fills, so every filled cell keeps a STRICTLY
+ /// descending path to its spill. This resolves the ~15,000 erosion pits
+ /// (task-20 finding) for ROUTING ONLY; the terrain itself is never modified.
+ /// 2. Depressions that are deep AND large enough (the endorheic dials) are NOT
+ /// filled through: their cells revert to original heights, so flow entering
+ /// them terminates at the basin minimum. Real closed drainage survives;
+ /// micro-pits route through.
+ /// 3. D8 flow directions on that routing surface. D8 was reverted as a CARVING
+ /// technique (task 10 — grid-aligned scratches in the terrain); using it to
+ /// COMPUTE where water flows is standard hydrology and leaves no mark.
+ /// 4. Flow accumulation by topological (Kahn) propagation — no sort needed.
+ /// 5. Promotion: outlets to the sea ranked by drainage area, top-N (separated)
+ /// become trunks; main stems traced upstream by max-accumulation; the
+ /// mountain-exit point found from the along-stem grade; LEAN tributaries and
+ /// LEAN endorheic terminals marked.
+ ///
+ /// The plan's lowland courses are provisional: erosion delivered the UPLAND
+ /// network only (task 18 §3), so below each mountain-exit the traced course is
+ /// "where the routing surface drains", not a designed river. Part 2 (task 22)
+ /// routes the lowland reach properly from the mountain-exit points — which is why
+ /// those points are this analysis's key output.
+ ///
+ public static class DrainageAnalysis
+ {
+ // Neighbour order is FIXED (it is the deterministic tiebreak).
+ private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
+ private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
+ private static readonly float[] DIST = {
+ 1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
+
+ public const sbyte D_NONE = -1, D_SEA = -2;
+
+ public class Params
+ {
+ // Endorheic qualification: a depression this deep AND this large is a real
+ // closed basin and terminates flow; anything smaller is a pit, filled through.
+ public float EndorheicMinDepthM = 2.0f;
+ public int EndorheicMinAreaPx = 10000;
+ // Endorheic REPORTING is lean: only terminals with at least this much
+ // upstream drainage, at most MaxCount of them.
+ public int EndorheicMinInflowPx = 50000;
+ public int EndorheicMaxCount = 3;
+
+ public int TrunkCount = 3; // ~3 sea-reaching trunks (developer)
+ public int GiantCount = 3; // 21b: top endorheic giants promoted
+ public int MinOutletSeparationPx = 400; // don't pick 3 mouths of one delta
+
+ public int StemMinAccPx = 1000; // stem tracing stops below this
+ public int TributaryMinAccPx = 30000; // LEAN: a branch must drain this much
+ public int TributaryMaxPerTrunk = 4; // ...and only the top few are marked
+
+ // Mountain-exit: furthest-downstream stem point where the upstream window
+ // still sustains this grade (m per px) over ExitWindowPx.
+ public float ExitGradeMin = 0.05f;
+ public int ExitWindowPx = 100;
+
+ public float SeaLevel = 0.15f; // flat sea scalar (raw units)
+ }
+
+ public class Stream
+ {
+ public List<(float x, float y)> Course = new(); // downstream-first
+ public long DrainageAreaPx;
+ public (float x, float y) Head; // upstream end
+ }
+
+ public class Trunk : Stream
+ {
+ public (float x, float y) Outlet; // last land cell before sea
+ public (float x, float y) MountainExit;
+ public float MountainExitElevM;
+ public bool ExitFound;
+ public List Tributaries = new();
+ }
+
+ ///
+ /// A promoted endorheic giant (task 21b): one of the island's biggest drainage
+ /// systems, which pools inland because erosion could not cross the flats.
+ /// Kind "routed" carries a PROVISIONAL route across the flats to the ocean —
+ /// the path part 2 would carve, drawn for the gate, not water. Kind
+ /// "lake-ender" keeps its lake/lagoon terminal (real geography, developer's
+ /// call). Terminal is where the MAIN STEM actually pools (its sub-minimum),
+ /// which on a flat basin floor is more truthful than the basin's deepest cell.
+ /// ⚠ chat2/12 computes the provisional route as the reference did (it is analysis) but does NOT
+ /// promote or draw it — lowland routing is a later task.
+ ///
+ public class Giant : Stream
+ {
+ public (float x, float y) Terminal;
+ public (float x, float y) Spill; // where the basin overtops
+ public float BasinDepthM;
+ public long BasinAreaPx;
+ public string Kind = "routed"; // "routed" | "lake-ender"
+ public bool SouthernCandidate;
+ public bool TerminalInClassifyWater;
+ public List<(float x, float y)> ProvisionalRoute; // null for lake-enders
+ public bool RouteReachedOcean;
+ public (float x, float y) MountainExit;
+ public float MountainExitElevM;
+ public bool ExitFound;
+ public List Tributaries = new();
+ }
+
+ public class EndorheicTerminal
+ {
+ public (float x, float y) Terminal; // basin minimum
+ public long DrainageAreaPx;
+ public float BasinDepthM;
+ public long BasinAreaPx;
+ }
+
+ public class Plan
+ {
+ public List Trunks = new();
+ public List Endorheics = new();
+ public List Giants = new(); // 21b: the promoted giants
+ public int TerminalBasinCount; // basins that qualified as sinks
+ public long PitsFilledCount; // depressions filled through
+ public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
+ public List<(float x, float y, long acc)> AllOutletsTop = new(); // top 12, pre-separation
+ public Params P;
+
+ // ---- exposed by the port (the reference kept these local) ----
+ /// D8 direction per cell (row-major x·n+y): 0..7, , . Analysis only.
+ public sbyte[] Dir;
+ /// Flow accumulation per cell (row-major); 0 on ocean.
+ public int[] Acc;
+ /// The routing surface after terminal basins reverted (row-major).
+ public float[] Filled;
+ /// The FULL priority-flood fill, before terminal reversion (row-major) — every cell drains to the border on it.
+ public float[] FullFilled;
+ /// Terminal-basin id per cell (row-major), 0 = none.
+ public int[] BasinId;
+ /// Per terminal basin id: total inflow (cells whose flow ends there).
+ public long[] BasinInflow;
+ }
+
+ /// Row-major mask of THE OCEAN body — the only water that counts as "the sea" for
+ /// sea-reaching trunks (in v2: RegionLabeling.OceanMask, the classify field's border-connected water).
+ /// Below-sea cells that are NOT ocean (enclosed lagoons, below-datum lake beds, island-fringe waters) are
+ /// ordinary terrain to the router: as depressions they either qualify as terminal basins or fill and spill
+ /// onward to the true sea.
+ /// Row-major mask of ANY classify water: a giant whose main stem pools inside
+ /// classify water is a natural lake-ender; one pooling on dry ground is a route-to-sea candidate.
+ /// Southernmost-town position (or -1 for none): the giant whose terminal lies closest is
+ /// flagged the SOUTHERN CANDIDATE and always routed provisionally, per the 21b design — shown, not forced.
+ public static Plan Run(float[,] height, int mapSize, bool[] isOcean,
+ bool[] isClassifyWater, float southX, float southY, Params p)
+ {
+ int n = mapSize;
+ int total = n * n;
+ var plan = new Plan { P = p };
+
+ // 1-D row-major copies (idx = x * n + y), same convention as the task-03 pass.
+ float[] original = new float[total];
+ for (int x = 0; x < n; x++)
+ for (int y = 0; y < n; y++)
+ original[x * n + y] = height[x, y];
+
+ float[] plan_fullFilled = null; // set inside step 2, used by 21b routing
+
+ // --- 1. Priority-flood with one-ulp epsilon (routing surface only) ---
+ float[] filled = (float[])original.Clone();
+ {
+ bool[] visited = new bool[total];
+ var heap = new PriorityQueue();
+ var pit = new Queue();
+ void Seed(int idx)
+ {
+ if (visited[idx]) return;
+ visited[idx] = true;
+ heap.Enqueue(idx, (filled[idx], idx)); // idx tiebreak => deterministic
+ }
+ for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); }
+ for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); }
+
+ while (heap.Count > 0 || pit.Count > 0)
+ {
+ int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue();
+ float fc = filled[c];
+ int cx = c / n, cy = c % n;
+ for (int k = 0; k < 8; k++)
+ {
+ int nx = cx + DX[k], ny = cy + DY[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (visited[ni]) continue;
+ visited[ni] = true;
+ if (filled[ni] <= fc)
+ {
+ // One ulp above the parent: strictly descending back out, so
+ // D8 never meets an exact flat inside a filled pit.
+ filled[ni] = MathF.BitIncrement(fc);
+ pit.Enqueue(ni);
+ }
+ else heap.Enqueue(ni, (filled[ni], ni));
+ }
+ }
+ }
+
+ // --- 2. Depression components; big+deep ones become terminal sinks ---
+ // Components of (filled > original), 8-connected — the pools. Qualifying
+ // pools revert to ORIGINAL height so flow terminates at their minimum.
+ int[] basinId = new int[total]; // 0 = not in a pool
+ var basinDepthM = new List { 0f };
+ var basinAreaPx = new List { 0L };
+ var basinMinCell = new List { -1 };
+ {
+ var stack = new Stack();
+ int nextId = 1;
+ for (int i = 0; i < total; i++)
+ {
+ if (basinId[i] != 0 || filled[i] <= original[i]) continue;
+ int id = nextId++;
+ long area = 0; float depth = 0f; int minCell = i; float minH = original[i];
+ stack.Push(i); basinId[i] = id;
+ while (stack.Count > 0)
+ {
+ int c = stack.Pop();
+ area++;
+ float d = WorldScale.MetresFromRaw(filled[c] - original[c]);
+ if (d > depth) depth = d;
+ if (original[c] < minH) { minH = original[c]; minCell = c; }
+ int cx = c / n, cy = c % n;
+ for (int k = 0; k < 8; k++)
+ {
+ int nx = cx + DX[k], ny = cy + DY[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (basinId[ni] == 0 && filled[ni] > original[ni])
+ { basinId[ni] = id; stack.Push(ni); }
+ }
+ }
+ basinDepthM.Add(depth); basinAreaPx.Add(area); basinMinCell.Add(minCell);
+ }
+
+ // 21b: the FULL fill (before terminal reversion) is the provisional-
+ // routing surface — on it, every basin overtops at its spill and drains
+ // to the border, which is exactly "where the water would continue".
+ plan_fullFilled = (float[])filled.Clone();
+
+ bool[] terminal = new bool[nextId];
+ for (int id = 1; id < nextId; id++)
+ {
+ if (basinDepthM[id] >= p.EndorheicMinDepthM && basinAreaPx[id] >= p.EndorheicMinAreaPx)
+ { terminal[id] = true; plan.TerminalBasinCount++; }
+ else plan.PitsFilledCount++;
+ }
+ // Revert terminal pools to the real surface; re-tag basinId to keep only
+ // terminal pools (routing needs to know "am I in a terminal basin").
+ for (int i = 0; i < total; i++)
+ {
+ if (basinId[i] == 0) continue;
+ if (terminal[basinId[i]]) filled[i] = original[i];
+ else basinId[i] = 0;
+ }
+ }
+
+ // --- 3. D8 flow directions on the routing surface ---
+ // dir[i] = 0..7 neighbour, SEA (into a below-sea cell), or NONE (sink).
+ sbyte[] dir = new sbyte[total];
+ bool IsSea(int idx) => isOcean[idx];
+ for (int i = 0; i < total; i++)
+ {
+ if (IsSea(i)) { dir[i] = D_NONE; continue; }
+ int cx = i / n, cy = i % n;
+ float best = 0f; int bestK = -1; bool bestIsSea = false;
+ for (int k = 0; k < 8; k++)
+ {
+ int nx = cx + DX[k], ny = cy + DY[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ float drop = (filled[i] - filled[ni]) / DIST[k];
+ if (drop > best) { best = drop; bestK = k; bestIsSea = IsSea(ni); }
+ }
+ dir[i] = bestK < 0 ? D_NONE : (bestIsSea ? D_SEA : (sbyte)bestK);
+ }
+
+ // --- 4. Flow accumulation (Kahn topological propagation) ---
+ int Target(int i)
+ {
+ if (dir[i] < 0) return -1;
+ int cx = i / n, cy = i % n;
+ return (cx + DX[dir[i]]) * n + (cy + DY[dir[i]]);
+ }
+ int[] acc = new int[total];
+ {
+ byte[] indeg = new byte[total];
+ for (int i = 0; i < total; i++)
+ if (dir[i] >= 0) indeg[Target(i)]++;
+ var q = new Queue();
+ for (int i = 0; i < total; i++)
+ {
+ if (IsSea(i)) continue;
+ acc[i] = 1;
+ if (indeg[i] == 0) q.Enqueue(i);
+ }
+ while (q.Count > 0)
+ {
+ int c = q.Dequeue();
+ if (dir[c] < 0) continue;
+ int t = Target(c);
+ acc[t] += acc[c];
+ if (--indeg[t] == 0 && !IsSea(t)) q.Enqueue(t);
+ }
+ }
+
+ // Bookkeeping: where does each cell's flow END — the sea, WHICH terminal
+ // basin, or stuck? Memoised downstream walk. The per-basin totals matter:
+ // crediting a terminal basin only with acc at its deepest cell undercounts
+ // badly when the basin floor is flat (a lagoon bed scatters inflow across
+ // many sub-minima — measured: a 500k-px lagoon system reported under 50k).
+ long[] basinInflow = new long[basinMinCell.Count];
+ int[] dest = new int[total]; // 0 unknown, -1 sea, -2 stuck, >0 basin id
+ {
+ var path = new List(4096);
+ for (int i = 0; i < total; i++)
+ {
+ if (IsSea(i) || dest[i] != 0) continue;
+ int c = i; path.Clear();
+ int result;
+ while (true)
+ {
+ if (dest[c] != 0) { result = dest[c]; break; }
+ path.Add(c);
+ if (dir[c] == D_SEA) { result = -1; break; }
+ if (dir[c] == D_NONE) { result = basinId[c] != 0 ? basinId[c] : -2; break; }
+ c = Target(c);
+ }
+ foreach (int pc in path) dest[pc] = result;
+ }
+ for (int i = 0; i < total; i++)
+ {
+ if (IsSea(i)) continue;
+ plan.LandCells++;
+ if (dest[i] == -1) plan.SeaReachingCells++;
+ else if (dest[i] > 0) { plan.EndorheicCells++; basinInflow[dest[i]]++; }
+ else plan.UnroutedCells++;
+ }
+ }
+
+ // --- 5a. Outlets: land cells whose flow enters the sea, ranked by acc ---
+ var outlets = new List<(int cell, long acc)>();
+ for (int i = 0; i < total; i++)
+ if (dir[i] == D_SEA) outlets.Add((i, acc[i]));
+ outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
+
+ foreach (var (cell, a) in outlets.GetRange(0, Math.Min(12, outlets.Count)))
+ plan.AllOutletsTop.Add((cell / n, cell % n, a));
+
+ // Greedy top-N with separation, so three mouths of one delta can't take
+ // all three trunk slots.
+ var picked = new List();
+ foreach (var (cell, _) in outlets)
+ {
+ if (picked.Count >= p.TrunkCount) break;
+ int cx = cell / n, cy = cell % n;
+ bool far = true;
+ foreach (int pcell in picked)
+ {
+ float ddx = cx - pcell / n, ddy = cy - pcell % n;
+ if (ddx * ddx + ddy * ddy < (float)p.MinOutletSeparationPx * p.MinOutletSeparationPx)
+ { far = false; break; }
+ }
+ if (far) picked.Add(cell);
+ }
+
+ // upstream max-acc walk shared by trunks and tributaries
+ List TraceStem(int fromCell, int minAcc)
+ {
+ var stem = new List { fromCell };
+ int c = fromCell;
+ while (true)
+ {
+ int cx = c / n, cy = c % n;
+ int bestN = -1; long bestA = minAcc - 1;
+ for (int k = 0; k < 8; k++)
+ {
+ int nx = cx + DX[k], ny = cy + DY[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (dir[ni] >= 0 && Target(ni) == c && acc[ni] > bestA)
+ { bestA = acc[ni]; bestN = ni; }
+ }
+ if (bestN < 0) break;
+ stem.Add(bestN);
+ c = bestN;
+ }
+ return stem;
+ }
+
+ List<(float x, float y)> Decimate(List cells, int step = 4)
+ {
+ var pts = new List<(float, float)>();
+ for (int i = 0; i < cells.Count; i += step)
+ pts.Add((cells[i] / n, cells[i] % n));
+ if ((cells.Count - 1) % step != 0)
+ pts.Add((cells[^1] / n, cells[^1] % n));
+ return pts;
+ }
+
+ // --- 5b. Trunks: stems, mountain exits, LEAN tributaries ---
+ foreach (int outletCell in picked)
+ {
+ var t = new Trunk
+ {
+ Outlet = (outletCell / n, outletCell % n),
+ DrainageAreaPx = acc[outletCell]
+ };
+ var stem = TraceStem(outletCell, p.StemMinAccPx);
+ t.Course = Decimate(stem);
+ t.Head = (stem[^1] / n, stem[^1] % n);
+
+ // Mountain-exit: walk the stem downstream-first; the exit is the
+ // furthest-DOWNSTREAM point whose upstream window still sustains the
+ // grade — i.e. where the mountains hand the river to the flats.
+ // Elevation truth is the ORIGINAL eroded surface, not the fill.
+ int w = p.ExitWindowPx;
+ for (int i = 0; i + w < stem.Count; i++)
+ {
+ float rise = WorldScale.MetresFromRaw(original[stem[i + w]] - original[stem[i]]);
+ if (rise / w >= p.ExitGradeMin)
+ {
+ t.ExitFound = true;
+ t.MountainExit = (stem[i] / n, stem[i] % n);
+ t.MountainExitElevM = WorldScale.MetresFromRaw(original[stem[i]]);
+ break;
+ }
+ }
+
+ // LEAN tributaries: junction branches off the stem with enough drainage,
+ // top few by accumulation.
+ var stemSet = new HashSet(stem);
+ var cands = new List<(int cell, long acc)>();
+ foreach (int sc in stem)
+ {
+ int cx = sc / n, cy = sc % n;
+ for (int k = 0; k < 8; k++)
+ {
+ int nx = cx + DX[k], ny = cy + DY[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (stemSet.Contains(ni)) continue;
+ if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
+ cands.Add((ni, acc[ni]));
+ }
+ }
+ cands.Sort((a, b) => b.acc.CompareTo(a.acc));
+ // Dedup: two inflow neighbours at adjacent stem cells are one confluence,
+ // not two tributaries — keep only junctions ≥ 30 px apart.
+ var taken = new List();
+ foreach (var (cell, a) in cands)
+ {
+ if (taken.Count >= p.TributaryMaxPerTrunk) break;
+ int cx2 = cell / n, cy2 = cell % n;
+ bool dup = false;
+ foreach (int tc in taken)
+ {
+ float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
+ if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
+ }
+ if (!dup) taken.Add(cell);
+ }
+ foreach (int cell in taken)
+ {
+ long a = acc[cell];
+ var trib = new Stream { DrainageAreaPx = a };
+ var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(a / 20)));
+ trib.Course = Decimate(ts);
+ trib.Head = (ts[^1] / n, ts[^1] % n);
+ t.Tributaries.Add(trib);
+ }
+ plan.Trunks.Add(t);
+ }
+
+ // --- 5c. LEAN endorheic terminals: terminal basins ranked by TOTAL inflow ---
+ {
+ var terms = new List<(int id, long inflow)>();
+ for (int id = 1; id < basinMinCell.Count; id++)
+ {
+ int mc = basinMinCell[id];
+ if (mc < 0 || basinId[mc] != id) continue; // not a terminal basin
+ if (basinInflow[id] >= p.EndorheicMinInflowPx) terms.Add((id, basinInflow[id]));
+ }
+ terms.Sort((a, b) => b.inflow.CompareTo(a.inflow));
+ foreach (var (id, inflow) in terms.GetRange(0, Math.Min(p.EndorheicMaxCount, terms.Count)))
+ {
+ int mc = basinMinCell[id];
+ plan.Endorheics.Add(new EndorheicTerminal
+ {
+ Terminal = (mc / n, mc % n),
+ DrainageAreaPx = inflow,
+ BasinDepthM = basinDepthM[id],
+ BasinAreaPx = basinAreaPx[id]
+ });
+ }
+ }
+
+ // --- 5d. The promoted GIANTS (21b): mixed set, provisional routes ---
+ // Top GiantCount terminal basins by TOTAL inflow. Their upland stems are the
+ // island's real big rivers; whether each continues to the sea is the gate's
+ // decision, previewed here.
+ {
+ var giantsRanked = new List<(int id, long inflow)>();
+ for (int id = 1; id < basinMinCell.Count; id++)
+ {
+ int mc = basinMinCell[id];
+ if (mc < 0 || basinId[mc] != id) continue;
+ if (basinInflow[id] >= p.EndorheicMinInflowPx) giantsRanked.Add((id, basinInflow[id]));
+ }
+ giantsRanked.Sort((a, b) => b.inflow.CompareTo(a.inflow));
+
+ // Does a terminal basin HOLD classify water? The lake-ender test must look
+ // at the whole pool, not the stem's single pooling cell — a stem can pool on
+ // dry ground a few hundred px short of its lagoon and still be a lagoon river.
+ bool[] basinHasLake = new bool[basinMinCell.Count];
+ for (int i = 0; i < total; i++)
+ if (basinId[i] != 0 && isClassifyWater[i] && !isOcean[i])
+ basinHasLake[basinId[i]] = true;
+
+ // The main stem's ENTRY into the basin: the highest-accumulation cell
+ // whose flow terminates in this basin. On a flat basin floor the deepest
+ // cell sees only local trickles (the task-21 lesson), so the stem is
+ // anchored on the strongest feeder instead.
+ var bestEntry = new Dictionary();
+ for (int i = 0; i < total; i++)
+ {
+ if (dest[i] <= 0) continue;
+ if (!bestEntry.TryGetValue(dest[i], out int cur) || acc[i] > acc[cur])
+ bestEntry[dest[i]] = i;
+ }
+
+ // The giant whose pooling point sits closest to the southernmost town is
+ // the SOUTHERN CANDIDATE — always routed provisionally (shown, not forced).
+ int southernPick = -1;
+ if (southX >= 0f)
+ {
+ float bestD = float.MaxValue;
+ foreach (var (id, _) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count)))
+ {
+ int mc = basinMinCell[id];
+ float ddx = mc / n - southX, ddy = mc % n - southY;
+ float d2 = ddx * ddx + ddy * ddy;
+ if (d2 < bestD) { bestD = d2; southernPick = id; }
+ }
+ }
+
+ foreach (var (id, inflow) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count)))
+ {
+ var g = new Giant { DrainageAreaPx = inflow, BasinDepthM = basinDepthM[id], BasinAreaPx = basinAreaPx[id] };
+ if (!bestEntry.TryGetValue(id, out int entry)) entry = basinMinCell[id];
+
+ // Downstream from the strongest feeder to where it actually pools…
+ int t2 = entry;
+ var down = new List { t2 };
+ while (dir[t2] >= 0) { t2 = Target(t2); down.Add(t2); }
+ g.Terminal = (t2 / n, t2 % n);
+ // …then the full main stem, traced upstream from that pooling point.
+ var stem = TraceStem(t2, p.StemMinAccPx);
+ g.Course = Decimate(stem);
+ g.Head = (stem[^1] / n, stem[^1] % n);
+ g.TerminalInClassifyWater = isClassifyWater[t2];
+
+ for (int i = 0; i + p.ExitWindowPx < stem.Count; i++)
+ {
+ float rise = WorldScale.MetresFromRaw(original[stem[i + p.ExitWindowPx]] - original[stem[i]]);
+ if (rise / p.ExitWindowPx >= p.ExitGradeMin)
+ {
+ g.ExitFound = true;
+ g.MountainExit = (stem[i] / n, stem[i] % n);
+ g.MountainExitElevM = WorldScale.MetresFromRaw(original[stem[i]]);
+ break;
+ }
+ }
+
+ // Lean tributaries on the giant's stem, same junction rule as trunks.
+ var stemSet = new HashSet(stem);
+ var cands = new List<(int cell, long acc)>();
+ foreach (int sc in stem)
+ {
+ int cx = sc / n, cy = sc % n;
+ for (int k = 0; k < 8; k++)
+ {
+ int nx = cx + DX[k], ny = cy + DY[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (stemSet.Contains(ni)) continue;
+ if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
+ cands.Add((ni, acc[ni]));
+ }
+ }
+ cands.Sort((a, b) => b.acc.CompareTo(a.acc));
+ var takenT = new List();
+ foreach (var (cell, _) in cands)
+ {
+ if (takenT.Count >= p.TributaryMaxPerTrunk) break;
+ int cx2 = cell / n, cy2 = cell % n;
+ bool dup = false;
+ foreach (int tc in takenT)
+ {
+ float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
+ if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
+ }
+ if (!dup) takenT.Add(cell);
+ }
+ foreach (int cell in takenT)
+ {
+ var trib = new Stream { DrainageAreaPx = acc[cell] };
+ var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(acc[cell] / 20)));
+ trib.Course = Decimate(ts);
+ trib.Head = (ts[^1] / n, ts[^1] % n);
+ g.Tributaries.Add(trib);
+ }
+
+ // Kind: the terminal BASIN holds a classify lake → natural lake-ender;
+ // dry pan → route to sea; the southern candidate is always routed.
+ g.SouthernCandidate = id == southernPick;
+ g.TerminalInClassifyWater = g.TerminalInClassifyWater || basinHasLake[id];
+ g.Kind = (basinHasLake[id] && !g.SouthernCandidate) ? "lake-ender" : "routed";
+
+ // PROVISIONAL route (routed giants): walk steepest descent on the FULL
+ // fill from the pooling point — the basin overtops at its spill and
+ // the walk continues along the terrain's own drainage to the ocean.
+ // DRAWN, not carved; part 2 carves along a route like this one.
+ // (chat2/12: computed as the reference did; not drawn, not promoted — routing is later.)
+ if (g.Kind == "routed")
+ {
+ var route = new List();
+ int c = t2;
+ bool spillRecorded = false;
+ for (int guard = 0; guard < 4 * n; guard++)
+ {
+ route.Add(c);
+ if (isOcean[c]) { g.RouteReachedOcean = true; break; }
+ if (!spillRecorded && basinId[c] != id)
+ { g.Spill = (c / n, c % n); spillRecorded = true; }
+ int cx = c / n, cy = c % n;
+ float best = float.MaxValue; int bestN = -1;
+ for (int k = 0; k < 8; k++)
+ {
+ int nx = cx + DX[k], ny = cy + DY[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (plan_fullFilled[ni] < best) { best = plan_fullFilled[ni]; bestN = ni; }
+ }
+ if (bestN < 0 || plan_fullFilled[bestN] >= plan_fullFilled[c]) break; // stuck (report via flag)
+ c = bestN;
+ }
+ g.ProvisionalRoute = Decimate(route);
+ }
+ plan.Giants.Add(g);
+ }
+ }
+
+ plan.Dir = dir; plan.Acc = acc; plan.Filled = filled; plan.FullFilled = plan_fullFilled;
+ plan.BasinId = basinId; plan.BasinInflow = basinInflow;
+ return plan;
+ }
+ }
+}
diff --git a/Core/Scripts/DrainageAnalysis.cs.uid b/Core/Scripts/DrainageAnalysis.cs.uid
new file mode 100644
index 0000000..645e1ca
--- /dev/null
+++ b/Core/Scripts/DrainageAnalysis.cs.uid
@@ -0,0 +1 @@
+uid://c6yfvp8p5br07
diff --git a/Core/Scripts/RegionLabeling.cs b/Core/Scripts/RegionLabeling.cs
index 15467f9..458c15a 100644
--- a/Core/Scripts/RegionLabeling.cs
+++ b/Core/Scripts/RegionLabeling.cs
@@ -214,6 +214,50 @@ namespace IslaApocalypse.Core
: bin < HistogramEdges.Length ? $"{HistogramEdges[bin - 1]}–{HistogramEdges[bin] - 1}"
: $"≥{HistogramEdges[^1]}";
+ // ═══ chat2/12 — THE OCEAN IDENTITY (the water-side complement of the land contract) ═══
+ //
+ // Water is 4-CONNECTED (the deliberate complement of land's 8 — a diagonal isthmus joins land and
+ // separates the water either side). THE OCEAN = the 4-connected water component that touches the
+ // map border (the Trench guarantees the border is water, so the corner is a safe seed). Every
+ // other below-sea cell — enclosed lagoons, lake beds, island-fringe pockets — is NOT ocean: to the
+ // drainage router it is ordinary terrain (a terminal basin or a fill-and-spill), and to a
+ // "sea-reaching" test it does not count as the sea. Computed on the CLASSIFY field (authoritative).
+
+ ///
+ /// The ocean mask, row-major (x·n+y): true for every below-sea cell 4-connected to the map
+ /// border. Pure: reads , writes nothing.
+ ///
+ public static bool[] OceanMask(float[,] classify, int mapSize, float sea, out long oceanCells, out long enclosedWaterCells)
+ {
+ int n = mapSize;
+ var ocean = new bool[n * n];
+ var q = new Queue();
+ void Seed(int x, int y) { if (classify[x, y] < sea && !ocean[x * n + y]) { ocean[x * n + y] = true; q.Enqueue(x * n + y); } }
+ for (int x = 0; x < n; x++) { Seed(x, 0); Seed(x, n - 1); }
+ for (int y = 0; y < n; y++) { Seed(0, y); Seed(n - 1, y); }
+ int[] dx4 = { -1, 1, 0, 0 }, dy4 = { 0, 0, -1, 1 };
+ while (q.Count > 0)
+ {
+ int c = q.Dequeue(); int cx = c / n, cy = c % n;
+ for (int k = 0; k < 4; k++)
+ {
+ int nx = cx + dx4[k], ny = cy + dy4[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (ocean[ni] || classify[nx, ny] >= sea) continue;
+ ocean[ni] = true; q.Enqueue(ni);
+ }
+ }
+ oceanCells = 0; enclosedWaterCells = 0;
+ for (int x = 0; x < n; x++)
+ for (int y = 0; y < n; y++)
+ {
+ if (classify[x, y] >= sea) continue;
+ if (ocean[x * n + y]) oceanCells++; else enclosedWaterCells++;
+ }
+ return ocean;
+ }
+
/// Island counts per hemisphere (non-mainland components, by centroid).
public static (int north, int south) IslandsByHemisphere(RegionLabels labels)
{
diff --git a/Tools/README.md b/Tools/README.md
index fc8c5a6..ae92b7a 100644
--- a/Tools/README.md
+++ b/Tools/README.md
@@ -46,6 +46,8 @@ constants, carried over verbatim — not re-derived from a design summary** (→
| `Scripts/FragGalleryTool.cs` + `Scenes/FragGalleryTool.tscn` | The chat2/10 gallery — render-only: 09's `frag_4` frozen across 2 anchors + 6 fresh seeds at 8192, with the count/size table |
| `Scripts/ErosionPass.cs` | ⭐ **Pass 2b** (chat2/11) — the erosion caller: render field only (copied if aliased), governors clamped as the reference's ConfigManager did, the crater exclusion passed through INERT, and the **flood guard** (render water pixels before/after; any change throws) |
| `Scripts/ErosionTool.cs` + `Scenes/ErosionTool.tscn` | The chat2/11 batch — 4 gallery seeds × erosion off/on at 8192, the mid-slope crop, the erosion stats table; also `TerrainShapeV1` — the locked shape's values pinned once |
+| `Scripts/DrainageRenderer.cs` | The drainage maps (chat2/12): log-scaled accumulation; the promoted-candidates overlay (trunks cyan, endorheic giants orange, lean terminals red) |
+| `Scripts/DrainageTool.cs` + `Scenes/DrainageTool.tscn` | The chat2/12 batch — `DrainageAnalysis` on the eroded 8192 fields of 4 task-11 seeds, analysis-only oracle (terrain bit-identical, no water, fill invariants, determinism, ocean from the region layer) |
| `Scripts/OffshoreIslandsTool.cs` + `Scenes/OffshoreIslandsTool.tscn` | The offshore batch — chat2/06: 4 plates + the count table + the diagnosis (the chat2/05 version is at `3b96e06`) |
| `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** |
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
diff --git a/Tools/Scenes/DrainageTool.tscn b/Tools/Scenes/DrainageTool.tscn
new file mode 100644
index 0000000..040b32b
--- /dev/null
+++ b/Tools/Scenes/DrainageTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://cdrainage12isla"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/DrainageTool.cs" id="1_drt"]
+
+[node name="DrainageTool" type="Node"]
+script = ExtResource("1_drt")
diff --git a/Tools/Scripts/DrainageRenderer.cs b/Tools/Scripts/DrainageRenderer.cs
new file mode 100644
index 0000000..d117d4f
--- /dev/null
+++ b/Tools/Scripts/DrainageRenderer.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// THE DRAINAGE MAPS (chat2/12) — presentation only, for eyeballing that the flow is sane:
+ ///
+ /// • the LOG-SCALED ACCUMULATION map — drainage spans orders of magnitude, so log(1+acc) over land;
+ /// the dendritic uplands and the trunks read as bright channels on dark hillslopes; the ocean is a
+ /// flat dark blue and enclosed (non-ocean) water a dark teal, so the ocean identity is visible too;
+ /// • the PROMOTED-CANDIDATES overlay — a faint grey terrain, the sea-reaching trunks in cyan (outlet
+ /// square, mountain-exit white ring, lean tributaries thin), the endorheic giants in orange (pooling
+ /// terminal disc, lean tributaries thin), the lean endorheic terminals as red rings. Provisional
+ /// routes are NOT drawn (routing is a later task). Nothing here touches data.
+ ///
+ public static class DrainageRenderer
+ {
+ private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f);
+ private static readonly Color Enclosed = new(0.060f, 0.220f, 0.230f);
+ private static readonly Color Trunk = new(0.250f, 0.900f, 1.000f);
+ private static readonly Color Giant = new(1.000f, 0.600f, 0.150f);
+ private static readonly Color Endo = new(1.000f, 0.250f, 0.250f);
+ private static readonly Color Exit = new(1.000f, 1.000f, 1.000f);
+ private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
+
+ /// log(1 + acc) / log(1 + max) over land; ocean / enclosed water flat.
+ public static Image Accumulation(int[] acc, bool[] isOcean, float[,] render, int n, float sea)
+ {
+ long max = 1;
+ for (int i = 0; i < acc.Length; i++) if (acc[i] > max) max = acc[i];
+ double lmax = Math.Log(1.0 + max);
+ var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
+ for (int x = 0; x < n; x++)
+ for (int y = 0; y < n; y++)
+ {
+ int i = x * n + y;
+ if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
+ if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
+ float v = (float)(Math.Log(1.0 + acc[i]) / lmax);
+ // a dark-to-bright ramp with a cool tint in the channels
+ float r = 0.06f + 0.94f * v * v, g = 0.08f + 0.92f * v, b = 0.12f + 0.88f * MathF.Sqrt(v);
+ img.SetPixel(x, y, new Color(MathF.Min(1f, r), MathF.Min(1f, g), MathF.Min(1f, b)));
+ }
+ return img;
+ }
+
+ /// The candidates over a faint terrain.
+ public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
+ {
+ var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
+ float span = MathF.Max(1e-6f, hMax - sea);
+ for (int x = 0; x < n; x++)
+ for (int y = 0; y < n; y++)
+ {
+ int i = x * n + y;
+ if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
+ if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
+ float t = MathF.Min(1f, (render[x, y] - sea) / span);
+ float g = 0.30f + 0.45f * MathF.Sqrt(t);
+ img.SetPixel(x, y, new Color(g, g, g * 0.96f));
+ }
+
+ int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
+ foreach (var g in plan.Giants)
+ {
+ foreach (var tr in g.Tributaries) Polyline(img, tr.Course, n, Giant, thin);
+ Polyline(img, g.Course, n, Giant, thick);
+ Disc(img, (int)g.Terminal.x, (int)g.Terminal.y, mark, n, Giant);
+ Ring(img, (int)g.Terminal.x, (int)g.Terminal.y, mark + 8, n, Ink, 3);
+ if (g.ExitFound) Ring(img, (int)g.MountainExit.x, (int)g.MountainExit.y, mark, n, Exit, 4);
+ }
+ foreach (var t in plan.Trunks)
+ {
+ foreach (var tr in t.Tributaries) Polyline(img, tr.Course, n, Trunk, thin);
+ Polyline(img, t.Course, n, Trunk, thick);
+ Square(img, (int)t.Outlet.x, (int)t.Outlet.y, mark, n, Trunk);
+ if (t.ExitFound) Ring(img, (int)t.MountainExit.x, (int)t.MountainExit.y, mark, n, Exit, 4);
+ }
+ foreach (var e in plan.Endorheics)
+ Ring(img, (int)e.Terminal.x, (int)e.Terminal.y, mark + 4, n, Endo, 4);
+
+ int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
+ TinyFont.Draw(img, title, 12, 12, s, Ink);
+ TinyFont.Draw(img, $"CYAN: SEA-REACHING TRUNKS ({plan.Trunks.Count}) - SQUARE = OUTLET WHITE RING = MOUNTAIN EXIT", 12, 12 + lh, s, Ink);
+ TinyFont.Draw(img, $"ORANGE: ENDORHEIC GIANTS ({plan.Giants.Count}) - DISC = POOLING TERMINAL (EXPECTED, NOT AN ERROR)", 12, 12 + lh * 2, s, Ink);
+ TinyFont.Draw(img, $"RED RING: LEAN ENDORHEIC TERMINALS ({plan.Endorheics.Count}) THIN LINES: LEAN TRIBUTARIES NOTHING CARVED - ANALYSIS ONLY", 12, 12 + lh * 3, s, Ink);
+ return img;
+ }
+
+ private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
+ {
+ for (int i = 1; i < pts.Count; i++)
+ Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick);
+ }
+
+ private static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
+ {
+ int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
+ int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
+ int err = dx + dy; int r = thick / 2;
+ int guard = 0;
+ while (true)
+ {
+ for (int ox = -r; ox <= r; ox++)
+ for (int oy = -r; oy <= r; oy++)
+ {
+ int px = x0 + ox, py = y0 + oy;
+ if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
+ }
+ if (x0 == x1 && y0 == y1) break;
+ if (++guard > 4 * n) break;
+ int e2 = 2 * err;
+ if (e2 >= dy) { err += dy; x0 += sx; }
+ if (e2 <= dx) { err += dx; y0 += sy; }
+ }
+ }
+
+ private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
+ {
+ for (int ox = -r; ox <= r; ox++)
+ for (int oy = -r; oy <= r; oy++)
+ {
+ if (ox * ox + oy * oy > r * r) continue;
+ int px = cx + ox, py = cy + oy;
+ if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
+ }
+ }
+
+ private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
+ {
+ for (int ox = -r; ox <= r; ox++)
+ for (int oy = -r; oy <= r; oy++)
+ {
+ int d2 = ox * ox + oy * oy;
+ if (d2 > r * r || d2 < (r - w) * (r - w)) continue;
+ int px = cx + ox, py = cy + oy;
+ if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
+ }
+ }
+
+ private static void Square(Image img, int cx, int cy, int r, int n, Color c)
+ {
+ for (int ox = -r; ox <= r; ox++)
+ for (int oy = -r; oy <= r; oy++)
+ {
+ int px = cx + ox, py = cy + oy;
+ if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
+ }
+ }
+ }
+}
diff --git a/Tools/Scripts/DrainageRenderer.cs.uid b/Tools/Scripts/DrainageRenderer.cs.uid
new file mode 100644
index 0000000..137b607
--- /dev/null
+++ b/Tools/Scripts/DrainageRenderer.cs.uid
@@ -0,0 +1 @@
+uid://dp11721l40ixx
diff --git a/Tools/Scripts/DrainageTool.cs b/Tools/Scripts/DrainageTool.cs
new file mode 100644
index 0000000..fceb5d9
--- /dev/null
+++ b/Tools/Scripts/DrainageTool.cs
@@ -0,0 +1,410 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ THE DRAINAGE-ANALYSIS BATCH (chat2/12) — minimal-first: is the flow sane before rivers are built
+ /// on it? Runs (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
+ ///
+ 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();
+ var perSeed = new List();
+ var rows = new List();
+
+ 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.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;
+ }
+
+ /// The reference's two routing-fill diagnostics: filled ≥ original everywhere; every cell has a non-ascending path to the border on the full fill.
+ 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(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();
+ 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 rows, DrainageAnalysis.Params dp,
+ List hard, List 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("
", 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("
", 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();
+ 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;
+ }
+ }
+}
diff --git a/Tools/Scripts/DrainageTool.cs.uid b/Tools/Scripts/DrainageTool.cs.uid
new file mode 100644
index 0000000..b8075db
--- /dev/null
+++ b/Tools/Scripts/DrainageTool.cs.uid
@@ -0,0 +1 @@
+uid://becalqn1wrw4k
diff --git a/Tools/Scripts/ShadeRenderer.cs.uid b/Tools/Scripts/ShadeRenderer.cs.uid
new file mode 100644
index 0000000..60207a0
--- /dev/null
+++ b/Tools/Scripts/ShadeRenderer.cs.uid
@@ -0,0 +1 @@
+uid://4k3liidno15g