using System; using System.Collections.Generic; using System.IO; using System.Text; using Godot; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// ⭐ THE LOCKED SHAPE — `terrain-shape-v1` (chat2/10 gallery-confirmed): the continuous curve + the /// frag_4 organic islands. Every later pass (erosion, rivers, …) starts from exactly these values, /// pinned here once so no tool re-types them. /// public static class TerrainShapeV1 { public const float FragmentAmp = 0.5f, FragmentFreq = 12f, BandCentre = 0.66f, BandHalfWidth = 0.18f; public const bool BitesOnly = false; public const float Stretch = 2f, BandStart = 0.70f, BandFeather = 0.05f; public const bool StretchSinker = true; public const float SpeckFrac = 2.5e-7f; /// Apply the locked shape to a config (curve settings are the caller's — they come from the calibration). public static void Apply(TerrainGenConfig c) { c.CoastShelf = false; c.Offshore = new OffshoreSettings(); c.RegionLabeling = true; c.SpeckRevert = true; c.MinLandComponentFrac = SpeckFrac; c.SouthStretch = Stretch; c.SouthBandStartFrac = BandStart; c.SouthBandFeatherFrac = BandFeather; c.StretchSinker = StretchSinker; c.FragmentAmp = FragmentAmp; c.FragmentFreqPerMapWidth = FragmentFreq; c.FragmentBandCentre = BandCentre; c.FragmentBandHalfWidth = BandHalfWidth; c.FragmentBitesOnly = BitesOnly; } public static string Describe() => $"terrain-shape-v1: frag amp {FragmentAmp} freq {FragmentFreq} window {BandCentre}±{BandHalfWidth} · stretch {Stretch} (band {BandStart}/{BandFeather}, sinker stretched) · speck revert {SpeckFrac:G2} · offshore OFF · shelf OFF · labeling ON"; } /// /// ⭐ 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()); 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", "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)}"); 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) { 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, }; TerrainShapeV1.Apply(c); 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); if (!skipTag) { string dump = Path.Combine(ToolingPaths.BatchesRoot, t10Source, $"{seed}", "height.f32"); if (File.Exists(dump) && mapSize == 8192) hard.Add(ShapingOracle.DumpRegression("a10", $"erosion OFF == terrain-shape-v1 (the task-10 gallery dump) [{seed}]", p2Off.Height, HeightField.Load(dump, mapSize), mapSize, dump)); else GD.Print($" a10 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the gallery's 8192" : $"no gallery dump at {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(new TerrainGenConfig { MapSize = calibSize, Seed = s }); pass1[s] = p1; rawPool.Accumulate(p1.Height, calibSize); } var knots = new CurveKnots(2, "v2_balanced", rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]), rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]), rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5])); float ceilingRaw = knots.K2; var rawAbove = new LandHistogram(sea); var outAbove = new LandHistogram(sea); foreach (int s in CalibrationSeeds) { var scfg = new TerrainGenConfig { MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true, CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase", }; 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; } } }