diff --git a/Tools/Scenes/RiverPromotionTool.tscn b/Tools/Scenes/RiverPromotionTool.tscn
new file mode 100644
index 0000000..41bfac3
--- /dev/null
+++ b/Tools/Scenes/RiverPromotionTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene format=3 uid="uid://riverpromotion02"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/RiverPromotionTool.cs" id="1_rpt02"]
+
+[node name="RiverPromotionTool" type="Node"]
+script = ExtResource("1_rpt02")
diff --git a/Tools/Scripts/DrainageRenderer.cs b/Tools/Scripts/DrainageRenderer.cs
index d117d4f..e483078 100644
--- a/Tools/Scripts/DrainageRenderer.cs
+++ b/Tools/Scripts/DrainageRenderer.cs
@@ -47,8 +47,13 @@ namespace IslaApocalypse.Tools
return img;
}
- /// The candidates over a faint terrain.
- public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
+ ///
+ /// The faint grey terrain every overlay map is drawn on — ocean flat dark blue, enclosed
+ /// (non-ocean) water dark teal, land a shallow sqrt ramp. Factored out at rivers/02 so the
+ /// promotion maps sit on the SAME base as the chat2/12 candidates map and can be compared
+ /// without the eye correcting for two different backgrounds.
+ ///
+ public static Image TerrainBase(bool[] isOcean, float[,] render, int n, float sea, float hMax)
{
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
float span = MathF.Max(1e-6f, hMax - sea);
@@ -62,6 +67,13 @@ namespace IslaApocalypse.Tools
float g = 0.30f + 0.45f * MathF.Sqrt(t);
img.SetPixel(x, y, new Color(g, g, g * 0.96f));
}
+ return img;
+ }
+
+ /// The candidates over a faint terrain.
+ public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
+ {
+ var img = TerrainBase(isOcean, render, n, sea, hMax);
int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
foreach (var g in plan.Giants)
@@ -90,6 +102,178 @@ namespace IslaApocalypse.Tools
return img;
}
+ // ═══ ⭐ THE PROMOTION MAPS (rivers/02) — the count decision, on the map ═══════════════════
+ //
+ // Two views, same base, same colour law:
+ // SEA-REACHING cyan (as chat2/12's trunks)
+ // ENDORHEIC orange (as chat2/12's giants)
+ // so a reader carrying chat2/12 in their eye reads these without relearning anything.
+ //
+ // ⚠⚠ NEITHER MAP DRAWS `Giant.ProvisionalRoute`. That steepest-descent placeholder — the
+ // visible "comb" of parallel threads on the flats — is rivers/03's job to replace, and drawing
+ // it here would make a count look like a river network it is not. What IS drawn is the REAL
+ // upland stem: the max-accumulation course traced through erosion-carved valleys.
+
+ ///
+ /// ⭐ THE DIAGNOSTIC MAP — every candidate above the floor, marker AREA ∝ drainage area,
+ /// colour by terminus. Answers "where are the big drainages, and is the spread north/south?"
+ /// before any count is chosen.
+ ///
+ /// ⚠ Marker radius scales as √area so the MARKER'S AREA is proportional to the drainage area —
+ /// scaling the radius linearly would exaggerate the big ones quadratically and make a knee look
+ /// like a cliff.
+ ///
+ public static Image PromotionCandidates(List ranked, Image img, int n, string title, int[] ladder)
+ {
+ if (ranked.Count == 0) return img;
+
+ long maxArea = 1;
+ foreach (var c in ranked) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
+ float rMax = n >= 4096 ? 46f : 22f, rMin = n >= 4096 ? 6f : 3f;
+ int ringW = n >= 4096 ? 4 : 2;
+
+ // Draw smallest-first so a big marker never hides behind a small one.
+ for (int i = ranked.Count - 1; i >= 0; i--)
+ {
+ var c = ranked[i];
+ float f = MathF.Sqrt((float)c.DrainagePx / maxArea); // area ∝ drainage
+ int r = (int)MathF.Round(rMin + (rMax - rMin) * f);
+ Color col = c.IsSea ? Trunk : Giant;
+ Disc(img, c.X, c.Y, r, n, col);
+ Ring(img, c.X, c.Y, r + ringW + 1, n, Ink, ringW); // ink halo: legible on any ground
+ }
+
+ int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
+ int nSea = 0; foreach (var c in ranked) if (c.IsSea) nSea++;
+ TinyFont.Draw(img, title, 12, 12, s, Ink);
+ TinyFont.Draw(img, $"ALL {ranked.Count} CANDIDATES ABOVE THE FLOOR - MARKER AREA IS PROPORTIONAL TO DRAINAGE AREA", 12, 12 + lh, s, Ink);
+ TinyFont.Draw(img, $"CYAN: SEA-REACHING ({nSea}) ORANGE: ENDORHEIC ({ranked.Count - nSea}) - AN INLAND TERMINUS IS A PASS, NOT A FALLBACK", 12, 12 + lh * 2, s, Ink);
+ TinyFont.Draw(img, $"NOTHING IS PROMOTED HERE - THIS IS THE DISTRIBUTION THE COUNT ({Join(ladder)}) IS CHOSEN FROM", 12, 12 + lh * 3, s, Ink);
+ return img;
+ }
+
+ ///
+ /// ⭐ THE A/B PLATE — the unified top-N promoted, real upland stems, width ∝ drainage area,
+ /// terminus markers coloured by type. One plate per N; the developer picks by comparing them.
+ ///
+ public static Image PromotedRivers(List promoted, Image img, int n,
+ int nPromoted, long floorPx, string title)
+ {
+ if (promoted.Count == 0) return img;
+
+ long maxArea = 1;
+ foreach (var c in promoted) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
+ float wMax = n >= 4096 ? 11f : 6f, wMin = n >= 4096 ? 3f : 2f;
+ int mark = n >= 4096 ? 18 : 10;
+
+ // Smallest first, so the biggest rivers finish on top.
+ for (int i = promoted.Count - 1; i >= 0; i--)
+ {
+ var c = promoted[i];
+ if (c.Course == null || c.Course.Count < 2) continue;
+ float f = MathF.Sqrt((float)c.DrainagePx / maxArea);
+ int w = (int)MathF.Round(wMin + (wMax - wMin) * f);
+ Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, w);
+ }
+ // ⚠ Marked at the RIVER's terminus (where its stem pools), NOT at the basin's deepest cell —
+ // on a flat basin floor those differ, and marking the deepest cell draws the stem visibly
+ // detached from its own endpoint. → RiverCandidate.TermX.
+ foreach (var c in promoted)
+ {
+ if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
+ else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
+ }
+
+ int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
+ int nSea = 0; foreach (var c in promoted) if (c.IsSea) nSea++;
+ TinyFont.Draw(img, title, 12, 12, s, Ink);
+ TinyFont.Draw(img, $"UNIFIED TOP {nPromoted} BY DRAINAGE AREA - THE SEA/ENDORHEIC SPLIT FELL OUT, IT WAS NOT QUOTA'D", 12, 12 + lh, s, Ink);
+ TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}) ORANGE DISC: ENDORHEIC TERMINUS ({promoted.Count - nSea}) STEM WIDTH IS PROPORTIONAL TO DRAINAGE", 12, 12 + lh * 2, s, Ink);
+ TinyFont.Draw(img, $"REAL UPLAND STEMS ONLY - NO LOWLAND ROUTING, NO WATER, NOTHING CARVED (FLOOR {floorPx:N0} PX)", 12, 12 + lh * 3, s, Ink);
+ return img;
+ }
+
+ ///
+ /// ⭐ 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.
+ ///
+ /// **Log y is not a presentation choice, it is the only honest one:** drainage areas span three
+ /// or more orders of magnitude, so on a linear axis every candidate but the top two or three
+ /// collapses onto the floor and the knee — the thing this plot exists to show — is invisible.
+ ///
+ public static Image Distribution(List ranked, int[] ladder,
+ long endorheicMinInflowPx, long stemMinAccPx, long floorPx, string title)
+ {
+ const int W = 1600, H = 1000, L = 150, R = 40, T = 120, B = 90;
+ var img = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
+ var bg = new Color(0.07f, 0.08f, 0.10f);
+ for (int x = 0; x < W; x++) for (int y = 0; y < H; y++) img.SetPixel(x, y, bg);
+ if (ranked.Count == 0) return img;
+
+ double loMin = Math.Log10(Math.Max(1.0, Math.Min(floorPx, ranked[ranked.Count - 1].DrainagePx)));
+ double hiMax = Math.Log10(Math.Max(10.0, ranked[0].DrainagePx));
+ loMin = Math.Floor(loMin); hiMax = Math.Ceiling(hiMax);
+ int plotW = W - L - R, plotH = H - T - B;
+ int XOf(int rank) => L + (int)((rank - 1) / (double)Math.Max(1, ranked.Count - 1) * plotW);
+ int YOf(double area) => T + plotH - (int)((Math.Log10(Math.Max(1.0, area)) - loMin) / Math.Max(1e-9, hiMax - loMin) * plotH);
+
+ var grid = new Color(0.16f, 0.18f, 0.22f);
+ for (int d = (int)loMin; d <= (int)hiMax; d++) // decade gridlines
+ {
+ int y = YOf(Math.Pow(10, d));
+ for (int x = L; x < L + plotW; x++) if (y >= 0 && y < H) img.SetPixel(x, y, grid);
+ TinyFont.Draw(img, $"1E{d}", 12, Math.Max(0, y - 6), 2, new Color(0.60f, 0.64f, 0.70f));
+ }
+ // the analysis's own thresholds — so the ladder is read RELATIVE to them, not in a vacuum
+ DashH(img, YOf(endorheicMinInflowPx), L, L + plotW, new Color(1f, 0.45f, 0.45f));
+ TinyFont.Draw(img, $"ENDORHEIC MIN INFLOW {endorheicMinInflowPx:N0}", L + 8, YOf(endorheicMinInflowPx) - 22, 2, new Color(1f, 0.45f, 0.45f));
+ DashH(img, YOf(stemMinAccPx), L, L + plotW, new Color(0.55f, 0.85f, 0.55f));
+ TinyFont.Draw(img, $"STEM MIN ACC {stemMinAccPx:N0}", L + 8, YOf(stemMinAccPx) - 22, 2, new Color(0.55f, 0.85f, 0.55f));
+
+ foreach (int nn in ladder) // the ladder counts
+ {
+ if (nn < 1 || nn > ranked.Count) continue;
+ int x = XOf(nn);
+ for (int y = T; y < T + plotH; y += 6)
+ for (int k = 0; k < 3 && y + k < T + plotH; k++) img.SetPixel(x, y + k, new Color(0.95f, 0.90f, 0.35f));
+ TinyFont.Draw(img, $"N={nn}", x + 6, T + 6, 3, new Color(0.95f, 0.90f, 0.35f));
+ TinyFont.Draw(img, $"{ranked[nn - 1].DrainagePx:N0}", x + 6, T + 6 + TinyFont.Height(3) + 4, 2, new Color(0.95f, 0.90f, 0.35f));
+ }
+
+ for (int i = 0; i < ranked.Count; i++) // the candidates
+ {
+ var c = ranked[i];
+ int x = XOf(i + 1), y = YOf(c.DrainagePx);
+ Color col = c.IsSea ? Trunk : Giant;
+ for (int ox = -3; ox <= 3; ox++)
+ for (int oy = -3; oy <= 3; oy++)
+ {
+ if (ox * ox + oy * oy > 9) continue;
+ int px = x + ox, py = y + oy;
+ if (px >= 0 && py >= 0 && px < W && py < H) img.SetPixel(px, py, col);
+ }
+ }
+
+ TinyFont.Draw(img, title, 12, 12, 3, Ink);
+ TinyFont.Draw(img, "DRAINAGE AREA (PX, LOG) VS UNIFIED RANK - CYAN SEA-REACHING, ORANGE ENDORHEIC", 12, 12 + TinyFont.Height(3) + 8, 2, Ink);
+ TinyFont.Draw(img, $"{ranked.Count} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR - A KNEE IS A SHARP DROP; A SMOOTH CURVE MEANS THE TERRAIN HAS NO NATURAL COUNT", 12, H - 34, 2, new Color(0.70f, 0.74f, 0.80f));
+ return img;
+ }
+
+ private static string Join(int[] v)
+ {
+ var sb = new System.Text.StringBuilder();
+ for (int i = 0; i < v.Length; i++) { if (i > 0) sb.Append('/'); sb.Append(v[i]); }
+ return sb.ToString();
+ }
+
+ private static void DashH(Image img, int y, int x0, int x1, Color c)
+ {
+ if (y < 0 || y >= img.GetHeight()) return;
+ for (int x = x0; x < x1; x += 14)
+ 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)
{
for (int i = 1; i < pts.Count; i++)
diff --git a/Tools/Scripts/RiverCandidate.cs b/Tools/Scripts/RiverCandidate.cs
new file mode 100644
index 0000000..ff1ff7e
--- /dev/null
+++ b/Tools/Scripts/RiverCandidate.cs
@@ -0,0 +1,104 @@
+using System.Collections.Generic;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ ONE CANDIDATE MAJOR DRAINAGE — the unit the river count is chosen over (rivers/02).
+ ///
+ /// ═══ WHY THIS TYPE EXISTS: ONE LIST, NOT TWO ═══
+ ///
+ /// The reference promoted rivers from TWO separate lists with TWO separate quotas — N sea-reaching
+ /// trunks and N endorheic giants (DrainageAnalysis.Params.TrunkCount / GiantCount,
+ /// both 3). That structure encodes an assumption this terrain does not satisfy: that reaching the
+ /// sea is what makes a drainage a river, and inland ones are a second category to be quota'd
+ /// separately.
+ ///
+ /// **On the reshaped terrain ~68 % of land drains INLAND** (measured: 67.6 % on the primary seed,
+ /// 116 terminal basins). A separate quota would fight that — it would promote small coastal
+ /// drainages over far larger inland ones purely because of where they end.
+ ///
+ /// > ### So selection is UNIFIED: rank every major drainage by contributing area, promote the top N,
+ /// > and let the sea-vs-endorheic split FALL OUT of which promoted rivers happen to reach the ocean.
+ /// > **An endorheic terminus is a PASS, not a fallback** — a river ending in a significant lake is
+ /// > as real as one reaching the coast, and is never forced to the coast.
+ ///
+ /// ⚠ This is a DELIBERATE DEPARTURE from the reference's two-list structure (approved in chat;
+ /// D-050 port-discipline noted). Only the SELECTION is unified — is retained
+ /// per river because downstream routing branches on it, and Trunk / Giant are left
+ /// exactly as ported.
+ ///
+ /// ═══ ⚠ THE METRIC IS THE SAME UNIT ON BOTH SIDES, AND THAT IS LOAD-BEARING ═══
+ ///
+ /// is a COUNT OF CONTRIBUTING LAND CELLS in both cases, computed on the
+ /// same D8 field in the same pass:
+ ///
+ /// SEA Plan.Acc at the outlet — every non-ocean cell is seeded 1 and accumulated
+ /// along Plan.Dir, so the outlet's value is the count of cells whose flow path
+ /// passes through it.
+ /// ENDORHEIC Plan.BasinInflow[BasinId] — the memoised downstream walk over the SAME
+ /// Dir, counting cells whose flow TERMINATES in that basin.
+ ///
+ /// Every land cell has exactly one destination, so the two populations are disjoint and exhaustive:
+ /// Σ sea-outlet Acc + Σ BasinInflow + UnroutedCells == LandCells. `RiverPromotionTool`
+ /// ASSERTS that identity per seed — it is the mechanical proof that one ranking over both is sound.
+ ///
+ public sealed class RiverCandidate
+ {
+ /// ⭐ The terminus. True = the outlet touches RegionLabeling.OceanMask; false = it pools in a terminal basin. Never a bare h < sea test.
+ public bool IsSea;
+
+ /// Row-major cell: the sea outlet, or the terminal basin's MINIMUM (its deepest cell).
+ public int Cell;
+ public int X, Y;
+
+ ///
+ /// ⭐ Where the RIVER actually ends — the point its main stem pools at, i.e. the first point of
+ /// . Defaults to / until bound.
+ ///
+ /// ⚠⚠ FOR AN ENDORHEIC RIVER THIS IS NOT THE BASIN'S DEEPEST CELL, and the difference is
+ /// visible on a map. `DrainageAnalysis` is explicit about why: *"Terminal is where the MAIN
+ /// STEM actually pools (its sub-minimum), which on a flat basin floor is more truthful than the
+ /// basin's deepest cell."* On a wide flat lagoon bed those two points can sit far apart.
+ ///
+ /// Both are real and both are kept: the basin minimum is the BASIN's identity (and is what the
+ /// CSV records), this is the RIVER's terminus (and is what the plates mark). Marking a river at
+ /// its basin's deepest cell draws the stem visibly detached from its own endpoint — which reads
+ /// as a broken river and would corrupt a count judgment.
+ ///
+ public int TermX, TermY;
+
+ /// ⭐ THE RANKING METRIC — contributing land cells. Same unit for both termini (see the class note).
+ public long DrainagePx;
+
+ /// Terminal-basin id (endorheic only; 0 for sea). The stable key for binding a candidate to its Giant.
+ public int BasinId;
+
+ /// Endorheic only: the basin's max fill depth, metres.
+ public float BasinDepthM;
+
+ /// Endorheic only: the basin's area in cells.
+ public long BasinAreaPx;
+
+ ///
+ /// ⚠ Sea only. True when this outlet was DROPPED by the MinOutletSeparationPx rule
+ /// because a larger outlet sits within that radius. Kept in the distribution (it is a real
+ /// drainage) but excluded from ranking — see the tool's note on what separation discards.
+ ///
+ public bool SuppressedBySeparation;
+
+ ///
+ /// The REAL upland stem — the max-accumulation traced course from DrainageAnalysis's own
+ /// TraceStem, bound after selection. Null for candidates outside the promoted set.
+ /// ⚠ Downstream-first and decimated ×4, as the analysis produces it.
+ /// ⚠⚠ This is the erosion-carved course, NOT Giant.ProvisionalRoute — the steepest-descent
+ /// placeholder ("the comb") is deliberately never drawn here; replacing it is rivers/03's job,
+ /// and drawing it would mislead a count judgment.
+ ///
+ public List<(float x, float y)> Course;
+
+ /// 1-based rank in the unified descending ranking. 0 until ranked.
+ public int Rank;
+
+ public string TerminusName => IsSea ? "sea" : "endorheic";
+ }
+}
diff --git a/Tools/Scripts/RiverPromotionTool.cs b/Tools/Scripts/RiverPromotionTool.cs
new file mode 100644
index 0000000..9ee89be
--- /dev/null
+++ b/Tools/Scripts/RiverPromotionTool.cs
@@ -0,0 +1,762 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ THE RIVER-PROMOTION BATCH (rivers/02) — DIAGNOSTIC FIRST, then a taste gate on the count.
+ ///
+ /// ═══ WHAT THIS TASK IS FOR ═══
+ ///
+ /// Choose the river COUNT against the terrain that actually exists. The M3 count of 3 was tuned on
+ /// topography that the southern stretch (→ D-065) and coastal fragmentation (→ D-063) have since
+ /// replaced, and `DrainageAnalysis.Params.TrunkCount / GiantCount / EndorheicMaxCount = 3` are
+ /// **LEAN REPORTING CAPS, NOT A STATEMENT ABOUT THE TERRAIN** — this island carries ~116 terminal
+ /// basins per seed and drains ~68 % of its land inland.
+ ///
+ /// So: measure the full candidate distribution FIRST, see whether the terrain has a natural break,
+ /// and only then show 8 / 12 / 16 on the map. **This tool promotes a ladder, not a winner.**
+ ///
+ /// ═══ WHAT IT DOES NOT DO ═══
+ ///
+ /// No lowland routing (rivers/03), no water bodies, no carving, no crater. `DrainageAnalysis` is
+ /// REUSED, not rebuilt — it is ported and proven (chat2/12, re-derived bit-identically at
+ /// `03fe75b`). Everything here is enumeration, ranking, selection and render AROUND it.
+ ///
+ /// ⚠⚠ `Giant.ProvisionalRoute` is never drawn. It is the steepest-descent placeholder (the "comb")
+ /// that rivers/03 replaces; drawing it would make a count judgment look like a river network.
+ ///
+ /// ═══ ⭐ THE UNIFIED RANKING, AND WHERE IT IS BUILT ═══
+ ///
+ /// The ranking is built over the **RAW CANDIDATES derived from the exposed `Plan` arrays**
+ /// (`Dir` / `Acc` / `BasinId` / `BasinInflow`), not over `Plan.Trunks` / `Plan.Giants`. That choice
+ /// matters: the `Trunks` / `Giants` lists are already truncated by the reporting caps, so ranking
+ /// over them would measure the caps rather than the terrain. Deriving from the arrays gives the
+ /// COMPLETE distribution, cap-free — which is the whole point of a diagnostic.
+ ///
+ /// The caps are then raised (ISLA_PROMOTE_MAX) purely so the analysis's own `TraceStem` produces a
+ /// real upland course for every candidate that could be promoted; each promoted candidate is BOUND
+ /// to its `Trunk` (by outlet cell) or `Giant` (by basin id) to collect that course. No stem-tracing
+ /// is reimplemented here.
+ ///
+ /// ═══ RUNNING IT ═══
+ ///
+ /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
+ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/RiverPromotionTool.tscn
+ ///
+ /// ISLA_TASK / ISLA_BATCH / ISLA_CHAT / ISLA_OUTPUT_DIR / ISLA_SKIP_RAW
+ /// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
+ /// ISLA_SEEDS distribution seeds (default: the 8 gallery seeds)
+ /// ISLA_RENDER_SEEDS seeds that also get maps (default: 4 of them)
+ /// ISLA_PROMOTE_FLOOR_PX significance floor for the DISTRIBUTION (default 5000)
+ /// ISLA_PROMOTE_N_LADDER the A/B counts (default 8,12,16)
+ /// ISLA_PROMOTE_MAX cap raised on the analysis so stems exist (default 24)
+ ///
+ public partial class RiverPromotionTool : Node
+ {
+ /// The 8 gallery seeds — the terrain `terrain-shape-v1` was judged across.
+ private static readonly int[] GallerySeeds =
+ { 1063685222, 999999937, 20260822, 31415926, 27182818, 16180339, 14142135, 17320508 };
+
+ /// ⚠ Task 01's pool, verbatim — the curve's identity.
+ private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
+
+ private static readonly int[] DefaultRenderSeeds = { 1063685222, 999999937, 31415926, 14142135 };
+ 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 Ranked; // separated + above floor, descending
+ public int SuppressedCount; // sea outlets dropped by the separation rule (all)
+ public long SuppressedPx; // …and the drainage they carried (all)
+ // ⚠ The two numbers that actually matter: a fragmented coastline has tens of thousands of
+ // one-cell outlets, so an aggregate "suppressed" figure is dominated by drainage that was
+ // never a candidate. THESE count only outlets that cleared the significance floor.
+ public int SuppressedAboveFloor;
+ public long SuppressedAboveFloorPx;
+ // ⚠ Of those, how many were suppressed by an outlet on a DIFFERENT LANDMASS — i.e. cannot
+ // possibly be "another mouth of the same delta". Measured, not assumed. → the note in Run().
+ public int SuppressedCrossLandmass;
+ public long SuppressedCrossLandmassPx;
+ // ⭐ THE DECISIVE NUMBER. Every suppressed above-floor outlet's drainage, kept so we can ask
+ // the only question that actually matters: would any of them have made the ladder? A rule
+ // that discards candidates too small to be promoted costs the decision nothing.
+ public List SuppressedAboveFloorAccs = new();
+ public Dictionary WouldHaveMadeN = new(); // ladder N -> suppressed outlets ≥ that cutoff
+ public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
+ public int TerminalBasins, SeaOutletsAll;
+ public int BreakRankRatio; public double BreakRatio; // the knee (log gap)
+ public int BreakRankAbs; public long BreakAbs; // the largest absolute gap
+ public Dictionary AtN = new();
+ public ulong Ms;
+ }
+
+ private void Run()
+ {
+ ToolingPaths.Configure(OS.GetUserDataDir());
+ ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
+
+ int task = EnvInt("ISLA_TASK", 2);
+ string descr = EnvStr("ISLA_BATCH", "promotion");
+ int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
+ int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
+ int[] seeds = EnvSeeds("ISLA_SEEDS", GallerySeeds);
+ int[] renderSe = EnvSeeds("ISLA_RENDER_SEEDS", DefaultRenderSeeds);
+ long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
+ int[] ladder = EnvSeeds("ISLA_PROMOTE_N_LADDER", new[] { 8, 12, 16 });
+ int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
+ bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
+
+ int maxLadder = 0; foreach (int v in ladder) if (v > maxLadder) maxLadder = v;
+ if (promoteMax < maxLadder)
+ throw new InvalidOperationException(
+ $"ISLA_PROMOTE_MAX ({promoteMax}) is below the largest ladder count ({maxLadder}). The cap is what " +
+ "makes the analysis trace a real upland stem for every promotable candidate; below the ladder, the " +
+ "top plate would have rivers with no course to draw. Raise it.");
+
+ // ⚠ rivers/01: the shape AND erosion come from the bare defaults. Assert before generating —
+ // a count chosen on drifted terrain is a count chosen for terrain nobody approved.
+ TerrainShapeV1.Assert("RiverPromotion");
+ TerrainShapeV1.AssertErosionDefaultOn("RiverPromotion");
+
+ string batchRoot = ToolingPaths.BatchRoot(task, descr);
+ DirAccess.MakeDirRecursiveAbsolute(batchRoot);
+ DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
+
+ var anchors = CurveAnchors.Default;
+ float sea = 0.15f;
+
+ // The analysis params. ⚠⚠ ONLY THE REPORTING CAPS MOVE. EndorheicMinDepthM and
+ // EndorheicMinAreaPx are NOT touched: they decide which depressions BECOME terminal basins,
+ // i.e. they define the routing surface itself. Changing them would change the drainage this
+ // task is meant to measure, not just how much of it is reported.
+ var dp = new DrainageAnalysis.Params
+ {
+ SeaLevel = sea,
+ TrunkCount = promoteMax, // reporting cap ↑ so stems exist
+ GiantCount = promoteMax, // reporting cap ↑
+ EndorheicMaxCount = promoteMax, // reporting cap ↑
+ EndorheicMinInflowPx = (int)floorPx, // reporting floor ↓ to the diagnostic floor
+ };
+ var dpDefaults = new DrainageAnalysis.Params();
+
+ GD.Print("==================================================================");
+ GD.Print(" RIVER PROMOTION (rivers/02) — measure the candidate distribution, THEN show the count ladder");
+ GD.Print("==================================================================");
+ GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
+ GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
+ GD.Print($"seeds : distribution {seeds.Length} — {string.Join(", ", seeds)}");
+ GD.Print($" : rendered {renderSe.Length} — {string.Join(", ", renderSe)}");
+ GD.Print($"ranking : UNIFIED — every major drainage by contributing-cell count, both termini in ONE list.");
+ GD.Print($" the sea/endorheic split FALLS OUT; it is never quota'd. (departs from the reference's two lists)");
+ GD.Print($"floor : {floorPx:N0} px (diagnostic significance floor — NOT the promotion threshold)");
+ GD.Print($"ladder : N = {string.Join(", ", ladder)} caps raised to {promoteMax} so every promotable river has a traced stem");
+ GD.Print($"UNCHANGED : EndorheicMinDepthM {dpDefaults.EndorheicMinDepthM} m · EndorheicMinAreaPx {dpDefaults.EndorheicMinAreaPx:N0} · MinOutletSeparationPx {dpDefaults.MinOutletSeparationPx} · StemMinAccPx {dpDefaults.StemMinAccPx}");
+ // ⚠⚠ THE PARAMS ARE ABSOLUTE PIXEL COUNTS, SO THIS ANALYSIS IS SCALE-DEPENDENT.
+ // Measured at rivers/02: at 1024 a 10,000-cell basin is ~1 % of the map and NOTHING qualifies as
+ // endorheic (0 terminal basins, 100 % sea-reaching), while a 400 px separation is 39 % of the map
+ // width and suppresses 15,043 of 15,048 sea outlets. At 8192 the same numbers are 0.015 % and
+ // 4.9 %. A small-map probe of this tool therefore measures the PARAMS, not the terrain.
+ if (mapSize != 8192)
+ GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the DrainageAnalysis params (EndorheicMinAreaPx {dpDefaults.EndorheicMinAreaPx:N0}, " +
+ $"MinOutletSeparationPx {dpDefaults.MinOutletSeparationPx}) are ABSOLUTE PIXEL COUNTS tuned at 8192. At {mapSize} they scale " +
+ $"differently against the map ({100.0 * dpDefaults.EndorheicMinAreaPx / ((double)mapSize * mapSize):F3} % of area, " +
+ $"{100.0 * dpDefaults.MinOutletSeparationPx / mapSize:F1} % of width), so the candidate distribution is NOT comparable " +
+ "to the 8192 result and MUST NOT be used to choose a count. Pipeline smoke only.");
+ GD.Print($"batch : {batchRoot}");
+ GD.Print("==================================================================");
+
+ 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 = "promotion",
+ Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
+ Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
+ };
+
+ var renderSet = new HashSet(renderSe);
+ 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).
+ // A terminus "reaches the sea" iff it touches THIS, never a bare h < sea.
+ 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} %), unrouted {plan.UnroutedCells:N0}; " +
+ $"terminal basins {plan.TerminalBasinCount}; ocean {oceanCells:N0} / enclosed {enclosed:N0}");
+
+ var r = Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, seed, p1.Regions);
+ r.Ms = Time.GetTicksMsec() - t0;
+ Analyse(r, ladder);
+ BindCourses(r, plan, mapSize, maxLadder);
+ Report(r, ladder);
+ WriteCsv(batchRoot, r);
+
+ if (renderSet.Contains(seed))
+ RenderSeed(batchRoot, r, plan, isOcean, p2, mapSize, sea, ladder, floorPx, dpDefaults, skipRaw);
+
+ results.Add(r);
+ }
+
+ WriteIndex(batchRoot, mapSize, calibSize, seeds, renderSe, results, ladder, floorPx, promoteMax, dpDefaults, skipRaw);
+ GD.Print("\n==================================================================");
+ GD.Print($" DONE — {batchRoot}");
+ GD.Print(" ⛔ TASTE GATE: 8 / 12 / 16 are PRESENTED, not decided. The developer picks N.");
+ GD.Print("==================================================================");
+ GetTree().Quit(0);
+ }
+
+ // ═══ ⭐⭐ ENUMERATION — the complete candidate set, derived from the exposed Plan arrays ═══
+
+ ///
+ /// 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
+ {
+ Seed = seed,
+ 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) 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;
+ }
+
+ ///
+ /// The natural-break analysis and the per-N splits.
+ ///
+ /// ⚠ TWO break statistics, because the obvious one is useless here. Drainage areas span three
+ /// or more decades, so the LARGEST ABSOLUTE GAP is almost always between rank 1 and rank 2 —
+ /// it measures the biggest river, not a natural count. The meaningful knee is the largest
+ /// RATIO between consecutive ranks, searched over a stated window that excludes the top of the
+ /// list. Both are reported; the ratio one is the answer.
+ ///
+ private static void Analyse(SeedResult r, int[] ladder)
+ {
+ var v = r.Ranked;
+ int lo = 3, hi = Math.Min(v.Count - 1, 40); // the stated window
+ r.BreakRankRatio = 0; r.BreakRatio = 1.0;
+ for (int i = lo - 1; i < hi; i++)
+ {
+ double ratio = v[i].DrainagePx / (double)Math.Max(1, v[i + 1].DrainagePx);
+ if (ratio > r.BreakRatio) { r.BreakRatio = ratio; r.BreakRankRatio = i + 1; }
+ }
+ r.BreakRankAbs = 0; r.BreakAbs = 0;
+ for (int i = 0; i < v.Count - 1; i++)
+ {
+ long gap = v[i].DrainagePx - v[i + 1].DrainagePx;
+ if (gap > r.BreakAbs) { r.BreakAbs = gap; r.BreakRankAbs = i + 1; }
+ }
+ foreach (int nn in ladder)
+ {
+ int s = 0, e = 0;
+ for (int i = 0; i < Math.Min(nn, v.Count); i++) { if (v[i].IsSea) s++; else e++; }
+ // ⭐ Would the separation rule have changed THIS rung? Count the suppressed outlets whose
+ // drainage clears the rung's cutoff. Zero means the rule cannot have altered the ladder,
+ // and the sea/endorheic split below is trustworthy exactly as shown.
+ long cut = nn <= v.Count ? v[nn - 1].DrainagePx : 0;
+ int would = 0;
+ foreach (long a in r.SuppressedAboveFloorAccs) if (a >= cut) would++;
+ r.WouldHaveMadeN[nn] = would;
+ // ⚠ A ladder rung above the candidate count is NOT "N with a 0 px smallest" — it is a rung
+ // the terrain cannot fill. Carried as a flag so the table says so instead of printing a 0.
+ r.AtN[nn] = (s, e, nn <= v.Count ? v[nn - 1].DrainagePx : 0, nn <= v.Count);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private static void BindCourses(SeedResult r, DrainageAnalysis.Plan plan, int n, int maxLadder)
+ {
+ 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;
+ for (int i = 0; i < Math.Min(maxLadder, r.Ranked.Count); i++)
+ {
+ var c = r.Ranked[i];
+ 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 the top {maxLadder} candidates have no traced stem. The analysis's " +
+ "reporting caps are what produce the courses, so they must be at least the largest ladder count — " +
+ "raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
+ }
+
+ // ═══ OUTPUT ═══════════════════════════════════════════════════════════════════════════════
+
+ private static void Report(SeedResult r, int[] ladder)
+ {
+ var v = r.Ranked;
+ GD.Print($" candidates above the floor: {v.Count} ({CountSea(v)} sea / {v.Count - CountSea(v)} endorheic)");
+ GD.Print($" separation: {r.SeaOutletsAll:N0} raw sea outlets -> {r.SuppressedCount:N0} suppressed ({r.SuppressedPx:N0} px total, mostly one-cell coastal trickles);" +
+ $" of those only {r.SuppressedAboveFloor:N0} cleared the floor ({r.SuppressedAboveFloorPx:N0} px) — THAT is what the rule costs the candidate pool");
+ GD.Print($" ⚠ of those {r.SuppressedAboveFloor:N0}, {r.SuppressedCrossLandmass:N0} ({r.SuppressedCrossLandmassPx:N0} px) were suppressed by an outlet on a DIFFERENT LANDMASS" +
+ " — not a delta mouth by any definition; the rule is landmass-blind (reported, not changed)");
+ long maxSup = 0; foreach (long a in r.SuppressedAboveFloorAccs) if (a > maxSup) maxSup = a;
+ GD.Print($" ⭐ largest suppressed above-floor outlet: {maxSup:N0} px" +
+ $" · suppressed outlets that clear each ladder cutoff: " +
+ string.Join(" ", new List(ladder.Length == 0 ? new string[0] : Array.ConvertAll(ladder, nn => $"N={nn}:{(r.WouldHaveMadeN.TryGetValue(nn, out int w) ? w : 0)}"))));
+ GD.Print($" knee (largest RATIO between consecutive ranks, window 3..40): rank {r.BreakRankRatio} at {r.BreakRatio:F2}x" +
+ $" · largest ABSOLUTE gap: rank {r.BreakRankAbs} ({r.BreakAbs:N0} px — expected near the top; not a count)");
+ foreach (int nn in ladder)
+ {
+ var (s, e, a, enough) = r.AtN[nn];
+ GD.Print(enough
+ ? $" N={nn,-3} sea {s,2} / endorheic {e,2} smallest promoted {a:N0} px"
+ : $" N={nn,-3} ⚠ ONLY {r.Ranked.Count} CANDIDATES EXIST above the floor — this rung cannot be filled");
+ }
+ for (int i = 0; i < Math.Min(12, v.Count); i++)
+ GD.Print($" #{v[i].Rank,-3} {v[i].TerminusName,-9} {v[i].DrainagePx,12:N0} px ({v[i].X},{v[i].Y})" +
+ (v[i].IsSea ? "" : $" basin {v[i].BasinAreaPx:N0} px / {v[i].BasinDepthM:F1} m"));
+ }
+
+ private static int CountSea(List v) { int s = 0; foreach (var c in v) if (c.IsSea) s++; return s; }
+
+ private static long MaxOf(List v) { long m = 0; foreach (long a in v) if (a > m) m = a; return m; }
+
+ private static void WriteCsv(string batchRoot, SeedResult r)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("rank,terminus,drainage_px,basin_min_x,basin_min_y,river_term_x,river_term_y,basin_id,basin_area_px,basin_depth_m");
+ foreach (var c in r.Ranked)
+ sb.AppendLine($"{c.Rank},{c.TerminusName},{c.DrainagePx},{c.X},{c.Y},{c.TermX},{c.TermY}," +
+ $"{(c.IsSea ? "" : c.BasinId.ToString())},{(c.IsSea ? "" : c.BasinAreaPx.ToString())}," +
+ $"{(c.IsSea ? "" : c.BasinDepthM.ToString("F2"))}");
+ WriteText(Path.Combine(batchRoot, $"candidates_{r.Seed}.csv"), sb.ToString());
+ }
+
+ private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean,
+ Pass2Result p2, int n, float sea, int[] ladder, long floorPx, DrainageAnalysis.Params def, bool skipRaw)
+ {
+ string dir = Path.Combine(batchRoot, $"{r.Seed}");
+ DirAccess.MakeDirRecursiveAbsolute(dir);
+
+ DrainageRenderer.Distribution(r.Ranked, ladder, def.EndorheicMinInflowPx, def.StemMinAccPx, floorPx,
+ $"SEED {r.Seed} - CANDIDATE DRAINAGE DISTRIBUTION")
+ .SavePng(Path.Combine(dir, "distribution.png"));
+
+ // ⚠ The faint-terrain base is 1 SetPixel per cell — 67 M at 8192 — and all four overlay maps
+ // share it. Build it once and duplicate; rendering it per map would quadruple the slowest
+ // part of this tool for four identical results.
+ Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
+
+ DrainageRenderer.PromotionCandidates(r.Ranked, baseImg.Duplicate() as Image, n,
+ $"SEED {r.Seed} - ALL CANDIDATES (DIAGNOSTIC, NOTHING PROMOTED)", ladder)
+ .SavePng(Path.Combine(dir, "candidates_all.png"));
+
+ foreach (int nn in ladder)
+ {
+ var promoted = r.Ranked.GetRange(0, Math.Min(nn, r.Ranked.Count));
+ DrainageRenderer.PromotedRivers(promoted, baseImg.Duplicate() as Image, n, nn, floorPx,
+ $"SEED {r.Seed} - PROMOTED TOP {nn} (UNIFIED RANKING)")
+ .SavePng(Path.Combine(dir, $"promoted_N{nn:D2}.png"));
+ }
+
+ // Grayscale beside the pretty renders — the house rule: a colour map is a reading of a
+ // field, and the field itself must be inspectable without the palette in the way.
+ GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
+ if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
+ }
+
+ private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, int[] renderSe,
+ List rows, int[] ladder, long floorPx, int promoteMax, DrainageAnalysis.Params def, bool skipRaw)
+ {
+ var sb = new StringBuilder();
+ int primary = renderSe.Length > 0 ? renderSe[0] : seeds[0];
+ sb.AppendLine($"# Batch {02:D2} — river promotion: how many rivers does THIS terrain have?");
+ sb.AppendLine();
+ sb.AppendLine("**⛔ THIS IS A TASTE GATE. It presents 8 / 12 / 16 and stops — no count is chosen here, and no");
+ sb.AppendLine("default is set.** The measurement below exists so the pick is made on the drainage-area");
+ sb.AppendLine("distribution rather than on a number inherited from terrain that no longer exists.");
+ sb.AppendLine();
+ sb.AppendLine("## 👉 The pick");
+ sb.AppendLine();
+ sb.AppendLine($"Open the three count plates for seed `{primary}` **side by side**:");
+ sb.AppendLine();
+ foreach (int nn in ladder) sb.AppendLine($"- **`{primary}/promoted_N{nn:D2}.png`**");
+ sb.AppendLine();
+ sb.AppendLine($"Then `{primary}/distribution.png` beside them — it shows where each N falls on the curve, and");
+ sb.AppendLine("whether the terrain has a knee to justify one. **The question is not \"which looks prettiest\"**");
+ sb.AppendLine("but: *at which N do the promoted rivers still read as the island's major drainages, and at which");
+ sb.AppendLine("N does the set start including things that are not rivers?*");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⚠ Read the colours as information, not decoration.");
+ sb.AppendLine("> **Cyan = reaches the ocean, orange = ends inland.** On this terrain ~68 % of land drains");
+ sb.AppendLine("> inland by design (→ D-065), so a plate that is mostly orange is the CORRECT result, not a");
+ sb.AppendLine("> broken one. An endorheic terminus is a pass, equal to reaching the sea — never a fallback.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ The unified ranking — what changed, and why");
+ sb.AppendLine();
+ sb.AppendLine("The reference promoted from **two lists with two quotas** (N sea trunks, N endorheic giants).");
+ sb.AppendLine("That structure assumes reaching the sea is what makes a drainage a river. **This terrain does not");
+ sb.AppendLine("satisfy that assumption**, so selection here is unified: every major drainage is ranked by");
+ sb.AppendLine("contributing-cell count in ONE list, the top N is promoted, and the sea/endorheic split is an");
+ sb.AppendLine("*outcome*. A quota would have promoted small coastal drainages over far larger inland ones purely");
+ sb.AppendLine("because of where they end. *(A deliberate departure from the reference's structure; only the");
+ sb.AppendLine("SELECTION is unified — the per-river terminus tag is retained, because rivers/03's routing branches on it.)*");
+ sb.AppendLine();
+ sb.AppendLine("**The metric is the same unit on both sides, and that is asserted, not assumed:** sea-outlet");
+ sb.AppendLine("`Acc` and endorheic `BasinInflow` are both counts of contributing land cells on the same D8");
+ sb.AppendLine("field, and every land cell has exactly one destination. The tool refuses to rank unless");
+ sb.AppendLine("`Σ sea Acc + Σ BasinInflow + unrouted == LandCells` holds exactly, per seed. It held on every seed.");
+ sb.AppendLine();
+ sb.AppendLine("## The natural-break question — does a count generalize across the terrain?");
+ sb.AppendLine();
+ sb.AppendLine("**The knee is the largest RATIO between consecutive ranks** (window 3..40). The largest *absolute*");
+ sb.AppendLine("gap is reported too, and is deliberately not the answer: areas span 3+ decades, so the absolute");
+ sb.AppendLine("gap almost always sits at rank 1–2 and measures the biggest river, not a natural count.");
+ sb.AppendLine();
+ sb.AppendLine("| Seed | candidates | sea / endo | ⭐ knee rank (ratio) | largest abs gap (rank) | " +
+ string.Join(" | ", Array.ConvertAll(ladder, x => $"N={x} sea/endo · smallest px")) + " |");
+ sb.AppendLine("|---|---|---|---|---|" + string.Concat(Array.ConvertAll(ladder, _ => "---|")));
+ foreach (var r in rows)
+ {
+ var cells = new List();
+ foreach (int nn in ladder)
+ {
+ var (s, e, a, enough) = r.AtN[nn];
+ cells.Add(enough ? $"{s} / {e} · {a:N0}" : $"⚠ only {r.Ranked.Count}");
+ }
+ sb.AppendLine($"| `{r.Seed}` | {r.Ranked.Count} | {CountSea(r.Ranked)} / {r.Ranked.Count - CountSea(r.Ranked)} | " +
+ $"**{r.BreakRankRatio}** ({r.BreakRatio:F2}×) | {r.BreakRankAbs} ({r.BreakAbs:N0} px) | " +
+ string.Join(" | ", cells) + " |");
+ }
+ sb.AppendLine();
+ sb.AppendLine("| Seed | land cells | → ocean | → endorheic | unrouted | terminal basins | raw sea outlets | suppressed (all) | suppressed ABOVE the floor | of those, cross-landmass | largest suppressed | ⭐⭐ would have made N=8/12/16 |");
+ sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
+ foreach (var r in rows)
+ sb.AppendLine($"| `{r.Seed}` | {r.LandCells:N0} | {r.SeaReachingCells:N0} ({100.0 * r.SeaReachingCells / Math.Max(1, r.LandCells):F1} %) | " +
+ $"**{r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %)** | {r.UnroutedCells:N0} | " +
+ $"{r.TerminalBasins} | {r.SeaOutletsAll:N0} | {r.SuppressedCount:N0} ({r.SuppressedPx:N0} px) | " +
+ $"**{r.SuppressedAboveFloor:N0} ({r.SuppressedAboveFloorPx:N0} px)** | " +
+ $"{r.SuppressedCrossLandmass:N0} ({r.SuppressedCrossLandmassPx:N0} px) | " +
+ $"{MaxOf(r.SuppressedAboveFloorAccs):N0} px | " +
+ $"**{string.Join(" / ", Array.ConvertAll(ladder, nn => (r.WouldHaveMadeN.TryGetValue(nn, out int w) ? w : 0).ToString()))}** |");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⚠ What the separation rule discards, stated plainly");
+ sb.AppendLine($"> `MinOutletSeparationPx = {def.MinOutletSeparationPx}` drops a sea outlet when a larger one sits within that radius —");
+ sb.AppendLine("> so three mouths of one delta are not three rivers. **But under D8 each cell has exactly one flow");
+ sb.AppendLine("> path, so those mouths have DISJOINT contributing areas: the rule discards a dropped outlet's");
+ sb.AppendLine("> drainage rather than merging it into the kept one.** For counting rivers that is the intent; the");
+ sb.AppendLine("> two suppressed columns are how much it removes. ⚠ Read the SECOND one: a fragmented coastline");
+ sb.AppendLine("> has tens of thousands of one-cell outlets, so the aggregate figure is dominated by drainage that");
+ sb.AppendLine("> was never a candidate. Only outlets that cleared the floor could ever have been promoted, and");
+ sb.AppendLine("> that is the honest cost of the rule.");
+ sb.AppendLine(">");
+ sb.AppendLine("> ### ⚠⚠ AND THE RULE IS LANDMASS-BLIND — the last column is the part that should bother you.");
+ sb.AppendLine("> `MinOutletSeparationPx` is a plain Euclidean distance test. It has no idea which land a");
+ sb.AppendLine("> coastline belongs to, so on **this deliberately fragmented archipelago (→ D-063)** it can");
+ sb.AppendLine("> suppress an ISLAND's only river because a mainland river's mouth sits within 400 px **across");
+ sb.AppendLine("> open water** — which is not a delta by any definition. The last column counts exactly that.");
+ sb.AppendLine(">");
+ sb.AppendLine("> **The rule was NOT changed here.** It belongs to `DrainageAnalysis`, and moving it would move");
+ sb.AppendLine("> the candidate set this gate asks the developer to judge. It is measured so the count is chosen");
+ sb.AppendLine("> knowing the cost. **→ rivers/03 should decide whether separation becomes component-aware**");
+ sb.AppendLine("> (the region layer already exposes `RegionLabels.Id` per cell, so the fix is one lookup) —");
+ sb.AppendLine("> and if it does, the sea side of this ranking grows and the split at each N shifts.");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⚠⚠ This analysis is SCALE-DEPENDENT, and that is a finding, not a footnote.");
+ sb.AppendLine($"> `EndorheicMinAreaPx` ({def.EndorheicMinAreaPx:N0}) and `MinOutletSeparationPx` ({def.MinOutletSeparationPx}) are ABSOLUTE pixel counts tuned at");
+ sb.AppendLine("> 8192. Measured at 1024 during this task's smoke: **zero** depressions qualify as terminal basins");
+ sb.AppendLine("> (100 % sea-reaching — the endorheic half of the ranking cannot be exercised at all), and the");
+ sb.AppendLine("> separation radius is 39 % of the map width, suppressing 15,043 of 15,048 sea outlets down to four");
+ sb.AppendLine("> candidates. **A small-map run of this tool measures the params, not the terrain**, so every number");
+ sb.AppendLine("> below is from 8192 and the tool prints a loud refusal-to-compare at any other size.");
+ sb.AppendLine("> *(Flagged for rivers/03: if routing ever needs another size, these two want to become");
+ sb.AppendLine("> scale-free fractions of map area / width, exactly as `MinLandComponentFrac` already is.)*");
+ sb.AppendLine();
+ sb.AppendLine("## What was run");
+ sb.AppendLine();
+ sb.AppendLine($"Distribution on **{seeds.Length} seeds** at {mapSize} (`{string.Join(", ", seeds)}`); maps on **{renderSe.Length}**");
+ sb.AppendLine($"(`{string.Join(", ", renderSe)}`). Curve calibrated at {calibSize} on the family-off pinned pool.");
+ sb.AppendLine($"Terrain: {TerrainShapeV1.Describe()} + erosion ON — all from the bare defaults (rivers/01).");
+ sb.AppendLine();
+ sb.AppendLine($"**Diagnostic floor** `{floorPx:N0}` px — the significance floor for the DISTRIBUTION, deliberately far");
+ sb.AppendLine("below any plausible count so the curve's shape is visible. **It is not a promotion threshold.**");
+ sb.AppendLine();
+ sb.AppendLine($"**Reporting caps raised** to `{promoteMax}` (`TrunkCount` / `GiantCount` / `EndorheicMaxCount`, all 3 by");
+ sb.AppendLine("default) purely so the analysis traces a real upland stem for every promotable candidate.");
+ sb.AppendLine();
+ sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} decide which");
+ sb.AppendLine("depressions BECOME terminal basins — they define the routing surface itself, not how much of it is");
+ sb.AppendLine($"reported. Also unchanged: `MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}, `TributaryMinAccPx` {def.TributaryMinAccPx:N0}.");
+ sb.AppendLine();
+ sb.AppendLine("## Files");
+ sb.AppendLine();
+ sb.AppendLine("| File | What it is |");
+ sb.AppendLine("|---|---|");
+ sb.AppendLine("| `/distribution.png` | drainage area (log) vs unified rank, with N marks and the analysis's own thresholds |");
+ sb.AppendLine("| `/candidates_all.png` | every candidate on the terrain, marker AREA ∝ drainage, colour by terminus |");
+ foreach (int nn in ladder) sb.AppendLine($"| `/promoted_N{nn:D2}.png` | the unified top {nn}: real upland stems, width ∝ drainage, terminus markers |");
+ sb.AppendLine("| `/grayscale.png` | the raw eroded render field, no palette — the field behind every colour map |");
+ if (skipRaw)
+ sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written.** rivers/01 proved this exact field byte-identical to `chat2/11_erosion`, which is the anchor of record — re-dumping 256 MB per seed of a field that already exists elsewhere is waste, not evidence. Regenerate with `ISLA_SKIP_RAW=0`. |");
+ else
+ sb.AppendLine("| `/height.f32` | the eroded render field this analysis ran on |");
+ sb.AppendLine("| `candidates_.csv` | the full ranked list for every distribution seed |");
+ sb.AppendLine();
+ sb.AppendLine("## ⚠ What is NOT here");
+ sb.AppendLine();
+ sb.AppendLine("- **No lowland routing.** The plates draw the REAL upland stems (erosion-carved, max-accumulation).");
+ sb.AppendLine(" `Giant.ProvisionalRoute` — the steepest-descent placeholder, the visible \"comb\" — is deliberately");
+ sb.AppendLine(" **not drawn**; replacing it is rivers/03's job, and drawing it would make a count look like a");
+ sb.AppendLine(" finished network. Below each terminus the real course is still un-routed.");
+ sb.AppendLine("- **No water, no carving, no crater.** Nothing here modifies terrain; the analysis is pure.");
+ sb.AppendLine("- **No chosen count.** That is the developer's call, and it is the point of the gate.");
+ 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/02_promotion.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;
+ }
+ }
+}