using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
///
/// ⭐ THE SEED GALLERY (chat2/04) — is continuous_restored good across seeds, or is
/// 1063685222 a lucky draw?
///
/// ═══ ⚠⚠ THIS TOOL RENDERS. IT DOES NOT TUNE. ═══
///
/// The curve is the one committed at tag terrain-curve-v1 and it is PINNED HERE IN CODE —
/// , and are
/// constants with no environment override, deliberately. Every other tool in this phase exposes
/// its knobs to ISLA_* so they can be probed; a GALLERY must not, because a stray
/// environment variable left over from a probe would silently render eight plates of a curve
/// nobody chose and they would look exactly like the real thing. The knobs are printed in the
/// run header and written into the INDEX so the plates can always be traced to a curve.
///
/// ═══ THE CALIBRATION IS RE-MEASURED, NOT RE-INVENTED ═══
///
/// continuous_restored is defined by a calibration measured on task 01's six-seed pool at
/// the ITERATION size, then applied at any size. This tool reproduces that measurement exactly —
/// same pool, same size, same primary seed for the normalization anchor — so the gallery renders
/// the same curve task 03 gated, not a look-alike. → .
///
/// ⚠ The gallery seeds are NOT the calibration pool. The pool stays fixed at task 01's six; the
/// gallery is eight separate draws, seven of them never previously rendered.
///
/// ═══ THE CHARACTER NOTE IS A HUMAN JUDGEMENT, AND IS TREATED AS ONE ═══
///
/// The INDEX carries a one-word note per seed ("clean" / "sharp mid-slope" / "flat draw"). That is
/// an EYE call, so this tool will not invent it. It reads the notes from
/// scratch/character_notes.tsv if that file exists and prints "(pending)" if it does not.
/// Write the notes after looking at the plates, then re-run with ISLA_INDEX_ONLY=1 to
/// rebuild the INDEX from scratch/metrics.tsv without re-rendering a single pixel.
///
/// What the tool DOES contribute is an objective companion, and getting it right took one correction
/// worth recording:
///
/// ⚠ THE CURVE'S NORMALIZED MID-SLOPE CANNOT DISCRIMINATE BETWEEN SEEDS. It is
/// dv/du on a fixed set of control points, and each seed's denormalization is an affine
/// rescale of both axes — which leaves dv/du untouched. It is the SAME NUMBER on every
/// seed by construction, so measuring it per seed answers nothing. (The first cut of this tool
/// reported exactly that and printed an identical 1.86 for every draw.)
///
/// So the metric that ships is the one the eye is actually reacting to: the **spatial height
/// gradient through the 100–220 m band**, in metres per pixel — how fast the ground climbs
/// through the heights where the yellow→orange transition sits. That is
/// (dOutput/dRaw) × (dRaw/dPixel): the first factor varies by seed because a taller
/// spikeMax spreads the same curve over more raw range, and the second is pure terrain.
/// Both are seed-dependent, and their product is what a render shows.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/SeedGalleryTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SEEDS / ISLA_SKIP_RAW
/// ISLA_MAPSIZE gallery render size (default 8192 — judging size, not calibration size)
/// ISLA_CALIB_SIZE calibration pool size (default 2048 — task 01's, do not change casually)
/// ISLA_INDEX_ONLY "1" to rebuild INDEX.md from scratch/metrics.tsv and skip all rendering
///
public partial class SeedGalleryTool : Node
{
// ═══ THE CURVE, PINNED. No environment override, by design — see the type header. ═══
/// task-03 `continuous_restored`: reproduce the staircase's mountain.
private const float MountainLift = 1.0f;
/// task-03 `continuous_restored`: a straight run to the cap, no extra summit steepening.
private const float PeakSharpness = 1.0f;
/// The flood line — hands over at exactly (K2, RED_CEIL).
private const float LowlandCeilingM = 30f;
///
/// ⚠ TASK 01'S CALIBRATION POOL, VERBATIM. Not the gallery seeds. Changing this changes the
/// curve, which is the one thing this task must not do.
///
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
///
/// ⭐ THE GALLERY. `1063685222` is the ANCHOR — the plate every previous task was judged on —
/// and the other seven are fresh draws never rendered before.
///
/// ⚠⚠ THESE WERE FIXED BEFORE THE FIRST RENDER AND WERE NEVER SCREENED OR REPLACED. That is
/// the whole point of a spread: hand-picking flattering draws would answer the question
/// "can this curve ever look good?" when the question asked is "does it look good generally?"
/// If a seed in this list turns out ugly, it stays in the gallery and goes in the report.
///
/// None of the seven appear in the calibration pool, so none of them shaped the curve they
/// are being used to test.
///
private static readonly int[] GallerySeeds =
{
1063685222, // ⭐ the anchor — Phase 1's primary, the plate 01/02/03 were judged on
20260821, // the date this gallery was drawn
8675309,
123456789,
271828182,
42424242,
555000111,
999999937, // a large prime, for no reason beyond being an unconsidered draw
};
private const int DefaultGallerySize = 8192;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 4);
string descr = EnvStr("ISLA_BATCH", "seed_gallery");
int gallerySize = EnvInt("ISLA_MAPSIZE", DefaultGallerySize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", GallerySeeds);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool indexOnly = EnvStr("ISLA_INDEX_ONLY", "0") == "1";
string batchRoot = ToolingPaths.BatchRoot(task, descr);
string scratch = ToolingPaths.BatchScratch(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(scratch);
string metricsPath = Path.Combine(scratch, "metrics.tsv");
string notesPath = Path.Combine(scratch, "character_notes.tsv");
var anchors = CurveAnchors.Default;
float sea = 0.15f;
int anchor = seeds[0];
GD.Print("==================================================================");
GD.Print(" SEED GALLERY (chat2/04) — the committed curve, across 8 draws");
GD.Print("==================================================================");
GD.Print($"curve : continuous_restored, PINNED — lift {MountainLift:F2}, " +
$"sharpness {PeakSharpness:F2}, ceiling {LowlandCeilingM:F0} m (no env override)");
GD.Print($"gallery : {gallerySize} calibration pool at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)} (anchor: {anchor})");
GD.Print($"batch : {batchRoot}");
GD.Print($"yardstick : {WorldScale.Describe()}");
GD.Print("==================================================================");
// ═══ INDEX-ONLY: rebuild the contact sheet from the recorded metrics ═══
if (indexOnly)
{
var recorded = ReadMetrics(metricsPath);
if (recorded.Count == 0)
throw new InvalidOperationException(
$"ISLA_INDEX_ONLY=1 but no metrics at {metricsPath}. Run the gallery first — " +
"the index is rebuilt FROM a render, never instead of one.");
GD.Print($"\n--- INDEX ONLY — {recorded.Count} seeds from {metricsPath} ---");
WriteIndex(batchRoot, recorded, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath);
GD.Print($" INDEX.md rebuilt. No pixels were rendered.");
GetTree().Quit(0);
return;
}
// ═══ 1. THE CURVE — re-measured exactly as task 03 did ═══
GD.Print($"\n--- 1. CALIBRATION (task-01 pool at {calibSize}, as task 03) ---");
var rawPool = new LandHistogram(sea);
var poolPass1 = new Dictionary();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
poolPass1[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]));
// Above-ceiling land, gated on RAW height, output measured off the staircase.
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = BaseConfig(calibSize, s, knots, anchors, "staircase");
scfg.CurveMode = CurveModeKind.Staircase;
scfg.ShelfDetail = true;
Pass2Result st = Shaping.Shape(poolPass1[s], scfg);
rawAbove.AccumulateWhere(poolPass1[s].Height, poolPass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, poolPass1[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]);
}
// ⚠ Anchored on the POOL PRIMARY's spikeMax, exactly as task 03 did — reproducing the
// gated curve matters more here than improving it. (Task 03 §7.4 flags the anchor choice
// as worth revisiting; a gallery is not the place to revisit it.)
var calibration = ClimbCalibration.FromPercentiles(
pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(poolPass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, MountainLift, PeakSharpness);
GD.Print($" {knots}");
GD.Print($" {calibration.Describe()}");
// ═══ 2. THE GALLERY ═══
GD.Print($"\n--- 2. GALLERY at {gallerySize} ---");
var metrics = new List();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
var cfg = BaseConfig(gallerySize, seed, knots, anchors, "restored");
cfg.CurveMode = CurveModeKind.Continuous;
cfg.ClimbCalibration = calibration;
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
// ⚠ The two invariants that must hold on EVERY plate, checked per seed rather than
// assumed from task 03's two. A gallery that quietly rendered a broken seed would be
// the worst possible artifact: eight plates, one of them lying.
var classify = ShapingOracle.ClassifyFidelity(p1, p2);
var offCfg = BaseConfig(gallerySize, seed, knots, anchors, "off"); offCfg.Curve = false;
var seaId = ShapingOracle.SeaIdentity(Shaping.Shape(p1, offCfg), p2, sea);
if (!classify.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {classify}");
if (!seaId.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {seaId}");
var m = WriteSeed(batchRoot, p1, p2, sea, anchors, skipRaw);
m.ClassifyOk = classify.Passed;
m.SeaOk = seaId.Passed;
m.ElapsedMs = Time.GetTicksMsec() - t0;
metrics.Add(m);
GD.Print($" {seed,-11} land {m.LandPct,5:F1}% >100m {m.Above100,5:F2}% >220m {m.Above220,5:F2}% " +
$"p90 {WorldScale.MetresFromRaw(m.P90 - sea),6:F1}m " +
$"grad100-220 p50 {m.BandGradP50:F2} p95 {m.BandGradP95:F2} m/px hMax {p1.HMaxSeed:F3} " +
$"{(m.ClassifyOk && m.SeaOk ? "ok" : "⚠ CHECK FAILED")} {m.ElapsedMs / 1000.0:F0}s");
}
WriteMetrics(metricsPath, metrics);
WriteIndex(batchRoot, metrics, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath);
bool allOk = metrics.TrueForAll(m => m.ClassifyOk && m.SeaOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" per-seed invariants: {(allOk ? "ALL PASS" : "*** A SEED FAILED — see above ***")}");
GD.Print($" ⚠ character notes are a HUMAN call — write {notesPath}");
GD.Print($" then re-run with ISLA_INDEX_ONLY=1 to fill them in.");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- per-seed record -------------------------------------------------
private sealed class SeedMetrics
{
public int Seed;
public float HMin, HMax, HMaxSeed;
public double LandPct, Above100, Above220;
public float P50, P75, P90, P99;
public float MaxMidSlope, MinSlope; // curve-space; identical every seed by construction
public float BandGradP50, BandGradP95; // ⭐ metres per pixel through the 100-220 m band
public long BandCells;
public float GrayMin, GrayMax;
public bool ClassifyOk = true, SeaOk = true;
public ulong ElapsedMs;
}
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
=> new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
LowlandCeilingM = LowlandCeilingM,
};
private static SeedMetrics WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2,
float sea, CurveAnchors anchors, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
// ⭐ Plain data beside the pretty render. The grayscale is the honest instrument; the
// relief is for eye-appeal and hillshade.
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 = "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, $"SEED {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
var land = new LandHistogram(sea);
land.Accumulate(p2.Height, p2.MapSize);
// ⚠ Curve-space slope: recorded for completeness, but it is the SAME on every seed by
// construction (see the type header). It is NOT the discriminator.
var (minN, _, maxMid, _, _, _) = p2.Continuous.SampleClimbSlopes();
// ⭐ THE ACTUAL DISCRIMINATOR: how fast the ground climbs through 100-220 m, in metres
// per pixel. This is what "the yellow-to-orange step looks abrupt" is a reaction to.
var (bandP50, bandP95, bandCells) = MidBandGradient(p2.Height, p2.MapSize, sea);
return new SeedMetrics
{
Seed = p2.Seed, HMin = p2.HMin, HMax = p2.HMax, HMaxSeed = p1.HMaxSeed,
LandPct = p2.LandFraction(sea) * 100.0, Above100 = a100, Above220 = a220,
P50 = land.Quantile(50), P75 = land.Quantile(75),
P90 = land.Quantile(90), P99 = land.Quantile(99),
MaxMidSlope = maxMid, MinSlope = minN, GrayMin = gMin, GrayMax = gMax,
BandGradP50 = bandP50, BandGradP95 = bandP95, BandCells = bandCells,
};
}
///
/// Median and p95 of the spatial height gradient, in METRES PER PIXEL, over land cells whose
/// height falls in the 100-220 m band — the stretch the developer flagged as reading abrupt.
///
/// Central differences on the interior; edge cells are skipped rather than one-sided, because
/// the map border is the Trench's synthetic wall and its gradient is not terrain.
///
/// ⚠ Gradients are collected into a coarse histogram rather than a list: at 8192 the band can
/// hold millions of cells and sorting them all to take two quantiles would cost more than the
/// render. 0.01 m/px bins are far finer than any difference worth reading.
///
private static (float p50, float p95, long cells) MidBandGradient(float[,] h, int n, float sea)
{
float lo = sea + WorldScale.RawFromMetres(100f);
float hi = sea + WorldScale.RawFromMetres(220f);
const float binW = 0.01f; // metres per pixel
const int bins = 4000; // up to 40 m/px — far beyond anything real
var hist = new long[bins + 1];
long cells = 0;
for (int x = 1; x < n - 1; x++)
{
for (int y = 1; y < n - 1; y++)
{
float v = h[x, y];
if (v < lo || v > hi) continue;
float gx = (h[x + 1, y] - h[x - 1, y]) * 0.5f;
float gy = (h[x, y + 1] - h[x, y - 1]) * 0.5f;
float g = WorldScale.MetresFromRaw(MathF.Sqrt(gx * gx + gy * gy));
int b = (int)(g / binW);
hist[b >= bins ? bins : b]++;
cells++;
}
}
if (cells == 0) return (0f, 0f, 0);
float Q(double q)
{
long target = (long)(q * cells), cum = 0;
for (int i = 0; i <= bins; i++)
{
cum += hist[i];
if (cum >= target) return (i + 0.5f) * binW;
}
return bins * binW;
}
return (Q(0.50), Q(0.95), cells);
}
// ---- metrics + notes round-trip -------------------------------------
private static void WriteMetrics(string path, List ms)
{
var sb = new StringBuilder();
sb.AppendLine("seed\tland\ta100\ta220\tp50\tp75\tp90\tp99\tmidslope\tminslope\thmaxseed\tgmin\tgmax\tok\tms\tgradp50\tgradp95\tbandcells");
foreach (var m in ms)
sb.AppendLine($"{m.Seed}\t{m.LandPct:F2}\t{m.Above100:F3}\t{m.Above220:F3}\t{m.P50:F4}\t{m.P75:F4}\t" +
$"{m.P90:F4}\t{m.P99:F4}\t{m.MaxMidSlope:F4}\t{m.MinSlope:F4}\t{m.HMaxSeed:F4}\t" +
$"{m.GrayMin:F4}\t{m.GrayMax:F4}\t{(m.ClassifyOk && m.SeaOk ? 1 : 0)}\t{m.ElapsedMs}\t" +
$"{m.BandGradP50:F4}\t{m.BandGradP95:F4}\t{m.BandCells}");
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(sb.ToString());
}
private static List ReadMetrics(string path)
{
var outp = new List();
if (!Godot.FileAccess.FileExists(path)) return outp;
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
if (f == null) return outp;
string all = f.GetAsText();
bool header = true;
foreach (string line in all.Split('\n'))
{
if (header) { header = false; continue; }
if (string.IsNullOrWhiteSpace(line)) continue;
string[] c = line.Split('\t');
if (c.Length < 15) continue; // older rows without the gradient columns still load
outp.Add(new SeedMetrics
{
Seed = int.Parse(c[0]), LandPct = double.Parse(c[1]),
Above100 = double.Parse(c[2]), Above220 = double.Parse(c[3]),
P50 = float.Parse(c[4]), P75 = float.Parse(c[5]),
P90 = float.Parse(c[6]), P99 = float.Parse(c[7]),
MaxMidSlope = float.Parse(c[8]), MinSlope = float.Parse(c[9]),
HMaxSeed = float.Parse(c[10]), GrayMin = float.Parse(c[11]), GrayMax = float.Parse(c[12]),
ClassifyOk = c[13].Trim() == "1", SeaOk = c[13].Trim() == "1",
ElapsedMs = ulong.Parse(c[14].Trim()),
BandGradP50 = c.Length > 15 ? float.Parse(c[15]) : 0f,
BandGradP95 = c.Length > 16 ? float.Parse(c[16]) : 0f,
BandCells = c.Length > 17 ? long.Parse(c[17].Trim()) : 0L,
});
}
return outp;
}
///
/// The human character notes, if they have been written yet. Format: seed\tnote.
/// Absent is a normal state, not an error — the first run cannot have them.
///
private static Dictionary ReadNotes(string path)
{
var notes = new Dictionary();
if (!Godot.FileAccess.FileExists(path)) return notes;
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
if (f == null) return notes;
foreach (string line in f.GetAsText().Split('\n'))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) continue;
string[] c = line.Split('\t');
if (c.Length < 2) continue;
if (int.TryParse(c[0].Trim(), out int s)) notes[s] = c[1].Trim();
}
return notes;
}
// ---- the contact sheet ----------------------------------------------
private static void WriteIndex(string batchRoot, List ms, Dictionary notes,
int anchor, int gallerySize, int calibSize, string notesPath)
{
var sb = new StringBuilder();
sb.AppendLine("# Batch 04 — seed gallery: the committed curve across 8 draws");
sb.AppendLine();
sb.AppendLine("**A contact sheet, not a tuning batch.** Every plate is the SAME curve — the one committed");
sb.AppendLine("at tag `terrain-curve-v1` (chat2/03 `continuous_restored`). The question is whether it holds");
sb.AppendLine("up across draws, or whether the anchor seed was lucky.");
sb.AppendLine();
sb.AppendLine($"- **Curve (pinned, no env override):** `mountainLift {MountainLift:F2}` · " +
$"`peakSharpness {PeakSharpness:F2}` · `lowlandCeiling {LowlandCeilingM:F0} m`");
sb.AppendLine($"- **Rendered at:** {gallerySize} · **calibration pool measured at:** {calibSize} (task 01's six seeds)");
sb.AppendLine($"- **Anchor:** `{anchor}` — the plate tasks 01–03 were judged on. **Compare the others to it.**");
sb.AppendLine($"- **Palette:** ⚠ `ProvisionalEven` — evenly spaced SEA → 420 m, **provisional**. The");
sb.AppendLine(" grayscale is the honest instrument; the relief is for shape and hillshade.");
sb.AppendLine();
sb.AppendLine("## ⭐ The contact sheet");
sb.AppendLine();
bool anyNotes = notes.Count > 0;
sb.AppendLine("| Seed | Character | Relief | Grayscale | land | >100 m | >220 m | p90 | 100–220 m grade (p50 / p95) |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var m in ms)
{
string tag = m.Seed == anchor ? " ⭐" : "";
string note = notes.TryGetValue(m.Seed, out string n) ? n : "*(pending)*";
string warn = m.ClassifyOk && m.SeaOk ? "" : " ⚠⚠ INVARIANT FAILED";
sb.AppendLine($"| `{m.Seed}`{tag} | {note}{warn} | [`{m.Seed}/relief.png`]({m.Seed}/relief.png) | " +
$"[`grayscale.png`]({m.Seed}/grayscale.png) | {m.LandPct:F1}% | {m.Above100:F2}% | " +
$"{m.Above220:F2}% | {WorldScale.MetresFromRaw(m.P90 - 0.15f):F0} m | " +
$"{m.BandGradP50:F2} / {m.BandGradP95:F2} m per px |");
}
sb.AppendLine();
if (!anyNotes)
{
sb.AppendLine("> ⚠ **Character notes are pending.** They are an EYE call and this tool will not invent");
sb.AppendLine($"> them. Write `{Path.GetFileName(notesPath)}` in `scratch/` as `seednote` lines, then");
sb.AppendLine("> re-run with `ISLA_INDEX_ONLY=1` to rebuild this table without re-rendering.");
sb.AppendLine();
}
sb.AppendLine("**100–220 m grade** is the spatial height gradient through the band where the yellow→orange");
sb.AppendLine("transition sits — metres of climb per pixel, median and p95 over the land in that band. It is");
sb.AppendLine("the objective companion to the eye's *\"is that step abrupt?\"*: near-constant across seeds");
sb.AppendLine("means abruptness is a **curve trait** worth a tuning pass; a wide spread means it is a **draw**.");
sb.AppendLine();
sb.AppendLine("> ⚠ The curve's own normalized mid-slope is deliberately NOT tabulated: it is `dv/du` on a");
sb.AppendLine("> fixed control polygon, and each seed's denormalization rescales both axes, so it is the same");
sb.AppendLine("> number on every seed **by construction** and can discriminate nothing.");
sb.AppendLine();
sb.AppendLine("## Spread");
sb.AppendLine();
if (ms.Count > 0)
{
double lo100 = double.MaxValue, hi100 = double.MinValue, sum100 = 0;
float loMid = float.MaxValue, hiMid = float.MinValue;
double sumG = 0;
foreach (var m in ms)
{
lo100 = Math.Min(lo100, m.Above100); hi100 = Math.Max(hi100, m.Above100); sum100 += m.Above100;
loMid = MathF.Min(loMid, m.BandGradP50); hiMid = MathF.Max(hiMid, m.BandGradP50);
sumG += m.BandGradP50;
}
sb.AppendLine($"- **land >100 m:** {lo100:F2} % … {hi100:F2} % (mean {sum100 / ms.Count:F2} %)");
sb.AppendLine($"- **100–220 m grade (p50):** {loMid:F2} … {hiMid:F2} m per px " +
$"(mean {sumG / ms.Count:F2}) — spread {(hiMid - loMid) / (sumG / ms.Count) * 100:F0}% of the mean");
sb.AppendLine();
}
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `relief.png`, `INDEX.md` | **keep** — the gallery |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the tagged curve — **large, clear freely** |");
sb.AppendLine("| `scratch/metrics.tsv`, `scratch/character_notes.tsv` | **keep** — the INDEX is rebuilt from them |");
sb.AppendLine();
sb.AppendLine("Every `.f32` here is reproducible from `git checkout terrain-curve-v1` plus the seed, so the");
sb.AppendLine("bulk of this batch is safe to delete once the gallery has been judged.");
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();
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;
}
}
}