islaApocalypse-v2/Tools/Scripts/FlowThroughTool.cs
beezm ccdd6a1996 rivers/05: flow-through routing — river → lake → over the spill → … → sea; the hydrology map and the flow-direction field
Replaces terminate-at-first with chaining through the basin graph (rivers/04): one D8 field on
FullFilled per seed; each promoted river follows it from its terminal, checked at every basin
entered against the floor→spill climb (SpillClimbM) vs ISLA_FLOW_CAP_M (30) — overflow or wall.
Lake basins are entered on the real terrain (Plan.Dir, rivers/03c fix B fallback), crossed as
water to the entered body's lowest-FullFilled outlet, left over the spill. Keep on OceanMask /
IsLake, drop on dry or puddle-only, read through the rivers/03b confluence root (reused verbatim).
The field at the cap (walled basins re-pointed onto the real terrain), its accumulation and every
cell's destination; hero-lake candidates ranked as data. HydrologyRenderer: the showpiece map on
the atlas relief and the flow-direction data map. Heights digested and asserted; nothing filled,
nothing carved. RiverRouting.Confluence and DrainageRenderer.LabelPlacer private→internal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EppUMXNhSeuA5Mu51UnTyP
2026-08-25 22:50:25 -04:00

582 lines
37 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>
/// ⭐⭐ 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)
/// </summary>
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<float, FlowThroughRouting.Result> ByCap = new();
public BasinGraph Graph;
public int DistinctMouths; public List<string> 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<float>(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<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;
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<int, List<int>>();
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<int>(); 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<string, int>(); 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<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;
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<string>();
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<SeedResult> 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_<seed>.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_<seed>.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("| `<seed>/hydrology_<seed>.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("| `<seed>/flow_direction_<seed>.png` | **the data map** — the field at the cap: hue by heading, sinks black, walled basins darkened |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `rivers_<seed>.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_<seed>.csv` | the hero-lake ranking, as data |");
if (skipRaw) sb.AppendLine("| ~~`<seed>/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<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 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<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;
}
private static float[] EnvFloats(string k, float[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<float>();
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;
}
}
}