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