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()); // ⭐ 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", 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(TerrainGenConfig.CalibrationPool(calibSize, 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; } /// /// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. chat2/04 is the PRE-FAMILY committed-curve /// gallery (`terrain-curve-v1`); the re-baseline flipped the bare defaults family-ON, and this /// tool must keep producing the curve gallery it was judged as. → TerrainGenConfig.WithFamilyOff(). /// 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, }.WithFamilyOff(); 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; } } }