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
303 lines
15 KiB
C#
303 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using Godot;
|
|
using IslaApocalypse.Core;
|
|
|
|
namespace IslaApocalypse.Tools
|
|
{
|
|
/// <summary>
|
|
/// Builds the Phase-1 REVIEW batch: plain grayscale views so the noise itself can be judged, the
|
|
/// wide gradient with a legend as the pretty map, and two labelled relief comparisons.
|
|
///
|
|
/// ⚠ PRESENTATION ONLY. Reads existing `.f32` dumps; generates nothing unless a dump is missing.
|
|
///
|
|
/// ⚠ THE BATCH FOLDER IS `<task>_<descriptor>` AND THE TASK NUMBER IS EXPLICIT.
|
|
/// It is passed as <c>ISLA_TASK</c> and composed by <see cref="ToolingPaths.BatchRoot"/>, which
|
|
/// refuses a descriptor carrying its own prefix. The prefix names the task that AUTHORED the
|
|
/// batch; it is not a running counter. → Tools/README.md.
|
|
///
|
|
/// ═══ RUNNING IT ═══
|
|
///
|
|
/// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
|
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ReviewBatchTool.tscn
|
|
///
|
|
/// ISLA_TASK authoring task number (default 4)
|
|
/// ISLA_BATCH descriptor, NO prefix (default "review")
|
|
/// ISLA_SOURCE batch holding the .f32 (default "chat1/02_pass1_port")
|
|
/// ISLA_MAPSIZE side in columns (default 2048)
|
|
/// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds)
|
|
/// </summary>
|
|
public partial class ReviewBatchTool : Node
|
|
{
|
|
private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 };
|
|
|
|
/// <summary>Top of the legend's scale, in raw height units. Covers the measured max (1.415).</summary>
|
|
private const float LegendTop = 1.45f;
|
|
|
|
public override void _Ready()
|
|
{
|
|
// ⚠ An exception thrown out of _Ready does NOT stop the engine — Godot logs it and the
|
|
// process sits there with no main loop to end it, so a misconfigured batch run HANGS
|
|
// instead of failing. Measured, not assumed: a deliberately bad ISLA_BATCH hung until
|
|
// killed. A tool that hangs on bad input is worse than one that crashes, because a
|
|
// hang looks like slow work. So: catch, say what went wrong, exit non-zero.
|
|
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, "chat1"));
|
|
|
|
int task = EnvInt("ISLA_TASK", 4);
|
|
string descr = EnvStr("ISLA_BATCH", "review");
|
|
// ⚠ A FULL batch folder name, prefix included — this NAMES AN EXISTING FOLDER rather than
|
|
// composing a new one, so it is not run through BatchRoot. The default tracks where
|
|
// TerrainGenTool writes by default: BatchRoot(task 2, "pass1_port") = chat1/02_pass1_port
|
|
// (rivers/01 namespaced batches by chat; the READ side carries the chatN/ prefix explicitly).
|
|
// It was "01_pass1_port" until chat1/05 renamed the folder to its authoring-task number;
|
|
// a stale default here does not fail loudly, it just silently regenerates instead of
|
|
// loading — which is exactly the kind of quiet cost worth pinning to the real name.
|
|
string source = EnvStr("ISLA_SOURCE", "chat1/02_pass1_port");
|
|
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
|
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
|
|
|
string batchRoot = ToolingPaths.BatchRoot(task, descr); // ⚠ composed, never free-form
|
|
string sourceRoot = Path.Combine(ToolingPaths.BatchesRoot, source);
|
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
|
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
|
|
|
GD.Print("==================================================================");
|
|
GD.Print(" PHASE-1 REVIEW — raw grayscale, the wide gradient, labelled relief");
|
|
GD.Print("==================================================================");
|
|
GD.Print($"batch : {batchRoot}");
|
|
GD.Print($"source : {sourceRoot}");
|
|
GD.Print($"MapSize : {mapSize} seeds: {string.Join(", ", seeds)}");
|
|
GD.Print("==================================================================");
|
|
|
|
var notes = new List<string>();
|
|
int primary = seeds[0];
|
|
|
|
// ---- 1. the raw crown-jewel noise, uncoloured (base-noise-only ablation) ----
|
|
float[,] baseNoise = Load(sourceRoot, $"{primary}_ab1_base_only", mapSize);
|
|
if (baseNoise != null)
|
|
notes.Add(Gray(baseNoise, mapSize, batchRoot, "1_noise_grayscale", primary,
|
|
"the raw crown-jewel noise, before any island shaping"));
|
|
else
|
|
GD.PrintErr($" ⚠ no ab1_base_only dump for {primary} at {mapSize} — 1_noise_grayscale SKIPPED, not faked.");
|
|
|
|
// ---- 2..5, all from the full pass-1 field ----
|
|
foreach (int seed in seeds)
|
|
{
|
|
float[,] h = Load(sourceRoot, $"{seed}_full", mapSize);
|
|
if (h == null)
|
|
{
|
|
GD.PrintErr($" ⚠ no full dump for {seed} at {mapSize} — generating deterministically.");
|
|
h = Topography.Generate(TerrainGenConfig.CalibrationPool(mapSize, seed)).Height;
|
|
}
|
|
|
|
notes.Add(Gray(h, mapSize, batchRoot, "2_height_grayscale", seed,
|
|
"the same noise, shaped into an island"));
|
|
|
|
notes.Add(Gradient(h, mapSize, batchRoot, "3_gradient_flat", seed));
|
|
|
|
// The two relief comparisons are for the primary seed only — they answer a question
|
|
// about the LOOK, and asking it four times is the iteration fatigue the batch
|
|
// discipline warns about.
|
|
if (seed == primary)
|
|
{
|
|
notes.Add(Relief(h, mapSize, batchRoot, "4_subtle_relief", seed, "subtle_relief"));
|
|
notes.Add(Relief(h, mapSize, batchRoot, "5_diagnostic_relief", seed, "diagnostic_relief"));
|
|
}
|
|
}
|
|
|
|
WriteIndex(batchRoot, source, mapSize, seeds, notes);
|
|
|
|
GD.Print("\n==================================================================");
|
|
GD.Print($" DONE — {notes.Count} images in {batchRoot}");
|
|
GD.Print("==================================================================");
|
|
GetTree().Quit(0);
|
|
}
|
|
|
|
private static float[,] Load(string sourceRoot, string folder, int mapSize)
|
|
=> HeightField.Load(Path.Combine(sourceRoot, folder, "height.f32"), mapSize);
|
|
|
|
private static string Gray(float[,] field, int mapSize, string batchRoot, string sub, int seed, string what)
|
|
{
|
|
ulong t0 = Time.GetTicksMsec();
|
|
string dir = Path.Combine(batchRoot, sub);
|
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
|
string path = Path.Combine(dir, $"{seed}.png");
|
|
var (min, max) = GrayscaleRenderer.SavePng(field, mapSize, path);
|
|
ulong ms = Time.GetTicksMsec() - t0;
|
|
|
|
GD.Print($" {sub}/{seed}.png black={min:F3} white={max:F3} {ms} ms ({what})");
|
|
return $"| `{sub}/{seed}.png` | {seed} | grayscale | black `{min:F3}` → white `{max:F3}` | {ms} ms |";
|
|
}
|
|
|
|
private static string Gradient(float[,] h, int mapSize, string batchRoot, string sub, int seed)
|
|
{
|
|
ulong t0 = Time.GetTicksMsec();
|
|
string dir = Path.Combine(batchRoot, sub);
|
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
|
|
|
var look = new LookConfig
|
|
{
|
|
Name = "gradient_flat", Palette = ReliefPalette.Kind.CostaRica, HillshadeStrength = 0f,
|
|
};
|
|
Image map = ReliefRenderer.Render(h, mapSize, look);
|
|
Image withLegend = LegendRenderer.WithLegend(map, look.Palette, look.SeaLevel, LegendTop, $"SEED {seed}");
|
|
|
|
string path = Path.Combine(dir, $"{seed}.png");
|
|
withLegend.SavePng(path);
|
|
ulong ms = Time.GetTicksMsec() - t0;
|
|
|
|
GD.Print($" {sub}/{seed}.png wide gradient + legend, FLAT (no relief) {ms} ms");
|
|
return $"| `{sub}/{seed}.png` | {seed} | ⭐ wide gradient, flat + legend | CostaRica, no hillshade | {ms} ms |";
|
|
}
|
|
|
|
private static string Relief(float[,] h, int mapSize, string batchRoot, string sub, int seed, string lookName)
|
|
{
|
|
ulong t0 = Time.GetTicksMsec();
|
|
string dir = Path.Combine(batchRoot, sub);
|
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
|
|
|
LookConfig look = null;
|
|
foreach (LookConfig l in LookConfig.Variants())
|
|
if (l.Name == lookName) look = l;
|
|
|
|
string path = Path.Combine(dir, $"{seed}.png");
|
|
ReliefRenderer.SavePng(h, mapSize, look, path);
|
|
ulong ms = Time.GetTicksMsec() - t0;
|
|
|
|
GD.Print($" {sub}/{seed}.png {look} {ms} ms");
|
|
return $"| `{sub}/{seed}.png` | {seed} | {lookName} | zex {look.ZExaggeration:F0}, strength {look.HillshadeStrength:F2} | {ms} ms |";
|
|
}
|
|
|
|
private static void WriteIndex(string batchRoot, string source, int mapSize, int[] seeds, List<string> rows)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("# Batch — Phase 1 review: judge the noise, confirm the colour direction");
|
|
sb.AppendLine();
|
|
sb.AppendLine("**Presentation only.** Every image here is the *same terrain* as");
|
|
sb.AppendLine($"`{source}`, loaded from its `.f32` dumps. Nothing here can change the world — only how it");
|
|
sb.AppendLine("is drawn.");
|
|
sb.AppendLine();
|
|
sb.AppendLine($"- **MapSize:** {mapSize} · **Seeds:** {string.Join(", ", seeds)}");
|
|
sb.AppendLine("- **Sea colour boundary:** 0.15 — a *colour* boundary, **not a water surface.** No water is modelled (Phase 2).");
|
|
sb.AppendLine();
|
|
sb.AppendLine("---");
|
|
sb.AppendLine();
|
|
sb.AppendLine("## What each folder is");
|
|
sb.AppendLine();
|
|
sb.AppendLine("### `1_noise_grayscale` — the raw noise, uncoloured");
|
|
sb.AppendLine();
|
|
sb.AppendLine("**This is the crown jewel with nothing done to it.** No island, no falloff, no spine — just");
|
|
sb.AppendLine("the FastNoiseLite field the whole world is built from, normalized black-to-white. If the");
|
|
sb.AppendLine("noise itself is good or bad, this is where you can see it: a palette bends the value");
|
|
sb.AppendLine("distribution and a hillshade adds shape the data doesn't have, so neither can answer that");
|
|
sb.AppendLine("question. This one can.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("### `2_height_grayscale` — the same noise, shaped into an island");
|
|
sb.AppendLine();
|
|
sb.AppendLine("Identical noise, now with the island mask, the edge roughness, the southern sinker, the");
|
|
sb.AppendLine("Trench and the mountain spine applied. Still uncoloured, so you are seeing the SHAPE and");
|
|
sb.AppendLine("nothing else. Compare against `1_` to see exactly what the shaping did.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("### ⭐ `3_gradient_flat` — THE PRETTY MAP. This is the direction.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("The wide Costa-Rica-style gradient, **rendered flat — no hillshade at all**, with an");
|
|
sb.AppendLine("elevation legend down the right side. Colour alone carries the elevation.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("**⚠ The legend is in RELATIVE units, not metres.** Raw height runs sea `0.15` to peak");
|
|
sb.AppendLine("`~1.45`. The metres conversion is Phase 2's elevation profile; labelling this bar \"m\"");
|
|
sb.AppendLine("now would be inventing a fact.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("**⚠ Honest expectation:** this island is mostly lowland — median land height `0.456`");
|
|
sb.AppendLine("against a `1.415` peak — so it reads green-to-yellow with the spine in orange, brown and");
|
|
sb.AppendLine("white. That is *accurate for a lowland island with a central range*, not a palette that");
|
|
sb.AppendLine("failed to use its range. The fuller Costa-Rica spread arrives when **Phase 2's");
|
|
sb.AppendLine("redistribution curve** moves the height distribution upward; the palette is built now so");
|
|
sb.AppendLine("Phase 2 inherits it rather than re-tuning it.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("### `4_subtle_relief` — the same gradient with gentle relief");
|
|
sb.AppendLine();
|
|
sb.AppendLine("What tasteful relief looks like (exaggeration 18, strength 0.30) versus the amped version.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("**⚠ Even this still shows fuzz, and that is the terrain, not the setting.** Raw pass-1");
|
|
sb.AppendLine("noise is fine-grained and incoherent — there is nothing yet for a light to model. Relief");
|
|
sb.AppendLine("comes into its own after **Phase 2's erosion carves coherent landforms**: ridges with");
|
|
sb.AppendLine("valleys between them, drainage that runs somewhere. Then a subtle hillshade has real shape");
|
|
sb.AppendLine("to light, and it will look like the reference maps.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("### ⚠ `5_diagnostic_relief` — A DEV TOOL. NOT A PRETTY MAP.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("Strong exaggeration (100) and strength (0.80). **This is for spotting artifacts** — a");
|
|
sb.AppendLine("centre-line crease, a stair-step, a seam, a discontinuity — by making slope impossible to");
|
|
sb.AppendLine("miss. **Its bumpiness is exaggerated SLOPE, not extra terrain.** The terrain in this image");
|
|
sb.AppendLine("is identical to `3_gradient_flat`; only the lighting lies. Do not judge the world by it,");
|
|
sb.AppendLine("and do not show it as the deliverable.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("---");
|
|
sb.AppendLine();
|
|
sb.AppendLine("## ⭐ What Phase 2 changes");
|
|
sb.AppendLine();
|
|
sb.AppendLine("The fine bumpiness you see everywhere is **raw fractal noise** — statistically correct and");
|
|
sb.AppendLine("geologically meaningless. Phase 2's **redistribution curve** reshapes the height");
|
|
sb.AppendLine("distribution (flats become flat, peaks become peaks) and **hydraulic erosion** carves");
|
|
sb.AppendLine("drainage into it, turning that fuzz into **coherent farmland, ridgelines and river");
|
|
sb.AppendLine("valleys**. The colour direction settled here is what those landforms will be painted with.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("---");
|
|
sb.AppendLine();
|
|
sb.AppendLine("## Images");
|
|
sb.AppendLine();
|
|
sb.AppendLine("| File | Seed | View | Settings / range | Time |");
|
|
sb.AppendLine("|---|---|---|---|---|");
|
|
foreach (string r in rows) sb.AppendLine(r);
|
|
sb.AppendLine();
|
|
sb.AppendLine("`scratch/` is persistent and is never cleaned.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("> **Folder naming:** the `04_` prefix is the number of the TASK that authored this batch —");
|
|
sb.AppendLine("> not a running counter. Everything task 04 produces is `04_*`.");
|
|
|
|
using var f = Godot.FileAccess.Open(Path.Combine(batchRoot, "INDEX.md"), Godot.FileAccess.ModeFlags.Write);
|
|
if (f == null) { GD.PrintErr("could not write INDEX.md"); return; }
|
|
f.StoreString(sb.ToString());
|
|
}
|
|
|
|
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 o = new List<int>();
|
|
foreach (string p in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
|
if (int.TryParse(p.Trim(), out int s) && s > 0) o.Add(s);
|
|
return o.Count > 0 ? o.ToArray() : fallback;
|
|
}
|
|
}
|
|
}
|