diff --git a/Core/Scripts/BasinGraph.cs b/Core/Scripts/BasinGraph.cs
new file mode 100644
index 0000000..9afd970
--- /dev/null
+++ b/Core/Scripts/BasinGraph.cs
@@ -0,0 +1,478 @@
+using System;
+using System.Collections.Generic;
+
+namespace IslaApocalypse.Core
+{
+ /// Where a terminal basin's spill drains next.
+ public enum DownstreamKind : byte
+ {
+ /// The spill walk found no strictly-lower neighbour before reaching anything — a genuinely closed sink (or an exact float flat).
+ None = 0,
+ /// The spill drains into RegionLabeling.OceanMask — the sea, on the CLASSIFY field.
+ Ocean = 1,
+ /// The spill drains into another terminal basin's cells ().
+ Basin = 2,
+ }
+
+ ///
+ /// ⭐⭐ ONE NODE OF THE BASIN GRAPH (rivers/04) — a terminal basin of ,
+ /// enriched with the three things the analysis left latent: its SPILL, its LAKE-IDENTITY, and its
+ /// DOWNSTREAM EDGE. Pure data. Nothing here is a height write or a water fill.
+ ///
+ /// ═══ ⚠⚠ D-046 — WHICH SURFACE EACH FIELD IS READ ON ═══
+ ///
+ /// RENDER / FLOW surface (Plan.FullFilled, the priority-flood of the eroded render height)
+ /// , , ,
+ /// , walk — everything about WHERE WATER GOES.
+ /// This is the surface RiverRouting.RouteTo routes on, so a spill height and a rim climb are
+ /// the same kind of number the router already measures.
+ ///
+ /// CLASSIFY / WATER surface (isClassifyWater = classify < sea; OceanMask)
+ /// , , , and the OCEAN terminus
+ /// of the downstream walk — everything about WHAT IS VISIBLY WATER. This is the surface the
+ /// router's terminus tests already use.
+ ///
+ /// ⛔ No field compares a classify height to a render height. The two surfaces meet only as
+ /// MEMBERSHIP (is this basin cell classify-water? is this walk cell ocean?), which is exactly the
+ /// split the routing already lives by (route on render, `OceanMask` on classify). No new seam.
+ ///
+ public sealed class BasinNode
+ {
+ /// The terminal-basin id, as Plan.BasinId carries it (sparse: pits that filled through gave up their ids).
+ public int Id;
+
+ /// Cells with BasinId == Id.
+ public long AreaPx;
+
+ /// Plan.BasinInflow[Id] — cells whose flow terminates here (the promotion metric).
+ public long InflowPx;
+
+ /// The basin's deepest cell on the RENDER height (first in scan order on ties), and its height.
+ public int FloorCell;
+ public float FloorHeightRaw;
+
+ ///
+ /// The basin's ENTRY cell: its minimum on FullFilled. The priority-flood raises the first cell
+ /// it steps into from the spill to exactly one ulp above the spill, so this is spill + 1 ulp.
+ ///
+ public int EntryCell;
+ public float EntryFullFilledRaw;
+
+ ///
+ /// ⭐⭐ THE SPILL — the lowest cell on the basin's 8-neighbour boundary, read on FullFilled
+ /// (== the render height there — asserted, see ). This is the rim cell
+ /// water would overtop. Ties (same height) resolve to the lowest cell index;
+ /// says how many boundary cells sit at exactly this height.
+ ///
+ public int SpillCell;
+ public float SpillHeightRaw;
+ public int SpillTies;
+
+ /// ⭐ Cross-check (a) vs (b): BitDecrement(EntryFullFilledRaw) == SpillHeightRaw. The uniform-fill-level reading and the rim-walk reading must agree exactly.
+ public bool SpillCrossCheckOk;
+ /// ⭐ The spill cell is real terrain: FullFilled[spill] == render[spill] (it was never raised by the flood).
+ public bool SpillOnTerrain;
+
+ /// Spill height above the sea scalar, metres (render surface; may be negative for a rim below the datum).
+ public float SpillAboveSeaM;
+ /// Floor → spill, metres, unclamped — the basin's depth to its overflow (≈ DrainageAnalysis's basinDepthM).
+ public float DepthToSpillM;
+ ///
+ /// ⭐ THE CLIMB THE CAP IS JUDGED AGAINST: ElevM(spill) − ElevM(floor) with elevation clamped at
+ /// sea exactly as RiverRouting.RouteTo clamps it — so a below-datum lagoon bed climbs from sea
+ /// level, not from its bed. Same number the router's RimClimbM is. ⚠ The clamp is an ELEVATION
+ /// rule on the render surface, not a water test — nothing here reads "render < sea" as water.
+ ///
+ public float SpillClimbM;
+
+ /// Cells with BasinId == Id that are classify-water and NOT ocean. Read on CLASSIFY.
+ public long LakeCells;
+ /// Of those, cells belonging to a SIGNIFICANT body (the router's ≥ floor mask) — for cross-reference with the routing's lake mask.
+ public long LakeCellsSignificant;
+ /// ⚠ Cells with BasinId == Id that are OCEAN on classify — a render depression under the sea. The D-046 seam, made visible rather than hidden.
+ public long OceanCells;
+ /// ⭐ LakeCells >= floor — a significant heightmap lake sits in this basin. This is the graph's lake/dry label.
+ public bool IsLake;
+ ///
+ /// ⚠⚠ EVERY cell of this basin is OCEAN on classify — a render depression on the SEABED. The priority-flood runs on
+ /// the whole render surface, so a deep-enough, large-enough pit under the sea qualifies as a "terminal basin" exactly
+ /// like a land one; hydrologically it is inert (its cells are D_NONE, its inflow is 0). Kept in the layer, EXCLUDED
+ /// from every lake/dry, spill and cap statistic, and counted loudly — this is the D-046 seam, not a lake.
+ ///
+ public bool IsSeabed;
+ /// Some but not all cells are ocean on classify — a basin straddling the shoreline seam. Treated as land (it has land cells and inflow) and counted.
+ public bool IsCoastal;
+ /// A basin with at least one land cell — the ones the graph is about.
+ public bool IsLand => !IsSeabed;
+ /// LakeCells > 0 — exactly DrainageAnalysis's basinHasLake (any size), the routing sort. Kept so the two labels can be compared.
+ public bool HasAnyLake;
+
+ /// ⭐⭐ THE EDGE — where the spill drains next.
+ public DownstreamKind Downstream;
+ /// The downstream basin id when is ; 0 otherwise.
+ public int DownstreamId;
+ /// The cell the spill walk ended on: the first ocean cell, the first cell of the next basin, or where it stuck.
+ public int DownstreamEntryCell;
+ /// The spill walk itself, spill → entry, 1-px cells — the reference's provisional-route descent on FullFilled.
+ public List SpillPath = new();
+ /// ⭐ Cross-check: following Plan.Dir (the analysis's own D8 field) from the first cell past the spill reaches the same node.
+ public bool DirWalkAgrees = true;
+ public DownstreamKind DirWalkKind;
+ public int DirWalkId;
+ }
+
+ ///
+ /// ⭐⭐ THE BASIN GRAPH — the water-bodies layer the flow-through routing model traverses (rivers/04).
+ ///
+ /// ═══ WHAT IT IS ═══
+ ///
+ /// already found the sinks (BasinId) and already computed the
+ /// overflow surface (FullFilled). This layer reads those outputs and records, per terminal basin,
+ /// its spill, whether a significant heightmap lake sits in it, and where its spill drains to. The
+ /// result is a DAG: an edge always leads to a strictly lower spill, so no chain can cycle.
+ ///
+ /// ═══ ⛔ THE RED LINE ═══
+ ///
+ /// **Reads heights, writes none. Creates no water. DrainageAnalysis is consumed, not edited.**
+ /// The caller asserts both height digests unchanged around .
+ ///
+ /// ═══ ⭐ WHY THE SPILL IS EXACT (the Part-0 argument, kept where the code is) ═══
+ ///
+ /// The routing fill is a Barnes priority-flood with a one-ulp pit epsilon. A depression is entered
+ /// from the lowest rim cell S popped off the heap (height L, never raised); the first cells inside
+ /// are raised to BitIncrement(L) and every deeper cell to one ulp above ITS parent. So:
+ /// • a terminal basin's cells (FullFilled > original, 8-connected) are ONE flood chain from
+ /// ONE spill, and their minimum on FullFilled is exactly L + 1 ulp;
+ /// • every boundary cell (8-adjacent, not in the basin) was NOT raised, so FullFilled == original
+ /// there, and its height is ≥ L (a lower one would have been a lower way in);
+ /// • the boundary minimum IS S, at exactly L.
+ /// Both readings are computed and compared per basin (), and
+ /// the "spill sits on real terrain" fact is asserted too ().
+ ///
+ /// ═══ ⭐ WHY THE DOWNSTREAM WALK IS ON FullFilled, NOT Plan.Dir ═══
+ ///
+ /// Plan.Dir is D8 on Filled — the surface with terminal basins REVERTED to real heights.
+ /// The spill cell is the saddle; on Filled its steepest neighbour may be back INTO its own basin
+ /// (the reverted floor is lower than the rim), which would name the basin its own downstream. On
+ /// FullFilled the basin stands at L + ulps above its spill, so the descent from S cannot re-enter
+ /// it — that is exactly why the reference walked its provisional route on the full fill. Dir is
+ /// used as the CROSS-CHECK from the first cell past the spill, where re-entry is impossible.
+ ///
+ public sealed class BasinGraph
+ {
+ // Neighbour order FIXED, identical to DrainageAnalysis — 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 };
+
+ public int MapSize;
+ public float SeaLevel;
+ /// The significance floor a basin's in-basin classify water must reach to make it a LAKE basin. A knob (ISLA_LAKE_MIN_PX).
+ public int LakeMinPx;
+
+ // ---- provenance, recorded on the layer ----
+ public const string SpillDatum =
+ "spill = minimum of Plan.FullFilled over the basin's 8-neighbour boundary (== eroded RENDER height there); " +
+ "cross-checked against BitDecrement(min FullFilled inside the basin); ties → lowest cell index";
+ public const string LakeDatum =
+ "lake = cells with BasinId == id AND classify < sea AND NOT OceanMask (CLASSIFY surface), total >= LakeMinPx";
+ public const string DownstreamMethod =
+ "edge = steepest descent on Plan.FullFilled from the spill cell (the reference's provisional-route walk), " +
+ "until OceanMask (classify) or another BasinId; cross-checked by following Plan.Dir from the first cell past the spill";
+
+ public List Nodes = new();
+ private Dictionary _byId = new();
+ public BasinNode Of(int id) => _byId.TryGetValue(id, out var b) ? b : null;
+
+ // ---- invariant tallies ----
+ public int SpillCrossCheckFailures, SpillNotOnTerrain, DirWalkDisagreements;
+ /// Tallies over LAND basins only (seabed basins excluded — see ).
+ public int ToOcean, ToBasin, Closed, LakeBasins, DryBasins;
+ /// ⚠ The seam counts: basins entirely under the classify sea, and basins straddling the shoreline.
+ public int Seabed, Coastal;
+ /// The land basins, in id order — what every statistic and the plate's graph are over.
+ public List LandNodes = new();
+
+ ///
+ /// ⭐ ONE SIGNIFICANT CLASSIFY-WATER BODY, and which basin (if any) owns it. The reconciliation the whole
+ /// layer exists for, measured per lake rather than assumed: heightmap lakes and terminal basins coincide
+ /// only by terrain coincidence, so this says, per significant body, how much of it sits inside a terminal
+ /// basin and which one — or that it floats free of the hydrology entirely.
+ ///
+ public sealed class LakeBody
+ {
+ public int Index; // 1-based, scan order
+ public long SizePx;
+ public long CellsInBasins; // cells with BasinId != 0
+ public int DominantBasinId; // the basin holding most of its cells (0 = none)
+ public long DominantCells;
+ public int BasinsTouched; // distinct basins it overlaps
+ public bool Owned => DominantBasinId != 0 && DominantCells * 2 >= SizePx; // ≥ half inside one basin
+ public bool Free => CellsInBasins == 0;
+ }
+ /// Every significant body (8-connected, ≥ floor), scan order.
+ public List LakeBodies = new();
+ public int LakeBodiesOwned, LakeBodiesFree, LakeBodiesSplit;
+ /// Non-ocean classify-water cells outside every terminal basin — heightmap water the hydrology never pooled into.
+ public long ClassifyWaterCellsOutsideBasins, ClassifyWaterCellsTotal;
+
+ public static BasinGraph Build(DrainageAnalysis.Plan plan, float[,] render, int n,
+ bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, int lakeMinPx)
+ {
+ int total = n * n;
+ var g = new BasinGraph { MapSize = n, SeaLevel = sea, LakeMinPx = lakeMinPx };
+ int[] basinId = plan.BasinId;
+ float[] ff = plan.FullFilled;
+
+ int maxId = 0;
+ for (int i = 0; i < total; i++) if (basinId[i] > maxId) maxId = basinId[i];
+
+ // ---- pass 1: per-basin scalars, scan order ----
+ var area = new long[maxId + 1];
+ var floorCell = new int[maxId + 1]; var floorH = new float[maxId + 1];
+ var entryCell = new int[maxId + 1]; var entryFF = new float[maxId + 1];
+ var lake = new long[maxId + 1]; var lakeSig = new long[maxId + 1]; var ocean = new long[maxId + 1];
+ for (int id = 0; id <= maxId; id++) { floorCell[id] = -1; floorH[id] = float.MaxValue; entryCell[id] = -1; entryFF[id] = float.MaxValue; }
+ for (int i = 0; i < total; i++)
+ {
+ int id = basinId[i];
+ if (id == 0) continue;
+ area[id]++;
+ float h = render[i / n, i % n];
+ if (h < floorH[id]) { floorH[id] = h; floorCell[id] = i; }
+ if (ff[i] < entryFF[id]) { entryFF[id] = ff[i]; entryCell[id] = i; }
+ if (isOcean[i]) ocean[id]++;
+ else if (isClassifyWater[i])
+ {
+ lake[id]++;
+ if (isSignificantWater != null && isSignificantWater[i]) lakeSig[id]++;
+ }
+ }
+
+ // ---- pass 2: boundary minimum on FullFilled (the rim walk) ----
+ var bMin = new float[maxId + 1]; var bCell = new int[maxId + 1];
+ for (int id = 0; id <= maxId; id++) { bMin[id] = float.MaxValue; bCell[id] = -1; }
+ for (int i = 0; i < total; i++)
+ {
+ int id = basinId[i];
+ if (id == 0) continue;
+ int cx = i / n, cy = i % 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] == id) continue;
+ float v = ff[ni];
+ if (v < bMin[id] || (v == bMin[id] && ni < bCell[id])) { bMin[id] = v; bCell[id] = ni; }
+ }
+ }
+ // ---- pass 3: how many DISTINCT boundary cells tie at the spill height ----
+ var ties = new HashSet[maxId + 1];
+ for (int i = 0; i < total; i++)
+ {
+ int id = basinId[i];
+ if (id == 0) continue;
+ int cx = i / n, cy = i % 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] == id || ff[ni] != bMin[id]) continue;
+ (ties[id] ??= new HashSet()).Add(ni);
+ }
+ }
+
+ float ElevM(float h) => MathF.Max(0f, WorldScale.MetresFromRaw(h - sea));
+
+ // ---- per basin: the node ----
+ for (int id = 1; id <= maxId; id++)
+ {
+ if (area[id] == 0) continue;
+ var b = new BasinNode
+ {
+ Id = id, AreaPx = area[id],
+ InflowPx = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0,
+ FloorCell = floorCell[id], FloorHeightRaw = floorH[id],
+ EntryCell = entryCell[id], EntryFullFilledRaw = entryFF[id],
+ SpillCell = bCell[id], SpillHeightRaw = bMin[id],
+ SpillTies = ties[id]?.Count ?? 0,
+ LakeCells = lake[id], LakeCellsSignificant = lakeSig[id], OceanCells = ocean[id],
+ };
+ b.SpillCrossCheckOk = bCell[id] >= 0 && MathF.BitDecrement(entryFF[id]) == bMin[id];
+ b.SpillOnTerrain = bCell[id] >= 0 && render[bCell[id] / n, bCell[id] % n] == bMin[id];
+ b.SpillAboveSeaM = WorldScale.MetresFromRaw(b.SpillHeightRaw - sea);
+ b.DepthToSpillM = WorldScale.MetresFromRaw(b.SpillHeightRaw - b.FloorHeightRaw);
+ b.SpillClimbM = ElevM(b.SpillHeightRaw) - ElevM(b.FloorHeightRaw);
+ b.IsLake = b.LakeCells >= lakeMinPx;
+ b.HasAnyLake = b.LakeCells > 0;
+ b.IsSeabed = b.OceanCells == b.AreaPx;
+ b.IsCoastal = b.OceanCells > 0 && !b.IsSeabed;
+ if (!b.SpillCrossCheckOk) g.SpillCrossCheckFailures++;
+ if (!b.SpillOnTerrain) g.SpillNotOnTerrain++;
+ if (b.IsSeabed) g.Seabed++;
+ if (b.IsCoastal) g.Coastal++;
+
+ // ⭐⭐ THE DOWNSTREAM WALK — the reference's provisional-route descent, started at the spill.
+ if (bCell[id] >= 0)
+ {
+ int c = bCell[id];
+ b.Downstream = DownstreamKind.None;
+ for (int guard = 0; guard < 4 * n; guard++)
+ {
+ b.SpillPath.Add(c);
+ if (isOcean[c]) { b.Downstream = DownstreamKind.Ocean; break; }
+ int bid = basinId[c];
+ if (bid != 0 && bid != id) { b.Downstream = DownstreamKind.Basin; b.DownstreamId = bid; break; }
+ 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 (ff[ni] < best) { best = ff[ni]; bestN = ni; }
+ }
+ if (bestN < 0 || ff[bestN] >= ff[c]) break; // stuck — a closed sink (or an exact flat)
+ c = bestN;
+ }
+ b.DownstreamEntryCell = b.SpillPath[^1];
+
+ // ⭐ Cross-check on the analysis's own D8 field, from the first cell PAST the spill.
+ if (b.SpillPath.Count >= 2)
+ {
+ int c2 = b.SpillPath[1];
+ var (dk, did) = WalkDir(plan, n, isOcean, c2);
+ b.DirWalkKind = dk; b.DirWalkId = did;
+ b.DirWalkAgrees = dk == b.Downstream && did == b.DownstreamId;
+ }
+ else { b.DirWalkKind = b.Downstream; b.DirWalkId = b.DownstreamId; b.DirWalkAgrees = true; }
+ if (!b.DirWalkAgrees) g.DirWalkDisagreements++;
+ }
+
+ if (b.IsLand)
+ {
+ switch (b.Downstream)
+ {
+ case DownstreamKind.Ocean: g.ToOcean++; break;
+ case DownstreamKind.Basin: g.ToBasin++; break;
+ default: g.Closed++; break;
+ }
+ if (b.IsLake) g.LakeBasins++; else g.DryBasins++;
+ g.LandNodes.Add(b);
+ }
+ g.Nodes.Add(b);
+ g._byId[id] = b;
+ }
+
+ // ---- the reconciliation, per significant body: which basin owns it? ----
+ for (int i = 0; i < total; i++)
+ if (isClassifyWater[i] && !isOcean[i]) { g.ClassifyWaterCellsTotal++; if (basinId[i] == 0) g.ClassifyWaterCellsOutsideBasins++; }
+ if (isSignificantWater != null)
+ {
+ var seen = new bool[total];
+ var stack = new Stack();
+ var perBasin = new Dictionary();
+ for (int s = 0; s < total; s++)
+ {
+ if (seen[s] || !isSignificantWater[s]) continue;
+ var body = new LakeBody { Index = g.LakeBodies.Count + 1 };
+ perBasin.Clear();
+ seen[s] = true; stack.Push(s);
+ while (stack.Count > 0)
+ {
+ int c = stack.Pop();
+ body.SizePx++;
+ int bid = basinId[c];
+ if (bid != 0) { body.CellsInBasins++; perBasin.TryGetValue(bid, out long cur); perBasin[bid] = cur + 1; }
+ 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 (seen[ni] || !isSignificantWater[ni]) continue;
+ seen[ni] = true; stack.Push(ni);
+ }
+ }
+ body.BasinsTouched = perBasin.Count;
+ foreach (var kv in perBasin)
+ if (kv.Value > body.DominantCells || (kv.Value == body.DominantCells && kv.Key < body.DominantBasinId))
+ { body.DominantCells = kv.Value; body.DominantBasinId = kv.Key; }
+ if (body.Free) g.LakeBodiesFree++; else if (body.Owned) g.LakeBodiesOwned++; else g.LakeBodiesSplit++;
+ g.LakeBodies.Add(body);
+ }
+ }
+ return g;
+ }
+
+ /// Follow Plan.Dir from a cell to where its flow ends: the sea, a terminal basin, or nowhere.
+ private static (DownstreamKind kind, int id) WalkDir(DrainageAnalysis.Plan plan, int n, bool[] isOcean, int start)
+ {
+ int c = start;
+ for (int guard = 0; guard < 8 * n; guard++)
+ {
+ if (isOcean[c]) return (DownstreamKind.Ocean, 0);
+ sbyte d = plan.Dir[c];
+ if (d == DrainageAnalysis.D_SEA) return (DownstreamKind.Ocean, 0);
+ if (d == DrainageAnalysis.D_NONE) return plan.BasinId[c] != 0 ? (DownstreamKind.Basin, plan.BasinId[c]) : (DownstreamKind.None, 0);
+ int cx = c / n, cy = c % n;
+ c = (cx + DX[d]) * n + (cy + DY[d]);
+ }
+ return (DownstreamKind.None, 0);
+ }
+
+ ///
+ /// ⭐ THE CAP PREVIEW — which basins have an UNBROKEN spill-chain to the ocean when every link's
+ /// must be ≤ . A preview of what the
+ /// flow-through model will trade at a given cap; it decides nothing.
+ ///
+ public bool[] ConnectedAtCap(float capM, out int connected)
+ {
+ var state = new Dictionary(); // 1 = yes, 2 = no, 3 = visiting
+ bool Reach(int id)
+ {
+ if (state.TryGetValue(id, out byte s)) return s == 1;
+ var b = Of(id);
+ if (b == null) { state[id] = 2; return false; }
+ state[id] = 3;
+ bool ok = false;
+ if (b.SpillClimbM <= capM)
+ {
+ if (b.Downstream == DownstreamKind.Ocean) ok = true;
+ else if (b.Downstream == DownstreamKind.Basin)
+ {
+ // The graph is a DAG (an edge always lands on a strictly lower spill); the
+ // visiting guard is belt-and-braces, never expected to fire.
+ bool visiting = state.TryGetValue(b.DownstreamId, out byte ds) && ds == 3;
+ ok = !visiting && Reach(b.DownstreamId);
+ }
+ }
+ state[id] = ok ? (byte)1 : (byte)2;
+ return ok;
+ }
+ var outp = new bool[Nodes.Count];
+ connected = 0;
+ for (int i = 0; i < Nodes.Count; i++)
+ {
+ outp[i] = Reach(Nodes[i].Id);
+ if (outp[i]) connected++;
+ }
+ return outp;
+ }
+
+ /// Chain length (edges) from a basin to the ocean, or -1 if the chain ends in a closed sink.
+ public int HopsToOcean(int id)
+ {
+ int hops = 0; var seen = new HashSet();
+ var b = Of(id);
+ while (b != null && seen.Add(b.Id))
+ {
+ if (b.Downstream == DownstreamKind.Ocean) return hops + 1;
+ if (b.Downstream != DownstreamKind.Basin) return -1;
+ b = Of(b.DownstreamId); hops++;
+ }
+ return -1;
+ }
+ }
+}
diff --git a/Tools/Scenes/BasinGraphTool.tscn b/Tools/Scenes/BasinGraphTool.tscn
new file mode 100644
index 0000000..2ffecac
--- /dev/null
+++ b/Tools/Scenes/BasinGraphTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene format=3 uid="uid://basingraph04"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/BasinGraphTool.cs" id="1_bgt04"]
+
+[node name="BasinGraphTool" type="Node"]
+script = ExtResource("1_bgt04")
diff --git a/Tools/Scripts/BasinGraphRenderer.cs b/Tools/Scripts/BasinGraphRenderer.cs
new file mode 100644
index 0000000..192bc40
--- /dev/null
+++ b/Tools/Scripts/BasinGraphRenderer.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ THE BASIN-GRAPH PLATE (rivers/04) — the taste gate on the foundation. Presentation only; reads
+ /// the graph and the height, writes pixels. Nothing here touches data.
+ ///
+ /// What the eye is meant to check, per the task:
+ /// • is each SPILL (yellow ring, labelled) at the true low rim where water would actually overtop?
+ /// • which basins hold REAL LAKES (blue tint, the lake cells brighter) vs DRY sinks (amber tint)?
+ /// • does "who drains to whom" (the arrows: cyan → ocean, white → another basin, red = closed)
+ /// look like a physically sane network?
+ ///
+ /// Label at each spill: "23M/7" = spill 23 m above the sea datum, 7 m climb from the basin
+ /// floor (the number the cap is judged against). Both on the RENDER surface.
+ ///
+ public static class BasinGraphRenderer
+ {
+ private static readonly Color LakeTint = new(0.250f, 0.520f, 1.000f);
+ private static readonly Color LakeWater = new(0.180f, 0.420f, 0.980f);
+ private static readonly Color DryTint = new(0.980f, 0.660f, 0.250f);
+ private static readonly Color OutlineL = new(0.100f, 0.250f, 0.650f);
+ private static readonly Color OutlineD = new(0.600f, 0.330f, 0.060f);
+ private static readonly Color Spill = new(1.000f, 0.930f, 0.350f);
+ private static readonly Color EdgeOcean = new(0.250f, 0.900f, 1.000f);
+ private static readonly Color EdgeBasin = new(1.000f, 1.000f, 1.000f);
+ private static readonly Color EdgeNone = new(1.000f, 0.250f, 0.250f);
+ private static readonly Color Floor = new(0.050f, 0.050f, 0.050f);
+ private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
+ private static readonly Color Shadow = new(0.000f, 0.000f, 0.000f);
+ private static readonly Color SeabedOutline = new(0.180f, 0.300f, 0.520f);
+
+ public static Image Plate(BasinGraph g, DrainageAnalysis.Plan plan, Image img, int n,
+ bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
+ {
+ int[] basinId = plan.BasinId;
+ int total = n * n;
+
+ // 1. tint every basin cell by lake/dry; lake cells inside a basin drawn as water.
+ var lakeOf = new Dictionary();
+ var seabed = new HashSet();
+ foreach (var b in g.Nodes) { lakeOf[b.Id] = b.IsLake; if (b.IsSeabed) seabed.Add(b.Id); }
+ for (int i = 0; i < total; i++)
+ {
+ int id = basinId[i];
+ if (id == 0 || seabed.Contains(id)) continue;
+ int x = i / n, y = i % n;
+ bool isLakeBasin = lakeOf.TryGetValue(id, out bool l) && l;
+ if (isClassifyWater[i] && !isOcean[i])
+ {
+ img.SetPixel(x, y, isLakeBasin ? LakeWater : img.GetPixel(x, y).Lerp(LakeWater, 0.55f));
+ continue;
+ }
+ img.SetPixel(x, y, img.GetPixel(x, y).Lerp(isLakeBasin ? LakeTint : DryTint, 0.38f));
+ }
+ // 2. outline: a basin cell with a 4-neighbour of a different id.
+ for (int i = 0; i < total; i++)
+ {
+ int id = basinId[i];
+ if (id == 0) continue;
+ int x = i / n, y = i % n;
+ bool edge = (x > 0 && basinId[i - n] != id) || (x < n - 1 && basinId[i + n] != id)
+ || (y > 0 && basinId[i - 1] != id) || (y < n - 1 && basinId[i + 1] != id);
+ if (!edge) continue;
+ if (seabed.Contains(id)) { img.SetPixel(x, y, SeabedOutline); continue; } // the seam: outline only
+ bool isLakeBasin = lakeOf.TryGetValue(id, out bool l) && l;
+ img.SetPixel(x, y, isLakeBasin ? OutlineL : OutlineD);
+ }
+
+ int thin = n >= 4096 ? 3 : 2, ring = n >= 4096 ? 16 : 9, ringW = n >= 4096 ? 4 : 3;
+ int floorR = n >= 4096 ? 6 : 3, head = n >= 4096 ? 22 : 12;
+ int scale = n >= 4096 ? 3 : 2;
+
+ // 3. the edges — the spill walk, arrowhead at the downstream end.
+ foreach (var b in g.LandNodes)
+ {
+ Color c = b.Downstream switch
+ {
+ DownstreamKind.Ocean => EdgeOcean,
+ DownstreamKind.Basin => EdgeBasin,
+ _ => EdgeNone,
+ };
+ var path = b.SpillPath;
+ if (path.Count >= 2)
+ {
+ for (int i = 1; i < path.Count; i++)
+ DrainageRenderer.Line(img, path[i - 1] / n, path[i - 1] % n, path[i] / n, path[i] % n, n, c, thin);
+ Arrowhead(img, path, n, c, head, thin);
+ }
+ else
+ {
+ // A spill that is itself the terminus (the rim cell is ocean) — or stuck on the spot.
+ DrainageRenderer.Disc(img, b.SpillCell / n, b.SpillCell % n, ring / 2, n, c);
+ }
+ }
+ // 4. spills, floors, labels.
+ foreach (var b in g.LandNodes)
+ {
+ int sx = b.SpillCell / n, sy = b.SpillCell % n;
+ DrainageRenderer.Ring(img, sx, sy, ring, n, Spill, ringW);
+ DrainageRenderer.Disc(img, b.FloorCell / n, b.FloorCell % n, floorR, n, Floor);
+ string lbl = $"{b.SpillAboveSeaM:F0}M/{b.SpillClimbM:F0}"; // TinyFont has no '+' or '^': "spill m above sea / climb m from floor"
+ Label(img, lbl, sx + ring + 4, sy - TinyFont.Height(scale) / 2, scale, n);
+ Label(img, $"#{b.Id}", b.FloorCell / n + floorR + 3, b.FloorCell % n - TinyFont.Height(scale) / 2, scale, n);
+ }
+
+ // 5. the legend.
+ int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
+ TinyFont.Draw(img, title, 12, 12, s, Ink);
+ TinyFont.Draw(img, subtitle, 12, 12 + lh, s, Ink);
+ TinyFont.Draw(img, third, 12, 12 + lh * 2, s, Ink);
+ TinyFont.Draw(img, "BLUE TINT = LAKE BASIN (SIGNIFICANT CLASSIFY WATER INSIDE) AMBER TINT = DRY SINK BLACK DOT = BASIN FLOOR (#ID)", 12, 12 + lh * 3, s, Ink);
+ TinyFont.Draw(img, "YELLOW RING = SPILL CELL. LABEL 12M/4 = SPILL 12 M ABOVE SEA / 4 M CLIMB FROM THE BASIN FLOOR TO OVERTOP (RENDER SURFACE)", 12, 12 + lh * 4, s, Ink);
+ TinyFont.Draw(img, "ARROW = WHERE THE SPILL DRAINS: CYAN TO OCEAN, WHITE INTO ANOTHER BASIN, RED = CLOSED. DATA LAYER ONLY - NOTHING FILLED, NOTHING CARVED", 12, 12 + lh * 5, s, Ink);
+ TinyFont.Draw(img, "FAINT BLUE OUTLINE, NO MARKS = SEABED PIT (A RENDER DEPRESSION UNDER THE CLASSIFY SEA - THE D-046 SEAM, INERT, EXCLUDED FROM THE GRAPH)", 12, 12 + lh * 6, s, Ink);
+ return img;
+ }
+
+ private static void Arrowhead(Image img, List path, int n, Color c, int len, int thick)
+ {
+ int end = path[^1];
+ int from = path[Math.Max(0, path.Count - 1 - 24)];
+ float ex = end / n, ey = end % n, fx = from / n, fy = from % n;
+ float dx = ex - fx, dy = ey - fy;
+ float L = MathF.Sqrt(dx * dx + dy * dy);
+ if (L < 1f) return;
+ dx /= L; dy /= L;
+ // two barbs, 30° either side of the reversed direction
+ const float a = 0.5236f;
+ float cs = MathF.Cos(a), sn = MathF.Sin(a);
+ float bx1 = -dx * cs - (-dy) * sn, by1 = -dx * sn + (-dy) * cs;
+ float bx2 = -dx * cs + (-dy) * sn, by2 = -(-dx) * sn + (-dy) * cs;
+ DrainageRenderer.Line(img, (int)ex, (int)ey, (int)(ex + bx1 * len), (int)(ey + by1 * len), n, c, thick);
+ DrainageRenderer.Line(img, (int)ex, (int)ey, (int)(ex + bx2 * len), (int)(ey + by2 * len), n, c, thick);
+ }
+
+ /// Ink over a one-px black shadow, clamped inside the image so a rim label near the edge is still readable.
+ private static void Label(Image img, string text, int x, int y, int scale, int n)
+ {
+ int w = TinyFont.Width(text, scale), h = TinyFont.Height(scale);
+ x = Math.Clamp(x, 0, Math.Max(0, n - w - 1));
+ y = Math.Clamp(y, 0, Math.Max(0, n - h - 1));
+ TinyFont.Draw(img, text, x + 1, y + 1, scale, Shadow);
+ TinyFont.Draw(img, text, x, y, scale, Ink);
+ }
+ }
+}
diff --git a/Tools/Scripts/BasinGraphTool.cs b/Tools/Scripts/BasinGraphTool.cs
new file mode 100644
index 0000000..f6432b5
--- /dev/null
+++ b/Tools/Scripts/BasinGraphTool.cs
@@ -0,0 +1,599 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ THE LAKE-BASIN LAYER (rivers/04) — build the basin graph and show it, so the developer can
+ /// trust the spills before anything is routed on them.
+ ///
+ /// ═══ WHAT THIS TASK IS FOR ═══
+ ///
+ /// The flow-through routing model (the next task) needs lakes to be NODES in the drainage graph,
+ /// not free-floating heightmap classifications the router bumps into. This builds that layer from
+ /// what `DrainageAnalysis` already computed — the sinks (`BasinId`) and the overflow surface
+ /// (`FullFilled`) — enriching each terminal basin with its SPILL, its LAKE-IDENTITY and its
+ /// DOWNSTREAM EDGE. → .
+ ///
+ /// ═══ ⛔ THE RED LINE — A DATA LAYER, NOT A TERRAIN WRITE ═══
+ ///
+ /// **No height is written. No water is filled or created. `DrainageAnalysis` is reused, not
+ /// rewritten.** Spills and lake-identity are computed and recorded, never stamped. Both height
+ /// fields are digested before the layer is built and after the plate is drawn, and the tool REFUSES
+ /// to continue if either changed — the flood-guard discipline every task since erosion has kept.
+ ///
+ /// ═══ RUNNING IT ═══
+ ///
+ /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
+ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/BasinGraphTool.tscn
+ ///
+ /// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
+ /// ISLA_LAKE_MIN_PX the significance floor for a basin's in-basin classify water (default 20000 — a KNOB)
+ /// ISLA_CAP_PREVIEW_M comma list of rim caps to preview connectivity at (default "15,30,60")
+ ///
+ public partial class BasinGraphTool : Node
+ {
+ private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 };
+ private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
+ private const int DefaultMapSize = 8192;
+ private const int DefaultCalibSize = 2048;
+
+ public override void _Ready()
+ {
+ try { Run(); }
+ catch (Exception e)
+ {
+ GD.PrintErr("==================================================================");
+ GD.PrintErr($" REFUSED: {e.Message}");
+ GD.PrintErr(e.StackTrace);
+ GD.PrintErr("==================================================================");
+ GetTree().Quit(2);
+ }
+ }
+
+ private sealed class SeedResult
+ {
+ public int Seed;
+ public BasinGraph Graph;
+ public int TerminalBasinCount;
+ public long LandCells, EndorheicCells;
+ public ulong RenderDigest, ClassifyDigest;
+ public int WaterBodiesKept, WaterBodiesTotal; public long WaterCellsKept, LargestWaterPx;
+ public float SpillMinM, SpillP25M, SpillMedM, SpillP75M, SpillMaxM;
+ public float ClimbMinM, ClimbP25M, ClimbMedM, ClimbP75M, ClimbMaxM;
+ public int[] ClimbBands; // <=5, 5-15, 15-30, 30-60, >60
+ public Dictionary Cap = new();
+ public int LakeAnyOnly; // HasAnyLake && !IsLake — where this layer's label differs from the routing sort
+ public int MaxHops;
+ public float GraphSeconds, RenderSeconds; public ulong Ms;
+ public float GMin, GMax;
+ }
+
+ private void Run()
+ {
+ ToolingPaths.Configure(OS.GetUserDataDir());
+ ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
+
+ int task = EnvInt("ISLA_TASK", 4);
+ string taskSfx = EnvStr("ISLA_TASK_SUFFIX", "");
+ string descr = EnvStr("ISLA_BATCH", "lake_basin_layer");
+ int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
+ int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
+ int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
+ int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx);
+ bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
+ float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f });
+
+ TerrainShapeV1.Assert("BasinGraph");
+ TerrainShapeV1.AssertErosionDefaultOn("BasinGraph");
+
+ string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
+ DirAccess.MakeDirRecursiveAbsolute(batchRoot);
+ DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
+
+ var anchors = CurveAnchors.Default;
+ float sea = 0.15f;
+ // ⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are the defaults — they
+ // define which depressions ARE terminal basins, i.e. the nodes of this graph. Reporting caps
+ // are irrelevant here: the layer reads BasinId / FullFilled / Dir, not the promoted lists.
+ var dp = new DrainageAnalysis.Params { SeaLevel = sea };
+
+ GD.Print("==================================================================");
+ GD.Print(" THE LAKE-BASIN LAYER (rivers/04) — the basin graph: spill + lake-identity + downstream edge, per terminal basin");
+ GD.Print("==================================================================");
+ GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
+ GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
+ GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
+ GD.Print($"spill : {BasinGraph.SpillDatum}");
+ GD.Print($"lake : {BasinGraph.LakeDatum} floor = {lakeMinPx:N0} px (ISLA_LAKE_MIN_PX, a knob)");
+ GD.Print($"edge : {BasinGraph.DownstreamMethod}");
+ GD.Print($"D-046 : spill geometry/height on RENDER (the flow surface, as RouteTo routes); lake presence + OCEAN on CLASSIFY (the water surface, as the terminus tests). No cross-surface comparison anywhere.");
+ GD.Print($"cap view : unbroken spill-chain to the ocean previewed at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m (every link's floor→spill climb ≤ cap; clamped at sea as RouteTo clamps)");
+ GD.Print($"⛔ RED LINE : DATA LAYER ONLY — no height mutated, no water filled, DrainageAnalysis untouched. ASSERTED per seed around build + render.");
+ GD.Print($"batch : {batchRoot}");
+ GD.Print("==================================================================");
+ if (mapSize != 8192)
+ GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the terminal-basin gates are ABSOLUTE PIXEL COUNTS tuned at 8192; a smaller " +
+ "map under-produces terminal basins. This run checks the PLUMBING (spill extraction, graph, render), not the character.");
+
+ GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
+ var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
+ GD.Print($" {knots}");
+
+ TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
+ {
+ MapSize = size, Seed = seed, VariantLabel = "basins",
+ Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
+ Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
+ };
+
+ var results = new List();
+ foreach (int seed in seeds)
+ {
+ ulong t0 = Time.GetTicksMsec();
+ GD.Print($"\n--- seed {seed} ---");
+ var cfg = Cfg(mapSize, seed);
+ Pass1Result p1 = Topography.Generate(cfg);
+ Pass2Result shaped = Shaping.Shape(p1, cfg);
+ var ero = ErosionPass.Apply(shaped, cfg);
+ Pass2Result p2 = ero.Shaped;
+
+ // ⭐ THE OCEAN IDENTITY and the water surface — CLASSIFY (→ D-066 / D-046).
+ bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
+ var isClassifyWater = new bool[mapSize * mapSize];
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true;
+
+ // ⭐ THE FLOW SURFACE — the analysis on the eroded RENDER height, exactly as every task since chat2/12.
+ var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
+ GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " +
+ $"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %); terminal basins {plan.TerminalBasinCount}; pits filled through {plan.PitsFilledCount:N0}");
+
+ bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize,
+ lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
+ GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest {largestPx:N0} px)");
+
+ // ═══ ⛔ THE RED-LINE GUARD — both fields digested BEFORE the layer ═══
+ ulong hRenderBefore = Digest(p2.Height, mapSize);
+ ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
+
+ ulong tg0 = Time.GetTicksMsec();
+ var g = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx);
+ float graphSec = (Time.GetTicksMsec() - tg0) / 1000f;
+
+ if (g.Nodes.Count != plan.TerminalBasinCount)
+ throw new InvalidOperationException($"[BasinGraph] node count {g.Nodes.Count} != Plan.TerminalBasinCount {plan.TerminalBasinCount} — the layer did not enumerate the analysis's basins exactly.");
+
+ var r = new SeedResult
+ {
+ Seed = seed, Graph = g, TerminalBasinCount = plan.TerminalBasinCount,
+ LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
+ WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies, WaterCellsKept = keptCells, LargestWaterPx = largestPx,
+ GraphSeconds = graphSec,
+ };
+ Summarise(r, caps);
+
+ GD.Print($" ⚠ SEAM: {g.Seabed} of {g.Nodes.Count} terminal basins are SEABED pits (every cell ocean on classify, inflow 0) — excluded from the graph statistics below; {g.Coastal} straddle the shoreline (kept as land).");
+ GD.Print($" ⭐ GRAPH (land): {g.LandNodes.Count} basins — {g.LakeBasins} LAKE / {g.DryBasins} DRY (floor {lakeMinPx:N0} px; {r.LakeAnyOnly} hold only a sub-floor puddle)");
+ GD.Print($" spills → ocean {g.ToOcean} / → another basin {g.ToBasin} / closed {g.Closed}; longest chain {r.MaxHops} hops");
+ GD.Print($" spill height above sea (m): min {r.SpillMinM:F1} p25 {r.SpillP25M:F1} median {r.SpillMedM:F1} p75 {r.SpillP75M:F1} max {r.SpillMaxM:F1}");
+ GD.Print($" floor→spill climb (m): min {r.ClimbMinM:F1} p25 {r.ClimbP25M:F1} median {r.ClimbMedM:F1} p75 {r.ClimbP75M:F1} max {r.ClimbMaxM:F1} bands ≤5/5–15/15–30/30–60/>60: {string.Join("/", r.ClimbBands)}");
+ foreach (float cap in caps)
+ {
+ var c = r.Cap[cap];
+ GD.Print($" cap {cap,3:F0} m: {c.all,3} of {g.LandNodes.Count} land basins chain to the ocean ({c.lake} lake / {c.dry} dry; {c.direct} of them directly)");
+ }
+ GD.Print($" ✅ invariants: spill cross-check (fill-level vs rim-walk) failures {g.SpillCrossCheckFailures}; spill-not-on-terrain {g.SpillNotOnTerrain}; " +
+ $"Dir-walk disagreements {g.DirWalkDisagreements}; seabed {g.Seabed} / coastal {g.Coastal}");
+ if (g.SpillCrossCheckFailures > 0 || g.SpillNotOnTerrain > 0)
+ GD.PrintErr(" ⚠⚠ A SPILL INVARIANT FAILED — the spill datum is not exact on this seed. Reported, not hidden; see the CSV.");
+ if (g.DirWalkDisagreements > 0)
+ GD.PrintErr(" ⚠ The FullFilled walk and the Dir walk disagree on some basin's downstream — listed in the CSV (dir_walk_agrees).");
+ GD.Print($" ⭐ RECONCILIATION: {g.LakeBodies.Count} significant classify bodies — {g.LakeBodiesOwned} owned by a basin (≥ half inside one), " +
+ $"{g.LakeBodiesSplit} split across basins, {g.LakeBodiesFree} FREE (in no terminal basin at all); " +
+ $"{g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} non-ocean classify-water cells lie outside every basin");
+ GD.Print($" graph built in {graphSec:F2}s");
+
+ WriteBasinCsv(batchRoot, r, caps);
+ WriteLakeCsv(batchRoot, r);
+
+ ulong tr0 = Time.GetTicksMsec();
+ RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, skipRaw);
+ r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000f;
+
+ // ═══ ⛔ …and asserted byte-identical AFTER build + render ═══
+ ulong hRenderAfter = Digest(p2.Height, mapSize);
+ ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
+ if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
+ throw new InvalidOperationException(
+ "[BasinGraph] RED-LINE VIOLATION: a height field CHANGED across the layer build / render.\n" +
+ $" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
+ $" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
+ "This task builds a DATA layer — it must never mutate a height or fill water. Refusing to continue.");
+ r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore;
+ GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across build + render — no height mutated, no water filled.");
+
+ r.Ms = Time.GetTicksMsec() - t0;
+ results.Add(r);
+ }
+
+ WriteIndex(batchRoot, mapSize, seeds, results, lakeMinPx, caps, dp, skipRaw);
+ GD.Print("\n==================================================================");
+ GD.Print($" DONE — {batchRoot}");
+ GD.Print(" ⛔ TASTE GATE: the graph is PRESENTED, not routed on. Nothing locked, nothing routed, nothing graduated.");
+ GD.Print(" ⛔ DATA LAYER ONLY: no height mutated, no water filled — asserted per seed.");
+ GD.Print("==================================================================");
+ GetTree().Quit(0);
+ }
+
+ private static void Summarise(SeedResult r, float[] caps)
+ {
+ var g = r.Graph;
+ var spill = new List(); var climb = new List();
+ r.ClimbBands = new int[5];
+ foreach (var b in g.LandNodes)
+ {
+ spill.Add(b.SpillAboveSeaM); climb.Add(b.SpillClimbM);
+ r.ClimbBands[b.SpillClimbM <= 5f ? 0 : b.SpillClimbM <= 15f ? 1 : b.SpillClimbM <= 30f ? 2 : b.SpillClimbM <= 60f ? 3 : 4]++;
+ if (b.HasAnyLake && !b.IsLake) r.LakeAnyOnly++;
+ int hops = g.HopsToOcean(b.Id);
+ if (hops > r.MaxHops) r.MaxHops = hops;
+ }
+ spill.Sort(); climb.Sort();
+ (r.SpillMinM, r.SpillP25M, r.SpillMedM, r.SpillP75M, r.SpillMaxM) = Quantiles(spill);
+ (r.ClimbMinM, r.ClimbP25M, r.ClimbMedM, r.ClimbP75M, r.ClimbMaxM) = Quantiles(climb);
+ foreach (float cap in caps)
+ {
+ bool[] ok = g.ConnectedAtCap(cap, out int connected);
+ int lake = 0, dry = 0, direct = 0;
+ connected = 0;
+ for (int i = 0; i < g.Nodes.Count; i++)
+ {
+ if (!ok[i] || !g.Nodes[i].IsLand) continue; // land basins only — a seabed pit "chains" trivially
+ connected++;
+ if (g.Nodes[i].IsLake) lake++; else dry++;
+ if (g.Nodes[i].Downstream == DownstreamKind.Ocean) direct++;
+ }
+ r.Cap[cap] = (connected, lake, dry, direct);
+ }
+ }
+
+ private static (float, float, float, float, float) Quantiles(List sorted)
+ {
+ if (sorted.Count == 0) return (0, 0, 0, 0, 0);
+ float Q(double q) => sorted[Math.Clamp((int)Math.Round(q * (sorted.Count - 1)), 0, sorted.Count - 1)];
+ return (sorted[0], Q(0.25), Q(0.5), Q(0.75), sorted[^1]);
+ }
+
+ /// FNV-1a over the raw float bits — "byte-identical", not "numerically close".
+ private static ulong Digest(float[,] f, int n)
+ {
+ ulong h = 14695981039346656037UL;
+ for (int x = 0; x < n; x++)
+ for (int y = 0; y < n; y++)
+ {
+ uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
+ for (int b = 0; b < 4; b++)
+ {
+ h ^= (byte)(bits >> (b * 8));
+ h *= 1099511628211UL;
+ }
+ }
+ return h;
+ }
+
+ private static string Kind(DownstreamKind k) => k switch
+ {
+ DownstreamKind.Ocean => "OCEAN",
+ DownstreamKind.Basin => "BASIN",
+ _ => "NONE",
+ };
+
+ private static void WriteBasinCsv(string batchRoot, SeedResult r, float[] caps)
+ {
+ var g = r.Graph; int n = g.MapSize;
+ var capOk = new Dictionary();
+ foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
+ var sb = new StringBuilder();
+ sb.Append("id,class,area_px,inflow_px,is_lake,has_any_lake,lake_cells,lake_cells_significant,ocean_cells," +
+ "floor_x,floor_y,floor_raw,spill_x,spill_y,spill_raw,spill_above_sea_m,depth_to_spill_m,spill_climb_m,spill_ties," +
+ "spill_crosscheck_ok,spill_on_terrain,downstream,downstream_id,downstream_x,downstream_y,spill_path_cells,dir_walk_agrees,dir_walk_kind,dir_walk_id,hops_to_ocean");
+ foreach (float cap in caps) sb.Append($",chain_ok_cap{cap:F0}");
+ sb.AppendLine();
+ for (int i = 0; i < g.Nodes.Count; i++)
+ {
+ var b = g.Nodes[i];
+ sb.Append($"{b.Id},{(b.IsSeabed ? "seabed" : b.IsCoastal ? "coastal" : "inland")},{b.AreaPx},{b.InflowPx},{(b.IsLake ? "yes" : "no")},{(b.HasAnyLake ? "yes" : "no")},{b.LakeCells},{b.LakeCellsSignificant},{b.OceanCells}," +
+ $"{b.FloorCell / n},{b.FloorCell % n},{b.FloorHeightRaw:R},{b.SpillCell / n},{b.SpillCell % n},{b.SpillHeightRaw:R}," +
+ $"{b.SpillAboveSeaM:F2},{b.DepthToSpillM:F2},{b.SpillClimbM:F2},{b.SpillTies}," +
+ $"{(b.SpillCrossCheckOk ? "yes" : "NO")},{(b.SpillOnTerrain ? "yes" : "NO")},{Kind(b.Downstream)},{b.DownstreamId}," +
+ $"{b.DownstreamEntryCell / n},{b.DownstreamEntryCell % n},{b.SpillPath.Count},{(b.DirWalkAgrees ? "yes" : "NO")},{Kind(b.DirWalkKind)},{b.DirWalkId},{g.HopsToOcean(b.Id)}");
+ foreach (float cap in caps) sb.Append($",{(capOk[cap][i] ? "yes" : "no")}");
+ sb.AppendLine();
+ }
+ WriteText(Path.Combine(batchRoot, $"basins_{r.Seed}.csv"), sb.ToString());
+ }
+
+ private static void WriteLakeCsv(string batchRoot, SeedResult r)
+ {
+ var g = r.Graph;
+ var sb = new StringBuilder();
+ sb.AppendLine("body,size_px,cells_in_basins,basins_touched,dominant_basin_id,dominant_cells,dominant_basin_is_lake,status");
+ foreach (var l in g.LakeBodies)
+ {
+ var b = l.DominantBasinId != 0 ? g.Of(l.DominantBasinId) : null;
+ sb.AppendLine($"{l.Index},{l.SizePx},{l.CellsInBasins},{l.BasinsTouched},{l.DominantBasinId},{l.DominantCells}," +
+ $"{(b == null ? "" : b.IsLake ? "yes" : "no")},{(l.Free ? "FREE" : l.Owned ? "owned" : "split")}");
+ }
+ WriteText(Path.Combine(batchRoot, $"lakes_{r.Seed}.csv"), sb.ToString());
+ }
+
+ private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean,
+ bool[] isClassifyWater, Pass2Result p2, int n, float sea, bool skipRaw)
+ {
+ string dir = Path.Combine(batchRoot, $"{r.Seed}");
+ DirAccess.MakeDirRecursiveAbsolute(dir);
+ var g = r.Graph;
+ Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
+ string caps = "";
+ foreach (var kv in r.Cap) caps += $"{kv.Key:F0}M:{kv.Value.all} ";
+ BasinGraphRenderer.Plate(g, plan, baseImg, n, isOcean, isClassifyWater,
+ $"SEED {r.Seed} - THE BASIN GRAPH: {g.LandNodes.Count} LAND BASINS, {g.LakeBasins} LAKE / {g.DryBasins} DRY (FLOOR {g.LakeMinPx} PX) [+{g.Seabed} SEABED PITS, OUTLINED ONLY]",
+ $"SPILLS: {g.ToOcean} TO OCEAN / {g.ToBasin} INTO ANOTHER BASIN / {g.Closed} CLOSED. LONGEST CHAIN {r.MaxHops} HOPS. BASINS CHAINING TO THE OCEAN AT CAP {caps.Trim()}",
+ $"SPILL HEIGHT ABOVE SEA: MEDIAN {r.SpillMedM:F0} M (RANGE {r.SpillMinM:F0}..{r.SpillMaxM:F0}). FLOOR-TO-SPILL CLIMB: MEDIAN {r.ClimbMedM:F0} M (RANGE {r.ClimbMinM:F0}..{r.ClimbMaxM:F0}). TASTE GATE - NOTHING ROUTED, NOTHING LOCKED")
+ .SavePng(Path.Combine(dir, $"basin_graph_{r.Seed}.png"));
+
+ var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
+ r.GMin = gmin; r.GMax = gmax;
+ GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
+ if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
+ }
+
+ private static void WriteIndex(string batchRoot, int mapSize, int[] seeds, List rows,
+ int lakeMinPx, float[] caps, DrainageAnalysis.Params def, bool skipRaw)
+ {
+ var sb = new StringBuilder();
+ int primary = seeds.Length > 0 ? seeds[0] : 0;
+ string capHdr = string.Join(" | ", Array.ConvertAll(caps, c => $"chain→ocean @ {c:F0} m"));
+
+ sb.AppendLine("# Batch 04 — the lake-basin layer: the basin graph (spill + lake-identity + downstream edge)");
+ sb.AppendLine();
+ sb.AppendLine("**⛔ TASTE GATE ON THE FOUNDATION. Nothing is routed, nothing is locked, nothing is graduated.** This is the");
+ sb.AppendLine("data layer the flow-through routing model (piece 2) will traverse; piece 2 is authored only once the spills");
+ sb.AppendLine("and the graph read right.");
+ sb.AppendLine();
+ sb.AppendLine("**⛔ DATA LAYER ONLY. No height was mutated, no water was filled or created, `DrainageAnalysis` was not");
+ sb.AppendLine("edited** — asserted per seed by an FNV digest of both height fields taken before the layer was built and");
+ sb.AppendLine("after the plate was drawn.");
+ sb.AppendLine();
+ sb.AppendLine("## 👉 The pick");
+ sb.AppendLine();
+ sb.AppendLine($"Open **`{primary}/basin_graph_{primary}.png`**. Then the other three: " +
+ string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
+ sb.AppendLine("> **Do the spills (yellow rings) sit where water would actually overflow — the true low rim? Are the");
+ sb.AppendLine("> lake/dry labels right (blue = a significant classify lake sits in the basin, amber = dry sink)? And does");
+ sb.AppendLine("> \"who drains to whom\" (cyan arrow → ocean, white arrow → another basin, red = closed) look like a real");
+ sb.AppendLine("> drainage network?** If the spills are wrong, piece 2 must not be built on this.");
+ sb.AppendLine(">");
+ sb.AppendLine("> Each spill is labelled `23M/7`: **23 m above the sea datum**, and **7 m of climb from the basin floor**");
+ sb.AppendLine("> to overtop — the second number is what a rim cap is judged against. Both read on the RENDER surface.");
+ sb.AppendLine("> The black dot is the basin floor, with its `#id` (the id `BasinId` carries — sparse, as the analysis leaves it).");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ The graph, per seed");
+ sb.AppendLine();
+ sb.AppendLine($"| Seed | terminal basins | ⚠ seabed (excluded) | coastal | **land basins** | **lake** | dry | (sub-floor puddle only) | spill → ocean | → basin | closed | longest chain | {capHdr} |");
+ sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
+ foreach (var r in rows)
+ {
+ var g = r.Graph;
+ sb.Append($"| `{r.Seed}` | {g.Nodes.Count} | {g.Seabed} | {g.Coastal} | **{g.LandNodes.Count}** | **{g.LakeBasins}** | {g.DryBasins} | {r.LakeAnyOnly} | {g.ToOcean} | {g.ToBasin} | {g.Closed} | {r.MaxHops} hops |");
+ foreach (float cap in caps) { var c = r.Cap[cap]; sb.Append($" **{c.all}** ({c.lake} lake / {c.dry} dry) |"); }
+ sb.AppendLine();
+ }
+ sb.AppendLine();
+ sb.AppendLine("> ### ⚠⚠ SEABED PITS — half the analysis's \"terminal basins\" are not on land");
+ sb.AppendLine("> The priority-flood runs on the whole RENDER surface, ocean floor included, so a deep-enough, large-enough");
+ sb.AppendLine("> depression UNDER the classify sea qualifies as a terminal basin exactly like a land one. Every cell of such");
+ sb.AppendLine("> a basin is `OceanMask`, its cells are `D_NONE`, its inflow is 0 — it is hydrologically inert, and it is the");
+ sb.AppendLine("> D-046 seam (render vs classify) made visible. They are kept in the layer and the CSV (`class = seabed`),");
+ sb.AppendLine("> drawn as a faint outline only, and **excluded from every statistic on this page.** The `land basins` column is");
+ sb.AppendLine("> the graph; `coastal` basins (some ocean cells, some land) are counted as land and flagged.");
+ sb.AppendLine();
+ sb.AppendLine("> **Reading the cap columns.** A basin \"chains to the ocean at cap C\" when every link from it to the sea — its");
+ sb.AppendLine("> own spill and every downstream basin's spill — climbs ≤ C m from that basin's floor (elevation clamped at sea,");
+ sb.AppendLine("> exactly as `RouteTo` clamps). This previews what the flow-through model will trade at a given cap; it decides");
+ sb.AppendLine("> nothing. Piece 2 routes; this only says how many basins *could* connect.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ Spill-height distributions (metres, render surface)");
+ sb.AppendLine();
+ sb.AppendLine("| Seed | spill above sea: min / p25 / median / p75 / max | floor→spill climb: min / p25 / median / p75 / max | climb bands ≤5 / 5–15 / 15–30 / 30–60 / >60 |");
+ sb.AppendLine("|---|---|---|---|");
+ foreach (var r in rows)
+ sb.AppendLine($"| `{r.Seed}` | {r.SpillMinM:F1} / {r.SpillP25M:F1} / {r.SpillMedM:F1} / {r.SpillP75M:F1} / {r.SpillMaxM:F1} | " +
+ $"{r.ClimbMinM:F1} / {r.ClimbP25M:F1} / {r.ClimbMedM:F1} / {r.ClimbP75M:F1} / {r.ClimbMaxM:F1} | {string.Join(" / ", r.ClimbBands)} |");
+ sb.AppendLine();
+ sb.AppendLine("## ✅ The invariants, per seed — the spill datum is exact, or it says so");
+ sb.AppendLine();
+ sb.AppendLine("| Seed | spill cross-check failures (fill-level vs rim-walk) | spill not on terrain | Dir-walk disagreements | seabed / coastal | render digest | classify digest |");
+ sb.AppendLine("|---|---|---|---|---|---|---|");
+ foreach (var r in rows)
+ {
+ var g = r.Graph;
+ sb.AppendLine($"| `{r.Seed}` | {(g.SpillCrossCheckFailures == 0 ? "**0** ✅" : $"**{g.SpillCrossCheckFailures}** ⚠⚠")} | " +
+ $"{(g.SpillNotOnTerrain == 0 ? "**0** ✅" : $"**{g.SpillNotOnTerrain}** ⚠⚠")} | " +
+ $"{(g.DirWalkDisagreements == 0 ? "**0** ✅" : $"**{g.DirWalkDisagreements}** ⚠")} | {g.Seabed} / {g.Coastal} | `{r.RenderDigest:X16}` | `{r.ClassifyDigest:X16}` |");
+ }
+ sb.AppendLine();
+ sb.AppendLine("*Cross-check: the basin's minimum on `FullFilled` is the spill + one ulp (the flood's own epsilon), so");
+ sb.AppendLine("`BitDecrement(min inside) == min over the rim` must hold exactly. \"On terrain\": `FullFilled == render` at the spill");
+ sb.AppendLine("cell — the rim was never raised by the flood. \"Dir-walk\": following `Plan.Dir` from the first cell past the spill");
+ sb.AppendLine("reaches the same node as the `FullFilled` descent. A basin holding OCEAN cells is a render depression under");
+ sb.AppendLine("classify-sea — the D-046 seam, counted rather than hidden.*");
+ sb.AppendLine();
+
+ sb.AppendLine("## ⭐ The reconciliation — does each significant heightmap lake sit in a terminal basin?");
+ sb.AppendLine();
+ sb.AppendLine("| Seed | significant bodies (≥ floor) | **owned** (≥ half inside one basin) | split across basins | **FREE** (in no basin) | non-ocean classify-water cells outside every basin |");
+ sb.AppendLine("|---|---|---|---|---|---|");
+ foreach (var r in rows)
+ {
+ var g = r.Graph;
+ sb.AppendLine($"| `{r.Seed}` | {g.LakeBodies.Count} | **{g.LakeBodiesOwned}** | {g.LakeBodiesSplit} | **{g.LakeBodiesFree}** | {g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} ({100.0 * g.ClassifyWaterCellsOutsideBasins / Math.Max(1, g.ClassifyWaterCellsTotal):F1} %) |");
+ }
+ sb.AppendLine();
+ sb.AppendLine("*A FREE body is a classify lake that is not a depression ≥ 2 m / 10,000 px on the RENDER surface (or filled through as a");
+ sb.AppendLine("pit) — heightmap water the hydrology never pooled into. On the plate these are the dark-teal patches with no tint. They are");
+ sb.AppendLine("exactly the \"free-floating heightmap lakes\" this layer exists to reconcile; per body detail in `lakes_.csv`.*");
+ sb.AppendLine();
+ foreach (var r in rows)
+ {
+ var g = r.Graph; int n = g.MapSize;
+ sb.AppendLine($"### `{r.Seed}` — every LAND basin, largest first ({g.Seabed} seabed pits omitted; see the CSV)");
+ sb.AppendLine();
+ sb.Append("| id | area px | inflow px | lake? | lake cells | spill (x,y) | spill +m | climb m | → | hops |");
+ foreach (float cap in caps) sb.Append($" @{cap:F0} |");
+ sb.AppendLine();
+ sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
+ var order = new List(g.LandNodes);
+ order.Sort((a, b) => b.AreaPx.CompareTo(a.AreaPx));
+ var capOk = new Dictionary();
+ foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
+ foreach (var b in order)
+ {
+ int idx = g.Nodes.IndexOf(b);
+ string to = b.Downstream switch
+ {
+ DownstreamKind.Ocean => "**OCEAN**",
+ DownstreamKind.Basin => $"#{b.DownstreamId}",
+ _ => "⚠ closed",
+ };
+ int hops = g.HopsToOcean(b.Id);
+ sb.Append($"| #{b.Id}{(b.IsCoastal ? " ⚠coastal" : "")} | {b.AreaPx:N0} | {b.InflowPx:N0} | {(b.IsLake ? "**lake**" : b.HasAnyLake ? "puddle" : "dry")} | {b.LakeCells:N0} | " +
+ $"({b.SpillCell / n},{b.SpillCell % n}) | {b.SpillAboveSeaM:F1} | {b.SpillClimbM:F1} | {to} | {(hops < 0 ? "—" : hops.ToString())} |");
+ foreach (float cap in caps) sb.Append(capOk[cap][idx] ? " ✅ |" : " — |");
+ sb.AppendLine();
+ }
+ sb.AppendLine();
+ sb.AppendLine($"*Land {r.LandCells:N0}, endorheic {r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %) · " +
+ $"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px ({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · " +
+ $"graph {r.GraphSeconds:F2}s, plate {r.RenderSeconds:F1}s, seed total {r.Ms / 1000.0:F0}s · grayscale range {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*");
+ sb.AppendLine();
+ }
+
+ sb.AppendLine("## The two \"lakes\" this layer reconciles — and the datum each is read on (D-046)");
+ sb.AppendLine();
+ sb.AppendLine("| Quantity | Surface | Why |");
+ sb.AppendLine("|---|---|---|");
+ sb.AppendLine("| spill cell, spill height, floor, climb | **RENDER** (`Plan.FullFilled`, the flood of the eroded render height) | where water GOES — the surface `RouteTo` routes on, so a climb here is the same number as the router's `RimClimbM` |");
+ sb.AppendLine("| downstream walk | **RENDER** (`FullFilled` descent) | the reference's provisional-route machinery, started at the spill |");
+ sb.AppendLine("| lake presence (`IsLake`, lake cells) | **CLASSIFY** (`classify < sea` and not `OceanMask`) | what is VISIBLY water — the surface the terminus tests use |");
+ sb.AppendLine("| the OCEAN terminus of a walk | **CLASSIFY** (`OceanMask`) | as routing: route on render, ocean on classify |");
+ sb.AppendLine();
+ sb.AppendLine("**No field compares a classify height to a render height.** The surfaces meet only as membership tests");
+ sb.AppendLine("(is this basin cell classify-water? is this walk cell ocean?) — the split the routing already lives by. No new seam.");
+ sb.AppendLine();
+ sb.AppendLine("## What was run");
+ sb.AppendLine();
+ sb.AppendLine($"Chain + drainage analysis + the layer at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered.");
+ sb.AppendLine($"`ISLA_LAKE_MIN_PX={lakeMinPx:N0}` (the significance floor — a knob); cap preview at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m.");
+ sb.AppendLine();
+ sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused — the layer reads `BasinId`, `FullFilled`, `Dir`, `BasinInflow`); " +
+ $"`EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} (they define which depressions ARE the nodes).");
+ sb.AppendLine();
+ sb.AppendLine("## Files");
+ sb.AppendLine();
+ sb.AppendLine("| File | What it is |");
+ sb.AppendLine("|---|---|");
+ sb.AppendLine("| `/basin_graph_.png` | the graph over the faint terrain: basins tinted lake/dry, spill rings labelled, arrows to the downstream node |");
+ sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |");
+ sb.AppendLine("| `basins_.csv` | every `BasinNode`: class (inland/coastal/seabed), area, inflow, lake cells, floor, spill (cell + raw + metres), climb, downstream kind/id/entry, invariants, hops, chain-ok per cap |");
+ sb.AppendLine("| `lakes_.csv` | every significant classify body: size, cells inside basins, dominant basin, owned / split / FREE |");
+ if (skipRaw)
+ sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. |");
+ sb.AppendLine();
+ sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
+ sb.AppendLine();
+ sb.AppendLine("→ `XX_Human/output/rivers/04_lake_basin_layer.report.md`");
+ WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
+ }
+
+ // ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
+
+ private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
+ {
+ var rawPool = new LandHistogram(sea);
+ var pass1 = new Dictionary();
+ foreach (int s in CalibrationSeeds)
+ {
+ var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
+ pass1[s] = p1;
+ rawPool.Accumulate(p1.Height, calibSize);
+ }
+ var knots = new CurveKnots(2, "v2_balanced",
+ rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
+ rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
+ rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
+ float ceilingRaw = knots.K2;
+ var rawAbove = new LandHistogram(sea);
+ var outAbove = new LandHistogram(sea);
+ foreach (int s in CalibrationSeeds)
+ {
+ var scfg = new TerrainGenConfig
+ {
+ MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
+ CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
+ }.WithFamilyOff();
+ Pass2Result st = Shaping.Shape(pass1[s], scfg);
+ rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
+ outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
+ }
+ var pcts = ClimbCalibration.DefaultPercentiles;
+ var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
+ for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
+ return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
+ HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
+ anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
+ }
+
+ // ---- env / io -----------------------------------------------------------------------------
+
+ private static void WriteText(string path, string text)
+ {
+ using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
+ if (f == null) { GD.PrintErr($"could not write {path}"); return; }
+ f.StoreString(text);
+ }
+
+ private static string EnvStr(string k, string fallback)
+ {
+ string v = System.Environment.GetEnvironmentVariable(k);
+ return string.IsNullOrWhiteSpace(v) ? fallback : v;
+ }
+ private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
+ private static int[] EnvSeeds(string k, int[] fallback)
+ {
+ string v = EnvStr(k, null);
+ if (v == null) return fallback;
+ var outp = new List();
+ foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
+ return outp.Count > 0 ? outp.ToArray() : fallback;
+ }
+ private static float[] EnvFloats(string k, float[] fallback)
+ {
+ string v = EnvStr(k, null);
+ if (v == null) return fallback;
+ var outp = new List();
+ foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
+ return outp.Count > 0 ? outp.ToArray() : fallback;
+ }
+ }
+}
diff --git a/Tools/Scripts/DrainageRenderer.cs b/Tools/Scripts/DrainageRenderer.cs
index a1631a4..f481e92 100644
--- a/Tools/Scripts/DrainageRenderer.cs
+++ b/Tools/Scripts/DrainageRenderer.cs
@@ -783,13 +783,13 @@ namespace IslaApocalypse.Tools
for (int k = 0; k < 8 && x + k < x1; k++) img.SetPixel(x + k, y, c);
}
- private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
+ internal 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)
+ internal 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;
@@ -811,7 +811,7 @@ namespace IslaApocalypse.Tools
}
}
- private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
+ internal 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++)
@@ -822,7 +822,7 @@ namespace IslaApocalypse.Tools
}
}
- private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
+ internal 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++)
@@ -834,7 +834,7 @@ namespace IslaApocalypse.Tools
}
}
- private static void Square(Image img, int cx, int cy, int r, int n, Color c)
+ internal 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++)