The bare TerrainGenConfig defaults did NOT reproduce the terrain the developer
locked, so "run the default generator" was not "the terrain in the gallery" —
the single most expensive fact in the codebase, and the reason a fresh chat
would spend an afternoon chasing differences that were CONFIGURATION, not
regression. This is the deliberate task that ends that, before any river work.
A1 — the defaults ARE the locked shape now. Five fields actually move:
SpeckRevert false->true, MinLandComponentFrac 3e-5->2.5e-7 (120x smaller; the
config default would have eaten real islands, not specks), SouthStretch 0->2,
FragmentAmp 0->0.5, Erosion false->true. Seven more were already correct via
SouthernStretch.Default* / CoastalFragment.Default* and are now pinned as
literals, because TerrainShapeV1 used to do that pinning and this default set
inherits the job. CoastShelf stays OFF — the locked shape has no shelf, and
evaluating it (D-041) is its own later task once water renders. Offshore stays
Off permanently (D-063): islands are organic-only, made by the stretch +
fragmentation and identified by the region layer, never placed.
A2 — the preserve mechanism. The curve knots are percentiles of the FAMILY-OFF
land distribution; flipping the defaults would have moved the pool, the knots,
and with them the render field of every batch including terrain-shape-v1
itself. So the pool is pinned family-off (TerrainGenConfig.WithFamilyOff /
CalibrationPool) rather than the knots being baked: calibration stays live, its
INPUT distribution is held still. The pin was a no-op by construction — it sets
the values the defaults carried the instant before the flip — and re-measuring
after confirms it: pool, all six knots, per-seed spread, shaped max,
monotonicity spikeMax and all seven band shares identical.
Applied wider than "in CalibrateCurve": OffshoreIslandsTool,
RegionLabelingTool and SouthernStretchTool generate their own family-off
field for the Phase-1 anchor, so the pool pin alone would NOT have covered
them and their a1 would have failed for a configuration reason. TerrainGenTool
too — it AUTHORED 02_pass1_port and must stay able to regenerate its own
anchor.
Recorded as a judged-and-parked property: knots measured family-off, applied
family-on. Deliberate, not an oversight. Same disposition as the mid-slope
feather.
A4 — no oracle may pass against a superseded baseline. Six anchors retired
(01/03/04/06/08/09) with their checks and ISLA_T0x_SOURCE defaults; three kept
(chat1/02_pass1_port as the family-off pass-1 guard, chat2/10 and chat2/11 as
the shape and erosion acceptance anchors). Two invariants were RE-POINTED
rather than lost — the southern stretch's north-lock and the coastal-fragment
interior-lock now compare against SAME-RUN fields, which is scale-free and
cannot be invalidated by a moved dump. The retired dumps are kept, not deleted,
and marked superseded in their INDEX.md.
A missing anchor is now LOUD. The old pattern skipped silently, so a moved
anchor did not make its oracle fail — it made it not RUN, and a batch with a
skipped check prints an all-PASS table that reads like a clean one. That is
the INVERSE of the hazard the re-baseline guards against, and the migration
below is exactly the event that would have triggered it, on nine anchors at
once. ShapingOracle.LoadAnchor now separates the two cases: absent -> throw;
present at another size -> loud INCONCLUSIVE, which is a fail, never a pass.
TerrainShapeV1 inverted from PRESET to GUARD and moved to its own file.
Apply() is gone — stamping the values on top of the defaults would MASK a
drift instead of catching it. Its constants are now the assertion target, and
Assert() refuses a run whose defaults have drifted off the locked shape.
B/C — batches are namespaced by chat: batches/<chat>/NN_slug/. Task numbers
restart at 00 per chat, so a flat root collided the moment a second chat
existed — four colliding prefixes across 25 batches, separable only by slug.
ToolingPaths.ChatSlug is REQUIRED (throws if unset) and defaults per tool to
its authoring chat, so re-running reproduces a batch in place while ISLA_CHAT
redirects — which is also what stops an acceptance run from overwriting the
very anchor it checks against. Writes go through BatchRoot; historical READS
compose against BatchesRoot and so carry the prefix in their own source string
("chat1/02_pass1_port"). The 25 existing batches were migrated moves-only.
ACCEPTANCE — 16 of 16 byte-identical, 0 failed. All 8 gallery seeds at 8192
from the bare defaults are byte-identical to chat2/10_frag4_seed_gallery
(= terrain-shape-v1, a59e52f); all 8 erosion fields byte-identical to
chat2/11_erosion (= ea291ea). Every gallery table row and every erosion
statistic reproduces its recorded value exactly. DrainageTool's a11 passes
bit-identical over 67,108,864 cells, and its analysis reproduces batch 12
exactly — so the whole chain rivers depends on (shape -> erosion -> drainage)
is unchanged. All 12 edited tools re-run clean; both new guards negative-tested.
The baseline moved in DEFAULTS, not in TERRAIN.
-> XX_Human/output/rivers/01_rebaseline_and_batch_namespace.report.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhkXBQh2tDmcWKpXYcj8vj
589 lines
27 KiB
C#
589 lines
27 KiB
C#
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());
|
||
// ⭐ 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<int, Pass1Result>();
|
||
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<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;
|
||
}
|
||
|
||
/// <remarks>
|
||
/// ⭐ 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().
|
||
/// </remarks>
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|
||
}
|