chat2/04: the seed gallery — render-only, plus the terrain-curve-v1 checkpoint
Renders the committed curve (tag terrain-curve-v1 = 639dc5f) across 8 draws to answer
two questions the anchor seed alone could not: does the curve produce good islands
generally, and is the mid-slope abruptness a curve trait or a lucky/unlucky draw.
No curve code is touched — `git diff terrain-curve-v1` over ClimbCalibration,
ContinuousCurve, HeightCurve, CurveAnchors, CurveKnots and Shaping is empty.
SeedGalleryTool pins mountainLift/peakSharpness/lowlandCeiling as CONSTANTS with no
env override, unlike every other tool in this phase. A gallery must not be knob-tunable:
a stray ISLA_* left over from a probe would render eight plates of a curve nobody chose
and they would look exactly like the real thing. It re-measures the calibration on task
01's pool at the iteration size, so the plates are the gated curve rather than a
look-alike.
The eight seeds were fixed before the first render and never screened or replaced. Seven
are fresh; none is in the calibration pool, so none shaped the curve it is testing.
Two things worth recording about the metric:
- The curve's normalized mid-slope CANNOT discriminate between seeds. It is dv/du on a
fixed control polygon and each seed's denormalization is an affine rescale of both
axes, so it is the same number on every draw by construction. The first cut of the
tool measured exactly that and dutifully printed an identical 1.86 eight times.
- What ships instead is the spatial height gradient through the 100-220 m band, in
metres per pixel — the thing the eye is actually reacting to, and genuinely
seed-dependent through both spikeMax and the terrain's own gradient.
The character note per seed is an eye call, so the tool refuses to invent it: it reads
scratch/character_notes.tsv and prints "(pending)" when absent. ISLA_INDEX_ONLY=1
rebuilds the contact sheet from scratch/metrics.tsv without re-rendering a pixel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
This commit is contained in:
parent
639dc5f5a9
commit
5d160511fd
3 changed files with 587 additions and 0 deletions
6
Tools/Scenes/SeedGalleryTool.tscn
Normal file
6
Tools/Scenes/SeedGalleryTool.tscn
Normal file
|
|
@ -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")
|
||||||
580
Tools/Scripts/SeedGalleryTool.cs
Normal file
580
Tools/Scripts/SeedGalleryTool.cs
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE SEED GALLERY (chat2/04) — is <c>continuous_restored</c> good across seeds, or is
|
||||||
|
/// 1063685222 a lucky draw?
|
||||||
|
///
|
||||||
|
/// ═══ ⚠⚠ THIS TOOL RENDERS. IT DOES NOT TUNE. ═══
|
||||||
|
///
|
||||||
|
/// The curve is the one committed at tag <c>terrain-curve-v1</c> and it is PINNED HERE IN CODE —
|
||||||
|
/// <see cref="MountainLift"/>, <see cref="PeakSharpness"/> and <see cref="LowlandCeilingM"/> are
|
||||||
|
/// constants with no environment override, deliberately. Every other tool in this phase exposes
|
||||||
|
/// its knobs to <c>ISLA_*</c> 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 ═══
|
||||||
|
///
|
||||||
|
/// <c>continuous_restored</c> 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. → <see cref="ClimbCalibration"/>.
|
||||||
|
///
|
||||||
|
/// ⚠ 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
|
||||||
|
/// <c>scratch/character_notes.tsv</c> if that file exists and prints "(pending)" if it does not.
|
||||||
|
/// Write the notes after looking at the plates, then re-run with <c>ISLA_INDEX_ONLY=1</c> to
|
||||||
|
/// rebuild the INDEX from <c>scratch/metrics.tsv</c> 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
|
||||||
|
/// <c>dv/du</c> on a fixed set of control points, and each seed's denormalization is an affine
|
||||||
|
/// rescale of both axes — which leaves <c>dv/du</c> 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
|
||||||
|
/// <c>(dOutput/dRaw) × (dRaw/dPixel)</c>: the first factor varies by seed because a taller
|
||||||
|
/// <c>spikeMax</c> 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
|
||||||
|
/// </summary>
|
||||||
|
public partial class SeedGalleryTool : Node
|
||||||
|
{
|
||||||
|
// ═══ THE CURVE, PINNED. No environment override, by design — see the type header. ═══
|
||||||
|
|
||||||
|
/// <summary>task-03 `continuous_restored`: reproduce the staircase's mountain.</summary>
|
||||||
|
private const float MountainLift = 1.0f;
|
||||||
|
|
||||||
|
/// <summary>task-03 `continuous_restored`: a straight run to the cap, no extra summit steepening.</summary>
|
||||||
|
private const float PeakSharpness = 1.0f;
|
||||||
|
|
||||||
|
/// <summary>The flood line — hands over at exactly (K2, RED_CEIL).</summary>
|
||||||
|
private const float LowlandCeilingM = 30f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ 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.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ 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.
|
||||||
|
/// </summary>
|
||||||
|
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<int, Pass1Result>();
|
||||||
|
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<SeedMetrics>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<SeedMetrics> 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<SeedMetrics> ReadMetrics(string path)
|
||||||
|
{
|
||||||
|
var outp = new List<SeedMetrics>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The human character notes, if they have been written yet. Format: <c>seed\tnote</c>.
|
||||||
|
/// Absent is a normal state, not an error — the first run cannot have them.
|
||||||
|
/// </summary>
|
||||||
|
private static Dictionary<int, string> ReadNotes(string path)
|
||||||
|
{
|
||||||
|
var notes = new Dictionary<int, string>();
|
||||||
|
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<SeedMetrics> ms, Dictionary<int, string> 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 `seed<TAB>note` 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<int>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/SeedGalleryTool.cs.uid
Normal file
1
Tools/Scripts/SeedGalleryTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bkh7iu47is60q
|
||||||
Loading…
Reference in a new issue