islaApocalypse-v2/Tools/Scripts/RiverPromotionTool.cs
beezm ea301f7a8c rivers/02: river promotion — unified ranking over both termini, and the count gate
Choose the river count against the terrain that actually exists. The M3 count of
3 was tuned on topography the southern stretch (D-065) and coastal fragmentation
(D-063) have since replaced, and TrunkCount/GiantCount/EndorheicMaxCount = 3 are
LEAN REPORTING CAPS, not a statement about this island.

UNIFIED RANKING. The reference promoted from two lists with two quotas — N sea
trunks, N endorheic giants — which encodes the assumption that reaching the sea
is what makes a drainage a river. This terrain does not satisfy it: 54-78% of
land drains inland across the gallery, 106-121 terminal basins per seed. So
selection is unified — every major drainage ranked by contributing-cell count in
ONE list, top N promoted, and the sea/endorheic split FALLS OUT. A quota would
have promoted small coastal drainages over far larger inland ones purely because
of where they end. Deliberate departure from the reference's structure (D-050
noted, approved in chat). Only SELECTION is unified: the per-terminus tag is
retained per river because rivers/03's routing branches on it, and Trunk/Giant
are untouched.

  The metric is the same unit on both sides, and that is now ASSERTED rather
  than argued: sea Acc and endorheic BasinInflow are both counts of contributing
  land cells on the same D8 field, and every land cell has exactly one
  destination, so Σ sea Acc + Σ BasinInflow + unrouted == LandCells exactly. The
  tool refuses to rank unless it holds, per seed. It held on all 8.

DrainageAnalysis.cs is NOT modified. The complete candidate set is re-derived
from state it already exposes (Dir/Acc/BasinId/BasinInflow/FullFilled); the
three internal per-basin values that are not exposed are exactly reconstructible
because terminal basins are reverted to the real surface while FullFilled keeps
the fill. Only REPORTING caps were raised, and only so the analysis's own
TraceStem produces a real upland course per promotable candidate.
EndorheicMinDepthM/MinAreaPx were left alone — they decide which depressions
BECOME terminal basins, i.e. they define the routing surface itself, and a count
chosen on a moved surface would be a count for terrain that does not exist.

TWO FINDINGS.

  1. THE TERRAIN HAS NO NATURAL COUNT. The knee (largest ratio between
     consecutive ranks) lands at rank 19/7/15/5/4/12/3/8 across the eight
     gallery seeds at ratios of 1.33-1.57x, with one uncorroborated 2.37x. A
     break that wanders across the whole plausible range at that strength is not
     a break — on a log axis the candidates fall on a near-straight line.
     N is a DESIGN choice, and the diagnostic says so instead of inventing one.

  2. THE ISLAND'S MAJOR RIVERS ARE INLAND RIVERS. Six of eight seeds have ZERO
     sea-reaching drainages in their top 8; the median at N=16 is one; one seed
     has none at 16. On the primary seed the largest sea drainage ranks 11th at
     736,088 px against 2,329,573 px for the largest endorheic — 3.2x. (That
     outlet is exactly batch 12's recorded top trunk, to the cell.)

A concern I raised and then CLOSED by measuring: MinOutletSeparationPx is a
plain Euclidean test with no notion of which landmass a coastline belongs to, so
on a fragmented archipelago it could suppress an island's only river. Measured:
0-6 cross-landmass suppressions per seed, and — decisively — the largest
suppressed above-floor outlet anywhere is 209,611 px against a smallest ladder
cutoff of 439,592 px, so ZERO suppressed outlets clear any rung on any seed. The
rule provably cannot have altered the ladder. Real but harmless; flagged for
rivers/03, not a blocker. The rule was NOT changed — it belongs to the analysis,
and moving it would move the candidate set the developer is being asked to judge.

Also found: the params are ABSOLUTE pixel counts, so this analysis is only valid
at the size they were tuned for. At 1024 nothing qualifies as endorheic (the
whole endorheic half cannot be exercised) and separation suppresses 15,043 of
15,048 outlets. The tool now refuses loudly to have a non-8192 run read as a
count decision. Flagged for rivers/03: these want to become scale-free fractions
as MinLandComponentFrac already is.

Giant.ProvisionalRoute is never rendered — the "comb" is rivers/03's to replace,
and drawing it would make a count judgment look like a finished network. The
plates draw the real erosion-carved upland stems. Endorheic rivers are marked at
where their stem POOLS, not at the basin's deepest cell: the analysis
distinguishes these deliberately and they sit up to 395 px apart, which drew
every stem detached from its own endpoint until it was fixed.

TASTE GATE: 8/12/16 are presented and nothing is decided. No count is chosen, no
default is set, and neither TerrainGenConfig nor DrainageAnalysis.Params changed.

-> XX_Human/output/rivers/02_promotion.report.md
-> batches/rivers/02_promotion/INDEX.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhkXBQh2tDmcWKpXYcj8vj
2026-08-23 06:13:03 -04:00

762 lines
45 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ THE RIVER-PROMOTION BATCH (rivers/02) — DIAGNOSTIC FIRST, then a taste gate on the count.
///
/// ═══ WHAT THIS TASK IS FOR ═══
///
/// Choose the river COUNT against the terrain that actually exists. The M3 count of 3 was tuned on
/// topography that the southern stretch (→ D-065) and coastal fragmentation (→ D-063) have since
/// replaced, and `DrainageAnalysis.Params.TrunkCount / GiantCount / EndorheicMaxCount = 3` are
/// **LEAN REPORTING CAPS, NOT A STATEMENT ABOUT THE TERRAIN** — this island carries ~116 terminal
/// basins per seed and drains ~68 % of its land inland.
///
/// So: measure the full candidate distribution FIRST, see whether the terrain has a natural break,
/// and only then show 8 / 12 / 16 on the map. **This tool promotes a ladder, not a winner.**
///
/// ═══ WHAT IT DOES NOT DO ═══
///
/// No lowland routing (rivers/03), no water bodies, no carving, no crater. `DrainageAnalysis` is
/// REUSED, not rebuilt — it is ported and proven (chat2/12, re-derived bit-identically at
/// `03fe75b`). Everything here is enumeration, ranking, selection and render AROUND it.
///
/// ⚠⚠ `Giant.ProvisionalRoute` is never drawn. It is the steepest-descent placeholder (the "comb")
/// that rivers/03 replaces; drawing it would make a count judgment look like a river network.
///
/// ═══ ⭐ THE UNIFIED RANKING, AND WHERE IT IS BUILT ═══
///
/// The ranking is built over the **RAW CANDIDATES derived from the exposed `Plan` arrays**
/// (`Dir` / `Acc` / `BasinId` / `BasinInflow`), not over `Plan.Trunks` / `Plan.Giants`. That choice
/// matters: the `Trunks` / `Giants` lists are already truncated by the reporting caps, so ranking
/// over them would measure the caps rather than the terrain. Deriving from the arrays gives the
/// COMPLETE distribution, cap-free — which is the whole point of a diagnostic.
///
/// The caps are then raised (ISLA_PROMOTE_MAX) purely so the analysis's own `TraceStem` produces a
/// real upland course for every candidate that could be promoted; each promoted candidate is BOUND
/// to its `Trunk` (by outlet cell) or `Giant` (by basin id) to collect that course. No stem-tracing
/// is reimplemented here.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/RiverPromotionTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_CHAT / ISLA_OUTPUT_DIR / ISLA_SKIP_RAW
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
/// ISLA_SEEDS distribution seeds (default: the 8 gallery seeds)
/// ISLA_RENDER_SEEDS seeds that also get maps (default: 4 of them)
/// ISLA_PROMOTE_FLOOR_PX significance floor for the DISTRIBUTION (default 5000)
/// ISLA_PROMOTE_N_LADDER the A/B counts (default 8,12,16)
/// ISLA_PROMOTE_MAX cap raised on the analysis so stems exist (default 24)
/// </summary>
public partial class RiverPromotionTool : Node
{
/// <summary>The 8 gallery seeds — the terrain `terrain-shape-v1` was judged across.</summary>
private static readonly int[] GallerySeeds =
{ 1063685222, 999999937, 20260822, 31415926, 27182818, 16180339, 14142135, 17320508 };
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private static readonly int[] DefaultRenderSeeds = { 1063685222, 999999937, 31415926, 14142135 };
private const int DefaultMapSize = 8192;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
// ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits with no
// main loop to end it, so a misconfigured run HANGS. Catch, say what was refused, exit 2.
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class SeedResult
{
public int Seed;
public List<RiverCandidate> Ranked; // separated + above floor, descending
public int SuppressedCount; // sea outlets dropped by the separation rule (all)
public long SuppressedPx; // …and the drainage they carried (all)
// ⚠ The two numbers that actually matter: a fragmented coastline has tens of thousands of
// one-cell outlets, so an aggregate "suppressed" figure is dominated by drainage that was
// never a candidate. THESE count only outlets that cleared the significance floor.
public int SuppressedAboveFloor;
public long SuppressedAboveFloorPx;
// ⚠ Of those, how many were suppressed by an outlet on a DIFFERENT LANDMASS — i.e. cannot
// possibly be "another mouth of the same delta". Measured, not assumed. → the note in Run().
public int SuppressedCrossLandmass;
public long SuppressedCrossLandmassPx;
// ⭐ THE DECISIVE NUMBER. Every suppressed above-floor outlet's drainage, kept so we can ask
// the only question that actually matters: would any of them have made the ladder? A rule
// that discards candidates too small to be promoted costs the decision nothing.
public List<long> SuppressedAboveFloorAccs = new();
public Dictionary<int, int> WouldHaveMadeN = new(); // ladder N -> suppressed outlets ≥ that cutoff
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
public int TerminalBasins, SeaOutletsAll;
public int BreakRankRatio; public double BreakRatio; // the knee (log gap)
public int BreakRankAbs; public long BreakAbs; // the largest absolute gap
public Dictionary<int, (int sea, int endo, long areaAtN, bool enough)> AtN = new();
public ulong Ms;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
int task = EnvInt("ISLA_TASK", 2);
string descr = EnvStr("ISLA_BATCH", "promotion");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", GallerySeeds);
int[] renderSe = EnvSeeds("ISLA_RENDER_SEEDS", DefaultRenderSeeds);
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
int[] ladder = EnvSeeds("ISLA_PROMOTE_N_LADDER", new[] { 8, 12, 16 });
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
int maxLadder = 0; foreach (int v in ladder) if (v > maxLadder) maxLadder = v;
if (promoteMax < maxLadder)
throw new InvalidOperationException(
$"ISLA_PROMOTE_MAX ({promoteMax}) is below the largest ladder count ({maxLadder}). The cap is what " +
"makes the analysis trace a real upland stem for every promotable candidate; below the ladder, the " +
"top plate would have rivers with no course to draw. Raise it.");
// ⚠ rivers/01: the shape AND erosion come from the bare defaults. Assert before generating —
// a count chosen on drifted terrain is a count chosen for terrain nobody approved.
TerrainShapeV1.Assert("RiverPromotion");
TerrainShapeV1.AssertErosionDefaultOn("RiverPromotion");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
// The analysis params. ⚠⚠ ONLY THE REPORTING CAPS MOVE. EndorheicMinDepthM and
// EndorheicMinAreaPx are NOT touched: they decide which depressions BECOME terminal basins,
// i.e. they define the routing surface itself. Changing them would change the drainage this
// task is meant to measure, not just how much of it is reported.
var dp = new DrainageAnalysis.Params
{
SeaLevel = sea,
TrunkCount = promoteMax, // reporting cap ↑ so stems exist
GiantCount = promoteMax, // reporting cap ↑
EndorheicMaxCount = promoteMax, // reporting cap ↑
EndorheicMinInflowPx = (int)floorPx, // reporting floor ↓ to the diagnostic floor
};
var dpDefaults = new DrainageAnalysis.Params();
GD.Print("==================================================================");
GD.Print(" RIVER PROMOTION (rivers/02) — measure the candidate distribution, THEN show the count ladder");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
GD.Print($"seeds : distribution {seeds.Length} — {string.Join(", ", seeds)}");
GD.Print($" : rendered {renderSe.Length} — {string.Join(", ", renderSe)}");
GD.Print($"ranking : UNIFIED — every major drainage by contributing-cell count, both termini in ONE list.");
GD.Print($" the sea/endorheic split FALLS OUT; it is never quota'd. (departs from the reference's two lists)");
GD.Print($"floor : {floorPx:N0} px (diagnostic significance floor — NOT the promotion threshold)");
GD.Print($"ladder : N = {string.Join(", ", ladder)} caps raised to {promoteMax} so every promotable river has a traced stem");
GD.Print($"UNCHANGED : EndorheicMinDepthM {dpDefaults.EndorheicMinDepthM} m · EndorheicMinAreaPx {dpDefaults.EndorheicMinAreaPx:N0} · MinOutletSeparationPx {dpDefaults.MinOutletSeparationPx} · StemMinAccPx {dpDefaults.StemMinAccPx}");
// ⚠⚠ THE PARAMS ARE ABSOLUTE PIXEL COUNTS, SO THIS ANALYSIS IS SCALE-DEPENDENT.
// Measured at rivers/02: at 1024 a 10,000-cell basin is ~1 % of the map and NOTHING qualifies as
// endorheic (0 terminal basins, 100 % sea-reaching), while a 400 px separation is 39 % of the map
// width and suppresses 15,043 of 15,048 sea outlets. At 8192 the same numbers are 0.015 % and
// 4.9 %. A small-map probe of this tool therefore measures the PARAMS, not the terrain.
if (mapSize != 8192)
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the DrainageAnalysis params (EndorheicMinAreaPx {dpDefaults.EndorheicMinAreaPx:N0}, " +
$"MinOutletSeparationPx {dpDefaults.MinOutletSeparationPx}) are ABSOLUTE PIXEL COUNTS tuned at 8192. At {mapSize} they scale " +
$"differently against the map ({100.0 * dpDefaults.EndorheicMinAreaPx / ((double)mapSize * mapSize):F3} % of area, " +
$"{100.0 * dpDefaults.MinOutletSeparationPx / mapSize:F1} % of width), so the candidate distribution is NOT comparable " +
"to the 8192 result and MUST NOT be used to choose a count. Pipeline smoke only.");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = "promotion",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
var renderSet = new HashSet<int>(renderSe);
var results = new List<SeedResult>();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
GD.Print($"\n--- seed {seed} ---");
var cfg = Cfg(mapSize, seed);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result shaped = Shaping.Shape(p1, cfg);
var ero = ErosionPass.Apply(shaped, cfg);
Pass2Result p2 = ero.Shaped;
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field (→ D-066).
// A terminus "reaches the sea" iff it touches THIS, never a bare h < sea.
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
var isClassifyWater = new bool[mapSize * mapSize];
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true;
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " +
$"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %), unrouted {plan.UnroutedCells:N0}; " +
$"terminal basins {plan.TerminalBasinCount}; ocean {oceanCells:N0} / enclosed {enclosed:N0}");
var r = Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, seed, p1.Regions);
r.Ms = Time.GetTicksMsec() - t0;
Analyse(r, ladder);
BindCourses(r, plan, mapSize, maxLadder);
Report(r, ladder);
WriteCsv(batchRoot, r);
if (renderSet.Contains(seed))
RenderSeed(batchRoot, r, plan, isOcean, p2, mapSize, sea, ladder, floorPx, dpDefaults, skipRaw);
results.Add(r);
}
WriteIndex(batchRoot, mapSize, calibSize, seeds, renderSe, results, ladder, floorPx, promoteMax, dpDefaults, skipRaw);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print(" ⛔ TASTE GATE: 8 / 12 / 16 are PRESENTED, not decided. The developer picks N.");
GD.Print("==================================================================");
GetTree().Quit(0);
}
// ═══ ⭐⭐ ENUMERATION — the complete candidate set, derived from the exposed Plan arrays ═══
/// <summary>
/// Enumerate every candidate major drainage, cap-free.
///
/// SEA every cell with <c>Dir == D_SEA</c>, carrying <c>Acc</c> at that cell, then the
/// analysis's own greedy <c>MinOutletSeparationPx</c> rule so three mouths of one
/// delta are not three rivers.
/// ENDORHEIC every terminal basin present in <c>BasinId</c>, carrying <c>BasinInflow[id]</c>,
/// with its terminal cell / area / depth re-derived from the exposed surfaces.
///
/// ⚠ Nothing here re-runs or re-implements the analysis: `Dir`, `Acc`, `BasinId`, `BasinInflow`
/// and `FullFilled` are all exposed on `Plan`, and every derived quantity below is a
/// reconstruction of a value the analysis computed internally, from those arrays.
/// </summary>
private static SeedResult Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
long floorPx, int separationPx, int seed, RegionLabels regions)
{
int total = n * n;
var r = new SeedResult
{
Seed = seed,
LandCells = plan.LandCells, SeaReachingCells = plan.SeaReachingCells,
EndorheicCells = plan.EndorheicCells, UnroutedCells = plan.UnroutedCells,
TerminalBasins = plan.TerminalBasinCount,
};
// ---- SEA: every outlet, then the separation rule ----
var outlets = new List<(int cell, long acc)>();
long seaSum = 0;
for (int i = 0; i < total; i++)
if (plan.Dir[i] == DrainageAnalysis.D_SEA) { outlets.Add((i, plan.Acc[i])); seaSum += plan.Acc[i]; }
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
r.SeaOutletsAll = outlets.Count;
var sea = new List<RiverCandidate>();
var kept = new List<int>();
foreach (var (cell, acc) in outlets)
{
int cx = cell / n, cy = cell % n;
bool far = true; int suppressor = -1;
foreach (int pcell in kept)
{
float ddx = cx - pcell / n, ddy = cy - pcell % n;
if (ddx * ddx + ddy * ddy < (float)separationPx * separationPx) { far = false; suppressor = pcell; break; }
}
var c = new RiverCandidate { IsSea = true, Cell = cell, X = cx, Y = cy, TermX = cx, TermY = cy, DrainagePx = acc, SuppressedBySeparation = !far };
if (far) kept.Add(cell);
else
{
r.SuppressedCount++; r.SuppressedPx += acc;
if (acc >= floorPx)
{
r.SuppressedAboveFloor++; r.SuppressedAboveFloorPx += acc; r.SuppressedAboveFloorAccs.Add(acc);
// ⚠⚠ IS THE SUPPRESSOR EVEN ON THE SAME LANDMASS? The separation rule is a plain
// Euclidean distance test — it has no idea what land a coastline belongs to. On
// this deliberately fragmented archipelago (→ D-063) that means an ISLAND's only
// river can be suppressed by a mainland river's mouth 400 px away ACROSS WATER,
// which is not a delta by any definition. Measured here rather than argued.
// ⚠ The rule is NOT changed — it belongs to the analysis, and changing it would
// move the candidate set the developer is being asked to judge. This counts
// what it costs, so the count decision is made knowing it.
if (regions != null && suppressor >= 0)
{
int a = regions.Id[cell], b = regions.Id[suppressor];
if (a != 0 && b != 0 && a != b) { r.SuppressedCrossLandmass++; r.SuppressedCrossLandmassPx += acc; }
}
}
}
if (acc >= floorPx) sea.Add(c);
}
// ---- ENDORHEIC: every terminal basin, metrics re-derived ----
// After the analysis's reversion, BasinId is non-zero ONLY on terminal-basin cells, and
// Filled == the original height there — so FullFilled height IS the fill depth, and the
// basin minimum is the argmin of height over the basin's cells. Both reconstruct exactly
// what the analysis computed internally as basinMinCell / basinDepthM / basinAreaPx.
int maxId = 0;
for (int i = 0; i < total; i++) if (plan.BasinId[i] > maxId) maxId = plan.BasinId[i];
var area = new long[maxId + 1];
var minCell = new int[maxId + 1];
var minH = new float[maxId + 1];
var depth = new float[maxId + 1];
for (int id = 0; id <= maxId; id++) { minCell[id] = -1; minH[id] = float.MaxValue; }
for (int i = 0; i < total; i++)
{
int id = plan.BasinId[i];
if (id == 0) continue;
area[id]++;
float h = height[i / n, i % n];
if (h < minH[id]) { minH[id] = h; minCell[id] = i; }
float d = WorldScale.MetresFromRaw(plan.FullFilled[i] - h);
if (d > depth[id]) depth[id] = d;
}
var endo = new List<RiverCandidate>();
long endoSum = 0;
for (int id = 1; id <= maxId; id++)
{
if (minCell[id] < 0) continue;
long inflow = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0;
endoSum += inflow;
if (inflow < floorPx) continue;
endo.Add(new RiverCandidate
{
IsSea = false, Cell = minCell[id], X = minCell[id] / n, Y = minCell[id] % n,
TermX = minCell[id] / n, TermY = minCell[id] % n, // replaced at bind time by the stem's pooling point
DrainagePx = inflow, BasinId = id, BasinAreaPx = area[id], BasinDepthM = depth[id],
});
}
// ═══ ⚠⚠ THE COMPARABILITY ASSERTION — the whole unified ranking rests on this ═══
//
// Both metrics are counts of contributing LAND CELLS on the same D8 field, and every land
// cell has exactly one destination — so the two populations partition the land exactly.
// If this identity ever fails, the two numbers are not the same unit and ranking them in
// one list is meaningless. It is asserted per seed rather than argued in a comment.
long partition = seaSum + endoSum + plan.UnroutedCells;
if (seaSum != plan.SeaReachingCells || endoSum != plan.EndorheicCells || partition != plan.LandCells)
throw new InvalidOperationException(
"[RiverPromotion] METRIC COMPARABILITY VIOLATION — the unified ranking is not sound on this field.\n" +
$" Σ Acc over sea outlets = {seaSum:N0}, expected SeaReachingCells = {plan.SeaReachingCells:N0}\n" +
$" Σ BasinInflow = {endoSum:N0}, expected EndorheicCells = {plan.EndorheicCells:N0}\n" +
$" sum + unrouted = {partition:N0}, expected LandCells = {plan.LandCells:N0}\n" +
"Sea-outlet drainage area and endorheic credited inflow must be the same unit over the same " +
"population for one ranking to mean anything. Refusing to rank. (rivers/02 Part 0 §2.)");
GD.Print($" ✅ comparability: Σ sea Acc {seaSum:N0} + Σ BasinInflow {endoSum:N0} + unrouted {plan.UnroutedCells:N0} == land {plan.LandCells:N0} — same unit, exact partition");
// ---- the unified ranking: one list, both termini, descending by contributing cells ----
var ranked = new List<RiverCandidate>();
foreach (var c in sea) if (!c.SuppressedBySeparation) ranked.Add(c);
ranked.AddRange(endo);
ranked.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
for (int i = 0; i < ranked.Count; i++) ranked[i].Rank = i + 1;
r.Ranked = ranked;
return r;
}
/// <summary>
/// The natural-break analysis and the per-N splits.
///
/// ⚠ TWO break statistics, because the obvious one is useless here. Drainage areas span three
/// or more decades, so the LARGEST ABSOLUTE GAP is almost always between rank 1 and rank 2 —
/// it measures the biggest river, not a natural count. The meaningful knee is the largest
/// RATIO between consecutive ranks, searched over a stated window that excludes the top of the
/// list. Both are reported; the ratio one is the answer.
/// </summary>
private static void Analyse(SeedResult r, int[] ladder)
{
var v = r.Ranked;
int lo = 3, hi = Math.Min(v.Count - 1, 40); // the stated window
r.BreakRankRatio = 0; r.BreakRatio = 1.0;
for (int i = lo - 1; i < hi; i++)
{
double ratio = v[i].DrainagePx / (double)Math.Max(1, v[i + 1].DrainagePx);
if (ratio > r.BreakRatio) { r.BreakRatio = ratio; r.BreakRankRatio = i + 1; }
}
r.BreakRankAbs = 0; r.BreakAbs = 0;
for (int i = 0; i < v.Count - 1; i++)
{
long gap = v[i].DrainagePx - v[i + 1].DrainagePx;
if (gap > r.BreakAbs) { r.BreakAbs = gap; r.BreakRankAbs = i + 1; }
}
foreach (int nn in ladder)
{
int s = 0, e = 0;
for (int i = 0; i < Math.Min(nn, v.Count); i++) { if (v[i].IsSea) s++; else e++; }
// ⭐ Would the separation rule have changed THIS rung? Count the suppressed outlets whose
// drainage clears the rung's cutoff. Zero means the rule cannot have altered the ladder,
// and the sea/endorheic split below is trustworthy exactly as shown.
long cut = nn <= v.Count ? v[nn - 1].DrainagePx : 0;
int would = 0;
foreach (long a in r.SuppressedAboveFloorAccs) if (a >= cut) would++;
r.WouldHaveMadeN[nn] = would;
// ⚠ A ladder rung above the candidate count is NOT "N with a 0 px smallest" — it is a rung
// the terrain cannot fill. Carried as a flag so the table says so instead of printing a 0.
r.AtN[nn] = (s, e, nn <= v.Count ? v[nn - 1].DrainagePx : 0, nn <= v.Count);
}
}
/// <summary>
/// Bind each promotable candidate to the <c>Trunk</c> / <c>Giant</c> the analysis already
/// traced, so the plates draw REAL upland stems rather than anything reimplemented here.
/// Sea binds by outlet cell (identical greedy pick, identical order); endorheic binds by
/// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells
/// at the minimum height and the analysis's DFS tie-break need not match a row-major scan.
/// </summary>
private static void BindCourses(SeedResult r, DrainageAnalysis.Plan plan, int n, int maxLadder)
{
var byOutlet = new Dictionary<int, DrainageAnalysis.Trunk>();
foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
var byBasin = new Dictionary<int, DrainageAnalysis.Giant>();
foreach (var g in plan.Giants)
{
int cell = (int)g.Terminal.x * n + (int)g.Terminal.y;
int id = plan.BasinId[cell];
if (id > 0 && !byBasin.ContainsKey(id)) byBasin[id] = g;
}
int missing = 0;
for (int i = 0; i < Math.Min(maxLadder, r.Ranked.Count); i++)
{
var c = r.Ranked[i];
if (c.IsSea)
{
if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; }
}
else
{
// ⚠ Take the RIVER's terminus from the Giant, not the basin minimum this candidate is
// keyed on — see RiverCandidate.TermX. They differ on a flat basin floor, and marking
// the wrong one draws every endorheic stem detached from its own endpoint.
if (byBasin.TryGetValue(c.BasinId, out var g)) { c.Course = g.Course; c.TermX = (int)g.Terminal.x; c.TermY = (int)g.Terminal.y; }
}
if (c.Course == null) missing++;
}
if (missing > 0)
throw new InvalidOperationException(
$"[RiverPromotion] {missing} of the top {maxLadder} candidates have no traced stem. The analysis's " +
"reporting caps are what produce the courses, so they must be at least the largest ladder count — " +
"raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
}
// ═══ OUTPUT ═══════════════════════════════════════════════════════════════════════════════
private static void Report(SeedResult r, int[] ladder)
{
var v = r.Ranked;
GD.Print($" candidates above the floor: {v.Count} ({CountSea(v)} sea / {v.Count - CountSea(v)} endorheic)");
GD.Print($" separation: {r.SeaOutletsAll:N0} raw sea outlets -> {r.SuppressedCount:N0} suppressed ({r.SuppressedPx:N0} px total, mostly one-cell coastal trickles);" +
$" of those only {r.SuppressedAboveFloor:N0} cleared the floor ({r.SuppressedAboveFloorPx:N0} px) — THAT is what the rule costs the candidate pool");
GD.Print($" ⚠ of those {r.SuppressedAboveFloor:N0}, {r.SuppressedCrossLandmass:N0} ({r.SuppressedCrossLandmassPx:N0} px) were suppressed by an outlet on a DIFFERENT LANDMASS" +
" — not a delta mouth by any definition; the rule is landmass-blind (reported, not changed)");
long maxSup = 0; foreach (long a in r.SuppressedAboveFloorAccs) if (a > maxSup) maxSup = a;
GD.Print($" ⭐ largest suppressed above-floor outlet: {maxSup:N0} px" +
$" · suppressed outlets that clear each ladder cutoff: " +
string.Join(" ", new List<string>(ladder.Length == 0 ? new string[0] : Array.ConvertAll(ladder, nn => $"N={nn}:{(r.WouldHaveMadeN.TryGetValue(nn, out int w) ? w : 0)}"))));
GD.Print($" knee (largest RATIO between consecutive ranks, window 3..40): rank {r.BreakRankRatio} at {r.BreakRatio:F2}x" +
$" · largest ABSOLUTE gap: rank {r.BreakRankAbs} ({r.BreakAbs:N0} px — expected near the top; not a count)");
foreach (int nn in ladder)
{
var (s, e, a, enough) = r.AtN[nn];
GD.Print(enough
? $" N={nn,-3} sea {s,2} / endorheic {e,2} smallest promoted {a:N0} px"
: $" N={nn,-3} ⚠ ONLY {r.Ranked.Count} CANDIDATES EXIST above the floor — this rung cannot be filled");
}
for (int i = 0; i < Math.Min(12, v.Count); i++)
GD.Print($" #{v[i].Rank,-3} {v[i].TerminusName,-9} {v[i].DrainagePx,12:N0} px ({v[i].X},{v[i].Y})" +
(v[i].IsSea ? "" : $" basin {v[i].BasinAreaPx:N0} px / {v[i].BasinDepthM:F1} m"));
}
private static int CountSea(List<RiverCandidate> v) { int s = 0; foreach (var c in v) if (c.IsSea) s++; return s; }
private static long MaxOf(List<long> v) { long m = 0; foreach (long a in v) if (a > m) m = a; return m; }
private static void WriteCsv(string batchRoot, SeedResult r)
{
var sb = new StringBuilder();
sb.AppendLine("rank,terminus,drainage_px,basin_min_x,basin_min_y,river_term_x,river_term_y,basin_id,basin_area_px,basin_depth_m");
foreach (var c in r.Ranked)
sb.AppendLine($"{c.Rank},{c.TerminusName},{c.DrainagePx},{c.X},{c.Y},{c.TermX},{c.TermY}," +
$"{(c.IsSea ? "" : c.BasinId.ToString())},{(c.IsSea ? "" : c.BasinAreaPx.ToString())}," +
$"{(c.IsSea ? "" : c.BasinDepthM.ToString("F2"))}");
WriteText(Path.Combine(batchRoot, $"candidates_{r.Seed}.csv"), sb.ToString());
}
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean,
Pass2Result p2, int n, float sea, int[] ladder, long floorPx, DrainageAnalysis.Params def, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
DrainageRenderer.Distribution(r.Ranked, ladder, def.EndorheicMinInflowPx, def.StemMinAccPx, floorPx,
$"SEED {r.Seed} - CANDIDATE DRAINAGE DISTRIBUTION")
.SavePng(Path.Combine(dir, "distribution.png"));
// ⚠ The faint-terrain base is 1 SetPixel per cell — 67 M at 8192 — and all four overlay maps
// share it. Build it once and duplicate; rendering it per map would quadruple the slowest
// part of this tool for four identical results.
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
DrainageRenderer.PromotionCandidates(r.Ranked, baseImg.Duplicate() as Image, n,
$"SEED {r.Seed} - ALL CANDIDATES (DIAGNOSTIC, NOTHING PROMOTED)", ladder)
.SavePng(Path.Combine(dir, "candidates_all.png"));
foreach (int nn in ladder)
{
var promoted = r.Ranked.GetRange(0, Math.Min(nn, r.Ranked.Count));
DrainageRenderer.PromotedRivers(promoted, baseImg.Duplicate() as Image, n, nn, floorPx,
$"SEED {r.Seed} - PROMOTED TOP {nn} (UNIFIED RANKING)")
.SavePng(Path.Combine(dir, $"promoted_N{nn:D2}.png"));
}
// Grayscale beside the pretty renders — the house rule: a colour map is a reading of a
// field, and the field itself must be inspectable without the palette in the way.
GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, int[] renderSe,
List<SeedResult> rows, int[] ladder, long floorPx, int promoteMax, DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = renderSe.Length > 0 ? renderSe[0] : seeds[0];
sb.AppendLine($"# Batch {02:D2} — river promotion: how many rivers does THIS terrain have?");
sb.AppendLine();
sb.AppendLine("**⛔ THIS IS A TASTE GATE. It presents 8 / 12 / 16 and stops — no count is chosen here, and no");
sb.AppendLine("default is set.** The measurement below exists so the pick is made on the drainage-area");
sb.AppendLine("distribution rather than on a number inherited from terrain that no longer exists.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick");
sb.AppendLine();
sb.AppendLine($"Open the three count plates for seed `{primary}` **side by side**:");
sb.AppendLine();
foreach (int nn in ladder) sb.AppendLine($"- **`{primary}/promoted_N{nn:D2}.png`**");
sb.AppendLine();
sb.AppendLine($"Then `{primary}/distribution.png` beside them — it shows where each N falls on the curve, and");
sb.AppendLine("whether the terrain has a knee to justify one. **The question is not \"which looks prettiest\"**");
sb.AppendLine("but: *at which N do the promoted rivers still read as the island's major drainages, and at which");
sb.AppendLine("N does the set start including things that are not rivers?*");
sb.AppendLine();
sb.AppendLine("> ### ⚠ Read the colours as information, not decoration.");
sb.AppendLine("> **Cyan = reaches the ocean, orange = ends inland.** On this terrain ~68 % of land drains");
sb.AppendLine("> inland by design (→ D-065), so a plate that is mostly orange is the CORRECT result, not a");
sb.AppendLine("> broken one. An endorheic terminus is a pass, equal to reaching the sea — never a fallback.");
sb.AppendLine();
sb.AppendLine("## ⭐ The unified ranking — what changed, and why");
sb.AppendLine();
sb.AppendLine("The reference promoted from **two lists with two quotas** (N sea trunks, N endorheic giants).");
sb.AppendLine("That structure assumes reaching the sea is what makes a drainage a river. **This terrain does not");
sb.AppendLine("satisfy that assumption**, so selection here is unified: every major drainage is ranked by");
sb.AppendLine("contributing-cell count in ONE list, the top N is promoted, and the sea/endorheic split is an");
sb.AppendLine("*outcome*. A quota would have promoted small coastal drainages over far larger inland ones purely");
sb.AppendLine("because of where they end. *(A deliberate departure from the reference's structure; only the");
sb.AppendLine("SELECTION is unified — the per-river terminus tag is retained, because rivers/03's routing branches on it.)*");
sb.AppendLine();
sb.AppendLine("**The metric is the same unit on both sides, and that is asserted, not assumed:** sea-outlet");
sb.AppendLine("`Acc` and endorheic `BasinInflow` are both counts of contributing land cells on the same D8");
sb.AppendLine("field, and every land cell has exactly one destination. The tool refuses to rank unless");
sb.AppendLine("`Σ sea Acc + Σ BasinInflow + unrouted == LandCells` holds exactly, per seed. It held on every seed.");
sb.AppendLine();
sb.AppendLine("## The natural-break question — does a count generalize across the terrain?");
sb.AppendLine();
sb.AppendLine("**The knee is the largest RATIO between consecutive ranks** (window 3..40). The largest *absolute*");
sb.AppendLine("gap is reported too, and is deliberately not the answer: areas span 3+ decades, so the absolute");
sb.AppendLine("gap almost always sits at rank 12 and measures the biggest river, not a natural count.");
sb.AppendLine();
sb.AppendLine("| Seed | candidates | sea / endo | ⭐ knee rank (ratio) | largest abs gap (rank) | " +
string.Join(" | ", Array.ConvertAll(ladder, x => $"N={x} sea/endo · smallest px")) + " |");
sb.AppendLine("|---|---|---|---|---|" + string.Concat(Array.ConvertAll(ladder, _ => "---|")));
foreach (var r in rows)
{
var cells = new List<string>();
foreach (int nn in ladder)
{
var (s, e, a, enough) = r.AtN[nn];
cells.Add(enough ? $"{s} / {e} · {a:N0}" : $"⚠ only {r.Ranked.Count}");
}
sb.AppendLine($"| `{r.Seed}` | {r.Ranked.Count} | {CountSea(r.Ranked)} / {r.Ranked.Count - CountSea(r.Ranked)} | " +
$"**{r.BreakRankRatio}** ({r.BreakRatio:F2}×) | {r.BreakRankAbs} ({r.BreakAbs:N0} px) | " +
string.Join(" | ", cells) + " |");
}
sb.AppendLine();
sb.AppendLine("| Seed | land cells | → ocean | → endorheic | unrouted | terminal basins | raw sea outlets | suppressed (all) | suppressed ABOVE the floor | of those, cross-landmass | largest suppressed | ⭐⭐ would have made N=8/12/16 |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | {r.LandCells:N0} | {r.SeaReachingCells:N0} ({100.0 * r.SeaReachingCells / Math.Max(1, r.LandCells):F1} %) | " +
$"**{r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %)** | {r.UnroutedCells:N0} | " +
$"{r.TerminalBasins} | {r.SeaOutletsAll:N0} | {r.SuppressedCount:N0} ({r.SuppressedPx:N0} px) | " +
$"**{r.SuppressedAboveFloor:N0} ({r.SuppressedAboveFloorPx:N0} px)** | " +
$"{r.SuppressedCrossLandmass:N0} ({r.SuppressedCrossLandmassPx:N0} px) | " +
$"{MaxOf(r.SuppressedAboveFloorAccs):N0} px | " +
$"**{string.Join(" / ", Array.ConvertAll(ladder, nn => (r.WouldHaveMadeN.TryGetValue(nn, out int w) ? w : 0).ToString()))}** |");
sb.AppendLine();
sb.AppendLine("> ### ⚠ What the separation rule discards, stated plainly");
sb.AppendLine($"> `MinOutletSeparationPx = {def.MinOutletSeparationPx}` drops a sea outlet when a larger one sits within that radius —");
sb.AppendLine("> so three mouths of one delta are not three rivers. **But under D8 each cell has exactly one flow");
sb.AppendLine("> path, so those mouths have DISJOINT contributing areas: the rule discards a dropped outlet's");
sb.AppendLine("> drainage rather than merging it into the kept one.** For counting rivers that is the intent; the");
sb.AppendLine("> two suppressed columns are how much it removes. ⚠ Read the SECOND one: a fragmented coastline");
sb.AppendLine("> has tens of thousands of one-cell outlets, so the aggregate figure is dominated by drainage that");
sb.AppendLine("> was never a candidate. Only outlets that cleared the floor could ever have been promoted, and");
sb.AppendLine("> that is the honest cost of the rule.");
sb.AppendLine(">");
sb.AppendLine("> ### ⚠⚠ AND THE RULE IS LANDMASS-BLIND — the last column is the part that should bother you.");
sb.AppendLine("> `MinOutletSeparationPx` is a plain Euclidean distance test. It has no idea which land a");
sb.AppendLine("> coastline belongs to, so on **this deliberately fragmented archipelago (→ D-063)** it can");
sb.AppendLine("> suppress an ISLAND's only river because a mainland river's mouth sits within 400 px **across");
sb.AppendLine("> open water** — which is not a delta by any definition. The last column counts exactly that.");
sb.AppendLine(">");
sb.AppendLine("> **The rule was NOT changed here.** It belongs to `DrainageAnalysis`, and moving it would move");
sb.AppendLine("> the candidate set this gate asks the developer to judge. It is measured so the count is chosen");
sb.AppendLine("> knowing the cost. **→ rivers/03 should decide whether separation becomes component-aware**");
sb.AppendLine("> (the region layer already exposes `RegionLabels.Id` per cell, so the fix is one lookup) —");
sb.AppendLine("> and if it does, the sea side of this ranking grows and the split at each N shifts.");
sb.AppendLine();
sb.AppendLine("> ### ⚠⚠ This analysis is SCALE-DEPENDENT, and that is a finding, not a footnote.");
sb.AppendLine($"> `EndorheicMinAreaPx` ({def.EndorheicMinAreaPx:N0}) and `MinOutletSeparationPx` ({def.MinOutletSeparationPx}) are ABSOLUTE pixel counts tuned at");
sb.AppendLine("> 8192. Measured at 1024 during this task's smoke: **zero** depressions qualify as terminal basins");
sb.AppendLine("> (100 % sea-reaching — the endorheic half of the ranking cannot be exercised at all), and the");
sb.AppendLine("> separation radius is 39 % of the map width, suppressing 15,043 of 15,048 sea outlets down to four");
sb.AppendLine("> candidates. **A small-map run of this tool measures the params, not the terrain**, so every number");
sb.AppendLine("> below is from 8192 and the tool prints a loud refusal-to-compare at any other size.");
sb.AppendLine("> *(Flagged for rivers/03: if routing ever needs another size, these two want to become");
sb.AppendLine("> scale-free fractions of map area / width, exactly as `MinLandComponentFrac` already is.)*");
sb.AppendLine();
sb.AppendLine("## What was run");
sb.AppendLine();
sb.AppendLine($"Distribution on **{seeds.Length} seeds** at {mapSize} (`{string.Join(", ", seeds)}`); maps on **{renderSe.Length}**");
sb.AppendLine($"(`{string.Join(", ", renderSe)}`). Curve calibrated at {calibSize} on the family-off pinned pool.");
sb.AppendLine($"Terrain: {TerrainShapeV1.Describe()} + erosion ON — all from the bare defaults (rivers/01).");
sb.AppendLine();
sb.AppendLine($"**Diagnostic floor** `{floorPx:N0}` px — the significance floor for the DISTRIBUTION, deliberately far");
sb.AppendLine("below any plausible count so the curve's shape is visible. **It is not a promotion threshold.**");
sb.AppendLine();
sb.AppendLine($"**Reporting caps raised** to `{promoteMax}` (`TrunkCount` / `GiantCount` / `EndorheicMaxCount`, all 3 by");
sb.AppendLine("default) purely so the analysis traces a real upland stem for every promotable candidate.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} decide which");
sb.AppendLine("depressions BECOME terminal basins — they define the routing surface itself, not how much of it is");
sb.AppendLine($"reported. Also unchanged: `MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}, `TributaryMinAccPx` {def.TributaryMinAccPx:N0}.");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `<seed>/distribution.png` | drainage area (log) vs unified rank, with N marks and the analysis's own thresholds |");
sb.AppendLine("| `<seed>/candidates_all.png` | every candidate on the terrain, marker AREA ∝ drainage, colour by terminus |");
foreach (int nn in ladder) sb.AppendLine($"| `<seed>/promoted_N{nn:D2}.png` | the unified top {nn}: real upland stems, width ∝ drainage, terminus markers |");
sb.AppendLine("| `<seed>/grayscale.png` | the raw eroded render field, no palette — the field behind every colour map |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written.** rivers/01 proved this exact field byte-identical to `chat2/11_erosion`, which is the anchor of record — re-dumping 256 MB per seed of a field that already exists elsewhere is waste, not evidence. Regenerate with `ISLA_SKIP_RAW=0`. |");
else
sb.AppendLine("| `<seed>/height.f32` | the eroded render field this analysis ran on |");
sb.AppendLine("| `candidates_<seed>.csv` | the full ranked list for every distribution seed |");
sb.AppendLine();
sb.AppendLine("## ⚠ What is NOT here");
sb.AppendLine();
sb.AppendLine("- **No lowland routing.** The plates draw the REAL upland stems (erosion-carved, max-accumulation).");
sb.AppendLine(" `Giant.ProvisionalRoute` — the steepest-descent placeholder, the visible \"comb\" — is deliberately");
sb.AppendLine(" **not drawn**; replacing it is rivers/03's job, and drawing it would make a count look like a");
sb.AppendLine(" finished network. Below each terminus the real course is still un-routed.");
sb.AppendLine("- **No water, no carving, no crater.** Nothing here modifies terrain; the analysis is pure.");
sb.AppendLine("- **No chosen count.** That is the developer's call, and it is the point of the gate.");
sb.AppendLine();
sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
sb.AppendLine();
sb.AppendLine("→ `XX_Human/output/rivers/02_promotion.report.md`");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
// ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
}.WithFamilyOff();
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
}
// ---- env / io -----------------------------------------------------------------------------
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static 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;
}
}
}