islaApocalypse-v2/Tools/Scripts/RiverRoutingTool.cs
beezm b5ceca0419 rivers/03b: routing refinement — three deliberate divergences from the faithful port
NOT a port. Each fix corrects a faithful reference behaviour that produced a
physically-wrong result, on the developer's explicit call. basinHasLake is KEPT
as the sort. Courses only: no height mutated, no water filled or created —
asserted per seed by a raw-bit digest of both height fields.

- FIX 1 rim cap (ISLA_RIM_CAP_M, default 30 m): a route to the sea that must
  climb higher than this above its terminal is refused; the river ends at its
  own terminal. Reference routes at any cost (rivers/03 found a 66.7 m one).
- FIX 3 lake targets: a router stops at the nearer of {ocean, significant lake},
  so it cannot skirt a lake to reach a distant coast. Reference targets ocean only.
- FIX 2 confluence: courses laid biggest-first join on TRUE cell intersection
  (never proximity); the smaller becomes a tributary and adopts the bigger one's
  downstream and terminus. Reference lays routes independently — rivers/03 found
  two rivers at the identical ocean cell on every seed.

All three are off by default (RiverRouting.Options.Faithful), so rivers/03 still
reproduces bit-for-bit from the same tool.

Taste gate: nothing locked, nothing graduated.
2026-08-24 22:00:22 -04:00

867 lines
53 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>rivers/03b classes.</summary>
public int LakeFed, Walled, Joined;
/// <summary>Rivers whose OWN course ends at the sea (tributaries excluded — they have no mouth).</summary>
public int SeaReaching;
/// <summary>⭐ Rivers whose water reaches the sea, counting tributaries through their trunk.</summary>
public int SeaConnected;
public List<string> CapMoved = new();
public List<string> LakeMoved = new();
public List<string> Joins = new();
/// <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"));
// ⭐ rivers/03b — the three approved DIVERGENCES. Default OFF, so this tool still reproduces
// rivers/03's faithful port bit-for-bit.
bool refined = EnvStr("ISLA_ROUTING_MODE", "faithful").Trim().ToLowerInvariant() == "refined";
float rimCapM = float.TryParse(EnvStr("ISLA_RIM_CAP_M", "30"), out float rc) ? rc : 30f;
var opt = refined
? new RiverRouting.Options { RimCapM = rimCapM, LakeTargetForRouters = true, Confluence = true }
: RiverRouting.Options.Faithful;
int task = EnvInt("ISLA_TASK", 3);
// ⭐ rivers/03b is a LETTERED SUB-TASK of 03 — same authoring task, three changed rules.
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", refined ? "b" : "");
string descr = EnvStr("ISLA_BATCH", refined ? "routing_refinement" : "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, taskSfx, 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(refined
? " ROUTING REFINEMENT (rivers/03b) — three DELIBERATE DIVERGENCES from the faithful port, COURSES ONLY"
: " 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.");
if (refined)
{
GD.Print("⚠⚠ THREE DELIBERATE DIVERGENCES FROM THE REFERENCE — approved, and NOT a port:");
GD.Print($" FIX 1 RIM CAP {rimCapM:F0} m — a route to the sea that must climb higher than this above its");
GD.Print( " terminal is REFUSED; the river becomes a walled-off inland lake-ender. (Reference: routes at ANY cost.)");
GD.Print( " FIX 3 LAKE TARGETS — a router stops at the nearer of {ocean, significant lake}, so it cannot");
GD.Print( " skirt a lake to reach a distant coast. (Reference: routers target ocean only.)");
GD.Print( " FIX 2 CONFLUENCE — courses laid biggest-first join on TRUE CELL INTERSECTION and the smaller");
GD.Print( " becomes a tributary. (Reference: no dedup, no join — parallel duplicates to one mouth.)");
GD.Print( " ⚠ KEPT: basinHasLake stays the sort — a basin that already holds a lake is a natural lake-ender.");
}
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), opt);
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)
{
switch (rr.Class)
{
case RiverRouting.RiverClass.OceanTrunk: r.Trunks++; break;
case RiverRouting.RiverClass.RoutedGiant: r.Routed++; break;
case RiverRouting.RiverClass.LakeFed: r.LakeFed++; break;
case RiverRouting.RiverClass.WalledOff: r.Walled++; break;
default: r.Lakes++; break;
}
if (rr.OceanProbe != null) r.TotalExpanded += rr.OceanProbe.Expanded;
if (rr.Joined) { r.Joined++; r.Joins.Add($"#{rr.Candidate.Rank}→#{rr.ConfluenceParentRank} at ({rr.JunctionCell.x},{rr.JunctionCell.y})"); }
// ⭐ Make every reclassification the divergences caused legible, not silent.
if (rr.RefusedByCap) r.CapMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px, rim {rr.CappedRimM:F1} m)");
if (rr.Class == RiverRouting.RiverClass.LakeFed) r.LakeMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px)");
}
// ⚠ A tributary has NO mouth of its own — it reaches the sea through its trunk. So the two
// numbers are different and both are reported: how many rivers END at the sea, and how many
// rivers' water GETS there.
foreach (var rr in rivers)
{
if (!rr.Joined && rr.ReachesSea) r.SeaReaching++;
if (RiverRouting.Root(rr, rivers).ReachesSea) r.SeaConnected++;
}
MeasureMouths(r, rivers);
r.Spread = Spread(rivers, mapSize);
r.Ms = Time.GetTicksMsec() - t0;
GD.Print($" ⭐ MIX: {r.Trunks} trunks + {r.Routed} routed-to-sea + {r.LakeFed} lake-fed + {r.Lakes} natural lake-enders + {r.Walled} walled-off = {rivers.Count}" +
(r.Joined > 0 ? $" ({r.Joined} joined as tributaries)" : ""));
GD.Print($" → {r.DistinctMouths} DISTINCT SEA MOUTHS from {r.SeaReaching} river(s) ending at the sea; {r.SeaConnected} rivers' water reaches the sea" +
(r.SharedMouths.Count > 0 ? $" ⚠⚠ STILL SHARED: {string.Join(", ", r.SharedMouths)}" : " ✅ no two rivers share a mouth") +
$" spread: {r.Spread}");
if (r.CapMoved.Count > 0) GD.Print($" ⚠ rim cap moved routed→walled-off: {string.Join(", ", r.CapMoved)}");
if (r.LakeMoved.Count > 0) GD.Print($" ⭐ lake-target moved routed→lake-fed: {string.Join(", ", r.LakeMoved)}");
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, refined, rimCapM);
results.Add(r);
}
if (refined) WriteRefinedIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, rimCapM, dpDefaults, skipRaw);
else 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)
{
// ⚠ A tributary adopted its trunk's terminus — it is not a separate mouth.
if (rr.Joined || !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.Joined || !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",
RiverRouting.RiverClass.LakeFed => "lake-fed",
RiverRouting.RiverClass.WalledOff => "walled-off",
_ => "lake-ender",
};
private static void WriteRiverCsv(string batchRoot, SeedResult r)
{
var sb = new StringBuilder();
sb.AppendLine("rank,class,basin_has_lake,analysis_kind,faithful_class,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,cap_verdict,max_step_uphill_m,max_elev_m,total_uphill_m,uphill_steps," +
"rim_x,rim_y,cells_expanded,lake_reached,lake_was_fallback,joined,confluence_parent_rank," +
"junction_x,junction_y,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 == "lake-ender" ? "yes" : "no"))},{(c.IsSea ? "" : c.AnalysisKind)}," +
$"{(c.IsSea ? "" : ClassName(rr.FaithfulClass))},{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") : "")}," +
$"{(rr.RefusedByCap ? "REFUSED" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under cap" : "")}," +
$"{(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") : "")}," +
$"{(rr.Joined ? "yes" : "no")},{(rr.Joined ? rr.ConfluenceParentRank.ToString() : "")}," +
$"{(rr.Joined ? rr.JunctionCell.x.ToString() : "")},{(rr.Joined ? rr.JunctionCell.y.ToString() : "")}," +
$"{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, bool refined, float rimCapM)
{
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
if (refined)
DrainageRenderer.RefinedMix(r.Rivers, baseImg.Duplicate() as Image, n,
$"SEED {r.Seed} - THE RESHAPED MIX ON THE PURE TOP {promoteN} (RIVERS/03B)",
$"{r.DistinctMouths} DISTINCT SEA MOUTHS - {r.Trunks} TRUNK + {r.Routed} ROUTED + {r.LakeFed} LAKE-FED + {r.Lakes} NATURAL LAKE-ENDER + {r.Walled} WALLED-OFF, {r.Joined} JOINED. SPREAD: {r.Spread.ToUpperInvariant()}",
$"CHEAPEST ROUTE TO THE SEA CLIMBS MORE THAN THE {rimCapM:F0} M RIM CAP, SO IT ENDS AT ITS OWN TERMINAL")
.SavePng(Path.Combine(dir, "refined_mix.png"));
else
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());
}
/// <summary>
/// ⭐ THE REFINEMENT INDEX (rivers/03b) — the before/after, and the one judgment it feeds.
/// </summary>
private static void WriteRefinedIndex(string batchRoot, int mapSize, int[] seeds,
List<SeedResult> rows, int promoteN, int lakeMinPx, float rimCapM,
DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = seeds.Length > 0 ? seeds[0] : 0;
sb.AppendLine($"# Batch 03b — routing refinement: the reshaped MIX on the pure top {promoteN}");
sb.AppendLine();
sb.AppendLine("**⛔ TASTE GATE. Nothing is locked, and nothing graduates yet** — the cap is a knob, and the");
sb.AppendLine("developer has said not to graduate mid-refinement.");
sb.AppendLine();
sb.AppendLine("**⛔ COURSES ONLY. No height mutated, no water filled or created, nothing carved** — asserted per");
sb.AppendLine("seed by a raw-bit digest of both height fields before and after routing. Lake-termination *ends a");
sb.AppendLine("course at* an existing lake; it does not fill or create one.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick — this is a BEFORE/AFTER");
sb.AppendLine();
sb.AppendLine($"Open **`{primary}/refined_mix.png`** beside rivers/03's");
sb.AppendLine($"**`../03_lowland_routing/{primary}/routed_mix.png`** (the before). Same base, same colours where");
sb.AppendLine("they carry over, same fixed width scale — the two are directly comparable.");
sb.AppendLine();
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
sb.AppendLine("> **Does the island now read as natural dendritic drainage — no uphill rivers, no parallel");
sb.AppendLine("> duplicate mouths, no skirting past a lake to reach the sea — and is the resulting sea-river count");
sb.AppendLine("> healthy, or is the island now too lake-locked?**");
sb.AppendLine(">");
sb.AppendLine($"> If too walled-off, the levers are the cap (`ISLA_RIM_CAP_M` up from {rimCapM:F0}) or — as its own");
sb.AppendLine("> future task — rim incision. If about right, routing character is settled and the whole routing");
sb.AppendLine("> unit graduates together.");
sb.AppendLine();
sb.AppendLine("## ⚠⚠ Three DELIBERATE DIVERGENCES from the reference — not a port");
sb.AppendLine();
sb.AppendLine("Each corrects a faithful behaviour that produced a physically-wrong result, on the developer's call.");
sb.AppendLine();
sb.AppendLine("| # | The reference does | rivers/03 showed | rivers/03b does instead |");
sb.AppendLine("|---|---|---|---|");
sb.AppendLine($"| **1** | routes a dry basin to the sea **at any cost** | an uphill river over a **66.7 m** rim (`31415926 #2`) | **rim cap {rimCapM:F0} m** — over it, the river is refused and ends at its own terminal |");
sb.AppendLine("| **2** | lays every route independently, **no dedup, no join** | **two rivers at the identical ocean cell on every seed**, never having met | **confluence** — biggest-first, join on true cell intersection, the smaller becomes a tributary |");
sb.AppendLine("| **3** | a router targets **ocean only** | a router **skirts a lake** to reach the distant sea | **lake targets** — a router stops at the nearer of {ocean, significant lake} |");
sb.AppendLine();
sb.AppendLine("**⚠ KEPT unchanged: `basinHasLake` is still the sort.** A basin that already holds a visible lake is");
sb.AppendLine("a natural lake-ender and its river feeds its own lake — that is rivers/03's finding and it stands.");
sb.AppendLine();
sb.AppendLine("## ⭐ The reshaped MIX, per seed");
sb.AppendLine();
sb.AppendLine("| Seed | trunk | routed→sea | lake-fed | natural lake-ender | ⚠ walled-off | joined | ⭐⭐ DISTINCT SEA MOUTHS | spread |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | {r.Trunks} | {r.Routed} | {r.LakeFed} | {r.Lakes} | {r.Walled} | {r.Joined} | " +
$"**{r.DistinctMouths}** | {r.Spread} |");
sb.AppendLine();
sb.AppendLine("⚠ *Rivers whose water reaches the sea, counting tributaries through their trunk:* " +
string.Join(", ", Array.ConvertAll(rows.ToArray(), r => $"`{r.Seed}` {r.SeaConnected}")) + ".");
sb.AppendLine("A tributary has no mouth of its own, so it is not a separate sea mouth — but its water still gets there.");
sb.AppendLine();
bool anyShared = false;
foreach (var r in rows) if (r.SharedMouths.Count > 0) anyShared = true;
if (anyShared)
{
sb.AppendLine("> ### ⚠⚠ SOME MOUTHS ARE STILL SHARED — fix 2 did not fully close it");
foreach (var r in rows)
if (r.SharedMouths.Count > 0) sb.AppendLine($"> - `{r.Seed}`: {string.Join("; ", r.SharedMouths)}");
sb.AppendLine("> Two courses can arrive at the same cell without their rasterised paths ever sharing one");
sb.AppendLine("> earlier — they approach from different sides. Reported, not papered over.");
}
else
{
sb.AppendLine("> ### ✅ NO TWO RIVERS SHARE A MOUTH on any seed — fix 2 closed rivers/03's duplicate-mouth finding.");
sb.AppendLine("> Every distinct mouth is now a distinct river, and rivers that meet do so as a confluence.");
}
sb.AppendLine();
sb.AppendLine("## What the divergences actually moved");
sb.AppendLine();
sb.AppendLine("| Seed | ⚠ rim cap moved routed → walled-off | ⭐ lake target moved routed → lake-fed | confluences formed |");
sb.AppendLine("|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | {(r.CapMoved.Count > 0 ? string.Join("; ", r.CapMoved) : " none")} | " +
$"{(r.LakeMoved.Count > 0 ? string.Join("; ", r.LakeMoved) : " none")} | " +
$"{(r.Joins.Count > 0 ? string.Join("; ", r.Joins) : " none")} |");
sb.AppendLine();
sb.AppendLine("> ### ⚠ The min-rim signal — what to watch for");
sb.AppendLine("> The cap is applied to the **least-cost route's** rim climb, not to a theoretical minimum-rim path.");
sb.AppendLine("> LOWGROUND penalises uphill heavily so the chosen route is almost always the low-rim one — **but if");
sb.AppendLine("> a basin is walled off that visibly should have had a low way out, that is the signal we need a");
sb.AppendLine("> bottleneck (min-rim) search.** Check each walled-off river on the plate against its surroundings.");
sb.AppendLine("> Not built here.");
sb.AppendLine();
sb.AppendLine("## The per-river diagnostic");
sb.AppendLine();
foreach (var r in rows)
{
sb.AppendLine($"### `{r.Seed}`");
sb.AppendLine();
sb.AppendLine("| rank | class | basin has lake | drainage px | terminus | rim climb m | cap | joins | route len px |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var rr in r.Rivers)
{
var c = rr.Candidate; var lo = rr.Lowland; var pr = rr.OceanProbe;
string term = rr.Joined
? $"→ tributary of #{rr.ConfluenceParentRank}"
: 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",
RiverRouting.RiverClass.LakeFed => lo != null && lo.Reached ? $"**lake** ({(int)lo.Target.x},{(int)lo.Target.y})" : "⚠ no route",
RiverRouting.RiverClass.WalledOff => $"**its own terminal** ({c.TermX},{c.TermY})",
_ => rr.LakeReached ? $"lake ({(int)lo.Target.x},{(int)lo.Target.y})" : $"its own terminal ({c.TermX},{c.TermY})",
};
sb.AppendLine($"| #{c.Rank} | {ClassName(rr.Class)} | {(c.IsSea ? "" : (c.AnalysisKind == "lake-ender" ? "**yes**" : "no"))} | {c.DrainagePx:N0} | {term} | " +
$"{(pr != null && pr.Reached ? pr.RimClimbM.ToString("F1") : "")} | " +
$"{(rr.RefusedByCap ? "**REFUSED**" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under" : "")} | " +
$"{(rr.Joined ? $"#{rr.ConfluenceParentRank} at ({rr.JunctionCell.x},{rr.JunctionCell.y})" : "")} | " +
$"{(lo != null && lo.Reached ? lo.LenPx.ToString("F0") : "")} |");
}
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($"Promoted set: the **pure top {promoteN}** — no quota; rivers/02b's K stays parked and unlocked.");
sb.AppendLine($"Rim cap **{rimCapM:F0} m** (`ISLA_RIM_CAP_M`). Significant water: 8-connected classify-water components ≥ {lakeMinPx:N0} px,");
sb.AppendLine("ocean excluded (the interim for v2's missing water-bodies table). Lowland-only smoothing unchanged —");
sb.AppendLine("the lowland reach is smoothed, the erosion-carved upland stem never is.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused, not rebuilt); `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0}");
sb.AppendLine($"(they define the routing surface); `MinOutletSeparationPx` {def.MinOutletSeparationPx}; `StemMinAccPx` {def.StemMinAccPx}. Termini are classified by");
sb.AppendLine("`RegionLabeling.OceanMask` and the significant-lake mask only — no bare `h < sea`. `Giant.ProvisionalRoute` never drawn.");
sb.AppendLine();
sb.AppendLine("**⚠ No spatial term anywhere.** The `1063685222` southern-coast gap is *not* addressed here — it stays a");
sb.AppendLine("placement-era question, not a routing one.");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `<seed>/refined_mix.png` | the five classes as a dendritic tree; white dot = confluence, yellow ring = rim crossed |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `rivers_<seed>.csv` | per river: class, `basin_has_lake`, faithful class, rim + cap verdict, confluence parent, route geometry, why |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. `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/03b_routing_refinement.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;
}
}
}