Phase 1: shaded relief and the Hispaniola palette — the grin gate

Presentation only. Nothing here touches Topography, the noise, or any generation
constant; the renderer is handed a height field LOADED from a .f32 dump and has no
way to produce one, so a look change provably cannot move the terrain.

Hillshade: Horn 3x3, cell size 1, edges clamped (never wrapped — the Trench border
is a wall, not a seam). ZExaggeration is required rather than optional: measured
median land slope is 0.22 degrees unexaggerated, so the island shades as a flat
plane. Chosen from a measured slope table — zex 75 gives 16/28/37 deg at land
median/p90/p99, which reads as natural relief. It is a look dial; raw units are not
metres and no code may read it as if they were.

Palette: stops placed on the MEASURED height distribution, not spread linearly.
Land is bottom-heavy (median 0.456, p99 1.113, max 1.415 over 8.6M land columns
across four seeds), so a linear ramp would spend 99% of its range on 1% of the land
and the map would read as green with a few white dots. Bathymetry is compressed by
d/(d+0.35) so the shallow shelf gets the range and the featureless abyss flattens.
Anchors are absolute and fixed across the batch — a map coloured on its own min/max
cannot be compared with its neighbour, and comparison is the point of a batch.

Blend: the naive tint x hillshade darkens everything by 29% before any slope is
involved, because flat ground shades to sin(altitude). So shade is normalized by
its flat-ground value first — flat terrain keeps its true tint and only SLOPE moves
the colour — then shadows multiply while highlights screen toward white. That
asymmetry is the difference between a colour ramp and a map you would frame.

Relief fades to zero with depth below sea. Not only taste: the deep floor is the
Trench, a synthetic wall whose gradient is ~1.5x the p99 land gradient, so shading
it faithfully draws a bright rim around the map and lights the abyss with noise
mottle. The fade puts relief on the near-shore shelf where the bathymetry is real.

Three named looks (atlas / relief / dusk), gated like any other change and kept
deliberately few — iteration fatigue on a subjective gate is a real failure mode.
Each pair isolates one question.

HeightMapRenderer.cs is retired: its raw I/O moved to HeightField.cs and its ramp
to ReliefPalette.cs, so there is one palette everywhere and the generator's own
quick-look is coloured identically to a beauty render.

No biome words, no classification, no water. The 0.15 line is a colour boundary.
This commit is contained in:
Stewart Howe 2026-08-19 21:58:44 -04:00
parent b60d78b752
commit b1cf282295
17 changed files with 806 additions and 153 deletions

View file

@ -33,10 +33,56 @@ constants, carried over verbatim — not re-derived from a design summary** (→
| `Scripts/IslandFalloff.cs` | `SmoothAbs` + `CREST_EPSILON` (pass-1 half of the reference file) |
| `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** |
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
| `Scripts/HeightMapRenderer.cs` | Hypsometric PNG + raw `.f32` dump |
| `Scripts/HeightField.cs` | Raw `.f32` save/load — **the generation/presentation seam** |
| `Scripts/TerrainGenTool.cs` | Batch entry point (ladder + seed batch + `INDEX.md`) |
| `Scenes/TerrainGenTool.tscn` | Run this |
### The relief render — 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
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/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/ReliefRenderTool.cs` | Taste-gate batch runner |
| `Scenes/ReliefRenderTool.tscn` | Run this |
```bash
Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
--path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ReliefRenderTool.tscn
```
`ISLA_LOOKS=atlas,relief,dusk` · `ISLA_SOURCE` (batch to read `.f32` from) · `ISLA_MAPSIZE` ·
`ISLA_SEEDS` · `ISLA_BATCH` · `ISLA_DUMP_RAW=1`
**Three looks, deliberately** — a subjective gate drowns in a wall of near-duplicates, and each pair
isolates one question: `atlas` vs `relief` asks how strong the relief should be (same palette and
light); `atlas` vs `dusk` asks about palette and sun angle.
> ### ⚠ Vertical exaggeration is required, and it is a LOOK dial — not a physical claim.
>
> Height is in raw noise units over a 1 m grid, so the true per-pixel gradient is tiny: measured
> median land slope is **0.22°**. Un-exaggerated, the whole island shades as a flat plane. The raw
> units are **not metres** — the metres-per-unit conversion is Phase 2's, not this renderer's.
**The blend, and why it is not a multiply.** Hillshade on flat ground is `sin(altitude)` ≈ 0.71, so a
plain `tint × shade` darkens the entire map by 29% before any slope is involved and the hypsometric
tints are never actually seen. So the shade is normalized by its flat-ground value first — flat
terrain keeps its true tint, and only *slope* moves the colour — then shadows **multiply** while
highlights **screen** toward white. That asymmetry is the difference between a colour ramp and a map
you would frame.
**⚠ Relief fades out with depth below sea.** Not only taste: the deep floor is dominated by the
Trench, a synthetic additive wall whose gradient is ~1.5× the p99 *land* gradient. Shading it
faithfully draws a bright rim around the whole map and lights up the abyss with base-noise mottle —
relief on terrain nobody is meant to look at. The fade puts relief where the bathymetry is real (the
near-shore shelf) and lets the abyss lie flat, which is the cartographic convention anyway.
```bash
Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
--path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/TerrainGenTool.tscn

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://bqp1x2v3isla3"]
[ext_resource type="Script" path="res://Tools/Scripts/ReliefRenderTool.cs" id="1_rrt"]
[node name="ReliefRenderTool" type="Node"]
script = ExtResource("1_rrt")

View file

@ -0,0 +1,55 @@
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Raw height-field I/O: little-endian float32, x-major (<c>index = x * MapSize + y</c>).
///
/// ⭐ THIS FILE IS WHY PRESENTATION AND GENERATION STAY SEPARATE. A relief render reads a dumped
/// height field and re-colours it; it never re-runs the generator. So a look change provably
/// cannot move the terrain — not by convention, but because the colours are computed from a
/// file the renderer cannot write.
///
/// It also makes a byte-level port-fidelity oracle possible later: two generators agree, or
/// their dumps differ. → `Design - Tooling - Iteration and Batching.md`, "build the oracle
/// before the taste-iteration".
/// </summary>
public static class HeightField
{
public static void Save(float[,] height, int mapSize, string absolutePath)
{
using var f = Godot.FileAccess.Open(absolutePath, Godot.FileAccess.ModeFlags.Write);
if (f == null)
{
GD.PrintErr($"[HeightField] cannot write {absolutePath}: {Godot.FileAccess.GetOpenError()}");
return;
}
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
f.StoreFloat(height[x, y]);
}
/// <summary>Load a dump. Returns null if absent or the wrong size for <paramref name="mapSize"/>.</summary>
public static float[,] Load(string absolutePath, int mapSize)
{
if (!Godot.FileAccess.FileExists(absolutePath)) return null;
using var f = Godot.FileAccess.Open(absolutePath, Godot.FileAccess.ModeFlags.Read);
if (f == null) return null;
long expected = (long)mapSize * mapSize * 4;
if ((long)f.GetLength() != expected)
{
GD.PrintErr($"[HeightField] {absolutePath} is {f.GetLength()} bytes, expected {expected} " +
$"for MapSize {mapSize} — ignoring it rather than guessing.");
return null;
}
var h = new float[mapSize, mapSize];
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
h[x, y] = f.GetFloat();
return h;
}
}
}

View file

@ -0,0 +1 @@
uid://cd6vej0ey8wo8

View file

@ -1,148 +0,0 @@
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Renders a raw pass-1 height field as a HYPSOMETRIC height-gradient PNG.
///
/// ═══ ⚠ WHY THIS WRITES A Godot.Image DIRECTLY, AND NOT VIA THE CAPTURE PATH ═══
///
/// The reference captured its maps through a SubViewport + a TextureRect whose `_Draw` painted
/// overlays — roads, town markers, river polylines — and then read the viewport texture back.
/// That machinery exists to COMPOSITE VECTOR OVERLAYS onto a raster. It also carries a real
/// cost: it awaits render frames, which is why unattended runs need `xvfb-run` and why
/// `--headless` HANGS on it.
///
/// Phase 1 has no overlays. A heightmap is a raster and nothing else, so it is written
/// per-pixel into an Image and saved. No viewport, no frame awaits, no display needed.
///
/// ⚠ DO NOT REINTRODUCE THE CAPTURE PATH BY HABIT. It becomes correct the moment something
/// vector needs compositing on top (roads, towns, river centrelines) — and not before.
///
/// ═══ THE COLOUR SCHEME ═══
///
/// Two ramps meeting at the sea threshold, Hispaniola-style:
/// below — bathymetric: deep navy → shallow cyan-ish blue
/// above — hypsometric: shore green → lowland green → tan → brown → grey → white peak
///
/// ⚠⚠ THERE ARE NO BIOME WORDS HERE AND THERE MUST NOT BE. Nothing in this file knows about
/// jungle, wasteland, snow or desert. It reads a single float per column and picks a colour on
/// an elevation ramp. Biomes are a stage-5 CLASSIFICATION of finished shape (D-049 §2, D-056),
/// and the whole point of the rewrite's seam is that terrain never learns their names.
/// "White at the top" is snow-COLOURED cartography, not a snow biome.
///
/// ⚠ The sea threshold is a VISUALIZATION boundary only. No water is modelled: no water bodies,
/// no ocean/lake distinction, no flooding. Phase 2 owns all of that.
/// </summary>
public static class HeightMapRenderer
{
/// <summary>
/// FIXED ramp anchors in raw height units, deliberately NOT per-run normalized.
///
/// Per-run normalization would make every PNG use its full colour range, which looks better
/// and lies: two seeds, or two rungs of the ablation ladder, would be coloured by different
/// scales and could not be compared by eye. Fixed anchors mean the same colour is the same
/// height in every image in a batch. Out-of-range values clamp, and the run prints the true
/// min/max so saturation is visible as a number rather than guessed from the picture.
/// </summary>
public const float RampTopHeight = 1.45f;
/// <summary>The deepest height the bathymetric ramp resolves. Below this, flat abyss colour.</summary>
public const float RampBottomHeight = -1.00f;
// ---- the land ramp: (t in [0,1] above sea) -> colour ----
private static readonly (float t, Color c)[] LandRamp =
{
(0.00f, new Color(0.36f, 0.55f, 0.35f)), // shore green
(0.12f, new Color(0.47f, 0.62f, 0.36f)), // lowland
(0.30f, new Color(0.72f, 0.71f, 0.44f)), // dry tan
(0.50f, new Color(0.72f, 0.57f, 0.36f)), // brown
(0.70f, new Color(0.60f, 0.45f, 0.34f)), // dark brown
(0.86f, new Color(0.66f, 0.64f, 0.64f)), // grey rock
(1.00f, new Color(0.98f, 0.98f, 0.98f)), // white peak
};
// ---- the sea ramp: (t in [0,1], 0 = deepest) -> colour ----
private static readonly (float t, Color c)[] SeaRamp =
{
(0.00f, new Color(0.03f, 0.06f, 0.18f)), // abyss
(0.45f, new Color(0.06f, 0.16f, 0.36f)), // deep
(0.75f, new Color(0.11f, 0.30f, 0.52f)), // mid
(0.92f, new Color(0.20f, 0.47f, 0.66f)), // shelf
(1.00f, new Color(0.42f, 0.68f, 0.79f)), // shallow, at the waterline
};
/// <summary>Render and save. Returns the absolute path written.</summary>
public static string SavePng(Pass1Result result, float seaLevel, string absolutePath)
{
int n = result.MapSize;
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
float h = result.Height[x, y];
Color c;
if (h >= seaLevel)
{
float t = Mathf.Clamp((h - seaLevel) / (RampTopHeight - seaLevel), 0f, 1f);
c = Sample(LandRamp, t);
}
else
{
float t = Mathf.Clamp((h - RampBottomHeight) / (seaLevel - RampBottomHeight), 0f, 1f);
c = Sample(SeaRamp, t);
}
// Image is row-major (x = column, y = row); the height field is [x, y] with y
// increasing SOUTHWARD, matching the reference's convention. So a larger y is
// further down the image, and "the southern sinker" sinks the bottom of the PNG.
img.SetPixel(x, y, c);
}
}
Error err = img.SavePng(absolutePath);
if (err != Error.Ok)
GD.PrintErr($"[HeightMapRenderer] SavePng failed ({err}) for {absolutePath}");
return absolutePath;
}
private static Color Sample((float t, Color c)[] ramp, float t)
{
for (int i = 1; i < ramp.Length; i++)
{
if (t <= ramp[i].t)
{
float span = ramp[i].t - ramp[i - 1].t;
float local = span <= 0f ? 0f : (t - ramp[i - 1].t) / span;
return ramp[i - 1].c.Lerp(ramp[i].c, local);
}
}
return ramp[ramp.Length - 1].c;
}
/// <summary>
/// Dump the raw float height field beside the PNG, little-endian float32, x-major.
///
/// Cheap, and it is what makes a future BYTE-LEVEL port-fidelity check possible — the kind
/// of automatic oracle that lets taste-iteration happen without correctness being judged by
/// eye. → `Design - Tooling - Iteration and Batching.md`, "build the oracle first".
/// </summary>
public static string SaveRaw(Pass1Result result, string absolutePath)
{
using var f = FileAccess.Open(absolutePath, FileAccess.ModeFlags.Write);
if (f == null)
{
GD.PrintErr($"[HeightMapRenderer] could not open {absolutePath}: {FileAccess.GetOpenError()}");
return absolutePath;
}
int n = result.MapSize;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
f.StoreFloat(result.Height[x, y]);
return absolutePath;
}
}
}

View file

@ -1 +0,0 @@
uid://tufk8dg4g14g

110
Tools/Scripts/Hillshade.cs Normal file
View file

@ -0,0 +1,110 @@
using System;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Classic cartographic SHADED RELIEF from a height field — the slope+aspect view.
///
/// ⚠ PRESENTATION ONLY. → `Design - Rendering - Roughness Is Presentation.md`. Nothing here
/// touches how height is GENERATED; it changes only how height is SHOWN. A hillshade parameter
/// is a look dial, never a claim about the world.
///
/// ═══ THE METHOD ═══
///
/// HORN'S 3x3 method — the standard for DEM shading, and more robust than a plain central
/// difference because it weights the four edge-adjacent neighbours double, so single-pixel noise
/// does not dominate the normal. Cell spacing is 1: one column per pixel, 1 m per column
/// (D-002, 1:1 scale).
///
/// a b c dz/dx = ((c + 2f + i) - (a + 2d + g)) / 8
/// d e f dz/dy = ((g + 2h + i) - (a + 2b + c)) / 8
/// g h i
///
/// ⚠ y INCREASES SOUTHWARD, matching the height field and the rendered image. So dz/dy is a
/// north→south gradient, and the light's north component points toward -y.
///
/// ⚠ EDGES CLAMP, THEY DO NOT WRAP. Sampling is replicated at the border. Wrapping would fold
/// the Trench's outer wall against the opposite edge and smear a hard, deliberate boundary into
/// a false slope — the Trench border is a wall, not a seam.
///
/// ═══ ⚠⚠ WHY VERTICAL EXAGGERATION IS NOT OPTIONAL HERE ═══
///
/// The height field is in RAW NOISE UNITS (sea 0.15, peaks ~1.45) over a 204810240 px map, so
/// the true per-pixel gradient is tiny. Measured on seed 1063685222 at 2048, over 135k land
/// samples: MEDIAN |grad| = 0.0038, i.e. a slope of **0.22°**. Un-exaggerated, the entire island
/// shades as a flat plane. That is not a defect in the terrain; it is what a 1:1 metre grid
/// looks like.
///
/// So <see cref="LookConfig.ZExaggeration"/> is a required look dial. Measured slopes at
/// candidate values (median / p90 / p99 of land):
///
/// zex 25 → 5.5° / 9.9° / 14.2° too flat to read
/// zex 75 → 16.0° / 27.6° / 37.1° natural, atlas-like ← default
/// zex 120 → 25.3° / 42.0° / 52.0° dramatic, shape-forward
/// zex 300 → 49.0° / 64.5° / 71.7° cartoon; the tint is lost
///
/// ⚠ IT IS A LOOK DIAL, NOT A PHYSICAL CLAIM. The raw units are not metres. The metres-per-unit
/// conversion belongs to Phase 2's elevation profile, not to this file, and no code should read
/// ZExaggeration as if it meant something about the world.
/// </summary>
public static class Hillshade
{
/// <summary>
/// Compute the hillshade factor at (x, y): the cosine of the angle between the surface
/// normal and the light, clamped to [0, 1]. 0 = fully shadowed, 1 = face-on to the light.
///
/// On PERFECTLY FLAT ground this returns <c>sin(altitude)</c> — not 1. That value is the
/// neutral point the blend divides by, so flat ground keeps its true hypsometric tint.
/// → <see cref="Neutral"/>.
/// </summary>
public static float At(float[,] h, int n, int x, int y, float zExaggeration,
float lightX, float lightY, float lightZ)
{
// Clamped 3x3 window — replicate at the border, never wrap.
int xm = x > 0 ? x - 1 : 0, xp = x < n - 1 ? x + 1 : n - 1;
int ym = y > 0 ? y - 1 : 0, yp = y < n - 1 ? y + 1 : n - 1;
float hA = h[xm, ym], hB = h[x, ym], hC = h[xp, ym];
float hD = h[xm, y], hF = h[xp, y];
float hG = h[xm, yp], hH = h[x, yp], hI = h[xp, yp];
float dzdx = ((hC + 2f * hF + hI) - (hA + 2f * hD + hG)) / 8f;
float dzdy = ((hG + 2f * hH + hI) - (hA + 2f * hB + hC)) / 8f;
// Surface normal of z = height * zExaggeration, before normalization.
float nx = -dzdx * zExaggeration;
float ny = -dzdy * zExaggeration;
const float nz = 1f;
float inv = 1f / MathF.Sqrt(nx * nx + ny * ny + nz * nz);
float dot = (nx * lightX + ny * lightY + nz * lightZ) * inv;
return dot < 0f ? 0f : (dot > 1f ? 1f : dot);
}
/// <summary>
/// The unit vector pointing FROM the surface TOWARD the light.
///
/// Azimuth is compass degrees, measured CLOCKWISE FROM NORTH — 315° is the cartographic
/// standard, light from the north-west. Altitude is degrees above the horizon; 45° standard.
///
/// North is -y in image space (y increases southward), which is why the y component is
/// negated. Get this wrong and the relief inverts: ridges read as valleys, which the eye
/// notices instantly even when it cannot say why.
/// </summary>
public static (float x, float y, float z) LightVector(float azimuthDegrees, float altitudeDegrees)
{
float az = azimuthDegrees * MathF.PI / 180f;
float alt = altitudeDegrees * MathF.PI / 180f;
float cosAlt = MathF.Cos(alt);
return (cosAlt * MathF.Sin(az), -cosAlt * MathF.Cos(az), MathF.Sin(alt));
}
/// <summary>
/// The hillshade value flat ground returns — <c>sin(altitude)</c>. The blend normalizes by
/// this so that flat terrain is neither darkened nor lightened, and only actual SLOPE moves
/// the colour away from its hypsometric tint.
/// </summary>
public static float Neutral(float altitudeDegrees) => MathF.Sin(altitudeDegrees * MathF.PI / 180f);
}
}

View file

@ -0,0 +1 @@
uid://dy8vvr2w7trpi

View file

@ -0,0 +1,99 @@
namespace IslaApocalypse.Tools
{
/// <summary>
/// The presentation dials — everything about how a heightmap is SHOWN, and nothing about how it
/// is MADE. → `Design - Rendering - Roughness Is Presentation.md`.
///
/// ⚠ Config-gated for the same reason every shaping change is: a look decision should be an A/B
/// pair, not a memory of last week's render, and a rejected look should cost a flipped default
/// rather than a reverted commit. → `Design - Tooling - Iteration and Batching.md`.
///
/// ⚠ Kept DELIBERATELY SMALL. This is a taste gate, and iteration fatigue on a subjective gate
/// is a real failure mode — a handful of variants the eye can actually judge beats a wall of
/// near-duplicates. Three named looks, chosen so each pair isolates one question.
/// </summary>
public sealed class LookConfig
{
/// <summary>Short label, used in output filenames.</summary>
public string Name = "atlas";
/// <summary>Which hypsometric palette. → <see cref="ReliefPalette.Kind"/>.</summary>
public ReliefPalette.Kind Palette = ReliefPalette.Kind.Atlas;
/// <summary>
/// Vertical exaggeration for the relief normal. ⚠ A LOOK DIAL, NOT A PHYSICAL CLAIM — the
/// raw height units are not metres. See <see cref="Hillshade"/> for the measured slope
/// table this was chosen from.
/// </summary>
public float ZExaggeration = 75f;
/// <summary>Compass degrees clockwise from north. 315° (NW) is the cartographic standard.</summary>
public float LightAzimuth = 315f;
/// <summary>Degrees above the horizon. 45° standard; lower = longer, more dramatic shadows.</summary>
public float LightAltitude = 45f;
/// <summary>
/// How much the relief moves the tint. 0 = flat hypsometric tint, no shading at all;
/// 1 = full relief. Above ~0.85 the tint starts washing out and the map reads as a
/// greyscale DEM wearing colour.
/// </summary>
public float HillshadeStrength = 0.55f;
/// <summary>
/// Relief multiplier BELOW sea level.
///
/// ⚠ Deliberately much weaker than on land, and the reason is the deferral: the seabed is
/// RAW — no coast shelf, no islets, both Phase-2 items — so it is steep and noisy in a way
/// the finished terrain will not be. Shading it at full strength drags the eye to the one
/// part of the map that is knowingly unfinished. Cartographic bathymetry is conventionally
/// shown flat or lightly shaded anyway, so the convention and the deferral agree.
/// </summary>
public float SeaHillshadeFactor = 0.35f;
/// <summary>
/// How much of the lit side is allowed to lift toward white. Highlights SCREEN rather than
/// multiply, so sunlit slopes brighten without blowing out to paper.
/// </summary>
public float HighlightGain = 0.5f;
/// <summary>The colour boundary. NOT a water surface — no water is modelled (Phase 2).</summary>
public float SeaLevel = 0.15f;
/// <summary>
/// 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)
/// </summary>
public static LookConfig[] Variants() => new[]
{
// Classic physical-atlas plate: tint-forward, relief present but polite.
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,
ZExaggeration = 100f, LightAzimuth = 300f, LightAltitude = 35f,
HillshadeStrength = 0.70f,
},
};
public override string ToString() =>
$"{Name}: palette={Palette} zex={ZExaggeration:F0} light={LightAzimuth:F0}°/{LightAltitude:F0}° " +
$"strength={HillshadeStrength:F2} sea×{SeaHillshadeFactor:F2}";
}
}

View file

@ -0,0 +1 @@
uid://ctf5uq0l5gqr5

View file

@ -0,0 +1,143 @@
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// The hypsometric palettes — a continuous elevation tint, in the physical-atlas tradition.
///
/// ═══ ⚠⚠ THIS IS CARTOGRAPHY, NOT CLASSIFICATION ═══
///
/// There are NO BIOME WORDS here and there must never be any. Nothing in this file knows about
/// jungle, wasteland, desert or snow. It maps ONE FLOAT — height — onto a continuous colour
/// ramp. White at the top is snow-COLOURED cartography; green at the bottom is
/// lowland-COLOURED. Biomes are a stage-5 CLASSIFICATION of finished shape (D-049 §2, D-056),
/// and the entire point of the rewrite's seam is that terrain never learns their names.
///
/// ⚠ CONTINUOUS, NOT BANDED. The stops below are interpolated, never quantized. Hard bands
/// would read as discrete classes — exactly the visual language of a biome map, and exactly the
/// wrong thing to suggest.
///
/// ═══ ⚠ THE STOPS ARE IN ABSOLUTE HEIGHT, AND THEY ARE FIXED ═══
///
/// Every stop is a raw height value, not a normalized fraction, and the same stops are used for
/// every image in a batch. A map coloured on its own min/max cannot be compared with its
/// neighbour — and comparison is the entire point of a batch. Out-of-range values clamp; the
/// run prints the true min/max so saturation shows up as a number rather than a guess.
///
/// ═══ WHY THE STOPS SIT WHERE THEY DO — measured, not eyeballed ═══
///
/// Land height is very bottom-heavy. Measured over all four pinned seeds at 2048 (8.6M land
/// columns): p10 0.214 · p25 0.306 · MEDIAN 0.456 · p75 0.648 · p90 0.835 · p99 1.113 ·
/// p99.9 1.267 · max 1.415.
///
/// A ramp spread linearly from 0.15 to 1.45 would therefore spend 99% of its colour range on
/// 1% of the land, and the map would read as "green with a few white dots". So the land stops
/// are placed on PERCENTILES of the measured distribution instead — the same thing an atlas
/// plate does when it picks non-uniform contour intervals.
///
/// Sea is the mirror problem: depth runs to 6.7 but 43% of it is shallower than 0.5 and the
/// deep floor is featureless. So the bathymetric ramp is COMPRESSED — it is indexed by
/// <c>d / (d + k)</c>, which gives the shallow shelf most of the range and lets the abyss
/// flatten to one dark tone. The interesting bathymetry is near shore.
///
/// ⚠ THE SEABED IS RAW, AND IT IS SUPPOSED TO BE. The submarine coast shelf and the offshore
/// islets are DEFERRED Phase-2 items. Plain, steep bathymetry here is the deferral showing, not
/// a broken render.
/// </summary>
public static class ReliefPalette
{
/// <summary>Named palettes. Gated so the look can be A/B'd like any other change.</summary>
public enum Kind { Atlas, Dusk }
/// <summary>
/// Bathymetric compression constant, in raw height units. The sea ramp is indexed by
/// <c>d / (d + SeaCompression)</c>. Smaller = more range spent on the shallows.
/// At 0.35: depth 0.05 → 12.5% of the ramp, 0.5 → 59%, 2.0 → 85%, 6.7 → 95%.
/// </summary>
public const float SeaCompression = 0.35f;
// ---- LAND: (absolute height, colour), interpolated. Placed on measured percentiles. ----
private static readonly (float h, Color c)[] AtlasLand =
{
(0.150f, new Color(0.427f, 0.561f, 0.376f)), // shoreline — soft green
(0.220f, new Color(0.475f, 0.596f, 0.388f)), // p10 lowland green
(0.310f, new Color(0.549f, 0.635f, 0.404f)), // p25
(0.460f, new Color(0.667f, 0.686f, 0.435f)), // p50 yellow-green
(0.650f, new Color(0.780f, 0.729f, 0.494f)), // p75 khaki
(0.840f, new Color(0.808f, 0.678f, 0.478f)), // p90 tan
(0.980f, new Color(0.757f, 0.588f, 0.427f)), // p95 light brown
(1.120f, new Color(0.667f, 0.510f, 0.404f)), // p99 brown
(1.270f, new Color(0.706f, 0.667f, 0.639f)), // p99.9 grey rock
(1.450f, new Color(0.988f, 0.988f, 0.980f)), // peak white
};
private static readonly (float h, Color c)[] AtlasSea =
{
(0.000f, new Color(0.478f, 0.729f, 0.769f)), // waterline — pale teal
(0.125f, new Color(0.325f, 0.596f, 0.706f)), // shelf
(0.310f, new Color(0.196f, 0.451f, 0.627f)), // mid blue
(0.590f, new Color(0.114f, 0.310f, 0.510f)), // deep
(0.760f, new Color(0.063f, 0.196f, 0.376f)), // deeper
(0.880f, new Color(0.035f, 0.118f, 0.259f)), // navy
(1.000f, new Color(0.020f, 0.063f, 0.157f)), // abyss floor — flat
};
// ---- DUSK: warmer land, deeper cooler water. A genuine alternative, not a tweak. ----
private static readonly (float h, Color c)[] DuskLand =
{
(0.150f, new Color(0.353f, 0.494f, 0.353f)),
(0.220f, new Color(0.427f, 0.541f, 0.361f)),
(0.310f, new Color(0.529f, 0.588f, 0.376f)),
(0.460f, new Color(0.663f, 0.639f, 0.408f)),
(0.650f, new Color(0.796f, 0.690f, 0.451f)),
(0.840f, new Color(0.831f, 0.627f, 0.412f)),
(0.980f, new Color(0.780f, 0.522f, 0.361f)),
(1.120f, new Color(0.663f, 0.435f, 0.353f)),
(1.270f, new Color(0.678f, 0.612f, 0.588f)),
(1.450f, new Color(0.980f, 0.973f, 0.949f)),
};
private static readonly (float h, Color c)[] DuskSea =
{
(0.000f, new Color(0.412f, 0.686f, 0.706f)),
(0.125f, new Color(0.263f, 0.545f, 0.647f)),
(0.310f, new Color(0.145f, 0.396f, 0.565f)),
(0.590f, new Color(0.078f, 0.263f, 0.443f)),
(0.760f, new Color(0.043f, 0.161f, 0.318f)),
(0.880f, new Color(0.024f, 0.094f, 0.212f)),
(1.000f, new Color(0.012f, 0.047f, 0.125f)),
};
/// <summary>The hypsometric tint for a height, before any relief shading.</summary>
public static Color Tint(Kind kind, float height, float seaLevel)
{
bool dusk = kind == Kind.Dusk;
if (height >= seaLevel)
return Sample(dusk ? DuskLand : AtlasLand, 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);
}
private static Color Sample((float h, Color c)[] stops, float v)
{
if (v <= stops[0].h) return stops[0].c;
for (int i = 1; i < stops.Length; i++)
{
if (v <= stops[i].h)
{
float span = stops[i].h - stops[i - 1].h;
float local = span <= 0f ? 0f : (v - stops[i - 1].h) / span;
return stops[i - 1].c.Lerp(stops[i].c, local);
}
}
return stops[stops.Length - 1].c;
}
/// <summary>The land range the stops cover, for a run header / INDEX.</summary>
public static (float lo, float hi) LandAnchors => (AtlasLand[0].h, AtlasLand[AtlasLand.Length - 1].h);
}
}

View file

@ -0,0 +1 @@
uid://bkso0vgv2ux4d

View file

@ -0,0 +1,217 @@
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_BATCH output batch folder (default 03_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
/// </summary>
public partial class ReliefRenderTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 };
public override void _Ready()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
string batch = EnvStr("ISLA_BATCH", "03_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);
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
{
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" };
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;
}
}
}

View file

@ -0,0 +1 @@
uid://ctkcngrni5ood

View file

@ -0,0 +1,116 @@
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE BEAUTY RENDER — hypsometric tint blended with shaded relief. The Phase-1 finish line.
///
/// ⚠ PRESENTATION ONLY (→ `Design - Rendering - Roughness Is Presentation.md`). This file
/// changes how height is SHOWN, never how it is MADE. It cannot: it is handed a height field
/// and has no way to produce one.
///
/// ═══ ⚠ WHY THIS WRITES A Godot.Image DIRECTLY, AND NOT VIA THE CAPTURE PATH ═══
///
/// The reference captured maps through a SubViewport + a TextureRect._Draw, because it
/// composited VECTOR OVERLAYS — roads, town markers, river polylines — onto the raster. That
/// machinery awaits render frames, which is why unattended runs need `xvfb-run` and why
/// `--headless` hangs on it. Phase 1 has no overlays, so this is a raster and nothing else.
/// **Do not reintroduce the capture path by habit** — it becomes correct the moment something
/// vector needs compositing, and not before.
///
/// ═══ THE BLEND — the difference between a colour ramp and a map you would frame ═══
///
/// The naive composite is <c>tint × hillshade</c>. It looks wrong, for a reason worth stating:
/// hillshade on FLAT ground is <c>sin(altitude)</c> ≈ 0.71 at a 45° sun, so a plain multiply
/// darkens the entire map by 29% before any slope is involved, and the carefully-placed
/// hypsometric tints are never actually seen.
///
/// So the shade is NORMALIZED BY ITS FLAT-GROUND VALUE first:
///
/// lum = shade / sin(altitude) → 1.0 on flat ground, &lt;1 shadowed, &gt;1 lit
/// factor = lerp(1, lum, strength) → strength dials relief without touching hue
///
/// Flat terrain therefore keeps its true tint, and ONLY SLOPE moves the colour. Then the two
/// sides are treated differently, because multiplying both ways blows the highlights to paper:
///
/// factor ≤ 1 → MULTIPLY : c = tint × factor (shadows deepen, hue held)
/// factor &gt; 1 → SCREEN : c = tint + (1tint)×(factor1)×gain (lit slopes lift toward
/// white, gently, never clipping)
///
/// That asymmetry is the whole trick. Shadows want to keep the tint's hue; highlights want to
/// desaturate toward sunlight, exactly as they do on a printed relief plate.
///
/// ⚠ NO BIOME WORDS. Continuous height ramp + slope. Nothing here classifies anything.
/// </summary>
public static class ReliefRenderer
{
/// <summary>Render a height field to a shaded-relief PNG. Returns the path written.</summary>
public static string SavePng(float[,] height, int mapSize, LookConfig look, string absolutePath)
{
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
var (lx, ly, lz) = Hillshade.LightVector(look.LightAzimuth, look.LightAltitude);
float neutral = Hillshade.Neutral(look.LightAltitude);
float invNeutral = neutral > 0.0001f ? 1f / neutral : 1f;
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
float h = height[x, y];
Color tint = ReliefPalette.Tint(look.Palette, h, look.SeaLevel);
float shade = Hillshade.At(height, mapSize, x, y, look.ZExaggeration, lx, ly, lz);
// Relief is dialled back below sea, AND FADED OUT WITH DEPTH.
//
// ⚠ The depth fade is not just taste. The deep floor is dominated by the
// TRENCH — a synthetic additive wall (falloff += (dist-0.90)*15) whose gradient
// is ~1.5x the p99 LAND gradient. Shading it faithfully draws a bright rim
// around the whole map and lights up the abyss with base-noise mottle: relief
// on terrain that was never meant to be looked at. Fading with depth puts the
// relief where the bathymetry is real — the near-shore shelf — and lets the
// abyss lie flat, which is also the cartographic convention.
//
// Uses the same compressed depth index as the palette, so the tint and the
// shading fade together instead of drifting apart.
float strength;
if (h >= look.SeaLevel)
{
strength = look.HillshadeStrength;
}
else
{
float depth = look.SeaLevel - h;
float dt = depth / (depth + ReliefPalette.SeaCompression); // 0 shore → 1 abyss
strength = look.HillshadeStrength * look.SeaHillshadeFactor * (1f - dt);
}
float lum = shade * invNeutral;
float factor = 1f + (lum - 1f) * strength;
Color c;
if (factor <= 1f)
{
c = new Color(tint.R * factor, tint.G * factor, tint.B * factor);
}
else
{
float lift = (factor - 1f) * look.HighlightGain;
c = new Color(
tint.R + (1f - tint.R) * lift,
tint.G + (1f - tint.G) * lift,
tint.B + (1f - tint.B) * lift);
}
img.SetPixel(x, y, new Color(
Mathf.Clamp(c.R, 0f, 1f), Mathf.Clamp(c.G, 0f, 1f), Mathf.Clamp(c.B, 0f, 1f)));
}
}
Error err = img.SavePng(absolutePath);
if (err != Error.Ok) GD.PrintErr($"[ReliefRenderer] SavePng failed ({err}) for {absolutePath}");
return absolutePath;
}
}
}

View file

@ -0,0 +1 @@
uid://c67yqu44cjno0

View file

@ -123,8 +123,11 @@ namespace IslaApocalypse.Tools
string dir = Path.Combine(batchRoot, $"{cfg.Seed}_{cfg.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
string png = HeightMapRenderer.SavePng(r, cfg.SeaLevel, Path.Combine(dir, "height.png"));
if (!skipRaw) HeightMapRenderer.SaveRaw(r, Path.Combine(dir, "height.f32"));
// The generator's own quick-look uses the default 'atlas' presentation, so a generation
// run and a beauty render are coloured identically and can be compared directly.
var look = new LookConfig { SeaLevel = cfg.SeaLevel };
ReliefRenderer.SavePng(r.Height, r.MapSize, look, Path.Combine(dir, "height.png"));
if (!skipRaw) HeightField.Save(r.Height, r.MapSize, Path.Combine(dir, "height.f32"));
float land = r.LandFraction(cfg.SeaLevel);
GD.Print($" {cfg.VariantLabel,-16} seed {cfg.Seed,-11} " +
@ -146,7 +149,8 @@ namespace IslaApocalypse.Tools
sb.AppendLine($"- **MapSize:** {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
sb.AppendLine($"- **Noise:** {TerrainNoise.Describe(0, scale).Replace(" · seed 0", "")}");
sb.AppendLine($"- **Sea threshold (visualization only):** 0.15");
sb.AppendLine($"- **Colour ramp anchors (FIXED across the batch):** sea {HeightMapRenderer.RampBottomHeight} .. 0.15, land 0.15 .. {HeightMapRenderer.RampTopHeight}");
var (landLo, landHi) = ReliefPalette.LandAnchors;
sb.AppendLine($"- **Palette anchors (FIXED across the batch):** land {landLo} .. {landHi}, sea compressed by d/(d+{ReliefPalette.SeaCompression})");
sb.AppendLine($"- **Seeds:** {string.Join(", ", seeds)}");
sb.AppendLine();
sb.AppendLine("## What to look at");