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; + } + } +}