rivers/03: lowland routing — the routed MIX on the pure N=12, courses only

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.
This commit is contained in:
Stewart Howe 2026-08-24 04:54:30 -04:00
parent 559306ca73
commit 4e4be6a83e
8 changed files with 1577 additions and 209 deletions

View file

@ -183,6 +183,72 @@ namespace IslaApocalypse.Core
return labels;
}
/// <summary>
/// ⭐⭐ 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 <paramref name="minPx"/> 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 <see cref="Label"/>, so component identity is deterministic.
/// </summary>
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<int>();
var component = new List<int>();
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;
}
/// <summary>
/// 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

View file

@ -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")

View file

@ -310,46 +310,11 @@ namespace IslaApocalypse.Tools
}
int ls = n >= 4096 ? 4 : 3;
int lineH = TinyFont.Height(ls);
var placed = new List<Rect2I>();
// 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);
}
/// <summary>
/// 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.
/// </summary>
private sealed class LabelPlacer
{
private readonly List<Rect2I> _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 ═══
/// <summary>Routed giants: the natural upland stem, muted.</summary>
private static readonly Color RoutedStem = new(0.250f, 0.620f, 0.330f);
/// <summary>⭐ The LOWLAND REACH routing added — bright, so the added channel is unmistakable.</summary>
private static readonly Color RoutedReach = new(0.380f, 1.000f, 0.420f);
/// <summary>The rim the route climbed over — the point the developer is asked to judge.</summary>
private static readonly Color RimMark = new(1.000f, 0.930f, 0.350f);
/// <summary>
/// ⭐⭐ 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.
/// </summary>
public static Image RoutedMix(List<RiverRouting.RoutedRiver> 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<RiverRouting.RoutedRiver>(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;
}
/// <summary>Ring the route's high point — the rim the channel crosses.</summary>
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);
}
/// <summary>
/// ⭐ 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.

View file

@ -99,6 +99,22 @@ namespace IslaApocalypse.Tools
/// <summary>1-based rank in the unified descending ranking. 0 until ranked.</summary>
public int Rank;
/// <summary>
/// ⭐ rivers/03 — THE ANALYSIS'S OWN routed/lake-ender verdict, copied from <c>Giant.Kind</c> at
/// bind time. Endorheic only ("" for sea candidates).
///
/// ⚠⚠ READ THE TEST BEFORE TRUSTING THE NAME. `DrainageAnalysis` assigns this as
/// <c>(basinHasLake[id] &amp;&amp; !SouthernCandidate) ? "lake-ender" : "routed"</c> — 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.
/// </summary>
public string AnalysisKind = "";
/// <summary>Endorheic only: the analysis found classify water in this terminal basin.</summary>
public bool TerminalInClassifyWater;
public string TerminusName => IsSea ? "sea" : "endorheic";
}
}

View file

@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ 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 <see cref="BindCourses"/>).
/// </summary>
public static class RiverCandidates
{
/// <summary>The enumeration's full result: the ranking, plus what the separation rule cost.</summary>
public sealed class Enumeration
{
/// <summary>Separated and above the floor, descending by <c>DrainagePx</c>, `Rank` assigned.</summary>
public List<RiverCandidate> Ranked;
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
public int TerminalBasins, SeaOutletsAll;
/// <summary>Sea outlets dropped by the separation rule — ALL of them, incl. one-cell trickles.</summary>
public int SuppressedCount;
public long SuppressedPx;
/// <summary>⭐ The two that matter: only outlets clearing the floor could ever have been promoted.</summary>
public int SuppressedAboveFloor;
public long SuppressedAboveFloorPx;
/// <summary>Of those, suppressed by an outlet on a DIFFERENT landmass — not a delta mouth by any definition.</summary>
public int SuppressedCrossLandmass;
public long SuppressedCrossLandmassPx;
public List<long> SuppressedAboveFloorAccs = new();
public int SeaCount { get { int s = 0; foreach (var c in Ranked) if (c.IsSea) s++; return s; } }
}
/// <summary>
/// Enumerate every candidate major drainage, cap-free.
///
/// SEA every cell with <c>Dir == D_SEA</c>, carrying <c>Acc</c> there, then the
/// analysis's own greedy <c>MinOutletSeparationPx</c> rule so three mouths of one
/// delta are not three rivers.
/// ENDORHEIC every terminal basin in <c>BasinId</c>, carrying <c>BasinInflow[id]</c>, with
/// terminal cell / area / depth re-derived from the exposed surfaces.
///
/// ⚠⚠ Throws unless the metric-comparability identity holds exactly — see below.
/// </summary>
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<RiverCandidate>();
var kept = new List<int>();
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<RiverCandidate>();
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<RiverCandidate>();
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;
}
/// <summary>
/// Bind each candidate in <paramref name="need"/> to the <c>Trunk</c> / <c>Giant</c> 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 <see cref="RiverCandidate.Kind"/> and
/// <see cref="RiverCandidate.TerminalInClassifyWater"/> for endorheic candidates — rivers/03
/// needs the reference's routed/lake-ender verdict to compare against its own.
/// </summary>
public static void BindCourses(DrainageAnalysis.Plan plan, int n, List<RiverCandidate> need, string what)
{
var byOutlet = new Dictionary<int, DrainageAnalysis.Trunk>();
foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
var byBasin = new Dictionary<int, DrainageAnalysis.Giant>();
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.");
}
}
}

View file

@ -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.
/// <summary>
/// Enumerate every candidate major drainage, cap-free.
///
/// SEA every cell with <c>Dir == D_SEA</c>, carrying <c>Acc</c> at that cell, then the
/// analysis's own greedy <c>MinOutletSeparationPx</c> rule so three mouths of one
/// delta are not three rivers.
/// ENDORHEIC every terminal basin present in <c>BasinId</c>, carrying <c>BasinInflow[id]</c>,
/// 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.
/// </summary>
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<RiverCandidate>();
var kept = new List<int>();
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<RiverCandidate>();
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<RiverCandidate>();
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;
}
/// <summary>
@ -544,48 +429,9 @@ namespace IslaApocalypse.Tools
}
}
/// <summary>
/// Bind each promotable candidate to the <c>Trunk</c> / <c>Giant</c> 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.
/// </summary>
/// <summary>Delegated to <see cref="RiverCandidates.BindCourses"/> — see the note on Enumerate.</summary>
private static void BindCourses(SeedResult r, DrainageAnalysis.Plan plan, int n, List<RiverCandidate> need, string what)
{
var byOutlet = new Dictionary<int, DrainageAnalysis.Trunk>();
foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
var byBasin = new Dictionary<int, DrainageAnalysis.Giant>();
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 ════════════════════════════

View file

@ -0,0 +1,400 @@
using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ 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.
/// </summary>
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;
/// <summary>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.</summary>
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 };
/// <summary>One lowland route, with the diagnostics the gate needs to judge it.</summary>
public sealed class Route
{
/// <summary>Terminal → target, 1-px steps, as Dijkstra produced it. Empty when no path exists.</summary>
public List<(float x, float y)> Path = new();
/// <summary>The same reach after RDP + Chaikin. This is what is drawn and spliced.</summary>
public List<(float x, float y)> Smoothed = new();
/// <summary>⭐ Did a path exist at all? Empty list on no path — never thrown.</summary>
public bool Reached;
/// <summary>Dijkstra cost at the goal (cost-model units, not metres).</summary>
public float Cost;
/// <summary>⭐⭐ 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.</summary>
public float MaxStepUphillM;
/// <summary>⭐ Total metres climbed along the route, and how many steps climbed at all.</summary>
public float TotalUphillM;
public int UphillSteps;
/// <summary>Highest point on the route, metres above sea — the rim's absolute height.</summary>
public float MaxElevM;
/// <summary>Net climb from the terminal to the route's high point, metres — what "over the rim" costs.</summary>
public float RimClimbM;
/// <summary>⭐ WHERE the route tops out — the rim cell, ringed on the plate.</summary>
public (float x, float y) RimPoint;
public float LenPx, StraightPx, WanderRatio;
/// <summary>Cells settled by the search — the honest cost of a Dijkstra at this map size.</summary>
public long Expanded;
public (float x, float y) Target;
}
/// <summary>
/// ⭐ Deterministic Dijkstra from a start cell to the nearest cell of <paramref name="targets"/>
/// 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.
/// </summary>
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, (float c, int i)>();
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;
}
/// <summary>
/// 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.
/// </summary>
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.
/// <summary>RDP tol 4 + 4 Chaikin corner-cutting passes, endpoints pinned.</summary>
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;
}
/// <summary>The three classes the MIX is made of.</summary>
public enum RiverClass
{
/// <summary>Sea-reaching already, exactly as erosion carved it. No lowland route needed.</summary>
OceanTrunk,
/// <summary>An endorheic basin connected to the coast by a routed over-the-rim channel.</summary>
RoutedGiant,
/// <summary>Stays inland: terminates at a significant lake, or at its own terminal.</summary>
LakeEnder,
}
/// <summary>One promoted river, classified, routed and assembled.</summary>
public sealed class RoutedRiver
{
public RiverCandidate Candidate;
public RiverClass Class;
/// <summary>The lowland reach actually used: the ocean route for a routed giant, the lake
/// route for a lake-ender. Null for trunks.</summary>
public Route Lowland;
/// <summary>⭐ The ocean route computed for EVERY giant, including lake-enders — see the note
/// on <see cref="RouteAll"/>. This is what makes an affordability threshold judgeable.</summary>
public Route OceanProbe;
/// <summary>Lake-enders: did the extension reach a SIGNIFICANT body (vs the classify fallback, vs nothing)?</summary>
public bool LakeReached, LakeWasFallback;
/// <summary>Head → terminus, stem + smoothed lowland reach.</summary>
public List<(float x, float y)> Course;
public string Why = "";
public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
}
/// <summary>
/// ⭐⭐ 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:**
/// <code>
/// Kind = (basinHasLake[id] &amp;&amp; !SouthernCandidate) ? "lake-ender" : "routed"
/// </code>
/// 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 (<see cref="RoutedRiver.OceanProbe"/>), 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.**
/// </summary>
public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
Action<string> log)
{
var outp = new List<RoutedRiver>();
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;
}
/// <summary>
/// ⭐ 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.
/// </summary>
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;
}
}
}

View file

@ -0,0 +1,649 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ 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)
/// </summary>
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<RiverRouting.RoutedRiver> Rivers;
public int Trunks, Routed, Lakes;
public int SeaReaching;
/// <summary>⭐ 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.</summary>
public int DistinctMouths;
/// <summary>Pairs of sea-reaching rivers sharing a mouth cell, as "#a+#b".</summary>
public List<string> 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<SeedResult>();
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);
}
/// <summary>
/// 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".
/// </summary>
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;
}
/// <summary>
/// ⭐⭐ 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.
/// </summary>
private static void MeasureMouths(SeedResult r, List<RiverRouting.RoutedRiver> rivers)
{
var at = new Dictionary<(int x, int y), List<int>>();
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<int>(); 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})");
}
/// <summary>
/// Where the sea-reaching rivers actually meet the coast. ⚠ In this codebase a cell is
/// <c>x*n + y</c> and the image is drawn <c>SetPixel(x, y)</c>, so +y is SOUTH on the plate.
/// </summary>
private static string Spread(List<RiverRouting.RoutedRiver> rivers, int n)
{
var counts = new Dictionary<string, int>();
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<string>();
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<SeedResult> 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 12 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("| `<seed>/routed_mix.png` | the three classes, with the added lowland reach and the rim it crossed drawn distinctly |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `rivers_<seed>.csv` | per river: class, analysis kind, route geometry, rim, cost, cells settled, why |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/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<int, Pass1Result>();
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<int>();
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;
}
}
}