using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
///
/// ⭐⭐ FLOW-THROUGH ROUTING (rivers/05) — the model rework, the hydrology map, and the taste gate.
///
/// river → lake → over the spill → river → … → sea. The promoted set chains through the basin graph
/// (rivers/04) on the terrain's own overflow structure, cap-gated at every rim, kept if it reaches the
/// sea or a real lake, dropped if it dead-ends dry. Output: the courses, a per-cell flow-direction
/// field, and the hydrology map — a first-class reference artifact (→ D-056).
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Routing and data only. No render or classify height written, no bed carved, no water body created
/// or filled.** Both height fields are digested before the graph is built and after the last plate is
/// drawn; any change refuses the run (exit 2).
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/FlowThroughTool.tscn
///
/// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
/// ISLA_FLOW_CAP_M the rim cap on floor→spill climb (default 30) — the connected-vs-inland knob
/// ISLA_CAP_PREVIEW_M caps to re-run the walks at for the sensitivity table (default "15,30,60")
/// ISLA_LAKE_MIN_PX the lake significance floor (default 20000)
/// ISLA_PROMOTE_N / ISLA_PROMOTE_FLOOR_PX / ISLA_PROMOTE_MAX as rivers/03 (12 / 5000 / 24)
///
public partial class FlowThroughTool : 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()
{
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 FlowThroughRouting.Result Main;
public Dictionary ByCap = new();
public BasinGraph Graph;
public int DistinctMouths; public List SharedMouths = new(); public string Spread = "";
public ulong RenderDigest, ClassifyDigest;
public int CandidateCount, SeaCandidates;
public double WalkSeconds, FieldSeconds, RenderSeconds; public ulong Ms;
public float GMin, GMax;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
int task = EnvInt("ISLA_TASK", 5);
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", "");
string descr = EnvStr("ISLA_BATCH", "flow_through_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_PX", RiverRouting.LakeMinTargetPx);
float capM = EnvFloat("ISLA_FLOW_CAP_M", 30f);
float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f });
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
if (Array.IndexOf(caps, capM) < 0) { var l = new List(caps) { capM }; l.Sort(); caps = l.ToArray(); }
if (promoteMax < promoteN)
throw new InvalidOperationException($"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN}).");
TerrainShapeV1.Assert("FlowThrough");
TerrainShapeV1.AssertErosionDefaultOn("FlowThrough");
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
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(" FLOW-THROUGH ROUTING (rivers/05) — river → lake → over the spill → river → … → sea. THE HYDROLOGY MAP.");
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.");
GD.Print($"model : follow the D8 field on FullFilled from each terminal; at every basin entered, floor→spill climb (SpillClimbM) ≤ cap → overflow, > cap → walled.");
GD.Print($" reaches OceanMask → KEEP; walls at IsLake (≥ {lakeMinPx:N0} px, classify) → KEEP (lake-terminal); walls dry or puddle-only → DROP. Read through the confluence root.");
GD.Print($"cap : ISLA_FLOW_CAP_M = {capM:F0} m (floor→spill, clamped at sea as RouteTo) — sensitivity at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m");
GD.Print($"lakes : entered on the real terrain (Plan.Dir; rivers/03c fix B lowground fallback), left at the lake's lowest FullFilled cell over the spill. The in-lake span is water, not channel.");
GD.Print($"confluence: rivers/03b's, unchanged — biggest-first, true cell intersection.");
GD.Print($"field : per land cell, the FullFilled D8 heading; walled basins' cells re-pointed onto the real terrain so flow entering one ends there. Emitted as a map; serialization DEFERRED.");
GD.Print($"⛔ RED LINE : routing + data only — no height mutated, no water filled, no bed carved. ASSERTED per seed.");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
if (mapSize != 8192)
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the basin gates are absolute pixel counts tuned at 8192; a smaller map under-produces basins. PLUMBING only.");
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 = "flow",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
var results = new List();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
GD.Print($"\n--- seed {seed} ---");
var cfg = Cfg(mapSize, seed);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result shaped = Shaping.Shape(p1, cfg);
var ero = ErosionPass.Apply(shaped, cfg);
Pass2Result p2 = ero.Shaped;
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 {100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %, endorheic {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)");
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize, lakeMinPx,
out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
// ═══ ⛔ RED-LINE GUARD — digest both fields BEFORE the graph, the field, the walks and the plates ═══
ulong hRenderBefore = Digest(p2.Height, mapSize);
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
var graph = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx);
GD.Print($" basin graph: {graph.LandNodes.Count} land basins ({graph.LakeBasins} lake / {graph.DryBasins} dry), {graph.Seabed} seabed pits excluded; spill invariants {graph.SpillCrossCheckFailures}/{graph.SpillNotOnTerrain}/{graph.DirWalkDisagreements}");
ulong tf0 = Time.GetTicksMsec();
var field = FlowThroughRouting.BuildField(plan, mapSize, isOcean);
var prep = FlowThroughRouting.Prepare(plan, graph, p2.Height, mapSize, isOcean, isClassifyWater);
double fieldSec = (Time.GetTicksMsec() - tf0) / 1000.0;
var r = new SeedResult { Seed = seed, Graph = graph, CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount, FieldSeconds = fieldSec };
ulong tw0 = Time.GetTicksMsec();
foreach (float cap in caps)
{
bool main = cap == capM;
GD.Print($" walks at cap {cap:F0} m{(main ? " (THE CAP)" : " (sensitivity)")}:");
var res = FlowThroughRouting.Run(promoted, plan, graph, field, prep, p2.Height, mapSize, isOcean, isClassifyWater, sea, cap,
m => { if (main) GD.Print(m); }, confluence: true);
r.ByCap[cap] = res;
if (main) r.Main = res;
GD.Print($" → {res.Trunks} trunk + {res.FlowThrough} flow-through + {res.LakeTerminal} lake-terminal = {res.Trunks + res.FlowThrough + res.LakeTerminal} kept; " +
$"{res.DroppedDry + res.DroppedClosed} dropped ({res.DroppedDry} dry, {res.DroppedClosed} closed); {res.Joined} joined; walled basins {res.WalledIds.Count}");
}
r.WalkSeconds = (Time.GetTicksMsec() - tw0) / 1000.0;
var mainRes = r.Main;
foreach (var fr in mainRes.Rivers)
GD.Print($" #{fr.Candidate.Rank,-3} {fr.Candidate.DrainagePx,10:N0} px {FlowThroughRouting.ClassName(fr).ToUpperInvariant(),-13} " +
$"hops {fr.Chain.Count} lakes {fr.LakesPassed} max rim {fr.MaxHopClimbM,5:F1} m lowland {fr.LowlandLenPx,6:F0} px" +
(fr.Routed.Joined ? $" → into #{fr.Routed.ConfluenceParentRank}" : "") + $" {fr.Why}");
FlowThroughRouting.BuildCappedField(mainRes, plan, graph, field, mapSize, isOcean);
FlowThroughRouting.RankHeroLakes(mainRes, graph, prep);
MeasureMouths(r, mainRes);
r.Spread = Spread(mainRes, mapSize);
GD.Print($" ⭐ HYDROLOGY at cap {capM:F0} m: {mainRes.Trunks} trunk + {mainRes.FlowThrough} flow-through + {mainRes.LakeTerminal} lake-terminal kept, {mainRes.DroppedDry + mainRes.DroppedClosed} dropped, {mainRes.Joined} joined" +
$" — {r.DistinctMouths} distinct sea mouths{(r.SharedMouths.Count > 0 ? $" ⚠ shared: {string.Join(", ", r.SharedMouths)}" : "")}; spread {r.Spread}");
GD.Print($" confluence: {mainRes.RescuedByConfluence} would-be-dropped river(s) rescued by joining a kept river; {mainRes.DroppedByConfluence} kept-on-its-own river(s) dropped by joining a dropped one");
GD.Print($" field: {100.0 * mainRes.CellsToSea / Math.Max(1, mainRes.LandCells):F1} % of land drains to the sea, {100.0 * mainRes.CellsToWalledLake / Math.Max(1, mainRes.LandCells):F1} % to a walled lake, " +
$"{100.0 * mainRes.CellsToWalledDry / Math.Max(1, mainRes.LandCells):F1} % to a walled dry sink, {100.0 * mainRes.CellsStuck / Math.Max(1, mainRes.LandCells):F2} % stuck; {mainRes.WalledIds.Count} walled basins");
GD.Print($" edge cross-check vs BasinGraph: {mainRes.EdgeAgree} agree / {mainRes.EdgeDisagree} disagree; lowground fallbacks into lakes {mainRes.LowgroundFallbacks}");
if (mainRes.HeroLakes.Count > 0)
{
var h = mainRes.HeroLakes[0];
GD.Print($" ⭐ HERO-LAKE CANDIDATE (data, NOT filled): basin #{h.BasinId} — lake {h.LakeCells:N0} px, fill volume {h.FillVolumeMPx / 1e6:F2} M m·px, river R{h.RiverRank} {h.RiverLenPx:F0} px, {h.RiversThrough} river(s) through; score {h.Score:F3}");
}
else GD.Print(" hero-lake candidate: none — no sea-reaching river passes through a lake on this seed");
WriteRiverCsv(batchRoot, r, caps);
ulong tr0 = Time.GetTicksMsec();
RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, capM, skipRaw);
r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000.0;
// ═══ ⛔ …and asserted byte-identical AFTER everything ═══
ulong hRenderAfter = Digest(p2.Height, mapSize);
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
throw new InvalidOperationException(
"[FlowThrough] RED-LINE VIOLATION: a height field CHANGED across routing / rendering.\n" +
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
"This task routes and draws — it must never mutate a height, fill water, or carve. Refusing to continue.");
r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore;
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical — no height mutated, no water filled, no bed carved.");
r.Ms = Time.GetTicksMsec() - t0;
results.Add(r);
}
WriteIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, capM, caps, dpDefaults, skipRaw);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print(" ⛔ TASTE GATE: the hydrology is PRESENTED, not locked. The cap is a knob; nothing graduated.");
GD.Print(" ⛔ ROUTING + DATA ONLY: no height mutated, no water filled, no bed carved — asserted per seed. Flow field emitted as a map; serialization deferred.");
GD.Print("==================================================================");
GetTree().Quit(0);
}
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;
}
private static void MeasureMouths(SeedResult r, FlowThroughRouting.Result res)
{
var at = new Dictionary>();
foreach (var fr in res.Rivers)
{
if (fr.Dropped || fr.Routed.Joined || fr.Terminus != FlowThroughRouting.Terminus.Ocean || fr.MouthCell < 0) continue;
if (!at.TryGetValue(fr.MouthCell, out var l)) { l = new List(); at[fr.MouthCell] = l; }
l.Add(fr.Candidate.Rank);
}
r.DistinctMouths = at.Count;
foreach (var kv in at) if (kv.Value.Count > 1) r.SharedMouths.Add("#" + string.Join("+#", kv.Value));
}
private static string Spread(FlowThroughRouting.Result res, int n)
{
var counts = new Dictionary(); int total = 0;
foreach (var fr in res.Rivers)
{
if (fr.Dropped || fr.Routed.Joined || fr.Terminus != FlowThroughRouting.Terminus.Ocean || fr.MouthCell < 0) continue;
string c = Compass(fr.MouthCell / n, fr.MouthCell % n, n);
counts.TryGetValue(c, out int cur); counts[c] = cur + 1; total++;
}
if (total == 0) return "none";
var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" };
var parts = new List();
foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}");
return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)";
}
private static string Compass(float x, float y, int n)
{
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half;
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 ChainText(FlowThroughRouting.FlowRiver fr)
{
var parts = new List();
foreach (var h in fr.Chain)
parts.Add($"#{h.BasinId}{(h.IsLake ? "L" : "d")}:{h.ClimbM:F1}{(h.Walled ? "!" : "")}");
return string.Join(" > ", parts) + (fr.Terminus == FlowThroughRouting.Terminus.Ocean ? " > SEA" : fr.Terminus == FlowThroughRouting.Terminus.Closed ? " > closed" : "");
}
private static void WriteRiverCsv(string batchRoot, SeedResult r, float[] caps)
{
var res = r.Main; int n = r.Graph.MapSize;
var sb = new StringBuilder();
sb.Append("rank,class,own_terminus,root_terminus,drainage_px,is_sea_candidate,terminal_basin,hops,lakes_passed,chain,max_hop_climb_m,total_climb_m," +
"terminus_basin,mouth_x,mouth_y,joined,confluence_parent_rank,junction_x,junction_y,stem_len_px,lowland_len_px,total_len_px,lowground_fallbacks,edge_disagreements,width_px,why");
foreach (float cap in caps) sb.Append($",class_at_cap{cap:F0}");
sb.AppendLine();
foreach (var fr in res.Rivers)
{
var c = fr.Candidate; var rr = fr.Routed;
int fb = 0, dis = 0; foreach (var h in fr.Chain) { if (h.UsedLowgroundFallback) fb++; if (h.SpillCell >= 0 && !h.EdgeAgreesWithGraph) dis++; }
sb.Append($"{c.Rank},{FlowThroughRouting.ClassName(fr)},{FlowThroughRouting.TerminusName(fr.Terminus)},{FlowThroughRouting.TerminusName(fr.RootTerminus)},{c.DrainagePx},{(c.IsSea ? "yes" : "no")}," +
$"{(c.IsSea ? 0 : (fr.Chain.Count > 0 ? fr.Chain[0].BasinId : c.BasinId))},{fr.Chain.Count},{fr.LakesPassed},\"{ChainText(fr)}\",{fr.MaxHopClimbM:F2},{fr.TotalClimbM:F2}," +
$"{fr.TerminusBasinId},{(fr.MouthCell >= 0 ? (fr.MouthCell / n).ToString() : "")},{(fr.MouthCell >= 0 ? (fr.MouthCell % n).ToString() : "")}," +
$"{(rr.Joined ? "yes" : "no")},{(rr.Joined ? rr.ConfluenceParentRank.ToString() : "")},{(rr.Joined ? rr.JunctionCell.x.ToString() : "")},{(rr.Joined ? rr.JunctionCell.y.ToString() : "")}," +
$"{fr.StemLenPx:F1},{fr.LowlandLenPx:F1},{fr.TotalLenPx:F1},{fb},{dis},{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{fr.Why}\"");
foreach (float cap in caps)
{
var other = r.ByCap[cap];
FlowThroughRouting.FlowRiver o = null;
foreach (var x in other.Rivers) if (x.Candidate.Rank == c.Rank) { o = x; break; }
sb.Append($",{(o == null ? "" : FlowThroughRouting.ClassName(o))}");
}
sb.AppendLine();
}
WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString());
// The hero-lake ranking, as data.
var hb = new StringBuilder();
hb.AppendLine("rank,basin_id,lake_cells,fill_volume_m_px,river_rank,river_len_px,rivers_through,score");
for (int i = 0; i < res.HeroLakes.Count; i++)
{
var h = res.HeroLakes[i];
hb.AppendLine($"{i + 1},{h.BasinId},{h.LakeCells},{h.FillVolumeMPx:F0},{h.RiverRank},{h.RiverLenPx:F0},{h.RiversThrough},{h.Score:F4}");
}
WriteText(Path.Combine(batchRoot, $"hero_lakes_{r.Seed}.csv"), hb.ToString());
}
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean, bool[] isClassifyWater,
Pass2Result p2, int n, float sea, float capM, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
var res = r.Main;
int kept = res.Trunks + res.FlowThrough + res.LakeTerminal;
Image baseImg = HydrologyRenderer.Base(p2.Height, n, sea);
HydrologyRenderer.Hydrology(res, res.CappedDir, plan, r.Graph, baseImg, n, isOcean, isClassifyWater,
$"SEED {r.Seed} - HYDROLOGY: {kept} RIVERS OF THE PURE TOP {res.Rivers.Count}, CHAINING THROUGH LAKES AND LOW GROUND TO THE SEA. CAP {capM:F0} M.",
$"{res.Trunks} NATURAL TRUNK + {res.FlowThrough} FLOW-THROUGH TO THE SEA + {res.LakeTerminal} LAKE-TERMINAL; {res.DroppedDry + res.DroppedClosed} DROPPED; {res.Joined} JOINED. {r.DistinctMouths} DISTINCT SEA MOUTHS. SPREAD: {r.Spread.ToUpperInvariant()}",
$"THE FIELD: {100.0 * res.CellsToSea / Math.Max(1, res.LandCells):F0}% OF LAND DRAINS TO THE SEA, {100.0 * res.CellsToWalledLake / Math.Max(1, res.LandCells):F0}% TO A WALLED LAKE, {100.0 * res.CellsToWalledDry / Math.Max(1, res.LandCells):F0}% TO A WALLED DRY SINK ({res.WalledIds.Count} WALLED BASINS). TASTE GATE - NOTHING LOCKED")
.SavePng(Path.Combine(dir, $"hydrology_{r.Seed}.png"));
HydrologyRenderer.FlowDirection(res.CappedDir, res.CappedAcc, plan, r.Graph, res.WalledIds, n, isOcean, isClassifyWater,
$"SEED {r.Seed} - FLOW DIRECTION FIELD AT CAP {capM:F0} M: PER LAND CELL, THE D8 HEADING ON THE OVERFLOW SURFACE (FULLFILLED), CHAINED OVER SPILLS TOWARD THE SEA",
$"{100.0 * res.CellsToSea / Math.Max(1, res.LandCells):F1}% OF LAND DRAINS TO THE SEA; {100.0 * res.CellsToWalledLake / Math.Max(1, res.LandCells):F1}% ENDS IN A WALLED LAKE; {100.0 * res.CellsToWalledDry / Math.Max(1, res.LandCells):F1}% IN A WALLED DRY SINK; {100.0 * res.CellsStuck / Math.Max(1, res.LandCells):F2}% STUCK. {res.WalledIds.Count} WALLED BASINS (RIM > CAP).",
"DATA MAP - THE REFERENCE FOR PLACEMENT, FLOODING (C3) AND IRRIGATION (C4). HELD IN MEMORY; SERIALIZATION DEFERRED TO THE COLUMN WATER-DATA PHASE.")
.SavePng(Path.Combine(dir, $"flow_direction_{r.Seed}.png"));
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
r.GMin = gmin; r.GMax = gmax;
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[] seeds, List rows, int promoteN, int lakeMinPx,
float capM, float[] caps, DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = seeds.Length > 0 ? seeds[0] : 0;
sb.AppendLine($"# Batch 05 — flow-through routing: the hydrology map (cap {capM:F0} m)");
sb.AppendLine();
sb.AppendLine("**⛔ TASTE GATE. Nothing is locked** — the cap is a knob, the count falls out, nothing is graduated. This is the");
sb.AppendLine("routing finale: if the hydrology map reads right, routing is done and the next step is the bed carve.");
sb.AppendLine();
sb.AppendLine("**⛔ ROUTING AND DATA ONLY. No height mutated, no water filled or created, no bed carved** — asserted per seed by an");
sb.AppendLine("FNV digest of both height fields taken before the basin graph was built and after the last plate was drawn.");
sb.AppendLine("`DrainageAnalysis` and `BasinGraph` reused. The flow-direction field is emitted as a map and held in memory; its");
sb.AppendLine("serialization is deferred to the blueprint / column water-data phase.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick");
sb.AppendLine();
sb.AppendLine($"Open **`{primary}/hydrology_{primary}.png`**. Then 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 the island's water now read as one connected, natural, gorgeous system — rivers chaining through lakes");
sb.AppendLine("> and low ground to the sea, dead-ends dropped, the whole network legible?** Then the count, the spread, and");
sb.AppendLine($"> whether the cap (`ISLA_FLOW_CAP_M`, {capM:F0} m) wants moving — the sensitivity table below says how the counts move.");
sb.AppendLine(">");
sb.AppendLine("> **Reading the map.** Pale-blue rivers reach the sea (natural trunks and flow-through chains alike; square = mouth).");
sb.AppendLine("> Amber rivers are lake-terminal (disc = where the river enters its lake). A river's span across a lake is water,");
sb.AppendLine("> not a drawn channel. White dot = confluence. A faint red ghost is a river's upland stem that was considered and");
sb.AppendLine("> dropped — its chain walled at a dry sink. The streamline texture is the flow-direction field.");
sb.AppendLine();
sb.AppendLine("## ⭐⭐ The hydrology, per seed (at the cap)");
sb.AppendLine();
sb.AppendLine("| Seed | trunk | **flow-through → sea** | **lake-terminal** | **kept** | dropped (dry / closed) | joined | rescued by confluence | **distinct sea mouths** | spread | land → sea / walled lake / walled dry |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var m = r.Main;
sb.AppendLine($"| `{r.Seed}` | {m.Trunks} | **{m.FlowThrough}** | **{m.LakeTerminal}** | **{m.Trunks + m.FlowThrough + m.LakeTerminal}** of {m.Rivers.Count} | {m.DroppedDry} / {m.DroppedClosed} | {m.Joined} | {m.RescuedByConfluence} | **{r.DistinctMouths}**{(r.SharedMouths.Count > 0 ? $" ⚠ {string.Join(", ", r.SharedMouths)}" : "")} | {r.Spread} | " +
$"{100.0 * m.CellsToSea / Math.Max(1, m.LandCells):F0} % / {100.0 * m.CellsToWalledLake / Math.Max(1, m.LandCells):F0} % / {100.0 * m.CellsToWalledDry / Math.Max(1, m.LandCells):F0} % |");
}
sb.AppendLine();
sb.AppendLine("## ⭐ The cap — how the counts move (the connected-vs-inland knob, off one run)");
sb.AppendLine();
sb.Append("| Seed |");
foreach (float cap in caps) sb.Append($" @ {cap:F0} m: kept (sea + lake) / dropped / joined |");
sb.AppendLine();
sb.AppendLine("|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
foreach (var r in rows)
{
sb.Append($"| `{r.Seed}` |");
foreach (float cap in caps)
{
var m = r.ByCap[cap];
sb.Append($" {(cap == capM ? "**" : "")}{m.Trunks + m.FlowThrough + m.LakeTerminal} ({m.Trunks + m.FlowThrough} + {m.LakeTerminal}) / {m.DroppedDry + m.DroppedClosed} / {m.Joined}{(cap == capM ? "**" : "")} |");
}
sb.AppendLine();
}
sb.AppendLine();
sb.AppendLine("*A higher cap lets rivers overflow deeper basins: more reach the sea, fewer end at lakes or drop. The per-river");
sb.AppendLine("class at every cap is in `rivers_.csv` (`class_at_capNN` columns), so the flip points are readable per river.*");
sb.AppendLine();
sb.AppendLine("## ⭐ The hero-lake candidate — DATA, not filled");
sb.AppendLine();
sb.AppendLine("Among lakes a sea-reaching, un-joined river flows *through*, ranked by √(fill volume × attached river length), each");
sb.AppendLine("normalised to the seed's maximum. Recorded intent: procedural, executed post-water-render. **Nothing is filled.**");
sb.AppendLine();
sb.AppendLine("| Seed | basin | lake px | fill volume (M m·px) | river | river length px | rivers through | score | runner-up |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var hl = r.Main.HeroLakes;
if (hl.Count == 0) { sb.AppendLine($"| `{r.Seed}` | — | | | | | | | no sea-reaching river passes through a lake |"); continue; }
var h = hl[0];
string ru = hl.Count > 1 ? $"#{hl[1].BasinId} ({hl[1].Score:F2})" : "—";
sb.AppendLine($"| `{r.Seed}` | **#{h.BasinId}** | {h.LakeCells:N0} | {h.FillVolumeMPx / 1e6:F2} | R{h.RiverRank} | {h.RiverLenPx:F0} | {h.RiversThrough} | {h.Score:F3} | {ru} |");
}
sb.AppendLine();
sb.AppendLine("## ⭐ Per river — the chain each one walked");
sb.AppendLine();
sb.AppendLine("Chain notation: `#id L|d : climb` per basin entered (L = lake basin, d = dry), `!` = walled there. The climb is the");
sb.AppendLine("basin's floor→spill (`SpillClimbM`, render surface, clamped at sea as `RouteTo`).");
sb.AppendLine();
foreach (var r in rows)
{
sb.AppendLine($"### `{r.Seed}`");
sb.AppendLine();
sb.AppendLine("| rank | class | drainage px | hops | lakes | max rim m | chain | terminus | joins | lowland px |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
foreach (var fr in r.Main.Rivers)
{
var c = fr.Candidate; int n = r.Graph.MapSize;
string cls = fr.Dropped ? "~~dropped~~" : fr.Trunk ? "trunk" : fr.ReachesSea ? "**flow-through**" : "**lake-terminal**";
string term = fr.Routed.Joined ? $"→ tributary of #{fr.Routed.ConfluenceParentRank}"
: fr.Terminus == FlowThroughRouting.Terminus.Ocean ? $"sea ({fr.MouthCell / n},{fr.MouthCell % n})"
: fr.Terminus == FlowThroughRouting.Terminus.Lake ? $"lake #{fr.TerminusBasinId}"
: fr.Terminus == FlowThroughRouting.Terminus.DrySink ? $"dry sink #{fr.TerminusBasinId} → dropped" : "closed → dropped";
sb.AppendLine($"| #{c.Rank} | {cls} | {c.DrainagePx:N0} | {fr.Chain.Count} | {fr.LakesPassed} | {(fr.Chain.Count > 0 ? fr.MaxHopClimbM.ToString("F1") : "—")} | `{(c.IsSea ? "trunk" : ChainText(fr))}` | {term} | {(fr.Routed.Joined ? $"#{fr.Routed.ConfluenceParentRank}" : "—")} | {fr.LowlandLenPx:F0} |");
}
sb.AppendLine();
sb.AppendLine($"*Candidates {r.CandidateCount} ({r.SeaCandidates} sea) · walled basins at the cap {r.Main.WalledIds.Count} · edge cross-check vs the basin graph {r.Main.EdgeAgree} agree / {r.Main.EdgeDisagree} disagree · " +
$"lowground fallbacks into lakes {r.Main.LowgroundFallbacks} · field {r.FieldSeconds:F1}s, walks ×{caps.Length} {r.WalkSeconds:F1}s, plates {r.RenderSeconds:F0}s, seed {r.Ms / 1000.0:F0}s · " +
$"digests render `{r.RenderDigest:X16}` classify `{r.ClassifyDigest:X16}` · grayscale {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*");
sb.AppendLine();
}
sb.AppendLine("## The model, as run");
sb.AppendLine();
sb.AppendLine("1. **The field** — per land cell, the D8 heading on `Plan.FullFilled` (the overflow surface), cap-independent; on it a");
sb.AppendLine(" basin's minimum is its spill, so descent leaves every basin over its spill into the next (rivers/04 §0.2).");
sb.AppendLine("2. **The walk** — each promoted river follows the field from its terminal. Every basin entered is checked once:");
sb.AppendLine($" floor→spill climb ≤ {capM:F0} m → overflow; > {capM:F0} m → walled. Uniform for lake and dry basins.");
sb.AppendLine("3. **Lakes** — inside an `IsLake` basin the river runs down the REAL terrain (`Plan.Dir`) into the basin's own classify");
sb.AppendLine(" water (rivers/03c fix B's lowground route as the fallback when the descent pools short), crosses the lake as water,");
sb.AppendLine(" and leaves from the lake's lowest `FullFilled` cell over the spill. A dry basin is crossed on the field as a visible line.");
sb.AppendLine($"4. **Disposition** — reaches `OceanMask` → keep; walls at `IsLake` (≥ {lakeMinPx:N0} px, classify) → keep (lake-terminal);");
sb.AppendLine(" walls dry or puddle-only → drop. Read through the confluence root, so a river that joins a kept river is kept.");
sb.AppendLine("5. **Confluence** — rivers/03b's, unchanged: biggest-first, true cell intersection, never proximity.");
sb.AppendLine("6. **The field at the cap** — every walled basin's cells re-pointed onto the real terrain, so flow entering one ends at");
sb.AppendLine(" its floor. That is the `flow_direction_.png` plate and the streamline texture on the hydrology map.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis`, `BasinGraph`; `EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0}; `MinOutletSeparationPx` {def.MinOutletSeparationPx}.");
sb.AppendLine("Termini by `OceanMask` and `IsLake` only — no bare `h < sea`.");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `/hydrology_.png` | **the showpiece** — relief, lakes as water, the field as streamlines, the kept rivers outlined at the fixed width law, confluences, mouths, dropped ghosts |");
sb.AppendLine("| `/flow_direction_.png` | **the data map** — the field at the cap: hue by heading, sinks black, walled basins darkened |");
sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `rivers_.csv` | per river: class, own vs root terminus, the chain with per-hop climbs, terminus, confluence, lengths, and the class at every preview cap |");
sb.AppendLine("| `hero_lakes_.csv` | the hero-lake ranking, as data |");
if (skipRaw) sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written** — byte-identical to `chat2/11_erosion` (rivers/01). |");
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/05_flow_through_routing.report.md`");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
// ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
}.WithFamilyOff();
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
}
// ---- env / io -----------------------------------------------------------------------------
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static float EnvFloat(string k, float fallback) =>
float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
private static float[] EnvFloats(string k, float[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}