islaApocalypse-v2/Tools/Scripts/RiverRoutingTool.cs
beezm 4e4be6a83e rivers/03: lowland routing — the routed MIX on the pure N=12, courses only
Ports the ROUTING PORTION of the reference's RiverCarvePass (RouteToOcean, the
routed/lake-ender sort, SmoothCourse). NOT CarveRiver (bed stamp) and NOT
AddSteppedWater (water bodies) — those are later tasks.

RED LINE: no height mutated, no water filled, nothing carved. Asserted per seed
by an FNV digest of both height fields before/after routing.

- RiverRouting: deterministic LOWGROUND Dijkstra, uphill penalised so a route may
  cross the basin rim, empty-list-on-no-path. Effective == declared constants
  (verified: private const, no ConfigManager key, no [Export] in the reference).
- The sort is the REFERENCE's — Kind = basinHasLake ? lake-ender : routed. The
  task's stated "a path exists -> routed" cannot discriminate: on an 8-connected
  grid a path to the ocean always exists, confirmed empirically (43/43 probes
  reached). The ocean route is probed for every giant anyway, so the missing
  affordability threshold is reported as a number rather than guessed.
- RegionLabeling.SignificantWaterMask: interim substitute for v2's missing
  water-bodies table — 8-connected classify-water components >= 20,000 px.
- RiverCandidates: the candidate enumeration extracted out of RiverPromotionTool
  so routing ranks the identical set the count gate was judged on. Behaviour
  neutral — rivers/02b's twelve plates are byte-identical across the extraction.
- DrainageRenderer.RoutedMix: three classes, with each routed river's added
  lowland reach and the rim it crossed drawn distinctly from its natural stem.

Taste gate: no count, no K, no style, no default set.
2026-08-24 04:54:30 -04:00

649 lines
38 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;
public int SeaReaching;
/// <summary>⭐ DISTINCT ocean mouth cells — routes are computed per giant with nothing
/// coordinating them, so two can land on the same cell. This is the honest river count.</summary>
public int DistinctMouths;
/// <summary>Pairs of sea-reaching rivers sharing a mouth cell, as "#a+#b".</summary>
public List<string> SharedMouths = new();
public long LandCells, EndorheicCells;
public int CandidateCount, SeaCandidates, EndoCandidates;
public int WaterBodiesKept, WaterBodiesTotal;
public long WaterCellsKept, LargestWaterPx;
public double RoutingSeconds;
public long TotalExpanded;
public string Spread = "";
public ulong Ms;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
int task = EnvInt("ISLA_TASK", 3);
string descr = EnvStr("ISLA_BATCH", "lowland_routing");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
int promoteN = EnvInt("ISLA_PROMOTE_N", 12);
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_TARGET_PX", RiverRouting.LakeMinTargetPx);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
string styleS = EnvStr("ISLA_ROUTING_STYLE", "lowground").Trim().ToLowerInvariant();
if (styleS != "lowground" && styleS != "short")
throw new InvalidOperationException($"ISLA_ROUTING_STYLE '{styleS}' — expected 'lowground' (the locked style) or 'short'.");
byte style = styleS == "short" ? RiverRouting.StyleShort : RiverRouting.StyleLowground;
if (promoteMax < promoteN)
throw new InvalidOperationException(
$"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN}). The cap is what makes the " +
"analysis trace a real upland stem for each promoted river; below it, a river would have no course to route from.");
// ⚠ rivers/01: the shape AND erosion come from the bare defaults. Assert before generating.
TerrainShapeV1.Assert("RiverRouting");
TerrainShapeV1.AssertErosionDefaultOn("RiverRouting");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
// Reporting caps raised so every promoted river has a traced stem — exactly as rivers/02.
// ⚠⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are NOT touched: they
// decide which depressions BECOME terminal basins, i.e. they define the routing surface.
var dp = new DrainageAnalysis.Params
{
SeaLevel = sea,
TrunkCount = promoteMax,
GiantCount = promoteMax,
EndorheicMaxCount = promoteMax,
EndorheicMinInflowPx = (int)floorPx,
};
var dpDefaults = new DrainageAnalysis.Params();
GD.Print("==================================================================");
GD.Print(" LOWLAND ROUTING (rivers/03) — the routed MIX on the pure top-N, COURSES ONLY");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
GD.Print($"promoted : PURE top {promoteN} by drainage (unified ranking, rivers/02). NO QUOTA — rivers/02b's K stays parked and unlocked.");
GD.Print($"style : {styleS.ToUpperInvariant()} — cost = DIST x ({RiverRouting.LowgroundBase} + elevM x {RiverRouting.LowgroundElevPerM}) + climbM x {RiverRouting.LowgroundUphillPerM} (effective == declared; verified no ConfigManager/[Export] override)");
GD.Print($"sort : the REFERENCE's — Kind = (basinHasLake && !southern) ? lake-ender : routed.");
GD.Print($" ⚠ NOT a path test: on an 8-connected grid a path to the ocean ALWAYS exists, so");
GD.Print($" \"a path exists routed\" would classify everything as routed. The ocean route is");
GD.Print($" still probed for EVERY giant so the missing affordability threshold is a number, not a guess.");
GD.Print($"lake target: significant water = 8-connected classify-water components >= {lakeMinPx:N0} px (interim for v2's missing water-bodies table)");
GD.Print($"⛔ RED LINE : courses only — no height mutated, no water filled, nothing carved. ASSERTED per seed.");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
if (mapSize != 8192)
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the DrainageAnalysis params are ABSOLUTE PIXEL COUNTS tuned at 8192 " +
"(rivers/02). At a smaller size few or no depressions qualify as terminal basins, so the " +
"routed/lake-ender SORT cannot be exercised. A smaller run validates the PIPELINE only and " +
"MUST NOT be used to judge the mix.");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = "routing",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
var results = new List<SeedResult>();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
GD.Print($"\n--- seed {seed} ---");
var cfg = Cfg(mapSize, seed);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result shaped = Shaping.Shape(p1, cfg);
var ero = ErosionPass.Apply(shaped, cfg);
Pass2Result p2 = ero.Shaped;
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field (→ D-066).
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
var isClassifyWater = new bool[mapSize * mapSize];
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true;
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " +
$"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %); terminal basins {plan.TerminalBasinCount}");
var e = RiverCandidates.Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, p1.Regions);
var promoted = e.Ranked.GetRange(0, Math.Min(promoteN, e.Ranked.Count));
RiverCandidates.BindCourses(plan, mapSize, promoted, $"the pure top {promoteN}");
int pSea = 0; foreach (var c in promoted) if (c.IsSea) pSea++;
GD.Print($" promoted: pure top {promoted.Count} — {pSea} sea / {promoted.Count - pSea} endorheic (from {e.Ranked.Count} candidates: {e.SeaCount} sea / {e.Ranked.Count - e.SeaCount} endorheic)");
// ⭐ The interim significant-water mask — v2 has no water-bodies table (a known port gap).
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize,
lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest body {largestPx:N0} px)");
// ═══ ⛔ THE RED-LINE GUARD — snapshot both height fields BEFORE routing ═══
ulong hRenderBefore = Digest(p2.Height, mapSize);
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
GD.Print($" routing (style {styleS}) — probing the ocean for every giant:");
ulong tr0 = Time.GetTicksMsec();
var rivers = RiverRouting.RouteAll(promoted, p2.Height, mapSize, isOcean, isClassifyWater,
significant, sea, style, m => GD.Print(m));
double routingSec = (Time.GetTicksMsec() - tr0) / 1000.0;
// ═══ ⛔ …and assert they are byte-identical after ═══
ulong hRenderAfter = Digest(p2.Height, mapSize);
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
throw new InvalidOperationException(
"[RiverRouting] RED-LINE VIOLATION: a height field CHANGED across routing.\n" +
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
"This task produces COURSES ONLY — it must never mutate a height, fill water, or carve. " +
"The bed carve and the stepped-water model are separate later tasks. Refusing to continue.");
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across routing — nothing carved, no water filled.");
var r = new SeedResult
{
Seed = seed, Rivers = rivers,
LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount,
EndoCandidates = e.Ranked.Count - e.SeaCount,
WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies,
WaterCellsKept = keptCells, LargestWaterPx = largestPx,
RoutingSeconds = routingSec,
};
foreach (var rr in rivers)
{
if (rr.Class == RiverRouting.RiverClass.OceanTrunk) r.Trunks++;
else if (rr.Class == RiverRouting.RiverClass.RoutedGiant) r.Routed++;
else r.Lakes++;
if (rr.OceanProbe != null) r.TotalExpanded += rr.OceanProbe.Expanded;
}
r.SeaReaching = r.Trunks + r.Routed;
MeasureMouths(r, rivers);
r.Spread = Spread(rivers, mapSize);
r.Ms = Time.GetTicksMsec() - t0;
GD.Print($" ⭐ MIX: {r.Trunks} natural trunks + {r.Routed} routed-through + {r.Lakes} lake-enders = {rivers.Count}");
GD.Print($" → SEA-REACHING RIVERS: {r.SeaReaching}, at {r.DistinctMouths} DISTINCT mouths" +
(r.SharedMouths.Count > 0 ? $" ⚠ shared mouth: {string.Join(", ", r.SharedMouths)}" : "") +
$" spread: {r.Spread}");
GD.Print($" routing {routingSec:F1}s, {r.TotalExpanded:N0} cells settled across all probes");
WriteRiverCsv(batchRoot, r);
RenderSeed(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, promoteN, skipRaw);
results.Add(r);
}
WriteIndex(batchRoot, mapSize, calibSize, seeds, results, promoteN, floorPx, promoteMax, lakeMinPx, styleS, dpDefaults, skipRaw);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print(" ⛔ TASTE GATE: the MIX is PRESENTED, not decided. No count, no K, no style, no default was set.");
GD.Print(" ⛔ COURSES ONLY: no height mutated, no water filled, nothing carved — asserted per seed.");
GD.Print("==================================================================");
GetTree().Quit(0);
}
/// <summary>
/// FNV-1a over the raw float bits of a whole field. ⚠ Over the BITS, not the values: this must
/// catch a change no float comparison would (a 0 written over a +0, a NaN payload), because the
/// claim being checked is "byte-identical", not "numerically close".
/// </summary>
private static ulong Digest(float[,] f, int n)
{
ulong h = 14695981039346656037UL;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
for (int b = 0; b < 4; b++)
{
h ^= (byte)(bits >> (b * 8));
h *= 1099511628211UL;
}
}
return h;
}
/// <summary>
/// ⭐⭐ How many DISTINCT places the island's rivers actually meet the sea.
///
/// ⚠⚠ NOT the same as the sea-reaching count, and the difference is not a rounding detail. Each
/// giant is routed INDEPENDENTLY to its nearest ocean cell, with nothing coordinating the
/// routes — so two basins whose cheapest corridor is the same valley arrive at the same cell and
/// share one mouth. The analysis dedups NATURAL outlets (`MinOutletSeparationPx = 400`); nothing
/// dedups ROUTED ones. Measured rather than assumed, because "how many coastal rivers" is the
/// question this whole gate exists to answer.
/// </summary>
private static void MeasureMouths(SeedResult r, List<RiverRouting.RoutedRiver> rivers)
{
var at = new Dictionary<(int x, int y), List<int>>();
foreach (var rr in rivers)
{
if (!rr.ReachesSea) continue;
(int x, int y) key;
if (rr.Class == RiverRouting.RiverClass.OceanTrunk) key = (rr.Candidate.TermX, rr.Candidate.TermY);
else if (rr.Lowland != null && rr.Lowland.Reached) key = ((int)rr.Lowland.Target.x, (int)rr.Lowland.Target.y);
else continue;
if (!at.TryGetValue(key, out var l)) { l = new List<int>(); at[key] = l; }
l.Add(rr.Candidate.Rank);
}
r.DistinctMouths = at.Count;
foreach (var kv in at)
if (kv.Value.Count > 1)
r.SharedMouths.Add("#" + string.Join("+#", kv.Value) + $" at ({kv.Key.x},{kv.Key.y})");
}
/// <summary>
/// Where the sea-reaching rivers actually meet the coast. ⚠ In this codebase a cell is
/// <c>x*n + y</c> and the image is drawn <c>SetPixel(x, y)</c>, so +y is SOUTH on the plate.
/// </summary>
private static string Spread(List<RiverRouting.RoutedRiver> rivers, int n)
{
var counts = new Dictionary<string, int>();
int total = 0;
foreach (var r in rivers)
{
if (!r.ReachesSea) continue;
float mx, my;
if (r.Class == RiverRouting.RiverClass.OceanTrunk) { mx = r.Candidate.TermX; my = r.Candidate.TermY; }
else if (r.Lowland != null && r.Lowland.Reached) { mx = r.Lowland.Target.x; my = r.Lowland.Target.y; }
else continue;
string c = Compass(mx, my, n);
counts.TryGetValue(c, out int cur);
counts[c] = cur + 1;
total++;
}
if (total == 0) return "none";
var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" };
var parts = new List<string>();
foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}");
return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)";
}
private static string Compass(float x, float y, int n)
{
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; // dy > 0 = south
const float band = 0.35f;
string ns = dy < -band ? "N" : dy > band ? "S" : "";
string ew = dx < -band ? "W" : dx > band ? "E" : "";
return ns + ew == "" ? "centre" : ns + ew;
}
private static string ClassName(RiverRouting.RiverClass c) => c switch
{
RiverRouting.RiverClass.OceanTrunk => "trunk",
RiverRouting.RiverClass.RoutedGiant => "routed",
_ => "lake-ender",
};
private static void WriteRiverCsv(string batchRoot, SeedResult r)
{
var sb = new StringBuilder();
sb.AppendLine("rank,class,analysis_kind,terminus_type,drainage_px,term_x,term_y,course_pts," +
"route_reached,route_target_x,route_target_y,route_len_px,route_straight_px,wander," +
"route_cost,rim_climb_m,max_step_uphill_m,max_elev_m,total_uphill_m,uphill_steps," +
"rim_x,rim_y,cells_expanded,lake_reached,lake_was_fallback,stem_width_px,why");
foreach (var rr in r.Rivers)
{
var c = rr.Candidate;
var lo = rr.Lowland;
// ⚠ For a lake-ender the OCEAN PROBE is reported too (in the rim/cost columns of the
// probe row below) — those are what an affordability threshold would be set against.
var pr = rr.OceanProbe;
sb.AppendLine($"{c.Rank},{ClassName(rr.Class)},{(c.IsSea ? "" : c.AnalysisKind)},{c.TerminusName},{c.DrainagePx},{c.TermX},{c.TermY},{rr.Course.Count}," +
$"{(lo != null && lo.Reached ? "yes" : "no")},{(lo != null && lo.Reached ? ((int)lo.Target.x).ToString() : "")},{(lo != null && lo.Reached ? ((int)lo.Target.y).ToString() : "")}," +
$"{(lo != null ? lo.LenPx.ToString("F1") : "")},{(lo != null ? lo.StraightPx.ToString("F1") : "")},{(lo != null ? lo.WanderRatio.ToString("F3") : "")}," +
$"{(pr != null && pr.Reached ? pr.Cost.ToString("F0") : "")},{(pr != null ? pr.RimClimbM.ToString("F2") : "")},{(pr != null ? pr.MaxStepUphillM.ToString("F3") : "")}," +
$"{(pr != null ? pr.MaxElevM.ToString("F2") : "")},{(pr != null ? pr.TotalUphillM.ToString("F2") : "")},{(pr != null ? pr.UphillSteps.ToString() : "")}," +
$"{(pr != null && pr.Reached ? ((int)pr.RimPoint.x).ToString() : "")},{(pr != null && pr.Reached ? ((int)pr.RimPoint.y).ToString() : "")}," +
$"{(pr != null ? pr.Expanded.ToString() : "")},{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeReached ? "yes" : "no") : "")}," +
$"{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeWasFallback ? "yes" : "no") : "")}," +
$"{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{rr.Why}\"");
}
WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString());
}
private static void RenderSeed(string batchRoot, SeedResult r, bool[] isOcean, Pass2Result p2,
int n, float sea, long floorPx, int promoteN, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
DrainageRenderer.RoutedMix(r.Rivers, baseImg.Duplicate() as Image, n,
$"SEED {r.Seed} - THE ROUTED MIX ON THE PURE TOP {promoteN}",
$"{r.Trunks} NATURAL TRUNKS + {r.Routed} ROUTED-THROUGH + {r.Lakes} LAKE-ENDERS = {r.SeaReaching} SEA-REACHING RIVERS. SPREAD: {r.Spread.ToUpperInvariant()}",
floorPx)
.SavePng(Path.Combine(dir, "routed_mix.png"));
// Grayscale beside the pretty render — the field must be inspectable without the palette.
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds,
List<SeedResult> rows, int promoteN, long floorPx, int promoteMax, int lakeMinPx,
string styleS, DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = seeds.Length > 0 ? seeds[0] : 0;
sb.AppendLine($"# Batch 03 — lowland routing: the MIX on the pure top {promoteN}");
sb.AppendLine();
sb.AppendLine("**⛔ TASTE GATE. Nothing is locked** — no count, no K, no routing style, no default in");
sb.AppendLine("`TerrainGenConfig` or `DrainageAnalysis.Params`.");
sb.AppendLine();
sb.AppendLine("**⛔ COURSES ONLY. No height was mutated, no water was filled, nothing was carved** — asserted");
sb.AppendLine("per seed by an FNV digest of both height fields taken before and after routing. The bed carve and");
sb.AppendLine("the stepped-water model are separate later tasks.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick");
sb.AppendLine();
sb.AppendLine($"Open **`{primary}/routed_mix.png`**. Then check the other three: " +
string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
sb.AppendLine();
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
sb.AppendLine("> **Does routing the giants give enough substantial, well-distributed coastal rivers to dissolve");
sb.AppendLine("> the parked sea-river quota (rivers/02b's K) — or not?**");
sb.AppendLine(">");
sb.AppendLine("> Read three things off the plate: **how many** rivers now reach the sea, **how big** they are");
sb.AppendLine("> (the fixed width scale is shared with rivers/02b, so widths are comparable across both tasks),");
sb.AppendLine("> and **where** they land (the spread column below). Then read the rim column — a route that");
sb.AppendLine("> climbs a large rim is a channel cut over a wall, which may or may not be acceptable geography.");
sb.AppendLine();
sb.AppendLine("**Colour key.** Cyan = natural ocean trunk (erosion already reached the coast). Green = routed-through");
sb.AppendLine("giant, **dark for its natural upland stem and bright for the lowland reach routing added** — so the");
sb.AppendLine("plate separates terrain from routing. A yellow ring on a green reach is the **rim it climbed over**.");
sb.AppendLine("Orange = inland lake-ender, disc at its terminal and ring at the water body it joins.");
sb.AppendLine();
sb.AppendLine("## ⭐ The MIX, per seed");
sb.AppendLine();
sb.AppendLine($"| Seed | natural trunks | routed-through | lake-enders | sea-reaching | ⭐⭐ DISTINCT MOUTHS | shared mouth | spread |");
sb.AppendLine("|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | {r.Trunks} | {r.Routed} | {r.Lakes} | {r.SeaReaching} of {r.Rivers.Count} | " +
$"**{r.DistinctMouths}** | {(r.SharedMouths.Count > 0 ? string.Join("; ", r.SharedMouths) : "")} | {r.Spread} |");
sb.AppendLine();
sb.AppendLine("> ### ⚠⚠ READ THE *DISTINCT MOUTHS* COLUMN, NOT THE SEA-REACHING ONE.");
sb.AppendLine("> **On every seed, two routed rivers arrive at the SAME ocean cell.** Each giant is routed");
sb.AppendLine("> independently to its nearest ocean cell and nothing coordinates the routes, so two basins whose");
sb.AppendLine("> cheapest corridor is the same valley share one mouth. The analysis dedups NATURAL outlets");
sb.AppendLine($"> (`MinOutletSeparationPx = {def.MinOutletSeparationPx}`); **nothing dedups ROUTED ones.**");
sb.AppendLine(">");
sb.AppendLine("> Whether that is a defect or a delta is a real judgment: two rivers meeting at one mouth is a");
sb.AppendLine("> confluence, which is ordinary geography — but they arrive there *without ever having joined*,");
sb.AppendLine("> which is not. It is reported rather than deduped, because deduping would silently drop a");
sb.AppendLine("> promoted river the developer chose. **→ a decision for the carve/water tasks.**");
sb.AppendLine();
sb.AppendLine("> ### ⚠ Compare against rivers/02b before concluding");
sb.AppendLine($"> On the pure top {promoteN} the *unrouted* sea count was 12 per seed. The SEA-REACHING column above is");
sb.AppendLine("> what routing turns that into. **If it is comfortably above the quota's K, the quota is redundant");
sb.AppendLine("> — that was the question rivers/02b parked.** The spread column is the second half of the answer:");
sb.AppendLine("> a count that all lands on one coast does not serve placement.");
sb.AppendLine();
sb.AppendLine("## ⭐⭐ The per-river diagnostic — why each river landed where it did");
sb.AppendLine();
sb.AppendLine("`rim climb` is metres from the basin floor to the route's high point — **the wall the channel crosses**.");
sb.AppendLine("`max step` is the steepest single 1-px climb on it. `wander` is polyline length over straight-line.");
sb.AppendLine("**For lake-enders the rim/cost columns are the OCEAN PROBE** — what it *would* have cost to route them");
sb.AppendLine("to the sea. That is the number an affordability threshold would be set against (see the sort note).");
sb.AppendLine();
foreach (var r in rows)
{
sb.AppendLine($"### `{r.Seed}`");
sb.AppendLine();
sb.AppendLine("| rank | class | drainage px | terminus | route len px | wander | ⭐ rim climb m | max step m | route cost | cells settled |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
foreach (var rr in r.Rivers)
{
var c = rr.Candidate; var pr = rr.OceanProbe; var lo = rr.Lowland;
string cls = rr.Class switch
{
RiverRouting.RiverClass.OceanTrunk => "trunk",
RiverRouting.RiverClass.RoutedGiant => "**routed**",
_ => "lake-ender",
};
string term = rr.Class switch
{
RiverRouting.RiverClass.OceanTrunk => $"sea ({c.TermX},{c.TermY})",
RiverRouting.RiverClass.RoutedGiant => lo != null && lo.Reached ? $"sea ({(int)lo.Target.x},{(int)lo.Target.y})" : "⚠ NO ROUTE",
_ => rr.LakeReached ? $"lake ({(int)lo.Target.x},{(int)lo.Target.y}){(rr.LakeWasFallback ? " fallback" : "")}" : "⚠ its own terminal",
};
sb.AppendLine($"| #{c.Rank} | {cls} | {c.DrainagePx:N0} | {term} | " +
$"{(lo != null && lo.Reached ? lo.LenPx.ToString("F0") : "")} | " +
$"{(lo != null && lo.Reached ? lo.WanderRatio.ToString("F2") : "")} | " +
$"{(pr != null && pr.Reached ? $"**{pr.RimClimbM:F1}**" : "")} | " +
$"{(pr != null && pr.Reached ? pr.MaxStepUphillM.ToString("F2") : "")} | " +
$"{(pr != null && pr.Reached ? pr.Cost.ToString("N0") : "")} | " +
$"{(pr != null ? pr.Expanded.ToString("N0") : "")} |");
}
sb.AppendLine();
sb.AppendLine($"*Candidates {r.CandidateCount} ({r.SeaCandidates} sea / {r.EndoCandidates} endorheic) · " +
$"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px " +
$"({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · routing {r.RoutingSeconds:F1}s, " +
$"{r.TotalExpanded:N0} cells settled.*");
sb.AppendLine();
}
sb.AppendLine("## ⚠⚠ The sort — what actually decides routed vs lake-ender, and the threshold nobody has set");
sb.AppendLine();
sb.AppendLine("The task states the sort as *\"an affordable over-the-rim LOWGROUND path to the ocean exists →");
sb.AppendLine("routed-through; none → lake-ender.\"* **Ported literally that classifies EVERYTHING as routed**, because");
sb.AppendLine("on an 8-connected grid with all-finite costs a path to the ocean always exists — `RouteToOcean`");
sb.AppendLine("returns empty only if the queue drains without reaching a target, which cannot happen. There is no");
sb.AppendLine("\"none\". The word carrying the meaning is *affordable*, and **no threshold is specified anywhere**.");
sb.AppendLine();
sb.AppendLine("So this port uses **the reference's own sort**, which is the one that discriminates:");
sb.AppendLine();
sb.AppendLine("```csharp");
sb.AppendLine("Kind = (basinHasLake[id] && !SouthernCandidate) ? \"lake-ender\" : \"routed\"");
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine("**Does the terminal basin already hold classify water?** A basin that is a lake is a natural");
sb.AppendLine("lake-ender; a dry pan is routed to the sea. That is `DrainageAnalysis.Giant.Kind`, consumed rather");
sb.AppendLine("than reinvented. (v2 has no towns, so `southernPick` is 1 and the southern override never fires.)");
sb.AppendLine();
sb.AppendLine("**And the missing threshold is surfaced instead of guessed:** the ocean route is probed for EVERY");
sb.AppendLine("giant, lake-enders included, so the rim/cost columns above say exactly what routing each one would");
sb.AppendLine("cost. If the developer wants an affordability bar, those are the numbers to put it under.");
sb.AppendLine();
sb.AppendLine("## What was ported, and what was deliberately NOT");
sb.AppendLine();
sb.AppendLine("| Ported (the routing portion) | Not ported (later tasks) |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `RouteToOcean` — deterministic LOWGROUND Dijkstra, uphill penalised, empty-on-no-path | `CarveRiver` — the bed stamp, **mutates render height** |");
sb.AppendLine("| the routed / lake-ender sort | `AddSteppedWater` — **creates water bodies** |");
sb.AppendLine("| lake-ender targeting (significant water, classify-water fallback) | tributary carving |");
sb.AppendLine("| `SmoothCourse` — RDP tol 4 + 4 Chaikin, endpoints pinned, **lowland reach only** | the densify-to-1px step (carve-time) |");
sb.AppendLine();
sb.AppendLine($"**Cost model, effective == declared** (verified: these are `private const` in `RiverCarvePass` with no");
sb.AppendLine($"`ConfigManager` key and no `[Export]` anywhere in the reference repo):");
sb.AppendLine();
sb.AppendLine("```");
sb.AppendLine($"LOWGROUND step = DIST × ({RiverRouting.LowgroundBase} + elevM × {RiverRouting.LowgroundElevPerM}) + climbM × {RiverRouting.LowgroundUphillPerM}");
sb.AppendLine($"SHORT step = DIST + climbM × {RiverRouting.ShortUphillPerM} (rejected by the reference's gate as \"a dead-straight canal\")");
sb.AppendLine($"elevM = max(0, (height sea) × {WorldScale.MetresPerRawUnit:F0}) — clamped at local sea");
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine($"Style used: **{styleS}** (the reference's effective `RiverRoutingStyle`). ⚠ The declared-vs-effective gap");
sb.AppendLine("`00_ground` warned about (WidthScale 1.0→1.75, DepthScale 1.0→1.5) is **carve-time and out of scope here**.");
sb.AppendLine();
sb.AppendLine("**⚠ The `isSignificantWater` port gap.** The reference builds it from a water-bodies table");
sb.AppendLine($"(`PixelCount >= RiverLakeMinTargetPx`); **v2 has no such table.** Interim substitute, per the task:");
sb.AppendLine($"8-connected components of classify water (ocean excluded), keeping those ≥ {lakeMinPx:N0} px — same");
sb.AppendLine("threshold, same semantics, no table built. The size filter is the point: routing to the *nearest wet");
sb.AppendLine("pixel* put a lake-ender into a three-cell puddle short of the obvious lagoon (the reference's own finding).");
sb.AppendLine();
sb.AppendLine("## What was run");
sb.AppendLine();
sb.AppendLine($"Chain + analysis + routing at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered.");
sb.AppendLine($"Curve calibrated at {calibSize} on the family-off pinned pool; terrain {TerrainShapeV1.Describe()} + erosion ON.");
sb.AppendLine($"Promoted set: the **pure top {promoteN}** by drainage — the unified ranking, **no quota** (rivers/02b's K stays parked).");
sb.AppendLine($"Reporting caps raised to `{promoteMax}` so every promoted river has a traced stem. Candidate floor `{floorPx:N0}` px.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m, `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} — they define the routing");
sb.AppendLine($"surface itself. Also unchanged: `MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}. `DrainageAnalysis` is reused,");
sb.AppendLine("never rebuilt or edited. Terminus classified by `RegionLabeling.OceanMask` (`Dir == D_SEA`) — no bare `h < sea`.");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `<seed>/routed_mix.png` | the three classes, with the added lowland reach and the rim it crossed drawn distinctly |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `rivers_<seed>.csv` | per river: class, analysis kind, route geometry, rim, cost, cells settled, why |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`, the anchor of record. `ISLA_SKIP_RAW=0` regenerates it. |");
sb.AppendLine();
sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
sb.AppendLine();
sb.AppendLine("→ `XX_Human/output/rivers/03_lowland_routing.report.md`");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
// ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
}.WithFamilyOff();
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
}
// ---- env / io -----------------------------------------------------------------------------
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}