islaApocalypse-v2/Tools/Scripts/ReviewBatchTool.cs
beezm fdf52f61ee Phase 1 review: raw grayscale, the wide gradient as hero, fixed batch naming
Presentation only. No generation file is touched — Topography, TerrainNoise,
IslandFalloff, Pass1Result, TerrainGenConfig and GenerationScale are all unchanged.

Batch naming, fixed in code. The prefix is the AUTHORING TASK number, not a running
counter: 04_review means "the batch task 04 authored", not "the fifth batch". It had
already drifted — tasks 02 and 03 produced 00_smoke through 05_trophy_10240 across
two tasks, so no folder name said which task made what. Now the task number is an
explicit argument to ToolingPaths.BatchRoot(taskNumber, descriptor), which composes
the prefix itself and REFUSES a descriptor that carries its own. All three tools
take it as ISLA_TASK. Verified: ISLA_BATCH=05_foo is refused with a stated reason
and exit 2, and creates no folder.

Also fixed: an exception out of _Ready does not stop Godot — it logs and the process
sits there with no main loop, so a misconfigured run HUNG rather than failing. A
hang looks like slow work, which is worse than a crash. The batch tools now catch,
print what was refused, and exit non-zero.

Grayscale mode: normalize a field to its own [min,max]. This is the one place
per-image normalization is correct — everywhere else anchors are fixed so images
compare, but here the point is to see one field at full contrast. The range is
printed and indexed so a shade reads back to a height. You cannot judge noise
through a palette: a ramp bends the distribution, a hillshade adds shape the data
does not have.

The wide Costa-Rica palette, and the reframing the developer asked for: the pretty
map is the WIDE GRADIENT RENDERED FLAT, and relief is no longer the hero. On raw
pass-1 noise a hillshade has nothing coherent to shade, so it renders fine fractal
bumpiness as fuzz that actively hides the elevation the colour is showing. Strong
hillshade is retained as diagnostic_relief and labelled a dev view — its bumpiness
is exaggerated slope, not extra terrain. Subtle relief is kept for comparison, with
the honest note that it still fuzzes until Phase 2 carves coherent landforms.

Legend: a colour bar with ticks drawn from the palette's own ramp, so it cannot
drift from the map beside it. Ticks are RELATIVE height and the image says NOT
METRES — the conversion is Phase 2's, and labelling it "m" would invent a fact in
the artefact a reader most trusts. Text comes from a 5x7 bitmap font written for
this, specifically so a legend does not drag in the SubViewport capture path.
2026-08-19 22:55:53 -04:00

292 lines
14 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 `&lt;task&gt;_&lt;descriptor&gt;` 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 01_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());
int task = EnvInt("ISLA_TASK", 4);
string descr = EnvStr("ISLA_BATCH", "review");
string source = EnvStr("ISLA_SOURCE", "01_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(new TerrainGenConfig { MapSize = mapSize, Seed = 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;
}
}
}