using System; using System.Collections.Generic; using System.IO; using System.Text; using Godot; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// ⭐⭐ THE LAKE-BASIN LAYER (rivers/04) — build the basin graph and show it, so the developer can /// trust the spills before anything is routed on them. /// /// ═══ WHAT THIS TASK IS FOR ═══ /// /// The flow-through routing model (the next task) needs lakes to be NODES in the drainage graph, /// not free-floating heightmap classifications the router bumps into. This builds that layer from /// what `DrainageAnalysis` already computed — the sinks (`BasinId`) and the overflow surface /// (`FullFilled`) — enriching each terminal basin with its SPILL, its LAKE-IDENTITY and its /// DOWNSTREAM EDGE. → . /// /// ═══ ⛔ THE RED LINE — A DATA LAYER, NOT A TERRAIN WRITE ═══ /// /// **No height is written. No water is filled or created. `DrainageAnalysis` is reused, not /// rewritten.** Spills and lake-identity are computed and recorded, never stamped. Both height /// fields are digested before the layer is built and after the plate is drawn, and the tool REFUSES /// to continue if either changed — the flood-guard discipline every task since erosion has kept. /// /// ═══ RUNNING IT ═══ /// /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/BasinGraphTool.tscn /// /// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW /// ISLA_LAKE_MIN_PX the significance floor for a basin's in-basin classify water (default 20000 — a KNOB) /// ISLA_CAP_PREVIEW_M comma list of rim caps to preview connectivity at (default "15,30,60") /// public partial class BasinGraphTool : 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 BasinGraph Graph; public int TerminalBasinCount; public long LandCells, EndorheicCells; public ulong RenderDigest, ClassifyDigest; public int WaterBodiesKept, WaterBodiesTotal; public long WaterCellsKept, LargestWaterPx; public float SpillMinM, SpillP25M, SpillMedM, SpillP75M, SpillMaxM; public float ClimbMinM, ClimbP25M, ClimbMedM, ClimbP75M, ClimbMaxM; public int[] ClimbBands; // <=5, 5-15, 15-30, 30-60, >60 public Dictionary Cap = new(); public int LakeAnyOnly; // HasAnyLake && !IsLake — where this layer's label differs from the routing sort public int MaxHops; public float GraphSeconds, 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", 4); string taskSfx = EnvStr("ISLA_TASK_SUFFIX", ""); string descr = EnvStr("ISLA_BATCH", "lake_basin_layer"); int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize); int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize); int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx); bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1"; float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f }); TerrainShapeV1.Assert("BasinGraph"); TerrainShapeV1.AssertErosionDefaultOn("BasinGraph"); string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr); DirAccess.MakeDirRecursiveAbsolute(batchRoot); DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); var anchors = CurveAnchors.Default; float sea = 0.15f; // ⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are the defaults — they // define which depressions ARE terminal basins, i.e. the nodes of this graph. Reporting caps // are irrelevant here: the layer reads BasinId / FullFilled / Dir, not the promoted lists. var dp = new DrainageAnalysis.Params { SeaLevel = sea }; GD.Print("=================================================================="); GD.Print(" THE LAKE-BASIN LAYER (rivers/04) — the basin graph: spill + lake-identity + downstream edge, per terminal basin"); 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($"spill : {BasinGraph.SpillDatum}"); GD.Print($"lake : {BasinGraph.LakeDatum} floor = {lakeMinPx:N0} px (ISLA_LAKE_MIN_PX, a knob)"); GD.Print($"edge : {BasinGraph.DownstreamMethod}"); GD.Print($"D-046 : spill geometry/height on RENDER (the flow surface, as RouteTo routes); lake presence + OCEAN on CLASSIFY (the water surface, as the terminus tests). No cross-surface comparison anywhere."); GD.Print($"cap view : unbroken spill-chain to the ocean previewed at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m (every link's floor→spill climb ≤ cap; clamped at sea as RouteTo clamps)"); GD.Print($"⛔ RED LINE : DATA LAYER ONLY — no height mutated, no water filled, DrainageAnalysis untouched. ASSERTED per seed around build + render."); GD.Print($"batch : {batchRoot}"); GD.Print("=================================================================="); if (mapSize != 8192) GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the terminal-basin gates are ABSOLUTE PIXEL COUNTS tuned at 8192; a smaller " + "map under-produces terminal basins. This run checks the PLUMBING (spill extraction, graph, render), not the character."); 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 = "basins", 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 and the water surface — CLASSIFY (→ D-066 / D-046). 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; // ⭐ THE FLOW SURFACE — the analysis on the eroded RENDER height, exactly as every task since chat2/12. 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}; pits filled through {plan.PitsFilledCount:N0}"); 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 {largestPx:N0} px)"); // ═══ ⛔ THE RED-LINE GUARD — both fields digested BEFORE the layer ═══ ulong hRenderBefore = Digest(p2.Height, mapSize); ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize); ulong tg0 = Time.GetTicksMsec(); var g = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx); float graphSec = (Time.GetTicksMsec() - tg0) / 1000f; if (g.Nodes.Count != plan.TerminalBasinCount) throw new InvalidOperationException($"[BasinGraph] node count {g.Nodes.Count} != Plan.TerminalBasinCount {plan.TerminalBasinCount} — the layer did not enumerate the analysis's basins exactly."); var r = new SeedResult { Seed = seed, Graph = g, TerminalBasinCount = plan.TerminalBasinCount, LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells, WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies, WaterCellsKept = keptCells, LargestWaterPx = largestPx, GraphSeconds = graphSec, }; Summarise(r, caps); GD.Print($" ⚠ SEAM: {g.Seabed} of {g.Nodes.Count} terminal basins are SEABED pits (every cell ocean on classify, inflow 0) — excluded from the graph statistics below; {g.Coastal} straddle the shoreline (kept as land)."); GD.Print($" ⭐ GRAPH (land): {g.LandNodes.Count} basins — {g.LakeBasins} LAKE / {g.DryBasins} DRY (floor {lakeMinPx:N0} px; {r.LakeAnyOnly} hold only a sub-floor puddle)"); GD.Print($" spills → ocean {g.ToOcean} / → another basin {g.ToBasin} / closed {g.Closed}; longest chain {r.MaxHops} hops"); GD.Print($" spill height above sea (m): min {r.SpillMinM:F1} p25 {r.SpillP25M:F1} median {r.SpillMedM:F1} p75 {r.SpillP75M:F1} max {r.SpillMaxM:F1}"); GD.Print($" floor→spill climb (m): min {r.ClimbMinM:F1} p25 {r.ClimbP25M:F1} median {r.ClimbMedM:F1} p75 {r.ClimbP75M:F1} max {r.ClimbMaxM:F1} bands ≤5/5–15/15–30/30–60/>60: {string.Join("/", r.ClimbBands)}"); foreach (float cap in caps) { var c = r.Cap[cap]; GD.Print($" cap {cap,3:F0} m: {c.all,3} of {g.LandNodes.Count} land basins chain to the ocean ({c.lake} lake / {c.dry} dry; {c.direct} of them directly)"); } GD.Print($" ✅ invariants: spill cross-check (fill-level vs rim-walk) failures {g.SpillCrossCheckFailures}; spill-not-on-terrain {g.SpillNotOnTerrain}; " + $"Dir-walk disagreements {g.DirWalkDisagreements}; seabed {g.Seabed} / coastal {g.Coastal}"); if (g.SpillCrossCheckFailures > 0 || g.SpillNotOnTerrain > 0) GD.PrintErr(" ⚠⚠ A SPILL INVARIANT FAILED — the spill datum is not exact on this seed. Reported, not hidden; see the CSV."); if (g.DirWalkDisagreements > 0) GD.PrintErr(" ⚠ The FullFilled walk and the Dir walk disagree on some basin's downstream — listed in the CSV (dir_walk_agrees)."); GD.Print($" ⭐ RECONCILIATION: {g.LakeBodies.Count} significant classify bodies — {g.LakeBodiesOwned} owned by a basin (≥ half inside one), " + $"{g.LakeBodiesSplit} split across basins, {g.LakeBodiesFree} FREE (in no terminal basin at all); " + $"{g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} non-ocean classify-water cells lie outside every basin"); GD.Print($" graph built in {graphSec:F2}s"); WriteBasinCsv(batchRoot, r, caps); WriteLakeCsv(batchRoot, r); ulong tr0 = Time.GetTicksMsec(); RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, skipRaw); r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000f; // ═══ ⛔ …and asserted byte-identical AFTER build + render ═══ ulong hRenderAfter = Digest(p2.Height, mapSize); ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize); if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore) throw new InvalidOperationException( "[BasinGraph] RED-LINE VIOLATION: a height field CHANGED across the layer build / render.\n" + $" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" + $" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" + "This task builds a DATA layer — it must never mutate a height or fill water. Refusing to continue."); r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore; GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across build + render — no height mutated, no water filled."); r.Ms = Time.GetTicksMsec() - t0; results.Add(r); } WriteIndex(batchRoot, mapSize, seeds, results, lakeMinPx, caps, dp, skipRaw); GD.Print("\n=================================================================="); GD.Print($" DONE — {batchRoot}"); GD.Print(" ⛔ TASTE GATE: the graph is PRESENTED, not routed on. Nothing locked, nothing routed, nothing graduated."); GD.Print(" ⛔ DATA LAYER ONLY: no height mutated, no water filled — asserted per seed."); GD.Print("=================================================================="); GetTree().Quit(0); } private static void Summarise(SeedResult r, float[] caps) { var g = r.Graph; var spill = new List(); var climb = new List(); r.ClimbBands = new int[5]; foreach (var b in g.LandNodes) { spill.Add(b.SpillAboveSeaM); climb.Add(b.SpillClimbM); r.ClimbBands[b.SpillClimbM <= 5f ? 0 : b.SpillClimbM <= 15f ? 1 : b.SpillClimbM <= 30f ? 2 : b.SpillClimbM <= 60f ? 3 : 4]++; if (b.HasAnyLake && !b.IsLake) r.LakeAnyOnly++; int hops = g.HopsToOcean(b.Id); if (hops > r.MaxHops) r.MaxHops = hops; } spill.Sort(); climb.Sort(); (r.SpillMinM, r.SpillP25M, r.SpillMedM, r.SpillP75M, r.SpillMaxM) = Quantiles(spill); (r.ClimbMinM, r.ClimbP25M, r.ClimbMedM, r.ClimbP75M, r.ClimbMaxM) = Quantiles(climb); foreach (float cap in caps) { bool[] ok = g.ConnectedAtCap(cap, out int connected); int lake = 0, dry = 0, direct = 0; connected = 0; for (int i = 0; i < g.Nodes.Count; i++) { if (!ok[i] || !g.Nodes[i].IsLand) continue; // land basins only — a seabed pit "chains" trivially connected++; if (g.Nodes[i].IsLake) lake++; else dry++; if (g.Nodes[i].Downstream == DownstreamKind.Ocean) direct++; } r.Cap[cap] = (connected, lake, dry, direct); } } private static (float, float, float, float, float) Quantiles(List sorted) { if (sorted.Count == 0) return (0, 0, 0, 0, 0); float Q(double q) => sorted[Math.Clamp((int)Math.Round(q * (sorted.Count - 1)), 0, sorted.Count - 1)]; return (sorted[0], Q(0.25), Q(0.5), Q(0.75), sorted[^1]); } /// FNV-1a over the raw float bits — "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; } private static string Kind(DownstreamKind k) => k switch { DownstreamKind.Ocean => "OCEAN", DownstreamKind.Basin => "BASIN", _ => "NONE", }; private static void WriteBasinCsv(string batchRoot, SeedResult r, float[] caps) { var g = r.Graph; int n = g.MapSize; var capOk = new Dictionary(); foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _); var sb = new StringBuilder(); sb.Append("id,class,area_px,inflow_px,is_lake,has_any_lake,lake_cells,lake_cells_significant,ocean_cells," + "floor_x,floor_y,floor_raw,spill_x,spill_y,spill_raw,spill_above_sea_m,depth_to_spill_m,spill_climb_m,spill_ties," + "spill_crosscheck_ok,spill_on_terrain,downstream,downstream_id,downstream_x,downstream_y,spill_path_cells,dir_walk_agrees,dir_walk_kind,dir_walk_id,hops_to_ocean"); foreach (float cap in caps) sb.Append($",chain_ok_cap{cap:F0}"); sb.AppendLine(); for (int i = 0; i < g.Nodes.Count; i++) { var b = g.Nodes[i]; sb.Append($"{b.Id},{(b.IsSeabed ? "seabed" : b.IsCoastal ? "coastal" : "inland")},{b.AreaPx},{b.InflowPx},{(b.IsLake ? "yes" : "no")},{(b.HasAnyLake ? "yes" : "no")},{b.LakeCells},{b.LakeCellsSignificant},{b.OceanCells}," + $"{b.FloorCell / n},{b.FloorCell % n},{b.FloorHeightRaw:R},{b.SpillCell / n},{b.SpillCell % n},{b.SpillHeightRaw:R}," + $"{b.SpillAboveSeaM:F2},{b.DepthToSpillM:F2},{b.SpillClimbM:F2},{b.SpillTies}," + $"{(b.SpillCrossCheckOk ? "yes" : "NO")},{(b.SpillOnTerrain ? "yes" : "NO")},{Kind(b.Downstream)},{b.DownstreamId}," + $"{b.DownstreamEntryCell / n},{b.DownstreamEntryCell % n},{b.SpillPath.Count},{(b.DirWalkAgrees ? "yes" : "NO")},{Kind(b.DirWalkKind)},{b.DirWalkId},{g.HopsToOcean(b.Id)}"); foreach (float cap in caps) sb.Append($",{(capOk[cap][i] ? "yes" : "no")}"); sb.AppendLine(); } WriteText(Path.Combine(batchRoot, $"basins_{r.Seed}.csv"), sb.ToString()); } private static void WriteLakeCsv(string batchRoot, SeedResult r) { var g = r.Graph; var sb = new StringBuilder(); sb.AppendLine("body,size_px,cells_in_basins,basins_touched,dominant_basin_id,dominant_cells,dominant_basin_is_lake,status"); foreach (var l in g.LakeBodies) { var b = l.DominantBasinId != 0 ? g.Of(l.DominantBasinId) : null; sb.AppendLine($"{l.Index},{l.SizePx},{l.CellsInBasins},{l.BasinsTouched},{l.DominantBasinId},{l.DominantCells}," + $"{(b == null ? "" : b.IsLake ? "yes" : "no")},{(l.Free ? "FREE" : l.Owned ? "owned" : "split")}"); } WriteText(Path.Combine(batchRoot, $"lakes_{r.Seed}.csv"), sb.ToString()); } private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean, bool[] isClassifyWater, Pass2Result p2, int n, float sea, bool skipRaw) { string dir = Path.Combine(batchRoot, $"{r.Seed}"); DirAccess.MakeDirRecursiveAbsolute(dir); var g = r.Graph; Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax); string caps = ""; foreach (var kv in r.Cap) caps += $"{kv.Key:F0}M:{kv.Value.all} "; BasinGraphRenderer.Plate(g, plan, baseImg, n, isOcean, isClassifyWater, $"SEED {r.Seed} - THE BASIN GRAPH: {g.LandNodes.Count} LAND BASINS, {g.LakeBasins} LAKE / {g.DryBasins} DRY (FLOOR {g.LakeMinPx} PX) [+{g.Seabed} SEABED PITS, OUTLINED ONLY]", $"SPILLS: {g.ToOcean} TO OCEAN / {g.ToBasin} INTO ANOTHER BASIN / {g.Closed} CLOSED. LONGEST CHAIN {r.MaxHops} HOPS. BASINS CHAINING TO THE OCEAN AT CAP {caps.Trim()}", $"SPILL HEIGHT ABOVE SEA: MEDIAN {r.SpillMedM:F0} M (RANGE {r.SpillMinM:F0}..{r.SpillMaxM:F0}). FLOOR-TO-SPILL CLIMB: MEDIAN {r.ClimbMedM:F0} M (RANGE {r.ClimbMinM:F0}..{r.ClimbMaxM:F0}). TASTE GATE - NOTHING ROUTED, NOTHING LOCKED") .SavePng(Path.Combine(dir, $"basin_graph_{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 lakeMinPx, float[] caps, DrainageAnalysis.Params def, bool skipRaw) { var sb = new StringBuilder(); int primary = seeds.Length > 0 ? seeds[0] : 0; string capHdr = string.Join(" | ", Array.ConvertAll(caps, c => $"chain→ocean @ {c:F0} m")); sb.AppendLine("# Batch 04 — the lake-basin layer: the basin graph (spill + lake-identity + downstream edge)"); sb.AppendLine(); sb.AppendLine("**⛔ TASTE GATE ON THE FOUNDATION. Nothing is routed, nothing is locked, nothing is graduated.** This is the"); sb.AppendLine("data layer the flow-through routing model (piece 2) will traverse; piece 2 is authored only once the spills"); sb.AppendLine("and the graph read right."); sb.AppendLine(); sb.AppendLine("**⛔ DATA LAYER ONLY. No height was mutated, no water was filled or created, `DrainageAnalysis` was not"); sb.AppendLine("edited** — asserted per seed by an FNV digest of both height fields taken before the layer was built and"); sb.AppendLine("after the plate was drawn."); sb.AppendLine(); sb.AppendLine("## 👉 The pick"); sb.AppendLine(); sb.AppendLine($"Open **`{primary}/basin_graph_{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("> **Do the spills (yellow rings) sit where water would actually overflow — the true low rim? Are the"); sb.AppendLine("> lake/dry labels right (blue = a significant classify lake sits in the basin, amber = dry sink)? And does"); sb.AppendLine("> \"who drains to whom\" (cyan arrow → ocean, white arrow → another basin, red = closed) look like a real"); sb.AppendLine("> drainage network?** If the spills are wrong, piece 2 must not be built on this."); sb.AppendLine(">"); sb.AppendLine("> Each spill is labelled `23M/7`: **23 m above the sea datum**, and **7 m of climb from the basin floor**"); sb.AppendLine("> to overtop — the second number is what a rim cap is judged against. Both read on the RENDER surface."); sb.AppendLine("> The black dot is the basin floor, with its `#id` (the id `BasinId` carries — sparse, as the analysis leaves it)."); sb.AppendLine(); sb.AppendLine("## ⭐ The graph, per seed"); sb.AppendLine(); sb.AppendLine($"| Seed | terminal basins | ⚠ seabed (excluded) | coastal | **land basins** | **lake** | dry | (sub-floor puddle only) | spill → ocean | → basin | closed | longest chain | {capHdr} |"); sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|"))); foreach (var r in rows) { var g = r.Graph; sb.Append($"| `{r.Seed}` | {g.Nodes.Count} | {g.Seabed} | {g.Coastal} | **{g.LandNodes.Count}** | **{g.LakeBasins}** | {g.DryBasins} | {r.LakeAnyOnly} | {g.ToOcean} | {g.ToBasin} | {g.Closed} | {r.MaxHops} hops |"); foreach (float cap in caps) { var c = r.Cap[cap]; sb.Append($" **{c.all}** ({c.lake} lake / {c.dry} dry) |"); } sb.AppendLine(); } sb.AppendLine(); sb.AppendLine("> ### ⚠⚠ SEABED PITS — half the analysis's \"terminal basins\" are not on land"); sb.AppendLine("> The priority-flood runs on the whole RENDER surface, ocean floor included, so a deep-enough, large-enough"); sb.AppendLine("> depression UNDER the classify sea qualifies as a terminal basin exactly like a land one. Every cell of such"); sb.AppendLine("> a basin is `OceanMask`, its cells are `D_NONE`, its inflow is 0 — it is hydrologically inert, and it is the"); sb.AppendLine("> D-046 seam (render vs classify) made visible. They are kept in the layer and the CSV (`class = seabed`),"); sb.AppendLine("> drawn as a faint outline only, and **excluded from every statistic on this page.** The `land basins` column is"); sb.AppendLine("> the graph; `coastal` basins (some ocean cells, some land) are counted as land and flagged."); sb.AppendLine(); sb.AppendLine("> **Reading the cap columns.** A basin \"chains to the ocean at cap C\" when every link from it to the sea — its"); sb.AppendLine("> own spill and every downstream basin's spill — climbs ≤ C m from that basin's floor (elevation clamped at sea,"); sb.AppendLine("> exactly as `RouteTo` clamps). This previews what the flow-through model will trade at a given cap; it decides"); sb.AppendLine("> nothing. Piece 2 routes; this only says how many basins *could* connect."); sb.AppendLine(); sb.AppendLine("## ⭐ Spill-height distributions (metres, render surface)"); sb.AppendLine(); sb.AppendLine("| Seed | spill above sea: min / p25 / median / p75 / max | floor→spill climb: min / p25 / median / p75 / max | climb bands ≤5 / 5–15 / 15–30 / 30–60 / >60 |"); sb.AppendLine("|---|---|---|---|"); foreach (var r in rows) sb.AppendLine($"| `{r.Seed}` | {r.SpillMinM:F1} / {r.SpillP25M:F1} / {r.SpillMedM:F1} / {r.SpillP75M:F1} / {r.SpillMaxM:F1} | " + $"{r.ClimbMinM:F1} / {r.ClimbP25M:F1} / {r.ClimbMedM:F1} / {r.ClimbP75M:F1} / {r.ClimbMaxM:F1} | {string.Join(" / ", r.ClimbBands)} |"); sb.AppendLine(); sb.AppendLine("## ✅ The invariants, per seed — the spill datum is exact, or it says so"); sb.AppendLine(); sb.AppendLine("| Seed | spill cross-check failures (fill-level vs rim-walk) | spill not on terrain | Dir-walk disagreements | seabed / coastal | render digest | classify digest |"); sb.AppendLine("|---|---|---|---|---|---|---|"); foreach (var r in rows) { var g = r.Graph; sb.AppendLine($"| `{r.Seed}` | {(g.SpillCrossCheckFailures == 0 ? "**0** ✅" : $"**{g.SpillCrossCheckFailures}** ⚠⚠")} | " + $"{(g.SpillNotOnTerrain == 0 ? "**0** ✅" : $"**{g.SpillNotOnTerrain}** ⚠⚠")} | " + $"{(g.DirWalkDisagreements == 0 ? "**0** ✅" : $"**{g.DirWalkDisagreements}** ⚠")} | {g.Seabed} / {g.Coastal} | `{r.RenderDigest:X16}` | `{r.ClassifyDigest:X16}` |"); } sb.AppendLine(); sb.AppendLine("*Cross-check: the basin's minimum on `FullFilled` is the spill + one ulp (the flood's own epsilon), so"); sb.AppendLine("`BitDecrement(min inside) == min over the rim` must hold exactly. \"On terrain\": `FullFilled == render` at the spill"); sb.AppendLine("cell — the rim was never raised by the flood. \"Dir-walk\": following `Plan.Dir` from the first cell past the spill"); sb.AppendLine("reaches the same node as the `FullFilled` descent. A basin holding OCEAN cells is a render depression under"); sb.AppendLine("classify-sea — the D-046 seam, counted rather than hidden.*"); sb.AppendLine(); sb.AppendLine("## ⭐ The reconciliation — does each significant heightmap lake sit in a terminal basin?"); sb.AppendLine(); sb.AppendLine("| Seed | significant bodies (≥ floor) | **owned** (≥ half inside one basin) | split across basins | **FREE** (in no basin) | non-ocean classify-water cells outside every basin |"); sb.AppendLine("|---|---|---|---|---|---|"); foreach (var r in rows) { var g = r.Graph; sb.AppendLine($"| `{r.Seed}` | {g.LakeBodies.Count} | **{g.LakeBodiesOwned}** | {g.LakeBodiesSplit} | **{g.LakeBodiesFree}** | {g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} ({100.0 * g.ClassifyWaterCellsOutsideBasins / Math.Max(1, g.ClassifyWaterCellsTotal):F1} %) |"); } sb.AppendLine(); sb.AppendLine("*A FREE body is a classify lake that is not a depression ≥ 2 m / 10,000 px on the RENDER surface (or filled through as a"); sb.AppendLine("pit) — heightmap water the hydrology never pooled into. On the plate these are the dark-teal patches with no tint. They are"); sb.AppendLine("exactly the \"free-floating heightmap lakes\" this layer exists to reconcile; per body detail in `lakes_.csv`.*"); sb.AppendLine(); foreach (var r in rows) { var g = r.Graph; int n = g.MapSize; sb.AppendLine($"### `{r.Seed}` — every LAND basin, largest first ({g.Seabed} seabed pits omitted; see the CSV)"); sb.AppendLine(); sb.Append("| id | area px | inflow px | lake? | lake cells | spill (x,y) | spill +m | climb m | → | hops |"); foreach (float cap in caps) sb.Append($" @{cap:F0} |"); sb.AppendLine(); sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|"))); var order = new List(g.LandNodes); order.Sort((a, b) => b.AreaPx.CompareTo(a.AreaPx)); var capOk = new Dictionary(); foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _); foreach (var b in order) { int idx = g.Nodes.IndexOf(b); string to = b.Downstream switch { DownstreamKind.Ocean => "**OCEAN**", DownstreamKind.Basin => $"#{b.DownstreamId}", _ => "⚠ closed", }; int hops = g.HopsToOcean(b.Id); sb.Append($"| #{b.Id}{(b.IsCoastal ? " ⚠coastal" : "")} | {b.AreaPx:N0} | {b.InflowPx:N0} | {(b.IsLake ? "**lake**" : b.HasAnyLake ? "puddle" : "dry")} | {b.LakeCells:N0} | " + $"({b.SpillCell / n},{b.SpillCell % n}) | {b.SpillAboveSeaM:F1} | {b.SpillClimbM:F1} | {to} | {(hops < 0 ? "—" : hops.ToString())} |"); foreach (float cap in caps) sb.Append(capOk[cap][idx] ? " ✅ |" : " — |"); sb.AppendLine(); } sb.AppendLine(); sb.AppendLine($"*Land {r.LandCells:N0}, endorheic {r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %) · " + $"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px ({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · " + $"graph {r.GraphSeconds:F2}s, plate {r.RenderSeconds:F1}s, seed total {r.Ms / 1000.0:F0}s · grayscale range {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*"); sb.AppendLine(); } sb.AppendLine("## The two \"lakes\" this layer reconciles — and the datum each is read on (D-046)"); sb.AppendLine(); sb.AppendLine("| Quantity | Surface | Why |"); sb.AppendLine("|---|---|---|"); sb.AppendLine("| spill cell, spill height, floor, climb | **RENDER** (`Plan.FullFilled`, the flood of the eroded render height) | where water GOES — the surface `RouteTo` routes on, so a climb here is the same number as the router's `RimClimbM` |"); sb.AppendLine("| downstream walk | **RENDER** (`FullFilled` descent) | the reference's provisional-route machinery, started at the spill |"); sb.AppendLine("| lake presence (`IsLake`, lake cells) | **CLASSIFY** (`classify < sea` and not `OceanMask`) | what is VISIBLY water — the surface the terminus tests use |"); sb.AppendLine("| the OCEAN terminus of a walk | **CLASSIFY** (`OceanMask`) | as routing: route on render, ocean on classify |"); sb.AppendLine(); sb.AppendLine("**No field compares a classify height to a render height.** The surfaces meet only as membership tests"); sb.AppendLine("(is this basin cell classify-water? is this walk cell ocean?) — the split the routing already lives by. No new seam."); sb.AppendLine(); sb.AppendLine("## What was run"); sb.AppendLine(); sb.AppendLine($"Chain + drainage analysis + the layer at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered."); sb.AppendLine($"`ISLA_LAKE_MIN_PX={lakeMinPx:N0}` (the significance floor — a knob); cap preview at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m."); sb.AppendLine(); sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused — the layer reads `BasinId`, `FullFilled`, `Dir`, `BasinInflow`); " + $"`EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} (they define which depressions ARE the nodes)."); sb.AppendLine(); sb.AppendLine("## Files"); sb.AppendLine(); sb.AppendLine("| File | What it is |"); sb.AppendLine("|---|---|"); sb.AppendLine("| `/basin_graph_.png` | the graph over the faint terrain: basins tinted lake/dry, spill rings labelled, arrows to the downstream node |"); sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |"); sb.AppendLine("| `basins_.csv` | every `BasinNode`: class (inland/coastal/seabed), area, inflow, lake cells, floor, spill (cell + raw + metres), climb, downstream kind/id/entry, invariants, hops, chain-ok per cap |"); sb.AppendLine("| `lakes_.csv` | every significant classify body: size, cells inside basins, dominant basin, owned / split / FREE |"); if (skipRaw) sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. |"); sb.AppendLine(); sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}."); sb.AppendLine(); sb.AppendLine("→ `XX_Human/output/rivers/04_lake_basin_layer.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; } 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; } } }