diff --git a/Tools/Scenes/SeedGalleryTool.tscn b/Tools/Scenes/SeedGalleryTool.tscn
new file mode 100644
index 0000000..01b1475
--- /dev/null
+++ b/Tools/Scenes/SeedGalleryTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://cseedgallery04isla"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/SeedGalleryTool.cs" id="1_sgt"]
+
+[node name="SeedGalleryTool" type="Node"]
+script = ExtResource("1_sgt")
diff --git a/Tools/Scripts/SeedGalleryTool.cs b/Tools/Scripts/SeedGalleryTool.cs
new file mode 100644
index 0000000..30dddb8
--- /dev/null
+++ b/Tools/Scripts/SeedGalleryTool.cs
@@ -0,0 +1,580 @@
+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;
+ }
+ }
+}
diff --git a/Tools/Scripts/SeedGalleryTool.cs.uid b/Tools/Scripts/SeedGalleryTool.cs.uid
new file mode 100644
index 0000000..136a65d
--- /dev/null
+++ b/Tools/Scripts/SeedGalleryTool.cs.uid
@@ -0,0 +1 @@
+uid://bkh7iu47is60q