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
230 lines
11 KiB
C#
230 lines
11 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Text;
|
||
using Godot;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// The taste-gate renderer: re-colours existing height fields into shaded-relief maps across a
|
||
/// small set of named looks, and writes an INDEX.md for browsing.
|
||
///
|
||
/// ⚠ IT RE-COLOURS; IT DOES NOT REGENERATE. Height fields are LOADED from the `.f32` dumps a
|
||
/// generation run left behind, so a look change provably cannot move the terrain. Only when a
|
||
/// dump is missing for the requested size — a showpiece at 4096, say, where no dump exists —
|
||
/// does it generate one, deterministically from the same seed. Same seed, same island.
|
||
///
|
||
/// ═══ RUNNING IT ═══
|
||
///
|
||
/// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
|
||
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ReliefRenderTool.tscn
|
||
///
|
||
/// ISLA_MAPSIZE side in columns (default 2048)
|
||
/// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds)
|
||
/// ISLA_LOOKS comma-separated look names (default: atlas,relief,dusk)
|
||
/// ISLA_TASK authoring task number (default 3)
|
||
/// ISLA_BATCH descriptor, NO prefix (default "relief_taste")
|
||
/// ISLA_SOURCE batch to read .f32 from (default "chat1/02_pass1_port")
|
||
/// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated
|
||
/// </summary>
|
||
public partial class ReliefRenderTool : Node
|
||
{
|
||
private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 };
|
||
|
||
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", 2048);
|
||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||
int task = EnvInt("ISLA_TASK", 3); // the task that authored this batch
|
||
string batch = EnvStr("ISLA_BATCH", "relief_taste");
|
||
// ⚠ A FULL batch folder name, prefix included — it names an EXISTING folder rather than
|
||
// composing a new one, so it is not run through BatchRoot. Tracks TerrainGenTool's
|
||
// default output: BatchRoot(task 2, "pass1_port") = <chat>/02_pass1_port (rivers/01: chat-namespaced).
|
||
string source = EnvStr("ISLA_SOURCE", "chat1/02_pass1_port");
|
||
bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1";
|
||
LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null));
|
||
|
||
// ⚠ Composed by BatchRoot, never free-form: the prefix is the AUTHORING TASK number,
|
||
// not a counter, and a descriptor carrying its own prefix is refused. → Tools/README.md.
|
||
string batchRoot = ToolingPaths.BatchRoot(task, batch);
|
||
string sourceRoot = Path.Combine(ToolingPaths.BatchesRoot, source);
|
||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||
|
||
GD.Print("==================================================================");
|
||
GD.Print(" SHADED RELIEF — hypsometric tint + hillshade (Phase 1, the look)");
|
||
GD.Print("==================================================================");
|
||
GD.Print($"MapSize : {mapSize}");
|
||
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
||
GD.Print($"looks : {string.Join(", ", Array.ConvertAll(looks, l => l.Name))}");
|
||
GD.Print($"source : {sourceRoot} (.f32 dumps; generated only if absent)");
|
||
GD.Print($"out : {batchRoot}");
|
||
GD.Print("------------------------------------------------------------------");
|
||
foreach (var l in looks) GD.Print($" {l}");
|
||
GD.Print("==================================================================");
|
||
|
||
var rows = new List<string>();
|
||
|
||
foreach (int seed in seeds)
|
||
{
|
||
// --- get the height field: LOAD if a dump exists, generate only as a fallback ---
|
||
ulong t0 = Time.GetTicksMsec();
|
||
string dumpPath = Path.Combine(sourceRoot, $"{seed}_full", "height.f32");
|
||
float[,] height = HeightField.Load(dumpPath, mapSize);
|
||
string origin;
|
||
|
||
if (height != null)
|
||
{
|
||
origin = "loaded";
|
||
}
|
||
else
|
||
{
|
||
// ⭐ rivers/01: family-off PINNED — this regenerates a PHASE-1 field to stand in for a
|
||
// missing `02_pass1_port` dump, so it must reproduce that dump, not the new default.
|
||
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" }.WithFamilyOff();
|
||
Pass1Result r = Topography.Generate(cfg);
|
||
height = r.Height;
|
||
origin = "generated";
|
||
if (dumpRaw)
|
||
{
|
||
string dir = Path.Combine(batchRoot, $"{seed}_source");
|
||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||
HeightField.Save(height, mapSize, Path.Combine(dir, "height.f32"));
|
||
}
|
||
}
|
||
ulong tLoad = Time.GetTicksMsec() - t0;
|
||
|
||
float hMin = float.MaxValue, hMax = float.MinValue;
|
||
for (int x = 0; x < mapSize; x++)
|
||
for (int y = 0; y < mapSize; y++)
|
||
{
|
||
float v = height[x, y];
|
||
if (v < hMin) hMin = v;
|
||
if (v > hMax) hMax = v;
|
||
}
|
||
|
||
GD.Print($"\n seed {seed} [{origin} in {tLoad} ms] h[{hMin:F3} .. {hMax:F3}]");
|
||
|
||
foreach (LookConfig look in looks)
|
||
{
|
||
ulong t1 = Time.GetTicksMsec();
|
||
string dir = Path.Combine(batchRoot, $"{seed}_{look.Name}");
|
||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||
ReliefRenderer.SavePng(height, mapSize, look, Path.Combine(dir, "relief.png"));
|
||
ulong ms = Time.GetTicksMsec() - t1;
|
||
|
||
GD.Print($" {look.Name,-8} -> {dir}/relief.png {ms} ms");
|
||
rows.Add($"| `{seed}_{look.Name}` | {seed} | {look.Name} | {look.Palette} | " +
|
||
$"{look.ZExaggeration:F0} | {look.LightAzimuth:F0}°/{look.LightAltitude:F0}° | " +
|
||
$"{look.HillshadeStrength:F2} | {ms} ms |");
|
||
}
|
||
}
|
||
|
||
WriteIndex(batchRoot, mapSize, seeds, looks, source, rows);
|
||
|
||
GD.Print("\n==================================================================");
|
||
GD.Print($" DONE — {rows.Count} renders in {batchRoot}");
|
||
GD.Print("==================================================================");
|
||
GetTree().Quit(0);
|
||
}
|
||
|
||
private static LookConfig[] SelectLooks(string csv)
|
||
{
|
||
LookConfig[] all = LookConfig.Variants();
|
||
if (string.IsNullOrWhiteSpace(csv)) return all;
|
||
|
||
var picked = new List<LookConfig>();
|
||
foreach (string want in csv.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||
foreach (LookConfig l in all)
|
||
if (string.Equals(l.Name, want.Trim(), StringComparison.OrdinalIgnoreCase))
|
||
picked.Add(l);
|
||
return picked.Count > 0 ? picked.ToArray() : all;
|
||
}
|
||
|
||
private static void WriteIndex(string batchRoot, int mapSize, int[] seeds,
|
||
LookConfig[] looks, string source, List<string> rows)
|
||
{
|
||
var (landLo, landHi) = ReliefPalette.LandAnchors;
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("# Batch — shaded relief, the taste gate (Phase 1)");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**Presentation only.** These are the *same* height fields as");
|
||
sb.AppendLine($"`{source}` — loaded from its `.f32` dumps, not regenerated. Nothing here can move the");
|
||
sb.AppendLine("terrain; only its colours and its lighting differ.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"- **MapSize:** {mapSize}");
|
||
sb.AppendLine($"- **Sea colour boundary:** 0.15 — a *colour* boundary, **not a water surface.** No water is modelled (Phase 2).");
|
||
sb.AppendLine($"- **Palette anchors (FIXED across every image):** land {landLo} .. {landHi}; sea compressed by `d/(d+{ReliefPalette.SeaCompression})`");
|
||
sb.AppendLine($"- **Seeds:** {string.Join(", ", seeds)}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐ Pick a look");
|
||
sb.AppendLine();
|
||
sb.AppendLine("Three, deliberately — a subjective gate drowns in a wall of near-duplicates. Each pair");
|
||
sb.AppendLine("isolates one question:");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Look | Palette | Z-exag | Light | Strength | The question it answers |");
|
||
sb.AppendLine("|---|---|---|---|---|---|");
|
||
foreach (LookConfig l in looks)
|
||
{
|
||
string q = l.Name switch
|
||
{
|
||
"atlas" => "the baseline — classic physical-atlas plate, tint-forward",
|
||
"relief" => "**vs `atlas`:** how strong should the relief be? (same palette + light)",
|
||
"dusk" => "**vs `atlas`:** warmer palette and a lower sun — better, or too much?",
|
||
_ => "",
|
||
};
|
||
sb.AppendLine($"| `{l.Name}` | {l.Palette} | {l.ZExaggeration:F0} | {l.LightAzimuth:F0}°/{l.LightAltitude:F0}° | {l.HillshadeStrength:F2} | {q} |");
|
||
}
|
||
sb.AppendLine();
|
||
sb.AppendLine("Compare the **same seed** across the three folders. `1063685222` is the reference's own");
|
||
sb.AppendLine("commented seed and the one used throughout tasks 02–03.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ⚠ **The seabed is raw, and that is expected — not unfinished-because-broken.** The");
|
||
sb.AppendLine("> submarine coast shelf and the offshore islets are DEFERRED Phase-2 items; they act only");
|
||
sb.AppendLine("> below sea level and are judged once water renders. Relief is deliberately dialled back");
|
||
sb.AppendLine("> underwater so the eye is not dragged to the one surface that is knowingly incomplete.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ⚠ **No biome colour here.** This is height + slope + above/below sea, on a continuous");
|
||
sb.AppendLine("> ramp. White at the top is snow-*coloured cartography*, not a snow biome.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Renders");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Folder | Seed | Look | Palette | Z-exag | Light | Strength | Time |");
|
||
sb.AppendLine("|---|---|---|---|---|---|---|---|");
|
||
foreach (string r in rows) sb.AppendLine(r);
|
||
sb.AppendLine();
|
||
sb.AppendLine("`scratch/` is persistent and is never cleaned.");
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|