islaApocalypse-v2/Tools/Scripts/ReliefRenderTool.cs
beezm 03fe75b378 rivers/01: re-baseline — bare defaults reproduce terrain-shape-v1 (calibration pinned family-off); retire stale oracles; namespace batch root by chat
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
2026-08-23 04:45:10 -04:00

230 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 0203.");
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;
}
}
}