diff --git a/Core/Scripts/ToolingPaths.cs b/Core/Scripts/ToolingPaths.cs
index 1247543..63ccb63 100644
--- a/Core/Scripts/ToolingPaths.cs
+++ b/Core/Scripts/ToolingPaths.cs
@@ -90,10 +90,52 @@ namespace IslaApocalypse.Core
public static string BatchScratch(string batchDir) => Path.Combine(batchDir, "scratch");
///
- /// A batch directory: batches/NN_<name>/<seed>_<variant>/.
+ /// ⭐ A BATCH ROOT: batches/<task>_<descriptor>/.
+ ///
+ /// ═══ ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER. ═══
+ ///
+ /// 04_review means "the batch task 04 authored". It does NOT mean "the fifth batch".
+ /// A task that produces six batches produces six 04_* folders, not 04_ through
+ /// 09_.
+ ///
+ /// This is enforced here, in code, because it already drifted once: tasks 02 and 03 used the
+ /// prefix as a global running counter and produced 00_smoke … 05_trophy_10240
+ /// across two tasks, so nothing in the folder name said which task made what. Passing the
+ /// task number as an explicit argument — rather than letting a caller compose a free-form
+ /// string — is what makes the convention unbreakable rather than remembered.
+ /// → `Design - Tooling - Iteration and Batching.md`.
+ ///
+ /// The descriptor must NOT carry its own numeric prefix; that is the mistake this method
+ /// exists to prevent, so it is refused rather than silently accepted.
///
- public static string BatchDir(int batchNumber, string batchName, long seed, string variant)
- => Path.Combine(BatchesRoot, $"{batchNumber:D2}_{batchName}", $"{seed}_{variant}");
+ public static string BatchRoot(int taskNumber, string descriptor)
+ {
+ if (taskNumber < 0)
+ throw new ArgumentOutOfRangeException(nameof(taskNumber), taskNumber,
+ "A batch is named for the task that authored it; there is no negative task.");
+ if (string.IsNullOrWhiteSpace(descriptor))
+ throw new ArgumentException("A batch needs a descriptor — '04_' alone is not browsable.", nameof(descriptor));
+
+ string d = descriptor.Trim();
+
+ // Refuse "04_review", "4_review", "05_foo" — the caller is re-adding a prefix, which is
+ // exactly how the counter drifted. The task number is this method's job, not theirs.
+ int us = d.IndexOf('_');
+ if (us > 0 && int.TryParse(d.Substring(0, us), out _))
+ throw new ArgumentException(
+ $"Descriptor '{d}' starts with its own numeric prefix. Pass the task number as " +
+ $"taskNumber and the descriptor WITHOUT one (e.g. \"review\", not \"04_review\") — " +
+ "the prefix is composed here so it cannot drift.", nameof(descriptor));
+
+ return Path.Combine(BatchesRoot, $"{taskNumber:D2}_{d}");
+ }
+
+ ///
+ /// A variant directory inside a batch:
+ /// batches/<task>_<descriptor>/<seed>_<variant>/.
+ ///
+ public static string BatchDir(int taskNumber, string descriptor, long seed, string variant)
+ => Path.Combine(BatchRoot(taskNumber, descriptor), $"{seed}_{variant}");
private static string Override(string variable)
{
diff --git a/Tools/README.md b/Tools/README.md
index 74b28a5..f6e628e 100644
--- a/Tools/README.md
+++ b/Tools/README.md
@@ -37,7 +37,7 @@ constants, carried over verbatim — not re-derived from a design summary** (→
| `Scripts/TerrainGenTool.cs` | Batch entry point (ladder + seed batch + `INDEX.md`) |
| `Scenes/TerrainGenTool.tscn` | Run this |
-### The relief render — presentation (Phase 1, the look)
+### The map renders — presentation (Phase 1, the look)
**Presentation only** (→ `Design - Rendering - Roughness Is Presentation.md`): it changes how height
is *shown*, never how it is *made*. It **cannot** change the terrain — it is handed a height field
@@ -45,12 +45,44 @@ loaded from a `.f32` dump and has no way to produce one.
| File | What it is |
|---|---|
-| `Scripts/Hillshade.cs` | Horn 3×3 shaded relief; the `ZExaggeration` slope table |
+| `Scripts/GrayscaleRenderer.cs` | ⭐ Plain grayscale — **for judging the noise itself** |
| `Scripts/ReliefPalette.cs` | The hypsometric palettes, stops placed on measured percentiles |
-| `Scripts/ReliefRenderer.cs` | ⭐ The tint + hillshade blend |
-| `Scripts/LookConfig.cs` | The look dials and the three named variants |
+| `Scripts/ReliefRenderer.cs` | The tint (+ optional hillshade) blend |
+| `Scripts/Hillshade.cs` | Horn 3×3 shaded relief; the `ZExaggeration` slope table |
+| `Scripts/LegendRenderer.cs` | ⭐ The elevation legend strip |
+| `Scripts/TinyFont.cs` | A 5×7 bitmap font, so a legend needs no viewport |
+| `Scripts/LookConfig.cs` | The look dials and the named variants |
+| `Scripts/ReviewBatchTool.cs` | ⭐ The review batch — grayscale + hero gradient + labelled relief |
| `Scripts/ReliefRenderTool.cs` | Taste-gate batch runner |
-| `Scenes/ReliefRenderTool.tscn` | Run this |
+| `Scenes/ReviewBatchTool.tscn`, `Scenes/ReliefRenderTool.tscn` | Run these |
+
+> ### ⭐ The pretty map is the WIDE GRADIENT, rendered FLAT. Relief is not the hero.
+>
+> A reference relief map gets its look from **wide colour + subtle relief + COHERENT TERRAIN.** We
+> have the first. The third is Phase 2's curve and erosion. On raw pass-1 noise a hillshade has
+> nothing coherent to shade, so it renders fine fractal bumpiness as **visual fuzz that actively
+> hides the elevation the colour is showing.** So:
+>
+> | Look | Role |
+> |---|---|
+> | **`gradient_flat`** | ⭐ **the hero.** Wide Costa-Rica gradient, `HillshadeStrength = 0`, no relief at all |
+> | `subtle_relief` | gentle relief (zex 18, strength 0.30) for comparison — **still fuzzes on raw noise** |
+> | ⚠ `diagnostic_relief` | **A DEV VIEW, NEVER THE PRETTY MAP.** Strong exaggeration to make slope artifacts jump out. Its bumpiness is exaggerated *slope*, not extra terrain |
+> | `atlas`, `dusk` | the task-03 plates, kept reachable |
+>
+> Relief comes into its own once erosion carves ridges and valleys worth lighting.
+
+**Plain grayscale** (`GrayscaleRenderer`) normalizes 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.
+Here the point is to see one field at full contrast, so the range used is printed and written into the
+`INDEX.md`, and a shade reads back to a height. **You cannot judge noise through a palette:** a ramp
+bends the value distribution and a hillshade adds shape the data does not have.
+
+**The legend** (`LegendRenderer`) draws a colour bar with ticks from the palette's own ramp, so it
+cannot drift from the map beside it. ⚠ **Ticks are RELATIVE height, not metres** — the metres
+conversion is Phase 2's elevation profile, and labelling the bar "m" now would invent a fact. It says
+so on the image. Text comes from `TinyFont`, a 5×7 bitmap font, specifically so a legend does not drag
+in the SubViewport capture path (which awaits render frames and is why `--headless` hangs).
```bash
Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
@@ -197,11 +229,25 @@ Enforced by `Core/Scripts/FileSafety.cs`, which throws rather than advises.
### 4. Batch layout
```
-batches/NN_/_/
-batches/NN_/INDEX.md
-batches/NN_/scratch/ ← persistent; never cleaned
+batches/_/_/
+batches/_/INDEX.md
+batches/_/scratch/ ← persistent; never cleaned
```
+> ### ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER.
+>
+> `04_review` means *"the batch task 04 authored"*. It does **not** mean "the fifth batch". A task
+> that produces six batches produces six `04_*` folders — not `04_` through `09_`.
+>
+> **This already drifted once.** Tasks 02 and 03 used the prefix as a global running counter and
+> produced `00_smoke` … `05_trophy_10240` across two tasks, so nothing in a folder name said which
+> task made what.
+>
+> **Enforced in code, not remembered:** 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** (`ISLA_BATCH=05_foo` → `REFUSED`, exit 2). Tools take it as
+> `ISLA_TASK`.
+
**A/B comparisons are browsed by a human, and a flat directory of same-named PNGs is not
browsable.** The `INDEX.md` is what makes a batch readable a week later.
diff --git a/Tools/Scenes/ReviewBatchTool.tscn b/Tools/Scenes/ReviewBatchTool.tscn
new file mode 100644
index 0000000..c65621b
--- /dev/null
+++ b/Tools/Scenes/ReviewBatchTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://bqp1x2v3isla4"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/ReviewBatchTool.cs" id="1_rbt"]
+
+[node name="ReviewBatchTool" type="Node"]
+script = ExtResource("1_rbt")
diff --git a/Tools/Scripts/GrayscaleRenderer.cs b/Tools/Scripts/GrayscaleRenderer.cs
new file mode 100644
index 0000000..130d1dd
--- /dev/null
+++ b/Tools/Scripts/GrayscaleRenderer.cs
@@ -0,0 +1,51 @@
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// A plain grayscale render: normalize a float field to its own [min, max] and map to
+ /// [black, white]. No colour, no hillshade, no interpretation.
+ ///
+ /// ═══ WHY THIS EXISTS ═══
+ ///
+ /// Because you cannot judge noise through a palette. A hypsometric ramp and a hillshade are both
+ /// opinionated transforms — one bends the value distribution onto chosen stops, the other adds
+ /// shape the data does not contain. Both are the right thing for a map and the wrong thing for
+ /// asking "is the noise itself good?". This mode answers that question and no other.
+ ///
+ /// ⚠ THIS IS THE ONE PLACE PER-IMAGE NORMALIZATION IS CORRECT. Everywhere else the palette
+ /// anchors are fixed so images can be compared. Here the point is to see the full structure of
+ /// ONE field at full contrast, so it is normalized to its own range — and the range used is
+ /// printed and written into the INDEX, so a shade reads back to a height.
+ ///
+ public static class GrayscaleRenderer
+ {
+ /// Render to grayscale. Returns the (min, max) used for the normalization.
+ public static (float min, float max) SavePng(float[,] field, int mapSize, string absolutePath)
+ {
+ float min = float.MaxValue, max = float.MinValue;
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ {
+ float v = field[x, y];
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+
+ float span = max - min;
+ float inv = span > 1e-9f ? 1f / span : 0f;
+
+ var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ {
+ float g = (field[x, y] - min) * inv;
+ img.SetPixel(x, y, new Color(g, g, g));
+ }
+
+ Error err = img.SavePng(absolutePath);
+ if (err != Error.Ok) GD.PrintErr($"[GrayscaleRenderer] SavePng failed ({err}) for {absolutePath}");
+ return (min, max);
+ }
+ }
+}
diff --git a/Tools/Scripts/GrayscaleRenderer.cs.uid b/Tools/Scripts/GrayscaleRenderer.cs.uid
new file mode 100644
index 0000000..5ab7684
--- /dev/null
+++ b/Tools/Scripts/GrayscaleRenderer.cs.uid
@@ -0,0 +1 @@
+uid://bfqaec3of3i7e
diff --git a/Tools/Scripts/LegendRenderer.cs b/Tools/Scripts/LegendRenderer.cs
new file mode 100644
index 0000000..11e9f71
--- /dev/null
+++ b/Tools/Scripts/LegendRenderer.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// Composites an elevation LEGEND down the right-hand side of a rendered map — a vertical colour
+ /// bar with height ticks, in the manner of the scale on a physical relief plate.
+ ///
+ /// ═══ ⚠⚠ THE TICKS ARE IN RAW / RELATIVE UNITS, NOT METRES ═══
+ ///
+ /// The height field is raw noise units (sea 0.15, peaks ~1.45). It is NOT metres, and there is
+ /// no conversion available yet — the metres-per-unit mapping belongs to Phase 2's elevation
+ /// profile. Labelling this bar "m" would be inventing a fact about the world in the one artefact
+ /// a reader is most likely to trust. So it says RELATIVE HEIGHT, NOT METRES, and it says it on
+ /// the image where it cannot be separated from the picture.
+ ///
+ /// The bar is drawn from the palette's own ramp, so it cannot drift from the map beside it: if a
+ /// stop moves, the legend moves with it.
+ ///
+ /// ⚠ THE LAYOUT IS DERIVED FROM TEXT METRICS, NOT FROM GUESSED FRACTIONS. The first cut sized
+ /// the font off the map height and the strip off the map width independently, so at 2048 the
+ /// title was 450 px of text in a 235 px strip — clipped, and overlapping the bar. Everything
+ /// below is now computed from the longest string that must fit, and the bar is placed between
+ /// the measured header and footer blocks rather than at a fixed offset.
+ ///
+ public static class LegendRenderer
+ {
+ public static Image WithLegend(Image map, ReliefPalette.Kind palette, float seaLevel,
+ float landTop, string title)
+ {
+ int mapW = map.GetWidth(), mapH = map.GetHeight();
+
+ // ---- content first, then a layout that fits it ----
+ var header = new List { title, "RELATIVE HEIGHT", "NOT METRES" };
+ var footer = new List { "BELOW SEA IS", "A DEPTH TINT" };
+
+ var ticks = new List { landTop, 1.20f, 1.00f, 0.80f, 0.60f, 0.46f, 0.31f, seaLevel };
+ ticks.RemoveAll(h => h > landTop || h < seaLevel);
+
+ int stripW = Mathf.Max(220, Mathf.RoundToInt(mapW * 0.16f));
+ int pad = Mathf.Max(10, Mathf.RoundToInt(stripW * 0.075f));
+
+ // Longest header line decides the font scale — everything else is narrower by design.
+ int longest = 0;
+ foreach (string h in header) longest = Math.Max(longest, h.Length);
+ int usable = stripW - pad * 2;
+ int scale = Mathf.Clamp(usable / (longest * (TinyFont.GlyphW + 1)), 1, 6);
+
+ int textH = TinyFont.Height(scale);
+ int lineGap = Mathf.Max(2, scale);
+ int lineH = textH + lineGap;
+
+ int barX = pad;
+ int barW = Mathf.Max(12, Mathf.RoundToInt(stripW * 0.18f));
+ int tickLen = Mathf.Max(4, barW / 3);
+ int labelX = barX + barW + tickLen + Mathf.Max(4, scale * 2);
+
+ int barTop = pad + header.Count * lineH + pad;
+ int barBottom = mapH - pad - footer.Count * lineH - pad;
+ int barH = barBottom - barTop;
+ if (barH < 32) return map; // absurdly small map — a broken legend is worse than none
+
+ var outImg = Image.CreateEmpty(mapW + stripW, mapH, false, Image.Format.Rgb8);
+ var paper = new Color(0.098f, 0.106f, 0.125f);
+ var ink = new Color(0.941f, 0.949f, 0.961f);
+ var faint = new Color(0.565f, 0.596f, 0.643f);
+
+ outImg.Fill(paper);
+ outImg.BlitRect(map, new Rect2I(0, 0, mapW, mapH), new Vector2I(0, 0));
+
+ int sx = mapW; // strip origin
+
+ // ---- header ----
+ for (int i = 0; i < header.Count; i++)
+ TinyFont.Draw(outImg, header[i], sx + pad, pad + i * lineH, scale,
+ i == 0 ? ink : faint);
+
+ // ---- the colour bar: TOP = highest, so it reads the way the terrain does ----
+ for (int row = 0; row < barH; row++)
+ {
+ float t = 1f - row / (float)(barH - 1);
+ float h = Mathf.Lerp(seaLevel, landTop, t);
+ Color c = ReliefPalette.Tint(palette, h, seaLevel);
+ for (int px = 0; px < barW; px++)
+ outImg.SetPixel(sx + barX + px, barTop + row, c);
+ }
+
+ // A thin frame, so the bar reads as an object rather than a smear.
+ for (int row = -1; row <= barH; row++)
+ {
+ SetSafe(outImg, sx + barX - 1, barTop + row, faint);
+ SetSafe(outImg, sx + barX + barW, barTop + row, faint);
+ }
+ for (int px = -1; px <= barW; px++)
+ {
+ SetSafe(outImg, sx + barX + px, barTop - 1, faint);
+ SetSafe(outImg, sx + barX + px, barTop + barH, faint);
+ }
+
+ // ---- ticks. The waterline gets the one word that means something. ----
+ foreach (float h in ticks)
+ {
+ float t = (h - seaLevel) / (landTop - seaLevel);
+ int row = barTop + Mathf.RoundToInt((1f - t) * (barH - 1));
+
+ for (int px = 0; px < tickLen; px++)
+ SetSafe(outImg, sx + barX + barW + px, row, ink);
+
+ string label = h.ToString("0.00") + (Mathf.Abs(h - seaLevel) < 0.0001f ? " SEA" : "");
+ int ty = Mathf.Clamp(row - textH / 2, barTop - textH, mapH - textH - 1);
+ TinyFont.Draw(outImg, label, sx + labelX, ty, scale, ink);
+ }
+
+ // ---- footer ----
+ int fy = barBottom + pad;
+ for (int i = 0; i < footer.Count; i++)
+ TinyFont.Draw(outImg, footer[i], sx + pad, fy + i * lineH, scale, faint);
+
+ return outImg;
+ }
+
+ private static void SetSafe(Image img, int x, int y, Color c)
+ {
+ if (x >= 0 && y >= 0 && x < img.GetWidth() && y < img.GetHeight()) img.SetPixel(x, y, c);
+ }
+ }
+}
diff --git a/Tools/Scripts/LegendRenderer.cs.uid b/Tools/Scripts/LegendRenderer.cs.uid
new file mode 100644
index 0000000..eea10f4
--- /dev/null
+++ b/Tools/Scripts/LegendRenderer.cs.uid
@@ -0,0 +1 @@
+uid://bamko0n3adsbv
diff --git a/Tools/Scripts/LookConfig.cs b/Tools/Scripts/LookConfig.cs
index 4da1d91..9369e37 100644
--- a/Tools/Scripts/LookConfig.cs
+++ b/Tools/Scripts/LookConfig.cs
@@ -62,28 +62,53 @@ namespace IslaApocalypse.Tools
///
/// The three looks offered to the taste gate. Each pair isolates one question:
- /// atlas vs relief → how strong should the relief be? (same palette and light)
- /// atlas vs dusk → which palette, and how low a sun? (comparable relief)
+ /// gradient_flat ⭐ the hero — wide colour, NO relief
+ /// subtle_relief the same gradient with gentle relief, for comparison
+ /// diagnostic_relief ⚠ a DEV VIEW for spotting artifacts. Never the pretty map.
+ /// atlas / dusk the task-03 plates, kept reachable
///
public static LookConfig[] Variants() => new[]
{
- // Classic physical-atlas plate: tint-forward, relief present but polite.
+ // ⭐ THE HERO. A wide, vivid gradient rendered FLAT — no relief at all.
+ //
+ // The developer's reference maps get their look from WIDE COLOUR + subtle relief +
+ // COHERENT TERRAIN. We have the first; the third is Phase 2's curve and erosion. On raw
+ // pre-Phase-2 noise a hillshade has nothing coherent to shade, so it renders the fine
+ // fractal bumpiness as visual fuzz and actively HIDES the elevation the colour is
+ // showing. So the pretty map leads with colour and no relief, and gets its shape from
+ // relief later, once there is shape worth lighting.
+ new LookConfig
+ {
+ Name = "gradient_flat", Palette = ReliefPalette.Kind.CostaRica,
+ HillshadeStrength = 0f,
+ },
+
+ // Tasteful relief, for comparison. ⚠ Still fuzzes on raw noise — that is the terrain,
+ // not the setting. Comes into its own after Phase 2 carves coherent landforms.
+ new LookConfig
+ {
+ Name = "subtle_relief", Palette = ReliefPalette.Kind.CostaRica,
+ ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
+ HillshadeStrength = 0.30f,
+ },
+
+ // ⚠⚠ A DIAGNOSTIC, NOT A PRETTY MAP. Strong exaggeration makes slope artifacts jump
+ // out — a centre-line crease, a stair-step, a seam. Its bumpiness is EXAGGERATED SLOPE,
+ // not extra terrain. Never present this as the deliverable.
+ new LookConfig
+ {
+ Name = "diagnostic_relief", Palette = ReliefPalette.Kind.CostaRica,
+ ZExaggeration = 100f, LightAzimuth = 315f, LightAltitude = 45f,
+ HillshadeStrength = 0.80f,
+ },
+
+ // The task-03 atlas plates, kept so that look is still reachable for comparison.
new LookConfig
{
Name = "atlas", Palette = ReliefPalette.Kind.Atlas,
ZExaggeration = 75f, LightAzimuth = 315f, LightAltitude = 45f,
HillshadeStrength = 0.55f,
},
-
- // Shape-forward: the same palette and light, harder relief. Isolates the relief dial.
- new LookConfig
- {
- Name = "relief", Palette = ReliefPalette.Kind.Atlas,
- ZExaggeration = 120f, LightAzimuth = 315f, LightAltitude = 45f,
- HillshadeStrength = 0.80f,
- },
-
- // Warmer palette, lower sun, longer shadows. Isolates palette + light.
new LookConfig
{
Name = "dusk", Palette = ReliefPalette.Kind.Dusk,
diff --git a/Tools/Scripts/ReliefPalette.cs b/Tools/Scripts/ReliefPalette.cs
index 34bc836..e669106 100644
--- a/Tools/Scripts/ReliefPalette.cs
+++ b/Tools/Scripts/ReliefPalette.cs
@@ -47,7 +47,7 @@ namespace IslaApocalypse.Tools
public static class ReliefPalette
{
/// Named palettes. Gated so the look can be A/B'd like any other change.
- public enum Kind { Atlas, Dusk }
+ public enum Kind { Atlas, Dusk, CostaRica }
///
/// Bathymetric compression constant, in raw height units. The sea ramp is indexed by
@@ -108,20 +108,75 @@ namespace IslaApocalypse.Tools
(1.000f, new Color(0.012f, 0.047f, 0.125f)),
};
+ // ---- COSTA RICA: the WIDE gradient. The hero. -------------------------
+ //
+ // ⭐ The point of this ramp is RANGE. The atlas palettes are muted by design — they are
+ // imitating a printed plate, where ink is expensive and subtlety is the aesthetic. This one
+ // imitates the modern relief maps the developer pointed at (Costa Rica, Hispaniola), which
+ // use a wide, vivid gradient so that ELEVATION IS LEGIBLE AT A GLANCE without a hillshade
+ // doing the work.
+ //
+ // Same percentile placement as the others — the stops sit on the measured land distribution,
+ // not spread evenly — but the colours travel much further: deep green all the way to white,
+ // through a full yellow and orange stage the atlas ramps skip.
+ //
+ // ⚠ HONEST EXPECTATION: this terrain is mostly lowland (median 0.456 of a 1.415 peak), so it
+ // reads green→yellow with the spine in orange/brown/white. That is ACCURATE for a lowland
+ // island with a central range, not a palette failure. The fullest spread arrives when Phase
+ // 2's redistribution curve moves the height distribution; the palette is got right now so
+ // that Phase 2 inherits it rather than re-tuning it.
+ private static readonly (float h, Color c)[] CostaRicaLand =
+ {
+ (0.150f, new Color(0.078f, 0.361f, 0.216f)), // shoreline — deep green
+ (0.220f, new Color(0.153f, 0.475f, 0.243f)), // p10 green
+ (0.310f, new Color(0.322f, 0.596f, 0.259f)), // p25 mid green
+ (0.460f, new Color(0.569f, 0.706f, 0.278f)), // p50 yellow-green
+ (0.560f, new Color(0.784f, 0.796f, 0.298f)), // yellow
+ (0.650f, new Color(0.914f, 0.792f, 0.310f)), // p75 golden
+ (0.750f, new Color(0.933f, 0.667f, 0.271f)), // yellow-orange
+ (0.840f, new Color(0.882f, 0.541f, 0.239f)), // p90 orange
+ (0.980f, new Color(0.749f, 0.412f, 0.220f)), // p95 tan-brown
+ (1.120f, new Color(0.573f, 0.318f, 0.208f)), // p99 brown
+ (1.220f, new Color(0.427f, 0.271f, 0.216f)), // dark brown
+ (1.320f, new Color(0.667f, 0.635f, 0.620f)), // p99.9 grey
+ (1.450f, new Color(1.000f, 1.000f, 1.000f)), // peak white
+ };
+
+ private static readonly (float h, Color c)[] CostaRicaSea =
+ {
+ (0.000f, new Color(0.616f, 0.851f, 0.859f)), // waterline — bright shallow teal
+ (0.125f, new Color(0.376f, 0.694f, 0.780f)),
+ (0.310f, new Color(0.208f, 0.510f, 0.686f)),
+ (0.590f, new Color(0.114f, 0.341f, 0.557f)),
+ (0.760f, new Color(0.059f, 0.212f, 0.408f)),
+ (0.880f, new Color(0.031f, 0.125f, 0.278f)),
+ (1.000f, new Color(0.016f, 0.063f, 0.165f)), // abyss — flat
+ };
+
/// The hypsometric tint for a height, before any relief shading.
public static Color Tint(Kind kind, float height, float seaLevel)
{
- bool dusk = kind == Kind.Dusk;
+ var (land, sea) = Ramps(kind);
if (height >= seaLevel)
- return Sample(dusk ? DuskLand : AtlasLand, height);
+ return Sample(land, height);
// Compressed bathymetry: index by d/(d+k), so the shallows get the range.
float depth = seaLevel - height;
float t = depth / (depth + SeaCompression);
- return Sample(dusk ? DuskSea : AtlasSea, t);
+ return Sample(sea, t);
}
+ private static ((float h, Color c)[] land, (float h, Color c)[] sea) Ramps(Kind kind) => kind switch
+ {
+ Kind.Dusk => (DuskLand, DuskSea),
+ Kind.CostaRica => (CostaRicaLand, CostaRicaSea),
+ _ => (AtlasLand, AtlasSea),
+ };
+
+ /// The land stops of a palette, for drawing a legend.
+ public static (float h, Color c)[] LandStops(Kind kind) => Ramps(kind).land;
+
private static Color Sample((float h, Color c)[] stops, float v)
{
if (v <= stops[0].h) return stops[0].c;
diff --git a/Tools/Scripts/ReliefRenderTool.cs b/Tools/Scripts/ReliefRenderTool.cs
index 236048e..2a47460 100644
--- a/Tools/Scripts/ReliefRenderTool.cs
+++ b/Tools/Scripts/ReliefRenderTool.cs
@@ -24,7 +24,8 @@ namespace IslaApocalypse.Tools
/// 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_BATCH output batch folder (default 03_relief_taste)
+ /// ISLA_TASK authoring task number (default 3)
+ /// ISLA_BATCH descriptor, NO prefix (default "relief_taste")
/// ISLA_SOURCE batch to read .f32 from (default 01_pass1_port)
/// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated
///
@@ -38,12 +39,15 @@ namespace IslaApocalypse.Tools
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
- string batch = EnvStr("ISLA_BATCH", "03_relief_taste");
+ int task = EnvInt("ISLA_TASK", 3); // the task that authored this batch
+ string batch = EnvStr("ISLA_BATCH", "relief_taste");
string source = EnvStr("ISLA_SOURCE", "01_pass1_port");
bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1";
LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null));
- string batchRoot = Path.Combine(ToolingPaths.BatchesRoot, batch);
+ // ⚠ 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));
diff --git a/Tools/Scripts/ReliefRenderer.cs b/Tools/Scripts/ReliefRenderer.cs
index f902aa0..7af17eb 100644
--- a/Tools/Scripts/ReliefRenderer.cs
+++ b/Tools/Scripts/ReliefRenderer.cs
@@ -46,6 +46,19 @@ namespace IslaApocalypse.Tools
{
/// Render a height field to a shaded-relief PNG. Returns the path written.
public static string SavePng(float[,] height, int mapSize, LookConfig look, string absolutePath)
+ {
+ Image img = Render(height, mapSize, look);
+ Error err = img.SavePng(absolutePath);
+ if (err != Error.Ok) GD.PrintErr($"[ReliefRenderer] SavePng failed ({err}) for {absolutePath}");
+ return absolutePath;
+ }
+
+ ///
+ /// Render to an in-memory image, so a caller can composite onto it (a legend, say) before
+ /// saving. ⚠ With HillshadeStrength = 0 this is a FLAT hypsometric tint and no relief
+ /// at all — which is the intended hero render, not a degenerate case.
+ ///
+ public static Image Render(float[,] height, int mapSize, LookConfig look)
{
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
@@ -108,9 +121,7 @@ namespace IslaApocalypse.Tools
}
}
- Error err = img.SavePng(absolutePath);
- if (err != Error.Ok) GD.PrintErr($"[ReliefRenderer] SavePng failed ({err}) for {absolutePath}");
- return absolutePath;
+ return img;
}
}
}
diff --git a/Tools/Scripts/ReviewBatchTool.cs b/Tools/Scripts/ReviewBatchTool.cs
new file mode 100644
index 0000000..4dbc30e
--- /dev/null
+++ b/Tools/Scripts/ReviewBatchTool.cs
@@ -0,0 +1,292 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// 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 ISLA_TASK and composed by , 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)
+ ///
+ public partial class ReviewBatchTool : Node
+ {
+ private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 };
+
+ /// Top of the legend's scale, in raw height units. Covers the measured max (1.415).
+ 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();
+ 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 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();
+ 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;
+ }
+ }
+}
diff --git a/Tools/Scripts/ReviewBatchTool.cs.uid b/Tools/Scripts/ReviewBatchTool.cs.uid
new file mode 100644
index 0000000..3f39bd1
--- /dev/null
+++ b/Tools/Scripts/ReviewBatchTool.cs.uid
@@ -0,0 +1 @@
+uid://bo683btnmgy36
diff --git a/Tools/Scripts/TerrainGenTool.cs b/Tools/Scripts/TerrainGenTool.cs
index 9cac756..799023e 100644
--- a/Tools/Scripts/TerrainGenTool.cs
+++ b/Tools/Scripts/TerrainGenTool.cs
@@ -24,7 +24,8 @@ namespace IslaApocalypse.Tools
///
/// ISLA_MAPSIZE map side in columns (default 2048 — iteration size)
/// ISLA_SEEDS comma-separated positive ints (default: the pinned batch below)
- /// ISLA_BATCH batch folder name (default 01_pass1_port)
+ /// 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)
@@ -45,12 +46,15 @@ namespace IslaApocalypse.Tools
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
- string batch = EnvStr("ISLA_BATCH", "01_pass1_port");
+ 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);
- string batchRoot = Path.Combine(ToolingPaths.BatchesRoot, batch);
+ // ⚠ 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
diff --git a/Tools/Scripts/TinyFont.cs b/Tools/Scripts/TinyFont.cs
new file mode 100644
index 0000000..245d7af
--- /dev/null
+++ b/Tools/Scripts/TinyFont.cs
@@ -0,0 +1,114 @@
+using System.Collections.Generic;
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// A 5x7 bitmap font, drawn straight into a .
+ ///
+ /// ═══ WHY A HAND-ROLLED FONT AND NOT GODOT'S ═══
+ ///
+ /// Godot's Font renders through a CanvasItem, which means a viewport — the SubViewport
+ /// capture path the renderer deliberately avoids, because it awaits render frames and is why
+ /// `--headless` hangs. A legend is a handful of digits; paying the whole capture path for it
+ /// would be a poor trade, and would drag a frame-loop dependency into a tool that currently
+ /// needs no display at all.
+ ///
+ /// So: 5x7 glyphs, integer-scaled, drawn per-pixel. Digits, a few punctuation marks and A–Z.
+ /// Unknown characters draw as blank rather than throwing — a legend with a missing letter is a
+ /// cosmetic problem, not a reason to lose the render.
+ ///
+ public static class TinyFont
+ {
+ public const int GlyphW = 5;
+ public const int GlyphH = 7;
+
+ // Each glyph is 7 rows of 5 columns. '#' = ink. Written out rather than hex-packed so a
+ // reader can verify a letter by looking at it.
+ private static readonly Dictionary Glyphs = new()
+ {
+ [' '] = new[] { ".....", ".....", ".....", ".....", ".....", ".....", "....." },
+ ['.'] = new[] { ".....", ".....", ".....", ".....", ".....", "..##.", "..##." },
+ ['-'] = new[] { ".....", ".....", ".....", "#####", ".....", ".....", "....." },
+ [':'] = new[] { ".....", "..##.", "..##.", ".....", "..##.", "..##.", "....." },
+ ['/'] = new[] { "....#", "...#.", "...#.", "..#..", ".#...", ".#...", "#...." },
+ ['('] = new[] { "...#.", "..#..", ".#...", ".#...", ".#...", "..#..", "...#." },
+ [')'] = new[] { ".#...", "..#..", "...#.", "...#.", "...#.", "..#..", ".#..." },
+
+ ['0'] = new[] { ".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###." },
+ ['1'] = new[] { "..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###." },
+ ['2'] = new[] { ".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####" },
+ ['3'] = new[] { "#####", "...#.", "..#..", "...#.", "....#", "#...#", ".###." },
+ ['4'] = new[] { "...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#." },
+ ['5'] = new[] { "#####", "#....", "####.", "....#", "....#", "#...#", ".###." },
+ ['6'] = new[] { "..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###." },
+ ['7'] = new[] { "#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..." },
+ ['8'] = new[] { ".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###." },
+ ['9'] = new[] { ".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.." },
+
+ ['A'] = new[] { ".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#" },
+ ['B'] = new[] { "####.", "#...#", "#...#", "####.", "#...#", "#...#", "####." },
+ ['C'] = new[] { ".###.", "#...#", "#....", "#....", "#....", "#...#", ".###." },
+ ['D'] = new[] { "###..", "#..#.", "#...#", "#...#", "#...#", "#..#.", "###.." },
+ ['E'] = new[] { "#####", "#....", "#....", "####.", "#....", "#....", "#####" },
+ ['F'] = new[] { "#####", "#....", "#....", "####.", "#....", "#....", "#...." },
+ ['G'] = new[] { ".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".####" },
+ ['H'] = new[] { "#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#" },
+ ['I'] = new[] { ".###.", "..#..", "..#..", "..#..", "..#..", "..#..", ".###." },
+ ['J'] = new[] { "..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.." },
+ ['K'] = new[] { "#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#" },
+ ['L'] = new[] { "#....", "#....", "#....", "#....", "#....", "#....", "#####" },
+ ['M'] = new[] { "#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#", "#...#" },
+ ['N'] = new[] { "#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#" },
+ ['O'] = new[] { ".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###." },
+ ['P'] = new[] { "####.", "#...#", "#...#", "####.", "#....", "#....", "#...." },
+ ['Q'] = new[] { ".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#" },
+ ['R'] = new[] { "####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#" },
+ ['S'] = new[] { ".####", "#....", "#....", ".###.", "....#", "....#", "####." },
+ ['T'] = new[] { "#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.." },
+ ['U'] = new[] { "#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###." },
+ ['V'] = new[] { "#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.." },
+ ['W'] = new[] { "#...#", "#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#" },
+ ['X'] = new[] { "#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#" },
+ ['Y'] = new[] { "#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.." },
+ ['Z'] = new[] { "#####", "....#", "...#.", "..#..", ".#...", "#....", "#####" },
+ };
+
+ /// Pixel width of at the given integer scale.
+ public static int Width(string text, int scale) => text.Length * (GlyphW + 1) * scale;
+
+ /// Pixel height of one line at the given integer scale.
+ public static int Height(int scale) => GlyphH * scale;
+
+ ///
+ /// Draw with its top-left at (x, y). Uppercases automatically —
+ /// the font has no lowercase, and silently dropping letters would be worse than shouting.
+ ///
+ public static void Draw(Image img, string text, int x, int y, int scale, Color color)
+ {
+ int cursor = x;
+ foreach (char raw in text.ToUpperInvariant())
+ {
+ if (Glyphs.TryGetValue(raw, out string[] g))
+ {
+ for (int row = 0; row < GlyphH; row++)
+ for (int col = 0; col < GlyphW; col++)
+ if (g[row][col] == '#')
+ FillCell(img, cursor + col * scale, y + row * scale, scale, color);
+ }
+ cursor += (GlyphW + 1) * scale;
+ }
+ }
+
+ private static void FillCell(Image img, int px, int py, int scale, Color color)
+ {
+ for (int dy = 0; dy < scale; dy++)
+ for (int dx = 0; dx < scale; dx++)
+ {
+ int tx = px + dx, ty = py + dy;
+ if (tx >= 0 && ty >= 0 && tx < img.GetWidth() && ty < img.GetHeight())
+ img.SetPixel(tx, ty, color);
+ }
+ }
+ }
+}
diff --git a/Tools/Scripts/TinyFont.cs.uid b/Tools/Scripts/TinyFont.cs.uid
new file mode 100644
index 0000000..51c7f83
--- /dev/null
+++ b/Tools/Scripts/TinyFont.cs.uid
@@ -0,0 +1 @@
+uid://bmk2lhoex5vog