The bare TerrainGenConfig defaults did NOT reproduce the terrain the developer
locked, so "run the default generator" was not "the terrain in the gallery" —
the single most expensive fact in the codebase, and the reason a fresh chat
would spend an afternoon chasing differences that were CONFIGURATION, not
regression. This is the deliberate task that ends that, before any river work.
A1 — the defaults ARE the locked shape now. Five fields actually move:
SpeckRevert false->true, MinLandComponentFrac 3e-5->2.5e-7 (120x smaller; the
config default would have eaten real islands, not specks), SouthStretch 0->2,
FragmentAmp 0->0.5, Erosion false->true. Seven more were already correct via
SouthernStretch.Default* / CoastalFragment.Default* and are now pinned as
literals, because TerrainShapeV1 used to do that pinning and this default set
inherits the job. CoastShelf stays OFF — the locked shape has no shelf, and
evaluating it (D-041) is its own later task once water renders. Offshore stays
Off permanently (D-063): islands are organic-only, made by the stretch +
fragmentation and identified by the region layer, never placed.
A2 — the preserve mechanism. The curve knots are percentiles of the FAMILY-OFF
land distribution; flipping the defaults would have moved the pool, the knots,
and with them the render field of every batch including terrain-shape-v1
itself. So the pool is pinned family-off (TerrainGenConfig.WithFamilyOff /
CalibrationPool) rather than the knots being baked: calibration stays live, its
INPUT distribution is held still. The pin was a no-op by construction — it sets
the values the defaults carried the instant before the flip — and re-measuring
after confirms it: pool, all six knots, per-seed spread, shaped max,
monotonicity spikeMax and all seven band shares identical.
Applied wider than "in CalibrateCurve": OffshoreIslandsTool,
RegionLabelingTool and SouthernStretchTool generate their own family-off
field for the Phase-1 anchor, so the pool pin alone would NOT have covered
them and their a1 would have failed for a configuration reason. TerrainGenTool
too — it AUTHORED 02_pass1_port and must stay able to regenerate its own
anchor.
Recorded as a judged-and-parked property: knots measured family-off, applied
family-on. Deliberate, not an oversight. Same disposition as the mid-slope
feather.
A4 — no oracle may pass against a superseded baseline. Six anchors retired
(01/03/04/06/08/09) with their checks and ISLA_T0x_SOURCE defaults; three kept
(chat1/02_pass1_port as the family-off pass-1 guard, chat2/10 and chat2/11 as
the shape and erosion acceptance anchors). Two invariants were RE-POINTED
rather than lost — the southern stretch's north-lock and the coastal-fragment
interior-lock now compare against SAME-RUN fields, which is scale-free and
cannot be invalidated by a moved dump. The retired dumps are kept, not deleted,
and marked superseded in their INDEX.md.
A missing anchor is now LOUD. The old pattern skipped silently, so a moved
anchor did not make its oracle fail — it made it not RUN, and a batch with a
skipped check prints an all-PASS table that reads like a clean one. That is
the INVERSE of the hazard the re-baseline guards against, and the migration
below is exactly the event that would have triggered it, on nine anchors at
once. ShapingOracle.LoadAnchor now separates the two cases: absent -> throw;
present at another size -> loud INCONCLUSIVE, which is a fail, never a pass.
TerrainShapeV1 inverted from PRESET to GUARD and moved to its own file.
Apply() is gone — stamping the values on top of the defaults would MASK a
drift instead of catching it. Its constants are now the assertion target, and
Assert() refuses a run whose defaults have drifted off the locked shape.
B/C — batches are namespaced by chat: batches/<chat>/NN_slug/. Task numbers
restart at 00 per chat, so a flat root collided the moment a second chat
existed — four colliding prefixes across 25 batches, separable only by slug.
ToolingPaths.ChatSlug is REQUIRED (throws if unset) and defaults per tool to
its authoring chat, so re-running reproduces a batch in place while ISLA_CHAT
redirects — which is also what stops an acceptance run from overwriting the
very anchor it checks against. Writes go through BatchRoot; historical READS
compose against BatchesRoot and so carry the prefix in their own source string
("chat1/02_pass1_port"). The 25 existing batches were migrated moves-only.
ACCEPTANCE — 16 of 16 byte-identical, 0 failed. All 8 gallery seeds at 8192
from the bare defaults are byte-identical to chat2/10_frag4_seed_gallery
(= terrain-shape-v1, a59e52f); all 8 erosion fields byte-identical to
chat2/11_erosion (= ea291ea). Every gallery table row and every erosion
statistic reproduces its recorded value exactly. DrainageTool's a11 passes
bit-identical over 67,108,864 cells, and its analysis reproduces batch 12
exactly — so the whole chain rivers depends on (shape -> erosion -> drainage)
is unchanged. All 12 edited tools re-run clean; both new guards negative-tested.
The baseline moved in DEFAULTS, not in TERRAIN.
-> XX_Human/output/rivers/01_rebaseline_and_batch_namespace.report.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhkXBQh2tDmcWKpXYcj8vj
674 lines
36 KiB
C#
674 lines
36 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Text;
|
||
using Godot;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// ⭐ THE FAITHFUL-CURVE BASELINE BATCH (chat2/01) — the control the reshape will be judged
|
||
/// against, plus the instrument that decides what the reshape should be.
|
||
///
|
||
/// ═══ WHAT IT DOES, IN ORDER ═══
|
||
///
|
||
/// 1. CALIBRATE — pool the LAND distribution across several seeds and take
|
||
/// P60/73/83/88/96/99. Those quantiles ARE the curve's knots, which is what makes the band
|
||
/// shares 60/13/10/5/8/3/1 exact by construction. Reports them against the reference's
|
||
/// shipped literals: that delta is the port-fidelity evidence.
|
||
/// 2. VARIANTS — per seed, `curve_off` (the control) and `curve_on` (the faithful baseline).
|
||
/// Grayscale + raw `.f32` + relief, because a pretty render alone cannot be argued with.
|
||
/// 3. HISTOGRAMS — raw and shaped land distributions for the showpiece seed, with the knots and
|
||
/// the output anchors overlaid, plus the per-band mass table.
|
||
/// 4. ORACLE — the four automatic checks, run before anything is looked at.
|
||
/// 5. SHOWPIECE — one render at a larger profile for the judging plate.
|
||
///
|
||
/// ═══ ⚠ CALIBRATE SMALL, CONFIRM BIG ═══
|
||
///
|
||
/// Knots are measured at the iteration profile (2K) and used at every profile. That rests on the
|
||
/// land distribution's SHAPE being scale-invariant — which Phase 1 established by construction
|
||
/// (every frequency and distance is a fraction of MapSize). The tool does not take that on trust:
|
||
/// the showpiece re-measures its own quantiles and reports the delta, so the assumption is
|
||
/// evidence rather than a footnote.
|
||
///
|
||
/// ═══ RUNNING IT ═══
|
||
///
|
||
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CurveBaselineTool.tscn
|
||
///
|
||
/// (`--headless` also works — this tool awaits no render frames — but `xvfb-run` is correct in
|
||
/// both cases and costs nothing. → Tools/README.md §1.)
|
||
///
|
||
/// ISLA_TASK authoring task number (default 1)
|
||
/// ISLA_BATCH descriptor, NO prefix (default "curve_baseline")
|
||
/// ISLA_MAPSIZE calibration/variant profile (default 2048)
|
||
/// ISLA_SEEDS comma-separated positive (default: the 6 pinned below)
|
||
/// ISLA_SHOWPIECE_SIZE larger confirmation profile (default 8192)
|
||
/// ISLA_SHOWPIECE "0" to skip the big render
|
||
/// ISLA_VARIANTS "0" to skip the per-seed variants (calibration-only probe)
|
||
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "chat1/02_pass1_port")
|
||
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
|
||
/// </summary>
|
||
public partial class CurveBaselineTool : Node
|
||
{
|
||
/// <summary>
|
||
/// PINNED POSITIVE SEEDS — the four Phase-1 seeds plus two, for a six-seed calibration pool.
|
||
/// Pinned, not random: a calibration you cannot reproduce is a magic number with a good story.
|
||
/// </summary>
|
||
private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||
|
||
private const int DefaultMapSize = 2048;
|
||
private const int DefaultShowpieceSize = 8192;
|
||
|
||
/// <summary>
|
||
/// Band-share tolerance, percentage points. The knots come from the SAME histogram the shares
|
||
/// are measured on, so the only error is in-bin interpolation — tenths, not units. A loose
|
||
/// tolerance here would make check (d) unfalsifiable.
|
||
/// </summary>
|
||
private const double ShareTolerancePp = 0.25;
|
||
|
||
public override void _Ready()
|
||
{
|
||
// ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits there
|
||
// with no main loop to end it, so a misconfigured run HANGS instead of failing. A hang
|
||
// looks like slow work, which is worse than a crash. Catch, say what was refused, exit 2.
|
||
try { Run(); }
|
||
catch (Exception e)
|
||
{
|
||
GD.PrintErr("==================================================================");
|
||
GD.PrintErr($" REFUSED: {e.Message}");
|
||
GD.PrintErr("==================================================================");
|
||
GetTree().Quit(2);
|
||
}
|
||
}
|
||
|
||
private void Run()
|
||
{
|
||
ToolingPaths.Configure(OS.GetUserDataDir());
|
||
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||
|
||
int task = EnvInt("ISLA_TASK", 1);
|
||
string descr = EnvStr("ISLA_BATCH", "curve_baseline");
|
||
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
|
||
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
|
||
bool variants = EnvStr("ISLA_VARIANTS", "1") == "1";
|
||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||
|
||
// ⚠ Composed by BatchRoot, never free-form — it refuses a descriptor carrying its own
|
||
// numeric prefix, which is how the counter drifted once already.
|
||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||
string scratch = ToolingPaths.BatchScratch(batchRoot);
|
||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||
DirAccess.MakeDirRecursiveAbsolute(scratch);
|
||
|
||
var scale = new GenerationScale(mapSize);
|
||
var anchors = CurveAnchors.Default;
|
||
float sea = 0.15f;
|
||
|
||
GD.Print("==================================================================");
|
||
GD.Print(" CURVE BASELINE — faithful redistribution port + the histogram");
|
||
GD.Print("==================================================================");
|
||
GD.Print($"MapSize : {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
|
||
GD.Print($"yardstick : {WorldScale.Describe()}");
|
||
GD.Print($"anchors : {anchors.DescribeMetres()}");
|
||
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
||
GD.Print($"batch : {batchRoot}");
|
||
GD.Print("------------------------------------------------------------------");
|
||
GD.Print(ToolingPaths.Describe());
|
||
GD.Print("==================================================================");
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 1. CALIBRATE — the knots ARE percentiles of the pooled land CDF
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
GD.Print("\n--- 1. CALIBRATION POOL ---");
|
||
|
||
var rawPool = new LandHistogram(sea);
|
||
var pass1 = new Dictionary<int, Pass1Result>();
|
||
var perSeedKnots = new List<CurveKnots>();
|
||
|
||
foreach (int seed in seeds)
|
||
{
|
||
var cfg = TerrainGenConfig.CalibrationPool(mapSize, seed);
|
||
Pass1Result p1 = Topography.Generate(cfg);
|
||
pass1[seed] = p1;
|
||
rawPool.Accumulate(p1.Height, mapSize);
|
||
|
||
// Each seed's OWN knots too — the pool is the calibration, but the SPREAD across
|
||
// seeds is what tells us how much of any later delta is scale and how much is just
|
||
// which seed you happened to look at. Without it, the scale-invariance check below
|
||
// compares a 6-seed pool against one seed and calls the difference "scale".
|
||
var solo = new LandHistogram(sea);
|
||
solo.Accumulate(p1.Height, mapSize);
|
||
perSeedKnots.Add(MeasureKnots(solo, $"seed_{seed}"));
|
||
|
||
GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms");
|
||
}
|
||
GD.Print($" {rawPool}");
|
||
|
||
float seedSpread = KnotSpread(perSeedKnots);
|
||
GD.Print($" per-seed knot spread at {mapSize}: max range across the 6 seeds = " +
|
||
$"{seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m)");
|
||
|
||
CurveKnots measured = MeasureKnots(rawPool, "v2_balanced");
|
||
GD.Print("\n MEASURED KNOTS vs THE REFERENCE'S SHIPPED LITERALS");
|
||
GD.Print(" knot pct v2 measured reference delta delta (m)");
|
||
for (int i = 0; i < 6; i++)
|
||
{
|
||
float v2 = measured[i], rf = CurveKnots.Reference[i];
|
||
GD.Print($" K{i + 1} P{CurveKnots.Percentiles[i],-5:F0} {v2,11:F6} {rf,11:F6} " +
|
||
$"{v2 - rf,+9:F6} {WorldScale.MetresFromRaw(v2 - rf),+8:F2}");
|
||
}
|
||
|
||
double[] shares = ShapingOracle.RealizedShares(rawPool, measured);
|
||
GD.Print("\n REALIZED LAND SHARES (target 60/13/10/5/8/3/1)");
|
||
for (int i = 0; i < 7; i++)
|
||
GD.Print($" {CurveKnots.BandNames[i],-16} {shares[i],6:F2} % (target {CurveKnots.BandShareTargets[i],4:F0} %)");
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 2. THE SHAPED POOL — what the curve actually produces
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
GD.Print("\n--- 2. SHAPED POOL (curve on, detail on) ---");
|
||
|
||
var shapedPool = new LandHistogram(sea);
|
||
var shapedResults = new Dictionary<int, Pass2Result>();
|
||
|
||
foreach (int seed in seeds)
|
||
{
|
||
var cfg = OnConfig(mapSize, seed, measured, anchors);
|
||
Pass2Result p2 = Shaping.Shape(pass1[seed], cfg);
|
||
shapedResults[seed] = p2;
|
||
shapedPool.Accumulate(p2.Height, mapSize);
|
||
if (seed == seeds[0]) foreach (string n in p2.Notes) GD.Print(" " + n);
|
||
GD.Print($" shaped seed {seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] {p2.ElapsedMs,5} ms");
|
||
}
|
||
GD.Print($" {shapedPool}");
|
||
|
||
GD.Print("\n SHAPED LAND PERCENTILES (what the palette would need to be placed on)");
|
||
double[] paletteP = { 10, 25, 50, 75, 90, 95, 99, 99.9 };
|
||
var sb2 = new StringBuilder(" ");
|
||
foreach (double p in paletteP) sb2.Append($" p{p:G}={shapedPool.Quantile(p):F3}");
|
||
GD.Print(sb2.ToString());
|
||
GD.Print($" shaped max {shapedPool.MaxLand:F4} (peak cap {anchors.PeakCap:F4})");
|
||
|
||
// ⚠ THE PALETTE WAS CALIBRATED ON THE *RAW* DISTRIBUTION (Phase 1, chat1/03) and the
|
||
// curve moves that distribution wholesale. This is reported, NOT fixed: re-placing the
|
||
// stops now would make the render look more dramatic while the terrain is genuinely
|
||
// lower — i.e. it would disguise the exact finding the histograms exist to deliver.
|
||
// Whether to recalibrate is a PRESENTATION call the developer makes AFTER deciding
|
||
// whether this elevation profile is the one they want.
|
||
double rawLow = raw3Stop(rawPool), shapedLow = raw3Stop(shapedPool);
|
||
double rawSat = 1.0 - rawPool.FractionBelow(1.450f), shapedSat = 1.0 - shapedPool.FractionBelow(1.450f);
|
||
GD.Print("\n PALETTE FIT (CostaRica stops were placed on the RAW distribution)");
|
||
GD.Print($" land below stop 3 (0.310 raw): raw {rawLow * 100:F1} % -> shaped {shapedLow * 100:F1} %");
|
||
GD.Print($" land above top stop (1.450): raw {rawSat * 100:F3} % -> shaped {shapedSat * 100:F3} % (clamps to white)");
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 3. THE ORACLE — before a single render is looked at
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
GD.Print("\n--- 3. ORACLE ---");
|
||
var checks = new List<ShapingOracle.Check>();
|
||
|
||
int primary = seeds[0];
|
||
Pass1Result pp1 = pass1[primary];
|
||
|
||
Pass2Result offPrimary = Shaping.Shape(pp1, OffConfig(mapSize, primary));
|
||
checks.Add(ShapingOracle.RegressionCurveOff(pp1, offPrimary));
|
||
|
||
string dumpPath = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
|
||
float[,] phase1Dump = HeightField.Load(dumpPath, mapSize);
|
||
checks.Add(ShapingOracle.RegressionAgainstDump(pp1.Height, phase1Dump, mapSize, dumpPath));
|
||
|
||
checks.Add(ShapingOracle.ClassifyFidelity(pp1, shapedResults[primary]));
|
||
checks.Add(ShapingOracle.Monotonicity(shapedResults[primary]));
|
||
checks.Add(ShapingOracle.BandShares(rawPool, measured, ShareTolerancePp));
|
||
|
||
// Every seed's classify field, not just the showpiece's — the invariant is per-seed.
|
||
long classifyDrift = 0;
|
||
foreach (int seed in seeds)
|
||
{
|
||
var c = ShapingOracle.ClassifyFidelity(pass1[seed], shapedResults[seed]);
|
||
if (!c.Passed) { classifyDrift++; GD.PrintErr($" classify drift on seed {seed}: {c.Detail}"); }
|
||
}
|
||
|
||
foreach (var c in checks) GD.Print(" " + c);
|
||
bool oracleOk = checks.TrueForAll(c => c.Passed) && classifyDrift == 0;
|
||
GD.Print($" ORACLE: {(oracleOk ? "ALL PASS" : "FAILURES PRESENT")} " +
|
||
$"(classify checked on all {seeds.Length} seeds: {seeds.Length - classifyDrift} clean)");
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 4. VARIANTS — plain data beside the pretty render
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
var rows = new List<string>();
|
||
if (variants)
|
||
{
|
||
GD.Print("\n--- 4. VARIANTS (curve_off / curve_on) ---");
|
||
foreach (int seed in seeds)
|
||
{
|
||
rows.Add(WriteVariant(batchRoot, pass1[seed], OffConfig(mapSize, seed), skipRaw, sea, 1.45f));
|
||
rows.Add(WriteVariant(batchRoot, pass1[seed], OnConfig(mapSize, seed, measured, anchors),
|
||
skipRaw, sea, anchors.PeakCap));
|
||
}
|
||
}
|
||
else GD.Print("\n--- variants skipped (ISLA_VARIANTS=0) ---");
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 5. HISTOGRAMS — the diagnostic, for the showpiece seed
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
GD.Print("\n--- 5. HISTOGRAMS ---");
|
||
var rawSeed = new LandHistogram(sea);
|
||
rawSeed.Accumulate(pass1[primary].Height, mapSize);
|
||
var shapedSeed = new LandHistogram(sea);
|
||
shapedSeed.Accumulate(shapedResults[primary].Height, mapSize);
|
||
|
||
DrawRawHistogram(rawSeed, measured, primary, mapSize, batchRoot);
|
||
DrawShapedHistogram(shapedSeed, rawSeed, measured, anchors, primary, mapSize, batchRoot);
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 6. SHOWPIECE — the judging plate, and the scale-invariance check
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
string showNote = "skipped (ISLA_SHOWPIECE=0)";
|
||
if (showpiece)
|
||
{
|
||
GD.Print($"\n--- 6. SHOWPIECE at {showSize} ---");
|
||
showNote = WriteShowpiece(batchRoot, primary, showSize, measured, anchors, sea, skipRaw, rows, seedSpread);
|
||
}
|
||
else GD.Print("\n--- showpiece skipped (ISLA_SHOWPIECE=0) ---");
|
||
|
||
WriteIndex(batchRoot, mapSize, showSize, scale, seeds, primary, measured, anchors,
|
||
rawPool, shapedPool, shares, checks, rows, oracleOk, showNote, variants, seedSpread);
|
||
|
||
GD.Print("\n==================================================================");
|
||
GD.Print($" DONE — {batchRoot}");
|
||
GD.Print($" ORACLE {(oracleOk ? "ALL PASS" : "*** FAILURES — see the table ***")}");
|
||
GD.Print("==================================================================");
|
||
GetTree().Quit(oracleOk ? 0 : 3);
|
||
}
|
||
|
||
// ---- configs --------------------------------------------------------
|
||
|
||
/// <remarks>
|
||
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This is chat-2 CURVE development: authored
|
||
/// and judged before the shape family existed, on the family-off distribution the knots are
|
||
/// percentiles of. The re-baseline flipped the bare defaults family-ON, so the pin is what
|
||
/// keeps this tool measuring the thing it was written to measure.
|
||
/// → <see cref="TerrainGenConfig.WithFamilyOff"/>.
|
||
/// </remarks>
|
||
private static TerrainGenConfig OffConfig(int mapSize, int seed) =>
|
||
new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_off", Curve = false, ShelfDetail = false }
|
||
.WithFamilyOff();
|
||
|
||
private static TerrainGenConfig OnConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a) =>
|
||
new TerrainGenConfig
|
||
{
|
||
MapSize = mapSize, Seed = seed, VariantLabel = "curve_on",
|
||
Curve = true, ShelfDetail = true, Knots = k, Anchors = a,
|
||
// ⚠ PINNED, not defaulted: this tool exists to reproduce the task-01 staircase
|
||
// bit-for-bit. chat2/02 moved the config DEFAULT to Continuous for its exploration;
|
||
// a control batch must not move with a default. (chat2/02.)
|
||
CurveMode = CurveModeKind.Staircase,
|
||
}.WithFamilyOff(); // ⭐ rivers/01 — see OffConfig: the staircase control is pre-family too
|
||
|
||
/// <summary>
|
||
/// Fraction of land below the CostaRica palette's third stop (0.310 raw). A blunt
|
||
/// "how much of the island is painted with the first two colours" number — the palette's own
|
||
/// stops are at measured RAW percentiles, so this is how far the curve moved the picture.
|
||
/// </summary>
|
||
private static double raw3Stop(LandHistogram h) => h.FractionBelow(0.310f);
|
||
|
||
/// <summary>
|
||
/// The widest max−min range of any single knot across a set of measurements — "how much does
|
||
/// K_n move if you just change which seed you looked at". The yardstick every other knot
|
||
/// delta in this batch has to be judged against.
|
||
/// </summary>
|
||
private static float KnotSpread(List<CurveKnots> sets)
|
||
{
|
||
float worst = 0f;
|
||
for (int i = 0; i < 6; i++)
|
||
{
|
||
float lo = float.MaxValue, hi = float.MinValue;
|
||
foreach (CurveKnots s in sets) { lo = MathF.Min(lo, s[i]); hi = MathF.Max(hi, s[i]); }
|
||
worst = MathF.Max(worst, hi - lo);
|
||
}
|
||
return worst;
|
||
}
|
||
|
||
/// <summary>The knots ARE the quantiles. This one method is the whole calibration.</summary>
|
||
private static CurveKnots MeasureKnots(LandHistogram pool, string name) => new CurveKnots(
|
||
2, name,
|
||
pool.Quantile(CurveKnots.Percentiles[0]), pool.Quantile(CurveKnots.Percentiles[1]),
|
||
pool.Quantile(CurveKnots.Percentiles[2]), pool.Quantile(CurveKnots.Percentiles[3]),
|
||
pool.Quantile(CurveKnots.Percentiles[4]), pool.Quantile(CurveKnots.Percentiles[5]));
|
||
|
||
// ---- output ---------------------------------------------------------
|
||
|
||
private static string WriteVariant(string batchRoot, Pass1Result p1, TerrainGenConfig cfg,
|
||
bool skipRaw, float sea, float legendTop)
|
||
{
|
||
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||
|
||
string dir = Path.Combine(batchRoot, $"{cfg.Seed}_{cfg.VariantLabel}");
|
||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||
|
||
// ⭐ PLAIN DATA BESIDE THE PRETTY RENDER, always — the point is judging the terrain, and
|
||
// you cannot judge a distribution through a palette.
|
||
var (gMin, gMax) = GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
|
||
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
|
||
|
||
var look = new LookConfig
|
||
{
|
||
Name = "gradient_flat", Palette = ReliefPalette.Kind.CostaRica,
|
||
HillshadeStrength = 0f, SeaLevel = sea,
|
||
};
|
||
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
|
||
Image withLegend = LegendRenderer.WithLegend(map, look.Palette, sea, legendTop,
|
||
cfg.VariantLabel.ToUpperInvariant());
|
||
withLegend.SavePng(Path.Combine(dir, "relief.png"));
|
||
|
||
float land = p2.LandFraction(sea);
|
||
GD.Print($" {cfg.VariantLabel,-10} seed {cfg.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " +
|
||
$" land {land * 100,5:F1}% {p2.ElapsedMs,5} ms");
|
||
|
||
return $"| `{cfg.Seed}_{cfg.VariantLabel}` | {cfg.Seed} | {cfg.VariantLabel} | " +
|
||
$"{p2.HMin:F3} | {p2.HMax:F3} | {land * 100:F1}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |";
|
||
}
|
||
|
||
private static void DrawRawHistogram(LandHistogram raw, CurveKnots k, int seed, int mapSize, string batchRoot)
|
||
{
|
||
// Coarse enough to draw, fine enough to keep the shape: ~360 bins across the range.
|
||
float top = MathF.Ceiling(raw.MaxLand * 20f) / 20f;
|
||
var display = raw.Rebin((top - raw.SeaLevel) / 360f);
|
||
|
||
var o = new HistogramRenderer.Options
|
||
{
|
||
Title = $"RAW LAND HEIGHTS - SEED {seed}",
|
||
Subtitle = "PRE-CURVE. THE KNOTS ARE PERCENTILES OF THIS DISTRIBUTION.",
|
||
XAxisLabel = "RAW HEIGHT (PRE-CURVE)",
|
||
XTop = top,
|
||
Footer = $"{raw.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize} - BIN {display.BinWidth:F4} RAW",
|
||
};
|
||
|
||
float[] edges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6, top };
|
||
for (int i = 0; i < 7; i++)
|
||
o.Bands.Add(new HistogramRenderer.Band
|
||
{
|
||
Lo = edges[i], Hi = edges[i + 1],
|
||
Label = CurveKnots.BandNames[i],
|
||
SharePercent = i < 6
|
||
? raw.FractionBetween(edges[i], edges[i + 1]) * 100.0
|
||
: Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0),
|
||
});
|
||
|
||
for (int i = 0; i < 6; i++)
|
||
o.Markers.Add(new HistogramRenderer.Marker
|
||
{
|
||
Value = k[i], Label = $"K{i + 1} P{CurveKnots.Percentiles[i]:F0}", Strong = true,
|
||
});
|
||
|
||
HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, "histogram_raw.png"));
|
||
GD.Print($" histogram_raw.png ({raw.TotalLand:N0} land columns, max {raw.MaxLand:F4})");
|
||
}
|
||
|
||
private static void DrawShapedHistogram(LandHistogram shaped, LandHistogram raw, CurveKnots k,
|
||
CurveAnchors a, int seed, int mapSize, string batchRoot)
|
||
{
|
||
float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f;
|
||
var display = shaped.Rebin((top - shaped.SeaLevel) / 360f);
|
||
|
||
var o = new HistogramRenderer.Options
|
||
{
|
||
Title = $"SHAPED LAND HEIGHTS - SEED {seed}",
|
||
Subtitle = "POST-CURVE. BANDS SIT AT THE STORM-LADDER OUTPUT ANCHORS.",
|
||
XAxisLabel = "RAW HEIGHT (POST-CURVE)",
|
||
XTop = top,
|
||
Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize} - BIN {display.BinWidth:F4} RAW",
|
||
};
|
||
|
||
// The OUTPUT bands: each input band's share, drawn where the curve puts it.
|
||
float benchTop = a.BenchBase + a.ShelfSpanMin;
|
||
float plateauTop = a.PlateauBase + a.ShelfSpanMin;
|
||
float[] outEdges = { a.Sea, a.OrangeCeil, a.RedCeil, a.BenchBase, benchTop, a.PlateauBase, plateauTop, top };
|
||
float[] inEdges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6 };
|
||
|
||
for (int i = 0; i < 7; i++)
|
||
o.Bands.Add(new HistogramRenderer.Band
|
||
{
|
||
Lo = outEdges[i], Hi = outEdges[i + 1],
|
||
Label = CurveKnots.BandNames[i],
|
||
SharePercent = i < 6
|
||
? raw.FractionBetween(inEdges[i], inEdges[i + 1]) * 100.0
|
||
: Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0),
|
||
});
|
||
|
||
o.Markers.Add(new HistogramRenderer.Marker { Value = a.OrangeCeil, Label = "ORANGE" });
|
||
o.Markers.Add(new HistogramRenderer.Marker { Value = a.RedCeil, Label = "RED" });
|
||
o.Markers.Add(new HistogramRenderer.Marker { Value = a.BenchBase, Label = "BENCH" });
|
||
o.Markers.Add(new HistogramRenderer.Marker { Value = a.PlateauBase, Label = "PLATEAU" });
|
||
o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" });
|
||
|
||
HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, "histogram_shaped.png"));
|
||
GD.Print($" histogram_shaped.png ({shaped.TotalLand:N0} land columns, max {shaped.MaxLand:F4})");
|
||
}
|
||
|
||
private static string WriteShowpiece(string batchRoot, int seed, int showSize, CurveKnots k,
|
||
CurveAnchors a, float sea, bool skipRaw, List<string> rows, float seedSpread)
|
||
{
|
||
var cfg = OnConfig(showSize, seed, k, a);
|
||
cfg.VariantLabel = "curve_on_showpiece";
|
||
|
||
Pass1Result p1 = Topography.Generate(cfg);
|
||
GD.Print($" pass1 {showSize}: h[{p1.HMinSeed:F3} .. {p1.HMaxSeed:F3}] {p1.ElapsedMs} ms");
|
||
|
||
// ⭐ THE SCALE-INVARIANCE CHECK. Knots were measured at the iteration profile; if the
|
||
// land distribution's shape really is scale-free, this profile's own quantiles land on
|
||
// the same numbers. Measured, not assumed.
|
||
var bigPool = new LandHistogram(sea);
|
||
bigPool.Accumulate(p1.Height, showSize);
|
||
var bigKnots = MeasureKnots(bigPool, $"at_{showSize}");
|
||
|
||
var deltas = new StringBuilder();
|
||
float worst = 0f;
|
||
for (int i = 0; i < 6; i++)
|
||
{
|
||
float d = bigKnots[i] - k[i];
|
||
if (MathF.Abs(d) > MathF.Abs(worst)) worst = d;
|
||
deltas.Append($" K{i + 1}{d:+0.0000;-0.0000}");
|
||
}
|
||
// ⚠ THE COMPARISON IS CONFOUNDED, AND SAYING SO IS THE POINT. This is ONE seed at the
|
||
// big profile against a SIX-SEED POOL at the small one, so the delta mixes scale effects
|
||
// with seed-to-seed variation. `seedSpread` is how far a knot moves from seed choice
|
||
// alone at a fixed size — if the delta sits inside it, scale is not what moved.
|
||
bool withinSeedNoise = MathF.Abs(worst) <= seedSpread;
|
||
GD.Print($" scale-invariance: knots re-measured at {showSize} differ by{deltas}");
|
||
GD.Print($" worst {worst:+0.000000;-0.000000} raw = {WorldScale.MetresFromRaw(worst):+0.00;-0.00} m " +
|
||
$"vs per-seed spread {seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m) " +
|
||
$"-> {(withinSeedNoise ? "WITHIN seed variation" : "EXCEEDS seed variation")}");
|
||
|
||
rows.Add(WriteVariant(batchRoot, p1, cfg, skipRaw, sea, a.PeakCap));
|
||
|
||
return $"seed {seed} at {showSize}; knots re-measured there differ by at most " +
|
||
$"{MathF.Abs(worst):F6} raw ({MathF.Abs(WorldScale.MetresFromRaw(worst)):F2} m), " +
|
||
$"{(withinSeedNoise ? "within" : "beyond")} the {WorldScale.MetresFromRaw(seedSpread):F2} m per-seed spread";
|
||
}
|
||
|
||
// ---- the index ------------------------------------------------------
|
||
|
||
private static void WriteIndex(string batchRoot, int mapSize, int showSize, GenerationScale scale,
|
||
int[] seeds, int primary, CurveKnots k, CurveAnchors a, LandHistogram rawPool,
|
||
LandHistogram shapedPool, double[] shares, List<ShapingOracle.Check> checks,
|
||
List<string> rows, bool oracleOk, string showNote, bool variants, float seedSpread)
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("# Batch 01 — the faithful curve baseline");
|
||
sb.AppendLine();
|
||
sb.AppendLine("The redistribution curve and shelf detail, ported faithfully and **re-calibrated against");
|
||
sb.AppendLine("v2's own pass-1 output**. This is the CONTROL every later reshape is judged against —");
|
||
sb.AppendLine("not a taste gate. No erosion, no rivers, no water, no crater, no coast shelf, no islets.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐ Open this first");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"1. **`{primary}_curve_on_showpiece/relief.png`** — the judging plate ({showNote}).");
|
||
sb.AppendLine($"2. **`histogram_raw.png`** and **`histogram_shaped.png`** — the diagnostic that says");
|
||
sb.AppendLine(" *why* the upper terrain looks the way it does. Read them before forming an opinion.");
|
||
sb.AppendLine($"3. **`{primary}_curve_off/relief.png`** vs **`{primary}_curve_on/relief.png`** — the A/B.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Disposability");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Artifact | Keep? |");
|
||
sb.AppendLine("|---|---|");
|
||
sb.AppendLine("| `relief.png` | **keep** — the judging plates |");
|
||
sb.AppendLine("| `histogram_*.png` | **keep** — the finding |");
|
||
sb.AppendLine("| `INDEX.md` | **keep** |");
|
||
sb.AppendLine("| `grayscale.png` | ♻ **regenerable** from the `.f32` — safe to clear |");
|
||
sb.AppendLine("| `height.f32` | ♻ **regenerable** from seed + code — safe to clear, but it is the byte-level oracle |");
|
||
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Setup");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"- **Calibration/variant profile:** {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
|
||
sb.AppendLine($"- **Showpiece profile:** {showSize}");
|
||
sb.AppendLine($"- **Seeds (pooled as one calibration set):** {string.Join(", ", seeds)}");
|
||
sb.AppendLine($"- **Yardstick:** {WorldScale.Describe()}");
|
||
sb.AppendLine($"- **Output anchors:** {a.DescribeMetres()}");
|
||
sb.AppendLine($"- **Curve:** v{HeightCurve.Version} · **detail:** v{TerrainDetailPass.Version}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐ The re-measured knots");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Pooled land CDF: **{rawPool.TotalLand:N0} samples** from {rawPool.FieldsPooled} seeds, ");
|
||
sb.AppendLine($"range [{rawPool.MinLand:F4} .. {rawPool.MaxLand:F4}] raw, bin {rawPool.BinWidth:G3}.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Knot | Percentile | v2 measured | reference literal | delta (raw) | delta (m) |");
|
||
sb.AppendLine("|---|---|---|---|---|---|");
|
||
for (int i = 0; i < 6; i++)
|
||
{
|
||
float v2 = k[i], rf = CurveKnots.Reference[i];
|
||
sb.AppendLine($"| K{i + 1} | P{CurveKnots.Percentiles[i]:F0} | `{v2:F6}` | `{rf:F6}` | " +
|
||
$"{v2 - rf:+0.000000;-0.000000} | {WorldScale.MetresFromRaw(v2 - rf):+0.00;-0.00} |");
|
||
}
|
||
sb.AppendLine();
|
||
sb.AppendLine("> **This delta is the port-fidelity check.** Near-identical means v2's pass-1 height");
|
||
sb.AppendLine("> distribution matches the reference's — the crown-jewel port is faithful. A large gap");
|
||
sb.AppendLine("> would mean pass 1 diverged, and would be the finding rather than a nuisance.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**Scale for judging any knot delta:** across the six seeds at {mapSize}, a single knot");
|
||
sb.AppendLine($"moves by up to **{seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m)** from seed choice alone.");
|
||
sb.AppendLine("Anything smaller than that is seed noise, not a difference.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**Scale invariance:** {showNote}.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Realized land shares");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Band | Realized | Target | Output lands at |");
|
||
sb.AppendLine("|---|---|---|---|");
|
||
float benchTop = a.BenchBase + a.ShelfSpanMin, plateauTop = a.PlateauBase + a.ShelfSpanMin;
|
||
string[] lands =
|
||
{
|
||
$"{WorldScale.MetresFromRaw(a.Sea - a.Sea):F0}–{WorldScale.MetresFromRaw(a.OrangeCeil - a.Sea):F0} m",
|
||
$"{WorldScale.MetresFromRaw(a.OrangeCeil - a.Sea):F0}–{WorldScale.MetresFromRaw(a.RedCeil - a.Sea):F0} m",
|
||
$"{WorldScale.MetresFromRaw(a.RedCeil - a.Sea):F0}–{WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m",
|
||
$"~{WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m (bench)",
|
||
$"{WorldScale.MetresFromRaw(benchTop - a.Sea):F0}–{WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m",
|
||
$"~{WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m (plateau)",
|
||
$"{WorldScale.MetresFromRaw(plateauTop - a.Sea):F0}–{WorldScale.MetresFromRaw(a.PeakCap - a.Sea):F0} m",
|
||
};
|
||
for (int i = 0; i < 7; i++)
|
||
sb.AppendLine($"| {CurveKnots.BandNames[i]} | {shares[i]:F2} % | {CurveKnots.BandShareTargets[i]:F0} % | {lands[i]} |");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Shaped land range: **[{shapedPool.MinLand:F4} .. {shapedPool.MaxLand:F4}] raw** " +
|
||
$"= {WorldScale.MetresFromRaw(shapedPool.MinLand - a.Sea):F0}–{WorldScale.MetresFromRaw(shapedPool.MaxLand - a.Sea):F0} m above sea.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐ The elevation profile this produces");
|
||
sb.AppendLine();
|
||
sb.AppendLine("Land height percentiles, before and after the curve — **this is the finding**:");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Percentile | raw | shaped | shaped, metres above sea |");
|
||
sb.AppendLine("|---|---|---|---|");
|
||
foreach (double p in new[] { 10.0, 25.0, 50.0, 75.0, 90.0, 95.0, 99.0, 99.9 })
|
||
{
|
||
float r = rawPool.Quantile(p), s = shapedPool.Quantile(p);
|
||
sb.AppendLine($"| p{p:G} | {r:F3} | {s:F3} | **{WorldScale.MetresFromRaw(s - a.Sea):F0} m** |");
|
||
}
|
||
sb.AppendLine();
|
||
double belowBench = shapedPool.FractionBelow(a.BenchBase) * 100.0;
|
||
double belowPlateau = shapedPool.FractionBelow(a.PlateauBase) * 100.0;
|
||
sb.AppendLine($"- **{belowBench:F1} %** of land sits below the bench " +
|
||
$"({WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m).");
|
||
sb.AppendLine($"- **{belowPlateau:F1} %** of land sits below the plateau " +
|
||
$"({WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m).");
|
||
// ⚠ Do NOT state the RAW median in metres. Raw pre-curve height has no metre meaning —
|
||
// the yardstick applies to CURVED output, which is what the storm-ladder anchors define.
|
||
// Converting the raw median would invent a "before" elevation the world never had.
|
||
sb.AppendLine($"- The median land column ends up **{WorldScale.MetresFromRaw(shapedPool.Quantile(50) - a.Sea):F0} m** " +
|
||
$"above sea: the curve maps raw {rawPool.Quantile(50):F3} → {shapedPool.Quantile(50):F3}.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ⚠ **Read `histogram_shaped.png` before concluding the terrain is broken.** The curve is");
|
||
sb.AppendLine("> doing exactly what its share targets say: 60 % of land into the bottom band, 73 % below");
|
||
sb.AppendLine("> the red ceiling, 4 % above the plateau. If the island reads flat, that is a decision");
|
||
sb.AppendLine("> showing up in a render — not a bug. Changing it is the RESHAPE, and the reshape is a");
|
||
sb.AppendLine("> later task with its own gate.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⚠ Palette fit — reported, not fixed");
|
||
sb.AppendLine();
|
||
sb.AppendLine("The CostaRica stops were placed on **measured percentiles of the RAW distribution**");
|
||
sb.AppendLine("(Phase 1). The curve moves that distribution, so the ramp no longer sits where the land is:");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| | raw | shaped |");
|
||
sb.AppendLine("|---|---|---|");
|
||
sb.AppendLine($"| land below palette stop 3 (0.310 raw) | {raw3Stop(rawPool) * 100:F1} % | **{raw3Stop(shapedPool) * 100:F1} %** |");
|
||
sb.AppendLine($"| land above the top stop (1.450, clamps white) | {(1.0 - rawPool.FractionBelow(1.450f)) * 100:F3} % | {(1.0 - shapedPool.FractionBelow(1.450f)) * 100:F3} % |");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**Deliberately left alone.** Re-placing the stops now would make the render look more");
|
||
sb.AppendLine("dramatic while the terrain is genuinely lower — it would disguise the very finding above.");
|
||
sb.AppendLine("Recalibrating the palette is a presentation call to make *after* the elevation profile is");
|
||
sb.AppendLine("settled, not before.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## The oracle");
|
||
sb.AppendLine();
|
||
sb.AppendLine(ShapingOracle.ToMarkdownTable(checks));
|
||
sb.AppendLine($"**{(oracleOk ? "ALL PASS" : "⚠⚠ FAILURES PRESENT — do not judge this batch until they are resolved")}**");
|
||
sb.AppendLine();
|
||
if (variants)
|
||
{
|
||
sb.AppendLine("## Results");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Folder | Seed | Variant | h min | h max | land % | grayscale range | time |");
|
||
sb.AppendLine("|---|---|---|---|---|---|---|---|");
|
||
foreach (string row in rows) sb.AppendLine(row);
|
||
sb.AppendLine();
|
||
}
|
||
sb.AppendLine("`scratch/` is persistent and is never cleaned.");
|
||
|
||
string index = Path.Combine(batchRoot, "INDEX.md");
|
||
using var f = Godot.FileAccess.Open(index, Godot.FileAccess.ModeFlags.Write);
|
||
if (f == null) { GD.PrintErr($"could not write {index}"); return; }
|
||
f.StoreString(sb.ToString());
|
||
}
|
||
|
||
// ---- env helpers ----------------------------------------------------
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|