islaApocalypse-v2/Tools/Scripts/RiverPromotionTool.cs
beezm 4e4be6a83e rivers/03: lowland routing — the routed MIX on the pure N=12, courses only
Ports the ROUTING PORTION of the reference's RiverCarvePass (RouteToOcean, the
routed/lake-ender sort, SmoothCourse). NOT CarveRiver (bed stamp) and NOT
AddSteppedWater (water bodies) — those are later tasks.

RED LINE: no height mutated, no water filled, nothing carved. Asserted per seed
by an FNV digest of both height fields before/after routing.

- RiverRouting: deterministic LOWGROUND Dijkstra, uphill penalised so a route may
  cross the basin rim, empty-list-on-no-path. Effective == declared constants
  (verified: private const, no ConfigManager key, no [Export] in the reference).
- The sort is the REFERENCE's — Kind = basinHasLake ? lake-ender : routed. The
  task's stated "a path exists -> routed" cannot discriminate: on an 8-connected
  grid a path to the ocean always exists, confirmed empirically (43/43 probes
  reached). The ocean route is probed for every giant anyway, so the missing
  affordability threshold is reported as a number rather than guessed.
- RegionLabeling.SignificantWaterMask: interim substitute for v2's missing
  water-bodies table — 8-connected classify-water components >= 20,000 px.
- RiverCandidates: the candidate enumeration extracted out of RiverPromotionTool
  so routing ranks the identical set the count gate was judged on. Behaviour
  neutral — rivers/02b's twelve plates are byte-identical across the extraction.
- DrainageRenderer.RoutedMix: three classes, with each routed river's added
  lowland reach and the rim it crossed drawn distinctly from its natural stem.

Taste gate: no count, no K, no style, no default set.
2026-08-24 04:54:30 -04:00

1074 lines
68 KiB
C#
Raw Permalink 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)
///
/// ═══ ⭐⭐ THE COMPOSITION MODE (rivers/02b) — ISLA_PROMOTE_MODE=composition ═══
///
/// rivers/02 (above) answered "how many?" and found the terrain has no natural count, and that its
/// honest top-of-distribution is INLAND-DOMINANT — 6 of 8 seeds have zero sea-reaching rivers in
/// their top 8. The developer wants to keep the total at a comfortable N but GUARANTEE coastal
/// presence for gameplay (the southern shipwreck, boats, coastal freshwater).
///
/// > ### That is a CONSCIOUS, MOTIVATED OVERRIDE of the pure unified ranking, not a correction of it.
/// > The unified ranking stays the mechanism. A **gameplay-motivated sea-river floor of K** is
/// > layered on top: the K largest SEA-REACHING candidates are forced in, and NK inland ones fill
/// > the rest. It deliberately reopens the "no sea quota" call from rivers/02 — now that the honest
/// > distribution has been seen, and for a stated reason that is about the game, not the terrain.
///
/// This mode renders the two compositions side by side at the same total so the developer can judge
/// the one thing that decides it: **do the forced sea rivers read as real rivers, just smaller — or
/// as sad thin threads beside the big inland ones?**
///
/// ISLA_PROMOTE_MODE=composition pure-vs-quota instead of the count ladder
/// ISLA_PROMOTE_N the fixed total (default 12)
/// ISLA_PROMOTE_QUOTA_K the sea-river floor (default 3)
///
/// ⚠ It re-runs NOTHING it does not need: the 8-seed distribution sweep, `candidates_all` and
/// `distribution` are unchanged by a re-selection and are NOT regenerated. `DrainageAnalysis` is
/// again reused untouched — the quota is a re-selection over the candidates rivers/02 already
/// enumerated, not a new analysis. **And it locks nothing: no N, no K, no default.**
/// </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;
/// <summary>rivers/02b only — the two compositions at the fixed total. Null in ladder mode.</summary>
public Composition Comp;
}
/// <summary>
/// ⭐⭐ THE TWO COMPOSITIONS AT ONE FIXED TOTAL (rivers/02b) — same N, different mix.
///
/// PURE the top N candidates by drainage area, terminus irrelevant. What rivers/02 produces.
/// QUOTA the K largest SEA-REACHING candidates forced in, plus the NK largest endorheic.
///
/// ⚠ Both are selections over the SAME unified ranking, computed by the SAME metric. The quota
/// does not re-rank anything and does not touch the analysis — it re-picks from the list.
/// </summary>
private sealed class Composition
{
public int N, K;
public List<RiverCandidate> Pure = new();
public List<RiverCandidate> Quota = new();
/// <summary>The K (or fewer) forced sea rivers, descending.</summary>
public List<RiverCandidate> ForcedSea = new();
/// <summary>The NK (or fewer) inland rivers kept beside them, descending.</summary>
public List<RiverCandidate> KeptInland = new();
/// <summary>In PURE but not in QUOTA — the inland basins the floor displaced to make room.</summary>
public List<RiverCandidate> Displaced = new();
/// <summary>In QUOTA but not in PURE — the sea rivers the floor promoted.</summary>
public List<RiverCandidate> Added = new();
public int SeaAvailable, EndoAvailable;
/// <summary>⚠ Fewer than K sea / NK inland candidates existed above the floor. Should not fire.</summary>
public bool SeaShort, EndoShort;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
// ⭐ rivers/02b: "composition" swaps the count ladder for one fixed total in two mixes.
bool composition = EnvStr("ISLA_PROMOTE_MODE", "ladder").Trim().ToLowerInvariant() == "composition";
int compN = EnvInt("ISLA_PROMOTE_N", 12);
int compK = EnvInt("ISLA_PROMOTE_QUOTA_K", 3);
int task = EnvInt("ISLA_TASK", 2);
// ⭐ rivers/02b is a LETTERED SUB-TASK of 02 — same authoring task, one changed choice — so it
// writes to `02b_composition`, not to a fresh task number that would claim to be task 03.
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", composition ? "b" : "");
string descr = EnvStr("ISLA_BATCH", composition ? "composition" : "promotion");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
// ⚠ In composition mode the DEFAULT seed set is the 4 RENDER seeds, not the 8 gallery seeds:
// the 8-seed distribution sweep is unchanged by a re-selection and re-running it would be
// ~2x the work for byte-identical numbers. rivers/02's sweep stands as the distribution of
// record. (Override with ISLA_SEEDS if that is ever wanted.)
int[] seeds = EnvSeeds("ISLA_SEEDS", composition ? DefaultRenderSeeds : GallerySeeds);
int[] renderSe = EnvSeeds("ISLA_RENDER_SEEDS", DefaultRenderSeeds);
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
int[] ladder = composition ? new[] { compN } : EnvSeeds("ISLA_PROMOTE_N_LADDER", new[] { 8, 12, 16 });
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
if (composition)
{
if (compK < 0 || compK > compN)
throw new InvalidOperationException(
$"ISLA_PROMOTE_QUOTA_K ({compK}) must be between 0 and ISLA_PROMOTE_N ({compN}) — the quota is a FLOOR " +
"inside a fixed total, not a second budget on top of it.");
// The forced sea rivers come from Plan.Trunks and the kept inland from Plan.Giants, both
// truncated at the raised cap — so the cap must cover BOTH halves of the quota as well
// as a pure top-N that happens to be all inland. Refuse rather than draw a bare marker.
if (promoteMax < compK || promoteMax < compN - compK)
throw new InvalidOperationException(
$"ISLA_PROMOTE_MAX ({promoteMax}) is below the quota's halves (K={compK} sea, N-K={compN - compK} inland). The cap is " +
"what makes the analysis trace a real upland stem for each; below it, a quota'd river would have no course to draw.");
}
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, taskSfx, 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(composition
? $" RIVER COMPOSITION (rivers/02b) — N={compN} PURE vs N={compN} with a gameplay sea-river floor of K={compK}"
: " 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.");
if (composition)
{
GD.Print($" ⭐ and then, ON PURPOSE, a GAMEPLAY SEA-RIVER FLOOR of K={compK} layered over it:");
GD.Print($" PURE = top {compN} by drainage, terminus irrelevant (the mechanism, unaltered)");
GD.Print($" QUOTA = the {compK} largest SEA-REACHING forced + the {compN - compK} largest endorheic ({compK} sea + {compN - compK} inland = {compN})");
GD.Print($" The floor is motivated by GAMEPLAY (shipwreck / boats / coastal freshwater), not by the terrain,");
GD.Print($" and it deliberately reopens rivers/02's \"no sea quota\" call now the honest distribution is known.");
GD.Print($" width : ONE fixed constant, shared across both plates and all seeds — {DrainageRenderer.StemWidthLaw()}");
}
else
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)");
if (!composition)
GD.Print($"ladder : N = {string.Join(", ", ladder)} caps raised to {promoteMax} so every promotable river has a traced stem");
else
GD.Print($"fixed N : {compN} 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);
if (composition)
{
// ⚠ Bind the UNION of both compositions, not the top N: a forced sea river can sit far
// down the unified ranking (measured at rivers/02: rank 29 on the primary seed), so
// "top maxLadder" would leave it with no traced stem.
r.Comp = Compose(r, compN, compK);
var need = new List<RiverCandidate>(r.Comp.Pure);
foreach (var c in r.Comp.Quota) if (!need.Contains(c)) need.Add(c);
BindCourses(r, plan, mapSize, need, $"the union of the N={compN} pure and K={compK} quota compositions");
ReportComposition(r);
WriteCompositionCsv(batchRoot, r);
if (renderSet.Contains(seed))
RenderComposition(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, skipRaw);
}
else
{
BindCourses(r, plan, mapSize, r.Ranked.GetRange(0, Math.Min(maxLadder, r.Ranked.Count)), $"the top {maxLadder} candidates");
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);
}
if (composition)
WriteCompositionIndex(batchRoot, mapSize, calibSize, seeds, renderSe, results, compN, compK, floorPx, promoteMax, dpDefaults, skipRaw);
else
WriteIndex(batchRoot, mapSize, calibSize, seeds, renderSe, results, ladder, floorPx, promoteMax, dpDefaults, skipRaw);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print(composition
? $" ⛔ TASTE GATE: pure and quota K={compK} are PRESENTED, not decided. No N, no K, no default was set."
: " ⛔ TASTE GATE: 8 / 12 / 16 are PRESENTED, not decided. The developer picks N.");
GD.Print("==================================================================");
GetTree().Quit(0);
}
// ═══ ENUMERATION — delegated to RiverCandidates (extracted at rivers/03) ═══
//
// ⚠ The enumeration moved to `RiverCandidates` UNCHANGED so routing ranks the identical set this
// gate was judged on. Two copies would be two answers to "which rivers does the island have".
// Verified: rivers/02b's twelve plates are byte-identical across the extraction.
private static SeedResult Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
long floorPx, int separationPx, int seed, RegionLabels regions)
{
var e = RiverCandidates.Enumerate(plan, height, n, floorPx, separationPx, regions);
return new SeedResult
{
Seed = seed, Ranked = e.Ranked,
LandCells = e.LandCells, SeaReachingCells = e.SeaReachingCells,
EndorheicCells = e.EndorheicCells, UnroutedCells = e.UnroutedCells,
TerminalBasins = e.TerminalBasins, SeaOutletsAll = e.SeaOutletsAll,
SuppressedCount = e.SuppressedCount, SuppressedPx = e.SuppressedPx,
SuppressedAboveFloor = e.SuppressedAboveFloor, SuppressedAboveFloorPx = e.SuppressedAboveFloorPx,
SuppressedCrossLandmass = e.SuppressedCrossLandmass, SuppressedCrossLandmassPx = e.SuppressedCrossLandmassPx,
SuppressedAboveFloorAccs = e.SuppressedAboveFloorAccs,
};
}
/// <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>Delegated to <see cref="RiverCandidates.BindCourses"/> — see the note on Enumerate.</summary>
private static void BindCourses(SeedResult r, DrainageAnalysis.Plan plan, int n, List<RiverCandidate> need, string what)
=> RiverCandidates.BindCourses(plan, n, need, what);
// ═══ ⭐⭐ THE COMPOSITION (rivers/02b) — the same N, two mixes ════════════════════════════
/// <summary>
/// ⭐⭐ Build both compositions at the fixed total N.
///
/// PURE <c>Ranked[0..N)</c> — the unified ranking, untouched. Terminus irrelevant.
/// QUOTA the K largest SEA-REACHING candidates + the NK largest ENDORHEIC ones.
///
/// The ranking is already descending, so both halves of the quota are just the first K / NK of
/// each terminus class. Fully deterministic — no tie-break, no randomness, no re-ranking. **The
/// metric is the same on both sides and the analysis is not consulted again: this is a
/// re-selection over candidates that were already enumerated.**
///
/// ⚠ Shortfall is handled rather than crashed: if fewer than K sea (or NK inland) candidates
/// exist above the floor, ALL available are taken and the plate carries fewer than N rivers,
/// flagged into the INDEX. It is NOT back-filled from the other class — that would silently
/// turn "the terrain cannot supply K sea rivers" into a plate that looks like it can. rivers/02
/// measured 6478 sea and 4561 endorheic candidates per seed, so this should not fire.
/// </summary>
private static Composition Compose(SeedResult r, int N, int K)
{
var v = r.Ranked;
var comp = new Composition { N = N, K = K };
comp.Pure = v.GetRange(0, Math.Min(N, v.Count));
var sea = new List<RiverCandidate>();
var endo = new List<RiverCandidate>();
foreach (var c in v) { if (c.IsSea) sea.Add(c); else endo.Add(c); }
comp.SeaAvailable = sea.Count;
comp.EndoAvailable = endo.Count;
comp.SeaShort = sea.Count < K;
comp.EndoShort = endo.Count < N - K;
for (int i = 0; i < Math.Min(K, sea.Count); i++) comp.ForcedSea.Add(sea[i]);
for (int i = 0; i < Math.Min(N - K, endo.Count); i++) comp.KeptInland.Add(endo[i]);
comp.Quota.AddRange(comp.ForcedSea);
comp.Quota.AddRange(comp.KeptInland);
comp.Quota.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
foreach (var c in comp.Pure) if (!comp.Quota.Contains(c)) comp.Displaced.Add(c);
foreach (var c in comp.Quota) if (!comp.Pure.Contains(c)) comp.Added.Add(c);
return comp;
}
/// <summary>The largest / smallest inland river the quota keeps — the yardsticks for "thread".</summary>
private static long LargestInland(Composition c) => c.KeptInland.Count > 0 ? c.KeptInland[0].DrainagePx : 0;
private static long SmallestInland(Composition c) => c.KeptInland.Count > 0 ? c.KeptInland[c.KeptInland.Count - 1].DrainagePx : 0;
private static string Compact(List<RiverCandidate> v)
{
var parts = new List<string>();
foreach (var c in v) parts.Add($"#{c.Rank}{(c.IsSea ? "S" : "E")} {c.DrainagePx:N0}");
return parts.Count == 0 ? "(none)" : string.Join(" · ", parts);
}
private static string Ordinal(int zeroBased)
{
int i = zeroBased + 1;
if (i == 1) return "largest";
string suffix = (i % 100 is >= 11 and <= 13) ? "th" : (i % 10) switch { 1 => "st", 2 => "nd", 3 => "rd", _ => "th" };
return $"{i}{suffix}";
}
/// <summary>
/// Coarse compass label for an outlet, from its offset relative to the map centre. ⚠ In this
/// codebase a cell is <c>x*n + y</c> and the image is drawn <c>SetPixel(x, y)</c>, so +y is
/// SOUTH on the plate — the sign is easy to get backwards and the label would then be a lie.
/// </summary>
private static string Compass(int x, int y, int n)
{
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; // dy > 0 = south
const float band = 0.35f; // "central" deadband
string ns = dy < -band ? "N" : dy > band ? "S" : "";
string ew = dx < -band ? "W" : dx > band ? "E" : "";
return ns + ew == "" ? "centre" : ns + ew;
}
private static void ReportComposition(SeedResult r)
{
var c = r.Comp;
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
GD.Print($" candidates above the floor: {r.Ranked.Count} ({c.SeaAvailable} sea / {c.EndoAvailable} endorheic)");
GD.Print($" PURE N={c.N} sea {pureSea} / inland {c.Pure.Count - pureSea}");
GD.Print($" {Compact(c.Pure)}");
GD.Print($" QUOTA N={c.N} K={c.K} sea {c.ForcedSea.Count} / inland {c.KeptInland.Count}");
GD.Print($" {Compact(c.Quota)}");
if (c.SeaShort) GD.PrintErr($" ⚠⚠ ONLY {c.SeaAvailable} SEA CANDIDATES EXIST above the floor — fewer than K={c.K}. All available taken; the quota plate carries {c.Quota.Count} rivers, not {c.N}.");
if (c.EndoShort) GD.PrintErr($" ⚠⚠ ONLY {c.EndoAvailable} ENDORHEIC CANDIDATES EXIST above the floor — fewer than N-K={c.N - c.K}. All available taken; the quota plate carries {c.Quota.Count} rivers, not {c.N}.");
long lo = SmallestInland(c), hi = LargestInland(c);
GD.Print($" ⭐ the thread question, quantified — inland yardsticks: smallest kept {lo:N0} px (the {c.KeptInland.Count}th), largest kept {hi:N0} px");
foreach (var f in c.ForcedSea)
GD.Print($" forced sea #{f.Rank,-4} {f.DrainagePx,10:N0} px {(lo > 0 ? f.DrainagePx / (double)lo : 0):F3}x the smallest kept inland " +
$"{(hi > 0 ? f.DrainagePx / (double)hi : 0):F3}x the largest stem {DrainageRenderer.StemWidthFixed(f.DrainagePx)} px wide" +
(DrainageRenderer.StemWidthAtFloor(f.DrainagePx) ? " (AT THE LEGIBILITY FLOOR)" : ""));
GD.Print($" displaced by the floor (in PURE, not in QUOTA): {Compact(c.Displaced)}");
GD.Print($" promoted by the floor (in QUOTA, not in PURE): {Compact(c.Added)}");
}
private static void WriteCompositionCsv(string batchRoot, SeedResult r)
{
var c = r.Comp;
var sb = new StringBuilder();
sb.AppendLine("composition,slot,terminus,drainage_px,unified_rank,forced,river_term_x,river_term_y,basin_id,stem_width_px,in_other_composition");
void Rows(string name, List<RiverCandidate> v, List<RiverCandidate> other)
{
for (int i = 0; i < v.Count; i++)
{
var x = v[i];
bool forced = name == "quota" && x.IsSea;
sb.AppendLine($"{name},{i + 1},{x.TerminusName},{x.DrainagePx},{x.Rank},{(forced ? "yes" : "no")},{x.TermX},{x.TermY}," +
$"{(x.IsSea ? "" : x.BasinId.ToString())},{DrainageRenderer.StemWidthFixed(x.DrainagePx)},{(other.Contains(x) ? "yes" : "no")}");
}
}
Rows("pure", c.Pure, c.Quota);
Rows("quota", c.Quota, c.Pure);
WriteText(Path.Combine(batchRoot, $"composition_{r.Seed}.csv"), sb.ToString());
}
/// <summary>
/// The two plates, on ONE shared faint-terrain base, at the ONE shared width scale.
///
/// ⚠ `candidates_all` and `distribution` are NOT re-rendered: a re-selection does not change the
/// candidate set or its distribution, and rivers/02's plates are the ones of record.
/// </summary>
private static void RenderComposition(string batchRoot, SeedResult r, bool[] isOcean,
Pass2Result p2, int n, float sea, long floorPx, bool skipRaw)
{
var c = r.Comp;
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
// One base, duplicated — it is 1 SetPixel per cell (67 M at 8192) and both plates share it.
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
DrainageRenderer.RiverComposition(c.Pure, baseImg.Duplicate() as Image, n,
$"SEED {r.Seed} - N{c.N} PURE UNIFIED RANKING",
$"PURE: THE TOP {c.N} BY DRAINAGE AREA, TERMINUS IRRELEVANT - {pureSea} SEA / {c.Pure.Count - pureSea} INLAND, A SPLIT THAT FELL OUT AND WAS NEVER QUOTA'D",
r.Ranked.Count, floorPx, true)
.SavePng(Path.Combine(dir, $"N{c.N}_pure.png"));
long lo = SmallestInland(c);
DrainageRenderer.RiverComposition(c.Quota, baseImg.Duplicate() as Image, n,
$"SEED {r.Seed} - N{c.N} WITH A GAMEPLAY SEA-RIVER FLOOR OF K={c.K}",
$"QUOTA: THE {c.ForcedSea.Count} LARGEST SEA-REACHING FORCED IN + THE {c.KeptInland.Count} LARGEST INLAND - SAME TOTAL, DIFFERENT MIX. SMALLEST KEPT INLAND {lo:N0} PX",
r.Ranked.Count, floorPx, true)
.SavePng(Path.Combine(dir, $"N{c.N}_quota_K{c.K}.png"));
// Grayscale beside the pretty plates — the house rule: the field must be inspectable
// without the palette in the way. Ranges printed so the plate is readable as data.
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
}
// ═══ 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());
}
/// <summary>
/// ⭐ THE COMPOSITION INDEX (rivers/02b) — it foregrounds the ONE question the developer stated,
/// and puts the numbers that predict the answer above the plates that confirm it.
/// </summary>
private static void WriteCompositionIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, int[] renderSe,
List<SeedResult> rows, int compN, int compK, long floorPx, int promoteMax, DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = renderSe.Length > 0 ? renderSe[0] : seeds[0];
string pure = $"N{compN}_pure.png", quota = $"N{compN}_quota_K{compK}.png";
sb.AppendLine($"# Batch 02b — composition: N={compN} pure ranking vs N={compN} with a gameplay sea-river floor of K={compK}");
sb.AppendLine();
sb.AppendLine("**⛔ THIS IS A TASTE GATE. Nothing is locked — no count, no K, no default in `TerrainGenConfig` or");
sb.AppendLine("`DrainageAnalysis.Params`.** Two compositions of the same total are presented; the developer picks.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick");
sb.AppendLine();
sb.AppendLine($"Open these two **side by side** for seed `{primary}`:");
sb.AppendLine();
sb.AppendLine($"- **`{primary}/{pure}`** — the top {compN} by drainage area, terminus irrelevant. rivers/02's mechanism, unaltered.");
sb.AppendLine($"- **`{primary}/{quota}`** — the same {compN}, re-mixed: the **{compK} largest sea-reaching forced in**, plus the {compN - compK} largest inland.");
sb.AppendLine();
var others = new List<string>();
foreach (int x in renderSe) if (x != primary) others.Add($"`{x}`");
sb.AppendLine($"Then confirm the read generalizes on the other {others.Count}: " + string.Join(", ", others) + ".");
sb.AppendLine();
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
sb.AppendLine($"> **Do the {compK} forced sea rivers hold up as REAL rivers — just smaller — or do they read as sad thin");
sb.AppendLine("> threads beside the big inland ones?**");
sb.AppendLine(">");
sb.AppendLine($"> - **Real → the quota is in at K={compK}.** Graduate as *\"unified ranking + a gameplay-motivated sea-river floor of K\"*.");
sb.AppendLine($"> - **Thin → drop to K=2** (a one-value re-run: `ISLA_PROMOTE_QUOTA_K=2`) **or accept inland-dominant** and keep the pure ranking.");
sb.AppendLine();
sb.AppendLine("## Why this override exists, and what it costs");
sb.AppendLine();
sb.AppendLine("rivers/02 established two things. The count is a **design choice** — the drainage-area distribution is a");
sb.AppendLine("power law with no natural break (the knee wanders rank 3→19 at ~1.4×). And this terrain's honest");
sb.AppendLine($"top-of-distribution is **inland-dominant**: 6 of 8 seeds have zero sea-reaching rivers in their top 8.");
sb.AppendLine();
sb.AppendLine($"The floor of K={compK} is a **conscious, motivated override of the pure ranking**, for gameplay — the southern");
sb.AppendLine("shipwreck, boats, coastal freshwater. **The unified ranking stays the mechanism**; the quota is layered");
sb.AppendLine("over it, and it deliberately reopens rivers/02's \"no sea quota\" call *on purpose*, now that the honest");
sb.AppendLine("distribution has been seen. It is not a correction of that call — it is a decision to overrule it for a");
sb.AppendLine("reason that is about the game rather than the terrain.");
sb.AppendLine();
sb.AppendLine("**What it costs is visible and bounded:** the displaced-basins column below names exactly which inland");
sb.AppendLine("drainages step aside, and the ratio columns say how much smaller the rivers taking their place are.");
sb.AppendLine();
sb.AppendLine("## ⭐⭐ The thread question, quantified — read this BEFORE opening the plates");
sb.AppendLine();
sb.AppendLine("The ratio of a forced sea river to the inland rivers it sits beside predicts \"thread\" before the eye");
sb.AppendLine($"is involved. Yardsticks: the **smallest inland kept** (the {compN - compK}th, the weakest thing the quota did NOT displace) and");
sb.AppendLine("the **largest inland** on the plate. A forced river near 1.0× the smallest is simply another river of the");
sb.AppendLine("set; one near 0.1× the largest is a thread next to it whatever the plate looks like.");
sb.AppendLine();
sb.AppendLine("| Seed | forced sea river | drainage px | unified rank | ÷ smallest inland kept | ÷ largest inland | stem px | ⚠ where it reaches the sea |");
sb.AppendLine("|---|---|---|---|---|---|---|---|");
int atFloorRows = 0;
foreach (var r in rows)
{
var c = r.Comp;
long lo = SmallestInland(c), hi = LargestInland(c);
for (int i = 0; i < c.ForcedSea.Count; i++)
{
var f = c.ForcedSea[i];
string bold = i == c.ForcedSea.Count - 1 ? "**" : "";
bool floor = DrainageRenderer.StemWidthAtFloor(f.DrainagePx);
if (floor) atFloorRows++;
sb.AppendLine($"| `{r.Seed}` | {Ordinal(i)} | {bold}{f.DrainagePx:N0}{bold} | #{f.Rank} | " +
$"{bold}{(lo > 0 ? f.DrainagePx / (double)lo : 0):F3}×{bold} | {(hi > 0 ? f.DrainagePx / (double)hi : 0):F3}× | " +
$"{DrainageRenderer.StemWidthFixed(f.DrainagePx)}{(floor ? " floor" : "")} | {Compass(f.TermX, f.TermY, mapSize)} ({f.TermX},{f.TermY}) |");
}
sb.AppendLine($"| `{r.Seed}` | *(yardsticks)* | smallest inland kept **{lo:N0}** · largest inland **{hi:N0}** | | | | | |");
}
sb.AppendLine();
sb.AppendLine("> ### ⚠⚠ THE FLOOR GUARANTEES SEA PRESENCE, NOT *WHERE* — read the last column.");
sb.AppendLine("> \"The K largest sea-reaching\" has **no spatial term in it**. The gameplay motive names the **southern**");
sb.AppendLine("> shipwreck specifically, and nothing in this rule promises a river anywhere near it: the K outlets can");
sb.AppendLine("> all land on one coast. Measured above per seed — check the coast spread before reading the count as");
sb.AppendLine("> \"coastal presence solved\". If the shipwreck needs a river, that is a placement constraint and a");
sb.AppendLine("> separate decision from K. *(Not designed in here: this gate was scoped to pure-vs-quota only.)*");
sb.AppendLine();
if (atFloorRows > 0)
{
sb.AppendLine($"> ⚠ `⚠floor` marks a stem drawn at the **{DrainageRenderer.StemWidthMinPx} px legibility floor** — a 1 px line at {mapSize} is invisible at any");
sb.AppendLine("> zoom a person actually reads a plate at, so such a river is **no thinner than drawn, possibly thinner**.");
sb.AppendLine("> It is flagged because the floor sits exactly where the \"thread\" verdict lives.");
}
else
sb.AppendLine($"> ✅ **No river on any plate hit the {DrainageRenderer.StemWidthMinPx} px legibility floor** (thinnest drawn stem is above it), so every width on");
if (atFloorRows == 0)
sb.AppendLine("> these plates is the fixed law's honest output — nothing was widened to stay visible.");
sb.AppendLine();
sb.AppendLine("## The composition delta — what the floor swapped");
sb.AppendLine();
sb.AppendLine("`#nS` / `#nE` is the candidate's rank in the **full** unified distribution (S = sea, E = endorheic).");
sb.AppendLine();
sb.AppendLine($"| Seed | candidates (sea / endo) | PURE sea/inland | QUOTA sea/inland | ⭐ inland basins DISPLACED | sea rivers PROMOTED |");
sb.AppendLine("|---|---|---|---|---|---|");
foreach (var r in rows)
{
var c = r.Comp;
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
sb.AppendLine($"| `{r.Seed}` | {r.Ranked.Count} ({c.SeaAvailable} / {c.EndoAvailable}) | {pureSea} / {c.Pure.Count - pureSea} | " +
$"{c.ForcedSea.Count} / {c.KeptInland.Count} | {Compact(c.Displaced)} | {Compact(c.Added)} |");
}
sb.AppendLine();
foreach (var r in rows)
{
var c = r.Comp;
sb.AppendLine($"**`{r.Seed}`** — every river, both compositions:");
sb.AppendLine();
sb.AppendLine($"- PURE : {Compact(c.Pure)}");
sb.AppendLine($"- QUOTA: {Compact(c.Quota)}");
if (c.SeaShort) sb.AppendLine($"- ⚠⚠ **ONLY {c.SeaAvailable} SEA CANDIDATES exist above the floor — fewer than K={compK}.** All available were taken; this seed's quota plate carries {c.Quota.Count} rivers, not {compN}. Not back-filled from the inland list: that would hide the fact that the terrain cannot supply K.");
if (c.EndoShort) sb.AppendLine($"- ⚠⚠ **ONLY {c.EndoAvailable} ENDORHEIC CANDIDATES exist above the floor — fewer than NK={compN - compK}.** All available were taken; this seed's quota plate carries {c.Quota.Count} rivers, not {compN}.");
sb.AppendLine();
}
sb.AppendLine("## ⭐ The fixed shared width scale — the one thing these plates must get right");
sb.AppendLine();
sb.AppendLine("> ### The question is \"is this river thin?\", so a plate that rescales itself cannot answer it.");
sb.AppendLine($"> rivers/02's `promoted_N*.png` normalise stem width to the widest river **on their own plate**. Under");
sb.AppendLine("> that law a forced sea river drawn thin would be reporting the plate's contents, not the river's size.");
sb.AppendLine();
sb.AppendLine($"So both plates, on all {renderSe.Length} seeds, use **one absolute constant**:");
sb.AppendLine();
sb.AppendLine("```");
sb.AppendLine($"stem width px = clamp( round( sqrt(drainage px) × {DrainageRenderer.StemWidthPerSqrtPx:F6} ), {DrainageRenderer.StemWidthMinPx}, {DrainageRenderer.StemWidthMaxPx} )");
sb.AppendLine($" = clamp( round( sqrt(drainage px) / {1f / DrainageRenderer.StemWidthPerSqrtPx:F0} ), {DrainageRenderer.StemWidthMinPx}, {DrainageRenderer.StemWidthMaxPx} )");
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine($"**The constant is `{DrainageRenderer.StemWidthPerSqrtPx:F6}` px of stem width per √(drainage px)** — never per-plate,");
sb.AppendLine("never per-seed. Width ∝ √area, so the *visual weight* of a stem tracks its drainage rather than");
sb.AppendLine("exaggerating it quadratically. It was chosen once from the measured population: the largest candidate");
sb.AppendLine("on any of the eight gallery seeds is **4,474,342 px** (seed `17320508`), √ = 2,115, so the biggest");
sb.AppendLine($"drainage the terrain produces draws at ~{(int)Math.Round(Math.Sqrt(4474342.0) * DrainageRenderer.StemWidthPerSqrtPx)} px — inside the {DrainageRenderer.StemWidthMaxPx} px ceiling, with nothing clipped anywhere.");
sb.AppendLine();
sb.AppendLine("Per-river labels read `<drainage> R<rank>` — e.g. `272K R29` beside `2.33M R1`. The rank is in the");
sb.AppendLine("**full** candidate distribution, not in the plate, so it says how far down the list the floor reached.");
sb.AppendLine();
sb.AppendLine("## What was run, and what was NOT");
sb.AppendLine();
sb.AppendLine($"Chain + analysis re-run at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all of them rendered.");
sb.AppendLine($"Curve calibrated at {calibSize} on the family-off pinned pool; terrain {TerrainShapeV1.Describe()} + erosion ON, bare defaults (rivers/01).");
sb.AppendLine();
sb.AppendLine("**⚠ NOT re-run, because a re-selection cannot change them:**");
sb.AppendLine();
sb.AppendLine("- the **8-seed distribution sweep** — rivers/02's is the distribution of record;");
sb.AppendLine("- **`candidates_all.png`** and **`distribution.png`** — same candidate set, same curve;");
sb.AppendLine("- **`DrainageAnalysis`** — not rebuilt, not edited. The quota is a re-selection over candidates it had");
sb.AppendLine(" already enumerated. The metric-comparability identity `Σ sea Acc + Σ BasinInflow + unrouted ==");
sb.AppendLine(" LandCells` was re-asserted per seed anyway, and held.");
sb.AppendLine();
sb.AppendLine($"**Reporting caps raised** to `{promoteMax}` (`TrunkCount` / `GiantCount` / `EndorheicMaxCount`) purely so the analysis");
sb.AppendLine("traces a real upland stem for every river on either plate. ⚠ The union is bound, not the top N: a forced");
sb.AppendLine("sea river can sit far down the unified ranking, so \"top N\" would leave it with no course to draw.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} — they decide which depressions");
sb.AppendLine("BECOME terminal basins, i.e. they define the routing surface itself. Also unchanged:");
sb.AppendLine($"`MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}. The terminus is classified by `RegionLabeling.OceanMask`");
sb.AppendLine("(`Dir == D_SEA`) — **there is no bare `h < sea` test anywhere in this tool.**");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine($"| `<seed>/{pure}` | the pure unified top {compN} — fixed width scale, per-river labels |");
sb.AppendLine($"| `<seed>/{quota}` | the same {compN} with the K={compK} sea floor — **same width scale**, so thin means smaller |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `composition_<seed>.csv` | both compositions row by row: rank, drainage, forced?, stem px, in-the-other-composition? |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`, the anchor of record. `ISLA_SKIP_RAW=0` regenerates it. |");
else
sb.AppendLine("| `<seed>/height.f32` | the eroded render field this analysis ran on |");
sb.AppendLine();
sb.AppendLine("**Not here:** `Giant.ProvisionalRoute` (the \"comb\") is **never drawn** — rivers/03 replaces it, and drawing");
sb.AppendLine("it would make a composition judgment look like a finished network. No lowland routing, no water, nothing carved.");
sb.AppendLine();
sb.AppendLine("## When the developer picks");
sb.AppendLine();
sb.AppendLine("Graduate as ONE unit — **\"unified ranking + a gameplay-motivated sea-river floor of K\"**. Candidate decision:");
sb.AppendLine();
sb.AppendLine($"> *\"the river count for the reshaped terrain is {compN}, composed as the top inland drainages plus a gameplay");
sb.AppendLine("> floor of K sea-reaching rivers, superseding the M3 count of 3.\"* (K filled in on confirmation.)");
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/02b_composition_pure_vs_quota.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;
}
}
}