chat2/12: drainage analysis (minimal-first) — the reference river plan ported, analysis only, on the eroded terrain

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
This commit is contained in:
Stewart Howe 2026-08-22 20:51:51 -04:00
parent ea291eaab5
commit 89e0f85c9f
11 changed files with 1319 additions and 0 deletions

View file

@ -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. |

View file

@ -0,0 +1,698 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐⭐ DRAINAGE ANALYSIS — THE FAITHFUL PORT (chat2/12). From the reference's
/// <c>Tools/Scripts/DrainageAnalysis.cs</c> at tag <c>pre-rewrite-reference</c> (<c>ab78883</c>), 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: <c>IslaApocalypse.Core</c> — engine-free, a C++ candidate.
/// • The yardstick: metres via <see cref="WorldScale.MetresFromRaw"/> (the same <c>× 251f</c>; no literal).
/// • <see cref="Plan.FullFilled"/> and <see cref="Plan.Filled"/> 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.
/// </summary>
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<Stream> Tributaries = new();
}
/// <summary>
/// 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.
/// </summary>
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<Stream> 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<Trunk> Trunks = new();
public List<EndorheicTerminal> Endorheics = new();
public List<Giant> 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) ----
/// <summary>D8 direction per cell (row-major x·n+y): 0..7, <see cref="D_SEA"/>, <see cref="D_NONE"/>. Analysis only.</summary>
public sbyte[] Dir;
/// <summary>Flow accumulation per cell (row-major); 0 on ocean.</summary>
public int[] Acc;
/// <summary>The routing surface after terminal basins reverted (row-major).</summary>
public float[] Filled;
/// <summary>The FULL priority-flood fill, before terminal reversion (row-major) — every cell drains to the border on it.</summary>
public float[] FullFilled;
/// <summary>Terminal-basin id per cell (row-major), 0 = none.</summary>
public int[] BasinId;
/// <summary>Per terminal basin id: total inflow (cells whose flow ends there).</summary>
public long[] BasinInflow;
}
/// <param name="isOcean">Row-major mask of THE OCEAN body — the only water that counts as "the sea" for
/// sea-reaching trunks (in v2: <c>RegionLabeling.OceanMask</c>, 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.</param>
/// <param name="isClassifyWater">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.</param>
/// <param name="southX">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.</param>
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<int, (float h, int idx)>();
var pit = new Queue<int>();
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<float> { 0f };
var basinAreaPx = new List<long> { 0L };
var basinMinCell = new List<int> { -1 };
{
var stack = new Stack<int>();
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<int>();
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<int>(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<int>();
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<int> TraceStem(int fromCell, int minAcc)
{
var stem = new List<int> { 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<int> 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<int>(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<int>();
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<int, int>();
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<int> { 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<int>(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<int>();
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>();
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;
}
}
}

View file

@ -0,0 +1 @@
uid://c6yfvp8p5br07

View file

@ -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).
/// <summary>
/// The ocean mask, row-major (<c>x·n+y</c>): true for every below-sea cell 4-connected to the map
/// border. Pure: reads <paramref name="classify"/>, writes nothing.
/// </summary>
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<int>();
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;
}
/// <summary>Island counts per hemisphere (non-mainland components, by centroid).</summary>
public static (int north, int south) IslandsByHemisphere(RegionLabels labels)
{

View file

@ -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 |

View file

@ -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")

View file

@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>log(1 + acc) / log(1 + max) over land; ocean / enclosed water flat.</summary>
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;
}
/// <summary>The candidates over a faint terrain.</summary>
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);
}
}
}
}

View file

@ -0,0 +1 @@
uid://dp11721l40ixx

View file

@ -0,0 +1,410 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE DRAINAGE-ANALYSIS BATCH (chat2/12) — minimal-first: is the flow sane before rivers are built
/// on it? Runs <see cref="DrainageAnalysis"/> (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
/// </summary>
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<ShapingOracle.Check>();
var perSeed = new List<ShapingOracle.Check>();
var rows = new List<Row>();
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.Check>
{
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;
}
/// <summary>The reference's two routing-fill diagnostics: filled ≥ original everywhere; every cell has a non-ascending path to the border on the full fill.</summary>
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<int>(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<int, Pass1Result>();
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<Row> rows, DrainageAnalysis.Params dp,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> 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("<br>", 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("<br>", 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<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://becalqn1wrw4k

View file

@ -0,0 +1 @@
uid://4k3liidno15g