islaApocalypse-v2/Tools/Scripts/OffshoreIslandsTool.cs
beezm 03fe75b378 rivers/01: re-baseline — bare defaults reproduce terrain-shape-v1 (calibration pinned family-off); retire stale oracles; namespace batch root by chat
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
2026-08-23 04:45:10 -04:00

681 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE OFFSHORE-ISLANDS BATCH — chat2/06: ORGANIC-ONLY, TUNED FOR COVERAGE, SOUTH-WEIGHTED,
/// NO FORCED COUNT. (chat2/05's version of this tool — faithful / floor_only / hybrid / dense —
/// is in git history at 3b96e06; the forced floor it batched was reverted out on look.)
///
/// ⚠ Since chat2/07 the island tag is set BY THE REGION LAYER (every non-mainland land component,
/// natural islets included), so this tool's counts now include natural islands; the chat2/06 batch
/// of record (offshore-pass islands only) was produced at e8571b2.
///
/// ═══ WHAT IT PRODUCES — a fixed budget: 4 plates + a count table + a diagnosis ═══
///
/// PLATES (4, at ISLA_MAPSIZE, grayscale + .f32 + relief + tags overlay):
/// {plate}_density_low / _mid / _high three densities on ONE seed — the developer picks the
/// look by eye (more islands vs slop), apples to apples.
/// {bulge}_density_mid the preset on the southern-bulge seed — the table seed
/// whose south was SPARSEST at density_mid (auto-picked,
/// or ISLA_BULGE_SEED) — the south fix is not seed-specific.
///
/// THE COUNT TABLE (data, not plates): per seed, north / south island counts across ~12 seeds for
/// each of the 3 density levels, with min / mean / max per hemisphere per level, the guard
/// ledger and the island sizes. This is how "a few south / a couple north, consistently" is
/// READ — as statistics of the tuning, never as a floor.
///
/// THE DIAGNOSIS (data): per hemisphere over the calibration seed pool — valid-zone area, which
/// gate binds, noise peaks clearing the threshold — measured BEFORE any knob moved (§2 of the
/// task). → <see cref="OffshoreDiagnosis"/>.
///
/// ═══ THE CURVE IS THE TAGGED CURVE, UNCHANGED ═══
///
/// `continuous_restored` (tag terrain-curve-v1), calibrated on task 01's pool at the iteration
/// size WITH OFFSHORE OFF — islands are additive land on top of a curve that does not know they
/// exist. Oracle a1 / a3 / a4 prove offshore-off is bit-identical to Phase 1, task 03 and the
/// tag's own 04 gallery dump.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/OffshoreIslandsTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE plate + table size (default 4096 — islands need pixels to read)
/// ISLA_TABLE_SIZE count-table size (default = ISLA_MAPSIZE; a probe may drop it)
/// ISLA_CALIB_SIZE curve calibration size (default 2048, task 01's) — also the diagnosis size
/// ISLA_TABLE_SEEDS the count-table seeds (default 12 below)
/// ISLA_PLATE_SEED the three-density seed (default 1063685222)
/// ISLA_BULGE_SEED the south-bulge seed (default 0 = auto: sparsest south at density_mid)
/// ISLA_DENS_LOW / ISLA_DENS_MID / ISLA_DENS_HIGH / ISLA_SOUTH_WEIGHT the tuning (probe overrides)
/// ISLA_OFF_MINAREA / ISLA_OFF_MINSEP / ISLA_OFF_MAXAREA the guards (probe overrides)
/// ISLA_OFF_FREQ / ISLA_OFF_CORE / ISLA_OFF_SHARP / ISLA_OFF_CREST the shape (probe overrides)
/// ISLA_TABLE_ONLY=1 probe: diagnosis + count table only (no regressions, no plates)
/// ISLA_PHASE1_SOURCE the Phase-1 regression dump's batch (default "chat1/02_pass1_port")
/// </summary>
public partial class OffshoreIslandsTool : Node
{
/// <summary>
/// The count-table pool: chat2/05's six hybrid seeds + task 01's calibration pool (minus the
/// shared anchor) + one more. Twelve draws; the anchor first.
/// </summary>
private static readonly int[] DefaultTableSeeds =
{
1063685222, 20260821, 8675309, 123456789, 271828182, 999999937,
20260819, 777001, 424242, 90210, 31337, 55555,
};
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity. Also the diagnosis pool.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
/// <summary>The consistency targets the table is read against: "a couple north, a few south".</summary>
private const int TargetNorth = 2, TargetSouth = 3;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class Row
{
public string Level; public int Seed;
public int CountN, CountS, PreN, PreS, SpecksN, SpecksS, ClustersN, ClustersS, BlobsN, BlobsS;
public long Lifted, SizeMin, SizeMed, SizeMax; public double SizeMean;
public float HMaxBefore, HMaxAfter;
public bool Ok; public ulong Ms;
}
private sealed class Level
{
public string Label; public OffshoreSettings Settings;
}
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", 6);
string descr = EnvStr("ISLA_BATCH", "offshore_organic_tune");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int tableSize = EnvInt("ISLA_TABLE_SIZE", mapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
int bulgeSeedEnv = EnvInt("ISLA_BULGE_SEED", 0);
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
// ═══ THE THREE DENSITY LEVELS — the preset of record is `mid` ═══
OffshoreSettings LevelSettings(float density)
{
var o = OffshoreSettings.Organic();
o.Density = density;
o.SouthWeight = EnvFloat("ISLA_SOUTH_WEIGHT", o.SouthWeight);
o.MinIslandAreaFrac = EnvFloat("ISLA_OFF_MINAREA", o.MinIslandAreaFrac);
o.MinSeparationFrac = EnvFloat("ISLA_OFF_MINSEP", o.MinSeparationFrac);
o.MaxIslandAreaFrac = EnvFloat("ISLA_OFF_MAXAREA", o.MaxIslandAreaFrac);
o.FreqPerMapWidth = EnvFloat("ISLA_OFF_FREQ", o.FreqPerMapWidth);
o.CoreFraction = EnvFloat("ISLA_OFF_CORE", o.CoreFraction);
o.EdgeSharpness = EnvFloat("ISLA_OFF_SHARP", o.EdgeSharpness);
o.CrestM = EnvFloat("ISLA_OFF_CREST", o.CrestM);
return o;
}
var levels = new List<Level>
{
new() { Label = "density_low", Settings = LevelSettings(EnvFloat("ISLA_DENS_LOW", OffshoreSettings.OrganicDensityLow)) },
new() { Label = "density_mid", Settings = LevelSettings(EnvFloat("ISLA_DENS_MID", OffshoreSettings.OrganicDensityMid)) },
new() { Label = "density_high", Settings = LevelSettings(EnvFloat("ISLA_DENS_HIGH", OffshoreSettings.OrganicDensityHigh)) },
};
Level mid = levels[1];
GD.Print("==================================================================");
GD.Print(" OFFSHORE ISLANDS (chat2/06) — organic-only, tuned for coverage, south-weighted, no forced count");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} (plates) table at {tableSize} curve calibrated at {calibSize} (offshore OFF) diagnosis at {calibSize}");
GD.Print($"table : {string.Join(", ", tableSeeds)}");
GD.Print($"plate seed: {plateSeed} bulge seed: {(bulgeSeedEnv > 0 ? bulgeSeedEnv.ToString() : "auto (sparsest south at density_mid)")}");
foreach (var l in levels) GD.Print($" {l.Label,-13} {l.Settings.Describe()}");
GD.Print($"hemisphere: NORTH = rows [0, {mapSize / 2}) SOUTH = rows [{mapSize / 2}, {mapSize}) (y runs south)");
GD.Print($"batch : {batchRoot}{(tableOnly ? " ISLA_TABLE_ONLY a probe, not the batch of record" : "")}");
GD.Print("==================================================================");
// ═══ 0. THE CURVE — continuous_restored, calibrated with offshore off ═══
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration, _) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
GD.Print($" {calibration.Describe()}");
// ═══ 1. REGRESSIONS — the things that must not have moved ═══
var hard = new List<ShapingOracle.Check>();
if (!tableOnly)
{
GD.Print($"\n--- 1. REGRESSIONS at {calibSize}, seed {plateSeed} ---");
var offCfg = BaseConfig(calibSize, plateSeed, knots, anchors, calibration, "off");
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
Pass2Result pOff = Shaping.Shape(p1, curveOff);
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF == Phase-1 .f32 dump",
pOff.Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, calibSize), calibSize, p1Dump));
// ⚑ RETIRED at rivers/01 — a3, `continuous_restored` == `03_mountain_restore`.
// A curve-development intermediate: it proved the chat2/03 climb restoration against
// chat2/02. Both are upstream of the locked shape, and `terrain-shape-v1` (a10) now
// asserts the whole chain end-to-end — subsuming it.
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
// Shelf ON, islets OFF: land must be bit-identical (the shelf touches only sea).
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
var j0 = ShapingOracle.MainlandUnmoved(p1, p1Shelf, sea);
j0.Name = "shelf alone: every land cell bit-identical (shelf is below-sea only)";
hard.Add(j0);
hard.Add(ShapingOracle.HMaxAfterOffshore(p1Shelf));
foreach (var c in hard) GD.Print(" " + c);
// ⭐ a4 — offshore OFF at the 04 gallery's size == the terrain-curve-v1 tag's OWN output.
// The literal "offshore-off is bit-identical to terrain-curve-v1", at full size.
// ⚑ RETIRED at rivers/01 — a4, offshore-OFF at 8192 == `04_seed_gallery` (`terrain-curve-v1`).
// The PRE-FAMILY committed curve. The locked shape is family-ON, so this dump is a
// baseline the generator is deliberately no longer on; `a10` replaced it as the 8192
// acceptance. (It also cost an 8192 generation on every run of this tool.)
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
}
// ═══ 2. THE DIAGNOSIS — measure the south before touching a knob ═══
GD.Print($"\n--- 2. DIAGNOSIS (calibration pool at {calibSize}, shelf on, {mid.Label} thresholds) ---");
var diag = new List<OffshoreDiagnosis.Report>();
foreach (int s in CalibrationSeeds)
{
var dCfg = BaseConfig(calibSize, s, knots, anchors, calibration, "shelf_only");
dCfg.CoastShelf = true;
Pass1Result pShelf = Topography.Generate(dCfg);
var rep = OffshoreDiagnosis.Run(pShelf.Height, pShelf.PreTrenchFalloff, calibSize, s, sea, mid.Settings, dCfg.Scale);
diag.Add(rep);
GD.Print($" seed {s,-11} N: zone {rep.North.Zone,9:N0} ({rep.North.ZoneShareOfSea,6:P1}) peaks in zone {rep.North.PeaksInZone,3} > thr {rep.North.PeaksInZoneOverThr,3} " +
$"S: zone {rep.South.Zone,9:N0} ({rep.South.ZoneShareOfSea,6:P1}) peaks in zone {rep.South.PeaksInZone,3} > thr {rep.South.PeaksInZoneOverThr,3} " +
$"sole-blocked N d/f/t {rep.North.SoleDepth:N0}/{rep.North.SoleFalloff:N0}/{rep.North.SoleTrench:N0} S {rep.South.SoleDepth:N0}/{rep.South.SoleFalloff:N0}/{rep.South.SoleTrench:N0}");
}
var (poolN, poolS) = OffshoreDiagnosis.Pool(diag);
string interpretation = OffshoreDiagnosis.Interpret(poolN, poolS);
GD.Print(" " + interpretation);
// ═══ 3. THE COUNT TABLE — ~12 seeds × 3 levels ═══
GD.Print($"\n--- 3. COUNT TABLE at {tableSize} ---");
var rows = new List<Row>();
var perFieldChecks = new List<ShapingOracle.Check>();
bool notesShown = false;
foreach (int seed in tableSeeds)
{
Pass1Result p1Off = Topography.Generate(BaseConfig(tableSize, seed, knots, anchors, calibration, "off"));
foreach (var lv in levels)
{
var cfg = BaseConfig(tableSize, seed, knots, anchors, calibration, lv.Label);
cfg.CoastShelf = true; cfg.Offshore = lv.Settings.Clone();
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
if (!notesShown) { foreach (string n in p1.Notes) GD.Print(" " + n); notesShown = true; }
var comps = OffshoreAnalysis.Components(p1.IsIsland, p1.Height, sea, tableSize);
var (cn, cs) = OffshoreAnalysis.CountByHemisphere(comps);
var (szMin, szMed, szMean, szMax, _) = OffshoreAnalysis.SizeSummary(comps, 0);
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.MoatIntact(p1, comps),
ShapingOracle.MainlandUnmoved(p1Off, p1, sea),
ShapingOracle.TagCoastlineConsistent(p2, sea),
ShapingOracle.HMaxAfterOffshore(p1),
ShapingOracle.ClassifyFidelity(p1, p2),
};
foreach (var c in checks) { c.Name += $" [{lv.Label} {seed}]"; perFieldChecks.Add(c); }
bool ok = checks.TrueForAll(c => c.Passed);
var row = new Row
{
Level = lv.Label, Seed = seed, CountN = cn, CountS = cs,
Lifted = p1.OffshoreLiftedCells, HMaxBefore = p1.HMaxSeedBeforeOffshore, HMaxAfter = p1.HMaxSeed,
SizeMin = szMin, SizeMed = szMed, SizeMean = szMean, SizeMax = szMax, Ok = ok, Ms = p1.ElapsedMs,
};
ReadGuardLedger(p1, row);
rows.Add(row);
GD.Print($" {lv.Label,-13} seed {seed,-11} N {cn,2} S {cs,2} (pre-guard N {row.PreN,2} S {row.PreS,2}; specks {row.SpecksN + row.SpecksS,2} clusters {row.ClustersN + row.ClustersS,2} blobs {row.BlobsN + row.BlobsS,2}) " +
$"size med {szMed,5} max {szMax,6} {(ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
}
var stats = LevelStats(levels, rows);
GD.Print("\n per level (min / mean / max):");
foreach (var st in stats)
GD.Print($" {st.Label,-13} N {st.MinN} / {st.MeanN:F1} / {st.MaxN} S {st.MinS} / {st.MeanS:F1} / {st.MaxS} " +
$"seeds with N≥{TargetNorth} & S≥{TargetSouth}: {st.MeetBoth}/{st.Seeds} S≥N: {st.SouthAtLeastNorth}/{st.Seeds} " +
$"guards: specks {st.Specks} clusters {st.Clusters} blobs {st.Blobs}");
// ═══ 4. THE PLATES — exactly four ═══
int bulgeSeed = bulgeSeedEnv > 0 ? bulgeSeedEnv : PickBulgeSeed(rows, mid.Label, plateSeed);
GD.Print($"\n southern-bulge seed: {bulgeSeed}{(bulgeSeedEnv > 0 ? " (ISLA_BULGE_SEED)" : " (auto: sparsest south at density_mid among the table seeds)")}");
var plates = new List<(int seed, Level level)> { (plateSeed, levels[0]), (plateSeed, levels[1]), (plateSeed, levels[2]), (bulgeSeed, mid) };
var plateRows = new List<Row>();
if (!tableOnly)
{
GD.Print($"\n--- 4. PLATES at {mapSize} ---");
foreach (var (seed, lv) in plates)
{
var cfg = BaseConfig(mapSize, seed, knots, anchors, calibration, lv.Label);
cfg.CoastShelf = true; cfg.Offshore = lv.Settings.Clone();
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
var comps = OffshoreAnalysis.Components(p1.IsIsland, p1.Height, sea, mapSize);
var (cn, cs) = OffshoreAnalysis.CountByHemisphere(comps);
var moat = ShapingOracle.MoatIntact(p1, comps); moat.Name += $" [plate {lv.Label} {seed}]";
var tag = ShapingOracle.TagCoastlineConsistent(p2, sea); tag.Name += $" [plate {lv.Label} {seed}]";
perFieldChecks.Add(moat); perFieldChecks.Add(tag);
WritePlate(batchRoot, p1, p2, sea, anchors, skipRaw, cn, cs);
var row = new Row { Level = lv.Label, Seed = seed, CountN = cn, CountS = cs, Lifted = p1.OffshoreLiftedCells, Ok = moat.Passed && tag.Passed };
ReadGuardLedger(p1, row);
plateRows.Add(row);
GD.Print($" plate {seed}_{lv.Label}: N {cn} S {cs} lifted {p1.OffshoreLiftedCells:N0} {(row.Ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
foreach (string n in p1.Notes) if (n.Contains("guards") || n.Contains("islands:")) GD.Print(" " + n);
}
}
bool allOk = hard.TrueForAll(c => c.Passed) && perFieldChecks.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perFieldChecks) if (!c.Passed) GD.PrintErr(" " + c);
// ═══ 5. THE DATA FILES + INDEX ═══
WriteCountTable(batchRoot, tableSize, levels, rows, stats);
WriteDiagnosis(batchRoot, calibSize, mid, diag, poolN, poolS, interpretation);
WriteIndex(batchRoot, mapSize, tableSize, calibSize, plateSeed, bulgeSeed, tableSeeds, levels, rows, stats, plateRows,
diag, poolN, poolS, interpretation, hard, perFieldChecks, allOk, tableOnly);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the table's statistics -------------------------------------------
private sealed class LevelStat
{
public string Label; public int Seeds;
public int MinN, MaxN, MinS, MaxS; public double MeanN, MeanS;
public int MeetBoth, SouthAtLeastNorth, Specks, Clusters, Blobs;
public long SizeMed, SizeMax;
}
private static List<LevelStat> LevelStats(List<Level> levels, List<Row> rows)
{
var outp = new List<LevelStat>();
foreach (var lv in levels)
{
var st = new LevelStat { Label = lv.Label, MinN = int.MaxValue, MinS = int.MaxValue };
double sumN = 0, sumS = 0; var meds = new List<long>();
foreach (var r in rows)
{
if (r.Level != lv.Label) continue;
st.Seeds++;
st.MinN = Math.Min(st.MinN, r.CountN); st.MaxN = Math.Max(st.MaxN, r.CountN); sumN += r.CountN;
st.MinS = Math.Min(st.MinS, r.CountS); st.MaxS = Math.Max(st.MaxS, r.CountS); sumS += r.CountS;
if (r.CountN >= TargetNorth && r.CountS >= TargetSouth) st.MeetBoth++;
if (r.CountS >= r.CountN) st.SouthAtLeastNorth++;
st.Specks += r.SpecksN + r.SpecksS; st.Clusters += r.ClustersN + r.ClustersS; st.Blobs += r.BlobsN + r.BlobsS;
meds.Add(r.SizeMed); st.SizeMax = Math.Max(st.SizeMax, r.SizeMax);
}
if (st.Seeds == 0) { st.MinN = st.MinS = 0; }
else { st.MeanN = sumN / st.Seeds; st.MeanS = sumS / st.Seeds; meds.Sort(); st.SizeMed = meds[meds.Count / 2]; }
outp.Add(st);
}
return outp;
}
/// <summary>The table seed whose SOUTH count at the preset level is lowest (ties → lower total, then first in the pool), excluding the plate seed.</summary>
private static int PickBulgeSeed(List<Row> rows, string midLabel, int plateSeed)
{
int best = 0, bestS = int.MaxValue, bestTotal = int.MaxValue;
foreach (var r in rows)
{
if (r.Level != midLabel || r.Seed == plateSeed) continue;
int total = r.CountN + r.CountS;
if (r.CountS < bestS || (r.CountS == bestS && total < bestTotal)) { best = r.Seed; bestS = r.CountS; bestTotal = total; }
}
return best == 0 ? plateSeed : best;
}
/// <summary>Copy the pass's guard ledger into a table row (the pass reports it; the tool does not recompute it).</summary>
private static void ReadGuardLedger(Pass1Result p1, Row row)
{
var l = p1.OffshoreLedger;
if (l == null) return;
row.PreN = l.PreGuardNorth; row.PreS = l.PreGuardSouth;
row.SpecksN = l.SpecksNorth; row.SpecksS = l.SpecksSouth;
row.ClustersN = l.ClustersNorth; row.ClustersS = l.ClustersSouth;
row.BlobsN = l.BlobsNorth; row.BlobsS = l.BlobsSouth;
}
// ---- the curve, measured exactly as tasks 03/04/05 did ----------------
private static (CurveKnots, ClimbCalibration, Dictionary<int, Pass1Result>) 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)); // family-off PINNED (rivers/01), not defaulted
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)
{
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
// calibration is family-off" is a total claim rather than a field-by-field one.)
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]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal, pass1);
}
/// <remarks>
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT
/// (chat2/0506): authored and judged before the shape family existed, and its regressions hold
/// pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the bare defaults
/// family-ON, so without <see cref="TerrainGenConfig.WithFamilyOff"/> every config here would
/// silently acquire stretch + fragmentation and the anchor checks would fail for a configuration
/// reason, not a regression. The variants re-enable offshore explicitly, after the pin.
/// </remarks>
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
ClimbCalibration cal, string label) => new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = k, Anchors = a, ClimbCalibration = cal, LowlandCeilingM = 30f,
}.WithFamilyOff(); // ⭐ shelf / islets / speck / stretch / frag / erosion all OFF — pinned
// ---- output -----------------------------------------------------------
private static void WritePlate(string batchRoot, Pass1Result p1, Pass2Result p2, float sea,
CurveAnchors anchors, bool skipRaw, int countN, int countS)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
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 = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
// ⭐ The tag overlay — the one artifact that shows the DATA this pass set.
TagOverlayRenderer.SavePng(p2.Height, p2.IsIsland, p2.IslandHemisphere, p2.MapSize, sea,
countN, countS, Path.Combine(dir, "tags.png"));
}
private static string CountTableMarkdown(List<Level> levels, List<Row> rows, List<LevelStat> stats)
{
var sb = new StringBuilder();
sb.AppendLine("| Level | Seed | **N** | **S** | total | pre-guard N / S | specks | clusters | blobs | size cells min / median / mean / max | lifted cells | HMaxSeed before → after | oracle | ms |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var lv in levels)
foreach (var r in rows)
{
if (r.Level != lv.Label) continue;
sb.AppendLine($"| `{r.Level}` | `{r.Seed}` | **{r.CountN}** | **{r.CountS}** | {r.CountN + r.CountS} | {r.PreN} / {r.PreS} | " +
$"{r.SpecksN + r.SpecksS} | {r.ClustersN + r.ClustersS} | {r.BlobsN + r.BlobsS} | " +
$"{r.SizeMin} / {r.SizeMed} / {r.SizeMean:F0} / {r.SizeMax} | {r.Lifted:N0} | " +
$"{r.HMaxBefore:F4} → {r.HMaxAfter:F4}{(r.HMaxBefore != r.HMaxAfter ? " " : "")} | {(r.Ok ? "pass" : "**FAIL**")} | {r.Ms} |");
}
sb.AppendLine();
sb.AppendLine("**Per level — the consistency read (min / mean / max over the seeds):**");
sb.AppendLine();
sb.AppendLine($"| Level | density N / S | seeds | **N min / mean / max** | **S min / mean / max** | seeds with N ≥ {TargetNorth} & S ≥ {TargetSouth} | seeds with S ≥ N | specks / clusters / blobs reverted | median island (cells) | largest island (cells) |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
foreach (var st in stats)
{
var lv = levels.Find(l => l.Label == st.Label);
sb.AppendLine($"| `{st.Label}` | {lv.Settings.Density:F4} / {lv.Settings.DensitySouth:F4} | {st.Seeds} | **{st.MinN} / {st.MeanN:F1} / {st.MaxN}** | **{st.MinS} / {st.MeanS:F1} / {st.MaxS}** | " +
$"**{st.MeetBoth} / {st.Seeds}** | {st.SouthAtLeastNorth} / {st.Seeds} | {st.Specks} / {st.Clusters} / {st.Blobs} | {st.SizeMed} | {st.SizeMax} |");
}
return sb.ToString();
}
private static void WriteCountTable(string batchRoot, int tableSize, List<Level> levels, List<Row> rows, List<LevelStat> stats)
{
var sb = new StringBuilder();
sb.AppendLine($"# The count table — {rows.Count / Math.Max(1, levels.Count)} seeds × {levels.Count} density levels at {tableSize}");
sb.AppendLine();
sb.AppendLine("Tagged 8-connected island components per hemisphere (centroid), after the guards. **No count is forced**;");
sb.AppendLine("this is the statistical outcome of the tuning. NORTH = rows [0, N/2), SOUTH = rows [N/2, N); y runs south.");
sb.AppendLine();
sb.Append(CountTableMarkdown(levels, rows, stats));
WriteText(Path.Combine(batchRoot, "count_table.md"), sb.ToString());
var csv = new StringBuilder();
csv.AppendLine("level,seed,density_n,density_s,north,south,total,preguard_n,preguard_s,specks,clusters,blobs,size_min,size_median,size_mean,size_max,lifted_cells,hmax_before,hmax_after,oracle,ms");
foreach (var r in rows)
{
var lv = levels.Find(l => l.Label == r.Level);
csv.AppendLine(string.Join(",", r.Level, r.Seed, lv.Settings.Density.ToString("F5", System.Globalization.CultureInfo.InvariantCulture),
lv.Settings.DensitySouth.ToString("F5", System.Globalization.CultureInfo.InvariantCulture),
r.CountN, r.CountS, r.CountN + r.CountS, r.PreN, r.PreS, r.SpecksN + r.SpecksS, r.ClustersN + r.ClustersS, r.BlobsN + r.BlobsS,
r.SizeMin, r.SizeMed, r.SizeMean.ToString("F1", System.Globalization.CultureInfo.InvariantCulture), r.SizeMax, r.Lifted,
r.HMaxBefore.ToString("G9", System.Globalization.CultureInfo.InvariantCulture), r.HMaxAfter.ToString("G9", System.Globalization.CultureInfo.InvariantCulture),
r.Ok ? "pass" : "FAIL", r.Ms));
}
WriteText(Path.Combine(batchRoot, "count_table.csv"), csv.ToString());
}
private static string DiagnosisMarkdown(int calibSize, Level mid, List<OffshoreDiagnosis.Report> diag,
HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation)
{
var sb = new StringBuilder();
sb.AppendLine($"Measured on the calibration pool at {calibSize} (shelf on, offshore off — the field the islet layer sees), with the");
sb.AppendLine($"`{mid.Label}` thresholds (density N {mid.Settings.Density:F4} / S {mid.Settings.DensitySouth:F4}). *Zone* = cells with zone weight > 0");
sb.AppendLine("(pass the falloff test + moat + outer bound); *peaks* = strict 8-neighbour local maxima of the islet noise field on sea cells;");
sb.AppendLine("*sole blocker* = sea cells that fail exactly one gate (loosen that gate and they join the zone); *lost > thr* = over-threshold");
sb.AppendLine("peaks outside the zone, with the gate(s) that excluded them.");
sb.AppendLine();
sb.AppendLine(OffshoreDiagnosis.TableHeader());
foreach (var r in diag)
{
sb.AppendLine(OffshoreDiagnosis.TableRow(r, r.North));
sb.AppendLine(OffshoreDiagnosis.TableRow(r, r.South));
}
var poolRep = new OffshoreDiagnosis.Report { Seed = 0 };
sb.AppendLine(OffshoreDiagnosis.TableRow(poolRep, poolN).Replace("| `0` |", "| **pool** |"));
sb.AppendLine(OffshoreDiagnosis.TableRow(poolRep, poolS).Replace("| `0` |", "| **pool** |"));
sb.AppendLine();
sb.AppendLine($"**Reading:** {interpretation}");
return sb.ToString();
}
private static void WriteDiagnosis(string batchRoot, int calibSize, Level mid, List<OffshoreDiagnosis.Report> diag,
HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation)
{
var sb = new StringBuilder();
sb.AppendLine("# The south diagnosis — zone area, gates and peaks per hemisphere");
sb.AppendLine();
sb.Append(DiagnosisMarkdown(calibSize, mid, diag, poolN, poolS, interpretation));
WriteText(Path.Combine(batchRoot, "diagnosis.md"), sb.ToString());
}
private static void WriteIndex(string batchRoot, int mapSize, int tableSize, int calibSize, int plateSeed, int bulgeSeed,
int[] tableSeeds, List<Level> levels, List<Row> rows, List<LevelStat> stats, List<Row> plateRows,
List<OffshoreDiagnosis.Report> diag, HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk, bool tableOnly)
{
var mid = levels[1];
var sb = new StringBuilder();
sb.AppendLine("# Batch 06 — offshore islands: organic-only, tuned for coverage, south-weighted, no forced count");
sb.AppendLine();
sb.AppendLine("The chat2/05 forced floor (seeded stamps, guaranteed ≥2 N / ≥4 S) is **reverted out** — it looked stamped.");
sb.AppendLine("The **organic noise-field layer is the only island mechanism**; this batch tunes its **density** (the main");
sb.AppendLine("knob) and a **south weight** so it yields *a few south / a couple north* **consistently across seeds, as a");
sb.AppendLine("statistical outcome** — never a hard-coded count. Guards against slop: specks, clusters and blobs are");
sb.AppendLine("reverted whole; every surviving island is a noise outline, small, low, crisp.");
sb.AppendLine();
if (tableOnly) sb.AppendLine("> ⚠ **ISLA_TABLE_ONLY** — a probe run: diagnosis + count table only, no regressions, no plates. Not the batch of record.\n");
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{plateSeed}_density_mid/tags.png`** — the preset of record: grey mainland, cyan = island N, orange = island S.");
sb.AppendLine(" No rings any more — nothing is seeded. Then its `relief.png` for the shape.");
sb.AppendLine($"2. **`{plateSeed}_density_low/`** and **`{plateSeed}_density_high/`** beside it — the same seed, less and more density;");
sb.AppendLine(" pick the look by eye (more islands vs slop).");
sb.AppendLine($"3. **`{bulgeSeed}_density_mid/tags.png`** — the preset on the southern-bulge seed (the table seed whose south was");
sb.AppendLine(" sparsest at `density_mid`): the south tuning is not seed-specific.");
sb.AppendLine("4. Then the count table below — *does it consistently give a few south / a couple north?*");
sb.AppendLine();
sb.AppendLine($"**Hemisphere convention (from the code, not invented):** y runs SOUTH. NORTH = rows `[0, {mapSize / 2})`,");
sb.AppendLine($"SOUTH = rows `[{mapSize / 2}, {mapSize})`. Component hemisphere is by centroid; the tag per cell is by row.");
sb.AppendLine();
sb.AppendLine("## The four plates");
sb.AppendLine();
sb.AppendLine("| Plate | N | S | total | pre-guard N / S | specks / clusters / blobs | lifted cells | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|");
foreach (var r in plateRows)
sb.AppendLine($"| `{r.Seed}_{r.Level}/` | **{r.CountN}** | **{r.CountS}** | {r.CountN + r.CountS} | {r.PreN} / {r.PreS} | {r.SpecksN + r.SpecksS} / {r.ClustersN + r.ClustersS} / {r.BlobsN + r.BlobsS} | {r.Lifted:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
if (plateRows.Count == 0) sb.AppendLine("| *(no plates — probe run)* | | | | | | | |");
sb.AppendLine();
sb.AppendLine($"## ⭐ The count table — {tableSeeds.Length} seeds × 3 levels at {tableSize} (the consistency evidence)");
sb.AppendLine();
sb.Append(CountTableMarkdown(levels, rows, stats));
sb.AppendLine();
sb.AppendLine("Also as plain data: `count_table.md` / `count_table.csv`.");
sb.AppendLine();
sb.AppendLine("## The levels — density and south weight");
sb.AppendLine();
sb.AppendLine("| Level | settings |");
sb.AppendLine("|---|---|");
foreach (var lv in levels) sb.AppendLine($"| `{lv.Label}`{(lv == mid ? " preset of record" : "")} | {lv.Settings.Describe()} |");
sb.AppendLine();
sb.AppendLine("The coast shelf is ON for every field (strength 0.775, scale 100 m, BitDecrement-clamped); invisible on these");
sb.AppendLine("hypsometric plates — ported faithfully, judged when water renders. Moat / falloff test / outer bound unchanged from chat2/05.");
sb.AppendLine();
sb.AppendLine("## The south diagnosis — measured before tuning");
sb.AppendLine();
sb.Append(DiagnosisMarkdown(calibSize, mid, diag, poolN, poolS, interpretation));
sb.AppendLine();
sb.AppendLine("Also as `diagnosis.md`.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven`, flagged. Grayscale + `tags.png` are the honest instruments here.");
sb.AppendLine();
sb.AppendLine("## The oracle");
sb.AppendLine();
sb.AppendLine("Regressions (offshore OFF must be bit-identical to Phase 1, task 03 and the `terrain-curve-v1` gallery dump):");
sb.AppendLine();
sb.AppendLine(hard.Count == 0 ? "*(skipped — probe run)*\n" : ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per field (moat i · mainland unmoved j · tag/coastline k · HMaxSeed l · classify b):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `tags.png`, `relief.png`, `INDEX.md`, `count_table.md` / `.csv`, `diagnosis.md` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Plates at {mapSize}, table at {tableSize}, curve calibrated at {calibSize} with offshore off. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
// ---- 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 float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float 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;
}
}
}