using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
///
/// ⭐ THE EROSION BATCH (chat2/11) — the faithful droplet erosion on the locked shape, judged across
/// seeds, erosion OFF vs ON. 4 seeds from the task-10 gallery × {off, on} = 8 fields at showpiece
/// size; per field grayscale + .f32 + hillshaded relief; a mid-slope close-up off/on on the first
/// seed (the green→yellow feather gate); the erosion stats table; the asymmetric oracle.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ErosionTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
/// ISLA_SEEDS (default 4 gallery seeds)
/// ISLA_ERO_COUNT / _LIFETIME / _CARVE / _DEPOSIT governor overrides (the faithful tune is the default)
/// ISLA_SKIP_TAG_CHECK=1 skip the bit-identity against the 10 gallery dumps (terrain-shape-v1)
///
public partial class ErosionTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 17320508 };
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 8192;
private const int DefaultCalibSize = 2048;
/// The pure-shade plate's vertical exaggeration — stronger than the relief's 18 so half-metre drainage reads. A look dial.
private const float ShadeZ = 60f;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class Row
{
public int Seed; public HydraulicErosion.Stats St; public HydraulicErosion.Params P;
public long WetBefore, WetAfter; public ulong MsErosion, MsGen;
public double ErodedMeanM, DepositedMeanM; public long LandCells;
public bool Ok;
}
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", 11);
string descr = EnvStr("ISLA_BATCH", "erosion");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool skipTag = EnvStr("ISLA_SKIP_TAG_CHECK", "0") == "1";
string t10Source = EnvStr("ISLA_T10_SOURCE", "chat2/10_frag4_seed_gallery");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
GD.Print("==================================================================");
GD.Print(" HYDRAULIC EROSION (chat2/11) — the faithful port on the locked shape, off vs on");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
TerrainShapeV1.Assert("ErosionTool"); // ⚠ rivers/01: refuse to render if the defaults drifted off the locked shape
GD.Print($"shape : {TerrainShapeV1.Describe()}");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed, string label, bool erosion)
{
// ⭐ rivers/01: THE SHAPE COMES FROM THE BARE DEFAULTS. `TerrainShapeV1.Apply(c)` used to
// sit here; the locked shape is now what `new TerrainGenConfig()` produces, so stamping
// a preset on top would MASK a default drift instead of catching it. Only the CURVE
// (measured this run) and the per-variant erosion flag are set.
// → TerrainShapeV1.Assert(), called before any generation below.
var c = new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
c.Erosion = erosion;
c.ErosionDropletCount = EnvInt("ISLA_ERO_COUNT", c.ErosionDropletCount);
c.ErosionDropletLifetime = EnvInt("ISLA_ERO_LIFETIME", c.ErosionDropletLifetime);
c.ErosionCarveCapM = EnvFloat("ISLA_ERO_CARVE", c.ErosionCarveCapM);
c.ErosionDepositCapM = EnvFloat("ISLA_ERO_DEPOSIT", c.ErosionDepositCapM);
return c;
}
var tuneCfg = Cfg(mapSize, seeds[0], "tune", true);
GD.Print($" tune : droplets {tuneCfg.ErosionDropletCount:N0} · lifetime {tuneCfg.ErosionDropletLifetime} · carve cap {tuneCfg.ErosionCarveCapM} m · deposit cap {tuneCfg.ErosionDepositCapM} m · sea margin {tuneCfg.ErosionSeaMarginM} m · brush {tuneCfg.ErosionBrushRadius} px · " +
$"inertia {tuneCfg.ErosionInertia} · capacity {tuneCfg.ErosionCapacity} · min slope {tuneCfg.ErosionMinSlopeM} m/px · erode {tuneCfg.ErosionErodeRate} · deposit {tuneCfg.ErosionDepositRate} · evaporation {tuneCfg.ErosionEvaporation} · gravity {tuneCfg.ErosionGravity} · crater exclusion INERT");
// ═══ THE FIELDS ═══
var hard = new List();
var perSeed = new List();
var rows = new List();
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
};
float bandLo = sea + WorldScale.RawFromMetres(30f), bandHi = sea + WorldScale.RawFromMetres(100f); // the green→yellow mid-slope band
bool cropDone = false;
for (int si = 0; si < seeds.Length; si++)
{
int seed = seeds[si];
GD.Print($"\n--- seed {seed} ---");
// OFF — the locked shape, the reference half.
var cOff = Cfg(mapSize, seed, "erosion_off", false);
Pass1Result p1Off = Topography.Generate(cOff);
Pass2Result p2Off = Shaping.Shape(p1Off, cOff);
// ⭐ a10 — THE SHAPE ACCEPTANCE ANCHOR (rivers/01 keeps this one). Since the re-baseline
// `p2Off` is generated from the BARE DEFAULTS, so this check is now the standing proof
// that the defaults still reproduce `terrain-shape-v1`.
// ⚠ A missing dump now THROWS (ShapingOracle.LoadAnchor) instead of skipping silently.
if (!skipTag)
{
string dump = Path.Combine(ToolingPaths.BatchesRoot, t10Source, $"{seed}", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a10", $"erosion OFF from the BARE DEFAULTS == terrain-shape-v1 (the task-10 gallery dump) [{seed}]",
p2Off.Height, ShapingOracle.LoadAnchor("a10", "ISLA_T10_SOURCE", dump, mapSize), mapSize, dump));
}
Image reliefOff = ReliefRenderer.Render(p2Off.Height, mapSize, look);
Image shadeOff = ShadeRenderer.Render(p2Off.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
WriteField(batchRoot, p2Off, sea, anchors, skipRaw, reliefOff, shadeOff, "OFF");
// ON — an independent generation, then the pass.
var cOn = Cfg(mapSize, seed, "erosion_on", true);
Pass1Result p1On = Topography.Generate(cOn);
Pass2Result p2Shaped = Shaping.Shape(p1On, cOn);
ulong tE = Time.GetTicksMsec();
var ero = ErosionPass.Apply(p2Shaped, cOn);
Pass2Result p2On = ero.Shaped;
foreach (string nline in ero.Notes) GD.Print(" " + nline);
Image reliefOn = ReliefRenderer.Render(p2On.Height, mapSize, look);
Image shadeOn = ShadeRenderer.Render(p2On.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
WriteField(batchRoot, p2On, sea, anchors, skipRaw, reliefOn, shadeOn, "ON");
// The oracle, per seed.
var checks = new List
{
ShapingOracle.NorthLocked("c", "classify field bit-identical, erosion OFF vs ON (erosion is render-only)", p1Off.Height, p1On.Height, mapSize, mapSize),
ShapingOracle.NorthLocked("c2", "the ON result's classify field IS the pass-1 field (untouched by the pass)", p2On.HeightClassify, p1On.Height, mapSize, mapSize),
ShapingOracle.LabelsDeterministic(p1Off, p1On),
FloodGuard(ero, p2Off.Height, mapSize, sea),
Caps(ero),
ShapingOracle.TagCoastlineConsistent(p2On, sea),
ShapingOracle.ClassifyFidelity(p1On, p2On),
ShapingOracle.CentreIsLand(p1On),
};
checks[2].Name = "region labeling + island tag identical, erosion OFF vs ON";
foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); GD.Print(" " + c); }
bool ok = checks.TrueForAll(c => c.Passed);
// Determinism — the first seed: shape again from the same pass 1, erode again, compare bit for bit.
if (si == 0)
{
var again = ErosionPass.Apply(Shaping.Shape(p1On, cOn), cOn);
var det = ShapingOracle.NorthLocked("o", "eroded render field bit-identical across two runs (determinism)", p2On.Height, again.Shaped.Height, mapSize, mapSize);
det.Name += $" [{seed}]"; perSeed.Add(det); GD.Print(" " + det);
ok &= det.Passed;
}
// The mid-slope crop — the first seed: the 1024² window with the most green→yellow band cells.
if (!cropDone)
{
var (cx, cy, cw) = FindMidSlopeWindow(p2Off.Height, mapSize, bandLo, bandHi, Math.Min(1024, mapSize / 4));
WriteCrop(batchRoot, reliefOff, reliefOn, cx, cy, cw, seed, "midslope");
WriteCrop(batchRoot, shadeOff, shadeOn, cx, cy, cw, seed, "midslope_shade");
GD.Print($" mid-slope crop: window ({cx},{cy}) {cw}² — {Path.Combine(batchRoot, "midslope_pair.png")}");
cropDone = true;
}
long land = 0; for (int x = 0; x < mapSize; x++) for (int y = 0; y < mapSize; y++) if (p1Off.Height[x, y] >= sea) land++;
rows.Add(new Row
{
Seed = seed, St = ero.Stats, P = ero.Params, WetBefore = ero.WetBefore, WetAfter = ero.WetAfter, MsErosion = ero.Ms, MsGen = p1On.ElapsedMs,
LandCells = land, ErodedMeanM = ero.Stats.ModifiedCells == 0 ? 0 : ero.Stats.ErodedVolumeM3 / ero.Stats.ModifiedCells,
DepositedMeanM = ero.Stats.ModifiedCells == 0 ? 0 : ero.Stats.DepositedVolumeM3 / ero.Stats.ModifiedCells, Ok = ok,
});
GD.Print($" seed {seed}: {(ok ? "ok" : "⚠ CHECK FAILED")} erosion {ero.Ms / 1000.0:F1}s");
}
bool allOk = hard.TrueForAll(c => c.Passed) && perSeed.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
WriteIndex(batchRoot, mapSize, calibSize, seeds, rows, tuneCfg, hard, perSeed, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES — see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the checks only this batch needs -----------------------------------
private static ShapingOracle.Check FloodGuard(ErosionPass.Result ero, float[,] offRender, int n, float sea)
{
long wetOff = ErosionPass.CountWaterPixels(offRender, n, sea);
var c = new ShapingOracle.Check { Id = "f", Name = "flood guard — render water pixels unchanged (OFF field, before, after)" };
c.Passed = wetOff == ero.WetBefore && ero.WetBefore == ero.WetAfter;
c.Detail = $"OFF {wetOff:N0} · before {ero.WetBefore:N0} · after {ero.WetAfter:N0}" + (c.Passed ? " — no coastline moved" : " — MOVED");
return c;
}
private static ShapingOracle.Check Caps(ErosionPass.Result ero)
{
var st = ero.Stats; var p = ero.Params;
var c = new ShapingOracle.Check { Id = "g", Name = "governor caps proven on exit (the pass re-checked here)" };
bool carveOk = st.MaxCellErosionM <= p.CarveCapM * (1f + 1e-5f);
bool depOk = p.DepositCapM <= 0f || st.MaxCellDepositM <= p.DepositCapM * (1f + 1e-5f);
c.Passed = carveOk && depOk;
c.Detail = $"max cell carve {st.MaxCellErosionM:F3} m ≤ {p.CarveCapM} m; max cell deposit {st.MaxCellDepositM:F3} m ≤ {p.DepositCapM} m; {st.ModifiedCells:N0} cells touched";
return c;
}
private static (int x, int y, int w) FindMidSlopeWindow(float[,] h, int n, float lo, float hi, int w)
{
int best = -1, bx = 0, by = 0; int step = Math.Max(64, w / 4);
for (int x0 = 0; x0 + w <= n; x0 += step)
for (int y0 = 0; y0 + w <= n; y0 += step)
{
int cnt = 0;
for (int x = x0; x < x0 + w; x += 4)
for (int y = y0; y < y0 + w; y += 4)
{ float v = h[x, y]; if (v >= lo && v <= hi) cnt++; }
if (cnt > best) { best = cnt; bx = x0; by = y0; }
}
return (bx, by, w);
}
// ---- the curve --------------------------------------------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary();
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)
{
// ⭐ 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);
}
// ---- output -----------------------------------------------------------
private static void WriteField(string batchRoot, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, Image relief, Image shade, string tag)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
shade.SavePng(Path.Combine(dir, "shade.png"));
GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
LegendRenderer.WithLegend((Image)relief.Duplicate(), ReliefPalette.Kind.ProvisionalEven, sea, anchors.PeakCap, $"EROSION {tag} {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
}
private static void WriteCrop(string batchRoot, Image off, Image on, int x, int y, int w, int seed, string name)
{
var rect = new Rect2I(x, y, w, w);
Image a = off.GetRegion(rect), b = on.GetRegion(rect);
a.SavePng(Path.Combine(batchRoot, $"{name}_off.png"));
b.SavePng(Path.Combine(batchRoot, $"{name}_on.png"));
var pair = Image.CreateEmpty(w * 2 + 16, w, false, Image.Format.Rgb8);
pair.Fill(new Color(0, 0, 0));
pair.BlitRect(a, new Rect2I(0, 0, w, w), new Vector2I(0, 0));
pair.BlitRect(b, new Rect2I(0, 0, w, w), new Vector2I(w + 16, 0));
int s = 3; int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(pair, $"MID-SLOPE (30-100 M BAND) SEED {seed} WINDOW ({x},{y}) {w}PX", 12, 12, s, new Color(0.94f, 0.95f, 0.96f));
TinyFont.Draw(pair, "LEFT: EROSION OFF", 12, 12 + lh, s, new Color(0.94f, 0.95f, 0.96f));
TinyFont.Draw(pair, "RIGHT: EROSION ON", w + 16 + 12, 12 + lh, s, new Color(0.94f, 0.95f, 0.96f));
pair.SavePng(Path.Combine(batchRoot, $"{name}_pair.png"));
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List rows, TerrainGenConfig tune,
List hard, List perSeed, bool allOk)
{
int best = seeds[0];
var sb = new StringBuilder();
sb.AppendLine($"# Batch 11 — hydraulic erosion on the locked shape: off vs on, {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.AppendLine("**The faithful droplet erosion** (the reference's `HydraulicErosion`, ported verbatim into `Core/`, `WorldScale`-denominated),");
sb.AppendLine("run on the RENDER map only after shaping. OFF is the locked shape `terrain-shape-v1` (bit-identical to the task-10 gallery);");
sb.AppendLine("ON is the star; the pair is the before/after. Hillshade is what makes the dendritic drainage read.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{best}_erosion_on/relief.png`** — then `{best}_erosion_off/relief.png` beside it.");
sb.AppendLine("2. **`midslope_pair.png`** — the mid-slope (30–100 m, green→yellow) close-up, OFF left / ON right: the feather gate; **`midslope_shade_pair.png`** is the same window in pure hillshade (z×60), where half-metre drainage reads.");
sb.AppendLine(" Every field also has **`shade.png`** — pure hillshade, land only — beside its `relief.png`.");
sb.AppendLine("3. The other three pairs below.");
sb.AppendLine();
sb.AppendLine("## The contact sheet — OFF / ON side by side");
sb.AppendLine();
sb.AppendLine("| Seed | erosion OFF (relief · shade) | erosion ON (relief · shade) | droplets · steps | eroded m³ / deposited m³ | max cell carve / deposit (m) | cells touched | water px before → after | erosion wall | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | [`relief`]({r.Seed}_erosion_off/relief.png) · [`shade`]({r.Seed}_erosion_off/shade.png) | [`relief`]({r.Seed}_erosion_on/relief.png) · [`shade`]({r.Seed}_erosion_on/shade.png) | " +
$"{r.St.Spawned:N0} · {r.St.Steps:N0} | {r.St.ErodedVolumeM3:N0} / {r.St.DepositedVolumeM3:N0} | {r.St.MaxCellErosionM:F2} / {r.St.MaxCellDepositM:F2} | {r.St.ModifiedCells:N0} ({100.0 * r.St.ModifiedCells / Math.Max(1, r.LandCells):F1} % of land) | {r.WetBefore:N0} → {r.WetAfter:N0} | {r.MsErosion / 1000.0:F0} s | {(r.Ok ? "pass" : "**FAIL**")} |");
sb.AppendLine();
sb.AppendLine("Deaths per seed (sea / edge / dry / lifetime): " + string.Join(" · ", rows.ConvertAll(r => $"`{r.Seed}` {r.St.DiedSea} / {r.St.DiedEdge} / {r.St.DiedDry} / {r.St.DiedLifetime}")));
sb.AppendLine();
sb.AppendLine("## The tune (faithful — the reference's declared defaults, unchanged unless stated)");
sb.AppendLine();
sb.AppendLine($"droplets `{tune.ErosionDropletCount:N0}` · lifetime `{tune.ErosionDropletLifetime}` · carve cap `{tune.ErosionCarveCapM} m` · deposit cap `{tune.ErosionDepositCapM} m` · sea margin `{tune.ErosionSeaMarginM} m` · brush `{tune.ErosionBrushRadius} px` · " +
$"inertia `{tune.ErosionInertia}` · capacity `{tune.ErosionCapacity}` · min slope `{tune.ErosionMinSlopeM} m/px` · erode `{tune.ErosionErodeRate}` · deposit `{tune.ErosionDepositRate}` · evaporation `{tune.ErosionEvaporation}` · gravity `{tune.ErosionGravity}` · " +
$"seed offset `{HydraulicErosion.SEED_OFFSET}` · crater exclusion **INERT** (no crater; core ×{tune.CraterErosionCore}, feather ×{tune.CraterErosionFeather}, feather mode — activates with the crater task).");
sb.AppendLine();
sb.AppendLine($"Shape: {TerrainShapeV1.Describe()}. Curve calibrated at {calibSize}.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven` + hillshade (z-exaggeration 18, strength 0.30), flagged. The grayscale is the honest instrument.");
sb.AppendLine();
sb.AppendLine("## The oracle (render-only: classify, labels and every island untouched; no coastline moved)");
sb.AppendLine();
sb.AppendLine(hard.Count == 0 ? "*(the terrain-shape-v1 check was skipped)*\n" : ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per seed (classify bit-identical c / c2 · labels identical · flood guard f · caps g · tag/coastline k · classify b · centre m · determinism o):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perSeed));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : "⚠⚠ FAILURES — do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `relief.png`, `shade.png` (both), `midslope_*.png`, `INDEX.md` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the locked shape (+ the tune) — 256 MB each, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"{WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static 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();
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;
}
}
}