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
224 lines
12 KiB
C#
224 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using Godot;
|
|
using IslaApocalypse.Core;
|
|
|
|
namespace IslaApocalypse.Tools
|
|
{
|
|
/// <summary>
|
|
/// The Phase-1 generation entry point: runs a seed batch plus the ablation ladder, writes
|
|
/// hypsometric PNGs and raw height dumps into the batch layout, and quits.
|
|
///
|
|
/// ═══ RUNNING IT ═══
|
|
///
|
|
/// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
|
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/TerrainGenTool.tscn
|
|
///
|
|
/// ⚠ `--headless` is correct HERE and will stay correct as long as the renderer writes a
|
|
/// Godot.Image directly. It awaits no render frames. The moment anything composites a `_Draw`
|
|
/// overlay through a SubViewport, this becomes an `xvfb-run` job — see Tools/README.md.
|
|
///
|
|
/// ═══ CONFIGURATION (all optional; every path is env-overridable per the tooling rails) ═══
|
|
///
|
|
/// ISLA_MAPSIZE map side in columns (default 2048 — iteration size)
|
|
/// ISLA_SEEDS comma-separated positive ints (default: the pinned batch below)
|
|
/// ISLA_TASK authoring task number (default 2)
|
|
/// ISLA_BATCH descriptor, NO prefix (default "pass1_port")
|
|
/// ISLA_OUTPUT_DIR where batches/ lives (Core/ToolingPaths)
|
|
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
|
|
/// ISLA_LADDER "0" to skip the ablation ladder (for a full-size confirmation pass)
|
|
/// </summary>
|
|
public partial class TerrainGenTool : Node
|
|
{
|
|
/// <summary>
|
|
/// PINNED POSITIVE SEEDS. Pinned, not random, so a batch is reproducible and two batches are
|
|
/// comparable — a standing convention. Positive only.
|
|
/// </summary>
|
|
private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 };
|
|
|
|
private const int DefaultMapSize = 2048;
|
|
|
|
public override void _Ready()
|
|
{
|
|
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 mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
|
int task = EnvInt("ISLA_TASK", 2); // the task that authored this batch
|
|
string batch = EnvStr("ISLA_BATCH", "pass1_port");
|
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
|
bool ladder = EnvStr("ISLA_LADDER", "1") == "1";
|
|
|
|
var scale = new GenerationScale(mapSize);
|
|
// ⚠ Composed by BatchRoot, never free-form. → Tools/README.md, "the prefix is the
|
|
// AUTHORING TASK number, not a counter".
|
|
string batchRoot = ToolingPaths.BatchRoot(task, batch);
|
|
string scratch = ToolingPaths.BatchScratch(batchRoot);
|
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
|
DirAccess.MakeDirRecursiveAbsolute(scratch); // persistent; never cleaned
|
|
|
|
GD.Print("==================================================================");
|
|
GD.Print(" PASS-1 TERRAIN GENERATION — the crown-jewel port (Phase 1)");
|
|
GD.Print("==================================================================");
|
|
GD.Print($"MapSize : {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
|
|
GD.Print($"noise : {TerrainNoise.Describe(0, scale).Replace(" · seed 0", "")}");
|
|
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
|
GD.Print($"batch : {batchRoot}");
|
|
GD.Print("------------------------------------------------------------------");
|
|
GD.Print(ToolingPaths.Describe());
|
|
GD.Print("==================================================================");
|
|
|
|
var rows = new List<string>();
|
|
|
|
// ═══ 1. THE ABLATION LADDER — first seed only, one element added per rung ═══
|
|
//
|
|
// This is the port-fidelity check: six simultaneous changes cannot be judged by their
|
|
// sum, so each element is switched on in the reference's own order and looked at alone.
|
|
if (ladder)
|
|
{
|
|
GD.Print("\n--- ABLATION LADDER (seed " + seeds[0] + ") ---");
|
|
foreach (var (label, mutate) in Ladder())
|
|
{
|
|
// ⭐⭐ rivers/01 — FAMILY-OFF PINNED. This tool AUTHORED `02_pass1_port`, the Phase-1
|
|
// regression anchor that survived the re-baseline. If it picked up the family-on
|
|
// defaults it could no longer regenerate its own dump, and the last link between
|
|
// today's generator and the Phase-1 port would break silently.
|
|
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seeds[0], VariantLabel = label }.WithFamilyOff();
|
|
mutate(cfg);
|
|
rows.Add(RunOne(cfg, batchRoot, skipRaw));
|
|
}
|
|
}
|
|
else GD.Print("\n--- ablation ladder skipped (ISLA_LADDER=0) ---");
|
|
|
|
// ═══ 2. THE SEED BATCH — the full six-element config across pinned seeds ═══
|
|
GD.Print("\n--- SEED BATCH (full pass-1) ---");
|
|
foreach (int seed in seeds)
|
|
{
|
|
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" }.WithFamilyOff(); // ⭐ see the ladder above
|
|
rows.Add(RunOne(cfg, batchRoot, skipRaw));
|
|
}
|
|
|
|
WriteIndex(batchRoot, mapSize, scale, seeds, rows);
|
|
|
|
GD.Print("\n==================================================================");
|
|
GD.Print($" DONE — {rows.Count} maps in {batchRoot}");
|
|
GD.Print("==================================================================");
|
|
GetTree().Quit(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The ablation rungs, in the reference's execution order. Each rung ADDS one element to the
|
|
/// one before it, so a difference between adjacent images is attributable to exactly one
|
|
/// ported element.
|
|
/// </summary>
|
|
private static List<(string, Action<TerrainGenConfig>)> Ladder() => new()
|
|
{
|
|
("ab1_base_only", c => { c.IslandFalloff = false; c.EdgeNoise = false; c.SouthernSinker = false; c.Trench = false; c.MountainSpine = false; }),
|
|
("ab2_falloff", c => { c.EdgeNoise = false; c.SouthernSinker = false; c.Trench = false; c.MountainSpine = false; }),
|
|
("ab3_edge", c => { c.SouthernSinker = false; c.Trench = false; c.MountainSpine = false; }),
|
|
("ab4_sinker", c => { c.Trench = false; c.MountainSpine = false; }),
|
|
("ab5_trench", c => { c.MountainSpine = false; }),
|
|
("ab6_spine_full", c => { /* everything on — identical to the "full" variant */ }),
|
|
};
|
|
|
|
private static string RunOne(TerrainGenConfig cfg, string batchRoot, bool skipRaw)
|
|
{
|
|
Pass1Result r = Topography.Generate(cfg);
|
|
|
|
// batches/NN_<name>/<seed>_<variant>/
|
|
string dir = Path.Combine(batchRoot, $"{cfg.Seed}_{cfg.VariantLabel}");
|
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
|
|
|
// The generator's own quick-look uses the default 'atlas' presentation, so a generation
|
|
// run and a beauty render are coloured identically and can be compared directly.
|
|
var look = new LookConfig { SeaLevel = cfg.SeaLevel };
|
|
ReliefRenderer.SavePng(r.Height, r.MapSize, look, Path.Combine(dir, "height.png"));
|
|
if (!skipRaw) HeightField.Save(r.Height, r.MapSize, Path.Combine(dir, "height.f32"));
|
|
|
|
float land = r.LandFraction(cfg.SeaLevel);
|
|
GD.Print($" {cfg.VariantLabel,-16} seed {cfg.Seed,-11} " +
|
|
$"h[{r.HMinSeed,7:F3} .. {r.HMaxSeed,6:F3}] land {land * 100,5:F1}% {r.ElapsedMs,5} ms");
|
|
|
|
return $"| `{cfg.Seed}_{cfg.VariantLabel}` | {cfg.Seed} | {cfg.VariantLabel} | " +
|
|
$"{r.HMinSeed:F3} | {r.HMaxSeed:F3} | {land * 100:F1}% | {r.ElapsedMs} ms |";
|
|
}
|
|
|
|
private static void WriteIndex(string batchRoot, int mapSize, GenerationScale scale, int[] seeds, List<string> rows)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("# Batch — pass-1 port (Phase 1)");
|
|
sb.AppendLine();
|
|
sb.AppendLine("Raw pass-1 terrain from the ported crown-jewel noise. **No curve, no erosion, no");
|
|
sb.AppendLine("rivers, no water, no crater, no biomes.** Each folder holds `height.png` (hypsometric)");
|
|
sb.AppendLine("and `height.f32` (raw float32, x-major, for byte-level comparison).");
|
|
sb.AppendLine();
|
|
sb.AppendLine($"- **MapSize:** {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
|
|
sb.AppendLine($"- **Noise:** {TerrainNoise.Describe(0, scale).Replace(" · seed 0", "")}");
|
|
sb.AppendLine($"- **Sea threshold (visualization only):** 0.15");
|
|
var (landLo, landHi) = ReliefPalette.LandAnchors;
|
|
sb.AppendLine($"- **Palette anchors (FIXED across the batch):** land {landLo} .. {landHi}, sea compressed by d/(d+{ReliefPalette.SeaCompression})");
|
|
sb.AppendLine($"- **Seeds:** {string.Join(", ", seeds)}");
|
|
sb.AppendLine();
|
|
sb.AppendLine("## What to look at");
|
|
sb.AppendLine();
|
|
sb.AppendLine("**The ablation ladder** (`ab1`..`ab6`, all on seed " + seeds[0] + ") adds one ported element per");
|
|
sb.AppendLine("rung, in the reference's execution order — so any difference between adjacent images is");
|
|
sb.AppendLine("attributable to exactly one element:");
|
|
sb.AppendLine();
|
|
sb.AppendLine("| Rung | Adds | Expect to see |");
|
|
sb.AppendLine("|---|---|---|");
|
|
sb.AppendLine("| `ab1_base_only` | base noise | featureless fractal field, no island, no coast |");
|
|
sb.AppendLine("| `ab2_falloff` | island mask + `Pow(·,2.5)` | an island appears, smooth-edged |");
|
|
sb.AppendLine("| `ab3_edge` | edge roughness | the coastline goes jagged — **and only the coastline** |");
|
|
sb.AppendLine("| `ab4_sinker` | southern sinker | the bottom 25% sinks; southern land bridges break up |");
|
|
sb.AppendLine("| `ab5_trench` | the Trench | a hard ocean border on all four edges |");
|
|
sb.AppendLine("| `ab6_spine_full` | mountain spine | a ridge up the centre, fading out toward the south |");
|
|
sb.AppendLine();
|
|
sb.AppendLine("**The seed batch** (`<seed>_full`) is the complete six-element pass-1 across pinned seeds.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("> ⚠ **The seabed reads raw, and that is expected.** The submarine coast shelf and the");
|
|
sb.AppendLine("> offshore islets are DEFERRED to Phase 2 — they act only below sea level and are judged");
|
|
sb.AppendLine("> once water renders. Steep, plain bathymetry here is not a bug.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("## Results");
|
|
sb.AppendLine();
|
|
sb.AppendLine("| Folder | Seed | Variant | h min | h max | land % | time |");
|
|
sb.AppendLine("|---|---|---|---|---|---|---|");
|
|
foreach (string row in rows) sb.AppendLine(row);
|
|
sb.AppendLine();
|
|
sb.AppendLine("`scratch/` is persistent and is never cleaned.");
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|