using System; using System.Collections.Generic; using System.IO; using System.Text; using Godot; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// ⭐⭐ 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) /// 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 Rivers; public int Trunks, Routed, Lakes; /// rivers/03b classes. public int LakeFed, Walled, Joined; /// Rivers whose OWN course ends at the sea (tributaries excluded — they have no mouth). public int SeaReaching; /// ⭐ Rivers whose water reaches the sea, counting tributaries through their trunk. public int SeaConnected; public List CapMoved = new(); public List LakeMoved = new(); public List Joins = new(); /// ⭐ 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. public int DistinctMouths; /// Pairs of sea-reaching rivers sharing a mouth cell, as "#a+#b". public List SharedMouths = new(); public long LandCells, EndorheicCells; public int CandidateCount, SeaCandidates, EndoCandidates; public int WaterBodiesKept, WaterBodiesTotal; public long WaterCellsKept, LargestWaterPx; public double RoutingSeconds; public long TotalExpanded; public string Spread = ""; public ulong Ms; } private void Run() { ToolingPaths.Configure(OS.GetUserDataDir()); ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers")); // ⭐ rivers/03b — the three approved DIVERGENCES. Default OFF, so this tool still reproduces // rivers/03's faithful port bit-for-bit. bool refined = EnvStr("ISLA_ROUTING_MODE", "faithful").Trim().ToLowerInvariant() == "refined"; float rimCapM = float.TryParse(EnvStr("ISLA_RIM_CAP_M", "30"), out float rc) ? rc : 30f; var opt = refined ? new RiverRouting.Options { RimCapM = rimCapM, LakeTargetForRouters = true, Confluence = true } : RiverRouting.Options.Faithful; int task = EnvInt("ISLA_TASK", 3); // ⭐ rivers/03b is a LETTERED SUB-TASK of 03 — same authoring task, three changed rules. string taskSfx = EnvStr("ISLA_TASK_SUFFIX", refined ? "b" : ""); string descr = EnvStr("ISLA_BATCH", refined ? "routing_refinement" : "lowland_routing"); int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize); int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize); int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000); int promoteN = EnvInt("ISLA_PROMOTE_N", 12); int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24); int lakeMinPx = EnvInt("ISLA_LAKE_MIN_TARGET_PX", RiverRouting.LakeMinTargetPx); bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1"; string styleS = EnvStr("ISLA_ROUTING_STYLE", "lowground").Trim().ToLowerInvariant(); if (styleS != "lowground" && styleS != "short") throw new InvalidOperationException($"ISLA_ROUTING_STYLE '{styleS}' — expected 'lowground' (the locked style) or 'short'."); byte style = styleS == "short" ? RiverRouting.StyleShort : RiverRouting.StyleLowground; if (promoteMax < promoteN) throw new InvalidOperationException( $"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN}). The cap is what makes the " + "analysis trace a real upland stem for each promoted river; below it, a river would have no course to route from."); // ⚠ rivers/01: the shape AND erosion come from the bare defaults. Assert before generating. TerrainShapeV1.Assert("RiverRouting"); TerrainShapeV1.AssertErosionDefaultOn("RiverRouting"); string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr); DirAccess.MakeDirRecursiveAbsolute(batchRoot); DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); var anchors = CurveAnchors.Default; float sea = 0.15f; // Reporting caps raised so every promoted river has a traced stem — exactly as rivers/02. // ⚠⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are NOT touched: they // decide which depressions BECOME terminal basins, i.e. they define the routing surface. var dp = new DrainageAnalysis.Params { SeaLevel = sea, TrunkCount = promoteMax, GiantCount = promoteMax, EndorheicMaxCount = promoteMax, EndorheicMinInflowPx = (int)floorPx, }; var dpDefaults = new DrainageAnalysis.Params(); GD.Print("=================================================================="); GD.Print(refined ? " ROUTING REFINEMENT (rivers/03b) — three DELIBERATE DIVERGENCES from the faithful port, COURSES ONLY" : " LOWLAND ROUTING (rivers/03) — the routed MIX on the pure top-N, COURSES ONLY"); GD.Print("=================================================================="); GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}"); GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default"); GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}"); GD.Print($"promoted : PURE top {promoteN} by drainage (unified ranking, rivers/02). NO QUOTA — rivers/02b's K stays parked and unlocked."); GD.Print($"style : {styleS.ToUpperInvariant()} — cost = DIST x ({RiverRouting.LowgroundBase} + elevM x {RiverRouting.LowgroundElevPerM}) + climbM x {RiverRouting.LowgroundUphillPerM} (effective == declared; verified no ConfigManager/[Export] override)"); GD.Print($"sort : the REFERENCE's — Kind = (basinHasLake && !southern) ? lake-ender : routed."); GD.Print($" ⚠ NOT a path test: on an 8-connected grid a path to the ocean ALWAYS exists, so"); GD.Print($" \"a path exists → routed\" would classify everything as routed. The ocean route is"); GD.Print($" still probed for EVERY giant so the missing affordability threshold is a number, not a guess."); if (refined) { GD.Print("⚠⚠ THREE DELIBERATE DIVERGENCES FROM THE REFERENCE — approved, and NOT a port:"); GD.Print($" FIX 1 RIM CAP {rimCapM:F0} m — a route to the sea that must climb higher than this above its"); GD.Print( " terminal is REFUSED; the river becomes a walled-off inland lake-ender. (Reference: routes at ANY cost.)"); GD.Print( " FIX 3 LAKE TARGETS — a router stops at the nearer of {ocean, significant lake}, so it cannot"); GD.Print( " skirt a lake to reach a distant coast. (Reference: routers target ocean only.)"); GD.Print( " FIX 2 CONFLUENCE — courses laid biggest-first join on TRUE CELL INTERSECTION and the smaller"); GD.Print( " becomes a tributary. (Reference: no dedup, no join — parallel duplicates to one mouth.)"); GD.Print( " ⚠ KEPT: basinHasLake stays the sort — a basin that already holds a lake is a natural lake-ender."); } GD.Print($"lake target: significant water = 8-connected classify-water components >= {lakeMinPx:N0} px (interim for v2's missing water-bodies table)"); GD.Print($"⛔ RED LINE : courses only — no height mutated, no water filled, nothing carved. ASSERTED per seed."); GD.Print($"batch : {batchRoot}"); GD.Print("=================================================================="); if (mapSize != 8192) GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the DrainageAnalysis params are ABSOLUTE PIXEL COUNTS tuned at 8192 " + "(rivers/02). At a smaller size few or no depressions qualify as terminal basins, so the " + "routed/lake-ender SORT cannot be exercised. A smaller run validates the PIPELINE only and " + "MUST NOT be used to judge the mix."); GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---"); var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors); GD.Print($" {knots}"); TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig { MapSize = size, Seed = seed, VariantLabel = "routing", Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous, Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f, }; var results = new List(); foreach (int seed in seeds) { ulong t0 = Time.GetTicksMsec(); GD.Print($"\n--- seed {seed} ---"); var cfg = Cfg(mapSize, seed); Pass1Result p1 = Topography.Generate(cfg); Pass2Result shaped = Shaping.Shape(p1, cfg); var ero = ErosionPass.Apply(shaped, cfg); Pass2Result p2 = ero.Shaped; // ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field (→ D-066). bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed); var isClassifyWater = new bool[mapSize * mapSize]; for (int x = 0; x < mapSize; x++) for (int y = 0; y < mapSize; y++) if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true; var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp); GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " + $"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %); terminal basins {plan.TerminalBasinCount}"); var e = RiverCandidates.Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, p1.Regions); var promoted = e.Ranked.GetRange(0, Math.Min(promoteN, e.Ranked.Count)); RiverCandidates.BindCourses(plan, mapSize, promoted, $"the pure top {promoteN}"); int pSea = 0; foreach (var c in promoted) if (c.IsSea) pSea++; GD.Print($" promoted: pure top {promoted.Count} — {pSea} sea / {promoted.Count - pSea} endorheic (from {e.Ranked.Count} candidates: {e.SeaCount} sea / {e.Ranked.Count - e.SeaCount} endorheic)"); // ⭐ The interim significant-water mask — v2 has no water-bodies table (a known port gap). bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize, lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx); GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest body {largestPx:N0} px)"); // ═══ ⛔ THE RED-LINE GUARD — snapshot both height fields BEFORE routing ═══ ulong hRenderBefore = Digest(p2.Height, mapSize); ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize); GD.Print($" routing (style {styleS}) — probing the ocean for every giant:"); ulong tr0 = Time.GetTicksMsec(); var rivers = RiverRouting.RouteAll(promoted, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, style, m => GD.Print(m), opt); double routingSec = (Time.GetTicksMsec() - tr0) / 1000.0; // ═══ ⛔ …and assert they are byte-identical after ═══ ulong hRenderAfter = Digest(p2.Height, mapSize); ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize); if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore) throw new InvalidOperationException( "[RiverRouting] RED-LINE VIOLATION: a height field CHANGED across routing.\n" + $" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" + $" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" + "This task produces COURSES ONLY — it must never mutate a height, fill water, or carve. " + "The bed carve and the stepped-water model are separate later tasks. Refusing to continue."); GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across routing — nothing carved, no water filled."); var r = new SeedResult { Seed = seed, Rivers = rivers, LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells, CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount, EndoCandidates = e.Ranked.Count - e.SeaCount, WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies, WaterCellsKept = keptCells, LargestWaterPx = largestPx, RoutingSeconds = routingSec, }; foreach (var rr in rivers) { switch (rr.Class) { case RiverRouting.RiverClass.OceanTrunk: r.Trunks++; break; case RiverRouting.RiverClass.RoutedGiant: r.Routed++; break; case RiverRouting.RiverClass.LakeFed: r.LakeFed++; break; case RiverRouting.RiverClass.WalledOff: r.Walled++; break; default: r.Lakes++; break; } if (rr.OceanProbe != null) r.TotalExpanded += rr.OceanProbe.Expanded; if (rr.Joined) { r.Joined++; r.Joins.Add($"#{rr.Candidate.Rank}→#{rr.ConfluenceParentRank} at ({rr.JunctionCell.x},{rr.JunctionCell.y})"); } // ⭐ Make every reclassification the divergences caused legible, not silent. if (rr.RefusedByCap) r.CapMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px, rim {rr.CappedRimM:F1} m)"); if (rr.Class == RiverRouting.RiverClass.LakeFed) r.LakeMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px)"); } // ⚠ A tributary has NO mouth of its own — it reaches the sea through its trunk. So the two // numbers are different and both are reported: how many rivers END at the sea, and how many // rivers' water GETS there. foreach (var rr in rivers) { if (!rr.Joined && rr.ReachesSea) r.SeaReaching++; if (RiverRouting.Root(rr, rivers).ReachesSea) r.SeaConnected++; } MeasureMouths(r, rivers); r.Spread = Spread(rivers, mapSize); r.Ms = Time.GetTicksMsec() - t0; GD.Print($" ⭐ MIX: {r.Trunks} trunks + {r.Routed} routed-to-sea + {r.LakeFed} lake-fed + {r.Lakes} natural lake-enders + {r.Walled} walled-off = {rivers.Count}" + (r.Joined > 0 ? $" ({r.Joined} joined as tributaries)" : "")); GD.Print($" → {r.DistinctMouths} DISTINCT SEA MOUTHS from {r.SeaReaching} river(s) ending at the sea; {r.SeaConnected} rivers' water reaches the sea" + (r.SharedMouths.Count > 0 ? $" ⚠⚠ STILL SHARED: {string.Join(", ", r.SharedMouths)}" : " ✅ no two rivers share a mouth") + $" spread: {r.Spread}"); if (r.CapMoved.Count > 0) GD.Print($" ⚠ rim cap moved routed→walled-off: {string.Join(", ", r.CapMoved)}"); if (r.LakeMoved.Count > 0) GD.Print($" ⭐ lake-target moved routed→lake-fed: {string.Join(", ", r.LakeMoved)}"); GD.Print($" routing {routingSec:F1}s, {r.TotalExpanded:N0} cells settled across all probes"); WriteRiverCsv(batchRoot, r); RenderSeed(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, promoteN, skipRaw, refined, rimCapM); results.Add(r); } if (refined) WriteRefinedIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, rimCapM, dpDefaults, skipRaw); else WriteIndex(batchRoot, mapSize, calibSize, seeds, results, promoteN, floorPx, promoteMax, lakeMinPx, styleS, dpDefaults, skipRaw); GD.Print("\n=================================================================="); GD.Print($" DONE — {batchRoot}"); GD.Print(" ⛔ TASTE GATE: the MIX is PRESENTED, not decided. No count, no K, no style, no default was set."); GD.Print(" ⛔ COURSES ONLY: no height mutated, no water filled, nothing carved — asserted per seed."); GD.Print("=================================================================="); GetTree().Quit(0); } /// /// 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". /// 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; } /// /// ⭐⭐ 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. /// private static void MeasureMouths(SeedResult r, List rivers) { var at = new Dictionary<(int x, int y), List>(); foreach (var rr in rivers) { // ⚠ A tributary adopted its trunk's terminus — it is not a separate mouth. if (rr.Joined || !rr.ReachesSea) continue; (int x, int y) key; if (rr.Class == RiverRouting.RiverClass.OceanTrunk) key = (rr.Candidate.TermX, rr.Candidate.TermY); else if (rr.Lowland != null && rr.Lowland.Reached) key = ((int)rr.Lowland.Target.x, (int)rr.Lowland.Target.y); else continue; if (!at.TryGetValue(key, out var l)) { l = new List(); 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})"); } /// /// Where the sea-reaching rivers actually meet the coast. ⚠ In this codebase a cell is /// x*n + y and the image is drawn SetPixel(x, y), so +y is SOUTH on the plate. /// private static string Spread(List rivers, int n) { var counts = new Dictionary(); int total = 0; foreach (var r in rivers) { if (r.Joined || !r.ReachesSea) continue; float mx, my; if (r.Class == RiverRouting.RiverClass.OceanTrunk) { mx = r.Candidate.TermX; my = r.Candidate.TermY; } else if (r.Lowland != null && r.Lowland.Reached) { mx = r.Lowland.Target.x; my = r.Lowland.Target.y; } else continue; string c = Compass(mx, my, n); counts.TryGetValue(c, out int cur); counts[c] = cur + 1; total++; } if (total == 0) return "none"; var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" }; var parts = new List(); foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}"); return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)"; } private static string Compass(float x, float y, int n) { float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; // dy > 0 = south const float band = 0.35f; string ns = dy < -band ? "N" : dy > band ? "S" : ""; string ew = dx < -band ? "W" : dx > band ? "E" : ""; return ns + ew == "" ? "centre" : ns + ew; } private static string ClassName(RiverRouting.RiverClass c) => c switch { RiverRouting.RiverClass.OceanTrunk => "trunk", RiverRouting.RiverClass.RoutedGiant => "routed", RiverRouting.RiverClass.LakeFed => "lake-fed", RiverRouting.RiverClass.WalledOff => "walled-off", _ => "lake-ender", }; private static void WriteRiverCsv(string batchRoot, SeedResult r) { var sb = new StringBuilder(); sb.AppendLine("rank,class,basin_has_lake,analysis_kind,faithful_class,terminus_type,drainage_px,term_x,term_y,course_pts," + "route_reached,route_target_x,route_target_y,route_len_px,route_straight_px,wander," + "route_cost,rim_climb_m,cap_verdict,max_step_uphill_m,max_elev_m,total_uphill_m,uphill_steps," + "rim_x,rim_y,cells_expanded,lake_reached,lake_was_fallback,joined,confluence_parent_rank," + "junction_x,junction_y,stem_width_px,why"); foreach (var rr in r.Rivers) { var c = rr.Candidate; var lo = rr.Lowland; // ⚠ For a lake-ender the OCEAN PROBE is reported too (in the rim/cost columns of the // probe row below) — those are what an affordability threshold would be set against. var pr = rr.OceanProbe; sb.AppendLine($"{c.Rank},{ClassName(rr.Class)},{(c.IsSea ? "" : (c.AnalysisKind == "lake-ender" ? "yes" : "no"))},{(c.IsSea ? "" : c.AnalysisKind)}," + $"{(c.IsSea ? "" : ClassName(rr.FaithfulClass))},{c.TerminusName},{c.DrainagePx},{c.TermX},{c.TermY},{rr.Course.Count}," + $"{(lo != null && lo.Reached ? "yes" : "no")},{(lo != null && lo.Reached ? ((int)lo.Target.x).ToString() : "")},{(lo != null && lo.Reached ? ((int)lo.Target.y).ToString() : "")}," + $"{(lo != null ? lo.LenPx.ToString("F1") : "")},{(lo != null ? lo.StraightPx.ToString("F1") : "")},{(lo != null ? lo.WanderRatio.ToString("F3") : "")}," + $"{(pr != null && pr.Reached ? pr.Cost.ToString("F0") : "")},{(pr != null ? pr.RimClimbM.ToString("F2") : "")}," + $"{(rr.RefusedByCap ? "REFUSED" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under cap" : "")}," + $"{(pr != null ? pr.MaxStepUphillM.ToString("F3") : "")}," + $"{(pr != null ? pr.MaxElevM.ToString("F2") : "")},{(pr != null ? pr.TotalUphillM.ToString("F2") : "")},{(pr != null ? pr.UphillSteps.ToString() : "")}," + $"{(pr != null && pr.Reached ? ((int)pr.RimPoint.x).ToString() : "")},{(pr != null && pr.Reached ? ((int)pr.RimPoint.y).ToString() : "")}," + $"{(pr != null ? pr.Expanded.ToString() : "")},{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeReached ? "yes" : "no") : "")}," + $"{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeWasFallback ? "yes" : "no") : "")}," + $"{(rr.Joined ? "yes" : "no")},{(rr.Joined ? rr.ConfluenceParentRank.ToString() : "")}," + $"{(rr.Joined ? rr.JunctionCell.x.ToString() : "")},{(rr.Joined ? rr.JunctionCell.y.ToString() : "")}," + $"{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{rr.Why}\""); } WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString()); } private static void RenderSeed(string batchRoot, SeedResult r, bool[] isOcean, Pass2Result p2, int n, float sea, long floorPx, int promoteN, bool skipRaw, bool refined, float rimCapM) { string dir = Path.Combine(batchRoot, $"{r.Seed}"); DirAccess.MakeDirRecursiveAbsolute(dir); Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax); if (refined) DrainageRenderer.RefinedMix(r.Rivers, baseImg.Duplicate() as Image, n, $"SEED {r.Seed} - THE RESHAPED MIX ON THE PURE TOP {promoteN} (RIVERS/03B)", $"{r.DistinctMouths} DISTINCT SEA MOUTHS - {r.Trunks} TRUNK + {r.Routed} ROUTED + {r.LakeFed} LAKE-FED + {r.Lakes} NATURAL LAKE-ENDER + {r.Walled} WALLED-OFF, {r.Joined} JOINED. SPREAD: {r.Spread.ToUpperInvariant()}", $"CHEAPEST ROUTE TO THE SEA CLIMBS MORE THAN THE {rimCapM:F0} M RIM CAP, SO IT ENDS AT ITS OWN TERMINAL") .SavePng(Path.Combine(dir, "refined_mix.png")); else DrainageRenderer.RoutedMix(r.Rivers, baseImg.Duplicate() as Image, n, $"SEED {r.Seed} - THE ROUTED MIX ON THE PURE TOP {promoteN}", $"{r.Trunks} NATURAL TRUNKS + {r.Routed} ROUTED-THROUGH + {r.Lakes} LAKE-ENDERS = {r.SeaReaching} SEA-REACHING RIVERS. SPREAD: {r.Spread.ToUpperInvariant()}", floorPx) .SavePng(Path.Combine(dir, "routed_mix.png")); // Grayscale beside the pretty render — the field must be inspectable without the palette. var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png")); GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m"); if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32")); } private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List 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 1–2 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("| `/routed_mix.png` | the three classes, with the added lowland reach and the rim it crossed drawn distinctly |"); sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |"); sb.AppendLine("| `rivers_.csv` | per river: class, analysis kind, route geometry, rim, cost, cells settled, why |"); if (skipRaw) sb.AppendLine("| ~~`/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 REFINEMENT INDEX (rivers/03b) — the before/after, and the one judgment it feeds. /// private static void WriteRefinedIndex(string batchRoot, int mapSize, int[] seeds, List rows, int promoteN, int lakeMinPx, float rimCapM, DrainageAnalysis.Params def, bool skipRaw) { var sb = new StringBuilder(); int primary = seeds.Length > 0 ? seeds[0] : 0; sb.AppendLine($"# Batch 03b — routing refinement: the reshaped MIX on the pure top {promoteN}"); sb.AppendLine(); sb.AppendLine("**⛔ TASTE GATE. Nothing is locked, and nothing graduates yet** — the cap is a knob, and the"); sb.AppendLine("developer has said not to graduate mid-refinement."); sb.AppendLine(); sb.AppendLine("**⛔ COURSES ONLY. No height mutated, no water filled or created, nothing carved** — asserted per"); sb.AppendLine("seed by a raw-bit digest of both height fields before and after routing. Lake-termination *ends a"); sb.AppendLine("course at* an existing lake; it does not fill or create one."); sb.AppendLine(); sb.AppendLine("## 👉 The pick — this is a BEFORE/AFTER"); sb.AppendLine(); sb.AppendLine($"Open **`{primary}/refined_mix.png`** beside rivers/03's"); sb.AppendLine($"**`../03_lowland_routing/{primary}/routed_mix.png`** (the before). Same base, same colours where"); sb.AppendLine("they carry over, same fixed width scale — the two are directly comparable."); sb.AppendLine(); sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED"); sb.AppendLine("> **Does the island now read as natural dendritic drainage — no uphill rivers, no parallel"); sb.AppendLine("> duplicate mouths, no skirting past a lake to reach the sea — and is the resulting sea-river count"); sb.AppendLine("> healthy, or is the island now too lake-locked?**"); sb.AppendLine(">"); sb.AppendLine($"> If too walled-off, the levers are the cap (`ISLA_RIM_CAP_M` up from {rimCapM:F0}) or — as its own"); sb.AppendLine("> future task — rim incision. If about right, routing character is settled and the whole routing"); sb.AppendLine("> unit graduates together."); sb.AppendLine(); sb.AppendLine("## ⚠⚠ Three DELIBERATE DIVERGENCES from the reference — not a port"); sb.AppendLine(); sb.AppendLine("Each corrects a faithful behaviour that produced a physically-wrong result, on the developer's call."); sb.AppendLine(); sb.AppendLine("| # | The reference does | rivers/03 showed | rivers/03b does instead |"); sb.AppendLine("|---|---|---|---|"); sb.AppendLine($"| **1** | routes a dry basin to the sea **at any cost** | an uphill river over a **66.7 m** rim (`31415926 #2`) | **rim cap {rimCapM:F0} m** — over it, the river is refused and ends at its own terminal |"); sb.AppendLine("| **2** | lays every route independently, **no dedup, no join** | **two rivers at the identical ocean cell on every seed**, never having met | **confluence** — biggest-first, join on true cell intersection, the smaller becomes a tributary |"); sb.AppendLine("| **3** | a router targets **ocean only** | a router **skirts a lake** to reach the distant sea | **lake targets** — a router stops at the nearer of {ocean, significant lake} |"); sb.AppendLine(); sb.AppendLine("**⚠ KEPT unchanged: `basinHasLake` is still the sort.** A basin that already holds a visible lake is"); sb.AppendLine("a natural lake-ender and its river feeds its own lake — that is rivers/03's finding and it stands."); sb.AppendLine(); sb.AppendLine("## ⭐ The reshaped MIX, per seed"); sb.AppendLine(); sb.AppendLine("| Seed | trunk | routed→sea | lake-fed | natural lake-ender | ⚠ walled-off | joined | ⭐⭐ DISTINCT SEA MOUTHS | spread |"); sb.AppendLine("|---|---|---|---|---|---|---|---|---|"); foreach (var r in rows) sb.AppendLine($"| `{r.Seed}` | {r.Trunks} | {r.Routed} | {r.LakeFed} | {r.Lakes} | {r.Walled} | {r.Joined} | " + $"**{r.DistinctMouths}** | {r.Spread} |"); sb.AppendLine(); sb.AppendLine("⚠ *Rivers whose water reaches the sea, counting tributaries through their trunk:* " + string.Join(", ", Array.ConvertAll(rows.ToArray(), r => $"`{r.Seed}` {r.SeaConnected}")) + "."); sb.AppendLine("A tributary has no mouth of its own, so it is not a separate sea mouth — but its water still gets there."); sb.AppendLine(); bool anyShared = false; foreach (var r in rows) if (r.SharedMouths.Count > 0) anyShared = true; if (anyShared) { sb.AppendLine("> ### ⚠⚠ SOME MOUTHS ARE STILL SHARED — fix 2 did not fully close it"); foreach (var r in rows) if (r.SharedMouths.Count > 0) sb.AppendLine($"> - `{r.Seed}`: {string.Join("; ", r.SharedMouths)}"); sb.AppendLine("> Two courses can arrive at the same cell without their rasterised paths ever sharing one"); sb.AppendLine("> earlier — they approach from different sides. Reported, not papered over."); } else { sb.AppendLine("> ### ✅ NO TWO RIVERS SHARE A MOUTH on any seed — fix 2 closed rivers/03's duplicate-mouth finding."); sb.AppendLine("> Every distinct mouth is now a distinct river, and rivers that meet do so as a confluence."); } sb.AppendLine(); sb.AppendLine("## What the divergences actually moved"); sb.AppendLine(); sb.AppendLine("| Seed | ⚠ rim cap moved routed → walled-off | ⭐ lake target moved routed → lake-fed | confluences formed |"); sb.AppendLine("|---|---|---|---|"); foreach (var r in rows) sb.AppendLine($"| `{r.Seed}` | {(r.CapMoved.Count > 0 ? string.Join("; ", r.CapMoved) : "— none")} | " + $"{(r.LakeMoved.Count > 0 ? string.Join("; ", r.LakeMoved) : "— none")} | " + $"{(r.Joins.Count > 0 ? string.Join("; ", r.Joins) : "— none")} |"); sb.AppendLine(); sb.AppendLine("> ### ⚠ The min-rim signal — what to watch for"); sb.AppendLine("> The cap is applied to the **least-cost route's** rim climb, not to a theoretical minimum-rim path."); sb.AppendLine("> LOWGROUND penalises uphill heavily so the chosen route is almost always the low-rim one — **but if"); sb.AppendLine("> a basin is walled off that visibly should have had a low way out, that is the signal we need a"); sb.AppendLine("> bottleneck (min-rim) search.** Check each walled-off river on the plate against its surroundings."); sb.AppendLine("> Not built here."); sb.AppendLine(); sb.AppendLine("## The per-river diagnostic"); sb.AppendLine(); foreach (var r in rows) { sb.AppendLine($"### `{r.Seed}`"); sb.AppendLine(); sb.AppendLine("| rank | class | basin has lake | drainage px | terminus | rim climb m | cap | joins | route len px |"); sb.AppendLine("|---|---|---|---|---|---|---|---|---|"); foreach (var rr in r.Rivers) { var c = rr.Candidate; var lo = rr.Lowland; var pr = rr.OceanProbe; string term = rr.Joined ? $"→ tributary of #{rr.ConfluenceParentRank}" : rr.Class switch { RiverRouting.RiverClass.OceanTrunk => $"sea ({c.TermX},{c.TermY})", RiverRouting.RiverClass.RoutedGiant => lo != null && lo.Reached ? $"sea ({(int)lo.Target.x},{(int)lo.Target.y})" : "⚠ no route", RiverRouting.RiverClass.LakeFed => lo != null && lo.Reached ? $"**lake** ({(int)lo.Target.x},{(int)lo.Target.y})" : "⚠ no route", RiverRouting.RiverClass.WalledOff => $"**its own terminal** ({c.TermX},{c.TermY})", _ => rr.LakeReached ? $"lake ({(int)lo.Target.x},{(int)lo.Target.y})" : $"its own terminal ({c.TermX},{c.TermY})", }; sb.AppendLine($"| #{c.Rank} | {ClassName(rr.Class)} | {(c.IsSea ? "—" : (c.AnalysisKind == "lake-ender" ? "**yes**" : "no"))} | {c.DrainagePx:N0} | {term} | " + $"{(pr != null && pr.Reached ? pr.RimClimbM.ToString("F1") : "—")} | " + $"{(rr.RefusedByCap ? "**REFUSED**" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under" : "—")} | " + $"{(rr.Joined ? $"#{rr.ConfluenceParentRank} at ({rr.JunctionCell.x},{rr.JunctionCell.y})" : "—")} | " + $"{(lo != null && lo.Reached ? lo.LenPx.ToString("F0") : "—")} |"); } sb.AppendLine(); } sb.AppendLine("## What was run"); sb.AppendLine(); sb.AppendLine($"Chain + analysis + routing at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered."); sb.AppendLine($"Promoted set: the **pure top {promoteN}** — no quota; rivers/02b's K stays parked and unlocked."); sb.AppendLine($"Rim cap **{rimCapM:F0} m** (`ISLA_RIM_CAP_M`). Significant water: 8-connected classify-water components ≥ {lakeMinPx:N0} px,"); sb.AppendLine("ocean excluded (the interim for v2's missing water-bodies table). Lowland-only smoothing unchanged —"); sb.AppendLine("the lowland reach is smoothed, the erosion-carved upland stem never is."); sb.AppendLine(); sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused, not rebuilt); `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0}"); sb.AppendLine($"(they define the routing surface); `MinOutletSeparationPx` {def.MinOutletSeparationPx}; `StemMinAccPx` {def.StemMinAccPx}. Termini are classified by"); sb.AppendLine("`RegionLabeling.OceanMask` and the significant-lake mask only — no bare `h < sea`. `Giant.ProvisionalRoute` never drawn."); sb.AppendLine(); sb.AppendLine("**⚠ No spatial term anywhere.** The `1063685222` southern-coast gap is *not* addressed here — it stays a"); sb.AppendLine("placement-era question, not a routing one."); sb.AppendLine(); sb.AppendLine("## Files"); sb.AppendLine(); sb.AppendLine("| File | What it is |"); sb.AppendLine("|---|---|"); sb.AppendLine("| `/refined_mix.png` | the five classes as a dendritic tree; white dot = confluence, yellow ring = rim crossed |"); sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |"); sb.AppendLine("| `rivers_.csv` | per river: class, `basin_has_lake`, faithful class, rim + cap verdict, confluence parent, route geometry, why |"); if (skipRaw) sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. `ISLA_SKIP_RAW=0` regenerates it. |"); sb.AppendLine(); sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}."); sb.AppendLine(); sb.AppendLine("→ `XX_Human/output/rivers/03b_routing_refinement.report.md`"); WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString()); } // ---- the curve (the house pattern; pool pinned family-off per rivers/01) ------------------- private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors) { var rawPool = new LandHistogram(sea); var pass1 = new Dictionary(); 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(); 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; } } }