From 89e0f85c9f0185971c9c407221bf62cd9dbfbd59 Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 22 Aug 2026 20:51:51 -0400 Subject: [PATCH] =?UTF-8?q?chat2/12:=20drainage=20analysis=20(minimal-firs?= =?UTF-8?q?t)=20=E2=80=94=20the=20reference=20river=20plan=20ported,=20ana?= =?UTF-8?q?lysis=20only,=20on=20the=20eroded=20terrain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core/Scripts/DrainageAnalysis.cs is the reference's DrainageAnalysis ported verbatim: the Barnes priority-flood routing fill (8-connected, seeded from the four borders, index tiebreak, pit fills ONE ULP above the parent so every filled cell keeps a strictly descending path to its spill; the terrain heightmap itself is never written - the fill lives in its own array), terminal-basin qualification (depth >= 2 m AND area >= 10,000 px; the rest are pits filled through), D8 flow directions on the routing surface - FOR ANALYSIS ONLY, the reverted-as-carving landmine stated in the file - Kahn accumulation, the memoised destination walk crediting a terminal basin with TOTAL inflow, sea-reaching outlets ranked by drainage area with the outlet separation, main stems by max accumulation, mountain exits from the along-stem grade, lean tributaries, lean endorheic terminals, and the promoted giants (provisional routes computed as the reference did, not drawn - routing is a later task). WorldScale-denominated; Dir / Acc / Filled / FullFilled / BasinId / BasinInflow exposed so the caller can prove the invariants. "The sea" is the OCEAN body from the region layer: RegionLabeling.OceanMask - the 4-connected water component touching the border, on the CLASSIFY field (the water-side complement of the land contract). Enclosed lagoons, lake beds and island-fringe pockets are ordinary terrain to the router. DrainageTool (4 task-11 seeds at 8192, the eroded fields bit-identical to the 11 dumps) renders the log-scaled accumulation map and the promoted-candidates overlay (trunks cyan, endorheic giants orange, lean terminals red; nothing carved) and writes the accumulation .f32. Oracle, all passing: render AND classify fields bit-identical before/after the analysis (zero terrain cells written), no water added, full fill >= original everywhere with a non-ascending path to the border from every cell, ocean mask all below sea and on the border, dir/acc/candidates identical across two runs. Endorheic basins are the expected first-class output: on every seed the top giants out-drain the top trunks - the biggest drainages pool inland because erosion delivers the upland network only and cannot cross the flats. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7 --- Core/README.md | 1 + Core/Scripts/DrainageAnalysis.cs | 698 ++++++++++++++++++++++++++ Core/Scripts/DrainageAnalysis.cs.uid | 1 + Core/Scripts/RegionLabeling.cs | 44 ++ Tools/README.md | 2 + Tools/Scenes/DrainageTool.tscn | 6 + Tools/Scripts/DrainageRenderer.cs | 154 ++++++ Tools/Scripts/DrainageRenderer.cs.uid | 1 + Tools/Scripts/DrainageTool.cs | 410 +++++++++++++++ Tools/Scripts/DrainageTool.cs.uid | 1 + Tools/Scripts/ShadeRenderer.cs.uid | 1 + 11 files changed, 1319 insertions(+) create mode 100644 Core/Scripts/DrainageAnalysis.cs create mode 100644 Core/Scripts/DrainageAnalysis.cs.uid create mode 100644 Tools/Scenes/DrainageTool.tscn create mode 100644 Tools/Scripts/DrainageRenderer.cs create mode 100644 Tools/Scripts/DrainageRenderer.cs.uid create mode 100644 Tools/Scripts/DrainageTool.cs create mode 100644 Tools/Scripts/DrainageTool.cs.uid create mode 100644 Tools/Scripts/ShadeRenderer.cs.uid diff --git a/Core/README.md b/Core/README.md index aaa72c4..b69a535 100644 --- a/Core/README.md +++ b/Core/README.md @@ -38,6 +38,7 @@ resolution and the file-safety rails. Constants and contracts. | `Scripts/CurveAnchors.cs` | The OUTPUT anchors — the storm-ladder elevations each band lands at. | | `Scripts/TerrainDetailPass.cs` | Shelf micro-relief + the shelf-edge **knot warp**. Output-height only. | | `Scripts/HydraulicErosion.cs` | ⭐⭐ **Droplet (hydraulic) erosion** (chat2/11) — the reference's pass ported VERBATIM: four governors (count, lifetime, carve cap, deposit cap) on a net-displacement ledger proven on exit, the sea clamp (below-sea read-only both ways), the cone brush shared by erode and deposit, the crater exclusion (inert until the carve exists). Engine-free, own PCG32, `WorldScale`-denominated (no literal 251). Render-map only — the caller (`Tools/ErosionPass`) owns the split and the flood guard. | +| `Scripts/DrainageAnalysis.cs` | ⭐⭐ **Drainage analysis** (chat2/12) — the reference's river-PLAN pass ported verbatim: priority-flood routing fill (one ulp above the parent, terrain never written), **D8 flow directions FOR ANALYSIS ONLY** (D8 was reverted as a carving technique), Kahn accumulation, outlets ranked by drainage area, endorheic terminals credited TOTAL inflow, promoted giants. Engine-free, `WorldScale`-denominated. "The sea" = the ocean body from `RegionLabeling.OceanMask`. | | `Scripts/RegionLabeling.cs` | ⭐⭐ **The region-labeling layer** (chat2/07) — shared infrastructure. 8-connected land components on the CLASSIFY field; mainland = the centre component; per component id / size / centroid / hemisphere (by centroid) / isMainland. Pure, engine-free, C++-candidate; a **contract** downstream phases consume (islands first; biomes, placement, rivers, the crater later). The hemisphere convention lives here. | | `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. | | `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. | diff --git a/Core/Scripts/DrainageAnalysis.cs b/Core/Scripts/DrainageAnalysis.cs new file mode 100644 index 0000000..528ec21 --- /dev/null +++ b/Core/Scripts/DrainageAnalysis.cs @@ -0,0 +1,698 @@ +using System; +using System.Collections.Generic; + +namespace IslaApocalypse.Core +{ + /// + /// ⭐⭐ DRAINAGE ANALYSIS — THE FAITHFUL PORT (chat2/12). From the reference's + /// Tools/Scripts/DrainageAnalysis.cs at tag pre-rewrite-reference (ab78883), verbatim + /// in arithmetic and order (D-050). PURE ANALYSIS: it reads the ERODED render heightmap and produces a + /// river PLAN — it changes zero terrain and adds zero water. + /// + /// ═══ WHAT THE PORT CHANGES (and nothing else) ═══ + /// + /// • Namespace + location: IslaApocalypse.Core — engine-free, a C++ candidate. + /// • The yardstick: metres via (the same × 251f; no literal). + /// • and are EXPOSED (the reference kept the + /// routing surfaces local) so the caller can prove the routing-fill invariants on them. + /// + /// ═══ ⚠⚠ THE D8 LANDMINE ═══ + /// + /// D8 flow direction is CORRECT FOR ANALYSIS and is used here for exactly that. It was REVERTED as a + /// CARVING technique (the prototype's task 10 — straight, grid-aligned grooves; → `Design - Terrain - + /// D8 Incision Revert`). Use D8 to COMPUTE, never to CARVE. Nothing in this file writes terrain. + /// + /// ═══ ⚠ ENDORHEIC BASINS ARE EXPECTED, FIRST-CLASS OUTPUT ═══ + /// + /// This terrain's biggest drainages pool inland: erosion delivers the upland network only and cannot + /// cross the flats. A screen full of endorheic basins is the CORRECT result, not a bug. + /// + /// ═══ THE REFERENCE'S CLASS DOC (verbatim) ═══ + /// + /// Drainage-network promotion — C0b part 1 (terrain-water task 21). PURE ANALYSIS: + /// reads the ERODED render heightmap and produces a river PLAN — it changes zero + /// terrain and adds zero water. Standalone numeric (D-035 family; no Godot types). + /// + /// Pipeline, built on the task-03 priority-flood family: + /// 1. Priority-flood the eroded surface from the map border (Barnes heap+pit + /// variant, 8-connected, same as RunPriorityFloodDiagnostics) — but with a + /// one-ulp epsilon on pit fills, so every filled cell keeps a STRICTLY + /// descending path to its spill. This resolves the ~15,000 erosion pits + /// (task-20 finding) for ROUTING ONLY; the terrain itself is never modified. + /// 2. Depressions that are deep AND large enough (the endorheic dials) are NOT + /// filled through: their cells revert to original heights, so flow entering + /// them terminates at the basin minimum. Real closed drainage survives; + /// micro-pits route through. + /// 3. D8 flow directions on that routing surface. D8 was reverted as a CARVING + /// technique (task 10 — grid-aligned scratches in the terrain); using it to + /// COMPUTE where water flows is standard hydrology and leaves no mark. + /// 4. Flow accumulation by topological (Kahn) propagation — no sort needed. + /// 5. Promotion: outlets to the sea ranked by drainage area, top-N (separated) + /// become trunks; main stems traced upstream by max-accumulation; the + /// mountain-exit point found from the along-stem grade; LEAN tributaries and + /// LEAN endorheic terminals marked. + /// + /// The plan's lowland courses are provisional: erosion delivered the UPLAND + /// network only (task 18 §3), so below each mountain-exit the traced course is + /// "where the routing surface drains", not a designed river. Part 2 (task 22) + /// routes the lowland reach properly from the mountain-exit points — which is why + /// those points are this analysis's key output. + /// + public static class DrainageAnalysis + { + // Neighbour order is FIXED (it is the deterministic tiebreak). + private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 }; + private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 }; + private static readonly float[] DIST = { + 1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f }; + + public const sbyte D_NONE = -1, D_SEA = -2; + + public class Params + { + // Endorheic qualification: a depression this deep AND this large is a real + // closed basin and terminates flow; anything smaller is a pit, filled through. + public float EndorheicMinDepthM = 2.0f; + public int EndorheicMinAreaPx = 10000; + // Endorheic REPORTING is lean: only terminals with at least this much + // upstream drainage, at most MaxCount of them. + public int EndorheicMinInflowPx = 50000; + public int EndorheicMaxCount = 3; + + public int TrunkCount = 3; // ~3 sea-reaching trunks (developer) + public int GiantCount = 3; // 21b: top endorheic giants promoted + public int MinOutletSeparationPx = 400; // don't pick 3 mouths of one delta + + public int StemMinAccPx = 1000; // stem tracing stops below this + public int TributaryMinAccPx = 30000; // LEAN: a branch must drain this much + public int TributaryMaxPerTrunk = 4; // ...and only the top few are marked + + // Mountain-exit: furthest-downstream stem point where the upstream window + // still sustains this grade (m per px) over ExitWindowPx. + public float ExitGradeMin = 0.05f; + public int ExitWindowPx = 100; + + public float SeaLevel = 0.15f; // flat sea scalar (raw units) + } + + public class Stream + { + public List<(float x, float y)> Course = new(); // downstream-first + public long DrainageAreaPx; + public (float x, float y) Head; // upstream end + } + + public class Trunk : Stream + { + public (float x, float y) Outlet; // last land cell before sea + public (float x, float y) MountainExit; + public float MountainExitElevM; + public bool ExitFound; + public List Tributaries = new(); + } + + /// + /// A promoted endorheic giant (task 21b): one of the island's biggest drainage + /// systems, which pools inland because erosion could not cross the flats. + /// Kind "routed" carries a PROVISIONAL route across the flats to the ocean — + /// the path part 2 would carve, drawn for the gate, not water. Kind + /// "lake-ender" keeps its lake/lagoon terminal (real geography, developer's + /// call). Terminal is where the MAIN STEM actually pools (its sub-minimum), + /// which on a flat basin floor is more truthful than the basin's deepest cell. + /// ⚠ chat2/12 computes the provisional route as the reference did (it is analysis) but does NOT + /// promote or draw it — lowland routing is a later task. + /// + public class Giant : Stream + { + public (float x, float y) Terminal; + public (float x, float y) Spill; // where the basin overtops + public float BasinDepthM; + public long BasinAreaPx; + public string Kind = "routed"; // "routed" | "lake-ender" + public bool SouthernCandidate; + public bool TerminalInClassifyWater; + public List<(float x, float y)> ProvisionalRoute; // null for lake-enders + public bool RouteReachedOcean; + public (float x, float y) MountainExit; + public float MountainExitElevM; + public bool ExitFound; + public List Tributaries = new(); + } + + public class EndorheicTerminal + { + public (float x, float y) Terminal; // basin minimum + public long DrainageAreaPx; + public float BasinDepthM; + public long BasinAreaPx; + } + + public class Plan + { + public List Trunks = new(); + public List Endorheics = new(); + public List Giants = new(); // 21b: the promoted giants + public int TerminalBasinCount; // basins that qualified as sinks + public long PitsFilledCount; // depressions filled through + public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells; + public List<(float x, float y, long acc)> AllOutletsTop = new(); // top 12, pre-separation + public Params P; + + // ---- exposed by the port (the reference kept these local) ---- + /// D8 direction per cell (row-major x·n+y): 0..7, , . Analysis only. + public sbyte[] Dir; + /// Flow accumulation per cell (row-major); 0 on ocean. + public int[] Acc; + /// The routing surface after terminal basins reverted (row-major). + public float[] Filled; + /// The FULL priority-flood fill, before terminal reversion (row-major) — every cell drains to the border on it. + public float[] FullFilled; + /// Terminal-basin id per cell (row-major), 0 = none. + public int[] BasinId; + /// Per terminal basin id: total inflow (cells whose flow ends there). + public long[] BasinInflow; + } + + /// Row-major mask of THE OCEAN body — the only water that counts as "the sea" for + /// sea-reaching trunks (in v2: RegionLabeling.OceanMask, the classify field's border-connected water). + /// Below-sea cells that are NOT ocean (enclosed lagoons, below-datum lake beds, island-fringe waters) are + /// ordinary terrain to the router: as depressions they either qualify as terminal basins or fill and spill + /// onward to the true sea. + /// Row-major mask of ANY classify water: a giant whose main stem pools inside + /// classify water is a natural lake-ender; one pooling on dry ground is a route-to-sea candidate. + /// Southernmost-town position (or -1 for none): the giant whose terminal lies closest is + /// flagged the SOUTHERN CANDIDATE and always routed provisionally, per the 21b design — shown, not forced. + public static Plan Run(float[,] height, int mapSize, bool[] isOcean, + bool[] isClassifyWater, float southX, float southY, Params p) + { + int n = mapSize; + int total = n * n; + var plan = new Plan { P = p }; + + // 1-D row-major copies (idx = x * n + y), same convention as the task-03 pass. + float[] original = new float[total]; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + original[x * n + y] = height[x, y]; + + float[] plan_fullFilled = null; // set inside step 2, used by 21b routing + + // --- 1. Priority-flood with one-ulp epsilon (routing surface only) --- + float[] filled = (float[])original.Clone(); + { + bool[] visited = new bool[total]; + var heap = new PriorityQueue(); + var pit = new Queue(); + void Seed(int idx) + { + if (visited[idx]) return; + visited[idx] = true; + heap.Enqueue(idx, (filled[idx], idx)); // idx tiebreak => deterministic + } + for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); } + for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); } + + while (heap.Count > 0 || pit.Count > 0) + { + int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue(); + float fc = filled[c]; + int cx = c / n, cy = c % n; + for (int k = 0; k < 8; k++) + { + int nx = cx + DX[k], ny = cy + DY[k]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (visited[ni]) continue; + visited[ni] = true; + if (filled[ni] <= fc) + { + // One ulp above the parent: strictly descending back out, so + // D8 never meets an exact flat inside a filled pit. + filled[ni] = MathF.BitIncrement(fc); + pit.Enqueue(ni); + } + else heap.Enqueue(ni, (filled[ni], ni)); + } + } + } + + // --- 2. Depression components; big+deep ones become terminal sinks --- + // Components of (filled > original), 8-connected — the pools. Qualifying + // pools revert to ORIGINAL height so flow terminates at their minimum. + int[] basinId = new int[total]; // 0 = not in a pool + var basinDepthM = new List { 0f }; + var basinAreaPx = new List { 0L }; + var basinMinCell = new List { -1 }; + { + var stack = new Stack(); + int nextId = 1; + for (int i = 0; i < total; i++) + { + if (basinId[i] != 0 || filled[i] <= original[i]) continue; + int id = nextId++; + long area = 0; float depth = 0f; int minCell = i; float minH = original[i]; + stack.Push(i); basinId[i] = id; + while (stack.Count > 0) + { + int c = stack.Pop(); + area++; + float d = WorldScale.MetresFromRaw(filled[c] - original[c]); + if (d > depth) depth = d; + if (original[c] < minH) { minH = original[c]; minCell = c; } + int cx = c / n, cy = c % n; + for (int k = 0; k < 8; k++) + { + int nx = cx + DX[k], ny = cy + DY[k]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (basinId[ni] == 0 && filled[ni] > original[ni]) + { basinId[ni] = id; stack.Push(ni); } + } + } + basinDepthM.Add(depth); basinAreaPx.Add(area); basinMinCell.Add(minCell); + } + + // 21b: the FULL fill (before terminal reversion) is the provisional- + // routing surface — on it, every basin overtops at its spill and drains + // to the border, which is exactly "where the water would continue". + plan_fullFilled = (float[])filled.Clone(); + + bool[] terminal = new bool[nextId]; + for (int id = 1; id < nextId; id++) + { + if (basinDepthM[id] >= p.EndorheicMinDepthM && basinAreaPx[id] >= p.EndorheicMinAreaPx) + { terminal[id] = true; plan.TerminalBasinCount++; } + else plan.PitsFilledCount++; + } + // Revert terminal pools to the real surface; re-tag basinId to keep only + // terminal pools (routing needs to know "am I in a terminal basin"). + for (int i = 0; i < total; i++) + { + if (basinId[i] == 0) continue; + if (terminal[basinId[i]]) filled[i] = original[i]; + else basinId[i] = 0; + } + } + + // --- 3. D8 flow directions on the routing surface --- + // dir[i] = 0..7 neighbour, SEA (into a below-sea cell), or NONE (sink). + sbyte[] dir = new sbyte[total]; + bool IsSea(int idx) => isOcean[idx]; + for (int i = 0; i < total; i++) + { + if (IsSea(i)) { dir[i] = D_NONE; continue; } + int cx = i / n, cy = i % n; + float best = 0f; int bestK = -1; bool bestIsSea = false; + for (int k = 0; k < 8; k++) + { + int nx = cx + DX[k], ny = cy + DY[k]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + float drop = (filled[i] - filled[ni]) / DIST[k]; + if (drop > best) { best = drop; bestK = k; bestIsSea = IsSea(ni); } + } + dir[i] = bestK < 0 ? D_NONE : (bestIsSea ? D_SEA : (sbyte)bestK); + } + + // --- 4. Flow accumulation (Kahn topological propagation) --- + int Target(int i) + { + if (dir[i] < 0) return -1; + int cx = i / n, cy = i % n; + return (cx + DX[dir[i]]) * n + (cy + DY[dir[i]]); + } + int[] acc = new int[total]; + { + byte[] indeg = new byte[total]; + for (int i = 0; i < total; i++) + if (dir[i] >= 0) indeg[Target(i)]++; + var q = new Queue(); + for (int i = 0; i < total; i++) + { + if (IsSea(i)) continue; + acc[i] = 1; + if (indeg[i] == 0) q.Enqueue(i); + } + while (q.Count > 0) + { + int c = q.Dequeue(); + if (dir[c] < 0) continue; + int t = Target(c); + acc[t] += acc[c]; + if (--indeg[t] == 0 && !IsSea(t)) q.Enqueue(t); + } + } + + // Bookkeeping: where does each cell's flow END — the sea, WHICH terminal + // basin, or stuck? Memoised downstream walk. The per-basin totals matter: + // crediting a terminal basin only with acc at its deepest cell undercounts + // badly when the basin floor is flat (a lagoon bed scatters inflow across + // many sub-minima — measured: a 500k-px lagoon system reported under 50k). + long[] basinInflow = new long[basinMinCell.Count]; + int[] dest = new int[total]; // 0 unknown, -1 sea, -2 stuck, >0 basin id + { + var path = new List(4096); + for (int i = 0; i < total; i++) + { + if (IsSea(i) || dest[i] != 0) continue; + int c = i; path.Clear(); + int result; + while (true) + { + if (dest[c] != 0) { result = dest[c]; break; } + path.Add(c); + if (dir[c] == D_SEA) { result = -1; break; } + if (dir[c] == D_NONE) { result = basinId[c] != 0 ? basinId[c] : -2; break; } + c = Target(c); + } + foreach (int pc in path) dest[pc] = result; + } + for (int i = 0; i < total; i++) + { + if (IsSea(i)) continue; + plan.LandCells++; + if (dest[i] == -1) plan.SeaReachingCells++; + else if (dest[i] > 0) { plan.EndorheicCells++; basinInflow[dest[i]]++; } + else plan.UnroutedCells++; + } + } + + // --- 5a. Outlets: land cells whose flow enters the sea, ranked by acc --- + var outlets = new List<(int cell, long acc)>(); + for (int i = 0; i < total; i++) + if (dir[i] == D_SEA) outlets.Add((i, acc[i])); + outlets.Sort((a, b) => b.acc.CompareTo(a.acc)); + + foreach (var (cell, a) in outlets.GetRange(0, Math.Min(12, outlets.Count))) + plan.AllOutletsTop.Add((cell / n, cell % n, a)); + + // Greedy top-N with separation, so three mouths of one delta can't take + // all three trunk slots. + var picked = new List(); + foreach (var (cell, _) in outlets) + { + if (picked.Count >= p.TrunkCount) break; + int cx = cell / n, cy = cell % n; + bool far = true; + foreach (int pcell in picked) + { + float ddx = cx - pcell / n, ddy = cy - pcell % n; + if (ddx * ddx + ddy * ddy < (float)p.MinOutletSeparationPx * p.MinOutletSeparationPx) + { far = false; break; } + } + if (far) picked.Add(cell); + } + + // upstream max-acc walk shared by trunks and tributaries + List TraceStem(int fromCell, int minAcc) + { + var stem = new List { fromCell }; + int c = fromCell; + while (true) + { + int cx = c / n, cy = c % n; + int bestN = -1; long bestA = minAcc - 1; + for (int k = 0; k < 8; k++) + { + int nx = cx + DX[k], ny = cy + DY[k]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (dir[ni] >= 0 && Target(ni) == c && acc[ni] > bestA) + { bestA = acc[ni]; bestN = ni; } + } + if (bestN < 0) break; + stem.Add(bestN); + c = bestN; + } + return stem; + } + + List<(float x, float y)> Decimate(List cells, int step = 4) + { + var pts = new List<(float, float)>(); + for (int i = 0; i < cells.Count; i += step) + pts.Add((cells[i] / n, cells[i] % n)); + if ((cells.Count - 1) % step != 0) + pts.Add((cells[^1] / n, cells[^1] % n)); + return pts; + } + + // --- 5b. Trunks: stems, mountain exits, LEAN tributaries --- + foreach (int outletCell in picked) + { + var t = new Trunk + { + Outlet = (outletCell / n, outletCell % n), + DrainageAreaPx = acc[outletCell] + }; + var stem = TraceStem(outletCell, p.StemMinAccPx); + t.Course = Decimate(stem); + t.Head = (stem[^1] / n, stem[^1] % n); + + // Mountain-exit: walk the stem downstream-first; the exit is the + // furthest-DOWNSTREAM point whose upstream window still sustains the + // grade — i.e. where the mountains hand the river to the flats. + // Elevation truth is the ORIGINAL eroded surface, not the fill. + int w = p.ExitWindowPx; + for (int i = 0; i + w < stem.Count; i++) + { + float rise = WorldScale.MetresFromRaw(original[stem[i + w]] - original[stem[i]]); + if (rise / w >= p.ExitGradeMin) + { + t.ExitFound = true; + t.MountainExit = (stem[i] / n, stem[i] % n); + t.MountainExitElevM = WorldScale.MetresFromRaw(original[stem[i]]); + break; + } + } + + // LEAN tributaries: junction branches off the stem with enough drainage, + // top few by accumulation. + var stemSet = new HashSet(stem); + var cands = new List<(int cell, long acc)>(); + foreach (int sc in stem) + { + int cx = sc / n, cy = sc % n; + for (int k = 0; k < 8; k++) + { + int nx = cx + DX[k], ny = cy + DY[k]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (stemSet.Contains(ni)) continue; + if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx) + cands.Add((ni, acc[ni])); + } + } + cands.Sort((a, b) => b.acc.CompareTo(a.acc)); + // Dedup: two inflow neighbours at adjacent stem cells are one confluence, + // not two tributaries — keep only junctions ≥ 30 px apart. + var taken = new List(); + foreach (var (cell, a) in cands) + { + if (taken.Count >= p.TributaryMaxPerTrunk) break; + int cx2 = cell / n, cy2 = cell % n; + bool dup = false; + foreach (int tc in taken) + { + float ddx = cx2 - tc / n, ddy = cy2 - tc % n; + if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; } + } + if (!dup) taken.Add(cell); + } + foreach (int cell in taken) + { + long a = acc[cell]; + var trib = new Stream { DrainageAreaPx = a }; + var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(a / 20))); + trib.Course = Decimate(ts); + trib.Head = (ts[^1] / n, ts[^1] % n); + t.Tributaries.Add(trib); + } + plan.Trunks.Add(t); + } + + // --- 5c. LEAN endorheic terminals: terminal basins ranked by TOTAL inflow --- + { + var terms = new List<(int id, long inflow)>(); + for (int id = 1; id < basinMinCell.Count; id++) + { + int mc = basinMinCell[id]; + if (mc < 0 || basinId[mc] != id) continue; // not a terminal basin + if (basinInflow[id] >= p.EndorheicMinInflowPx) terms.Add((id, basinInflow[id])); + } + terms.Sort((a, b) => b.inflow.CompareTo(a.inflow)); + foreach (var (id, inflow) in terms.GetRange(0, Math.Min(p.EndorheicMaxCount, terms.Count))) + { + int mc = basinMinCell[id]; + plan.Endorheics.Add(new EndorheicTerminal + { + Terminal = (mc / n, mc % n), + DrainageAreaPx = inflow, + BasinDepthM = basinDepthM[id], + BasinAreaPx = basinAreaPx[id] + }); + } + } + + // --- 5d. The promoted GIANTS (21b): mixed set, provisional routes --- + // Top GiantCount terminal basins by TOTAL inflow. Their upland stems are the + // island's real big rivers; whether each continues to the sea is the gate's + // decision, previewed here. + { + var giantsRanked = new List<(int id, long inflow)>(); + for (int id = 1; id < basinMinCell.Count; id++) + { + int mc = basinMinCell[id]; + if (mc < 0 || basinId[mc] != id) continue; + if (basinInflow[id] >= p.EndorheicMinInflowPx) giantsRanked.Add((id, basinInflow[id])); + } + giantsRanked.Sort((a, b) => b.inflow.CompareTo(a.inflow)); + + // Does a terminal basin HOLD classify water? The lake-ender test must look + // at the whole pool, not the stem's single pooling cell — a stem can pool on + // dry ground a few hundred px short of its lagoon and still be a lagoon river. + bool[] basinHasLake = new bool[basinMinCell.Count]; + for (int i = 0; i < total; i++) + if (basinId[i] != 0 && isClassifyWater[i] && !isOcean[i]) + basinHasLake[basinId[i]] = true; + + // The main stem's ENTRY into the basin: the highest-accumulation cell + // whose flow terminates in this basin. On a flat basin floor the deepest + // cell sees only local trickles (the task-21 lesson), so the stem is + // anchored on the strongest feeder instead. + var bestEntry = new Dictionary(); + for (int i = 0; i < total; i++) + { + if (dest[i] <= 0) continue; + if (!bestEntry.TryGetValue(dest[i], out int cur) || acc[i] > acc[cur]) + bestEntry[dest[i]] = i; + } + + // The giant whose pooling point sits closest to the southernmost town is + // the SOUTHERN CANDIDATE — always routed provisionally (shown, not forced). + int southernPick = -1; + if (southX >= 0f) + { + float bestD = float.MaxValue; + foreach (var (id, _) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count))) + { + int mc = basinMinCell[id]; + float ddx = mc / n - southX, ddy = mc % n - southY; + float d2 = ddx * ddx + ddy * ddy; + if (d2 < bestD) { bestD = d2; southernPick = id; } + } + } + + foreach (var (id, inflow) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count))) + { + var g = new Giant { DrainageAreaPx = inflow, BasinDepthM = basinDepthM[id], BasinAreaPx = basinAreaPx[id] }; + if (!bestEntry.TryGetValue(id, out int entry)) entry = basinMinCell[id]; + + // Downstream from the strongest feeder to where it actually pools… + int t2 = entry; + var down = new List { t2 }; + while (dir[t2] >= 0) { t2 = Target(t2); down.Add(t2); } + g.Terminal = (t2 / n, t2 % n); + // …then the full main stem, traced upstream from that pooling point. + var stem = TraceStem(t2, p.StemMinAccPx); + g.Course = Decimate(stem); + g.Head = (stem[^1] / n, stem[^1] % n); + g.TerminalInClassifyWater = isClassifyWater[t2]; + + for (int i = 0; i + p.ExitWindowPx < stem.Count; i++) + { + float rise = WorldScale.MetresFromRaw(original[stem[i + p.ExitWindowPx]] - original[stem[i]]); + if (rise / p.ExitWindowPx >= p.ExitGradeMin) + { + g.ExitFound = true; + g.MountainExit = (stem[i] / n, stem[i] % n); + g.MountainExitElevM = WorldScale.MetresFromRaw(original[stem[i]]); + break; + } + } + + // Lean tributaries on the giant's stem, same junction rule as trunks. + var stemSet = new HashSet(stem); + var cands = new List<(int cell, long acc)>(); + foreach (int sc in stem) + { + int cx = sc / n, cy = sc % n; + for (int k = 0; k < 8; k++) + { + int nx = cx + DX[k], ny = cy + DY[k]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (stemSet.Contains(ni)) continue; + if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx) + cands.Add((ni, acc[ni])); + } + } + cands.Sort((a, b) => b.acc.CompareTo(a.acc)); + var takenT = new List(); + foreach (var (cell, _) in cands) + { + if (takenT.Count >= p.TributaryMaxPerTrunk) break; + int cx2 = cell / n, cy2 = cell % n; + bool dup = false; + foreach (int tc in takenT) + { + float ddx = cx2 - tc / n, ddy = cy2 - tc % n; + if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; } + } + if (!dup) takenT.Add(cell); + } + foreach (int cell in takenT) + { + var trib = new Stream { DrainageAreaPx = acc[cell] }; + var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(acc[cell] / 20))); + trib.Course = Decimate(ts); + trib.Head = (ts[^1] / n, ts[^1] % n); + g.Tributaries.Add(trib); + } + + // Kind: the terminal BASIN holds a classify lake → natural lake-ender; + // dry pan → route to sea; the southern candidate is always routed. + g.SouthernCandidate = id == southernPick; + g.TerminalInClassifyWater = g.TerminalInClassifyWater || basinHasLake[id]; + g.Kind = (basinHasLake[id] && !g.SouthernCandidate) ? "lake-ender" : "routed"; + + // PROVISIONAL route (routed giants): walk steepest descent on the FULL + // fill from the pooling point — the basin overtops at its spill and + // the walk continues along the terrain's own drainage to the ocean. + // DRAWN, not carved; part 2 carves along a route like this one. + // (chat2/12: computed as the reference did; not drawn, not promoted — routing is later.) + if (g.Kind == "routed") + { + var route = new List(); + int c = t2; + bool spillRecorded = false; + for (int guard = 0; guard < 4 * n; guard++) + { + route.Add(c); + if (isOcean[c]) { g.RouteReachedOcean = true; break; } + if (!spillRecorded && basinId[c] != id) + { g.Spill = (c / n, c % n); spillRecorded = true; } + int cx = c / n, cy = c % n; + float best = float.MaxValue; int bestN = -1; + for (int k = 0; k < 8; k++) + { + int nx = cx + DX[k], ny = cy + DY[k]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (plan_fullFilled[ni] < best) { best = plan_fullFilled[ni]; bestN = ni; } + } + if (bestN < 0 || plan_fullFilled[bestN] >= plan_fullFilled[c]) break; // stuck (report via flag) + c = bestN; + } + g.ProvisionalRoute = Decimate(route); + } + plan.Giants.Add(g); + } + } + + plan.Dir = dir; plan.Acc = acc; plan.Filled = filled; plan.FullFilled = plan_fullFilled; + plan.BasinId = basinId; plan.BasinInflow = basinInflow; + return plan; + } + } +} diff --git a/Core/Scripts/DrainageAnalysis.cs.uid b/Core/Scripts/DrainageAnalysis.cs.uid new file mode 100644 index 0000000..645e1ca --- /dev/null +++ b/Core/Scripts/DrainageAnalysis.cs.uid @@ -0,0 +1 @@ +uid://c6yfvp8p5br07 diff --git a/Core/Scripts/RegionLabeling.cs b/Core/Scripts/RegionLabeling.cs index 15467f9..458c15a 100644 --- a/Core/Scripts/RegionLabeling.cs +++ b/Core/Scripts/RegionLabeling.cs @@ -214,6 +214,50 @@ namespace IslaApocalypse.Core : bin < HistogramEdges.Length ? $"{HistogramEdges[bin - 1]}–{HistogramEdges[bin] - 1}" : $"≥{HistogramEdges[^1]}"; + // ═══ chat2/12 — THE OCEAN IDENTITY (the water-side complement of the land contract) ═══ + // + // Water is 4-CONNECTED (the deliberate complement of land's 8 — a diagonal isthmus joins land and + // separates the water either side). THE OCEAN = the 4-connected water component that touches the + // map border (the Trench guarantees the border is water, so the corner is a safe seed). Every + // other below-sea cell — enclosed lagoons, lake beds, island-fringe pockets — is NOT ocean: to the + // drainage router it is ordinary terrain (a terminal basin or a fill-and-spill), and to a + // "sea-reaching" test it does not count as the sea. Computed on the CLASSIFY field (authoritative). + + /// + /// The ocean mask, row-major (x·n+y): true for every below-sea cell 4-connected to the map + /// border. Pure: reads , writes nothing. + /// + public static bool[] OceanMask(float[,] classify, int mapSize, float sea, out long oceanCells, out long enclosedWaterCells) + { + int n = mapSize; + var ocean = new bool[n * n]; + var q = new Queue(); + void Seed(int x, int y) { if (classify[x, y] < sea && !ocean[x * n + y]) { ocean[x * n + y] = true; q.Enqueue(x * n + y); } } + for (int x = 0; x < n; x++) { Seed(x, 0); Seed(x, n - 1); } + for (int y = 0; y < n; y++) { Seed(0, y); Seed(n - 1, y); } + int[] dx4 = { -1, 1, 0, 0 }, dy4 = { 0, 0, -1, 1 }; + while (q.Count > 0) + { + int c = q.Dequeue(); int cx = c / n, cy = c % n; + for (int k = 0; k < 4; k++) + { + int nx = cx + dx4[k], ny = cy + dy4[k]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (ocean[ni] || classify[nx, ny] >= sea) continue; + ocean[ni] = true; q.Enqueue(ni); + } + } + oceanCells = 0; enclosedWaterCells = 0; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + { + if (classify[x, y] >= sea) continue; + if (ocean[x * n + y]) oceanCells++; else enclosedWaterCells++; + } + return ocean; + } + /// Island counts per hemisphere (non-mainland components, by centroid). public static (int north, int south) IslandsByHemisphere(RegionLabels labels) { diff --git a/Tools/README.md b/Tools/README.md index fc8c5a6..ae92b7a 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -46,6 +46,8 @@ constants, carried over verbatim — not re-derived from a design summary** (→ | `Scripts/FragGalleryTool.cs` + `Scenes/FragGalleryTool.tscn` | The chat2/10 gallery — render-only: 09's `frag_4` frozen across 2 anchors + 6 fresh seeds at 8192, with the count/size table | | `Scripts/ErosionPass.cs` | ⭐ **Pass 2b** (chat2/11) — the erosion caller: render field only (copied if aliased), governors clamped as the reference's ConfigManager did, the crater exclusion passed through INERT, and the **flood guard** (render water pixels before/after; any change throws) | | `Scripts/ErosionTool.cs` + `Scenes/ErosionTool.tscn` | The chat2/11 batch — 4 gallery seeds × erosion off/on at 8192, the mid-slope crop, the erosion stats table; also `TerrainShapeV1` — the locked shape's values pinned once | +| `Scripts/DrainageRenderer.cs` | The drainage maps (chat2/12): log-scaled accumulation; the promoted-candidates overlay (trunks cyan, endorheic giants orange, lean terminals red) | +| `Scripts/DrainageTool.cs` + `Scenes/DrainageTool.tscn` | The chat2/12 batch — `DrainageAnalysis` on the eroded 8192 fields of 4 task-11 seeds, analysis-only oracle (terrain bit-identical, no water, fill invariants, determinism, ocean from the region layer) | | `Scripts/OffshoreIslandsTool.cs` + `Scenes/OffshoreIslandsTool.tscn` | The offshore batch — chat2/06: 4 plates + the count table + the diagnosis (the chat2/05 version is at `3b96e06`) | | `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** | | `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles | diff --git a/Tools/Scenes/DrainageTool.tscn b/Tools/Scenes/DrainageTool.tscn new file mode 100644 index 0000000..040b32b --- /dev/null +++ b/Tools/Scenes/DrainageTool.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://cdrainage12isla"] + +[ext_resource type="Script" path="res://Tools/Scripts/DrainageTool.cs" id="1_drt"] + +[node name="DrainageTool" type="Node"] +script = ExtResource("1_drt") diff --git a/Tools/Scripts/DrainageRenderer.cs b/Tools/Scripts/DrainageRenderer.cs new file mode 100644 index 0000000..d117d4f --- /dev/null +++ b/Tools/Scripts/DrainageRenderer.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// THE DRAINAGE MAPS (chat2/12) — presentation only, for eyeballing that the flow is sane: + /// + /// • the LOG-SCALED ACCUMULATION map — drainage spans orders of magnitude, so log(1+acc) over land; + /// the dendritic uplands and the trunks read as bright channels on dark hillslopes; the ocean is a + /// flat dark blue and enclosed (non-ocean) water a dark teal, so the ocean identity is visible too; + /// • the PROMOTED-CANDIDATES overlay — a faint grey terrain, the sea-reaching trunks in cyan (outlet + /// square, mountain-exit white ring, lean tributaries thin), the endorheic giants in orange (pooling + /// terminal disc, lean tributaries thin), the lean endorheic terminals as red rings. Provisional + /// routes are NOT drawn (routing is a later task). Nothing here touches data. + /// + public static class DrainageRenderer + { + private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f); + private static readonly Color Enclosed = new(0.060f, 0.220f, 0.230f); + private static readonly Color Trunk = new(0.250f, 0.900f, 1.000f); + private static readonly Color Giant = new(1.000f, 0.600f, 0.150f); + private static readonly Color Endo = new(1.000f, 0.250f, 0.250f); + private static readonly Color Exit = new(1.000f, 1.000f, 1.000f); + private static readonly Color Ink = new(0.941f, 0.949f, 0.961f); + + /// log(1 + acc) / log(1 + max) over land; ocean / enclosed water flat. + public static Image Accumulation(int[] acc, bool[] isOcean, float[,] render, int n, float sea) + { + long max = 1; + for (int i = 0; i < acc.Length; i++) if (acc[i] > max) max = acc[i]; + double lmax = Math.Log(1.0 + max); + var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8); + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + { + int i = x * n + y; + if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; } + if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; } + float v = (float)(Math.Log(1.0 + acc[i]) / lmax); + // a dark-to-bright ramp with a cool tint in the channels + float r = 0.06f + 0.94f * v * v, g = 0.08f + 0.92f * v, b = 0.12f + 0.88f * MathF.Sqrt(v); + img.SetPixel(x, y, new Color(MathF.Min(1f, r), MathF.Min(1f, g), MathF.Min(1f, b))); + } + return img; + } + + /// The candidates over a faint terrain. + public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title) + { + var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8); + float span = MathF.Max(1e-6f, hMax - sea); + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + { + int i = x * n + y; + if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; } + if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; } + float t = MathF.Min(1f, (render[x, y] - sea) / span); + float g = 0.30f + 0.45f * MathF.Sqrt(t); + img.SetPixel(x, y, new Color(g, g, g * 0.96f)); + } + + int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10; + foreach (var g in plan.Giants) + { + foreach (var tr in g.Tributaries) Polyline(img, tr.Course, n, Giant, thin); + Polyline(img, g.Course, n, Giant, thick); + Disc(img, (int)g.Terminal.x, (int)g.Terminal.y, mark, n, Giant); + Ring(img, (int)g.Terminal.x, (int)g.Terminal.y, mark + 8, n, Ink, 3); + if (g.ExitFound) Ring(img, (int)g.MountainExit.x, (int)g.MountainExit.y, mark, n, Exit, 4); + } + foreach (var t in plan.Trunks) + { + foreach (var tr in t.Tributaries) Polyline(img, tr.Course, n, Trunk, thin); + Polyline(img, t.Course, n, Trunk, thick); + Square(img, (int)t.Outlet.x, (int)t.Outlet.y, mark, n, Trunk); + if (t.ExitFound) Ring(img, (int)t.MountainExit.x, (int)t.MountainExit.y, mark, n, Exit, 4); + } + foreach (var e in plan.Endorheics) + Ring(img, (int)e.Terminal.x, (int)e.Terminal.y, mark + 4, n, Endo, 4); + + int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6; + TinyFont.Draw(img, title, 12, 12, s, Ink); + TinyFont.Draw(img, $"CYAN: SEA-REACHING TRUNKS ({plan.Trunks.Count}) - SQUARE = OUTLET WHITE RING = MOUNTAIN EXIT", 12, 12 + lh, s, Ink); + TinyFont.Draw(img, $"ORANGE: ENDORHEIC GIANTS ({plan.Giants.Count}) - DISC = POOLING TERMINAL (EXPECTED, NOT AN ERROR)", 12, 12 + lh * 2, s, Ink); + TinyFont.Draw(img, $"RED RING: LEAN ENDORHEIC TERMINALS ({plan.Endorheics.Count}) THIN LINES: LEAN TRIBUTARIES NOTHING CARVED - ANALYSIS ONLY", 12, 12 + lh * 3, s, Ink); + return img; + } + + private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick) + { + for (int i = 1; i < pts.Count; i++) + Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick); + } + + private static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick) + { + int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1; + int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1; + int err = dx + dy; int r = thick / 2; + int guard = 0; + while (true) + { + for (int ox = -r; ox <= r; ox++) + for (int oy = -r; oy <= r; oy++) + { + int px = x0 + ox, py = y0 + oy; + if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c); + } + if (x0 == x1 && y0 == y1) break; + if (++guard > 4 * n) break; + int e2 = 2 * err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + } + } + + private static void Disc(Image img, int cx, int cy, int r, int n, Color c) + { + for (int ox = -r; ox <= r; ox++) + for (int oy = -r; oy <= r; oy++) + { + if (ox * ox + oy * oy > r * r) continue; + int px = cx + ox, py = cy + oy; + if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c); + } + } + + private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w) + { + for (int ox = -r; ox <= r; ox++) + for (int oy = -r; oy <= r; oy++) + { + int d2 = ox * ox + oy * oy; + if (d2 > r * r || d2 < (r - w) * (r - w)) continue; + int px = cx + ox, py = cy + oy; + if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c); + } + } + + private static void Square(Image img, int cx, int cy, int r, int n, Color c) + { + for (int ox = -r; ox <= r; ox++) + for (int oy = -r; oy <= r; oy++) + { + int px = cx + ox, py = cy + oy; + if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c); + } + } + } +} diff --git a/Tools/Scripts/DrainageRenderer.cs.uid b/Tools/Scripts/DrainageRenderer.cs.uid new file mode 100644 index 0000000..137b607 --- /dev/null +++ b/Tools/Scripts/DrainageRenderer.cs.uid @@ -0,0 +1 @@ +uid://dp11721l40ixx diff --git a/Tools/Scripts/DrainageTool.cs b/Tools/Scripts/DrainageTool.cs new file mode 100644 index 0000000..fceb5d9 --- /dev/null +++ b/Tools/Scripts/DrainageTool.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// ⭐ THE DRAINAGE-ANALYSIS BATCH (chat2/12) — minimal-first: is the flow sane before rivers are built + /// on it? Runs (pure analysis) on the ERODED render field of the locked + /// shape for 4 seeds from the task-11 batch, renders the log-accumulation map + the promoted-candidates + /// overlay, writes the accumulation as .f32, and proves: terrain bit-identical before/after (nothing + /// carved), no water added, the routing-fill invariants, determinism, ocean identity from the region + /// layer. ⚠ D8 is used to COMPUTE where water flows — never to carve. + /// + /// ═══ RUNNING IT ═══ + /// + /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \ + /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/DrainageTool.tscn + /// + /// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR + /// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048) + /// ISLA_SEEDS (default the 4 task-11 seeds) + /// ISLA_SKIP_T11_CHECK=1 skip the bit-identity against the task-11 erosion_on dumps + /// + public partial class DrainageTool : Node + { + private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 17320508 }; + 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 Row + { + public int Seed; public DrainageAnalysis.Plan Plan; public long OceanCells, EnclosedWater, LandCells; + public ulong MsAnalysis; public bool Ok; + } + + private void Run() + { + ToolingPaths.Configure(OS.GetUserDataDir()); + + int task = EnvInt("ISLA_TASK", 12); + string descr = EnvStr("ISLA_BATCH", "drainage_analysis"); + int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize); + int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize); + int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); + bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1"; + bool skipT11 = EnvStr("ISLA_SKIP_T11_CHECK", "0") == "1"; + string t11Source = EnvStr("ISLA_T11_SOURCE", "11_erosion"); + + string batchRoot = ToolingPaths.BatchRoot(task, descr); + DirAccess.MakeDirRecursiveAbsolute(batchRoot); + DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); + + var anchors = CurveAnchors.Default; + float sea = 0.15f; + var dp = new DrainageAnalysis.Params { SeaLevel = sea }; + + GD.Print("=================================================================="); + GD.Print(" DRAINAGE ANALYSIS (chat2/12) — minimal-first: is the flow sane? (analysis only, nothing carved)"); + GD.Print("=================================================================="); + GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}"); + GD.Print($"seeds : {string.Join(", ", seeds)}"); + GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON (faithful tune) — the task-11 erosion_on field"); + GD.Print($"params : endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx}, inflow ≥ {dp.EndorheicMinInflowPx}, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount} sep {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px"); + GD.Print($"batch : {batchRoot}"); + GD.Print("=================================================================="); + + GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---"); + var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors); + GD.Print($" {knots}"); + + TerrainGenConfig Cfg(int size, int seed) + { + var c = new TerrainGenConfig + { + MapSize = size, Seed = seed, VariantLabel = "drainage", + Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous, + Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f, + }; + TerrainShapeV1.Apply(c); + c.Erosion = true; // the faithful tune — the defaults + return c; + } + + var hard = new List(); + var perSeed = new List(); + var rows = new List(); + + for (int si = 0; si < seeds.Length; si++) + { + int seed = seeds[si]; + 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; + GD.Print($" terrain ready ({p1.ElapsedMs} ms pass 1, erosion {ero.Ms / 1000.0:F1} s)"); + + if (!skipT11) + { + string dump = Path.Combine(ToolingPaths.BatchesRoot, t11Source, $"{seed}_erosion_on", "height.f32"); + if (File.Exists(dump) && mapSize == 8192) + { + var a11 = ShapingOracle.DumpRegression("a11", $"the eroded render field == the task-11 erosion_on dump (the terrain the developer saw) [{seed}]", p2.Height, HeightField.Load(dump, mapSize), mapSize, dump); + hard.Add(a11); GD.Print(" " + a11); + } + else GD.Print($" a11 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the 11 batch's 8192" : $"no dump at {dump}")}"); + } + + // ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field. + bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed); + var isClassifyWater = new bool[mapSize * mapSize]; + long waterPx = 0; + 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; waterPx++; } + GD.Print($" ocean (region layer, classify): {oceanCells:N0} cells; enclosed non-ocean water: {enclosed:N0} cells; classify water total {waterPx:N0}"); + + // Snapshot both fields — the analysis must write ZERO terrain cells. + var renderBefore = (float[,])p2.Height.Clone(); + var classifyBefore = (float[,])p2.HeightClassify.Clone(); + long wetRenderBefore = ErosionPass.CountWaterPixels(p2.Height, mapSize, sea); + + ulong tA = Time.GetTicksMsec(); + var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp); + ulong msA = Time.GetTicksMsec() - tA; + GD.Print($" analysis {msA / 1000.0:F1} s: 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} %), unrouted {plan.UnroutedCells:N0}; terminal basins {plan.TerminalBasinCount}, pits filled through {plan.PitsFilledCount:N0}"); + foreach (var t in plan.Trunks) GD.Print($" trunk: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}) drainage {t.DrainageAreaPx:N0} px, stem {t.Course.Count * 4} px, exit {(t.ExitFound ? $"({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m" : "NOT FOUND")}, tributaries {t.Tributaries.Count}"); + foreach (var g in plan.Giants) GD.Print($" giant: terminal ({g.Terminal.x:F0},{g.Terminal.y:F0}) inflow {g.DrainageAreaPx:N0} px, basin {g.BasinAreaPx:N0} px / {g.BasinDepthM:F1} m deep, kind {g.Kind}, exit {(g.ExitFound ? $"{g.MountainExitElevM:F0} m" : "NOT FOUND")}, tributaries {g.Tributaries.Count}"); + foreach (var e in plan.Endorheics) GD.Print($" lean terminal: ({e.Terminal.x:F0},{e.Terminal.y:F0}) inflow {e.DrainageAreaPx:N0}, basin {e.BasinAreaPx:N0} px / {e.BasinDepthM:F1} m"); + + // ═══ THE ORACLE ═══ + var checks = new List + { + ShapingOracle.NorthLocked("t", "terrain untouched — render field bit-identical before/after the analysis", renderBefore, p2.Height, mapSize, mapSize), + ShapingOracle.NorthLocked("t2", "terrain untouched — classify field bit-identical before/after the analysis", classifyBefore, p2.HeightClassify, mapSize, mapSize), + WaterUnchanged(wetRenderBefore, p2, mapSize, sea, p1), + FillInvariants(plan, p2.Height, mapSize), + OceanFromRegionLayer(isOcean, p2.HeightClassify, mapSize, sea, oceanCells, enclosed), + }; + if (si == 0) + { + var plan2 = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp); + checks.Add(Deterministic(plan, plan2)); + } + foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); GD.Print(" " + c); } + bool ok = checks.TrueForAll(c => c.Passed); + + WriteSeed(batchRoot, seed, plan, isOcean, p2, mapSize, sea, skipRaw); + rows.Add(new Row { Seed = seed, Plan = plan, OceanCells = oceanCells, EnclosedWater = enclosed, LandCells = plan.LandCells, MsAnalysis = msA, Ok = ok }); + } + + bool allOk = hard.TrueForAll(c => c.Passed) && perSeed.TrueForAll(c => c.Passed); + GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}"); + foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c); + + WriteIndex(batchRoot, mapSize, calibSize, seeds, rows, dp, hard, perSeed, allOk); + GD.Print("\n=================================================================="); + GD.Print($" DONE — {batchRoot}"); + GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES — see the table ***")}"); + GD.Print("=================================================================="); + GetTree().Quit(allOk ? 0 : 3); + } + + // ---- the checks ------------------------------------------------------- + + private static ShapingOracle.Check WaterUnchanged(long wetBefore, Pass2Result p2, int n, float sea, Pass1Result p1) + { + long wetAfter = ErosionPass.CountWaterPixels(p2.Height, n, sea); + var c = new ShapingOracle.Check { Id = "w", Name = "no water added — render water pixels unchanged; region labeling + island tag untouched" }; + c.Passed = wetBefore == wetAfter && p1.Regions != null; + c.Detail = $"water pixels {wetBefore:N0} → {wetAfter:N0}; {p1.Regions?.IslandCount ?? 0} islands in the (untouched) region table"; + return c; + } + + /// The reference's two routing-fill diagnostics: filled ≥ original everywhere; every cell has a non-ascending path to the border on the full fill. + private static ShapingOracle.Check FillInvariants(DrainageAnalysis.Plan plan, float[,] height, int n) + { + var c = new ShapingOracle.Check { Id = "r", Name = "routing fill — full fill ≥ original everywhere; every cell has a non-ascending 8-path to the border" }; + long below = 0, raised = 0; string first = null; + var ff = plan.FullFilled; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + { + int i = x * n + y; float h = height[x, y]; + if (ff[i] < h) { below++; first ??= $"[{x},{y}] filled {ff[i]:G9} < original {h:G9}"; } + else if (ff[i] > h) raised++; + } + // Non-ascending path: follow the lowest neighbour; memoised. -1 unknown, 1 reaches border, 2 stuck. + var state = new sbyte[n * n]; long stuck = 0; var path = new List(1 << 12); + int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 }, DY = { -1, 0, 1, -1, 1, -1, 0, 1 }; + for (int i = 0; i < n * n && stuck == 0; i++) + { + if (state[i] != 0) continue; + int cur = i; path.Clear(); sbyte result = 0; + while (true) + { + if (state[cur] != 0) { result = state[cur]; break; } + path.Add(cur); + int cx = cur / n, cy = cur % n; + if (cx == 0 || cy == 0 || cx == n - 1 || cy == n - 1) { result = 1; break; } + float best = ff[cur]; int bestN = -1; + for (int k = 0; k < 8; k++) + { + int ni = (cx + DX[k]) * n + (cy + DY[k]); + if (ff[ni] < best) { best = ff[ni]; bestN = ni; } // the strictly lowest neighbour + } + if (bestN < 0) + { + // no strictly lower neighbour: allow an EQUAL neighbour not yet on this path (flat), else stuck + for (int k = 0; k < 8 && bestN < 0; k++) + { + int ni = (cx + DX[k]) * n + (cy + DY[k]); + if (ff[ni] == ff[cur] && state[ni] == 1) bestN = ni; + } + if (bestN < 0) { result = 2; break; } + } + cur = bestN; + if (path.Count > 4 * n) { result = 2; break; } + } + foreach (int pc in path) state[pc] = result; + if (result == 2) { stuck++; first ??= $"cell {path[0] / n},{path[0] % n} has no non-ascending path to the border"; } + } + c.Passed = below == 0 && stuck == 0; + c.Detail = c.Passed ? $"filled ≥ original on all {(long)n * n:N0} cells ({raised:N0} raised); every cell drains to the border on the full fill" + : $"VIOLATION — {below:N0} cells filled below original, {stuck:N0} stuck — {first}"; + return c; + } + + private static ShapingOracle.Check OceanFromRegionLayer(bool[] isOcean, float[,] classify, int n, float sea, long oceanCells, long enclosed) + { + var c = new ShapingOracle.Check { Id = "s", Name = "\"the sea\" = the ocean body from the region layer (classify, 4-connected to the border) — not any below-sea cell" }; + long oceanLand = 0, oceanTouchBorder = 0, count = 0; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + { + if (!isOcean[x * n + y]) continue; + count++; + if (classify[x, y] >= sea) oceanLand++; + if (x == 0 || y == 0 || x == n - 1 || y == n - 1) oceanTouchBorder++; + } + c.Passed = oceanLand == 0 && oceanTouchBorder > 0 && count == oceanCells; + c.Detail = $"{count:N0} ocean cells, all below sea, {oceanTouchBorder:N0} on the border; {enclosed:N0} below-sea cells are NOT ocean (enclosed water — ordinary terrain to the router)"; + return c; + } + + private static ShapingOracle.Check Deterministic(DrainageAnalysis.Plan a, DrainageAnalysis.Plan b) + { + var c = new ShapingOracle.Check { Id = "o", Name = "deterministic — flow field, accumulation and candidate set identical across two runs" }; + long dirDiff = 0, accDiff = 0; + for (int i = 0; i < a.Dir.Length; i++) { if (a.Dir[i] != b.Dir[i]) dirDiff++; if (a.Acc[i] != b.Acc[i]) accDiff++; } + bool cand = a.Trunks.Count == b.Trunks.Count && a.Giants.Count == b.Giants.Count && a.Endorheics.Count == b.Endorheics.Count; + if (cand) for (int i = 0; i < a.Trunks.Count; i++) cand &= a.Trunks[i].DrainageAreaPx == b.Trunks[i].DrainageAreaPx && a.Trunks[i].Outlet == b.Trunks[i].Outlet; + if (cand) for (int i = 0; i < a.Giants.Count; i++) cand &= a.Giants[i].DrainageAreaPx == b.Giants[i].DrainageAreaPx && a.Giants[i].Terminal == b.Giants[i].Terminal; + c.Passed = dirDiff == 0 && accDiff == 0 && cand; + c.Detail = c.Passed ? $"dir and acc identical over {a.Dir.Length:N0} cells; {a.Trunks.Count} trunks / {a.Giants.Count} giants / {a.Endorheics.Count} lean terminals identical" + : $"DIFFER — dir {dirDiff:N0} cells, acc {accDiff:N0} cells, candidates {(cand ? "same" : "DIFFER")}"; + return c; + } + + // ---- the curve -------------------------------------------------------- + + 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(new TerrainGenConfig { MapSize = calibSize, Seed = 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", + }; + 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]); } + var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw, + HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors), + anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f); + return (knots, cal); + } + + // ---- output ----------------------------------------------------------- + + private static void WriteSeed(string batchRoot, int seed, DrainageAnalysis.Plan plan, bool[] isOcean, Pass2Result p2, int n, float sea, bool skipRaw) + { + string dir = Path.Combine(batchRoot, $"{seed}"); + DirAccess.MakeDirRecursiveAbsolute(dir); + DrainageRenderer.Accumulation(plan.Acc, isOcean, p2.Height, n, sea).SavePng(Path.Combine(dir, "accumulation.png")); + DrainageRenderer.Candidates(plan, isOcean, p2.Height, n, sea, p2.HMax, $"DRAINAGE PLAN SEED {seed} (ERODED TERRAIN, D8 ANALYSIS)").SavePng(Path.Combine(dir, "candidates.png")); + if (!skipRaw) + { + var accF = new float[n, n]; + for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) accF[x, y] = plan.Acc[x * n + y]; + HeightField.Save(accF, n, Path.Combine(dir, "accumulation.f32")); + } + } + + private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List rows, DrainageAnalysis.Params dp, + List hard, List perSeed, bool allOk) + { + var sb = new StringBuilder(); + sb.AppendLine($"# Batch 12 — drainage analysis (minimal-first): is the flow sane? {seeds.Length} seeds at {mapSize}"); + sb.AppendLine(); + sb.AppendLine("**Analysis only — nothing carved, no water added.** The reference `DrainageAnalysis` (priority-flood routing fill with a"); + sb.AppendLine("one-ulp epsilon, D8 flow directions FOR ANALYSIS, Kahn accumulation, drainage-area promotion) on the eroded render field of the"); + sb.AppendLine("locked shape. **\"The sea\" is the OCEAN body from the region layer** (classify, 4-connected to the border); enclosed water is"); + sb.AppendLine("ordinary terrain to the router. **⚠ Endorheic basins are EXPECTED here, not errors:** erosion delivers the upland network only and"); + sb.AppendLine("cannot cross the flats, so the biggest drainages pool inland. A map full of orange terminals is the correct result."); + sb.AppendLine(); + sb.AppendLine("## ⭐ Open this first"); + sb.AppendLine(); + sb.AppendLine($"1. **`{seeds[0]}/accumulation.png`** — log-scaled flow accumulation: dendritic uplands and trunks bright on dark hillslopes."); + sb.AppendLine($"2. **`{seeds[0]}/candidates.png`** — the promoted candidates over a faint terrain: cyan = sea-reaching trunks (square outlet, white ring = mountain exit), orange = endorheic giants (disc = pooling terminal), red rings = lean endorheic terminals."); + sb.AppendLine("3. The other three seeds, then the table."); + sb.AppendLine(); + sb.AppendLine("## The summary table — sea-reaching vs endorheic (endorheic dominance is the expected finding)"); + sb.AppendLine(); + sb.AppendLine("| Seed | land cells | → ocean | → endorheic | unrouted | terminal basins / pits filled | trunks (drainage px; exit) | giants (inflow px; basin px / depth; kind) | largest endorheic giant vs largest trunk | lean terminals | ocean / enclosed water cells | oracle |"); + sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|"); + foreach (var r in rows) + { + var p = r.Plan; + string trunks = p.Trunks.Count == 0 ? "—" : string.Join("
", p.Trunks.ConvertAll(t => $"({t.Outlet.x:F0},{t.Outlet.y:F0}) {t.DrainageAreaPx:N0}; exit {(t.ExitFound ? $"{t.MountainExitElevM:F0} m" : "none")}")); + string giants = p.Giants.Count == 0 ? "—" : string.Join("
", p.Giants.ConvertAll(g => $"({g.Terminal.x:F0},{g.Terminal.y:F0}) {g.DrainageAreaPx:N0}; {g.BasinAreaPx:N0} / {g.BasinDepthM:F1} m; {g.Kind}")); + long bigG = p.Giants.Count == 0 ? 0 : p.Giants[0].DrainageAreaPx, bigT = p.Trunks.Count == 0 ? 0 : p.Trunks[0].DrainageAreaPx; + sb.AppendLine($"| `{r.Seed}` | {p.LandCells:N0} | {p.SeaReachingCells:N0} ({100.0 * p.SeaReachingCells / Math.Max(1, p.LandCells):F1} %) | **{p.EndorheicCells:N0} ({100.0 * p.EndorheicCells / Math.Max(1, p.LandCells):F1} %)** | {p.UnroutedCells:N0} | {p.TerminalBasinCount} / {p.PitsFilledCount:N0} | {trunks} | {giants} | **{bigG:N0} vs {bigT:N0}** ({(bigT > 0 ? (double)bigG / bigT : 0):F1}×) | {p.Endorheics.Count} | {r.OceanCells:N0} / {r.EnclosedWater:N0} | {(r.Ok ? "pass" : "**FAIL**")} |"); + } + sb.AppendLine(); + sb.AppendLine($"Params: endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx:N0} px, inflow ≥ {dp.EndorheicMinInflowPx:N0} px, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount}, outlet separation {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx:N0} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px — the reference's declared defaults. Provisional routes are computed (as the reference did) but NOT drawn or promoted — routing is a later task."); + sb.AppendLine(); + sb.AppendLine("## The oracle (analysis-only guarantees)"); + sb.AppendLine(); + sb.AppendLine(hard.Count == 0 ? "*(the task-11 bit-identity check was skipped)*\n" : ShapingOracle.ToMarkdownTable(hard)); + sb.AppendLine("Per seed (terrain untouched t / t2 · no water added w · routing-fill invariants r · ocean from the region layer s · determinism o):"); + sb.AppendLine(); + sb.AppendLine(ShapingOracle.ToMarkdownTable(perSeed)); + sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : "⚠⚠ FAILURES — do not judge this batch")}**"); + sb.AppendLine(); + sb.AppendLine("## Disposability"); + sb.AppendLine(); + sb.AppendLine("| Artifact | Keep? |"); + sb.AppendLine("|---|---|"); + sb.AppendLine("| `accumulation.png`, `candidates.png`, `INDEX.md` | **keep** |"); + sb.AppendLine("| `accumulation.f32` | ♻ regenerable (analysis of a regenerable field) — 256 MB each, clear freely |"); + sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |"); + sb.AppendLine(); + sb.AppendLine($"Analysis at {mapSize}, curve calibrated at {calibSize}. {WorldScale.Describe()}."); + WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString()); + } + + 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; + } + } +} diff --git a/Tools/Scripts/DrainageTool.cs.uid b/Tools/Scripts/DrainageTool.cs.uid new file mode 100644 index 0000000..b8075db --- /dev/null +++ b/Tools/Scripts/DrainageTool.cs.uid @@ -0,0 +1 @@ +uid://becalqn1wrw4k diff --git a/Tools/Scripts/ShadeRenderer.cs.uid b/Tools/Scripts/ShadeRenderer.cs.uid new file mode 100644 index 0000000..60207a0 --- /dev/null +++ b/Tools/Scripts/ShadeRenderer.cs.uid @@ -0,0 +1 @@ +uid://4k3liidno15g