diff --git a/Core/Scripts/RegionLabeling.cs b/Core/Scripts/RegionLabeling.cs
index 458c15a..175ccee 100644
--- a/Core/Scripts/RegionLabeling.cs
+++ b/Core/Scripts/RegionLabeling.cs
@@ -183,6 +183,72 @@ namespace IslaApocalypse.Core
return labels;
}
+ ///
+ /// ⭐⭐ SIGNIFICANT WATER (rivers/03) — the interim substitute for the reference's water-bodies
+ /// table, built with this layer's own connected-component machinery.
+ ///
+ /// ═══ WHY THIS EXISTS ═══
+ ///
+ /// The reference builds `isSignificantWater` from `_waterBodies` — cells of any body with
+ /// `PixelCount >= RiverLakeMinTargetPx` — and a lake-ender routes to THAT rather than to any wet
+ /// pixel. **v2 has no water-bodies table yet** (a known port gap, `00_ground` §D3 /
+ /// carry-forward §5), so this labels 8-connected components of classify water directly and keeps
+ /// the ones at least cells. Same semantics, same threshold, no table.
+ ///
+ /// ⚠ The size filter is the whole point and it is not a detail: routing a lake-ender to the
+ /// NEAREST wet pixel put one into a three-cell puddle a few hundred px short of the obvious
+ /// lagoon — the reference's own task-23 gate finding. "Nearest water" is satisfied by a puddle.
+ ///
+ /// ⚠ OCEAN IS EXCLUDED. A lake-ender that could reach the ocean is not a lake-ender; including
+ /// ocean here would let one "terminate" at the coast and quietly become a sea river without ever
+ /// passing the routed test.
+ ///
+ /// Pure: reads the mask, writes nothing, creates no water. Same 8-connectivity and same fixed
+ /// neighbour order as , so component identity is deterministic.
+ ///
+ public static bool[] SignificantWaterMask(bool[] isClassifyWater, bool[] isOcean, int mapSize,
+ int minPx, out int bodiesKept, out int bodiesTotal, out long cellsKept, out long largestPx)
+ {
+ int n = mapSize;
+ var seen = new bool[n * n];
+ var mask = new bool[n * n];
+ var stack = new Stack();
+ var component = new List();
+ bodiesKept = 0; bodiesTotal = 0; cellsKept = 0; largestPx = 0;
+
+ for (int s = 0; s < n * n; s++)
+ {
+ if (seen[s] || !isClassifyWater[s] || isOcean[s]) continue;
+ component.Clear();
+ seen[s] = true;
+ stack.Push(s);
+ while (stack.Count > 0)
+ {
+ int cur = stack.Pop();
+ component.Add(cur);
+ int cx = cur / n, cy = cur % 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] || !isClassifyWater[ni] || isOcean[ni]) continue;
+ seen[ni] = true;
+ stack.Push(ni);
+ }
+ }
+ bodiesTotal++;
+ if (component.Count > largestPx) largestPx = component.Count;
+ if (component.Count >= minPx)
+ {
+ bodiesKept++;
+ cellsKept += component.Count;
+ foreach (int c in component) mask[c] = true;
+ }
+ }
+ return mask;
+ }
+
///
/// Size statistics over the islands (non-mainland components): count, min / median / mean /
/// max cells, and a log-spaced histogram — the instrument that turns "nice pieces vs shattered
diff --git a/Tools/Scenes/RiverRoutingTool.tscn b/Tools/Scenes/RiverRoutingTool.tscn
new file mode 100644
index 0000000..df3be21
--- /dev/null
+++ b/Tools/Scenes/RiverRoutingTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene format=3 uid="uid://riverrouting03"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/RiverRoutingTool.cs" id="1_rrt03"]
+
+[node name="RiverRoutingTool" type="Node"]
+script = ExtResource("1_rrt03")
diff --git a/Tools/Scripts/DrainageRenderer.cs b/Tools/Scripts/DrainageRenderer.cs
index bd19409..926ad5c 100644
--- a/Tools/Scripts/DrainageRenderer.cs
+++ b/Tools/Scripts/DrainageRenderer.cs
@@ -310,46 +310,11 @@ namespace IslaApocalypse.Tools
}
int ls = n >= 4096 ? 4 : 3;
- int lineH = TinyFont.Height(ls);
- var placed = new List();
- // Reserve the legend block so a river label never lands under the header text.
- placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(ls) + 6) * 7));
+ var placer = new LabelPlacer(n, ls, headerLines: 7);
int dropped = 0;
foreach (var c in toLabel)
- {
- string txt = $"{DrainageLabel(c.DrainagePx)} R{c.Rank}";
- int w = TinyFont.Width(txt, ls), h = lineH;
- int pad = 4 * (ls >= 4 ? 2 : 1);
- int gap = mark + 10;
- // right, left, below, above, then pushed further out — first clear slot wins.
- var tries = new (int x, int y)[]
- {
- (c.TermX + gap, c.TermY - h / 2),
- (c.TermX - gap - w, c.TermY - h / 2),
- (c.TermX - w / 2, c.TermY + gap),
- (c.TermX - w / 2, c.TermY - gap - h),
- (c.TermX + gap * 2 + w / 2, c.TermY - h / 2),
- (c.TermX - gap * 2 - w - w / 2, c.TermY - h / 2),
- (c.TermX - w / 2, c.TermY + gap * 2 + h),
- (c.TermX - w / 2, c.TermY - gap * 2 - h * 2),
- };
- bool ok = false;
- foreach (var (tx, ty) in tries)
- {
- int bx = Math.Clamp(tx - pad, 0, Math.Max(0, n - (w + pad * 2)));
- int by = Math.Clamp(ty - pad, 0, Math.Max(0, n - (h + pad * 2)));
- var box = new Rect2I(bx, by, w + pad * 2, h + pad * 2);
- bool hit = false;
- foreach (var q in placed) if (box.Intersects(q)) { hit = true; break; }
- if (hit) continue;
- FillRect(img, box, new Color(0.04f, 0.05f, 0.07f), n);
- TinyFont.Draw(img, txt, bx + pad, by + pad, ls, c.IsSea ? Trunk : Giant);
- placed.Add(box);
- ok = true;
- break;
- }
- if (!ok) dropped++;
- }
+ if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank}", c.TermX, c.TermY, mark,
+ c.IsSea ? Trunk : Giant)) dropped++;
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
int nSea = 0; long seaPx = 0, endoPx = 0;
@@ -376,6 +341,193 @@ namespace IslaApocalypse.Tools
if (x >= 0 && y >= 0 && x < n && y < n) img.SetPixel(x, y, c);
}
+ ///
+ /// Greedy non-overlapping label placement on a dark backing box, so a number is legible over
+ /// both bright terrain and dark ocean. A label that cannot be placed clear of the others is
+ /// DROPPED rather than drawn illegibly on top of one — and every caller reports how many, so a
+ /// missing number is never silent. Shared by the composition and routed-mix plates.
+ ///
+ private sealed class LabelPlacer
+ {
+ private readonly List _placed = new();
+ private readonly int _n, _scale, _pad;
+
+ public LabelPlacer(int n, int scale, int headerLines)
+ {
+ _n = n; _scale = scale; _pad = 4 * (scale >= 4 ? 2 : 1);
+ // Reserve the legend block so a river label never lands under the header text.
+ _placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(scale) + 6) * headerLines));
+ }
+
+ public bool Place(Image img, string txt, int atX, int atY, int mark, Color ink)
+ {
+ int w = TinyFont.Width(txt, _scale), h = TinyFont.Height(_scale);
+ int gap = mark + 10;
+ // right, left, below, above, then pushed further out — first clear slot wins.
+ var tries = new (int x, int y)[]
+ {
+ (atX + gap, atY - h / 2),
+ (atX - gap - w, atY - h / 2),
+ (atX - w / 2, atY + gap),
+ (atX - w / 2, atY - gap - h),
+ (atX + gap * 2 + w / 2, atY - h / 2),
+ (atX - gap * 2 - w - w / 2, atY - h / 2),
+ (atX - w / 2, atY + gap * 2 + h),
+ (atX - w / 2, atY - gap * 2 - h * 2),
+ };
+ foreach (var (tx, ty) in tries)
+ {
+ int bx = Math.Clamp(tx - _pad, 0, Math.Max(0, _n - (w + _pad * 2)));
+ int by = Math.Clamp(ty - _pad, 0, Math.Max(0, _n - (h + _pad * 2)));
+ var box = new Rect2I(bx, by, w + _pad * 2, h + _pad * 2);
+ bool hit = false;
+ foreach (var q in _placed) if (box.Intersects(q)) { hit = true; break; }
+ if (hit) continue;
+ FillRect(img, box, new Color(0.04f, 0.05f, 0.07f), _n);
+ TinyFont.Draw(img, txt, bx + _pad, by + _pad, _scale, ink);
+ _placed.Add(box);
+ return true;
+ }
+ return false;
+ }
+ }
+
+ // ═══ ⭐⭐ THE ROUTED MIX (rivers/03) — three classes, and where routing added the channel ═══
+
+ /// Routed giants: the natural upland stem, muted.
+ private static readonly Color RoutedStem = new(0.250f, 0.620f, 0.330f);
+ /// ⭐ The LOWLAND REACH routing added — bright, so the added channel is unmistakable.
+ private static readonly Color RoutedReach = new(0.380f, 1.000f, 0.420f);
+ /// The rim the route climbed over — the point the developer is asked to judge.
+ private static readonly Color RimMark = new(1.000f, 0.930f, 0.350f);
+
+ ///
+ /// ⭐⭐ THE MIX PLATE — natural ocean trunks, routed-through giants, and inland lake-enders, on
+ /// the shared faint base at rivers/02b's FIXED width scale.
+ ///
+ /// The one thing this plate exists to show: **which part of a routed river is terrain and which
+ /// part is routing.** So a routed giant is drawn in two tones of one colour — its erosion-carved
+ /// upland stem muted, the lowland reach the Dijkstra added bright — and the point where that
+ /// reach crosses its rim is ringed. A reader can then see, without reading a table, how far the
+ /// river was carried and how high it had to climb to get there.
+ ///
+ /// ⚠⚠ `Giant.ProvisionalRoute` is NOT drawn — the real route is what replaces it.
+ ///
+ public static Image RoutedMix(List rivers, Image img, int n,
+ string title, string subtitle, long floorPx)
+ {
+ if (rivers.Count == 0) return img;
+
+ int mark = n >= 4096 ? 18 : 10;
+ var byArea = new List(rivers);
+ byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
+
+ // Smallest first, so the biggest rivers finish on top.
+ for (int i = byArea.Count - 1; i >= 0; i--)
+ {
+ var r = byArea[i];
+ int w = StemWidthFixed(r.Candidate.DrainagePx);
+ Color stemCol = r.Class switch
+ {
+ RiverRouting.RiverClass.OceanTrunk => Trunk,
+ RiverRouting.RiverClass.RoutedGiant => RoutedStem,
+ _ => Giant,
+ };
+ // The upland stem, as erosion made it (head → terminal), reversed out of the analysis.
+ var stem = new List<(float x, float y)>(r.Candidate.Course);
+ stem.Reverse();
+ Polyline(img, stem, n, stemCol, w);
+
+ // The lowland reach routing added, drawn distinctly on top of its own stem.
+ if (r.Lowland != null && r.Lowland.Smoothed != null && r.Lowland.Smoothed.Count > 1)
+ {
+ Color reachCol = r.Class == RiverRouting.RiverClass.RoutedGiant ? RoutedReach : Giant;
+ Polyline(img, r.Lowland.Smoothed, n, reachCol, w);
+ }
+ }
+
+ // Terminus markers, and the rim a routed river crossed.
+ foreach (var r in byArea)
+ {
+ var c = r.Candidate;
+ switch (r.Class)
+ {
+ case RiverRouting.RiverClass.OceanTrunk:
+ Square(img, c.TermX, c.TermY, mark, n, Trunk);
+ break;
+ case RiverRouting.RiverClass.RoutedGiant:
+ if (r.Lowland != null && r.Lowland.Reached)
+ {
+ var t = r.Lowland.Target;
+ Square(img, (int)t.x, (int)t.y, mark, n, RoutedReach);
+ Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
+ MarkRim(img, r.Lowland, n, mark);
+ }
+ // The basin it came FROM stays marked, so the reader sees what was connected.
+ Ring(img, c.TermX, c.TermY, mark, n, RoutedStem, 4);
+ break;
+ default:
+ Disc(img, c.TermX, c.TermY, mark, n, Giant);
+ Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
+ if (r.Lowland != null && r.Lowland.Reached)
+ {
+ var t = r.Lowland.Target;
+ Ring(img, (int)t.x, (int)t.y, mark, n, Giant, 4);
+ }
+ break;
+ }
+ }
+
+ // ---- labels ----
+ int ls = n >= 4096 ? 4 : 3;
+ var placer = new LabelPlacer(n, ls, headerLines: 8);
+ int dropped = 0;
+ foreach (var r in byArea)
+ {
+ var c = r.Candidate;
+ string txt = r.Class switch
+ {
+ RiverRouting.RiverClass.OceanTrunk => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} TRUNK",
+ RiverRouting.RiverClass.RoutedGiant => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M",
+ _ => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} LAKE",
+ };
+ Color ink = r.Class switch
+ {
+ RiverRouting.RiverClass.OceanTrunk => Trunk,
+ RiverRouting.RiverClass.RoutedGiant => RoutedReach,
+ _ => Giant,
+ };
+ if (!placer.Place(img, txt, c.TermX, c.TermY, mark, ink)) dropped++;
+ }
+
+ int trunks = 0, routed = 0, lakes = 0;
+ foreach (var r in byArea)
+ {
+ if (r.Class == RiverRouting.RiverClass.OceanTrunk) trunks++;
+ else if (r.Class == RiverRouting.RiverClass.RoutedGiant) routed++;
+ else lakes++;
+ }
+ 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, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) - EROSION ALREADY REACHES THE COAST, NO LOWLAND ROUTE ADDED", 12, 12 + lh * 2, s, Trunk);
+ TinyFont.Draw(img, $"GREEN: ROUTED-THROUGH GIANT ({routed}) - DARK = ITS NATURAL UPLAND STEM, BRIGHT = THE LOWLAND REACH ROUTING ADDED", 12, 12 + lh * 3, s, RoutedReach);
+ TinyFont.Draw(img, $"YELLOW RING ON A GREEN REACH = THE RIM IT CLIMBED OVER (ROUTE HIGH POINT). LABEL RIM = METRES CLIMBED FROM THE BASIN", 12, 12 + lh * 4, s, RimMark);
+ TinyFont.Draw(img, $"ORANGE: INLAND LAKE-ENDER ({lakes}) - DISC = ITS TERMINAL, RING = THE SIGNIFICANT WATER BODY IT JOINS", 12, 12 + lh * 5, s, Giant);
+ TinyFont.Draw(img, $"WIDTH: {StemWidthLaw()} - THE SAME FIXED CONSTANT AS RIVERS/02B, EVERY PLATE AND SEED", 12, 12 + lh * 6, s, Ink);
+ TinyFont.Draw(img, $"COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED, NOTHING CARVED. PROVISIONALROUTE (THE COMB) NOT DRAWN." +
+ (dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 7, s, Ink);
+ return img;
+ }
+
+ /// Ring the route's high point — the rim the channel crosses.
+ private static void MarkRim(Image img, RiverRouting.Route route, int n, int mark)
+ {
+ if (route.Path == null || route.Path.Count < 2 || route.RimClimbM <= 0.01f) return;
+ var p = route.RimPoint;
+ Ring(img, (int)p.x, (int)p.y, mark - 4, n, RimMark, 4);
+ }
+
///
/// ⭐ THE DISTRIBUTION PLOT — drainage area (log y) against rank (linear x), with the ladder
/// counts marked vertically and the analysis's own thresholds marked horizontally.
diff --git a/Tools/Scripts/RiverCandidate.cs b/Tools/Scripts/RiverCandidate.cs
index ff1ff7e..cce08c1 100644
--- a/Tools/Scripts/RiverCandidate.cs
+++ b/Tools/Scripts/RiverCandidate.cs
@@ -99,6 +99,22 @@ namespace IslaApocalypse.Tools
/// 1-based rank in the unified descending ranking. 0 until ranked.
public int Rank;
+ ///
+ /// ⭐ rivers/03 — THE ANALYSIS'S OWN routed/lake-ender verdict, copied from Giant.Kind at
+ /// bind time. Endorheic only ("" for sea candidates).
+ ///
+ /// ⚠⚠ READ THE TEST BEFORE TRUSTING THE NAME. `DrainageAnalysis` assigns this as
+ /// (basinHasLake[id] && !SouthernCandidate) ? "lake-ender" : "routed" — i.e. purely on
+ /// **whether the terminal basin holds classify water**. It is NOT a path test: "routed" means
+ /// "this basin is a dry pan, so it SHOULD be routed", not "a route to the sea exists". Whether
+ /// one actually does is what `RiverRouting.RouteToOcean` decides, and the two CAN disagree.
+ /// rivers/03 reports both per river rather than silently picking one.
+ ///
+ public string AnalysisKind = "";
+
+ /// Endorheic only: the analysis found classify water in this terminal basin.
+ public bool TerminalInClassifyWater;
+
public string TerminusName => IsSea ? "sea" : "endorheic";
}
}
diff --git a/Tools/Scripts/RiverCandidates.cs b/Tools/Scripts/RiverCandidates.cs
new file mode 100644
index 0000000..4d140d9
--- /dev/null
+++ b/Tools/Scripts/RiverCandidates.cs
@@ -0,0 +1,233 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ THE CANDIDATE SET — one implementation, shared by every task that ranks rivers.
+ ///
+ /// Extracted from `RiverPromotionTool` at rivers/03, unchanged in behaviour, because routing needs
+ /// exactly the same promoted set the count gate was judged on. **Two copies of this enumeration
+ /// would be two answers to "which rivers does the island have", and the epic rests on there being
+ /// one.** `RiverPromotionTool` now delegates here; its plates are byte-identical across the change.
+ ///
+ /// ═══ WHAT IT DOES, AND WHAT IT DELIBERATELY DOES NOT ═══
+ ///
+ /// It derives the COMPLETE candidate set from the arrays `DrainageAnalysis.Plan` exposes —
+ /// `Dir` / `Acc` / `BasinId` / `BasinInflow` / `FullFilled` — rather than from `Plan.Trunks` /
+ /// `Plan.Giants`, which are already truncated by the analysis's lean reporting caps. Ranking over
+ /// the truncated lists would measure the caps rather than the terrain.
+ ///
+ /// ⚠ **`DrainageAnalysis` is reused, never rebuilt.** Every derived quantity here is a
+ /// reconstruction of a value the analysis computed internally, from state it exposes. Nothing the
+ /// analysis owns is reimplemented — least of all stem tracing, which is BOUND from the analysis's
+ /// own `Trunk` / `Giant` (see ).
+ ///
+ public static class RiverCandidates
+ {
+ /// The enumeration's full result: the ranking, plus what the separation rule cost.
+ public sealed class Enumeration
+ {
+ /// Separated and above the floor, descending by DrainagePx, `Rank` assigned.
+ public List Ranked;
+ public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
+ public int TerminalBasins, SeaOutletsAll;
+ /// Sea outlets dropped by the separation rule — ALL of them, incl. one-cell trickles.
+ public int SuppressedCount;
+ public long SuppressedPx;
+ /// ⭐ The two that matter: only outlets clearing the floor could ever have been promoted.
+ public int SuppressedAboveFloor;
+ public long SuppressedAboveFloorPx;
+ /// Of those, suppressed by an outlet on a DIFFERENT landmass — not a delta mouth by any definition.
+ public int SuppressedCrossLandmass;
+ public long SuppressedCrossLandmassPx;
+ public List SuppressedAboveFloorAccs = new();
+
+ public int SeaCount { get { int s = 0; foreach (var c in Ranked) if (c.IsSea) s++; return s; } }
+ }
+
+ ///
+ /// Enumerate every candidate major drainage, cap-free.
+ ///
+ /// SEA every cell with Dir == D_SEA, carrying Acc there, then the
+ /// analysis's own greedy MinOutletSeparationPx rule so three mouths of one
+ /// delta are not three rivers.
+ /// ENDORHEIC every terminal basin in BasinId, carrying BasinInflow[id], with
+ /// terminal cell / area / depth re-derived from the exposed surfaces.
+ ///
+ /// ⚠⚠ Throws unless the metric-comparability identity holds exactly — see below.
+ ///
+ public static Enumeration Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
+ long floorPx, int separationPx, RegionLabels regions)
+ {
+ int total = n * n;
+ var r = new Enumeration
+ {
+ LandCells = plan.LandCells, SeaReachingCells = plan.SeaReachingCells,
+ EndorheicCells = plan.EndorheicCells, UnroutedCells = plan.UnroutedCells,
+ TerminalBasins = plan.TerminalBasinCount,
+ };
+
+ // ---- SEA: every outlet, then the separation rule ----
+ var outlets = new List<(int cell, long acc)>();
+ long seaSum = 0;
+ for (int i = 0; i < total; i++)
+ if (plan.Dir[i] == DrainageAnalysis.D_SEA) { outlets.Add((i, plan.Acc[i])); seaSum += plan.Acc[i]; }
+ outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
+ r.SeaOutletsAll = outlets.Count;
+
+ var sea = new List();
+ var kept = new List();
+ foreach (var (cell, acc) in outlets)
+ {
+ int cx = cell / n, cy = cell % n;
+ bool far = true; int suppressor = -1;
+ foreach (int pcell in kept)
+ {
+ float ddx = cx - pcell / n, ddy = cy - pcell % n;
+ if (ddx * ddx + ddy * ddy < (float)separationPx * separationPx) { far = false; suppressor = pcell; break; }
+ }
+ var c = new RiverCandidate { IsSea = true, Cell = cell, X = cx, Y = cy, TermX = cx, TermY = cy, DrainagePx = acc, SuppressedBySeparation = !far };
+ if (far) kept.Add(cell);
+ else
+ {
+ r.SuppressedCount++; r.SuppressedPx += acc;
+ if (acc >= floorPx)
+ {
+ r.SuppressedAboveFloor++; r.SuppressedAboveFloorPx += acc; r.SuppressedAboveFloorAccs.Add(acc);
+ // ⚠⚠ IS THE SUPPRESSOR EVEN ON THE SAME LANDMASS? The separation rule is a plain
+ // Euclidean distance test — it has no idea what land a coastline belongs to. On
+ // this deliberately fragmented archipelago (→ D-063) an ISLAND's only river can
+ // be suppressed by a mainland mouth 400 px away ACROSS WATER. Measured, not
+ // argued; the rule itself is NOT changed (it belongs to the analysis).
+ if (regions != null && suppressor >= 0)
+ {
+ int a = regions.Id[cell], b = regions.Id[suppressor];
+ if (a != 0 && b != 0 && a != b) { r.SuppressedCrossLandmass++; r.SuppressedCrossLandmassPx += acc; }
+ }
+ }
+ }
+ if (acc >= floorPx) sea.Add(c);
+ }
+
+ // ---- ENDORHEIC: every terminal basin, metrics re-derived ----
+ // After the analysis's reversion, BasinId is non-zero ONLY on terminal-basin cells, and
+ // Filled == the original height there — so FullFilled − height IS the fill depth, and the
+ // basin minimum is the argmin of height over the basin's cells. Both reconstruct exactly
+ // what the analysis computed internally as basinMinCell / basinDepthM / basinAreaPx.
+ int maxId = 0;
+ for (int i = 0; i < total; i++) if (plan.BasinId[i] > maxId) maxId = plan.BasinId[i];
+ var area = new long[maxId + 1];
+ var minCell = new int[maxId + 1];
+ var minH = new float[maxId + 1];
+ var depth = new float[maxId + 1];
+ for (int id = 0; id <= maxId; id++) { minCell[id] = -1; minH[id] = float.MaxValue; }
+ for (int i = 0; i < total; i++)
+ {
+ int id = plan.BasinId[i];
+ if (id == 0) continue;
+ area[id]++;
+ float h = height[i / n, i % n];
+ if (h < minH[id]) { minH[id] = h; minCell[id] = i; }
+ float d = WorldScale.MetresFromRaw(plan.FullFilled[i] - h);
+ if (d > depth[id]) depth[id] = d;
+ }
+
+ var endo = new List();
+ long endoSum = 0;
+ for (int id = 1; id <= maxId; id++)
+ {
+ if (minCell[id] < 0) continue;
+ long inflow = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0;
+ endoSum += inflow;
+ if (inflow < floorPx) continue;
+ endo.Add(new RiverCandidate
+ {
+ IsSea = false, Cell = minCell[id], X = minCell[id] / n, Y = minCell[id] % n,
+ TermX = minCell[id] / n, TermY = minCell[id] % n, // replaced at bind time by the stem's pooling point
+ DrainagePx = inflow, BasinId = id, BasinAreaPx = area[id], BasinDepthM = depth[id],
+ });
+ }
+
+ // ═══ ⚠⚠ THE COMPARABILITY ASSERTION — the whole unified ranking rests on this ═══
+ //
+ // Both metrics are counts of contributing LAND CELLS on the same D8 field, and every land
+ // cell has exactly one destination — so the two populations partition the land exactly.
+ // If this identity ever fails, the two numbers are not the same unit and ranking them in
+ // one list is meaningless. It is asserted per seed rather than argued in a comment.
+ long partition = seaSum + endoSum + plan.UnroutedCells;
+ if (seaSum != plan.SeaReachingCells || endoSum != plan.EndorheicCells || partition != plan.LandCells)
+ throw new InvalidOperationException(
+ "[RiverCandidates] METRIC COMPARABILITY VIOLATION — the unified ranking is not sound on this field.\n" +
+ $" Σ Acc over sea outlets = {seaSum:N0}, expected SeaReachingCells = {plan.SeaReachingCells:N0}\n" +
+ $" Σ BasinInflow = {endoSum:N0}, expected EndorheicCells = {plan.EndorheicCells:N0}\n" +
+ $" sum + unrouted = {partition:N0}, expected LandCells = {plan.LandCells:N0}\n" +
+ "Sea-outlet drainage area and endorheic credited inflow must be the same unit over the same " +
+ "population for one ranking to mean anything. Refusing to rank. (rivers/02 Part 0 §2.)");
+ GD.Print($" ✅ comparability: Σ sea Acc {seaSum:N0} + Σ BasinInflow {endoSum:N0} + unrouted {plan.UnroutedCells:N0} == land {plan.LandCells:N0} — same unit, exact partition");
+
+ // ---- the unified ranking: one list, both termini, descending by contributing cells ----
+ var ranked = new List();
+ foreach (var c in sea) if (!c.SuppressedBySeparation) ranked.Add(c);
+ ranked.AddRange(endo);
+ ranked.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
+ for (int i = 0; i < ranked.Count; i++) ranked[i].Rank = i + 1;
+ r.Ranked = ranked;
+ return r;
+ }
+
+ ///
+ /// Bind each candidate in to the Trunk / Giant the
+ /// analysis already traced, so plates draw REAL upland stems rather than anything reimplemented
+ /// here. Sea binds by outlet cell (identical greedy pick, identical order); endorheic binds by
+ /// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells at
+ /// the minimum height and the analysis's DFS tie-break need not match a row-major scan.
+ ///
+ /// ⚠ Also transfers the analysis's own and
+ /// for endorheic candidates — rivers/03
+ /// needs the reference's routed/lake-ender verdict to compare against its own.
+ ///
+ public static void BindCourses(DrainageAnalysis.Plan plan, int n, List need, string what)
+ {
+ var byOutlet = new Dictionary();
+ foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
+ var byBasin = new Dictionary();
+ foreach (var g in plan.Giants)
+ {
+ int cell = (int)g.Terminal.x * n + (int)g.Terminal.y;
+ int id = plan.BasinId[cell];
+ if (id > 0 && !byBasin.ContainsKey(id)) byBasin[id] = g;
+ }
+
+ int missing = 0;
+ foreach (var c in need)
+ {
+ if (c.Course != null) continue;
+ if (c.IsSea)
+ {
+ if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; }
+ }
+ else
+ {
+ // ⚠ Take the RIVER's terminus from the Giant, not the basin minimum this candidate
+ // is keyed on — see RiverCandidate.TermX. They differ on a flat basin floor, and
+ // marking the wrong one draws every endorheic stem detached from its own endpoint.
+ if (byBasin.TryGetValue(c.BasinId, out var g))
+ {
+ c.Course = g.Course; c.TermX = (int)g.Terminal.x; c.TermY = (int)g.Terminal.y;
+ c.AnalysisKind = g.Kind;
+ c.TerminalInClassifyWater = g.TerminalInClassifyWater;
+ }
+ }
+ if (c.Course == null) missing++;
+ }
+ if (missing > 0)
+ throw new InvalidOperationException(
+ $"[RiverCandidates] {missing} of {need.Count} candidates in {what} have no traced stem. The analysis's " +
+ "reporting caps are what produce the courses, so they must cover every candidate being drawn — " +
+ "raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
+ }
+ }
+}
diff --git a/Tools/Scripts/RiverPromotionTool.cs b/Tools/Scripts/RiverPromotionTool.cs
index c1e3c20..0c55cf2 100644
--- a/Tools/Scripts/RiverPromotionTool.cs
+++ b/Tools/Scripts/RiverPromotionTool.cs
@@ -364,142 +364,27 @@ namespace IslaApocalypse.Tools
GetTree().Quit(0);
}
- // ═══ ⭐⭐ ENUMERATION — the complete candidate set, derived from the exposed Plan arrays ═══
+ // ═══ ENUMERATION — delegated to RiverCandidates (extracted at rivers/03) ═══
+ //
+ // ⚠ The enumeration moved to `RiverCandidates` UNCHANGED so routing ranks the identical set this
+ // gate was judged on. Two copies would be two answers to "which rivers does the island have".
+ // Verified: rivers/02b's twelve plates are byte-identical across the extraction.
- ///
- /// Enumerate every candidate major drainage, cap-free.
- ///
- /// SEA every cell with Dir == D_SEA, carrying Acc at that cell, then the
- /// analysis's own greedy MinOutletSeparationPx rule so three mouths of one
- /// delta are not three rivers.
- /// ENDORHEIC every terminal basin present in BasinId, carrying BasinInflow[id],
- /// with its terminal cell / area / depth re-derived from the exposed surfaces.
- ///
- /// ⚠ Nothing here re-runs or re-implements the analysis: `Dir`, `Acc`, `BasinId`, `BasinInflow`
- /// and `FullFilled` are all exposed on `Plan`, and every derived quantity below is a
- /// reconstruction of a value the analysis computed internally, from those arrays.
- ///
private static SeedResult Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
long floorPx, int separationPx, int seed, RegionLabels regions)
{
- int total = n * n;
- var r = new SeedResult
+ var e = RiverCandidates.Enumerate(plan, height, n, floorPx, separationPx, regions);
+ return new SeedResult
{
- Seed = seed,
- LandCells = plan.LandCells, SeaReachingCells = plan.SeaReachingCells,
- EndorheicCells = plan.EndorheicCells, UnroutedCells = plan.UnroutedCells,
- TerminalBasins = plan.TerminalBasinCount,
+ Seed = seed, Ranked = e.Ranked,
+ LandCells = e.LandCells, SeaReachingCells = e.SeaReachingCells,
+ EndorheicCells = e.EndorheicCells, UnroutedCells = e.UnroutedCells,
+ TerminalBasins = e.TerminalBasins, SeaOutletsAll = e.SeaOutletsAll,
+ SuppressedCount = e.SuppressedCount, SuppressedPx = e.SuppressedPx,
+ SuppressedAboveFloor = e.SuppressedAboveFloor, SuppressedAboveFloorPx = e.SuppressedAboveFloorPx,
+ SuppressedCrossLandmass = e.SuppressedCrossLandmass, SuppressedCrossLandmassPx = e.SuppressedCrossLandmassPx,
+ SuppressedAboveFloorAccs = e.SuppressedAboveFloorAccs,
};
-
- // ---- SEA: every outlet, then the separation rule ----
- var outlets = new List<(int cell, long acc)>();
- long seaSum = 0;
- for (int i = 0; i < total; i++)
- if (plan.Dir[i] == DrainageAnalysis.D_SEA) { outlets.Add((i, plan.Acc[i])); seaSum += plan.Acc[i]; }
- outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
- r.SeaOutletsAll = outlets.Count;
-
- var sea = new List();
- var kept = new List();
- foreach (var (cell, acc) in outlets)
- {
- int cx = cell / n, cy = cell % n;
- bool far = true; int suppressor = -1;
- foreach (int pcell in kept)
- {
- float ddx = cx - pcell / n, ddy = cy - pcell % n;
- if (ddx * ddx + ddy * ddy < (float)separationPx * separationPx) { far = false; suppressor = pcell; break; }
- }
- var c = new RiverCandidate { IsSea = true, Cell = cell, X = cx, Y = cy, TermX = cx, TermY = cy, DrainagePx = acc, SuppressedBySeparation = !far };
- if (far) kept.Add(cell);
- else
- {
- r.SuppressedCount++; r.SuppressedPx += acc;
- if (acc >= floorPx)
- {
- r.SuppressedAboveFloor++; r.SuppressedAboveFloorPx += acc; r.SuppressedAboveFloorAccs.Add(acc);
- // ⚠⚠ IS THE SUPPRESSOR EVEN ON THE SAME LANDMASS? The separation rule is a plain
- // Euclidean distance test — it has no idea what land a coastline belongs to. On
- // this deliberately fragmented archipelago (→ D-063) that means an ISLAND's only
- // river can be suppressed by a mainland river's mouth 400 px away ACROSS WATER,
- // which is not a delta by any definition. Measured here rather than argued.
- // ⚠ The rule is NOT changed — it belongs to the analysis, and changing it would
- // move the candidate set the developer is being asked to judge. This counts
- // what it costs, so the count decision is made knowing it.
- if (regions != null && suppressor >= 0)
- {
- int a = regions.Id[cell], b = regions.Id[suppressor];
- if (a != 0 && b != 0 && a != b) { r.SuppressedCrossLandmass++; r.SuppressedCrossLandmassPx += acc; }
- }
- }
- }
- if (acc >= floorPx) sea.Add(c);
- }
-
- // ---- ENDORHEIC: every terminal basin, metrics re-derived ----
- // After the analysis's reversion, BasinId is non-zero ONLY on terminal-basin cells, and
- // Filled == the original height there — so FullFilled − height IS the fill depth, and the
- // basin minimum is the argmin of height over the basin's cells. Both reconstruct exactly
- // what the analysis computed internally as basinMinCell / basinDepthM / basinAreaPx.
- int maxId = 0;
- for (int i = 0; i < total; i++) if (plan.BasinId[i] > maxId) maxId = plan.BasinId[i];
- var area = new long[maxId + 1];
- var minCell = new int[maxId + 1];
- var minH = new float[maxId + 1];
- var depth = new float[maxId + 1];
- for (int id = 0; id <= maxId; id++) { minCell[id] = -1; minH[id] = float.MaxValue; }
- for (int i = 0; i < total; i++)
- {
- int id = plan.BasinId[i];
- if (id == 0) continue;
- area[id]++;
- float h = height[i / n, i % n];
- if (h < minH[id]) { minH[id] = h; minCell[id] = i; }
- float d = WorldScale.MetresFromRaw(plan.FullFilled[i] - h);
- if (d > depth[id]) depth[id] = d;
- }
-
- var endo = new List();
- long endoSum = 0;
- for (int id = 1; id <= maxId; id++)
- {
- if (minCell[id] < 0) continue;
- long inflow = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0;
- endoSum += inflow;
- if (inflow < floorPx) continue;
- endo.Add(new RiverCandidate
- {
- IsSea = false, Cell = minCell[id], X = minCell[id] / n, Y = minCell[id] % n,
- TermX = minCell[id] / n, TermY = minCell[id] % n, // replaced at bind time by the stem's pooling point
- DrainagePx = inflow, BasinId = id, BasinAreaPx = area[id], BasinDepthM = depth[id],
- });
- }
-
- // ═══ ⚠⚠ THE COMPARABILITY ASSERTION — the whole unified ranking rests on this ═══
- //
- // Both metrics are counts of contributing LAND CELLS on the same D8 field, and every land
- // cell has exactly one destination — so the two populations partition the land exactly.
- // If this identity ever fails, the two numbers are not the same unit and ranking them in
- // one list is meaningless. It is asserted per seed rather than argued in a comment.
- long partition = seaSum + endoSum + plan.UnroutedCells;
- if (seaSum != plan.SeaReachingCells || endoSum != plan.EndorheicCells || partition != plan.LandCells)
- throw new InvalidOperationException(
- "[RiverPromotion] METRIC COMPARABILITY VIOLATION — the unified ranking is not sound on this field.\n" +
- $" Σ Acc over sea outlets = {seaSum:N0}, expected SeaReachingCells = {plan.SeaReachingCells:N0}\n" +
- $" Σ BasinInflow = {endoSum:N0}, expected EndorheicCells = {plan.EndorheicCells:N0}\n" +
- $" sum + unrouted = {partition:N0}, expected LandCells = {plan.LandCells:N0}\n" +
- "Sea-outlet drainage area and endorheic credited inflow must be the same unit over the same " +
- "population for one ranking to mean anything. Refusing to rank. (rivers/02 Part 0 §2.)");
- GD.Print($" ✅ comparability: Σ sea Acc {seaSum:N0} + Σ BasinInflow {endoSum:N0} + unrouted {plan.UnroutedCells:N0} == land {plan.LandCells:N0} — same unit, exact partition");
-
- // ---- the unified ranking: one list, both termini, descending by contributing cells ----
- var ranked = new List();
- foreach (var c in sea) if (!c.SuppressedBySeparation) ranked.Add(c);
- ranked.AddRange(endo);
- ranked.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
- for (int i = 0; i < ranked.Count; i++) ranked[i].Rank = i + 1;
- r.Ranked = ranked;
- return r;
}
///
@@ -544,48 +429,9 @@ namespace IslaApocalypse.Tools
}
}
- ///
- /// Bind each promotable candidate to the Trunk / Giant the analysis already
- /// traced, so the plates draw REAL upland stems rather than anything reimplemented here.
- /// Sea binds by outlet cell (identical greedy pick, identical order); endorheic binds by
- /// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells
- /// at the minimum height and the analysis's DFS tie-break need not match a row-major scan.
- ///
+ /// Delegated to — see the note on Enumerate.
private static void BindCourses(SeedResult r, DrainageAnalysis.Plan plan, int n, List need, string what)
- {
- var byOutlet = new Dictionary();
- foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
- var byBasin = new Dictionary();
- foreach (var g in plan.Giants)
- {
- int cell = (int)g.Terminal.x * n + (int)g.Terminal.y;
- int id = plan.BasinId[cell];
- if (id > 0 && !byBasin.ContainsKey(id)) byBasin[id] = g;
- }
-
- int missing = 0;
- foreach (var c in need)
- {
- if (c.Course != null) continue;
- if (c.IsSea)
- {
- if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; }
- }
- else
- {
- // ⚠ Take the RIVER's terminus from the Giant, not the basin minimum this candidate is
- // keyed on — see RiverCandidate.TermX. They differ on a flat basin floor, and marking
- // the wrong one draws every endorheic stem detached from its own endpoint.
- if (byBasin.TryGetValue(c.BasinId, out var g)) { c.Course = g.Course; c.TermX = (int)g.Terminal.x; c.TermY = (int)g.Terminal.y; }
- }
- if (c.Course == null) missing++;
- }
- if (missing > 0)
- throw new InvalidOperationException(
- $"[RiverPromotion] {missing} of {need.Count} candidates in {what} have no traced stem. The analysis's " +
- "reporting caps are what produce the courses, so they must cover every candidate being drawn — " +
- "raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
- }
+ => RiverCandidates.BindCourses(plan, n, need, what);
// ═══ ⭐⭐ THE COMPOSITION (rivers/02b) — the same N, two mixes ════════════════════════════
diff --git a/Tools/Scripts/RiverRouting.cs b/Tools/Scripts/RiverRouting.cs
new file mode 100644
index 0000000..b1ac24b
--- /dev/null
+++ b/Tools/Scripts/RiverRouting.cs
@@ -0,0 +1,400 @@
+using System;
+using System.Collections.Generic;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ LOWLAND ROUTING (rivers/03) — the ROUTING PORTION of the reference's `RiverCarvePass`,
+ /// ported faithfully (D-050). **Courses only. This file reads heights and writes none.**
+ ///
+ /// ═══ ⛔ THE RED LINE ═══
+ ///
+ /// **Nothing here fills water, creates a water body, or mutates any height field.** It produces
+ /// polylines. The bed CARVE (`CarveRiver`, mutates render height, flood-guarded) and the STEPPED
+ /// WATER model (`AddSteppedWater`, creates bodies) are the reference's separate stages and are
+ /// separate later tasks. Verified at rivers/03 Part 0: in the reference, routing is pure — the
+ /// carve mutates, and `AddSteppedWater` is a call the CALLER makes afterwards, not something
+ /// `Apply` does. Lake-enders target EXISTING classify water; no lake is ever created.
+ ///
+ /// ═══ ⭐ WHY THE COST MODEL IS THE LOAD-BEARING PIECE ═══
+ ///
+ /// **An endorheic terminal is a local minimum by definition** — a downhill path out of it does not
+ /// exist, so "can it flow to the sea?" cannot be answered by descent. It is answered by cost: the
+ /// cheapest LOWGROUND path is allowed to climb over the basin's rim, paying heavily for it
+ /// (uphill penalised, never forbidden). That is the route-version of an overflow channel — a
+ /// channel over the spill, **with no water filled**.
+ ///
+ /// SHORT cost ≈ distance, uphill lightly penalised — heads direct, avoids walls. (Rejected
+ /// by the reference's own gate as "a dead-straight canal"; ported for completeness.)
+ /// LOWGROUND cost ≈ BEING high (per px of travel) plus heavily for CLIMBING, so the cheapest
+ /// corridor is the lowest ground even when that wanders. **The locked style.**
+ ///
+ /// ═══ ⚠⚠ THE CONSTANTS ARE DECLARED == EFFECTIVE, AND THAT WAS CHECKED ═══
+ ///
+ /// `00_ground` warned that the reference's effective river tunables live in `ConfigManager`, not in
+ /// the `Params` initializers (WidthScale 1.0→1.75, DepthScale 1.0→1.5). **Those are carve-time and
+ /// out of scope here.** The four ROUTING cost constants below are `private const` inside
+ /// `RiverCarvePass` with no `ConfigManager` key and no `[Export]` anywhere in the reference repo —
+ /// verified by grep at rivers/03 Part 0 — so for routing, declared IS effective. The one routing
+ /// value that does come from config is the STYLE, effective `"lowground"`, which equals the
+ /// declared default.
+ ///
+ public static class RiverRouting
+ {
+ public const byte StyleShort = 0;
+ public const byte StyleLowground = 1;
+
+ // ⚠ Ported verbatim. SHORT pays lightly for climbing (8 per metre of rise, so a 10 m wall costs
+ // like an 80 px detour). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
+ // heavily for climbing (50 per metre).
+ public const float ShortUphillPerM = 8f;
+ public const float LowgroundElevPerM = 1f;
+ public const float LowgroundBase = 0.05f;
+ public const float LowgroundUphillPerM = 50f;
+
+ /// The reference's smallest water body a lake-ender may target (`RiverLakeMinTargetPx`,
+ /// effective 20,000 — declared and config agree). "Nearest wet pixel" routed one into a 3-cell
+ /// puddle a few hundred px short of the obvious lagoon; that was the task-23 gate finding.
+ public const int LakeMinTargetPx = 20_000;
+
+ // 8-connectivity in the reference's exact order — the tie-break structure is part of the result.
+ 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 };
+
+ /// One lowland route, with the diagnostics the gate needs to judge it.
+ public sealed class Route
+ {
+ /// Terminal → target, 1-px steps, as Dijkstra produced it. Empty when no path exists.
+ public List<(float x, float y)> Path = new();
+ /// The same reach after RDP + Chaikin. This is what is drawn and spliced.
+ public List<(float x, float y)> Smoothed = new();
+ /// ⭐ Did a path exist at all? Empty list on no path — never thrown.
+ public bool Reached;
+ /// Dijkstra cost at the goal (cost-model units, not metres).
+ public float Cost;
+ /// ⭐⭐ THE RIM: the largest single-step climb on the route, metres. The number that
+ /// says whether a route crawls over a saddle or vaults a wall.
+ public float MaxStepUphillM;
+ /// ⭐ Total metres climbed along the route, and how many steps climbed at all.
+ public float TotalUphillM;
+ public int UphillSteps;
+ /// Highest point on the route, metres above sea — the rim's absolute height.
+ public float MaxElevM;
+ /// Net climb from the terminal to the route's high point, metres — what "over the rim" costs.
+ public float RimClimbM;
+ /// ⭐ WHERE the route tops out — the rim cell, ringed on the plate.
+ public (float x, float y) RimPoint;
+ public float LenPx, StraightPx, WanderRatio;
+ /// Cells settled by the search — the honest cost of a Dijkstra at this map size.
+ public long Expanded;
+ public (float x, float y) Target;
+ }
+
+ ///
+ /// ⭐ Deterministic Dijkstra from a start cell to the nearest cell of
+ /// under the selected cost model. Ported from `RiverCarvePass.RouteToOcean`.
+ ///
+ /// ⚠ **Returns an empty path when no path exists — it never throws.** That contract is
+ /// load-bearing: "no affordable route" is a RESULT (the river is a lake-ender), not an error.
+ ///
+ /// ⚠ `targets` is a generic mask: `OceanMask` for a route to the sea, significant-water for a
+ /// lake-ender's extension. One routine, two uses — as the reference has it.
+ ///
+ /// Determinism: the priority is `(cost, cellIndex)`, so equal costs break on the lower index and
+ /// the result cannot depend on heap internals. The search settles a cell once (`closed`) and
+ /// stops the moment it DEQUEUES a target, so the first target reached is the cheapest.
+ ///
+ public static Route RouteTo(float[,] height, int n, bool[] targets, int sx, int sy, byte style, float sea)
+ {
+ int total = n * n;
+ var gcost = new float[total];
+ var parent = new int[total];
+ var closed = new bool[total];
+ Array.Fill(gcost, float.MaxValue);
+ Array.Fill(parent, -1);
+
+ // ⚠ Elevation is clamped at sea: below-sea ground is not "cheaper than sea level", it is sea
+ // level. Without the clamp a route would dive for the deepest hole it could find.
+ float ElevM(int x, int y) => MathF.Max(0f, WorldScale.MetresFromRaw(height[x, y] - sea));
+
+ var pq = new PriorityQueue();
+ int start = sx * n + sy;
+ gcost[start] = 0f;
+ pq.Enqueue(start, (0f, start));
+ int goal = -1;
+ long expanded = 0;
+
+ while (pq.Count > 0)
+ {
+ int c = pq.Dequeue();
+ if (closed[c]) continue;
+ closed[c] = true;
+ expanded++;
+ if (targets[c]) { goal = c; break; }
+ int cx = c / n, cy = c % n;
+ float hc = height[cx, cy];
+ 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 (closed[ni]) continue;
+ float dhM = MathF.Max(0f, WorldScale.MetresFromRaw(height[nx, ny] - hc));
+ float step = style == StyleShort
+ ? DIST[k] + dhM * ShortUphillPerM
+ : DIST[k] * (LowgroundBase + ElevM(nx, ny) * LowgroundElevPerM)
+ + dhM * LowgroundUphillPerM;
+ float nc = gcost[c] + step;
+ if (nc < gcost[ni])
+ {
+ gcost[ni] = nc;
+ parent[ni] = c;
+ pq.Enqueue(ni, (nc, ni));
+ }
+ }
+ }
+
+ var r = new Route { Expanded = expanded };
+ if (goal < 0) return r; // no path — an empty route, reported upstream
+
+ for (int c = goal; c >= 0; c = parent[c]) r.Path.Add((c / n, c % n));
+ r.Path.Reverse();
+ r.Reached = true;
+ r.Cost = gcost[goal];
+ r.Target = r.Path[^1];
+ Measure(r, height, n, sea);
+ r.Smoothed = SmoothCourse(r.Path);
+ return r;
+ }
+
+ ///
+ /// The diagnostics the gate reads — measured on the RAW path, before smoothing, because the
+ /// rim it crossed is a fact about the terrain and must not be a function of the pretty pass.
+ ///
+ private static void Measure(Route r, float[,] height, int n, float sea)
+ {
+ float startElev = ElevAt(r.Path[0]);
+ float maxElev = startElev;
+ r.RimPoint = r.Path[0];
+ for (int i = 1; i < r.Path.Count; i++)
+ {
+ var a = r.Path[i - 1]; var b = r.Path[i];
+ float dx = b.x - a.x, dy = b.y - a.y;
+ r.LenPx += MathF.Sqrt(dx * dx + dy * dy);
+ float climb = ElevAt(b) - ElevAt(a);
+ if (climb > 0f) { r.TotalUphillM += climb; r.UphillSteps++; }
+ if (climb > r.MaxStepUphillM) r.MaxStepUphillM = climb;
+ if (ElevAt(b) > maxElev) { maxElev = ElevAt(b); r.RimPoint = b; }
+ }
+ r.MaxElevM = maxElev;
+ r.RimClimbM = maxElev - startElev;
+ var s = r.Path[0]; var e = r.Path[^1];
+ r.StraightPx = MathF.Sqrt((e.x - s.x) * (e.x - s.x) + (e.y - s.y) * (e.y - s.y));
+ // ⚠ Wander is POLYLINE length over straight-line — a cell count undercounts diagonal steps
+ // and can read below 1, which is geometrically impossible. (The reference's own fix.)
+ r.WanderRatio = r.StraightPx > 1f ? r.LenPx / r.StraightPx : 1f;
+
+ float ElevAt((float x, float y) p) =>
+ MathF.Max(0f, WorldScale.MetresFromRaw(height[(int)p.x, (int)p.y] - sea));
+ }
+
+ // ---- Route smoothing — ported verbatim: RDP(4.0) + 4 Chaikin passes, endpoints pinned ------
+ //
+ // ⚠⚠ THIS IS APPLIED TO THE LOWLAND REACH ONLY, NEVER THE UPLAND STEM, and that split is not a
+ // style preference — it is a measured result. The Dijkstra's 45° kinks live on near-flat ground
+ // where a rounded corner costs nothing. The upland stems already thread the erosion-carved
+ // valley FLOORS; smoothing them cuts the corners off the valleys themselves, which in the
+ // reference took the max cut from 14.6 m to 27.3 m.
+
+ /// RDP tol 4 + 4 Chaikin corner-cutting passes, endpoints pinned.
+ public static List<(float x, float y)> SmoothCourse(List<(float x, float y)> raw)
+ {
+ if (raw.Count < 3) return raw;
+ var dec = Rdp(raw, 0, raw.Count - 1, 4.0f);
+ if (dec.Count < 3) return raw;
+ var sm = dec;
+ for (int pass = 0; pass < 4; pass++)
+ {
+ var nxt = new List<(float x, float y)>(sm.Count * 2) { sm[0] };
+ for (int i = 0; i + 1 < sm.Count; i++)
+ {
+ var a = sm[i]; var b = sm[i + 1];
+ nxt.Add((a.x * 0.75f + b.x * 0.25f, a.y * 0.75f + b.y * 0.25f));
+ nxt.Add((a.x * 0.25f + b.x * 0.75f, a.y * 0.25f + b.y * 0.75f));
+ }
+ nxt.Add(sm[^1]);
+ sm = nxt;
+ }
+ return sm;
+ }
+
+ private static List<(float x, float y)> Rdp(List<(float x, float y)> pts, int i0, int i1, float tol)
+ {
+ if (i1 - i0 <= 1) return new List<(float x, float y)> { pts[i0], pts[i1] };
+ var a = pts[i0]; var b = pts[i1];
+ float abx = b.x - a.x, aby = b.y - a.y;
+ float abLen = MathF.Sqrt(abx * abx + aby * aby);
+ float maxD = 0f; int maxI = i0;
+ for (int i = i0 + 1; i < i1; i++)
+ {
+ float d = abLen < 1e-6f
+ ? MathF.Sqrt((pts[i].x - a.x) * (pts[i].x - a.x) + (pts[i].y - a.y) * (pts[i].y - a.y))
+ : MathF.Abs(abx * (a.y - pts[i].y) - (a.x - pts[i].x) * aby) / abLen;
+ if (d > maxD) { maxD = d; maxI = i; }
+ }
+ if (maxD <= tol) return new List<(float x, float y)> { pts[i0], pts[i1] };
+ var left = Rdp(pts, i0, maxI, tol);
+ var right = Rdp(pts, maxI, i1, tol);
+ left.RemoveAt(left.Count - 1);
+ left.AddRange(right);
+ return left;
+ }
+
+ /// The three classes the MIX is made of.
+ public enum RiverClass
+ {
+ /// Sea-reaching already, exactly as erosion carved it. No lowland route needed.
+ OceanTrunk,
+ /// An endorheic basin connected to the coast by a routed over-the-rim channel.
+ RoutedGiant,
+ /// Stays inland: terminates at a significant lake, or at its own terminal.
+ LakeEnder,
+ }
+
+ /// One promoted river, classified, routed and assembled.
+ public sealed class RoutedRiver
+ {
+ public RiverCandidate Candidate;
+ public RiverClass Class;
+ /// The lowland reach actually used: the ocean route for a routed giant, the lake
+ /// route for a lake-ender. Null for trunks.
+ public Route Lowland;
+ /// ⭐ The ocean route computed for EVERY giant, including lake-enders — see the note
+ /// on . This is what makes an affordability threshold judgeable.
+ public Route OceanProbe;
+ /// Lake-enders: did the extension reach a SIGNIFICANT body (vs the classify fallback, vs nothing)?
+ public bool LakeReached, LakeWasFallback;
+ /// Head → terminus, stem + smoothed lowland reach.
+ public List<(float x, float y)> Course;
+ public string Why = "";
+
+ public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
+ }
+
+ ///
+ /// ⭐⭐ CLASSIFY AND ROUTE THE PROMOTED SET.
+ ///
+ /// ═══ ⚠⚠⚠ WHAT DECIDES routed-vs-lake-ender, AND WHY IT IS NOT A PATH TEST ═══
+ ///
+ /// rivers/03's task states the sort as *"an affordable over-the-rim LOWGROUND path to the ocean
+ /// exists → routed-through; none → lake-ender."* **Ported literally, that test classifies
+ /// everything as routed, because on an 8-connected grid with all-finite costs a path to the
+ /// ocean ALWAYS exists.** `RouteTo` returns empty only when the queue drains without reaching a
+ /// target, which cannot happen when the ocean is reachable at *some* price. There is no "none".
+ /// The word doing the work is *affordable*, and no threshold is specified anywhere.
+ ///
+ /// **So the reference's sort is used, because it is the one that actually discriminates:**
+ ///
+ /// Kind = (basinHasLake[id] && !SouthernCandidate) ? "lake-ender" : "routed"
+ ///
+ /// i.e. **does the terminal basin hold classify water?** A basin that is already a lake is a
+ /// natural lake-ender; a dry pan gets routed to the sea. That is `DrainageAnalysis`'s own
+ /// verdict, carried on `Giant.Kind`, and this port consumes it rather than inventing a rule.
+ /// (v2 has no towns, so `southernPick` is −1 and the southern override never fires.)
+ ///
+ /// ⭐ **And the missing threshold is surfaced rather than guessed:** the ocean route is computed
+ /// for EVERY giant, lake-enders included (), so the batch can
+ /// report what each one WOULD cost and how high a rim it WOULD have to cross. That turns
+ /// "affordable" from an unstated assumption into a number the developer can put a bar under.
+ /// **Nothing is locked here — the classification shown is the reference's.**
+ ///
+ public static List RouteAll(List promoted, float[,] height, int n,
+ bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
+ Action log)
+ {
+ var outp = new List();
+ foreach (var c in promoted)
+ {
+ var rr = new RoutedRiver { Candidate = c };
+
+ if (c.IsSea)
+ {
+ // A natural ocean trunk needs no lowland route: erosion already carried it to the
+ // coast, and its outlet is ON the coast by construction. The stem IS the course.
+ rr.Class = RiverClass.OceanTrunk;
+ rr.Course = new List<(float x, float y)>(c.Course);
+ rr.Course.Reverse();
+ rr.Why = "sea outlet — erosion already reaches the coast; no lowland route needed";
+ outp.Add(rr);
+ log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px TRUNK (natural, {rr.Course.Count} pts)");
+ continue;
+ }
+
+ // ⭐ The ocean probe, for every giant — the affordability evidence.
+ var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
+ rr.OceanProbe = probe;
+
+ bool refLakeEnder = c.AnalysisKind == "lake-ender";
+ if (!refLakeEnder)
+ {
+ rr.Class = RiverClass.RoutedGiant;
+ rr.Lowland = probe;
+ rr.Why = probe.Reached
+ ? $"dry pan → routed; rim climb {probe.RimClimbM:F1} m, max step {probe.MaxStepUphillM:F2} m, cost {probe.Cost:N0}"
+ : "dry pan → routed, but NO path to the ocean was found (unexpected — report)";
+ }
+ else
+ {
+ rr.Class = RiverClass.LakeEnder;
+ // The stem pools on dry ground short of its lake BECAUSE the pooling point is a local
+ // minimum — a blind descent dead-ends there immediately. Route to the nearest
+ // SIGNIFICANT body with the same lowground Dijkstra, so the course joins the lake.
+ // ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
+ var ext = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
+ if (!ext.Reached)
+ {
+ // Fall back to ANY classify water, so a seed whose lake-ender genuinely has only
+ // small ponds still connects rather than dead-ending.
+ var fb = RouteTo(height, n, isClassifyWater, c.TermX, c.TermY, StyleLowground, sea);
+ if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
+ }
+ if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; }
+ rr.Why = rr.LakeReached
+ ? $"terminal basin holds classify water → lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away"
+ : "terminal basin holds classify water → lake-ender; no water body reachable, course ends at its terminal";
+ }
+
+ rr.Course = Assemble(c.Course, rr.Lowland);
+ outp.Add(rr);
+ log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {(rr.Class == RiverClass.RoutedGiant ? "ROUTED " : "LAKE-ENDER")} " +
+ $"probe{(probe.Reached ? $" reached cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2} expanded {probe.Expanded:N0}" : " NO PATH")}" +
+ $"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}");
+ }
+ return outp;
+ }
+
+ ///
+ /// ⭐ Assemble one river's full course: upland stem (head → terminal) + the smoothed lowland
+ /// reach (terminal → target).
+ ///
+ /// ⚠ `Course` from the analysis is DOWNSTREAM-FIRST and decimated ×4, so it is reversed to run
+ /// head → terminal, exactly as the reference does. The route's first point IS the terminal, so
+ /// it is skipped when splicing — otherwise the join carries a duplicate vertex.
+ ///
+ /// ⚠ The reference then DENSIFIES the spliced polyline to ~1-px samples. That is done inside
+ /// `CarveRiver`, for the bed stamp — it is carve-time and deliberately not done here: this task
+ /// produces courses, and a densified polyline draws and measures identically.
+ ///
+ public static List<(float x, float y)> Assemble(List<(float x, float y)> uplandStem, Route lowland)
+ {
+ var pts = new List<(float x, float y)>(uplandStem);
+ pts.Reverse(); // downstream-first → head → terminal
+ if (lowland != null && lowland.Smoothed != null && lowland.Smoothed.Count > 1)
+ pts.AddRange(lowland.Smoothed.GetRange(1, lowland.Smoothed.Count - 1));
+ return pts;
+ }
+ }
+}
diff --git a/Tools/Scripts/RiverRoutingTool.cs b/Tools/Scripts/RiverRoutingTool.cs
new file mode 100644
index 0000000..3d9638b
--- /dev/null
+++ b/Tools/Scripts/RiverRoutingTool.cs
@@ -0,0 +1,649 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ LOWLAND ROUTING (rivers/03) — route the promoted rivers, and make the MIX visible.
+ ///
+ /// ═══ WHAT THIS TASK IS FOR ═══
+ ///
+ /// rivers/02 promoted a set; this makes each member reach its true terminus, and shows the split the
+ /// developer asked to see clearly:
+ ///
+ /// NATURAL OCEAN TRUNKS sea-reaching already, left exactly as erosion made them
+ /// ROUTED-THROUGH GIANTS endorheic basins connected to the coast over their rim
+ /// INLAND LAKE-ENDERS staying inland, joining a significant water body
+ ///
+ /// The judgment it feeds: **does routing the giants yield enough substantial, well-spread coastal
+ /// rivers to dissolve the parked sea-river quota (rivers/02b), or not?**
+ ///
+ /// ═══ ⛔ THE RED LINE — COURSES ONLY ═══
+ ///
+ /// **No height is mutated. No water is filled. Nothing is carved.** This ports the ROUTING PORTION
+ /// of the reference's `RiverCarvePass` — `RouteToOcean`, the routed/lake-ender sort, `SmoothCourse`
+ /// — and deliberately NOT `CarveRiver` (bed stamp) or `AddSteppedWater` (water bodies), which are
+ /// separate later tasks. The tool ASSERTS both height fields are unchanged across routing, in the
+ /// flood-guard discipline erosion and drainage established: a claim that is checked, not promised.
+ ///
+ /// ═══ RUNNING IT ═══
+ ///
+ /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
+ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/RiverRoutingTool.tscn
+ ///
+ /// ISLA_TASK / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
+ /// ISLA_PROMOTE_N the promoted count to route (default 12 — the PURE set, no quota)
+ /// ISLA_PROMOTE_FLOOR_PX candidate significance floor (default 5000, as rivers/02)
+ /// ISLA_PROMOTE_MAX analysis reporting caps, so every promoted river has a traced stem (24)
+ /// ISLA_ROUTING_STYLE "lowground" (default, the locked style) | "short"
+ /// ISLA_LAKE_MIN_TARGET_PX significant-water threshold (default 20000, the reference's effective)
+ ///
+ public partial class RiverRoutingTool : 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()
+ {
+ // ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits with no
+ // main loop to end it, so a misconfigured run HANGS. Catch, say what was refused, exit 2.
+ 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 List Rivers;
+ public int Trunks, Routed, Lakes;
+ public int SeaReaching;
+ /// ⭐ DISTINCT ocean mouth cells — routes are computed per giant with nothing
+ /// coordinating them, so two can land on the same cell. This is the honest river count.
+ public int DistinctMouths;
+ /// Pairs of sea-reaching rivers sharing a mouth cell, as "#a+#b".
+ public List SharedMouths = new();
+ public long LandCells, EndorheicCells;
+ public int CandidateCount, SeaCandidates, EndoCandidates;
+ public int WaterBodiesKept, WaterBodiesTotal;
+ public long WaterCellsKept, LargestWaterPx;
+ public double RoutingSeconds;
+ public long TotalExpanded;
+ public string Spread = "";
+ public ulong Ms;
+ }
+
+ private void Run()
+ {
+ ToolingPaths.Configure(OS.GetUserDataDir());
+ ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
+
+ int task = EnvInt("ISLA_TASK", 3);
+ string descr = EnvStr("ISLA_BATCH", "lowland_routing");
+ int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
+ int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
+ int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
+ long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
+ int promoteN = EnvInt("ISLA_PROMOTE_N", 12);
+ int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
+ int lakeMinPx = EnvInt("ISLA_LAKE_MIN_TARGET_PX", RiverRouting.LakeMinTargetPx);
+ bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
+ string styleS = EnvStr("ISLA_ROUTING_STYLE", "lowground").Trim().ToLowerInvariant();
+ if (styleS != "lowground" && styleS != "short")
+ throw new InvalidOperationException($"ISLA_ROUTING_STYLE '{styleS}' — expected 'lowground' (the locked style) or 'short'.");
+ byte style = styleS == "short" ? RiverRouting.StyleShort : RiverRouting.StyleLowground;
+
+ if (promoteMax < promoteN)
+ throw new InvalidOperationException(
+ $"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN}). The cap is what makes the " +
+ "analysis trace a real upland stem for each promoted river; below it, a river would have no course to route from.");
+
+ // ⚠ rivers/01: the shape AND erosion come from the bare defaults. Assert before generating.
+ TerrainShapeV1.Assert("RiverRouting");
+ TerrainShapeV1.AssertErosionDefaultOn("RiverRouting");
+
+ string batchRoot = ToolingPaths.BatchRoot(task, descr);
+ DirAccess.MakeDirRecursiveAbsolute(batchRoot);
+ DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
+
+ var anchors = CurveAnchors.Default;
+ float sea = 0.15f;
+
+ // Reporting caps raised so every promoted river has a traced stem — exactly as rivers/02.
+ // ⚠⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are NOT touched: they
+ // decide which depressions BECOME terminal basins, i.e. they define the routing surface.
+ var dp = new DrainageAnalysis.Params
+ {
+ SeaLevel = sea,
+ TrunkCount = promoteMax,
+ GiantCount = promoteMax,
+ EndorheicMaxCount = promoteMax,
+ EndorheicMinInflowPx = (int)floorPx,
+ };
+ var dpDefaults = new DrainageAnalysis.Params();
+
+ GD.Print("==================================================================");
+ GD.Print(" LOWLAND ROUTING (rivers/03) — the routed MIX on the pure top-N, COURSES ONLY");
+ 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($"promoted : PURE top {promoteN} by drainage (unified ranking, rivers/02). NO QUOTA — rivers/02b's K stays parked and unlocked.");
+ GD.Print($"style : {styleS.ToUpperInvariant()} — cost = DIST x ({RiverRouting.LowgroundBase} + elevM x {RiverRouting.LowgroundElevPerM}) + climbM x {RiverRouting.LowgroundUphillPerM} (effective == declared; verified no ConfigManager/[Export] override)");
+ GD.Print($"sort : the REFERENCE's — Kind = (basinHasLake && !southern) ? lake-ender : routed.");
+ GD.Print($" ⚠ NOT a path test: on an 8-connected grid a path to the ocean ALWAYS exists, so");
+ GD.Print($" \"a path exists → routed\" would classify everything as routed. The ocean route is");
+ GD.Print($" still probed for EVERY giant so the missing affordability threshold is a number, not a guess.");
+ GD.Print($"lake target: significant water = 8-connected classify-water components >= {lakeMinPx:N0} px (interim for v2's missing water-bodies table)");
+ GD.Print($"⛔ RED LINE : courses only — no height mutated, no water filled, nothing carved. ASSERTED per seed.");
+ GD.Print($"batch : {batchRoot}");
+ GD.Print("==================================================================");
+
+ if (mapSize != 8192)
+ GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the DrainageAnalysis params are ABSOLUTE PIXEL COUNTS tuned at 8192 " +
+ "(rivers/02). At a smaller size few or no depressions qualify as terminal basins, so the " +
+ "routed/lake-ender SORT cannot be exercised. A smaller run validates the PIPELINE only and " +
+ "MUST NOT be used to judge the mix.");
+
+ 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 = "routing",
+ 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 — from the region layer, on the CLASSIFY field (→ D-066).
+ 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;
+
+ 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}");
+
+ var e = RiverCandidates.Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, p1.Regions);
+ var promoted = e.Ranked.GetRange(0, Math.Min(promoteN, e.Ranked.Count));
+ RiverCandidates.BindCourses(plan, mapSize, promoted, $"the pure top {promoteN}");
+ int pSea = 0; foreach (var c in promoted) if (c.IsSea) pSea++;
+ GD.Print($" promoted: pure top {promoted.Count} — {pSea} sea / {promoted.Count - pSea} endorheic (from {e.Ranked.Count} candidates: {e.SeaCount} sea / {e.Ranked.Count - e.SeaCount} endorheic)");
+
+ // ⭐ The interim significant-water mask — v2 has no water-bodies table (a known port gap).
+ 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 body {largestPx:N0} px)");
+
+ // ═══ ⛔ THE RED-LINE GUARD — snapshot both height fields BEFORE routing ═══
+ ulong hRenderBefore = Digest(p2.Height, mapSize);
+ ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
+
+ GD.Print($" routing (style {styleS}) — probing the ocean for every giant:");
+ ulong tr0 = Time.GetTicksMsec();
+ var rivers = RiverRouting.RouteAll(promoted, p2.Height, mapSize, isOcean, isClassifyWater,
+ significant, sea, style, m => GD.Print(m));
+ double routingSec = (Time.GetTicksMsec() - tr0) / 1000.0;
+
+ // ═══ ⛔ …and assert they are byte-identical after ═══
+ ulong hRenderAfter = Digest(p2.Height, mapSize);
+ ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
+ if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
+ throw new InvalidOperationException(
+ "[RiverRouting] RED-LINE VIOLATION: a height field CHANGED across routing.\n" +
+ $" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
+ $" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
+ "This task produces COURSES ONLY — it must never mutate a height, fill water, or carve. " +
+ "The bed carve and the stepped-water model are separate later tasks. Refusing to continue.");
+ GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across routing — nothing carved, no water filled.");
+
+ var r = new SeedResult
+ {
+ Seed = seed, Rivers = rivers,
+ LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
+ CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount,
+ EndoCandidates = e.Ranked.Count - e.SeaCount,
+ WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies,
+ WaterCellsKept = keptCells, LargestWaterPx = largestPx,
+ RoutingSeconds = routingSec,
+ };
+ foreach (var rr in rivers)
+ {
+ if (rr.Class == RiverRouting.RiverClass.OceanTrunk) r.Trunks++;
+ else if (rr.Class == RiverRouting.RiverClass.RoutedGiant) r.Routed++;
+ else r.Lakes++;
+ if (rr.OceanProbe != null) r.TotalExpanded += rr.OceanProbe.Expanded;
+ }
+ r.SeaReaching = r.Trunks + r.Routed;
+ MeasureMouths(r, rivers);
+ r.Spread = Spread(rivers, mapSize);
+ r.Ms = Time.GetTicksMsec() - t0;
+
+ GD.Print($" ⭐ MIX: {r.Trunks} natural trunks + {r.Routed} routed-through + {r.Lakes} lake-enders = {rivers.Count}");
+ GD.Print($" → SEA-REACHING RIVERS: {r.SeaReaching}, at {r.DistinctMouths} DISTINCT mouths" +
+ (r.SharedMouths.Count > 0 ? $" ⚠ shared mouth: {string.Join(", ", r.SharedMouths)}" : "") +
+ $" spread: {r.Spread}");
+ GD.Print($" routing {routingSec:F1}s, {r.TotalExpanded:N0} cells settled across all probes");
+
+ WriteRiverCsv(batchRoot, r);
+ RenderSeed(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, promoteN, skipRaw);
+ results.Add(r);
+ }
+
+ WriteIndex(batchRoot, mapSize, calibSize, seeds, results, promoteN, floorPx, promoteMax, lakeMinPx, styleS, dpDefaults, skipRaw);
+ GD.Print("\n==================================================================");
+ GD.Print($" DONE — {batchRoot}");
+ GD.Print(" ⛔ TASTE GATE: the MIX is PRESENTED, not decided. No count, no K, no style, no default was set.");
+ GD.Print(" ⛔ COURSES ONLY: no height mutated, no water filled, nothing carved — asserted per seed.");
+ GD.Print("==================================================================");
+ GetTree().Quit(0);
+ }
+
+ ///
+ /// FNV-1a over the raw float bits of a whole field. ⚠ Over the BITS, not the values: this must
+ /// catch a change no float comparison would (a −0 written over a +0, a NaN payload), because the
+ /// claim being checked is "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;
+ }
+
+ ///
+ /// ⭐⭐ How many DISTINCT places the island's rivers actually meet the sea.
+ ///
+ /// ⚠⚠ NOT the same as the sea-reaching count, and the difference is not a rounding detail. Each
+ /// giant is routed INDEPENDENTLY to its nearest ocean cell, with nothing coordinating the
+ /// routes — so two basins whose cheapest corridor is the same valley arrive at the same cell and
+ /// share one mouth. The analysis dedups NATURAL outlets (`MinOutletSeparationPx = 400`); nothing
+ /// dedups ROUTED ones. Measured rather than assumed, because "how many coastal rivers" is the
+ /// question this whole gate exists to answer.
+ ///
+ private static void MeasureMouths(SeedResult r, List rivers)
+ {
+ var at = new Dictionary<(int x, int y), List>();
+ foreach (var rr in rivers)
+ {
+ if (!rr.ReachesSea) continue;
+ (int x, int y) key;
+ if (rr.Class == RiverRouting.RiverClass.OceanTrunk) key = (rr.Candidate.TermX, rr.Candidate.TermY);
+ else if (rr.Lowland != null && rr.Lowland.Reached) key = ((int)rr.Lowland.Target.x, (int)rr.Lowland.Target.y);
+ else continue;
+ if (!at.TryGetValue(key, out var l)) { l = new List(); at[key] = l; }
+ l.Add(rr.Candidate.Rank);
+ }
+ r.DistinctMouths = at.Count;
+ foreach (var kv in at)
+ if (kv.Value.Count > 1)
+ r.SharedMouths.Add("#" + string.Join("+#", kv.Value) + $" at ({kv.Key.x},{kv.Key.y})");
+ }
+
+ ///
+ /// Where the sea-reaching rivers actually meet the coast. ⚠ In this codebase a cell is
+ /// x*n + y and the image is drawn SetPixel(x, y), so +y is SOUTH on the plate.
+ ///
+ private static string Spread(List rivers, int n)
+ {
+ var counts = new Dictionary();
+ int total = 0;
+ foreach (var r in rivers)
+ {
+ if (!r.ReachesSea) continue;
+ float mx, my;
+ if (r.Class == RiverRouting.RiverClass.OceanTrunk) { mx = r.Candidate.TermX; my = r.Candidate.TermY; }
+ else if (r.Lowland != null && r.Lowland.Reached) { mx = r.Lowland.Target.x; my = r.Lowland.Target.y; }
+ else continue;
+ string c = Compass(mx, my, n);
+ counts.TryGetValue(c, out int cur);
+ counts[c] = cur + 1;
+ total++;
+ }
+ if (total == 0) return "none";
+ var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" };
+ var parts = new List();
+ foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}");
+ return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)";
+ }
+
+ private static string Compass(float x, float y, int n)
+ {
+ float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; // dy > 0 = south
+ const float band = 0.35f;
+ string ns = dy < -band ? "N" : dy > band ? "S" : "";
+ string ew = dx < -band ? "W" : dx > band ? "E" : "";
+ return ns + ew == "" ? "centre" : ns + ew;
+ }
+
+ private static string ClassName(RiverRouting.RiverClass c) => c switch
+ {
+ RiverRouting.RiverClass.OceanTrunk => "trunk",
+ RiverRouting.RiverClass.RoutedGiant => "routed",
+ _ => "lake-ender",
+ };
+
+ private static void WriteRiverCsv(string batchRoot, SeedResult r)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("rank,class,analysis_kind,terminus_type,drainage_px,term_x,term_y,course_pts," +
+ "route_reached,route_target_x,route_target_y,route_len_px,route_straight_px,wander," +
+ "route_cost,rim_climb_m,max_step_uphill_m,max_elev_m,total_uphill_m,uphill_steps," +
+ "rim_x,rim_y,cells_expanded,lake_reached,lake_was_fallback,stem_width_px,why");
+ foreach (var rr in r.Rivers)
+ {
+ var c = rr.Candidate;
+ var lo = rr.Lowland;
+ // ⚠ For a lake-ender the OCEAN PROBE is reported too (in the rim/cost columns of the
+ // probe row below) — those are what an affordability threshold would be set against.
+ var pr = rr.OceanProbe;
+ sb.AppendLine($"{c.Rank},{ClassName(rr.Class)},{(c.IsSea ? "" : c.AnalysisKind)},{c.TerminusName},{c.DrainagePx},{c.TermX},{c.TermY},{rr.Course.Count}," +
+ $"{(lo != null && lo.Reached ? "yes" : "no")},{(lo != null && lo.Reached ? ((int)lo.Target.x).ToString() : "")},{(lo != null && lo.Reached ? ((int)lo.Target.y).ToString() : "")}," +
+ $"{(lo != null ? lo.LenPx.ToString("F1") : "")},{(lo != null ? lo.StraightPx.ToString("F1") : "")},{(lo != null ? lo.WanderRatio.ToString("F3") : "")}," +
+ $"{(pr != null && pr.Reached ? pr.Cost.ToString("F0") : "")},{(pr != null ? pr.RimClimbM.ToString("F2") : "")},{(pr != null ? pr.MaxStepUphillM.ToString("F3") : "")}," +
+ $"{(pr != null ? pr.MaxElevM.ToString("F2") : "")},{(pr != null ? pr.TotalUphillM.ToString("F2") : "")},{(pr != null ? pr.UphillSteps.ToString() : "")}," +
+ $"{(pr != null && pr.Reached ? ((int)pr.RimPoint.x).ToString() : "")},{(pr != null && pr.Reached ? ((int)pr.RimPoint.y).ToString() : "")}," +
+ $"{(pr != null ? pr.Expanded.ToString() : "")},{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeReached ? "yes" : "no") : "")}," +
+ $"{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeWasFallback ? "yes" : "no") : "")}," +
+ $"{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{rr.Why}\"");
+ }
+ WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString());
+ }
+
+ private static void RenderSeed(string batchRoot, SeedResult r, bool[] isOcean, Pass2Result p2,
+ int n, float sea, long floorPx, int promoteN, bool skipRaw)
+ {
+ string dir = Path.Combine(batchRoot, $"{r.Seed}");
+ DirAccess.MakeDirRecursiveAbsolute(dir);
+ Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
+
+ DrainageRenderer.RoutedMix(r.Rivers, baseImg.Duplicate() as Image, n,
+ $"SEED {r.Seed} - THE ROUTED MIX ON THE PURE TOP {promoteN}",
+ $"{r.Trunks} NATURAL TRUNKS + {r.Routed} ROUTED-THROUGH + {r.Lakes} LAKE-ENDERS = {r.SeaReaching} SEA-REACHING RIVERS. SPREAD: {r.Spread.ToUpperInvariant()}",
+ floorPx)
+ .SavePng(Path.Combine(dir, "routed_mix.png"));
+
+ // Grayscale beside the pretty render — the field must be inspectable without the palette.
+ var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
+ 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 calibSize, int[] seeds,
+ List rows, int promoteN, long floorPx, int promoteMax, int lakeMinPx,
+ string styleS, DrainageAnalysis.Params def, bool skipRaw)
+ {
+ var sb = new StringBuilder();
+ int primary = seeds.Length > 0 ? seeds[0] : 0;
+
+ sb.AppendLine($"# Batch 03 — lowland routing: the MIX on the pure top {promoteN}");
+ sb.AppendLine();
+ sb.AppendLine("**⛔ TASTE GATE. Nothing is locked** — no count, no K, no routing style, no default in");
+ sb.AppendLine("`TerrainGenConfig` or `DrainageAnalysis.Params`.");
+ sb.AppendLine();
+ sb.AppendLine("**⛔ COURSES ONLY. No height was mutated, no water was filled, nothing was carved** — asserted");
+ sb.AppendLine("per seed by an FNV digest of both height fields taken before and after routing. The bed carve and");
+ sb.AppendLine("the stepped-water model are separate later tasks.");
+ sb.AppendLine();
+ sb.AppendLine("## 👉 The pick");
+ sb.AppendLine();
+ sb.AppendLine($"Open **`{primary}/routed_mix.png`**. Then check the other three: " +
+ string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
+ sb.AppendLine("> **Does routing the giants give enough substantial, well-distributed coastal rivers to dissolve");
+ sb.AppendLine("> the parked sea-river quota (rivers/02b's K) — or not?**");
+ sb.AppendLine(">");
+ sb.AppendLine("> Read three things off the plate: **how many** rivers now reach the sea, **how big** they are");
+ sb.AppendLine("> (the fixed width scale is shared with rivers/02b, so widths are comparable across both tasks),");
+ sb.AppendLine("> and **where** they land (the spread column below). Then read the rim column — a route that");
+ sb.AppendLine("> climbs a large rim is a channel cut over a wall, which may or may not be acceptable geography.");
+ sb.AppendLine();
+ sb.AppendLine("**Colour key.** Cyan = natural ocean trunk (erosion already reached the coast). Green = routed-through");
+ sb.AppendLine("giant, **dark for its natural upland stem and bright for the lowland reach routing added** — so the");
+ sb.AppendLine("plate separates terrain from routing. A yellow ring on a green reach is the **rim it climbed over**.");
+ sb.AppendLine("Orange = inland lake-ender, disc at its terminal and ring at the water body it joins.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ The MIX, per seed");
+ sb.AppendLine();
+ sb.AppendLine($"| Seed | natural trunks | routed-through | lake-enders | sea-reaching | ⭐⭐ DISTINCT MOUTHS | shared mouth | spread |");
+ sb.AppendLine("|---|---|---|---|---|---|---|---|");
+ foreach (var r in rows)
+ sb.AppendLine($"| `{r.Seed}` | {r.Trunks} | {r.Routed} | {r.Lakes} | {r.SeaReaching} of {r.Rivers.Count} | " +
+ $"**{r.DistinctMouths}** | {(r.SharedMouths.Count > 0 ? string.Join("; ", r.SharedMouths) : "—")} | {r.Spread} |");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⚠⚠ READ THE *DISTINCT MOUTHS* COLUMN, NOT THE SEA-REACHING ONE.");
+ sb.AppendLine("> **On every seed, two routed rivers arrive at the SAME ocean cell.** Each giant is routed");
+ sb.AppendLine("> independently to its nearest ocean cell and nothing coordinates the routes, so two basins whose");
+ sb.AppendLine("> cheapest corridor is the same valley share one mouth. The analysis dedups NATURAL outlets");
+ sb.AppendLine($"> (`MinOutletSeparationPx = {def.MinOutletSeparationPx}`); **nothing dedups ROUTED ones.**");
+ sb.AppendLine(">");
+ sb.AppendLine("> Whether that is a defect or a delta is a real judgment: two rivers meeting at one mouth is a");
+ sb.AppendLine("> confluence, which is ordinary geography — but they arrive there *without ever having joined*,");
+ sb.AppendLine("> which is not. It is reported rather than deduped, because deduping would silently drop a");
+ sb.AppendLine("> promoted river the developer chose. **→ a decision for the carve/water tasks.**");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⚠ Compare against rivers/02b before concluding");
+ sb.AppendLine($"> On the pure top {promoteN} the *unrouted* sea count was 1–2 per seed. The SEA-REACHING column above is");
+ sb.AppendLine("> what routing turns that into. **If it is comfortably above the quota's K, the quota is redundant");
+ sb.AppendLine("> — that was the question rivers/02b parked.** The spread column is the second half of the answer:");
+ sb.AppendLine("> a count that all lands on one coast does not serve placement.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐⭐ The per-river diagnostic — why each river landed where it did");
+ sb.AppendLine();
+ sb.AppendLine("`rim climb` is metres from the basin floor to the route's high point — **the wall the channel crosses**.");
+ sb.AppendLine("`max step` is the steepest single 1-px climb on it. `wander` is polyline length over straight-line.");
+ sb.AppendLine("**For lake-enders the rim/cost columns are the OCEAN PROBE** — what it *would* have cost to route them");
+ sb.AppendLine("to the sea. That is the number an affordability threshold would be set against (see the sort note).");
+ sb.AppendLine();
+ foreach (var r in rows)
+ {
+ sb.AppendLine($"### `{r.Seed}`");
+ sb.AppendLine();
+ sb.AppendLine("| rank | class | drainage px | terminus | route len px | wander | ⭐ rim climb m | max step m | route cost | cells settled |");
+ sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
+ foreach (var rr in r.Rivers)
+ {
+ var c = rr.Candidate; var pr = rr.OceanProbe; var lo = rr.Lowland;
+ string cls = rr.Class switch
+ {
+ RiverRouting.RiverClass.OceanTrunk => "trunk",
+ RiverRouting.RiverClass.RoutedGiant => "**routed**",
+ _ => "lake-ender",
+ };
+ string term = rr.Class switch
+ {
+ RiverRouting.RiverClass.OceanTrunk => $"sea ({c.TermX},{c.TermY})",
+ RiverRouting.RiverClass.RoutedGiant => lo != null && lo.Reached ? $"sea ({(int)lo.Target.x},{(int)lo.Target.y})" : "⚠ NO ROUTE",
+ _ => rr.LakeReached ? $"lake ({(int)lo.Target.x},{(int)lo.Target.y}){(rr.LakeWasFallback ? " ⚠fallback" : "")}" : "⚠ its own terminal",
+ };
+ sb.AppendLine($"| #{c.Rank} | {cls} | {c.DrainagePx:N0} | {term} | " +
+ $"{(lo != null && lo.Reached ? lo.LenPx.ToString("F0") : "—")} | " +
+ $"{(lo != null && lo.Reached ? lo.WanderRatio.ToString("F2") : "—")} | " +
+ $"{(pr != null && pr.Reached ? $"**{pr.RimClimbM:F1}**" : "—")} | " +
+ $"{(pr != null && pr.Reached ? pr.MaxStepUphillM.ToString("F2") : "—")} | " +
+ $"{(pr != null && pr.Reached ? pr.Cost.ToString("N0") : "—")} | " +
+ $"{(pr != null ? pr.Expanded.ToString("N0") : "—")} |");
+ }
+ sb.AppendLine();
+ sb.AppendLine($"*Candidates {r.CandidateCount} ({r.SeaCandidates} sea / {r.EndoCandidates} endorheic) · " +
+ $"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px " +
+ $"({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · routing {r.RoutingSeconds:F1}s, " +
+ $"{r.TotalExpanded:N0} cells settled.*");
+ sb.AppendLine();
+ }
+ sb.AppendLine("## ⚠⚠ The sort — what actually decides routed vs lake-ender, and the threshold nobody has set");
+ sb.AppendLine();
+ sb.AppendLine("The task states the sort as *\"an affordable over-the-rim LOWGROUND path to the ocean exists →");
+ sb.AppendLine("routed-through; none → lake-ender.\"* **Ported literally that classifies EVERYTHING as routed**, because");
+ sb.AppendLine("on an 8-connected grid with all-finite costs a path to the ocean always exists — `RouteToOcean`");
+ sb.AppendLine("returns empty only if the queue drains without reaching a target, which cannot happen. There is no");
+ sb.AppendLine("\"none\". The word carrying the meaning is *affordable*, and **no threshold is specified anywhere**.");
+ sb.AppendLine();
+ sb.AppendLine("So this port uses **the reference's own sort**, which is the one that discriminates:");
+ sb.AppendLine();
+ sb.AppendLine("```csharp");
+ sb.AppendLine("Kind = (basinHasLake[id] && !SouthernCandidate) ? \"lake-ender\" : \"routed\"");
+ sb.AppendLine("```");
+ sb.AppendLine();
+ sb.AppendLine("**Does the terminal basin already hold classify water?** A basin that is a lake is a natural");
+ sb.AppendLine("lake-ender; a dry pan is routed to the sea. That is `DrainageAnalysis.Giant.Kind`, consumed rather");
+ sb.AppendLine("than reinvented. (v2 has no towns, so `southernPick` is −1 and the southern override never fires.)");
+ sb.AppendLine();
+ sb.AppendLine("**And the missing threshold is surfaced instead of guessed:** the ocean route is probed for EVERY");
+ sb.AppendLine("giant, lake-enders included, so the rim/cost columns above say exactly what routing each one would");
+ sb.AppendLine("cost. If the developer wants an affordability bar, those are the numbers to put it under.");
+ sb.AppendLine();
+ sb.AppendLine("## What was ported, and what was deliberately NOT");
+ sb.AppendLine();
+ sb.AppendLine("| Ported (the routing portion) | Not ported (later tasks) |");
+ sb.AppendLine("|---|---|");
+ sb.AppendLine("| `RouteToOcean` — deterministic LOWGROUND Dijkstra, uphill penalised, empty-on-no-path | `CarveRiver` — the bed stamp, **mutates render height** |");
+ sb.AppendLine("| the routed / lake-ender sort | `AddSteppedWater` — **creates water bodies** |");
+ sb.AppendLine("| lake-ender targeting (significant water, classify-water fallback) | tributary carving |");
+ sb.AppendLine("| `SmoothCourse` — RDP tol 4 + 4 Chaikin, endpoints pinned, **lowland reach only** | the densify-to-1px step (carve-time) |");
+ sb.AppendLine();
+ sb.AppendLine($"**Cost model, effective == declared** (verified: these are `private const` in `RiverCarvePass` with no");
+ sb.AppendLine($"`ConfigManager` key and no `[Export]` anywhere in the reference repo):");
+ sb.AppendLine();
+ sb.AppendLine("```");
+ sb.AppendLine($"LOWGROUND step = DIST × ({RiverRouting.LowgroundBase} + elevM × {RiverRouting.LowgroundElevPerM}) + climbM × {RiverRouting.LowgroundUphillPerM}");
+ sb.AppendLine($"SHORT step = DIST + climbM × {RiverRouting.ShortUphillPerM} (rejected by the reference's gate as \"a dead-straight canal\")");
+ sb.AppendLine($"elevM = max(0, (height − sea) × {WorldScale.MetresPerRawUnit:F0}) — clamped at local sea");
+ sb.AppendLine("```");
+ sb.AppendLine();
+ sb.AppendLine($"Style used: **{styleS}** (the reference's effective `RiverRoutingStyle`). ⚠ The declared-vs-effective gap");
+ sb.AppendLine("`00_ground` warned about (WidthScale 1.0→1.75, DepthScale 1.0→1.5) is **carve-time and out of scope here**.");
+ sb.AppendLine();
+ sb.AppendLine("**⚠ The `isSignificantWater` port gap.** The reference builds it from a water-bodies table");
+ sb.AppendLine($"(`PixelCount >= RiverLakeMinTargetPx`); **v2 has no such table.** Interim substitute, per the task:");
+ sb.AppendLine($"8-connected components of classify water (ocean excluded), keeping those ≥ {lakeMinPx:N0} px — same");
+ sb.AppendLine("threshold, same semantics, no table built. The size filter is the point: routing to the *nearest wet");
+ sb.AppendLine("pixel* put a lake-ender into a three-cell puddle short of the obvious lagoon (the reference's own finding).");
+ sb.AppendLine();
+ sb.AppendLine("## What was run");
+ sb.AppendLine();
+ sb.AppendLine($"Chain + analysis + routing at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered.");
+ sb.AppendLine($"Curve calibrated at {calibSize} on the family-off pinned pool; terrain {TerrainShapeV1.Describe()} + erosion ON.");
+ sb.AppendLine($"Promoted set: the **pure top {promoteN}** by drainage — the unified ranking, **no quota** (rivers/02b's K stays parked).");
+ sb.AppendLine($"Reporting caps raised to `{promoteMax}` so every promoted river has a traced stem. Candidate floor `{floorPx:N0}` px.");
+ sb.AppendLine();
+ sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m, `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} — they define the routing");
+ sb.AppendLine($"surface itself. Also unchanged: `MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}. `DrainageAnalysis` is reused,");
+ sb.AppendLine("never rebuilt or edited. Terminus classified by `RegionLabeling.OceanMask` (`Dir == D_SEA`) — no bare `h < sea`.");
+ sb.AppendLine();
+ sb.AppendLine("## Files");
+ sb.AppendLine();
+ sb.AppendLine("| File | What it is |");
+ sb.AppendLine("|---|---|");
+ sb.AppendLine("| `/routed_mix.png` | the three classes, with the added lowland reach and the rim it crossed drawn distinctly |");
+ sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |");
+ sb.AppendLine("| `rivers_.csv` | per river: class, analysis kind, route geometry, rim, cost, cells settled, why |");
+ if (skipRaw)
+ sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`, the anchor of record. `ISLA_SKIP_RAW=0` regenerates it. |");
+ 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/03_lowland_routing.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;
+ }
+ }
+}