islaApocalypse-v2/Tools/Scripts/RiverRoutingTool.cs
beezm 09d23127e7 rivers/03c: routing polish — lake-termination as a preference, lake-enders come home
Two surgical refinements of rivers/03b's fix 3. Everything else (confluence,
the rim cap, the faithful default) unchanged. Courses only: no height mutated,
no water filled or created — asserted per seed by a raw-bit digest.

- FIX A (ISLA_LAKE_PREFER_RATIO, default 0.4): a dry-basin router goes to the SEA
  unless cost_lake < ratio * cost_ocean, i.e. unless a lake is materially cheaper.
  rivers/03b used "nearest of {ocean, lake}", which is exactly ratio < 1.0 and
  captured 12 rivers, halving the sea mouths. Both leg costs are now recorded for
  every router so the knob is readable off the table without a re-run.
- FIX B (OwnBasinLakeEnder): a natural lake-ender terminates at the water inside
  its OWN terminal basin at any size. The 20,000 px significance threshold had
  exiled 999999937 #3 from its sub-threshold home lake, sending it ~5,800 px along
  the shoreline. The threshold still applies to routers choosing a distant lake.

As predicted, the rim cap re-arms: 31415926 #2 (66.7 m) now goes to the sea and
is refused, rather than being hidden in a lake.

Both fixes are off by default, so faithful mode still reproduces rivers/03 and
refined mode still reproduces rivers/03b.

Taste gate: nothing locked, nothing graduated.
2026-08-24 23:28:02 -04:00

1076 lines
68 KiB
C#
Raw Permalink 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;
/// <summary>⭐ rivers/03c: every router's lake/ocean cost ratio — the tuning lever, as data.</summary>
public List<(int rank, long px, bool lakeFed, float costOcean, float costLake, float ratio)> Ratios = new();
/// <summary>The coast-hugger check: the longest natural-lake-ender extension on this seed.</summary>
public float MaxLakeEnderLenPx; public int MaxLakeEnderRank;
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.
string mode = EnvStr("ISLA_ROUTING_MODE", "faithful").Trim().ToLowerInvariant();
bool polished = mode == "polished"; // rivers/03c
bool refined = mode == "refined" || polished;
float rimCapM = float.TryParse(EnvStr("ISLA_RIM_CAP_M", "30"), out float rc) ? rc : 30f;
// ⭐ rivers/03c FIX A — sea-aggressive by default, per the developer's lean.
float lakeRatio = float.TryParse(EnvStr("ISLA_LAKE_PREFER_RATIO", "0.4"), out float lr) ? lr : 0.4f;
var opt = polished
? new RiverRouting.Options
{
RimCapM = rimCapM, LakeTargetForRouters = true, Confluence = true,
LakePreferRatio = lakeRatio, OwnBasinLakeEnder = true,
}
: 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", polished ? "c" : refined ? "b" : "");
string descr = EnvStr("ISLA_BATCH", polished ? "routing_polish" : 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(polished
? " ROUTING POLISH (rivers/03c) — lake-termination as a PREFERENCE + own-basin lake-enders, COURSES ONLY"
: 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.");
}
if (polished)
{
GD.Print("⭐⭐ rivers/03c — TWO SURGICAL REFINEMENTS OF FIX 3 (rivers/03b overshot: 12 rivers moved to");
GD.Print(" lake-fed and the sea mouths roughly halved, 5/5/6/5 → 4/2/3/3):");
GD.Print($" FIX A LAKE-TERMINATION IS NOW A PREFERENCE, ratio {lakeRatio:F2} — a router goes to the SEA unless");
GD.Print($" cost_lake < {lakeRatio:F2} x cost_ocean, i.e. unless the lake is MATERIALLY cheaper. Lower → more sea rivers.");
GD.Print( " Both costs are recorded for EVERY router so the knob is readable off the table without a re-run.");
GD.Print( " FIX B A NATURAL LAKE-ENDER ENDS AT ITS OWN BASIN'S WATER, at any size — the 20,000 px significance");
GD.Print( " threshold no longer exiles it from home. (It still applies to ROUTERS choosing a DISTANT lake.)");
GD.Print($" ⚠ EXPECT THE RIM CAP TO RE-ARM: fix A sends high-rim routers at the sea again, so {rimCapM:F0} m starts refusing.");
}
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, plan.BasinId);
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{(float.IsNaN(rr.CostRatio) ? "" : $", ratio {rr.CostRatio:F3}")})");
if (!float.IsNaN(rr.CostRatio) || rr.Class == RiverRouting.RiverClass.LakeFed || rr.Class == RiverRouting.RiverClass.RoutedGiant || rr.Class == RiverRouting.RiverClass.WalledOff)
if (!rr.Candidate.IsSea && rr.Candidate.AnalysisKind != "lake-ender")
r.Ratios.Add((rr.Candidate.Rank, rr.Candidate.DrainagePx, rr.Class == RiverRouting.RiverClass.LakeFed, rr.CostOcean, rr.CostLake, rr.CostRatio));
// ⭐ The coast-hugger check — how far a NATURAL lake-ender had to travel to find water.
if (rr.Class == RiverRouting.RiverClass.LakeEnder && rr.Lowland != null && rr.Lowland.Reached
&& rr.Lowland.LenPx > r.MaxLakeEnderLenPx)
{ r.MaxLakeEnderLenPx = rr.Lowland.LenPx; r.MaxLakeEnderRank = rr.Candidate.Rank; }
}
// ⚠ 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-fed: {string.Join(", ", r.LakeMoved)}");
GD.Print($" ⭐ coast-hugger check: longest NATURAL lake-ender extension = {r.MaxLakeEnderLenPx:F0} px" +
(r.MaxLakeEnderRank > 0 ? $" (#{r.MaxLakeEnderRank})" : "") +
(r.MaxLakeEnderLenPx > 2000f ? " ⚠⚠ STILL WANDERING" : " ✅"));
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 (polished) WritePolishIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, rimCapM, lakeRatio, dpDefaults, skipRaw);
else 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,cost_ocean,cost_lake,cost_ratio,own_basin_target,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") : "")}," +
$"{(float.IsNaN(rr.CostOcean) ? "" : rr.CostOcean.ToString("F0"))},{(float.IsNaN(rr.CostLake) ? "" : rr.CostLake.ToString("F0"))}," +
$"{(float.IsNaN(rr.CostRatio) ? "" : rr.CostRatio.ToString("F4"))},{(rr.OwnBasinTargeted ? "yes" : "")}," +
$"{(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 MIX ON THE PURE TOP {promoteN}",
$"{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());
}
/// <summary>
/// ⭐ THE POLISH INDEX (rivers/03c) — the before/after/after, and the ratio table that IS the knob.
/// </summary>
private static void WritePolishIndex(string batchRoot, int mapSize, int[] seeds,
List<SeedResult> rows, int promoteN, int lakeMinPx, float rimCapM, float lakeRatio,
DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = seeds.Length > 0 ? seeds[0] : 0;
sb.AppendLine($"# Batch 03c — routing polish: lake-termination as a PREFERENCE, and lake-enders come home");
sb.AppendLine();
sb.AppendLine("**⛔ TASTE GATE. Nothing locked, nothing graduated.** The ratio and the cap are both knobs.");
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. Fix B *terminates a course");
sb.AppendLine("at* existing in-basin water; it creates nothing.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick — a before / after / after");
sb.AppendLine();
sb.AppendLine($"- **`999999937/refined_mix.png`** beside **`../03b_routing_refinement/999999937/refined_mix.png`**");
sb.AppendLine(" — the coast-hugger seed. `#3` marched ~5,800 px along the shoreline in 03b; it should now stop at home.");
sb.AppendLine($"- **`{primary}/refined_mix.png`** beside **`../03_lowland_routing/{primary}/routed_mix.png`**");
sb.AppendLine(" — the full arc, from the faithful port to here.");
sb.AppendLine();
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
sb.AppendLine("> **Does the island read good now — more sea rivers, no coast-hugger, no uphill river, a natural");
sb.AppendLine("> dendritic tree — and is the mouth count and coastal spread healthy?**");
sb.AppendLine(">");
sb.AppendLine($"> The tuning lever is the ratio table below. To add more sea rivers, **lower `ISLA_LAKE_PREFER_RATIO`**");
sb.AppendLine($"> from {lakeRatio:F2} — the table shows exactly which rivers flip at which value. If too many now skirt");
sb.AppendLine("> lakes to reach the sea, raise it. One value, one re-run.");
sb.AppendLine();
sb.AppendLine("## The two fixes");
sb.AppendLine();
sb.AppendLine("| | rivers/03b did | rivers/03c does |");
sb.AppendLine("|---|---|---|");
sb.AppendLine($"| **A** | router goes to the **nearest** of {{ocean significant lake}} — a lake merely a bit closer captures it | router goes to the **sea** unless `cost_lake < {lakeRatio:F2} × cost_ocean`, i.e. unless the lake is **materially** cheaper |");
sb.AppendLine($"| **B** | a natural lake-ender hunts the nearest **significant** (≥ {lakeMinPx:N0} px) body — exiling it from a sub-threshold home lake | it terminates at **its own basin's** water, **any size** |");
sb.AppendLine();
sb.AppendLine($"⚠ The {lakeMinPx:N0} px threshold **still applies to routers** choosing a *distant* lake — a dry basin still");
sb.AppendLine("cannot connect itself to a three-cell puddle. It simply no longer exiles a lake-ender from home.");
sb.AppendLine();
sb.AppendLine("**Unchanged from rivers/03b:** the confluence post-pass, the rim cap, the `basinHasLake` sort, and the");
sb.AppendLine("faithful default (which still reproduces rivers/03 bit-for-bit).");
sb.AppendLine();
sb.AppendLine("## ⭐ The MIX");
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();
bool anyShared = false;
foreach (var r in rows) if (r.SharedMouths.Count > 0) anyShared = true;
sb.AppendLine(anyShared
? "> ⚠⚠ Some mouths are still shared — see the per-seed lines in the log."
: "> ✅ **No two rivers share a mouth on any seed** — the confluence pass still holds.");
sb.AppendLine();
sb.AppendLine("## ⭐⭐ The ratio table — THIS IS THE KNOB");
sb.AppendLine();
sb.AppendLine("Every dry-basin router, with the cost of reaching a significant lake against the cost of reaching the");
sb.AppendLine($"sea. **A river is lake-fed iff its ratio is below `{lakeRatio:F2}`.** Read down the ratio column to see");
sb.AppendLine("exactly which rivers a different setting would flip — no re-run needed.");
sb.AppendLine();
sb.AppendLine("| Seed | river | drainage px | cost to sea | cost to lake | ⭐ ratio | verdict |");
sb.AppendLine("|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var sorted = new List<(int rank, long px, bool lakeFed, float costOcean, float costLake, float ratio)>(r.Ratios);
sorted.Sort((a, b) => (float.IsNaN(a.ratio) ? float.MaxValue : a.ratio).CompareTo(float.IsNaN(b.ratio) ? float.MaxValue : b.ratio));
foreach (var t in sorted)
sb.AppendLine($"| `{r.Seed}` | #{t.rank} | {t.px:N0} | {(float.IsNaN(t.costOcean) ? "" : t.costOcean.ToString("N0"))} | " +
$"{(float.IsNaN(t.costLake) ? " (no lake)" : t.costLake.ToString("N0"))} | " +
$"{(float.IsNaN(t.ratio) ? "" : $"**{t.ratio:F3}**")} | {(t.lakeFed ? "lake-fed" : " sea")} |");
}
sb.AppendLine();
sb.AppendLine("## What the cap now catches, and the min-rim re-check");
sb.AppendLine();
sb.AppendLine($"Fix A sends high-rim routers at the sea again, so the **{rimCapM:F0} m rim cap re-arms** — in rivers/03b it");
sb.AppendLine("caught nothing, because fix 3 had already hidden those rivers in lakes.");
sb.AppendLine();
sb.AppendLine("| Seed | ⚠ walled off by the cap | lake-fed (with ratio) | confluences |");
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 — check each walled-off river on the plate");
sb.AppendLine("> The cap tests the **least-cost route's** rim, not a theoretical minimum-rim path. LOWGROUND penalises");
sb.AppendLine("> uphill heavily so the chosen route is almost always the low-rim one — **but if a walled-off basin");
sb.AppendLine("> visibly should have had a low way out, that is the signal a bottleneck (min-rim) search is wanted.**");
sb.AppendLine("> Not built here; flag it if you see it.");
sb.AppendLine();
sb.AppendLine("## ⭐ The coast-hugger check (fix B)");
sb.AppendLine();
sb.AppendLine("The longest **natural lake-ender** extension per seed. In rivers/03b `999999937 #3` ran **5,841 px**.");
sb.AppendLine();
sb.AppendLine("| Seed | longest natural lake-ender extension | verdict |");
sb.AppendLine("|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | {r.MaxLakeEnderLenPx:F0} px{(r.MaxLakeEnderRank > 0 ? $" (#{r.MaxLakeEnderRank})" : "")} | " +
$"{(r.MaxLakeEnderLenPx > 2000f ? " still wandering" : " home")} |");
sb.AppendLine();
sb.AppendLine("> ### ⚠ One thing this task deliberately does NOT fix");
sb.AppendLine("> A **routed-to-sea** river still tracing a bit of coastline near its mouth is the **deferred**");
sb.AppendLine("> LOWGROUND-on-flat-lowland arc — the cheapest corridor across a flat plain runs near the shore. That");
sb.AppendLine("> is judged **after the bed carve gives rivers width and depth**, not corrected here. It is a different");
sb.AppendLine("> thing from the lake-ender wander this task kills outright.");
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 | ratio | rim climb m | cap | joins |");
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 ? $"own lake ({(int)lo.Target.x},{(int)lo.Target.y}), {lo.LenPx:F0} px" : $"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} | " +
$"{(float.IsNaN(rr.CostRatio) ? "" : rr.CostRatio.ToString("F3"))} | " +
$"{(pr != null && pr.Reached ? pr.RimClimbM.ToString("F1") : "")} | " +
$"{(rr.RefusedByCap ? "**REFUSED**" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under" : "")} | " +
$"{(rr.Joined ? $"#{rr.ConfluenceParentRank}" : "")} |");
}
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($"Pure top {promoteN}, no quota. `ISLA_LAKE_PREFER_RATIO={lakeRatio:F2}`, `ISLA_RIM_CAP_M={rimCapM:F0}`, significance {lakeMinPx:N0} px.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused); `EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} (they define");
sb.AppendLine($"the routing surface); `MinOutletSeparationPx` {def.MinOutletSeparationPx}; `StemMinAccPx` {def.StemMinAccPx}. Termini classified by `OceanMask`, the");
sb.AppendLine("significant-lake mask, and per-basin classify water only — no bare `h < sea`. `ProvisionalRoute` never drawn.");
sb.AppendLine();
sb.AppendLine("**⚠ No spatial term anywhere** — the southern-coast question stays a placement-era 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, incl. **`cost_ocean` / `cost_lake` / `cost_ratio`** (fix A's lever) and `own_basin_target` (fix B) |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. |");
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/03c_routing_polish.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;
}
}
}