From 9af5c73b04eff4b4f44670edbdf8c2ddd7aff85c Mon Sep 17 00:00:00 2001 From: beezm Date: Wed, 19 Aug 2026 21:27:20 -0400 Subject: [PATCH] Phase 1: port the crown-jewel noise and pass-1, and render the island MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports MapGenerator.GenerateTopography's pass-1 loop (reference ~:553-619 at tag pre-rewrite-reference / ab78883) into Tools/. Working code and its tuned constants carried over verbatim, read from source rather than re-derived from a design summary (D-050). Six elements, in the reference's execution order — the order is load-bearing: the sinker lands before the Pow (superlinear), the Trench after it (a raw additive wall), preTrenchFalloff captured between them. The fractal config is now PINNED. The reference set only NoiseType/Seed/Frequency, so the island's entire fractal character was Godot 4.7.1 constructor defaults that nothing recorded. Measured on 4.7.2 (NoiseDefaultsProbe): Fbm / 5 / 0.5 / 2.0 / 0.0 — identical to the 4.7.1 values, so pinning reproduces the look with no delta. The terrain no longer depends on an engine default, and the probe makes a future drift visible instead of silent. Two deliberate departures from the reference's literals, both flagged in-code: - the latitude noise offset is expressed in map widths, not the reference's raw +1000 px, so a resize no longer samples a different slice of the noise field (chat1/00 §4.2). Pinned to 1000/8192 so the 8K realization is unchanged. - the `if (temperature < 0.65f)` spine gate is dropped as the provable no-op it is — southernFade already reaches exactly 0 at 0.65. Bit-identical. The reference's "temperature" is ported as a latitude field and deliberately NOT named temperature: it is stage-1-local input to terrain geometry, and climate is stage 3 (D-049 §2, D-056). The ±0.1 wobble is kept — it is what stops the spine's southern terminus being a ruler-straight line. Deferred to Phase 2 with the seam open: coast shelf and offshore islets (below-sea, judged once water renders). Pass1Result exposes PreTrenchFalloff at their exact consumption point, plus HMaxSeed for the seed-dependent curve. Render is a direct Godot.Image, not the SubViewport capture path — that machinery exists to composite vector overlays and is why --headless hangs; Phase 1 has none. Hypsometric above 0.15, bathymetric below, fixed ramp anchors so seeds and ablation rungs are comparable. No biome words anywhere. Verified: land fraction 51.5% identical at 1024, 2048 and 10240 — the scaling discipline holds across a 10x resize. Full-size 10240 pass: 36.3 s. No curve, erosion, rivers, water, crater, biomes, roads, or mesher. --- Tools/README.md | 73 +++++- Tools/Scenes/NoiseDefaultsProbe.tscn | 6 + Tools/Scenes/TerrainGenTool.tscn | 6 + Tools/Scripts/HeightMapRenderer.cs | 148 ++++++++++++ Tools/Scripts/HeightMapRenderer.cs.uid | 1 + Tools/Scripts/IslandFalloff.cs | 51 +++++ Tools/Scripts/IslandFalloff.cs.uid | 1 + Tools/Scripts/NoiseDefaultsProbe.cs | 86 +++++++ Tools/Scripts/NoiseDefaultsProbe.cs.uid | 1 + Tools/Scripts/Pass1Result.cs | 88 +++++++ Tools/Scripts/Pass1Result.cs.uid | 1 + Tools/Scripts/TerrainGenConfig.cs | 104 +++++++++ Tools/Scripts/TerrainGenConfig.cs.uid | 1 + Tools/Scripts/TerrainGenTool.cs | 208 +++++++++++++++++ Tools/Scripts/TerrainGenTool.cs.uid | 1 + Tools/Scripts/TerrainNoise.cs | 93 ++++++++ Tools/Scripts/TerrainNoise.cs.uid | 1 + Tools/Scripts/Topography.cs | 292 ++++++++++++++++++++++++ Tools/Scripts/Topography.cs.uid | 1 + 19 files changed, 1159 insertions(+), 4 deletions(-) create mode 100644 Tools/Scenes/NoiseDefaultsProbe.tscn create mode 100644 Tools/Scenes/TerrainGenTool.tscn create mode 100644 Tools/Scripts/HeightMapRenderer.cs create mode 100644 Tools/Scripts/HeightMapRenderer.cs.uid create mode 100644 Tools/Scripts/IslandFalloff.cs create mode 100644 Tools/Scripts/IslandFalloff.cs.uid create mode 100644 Tools/Scripts/NoiseDefaultsProbe.cs create mode 100644 Tools/Scripts/NoiseDefaultsProbe.cs.uid create mode 100644 Tools/Scripts/Pass1Result.cs create mode 100644 Tools/Scripts/Pass1Result.cs.uid create mode 100644 Tools/Scripts/TerrainGenConfig.cs create mode 100644 Tools/Scripts/TerrainGenConfig.cs.uid create mode 100644 Tools/Scripts/TerrainGenTool.cs create mode 100644 Tools/Scripts/TerrainGenTool.cs.uid create mode 100644 Tools/Scripts/TerrainNoise.cs create mode 100644 Tools/Scripts/TerrainNoise.cs.uid create mode 100644 Tools/Scripts/Topography.cs create mode 100644 Tools/Scripts/Topography.cs.uid diff --git a/Tools/README.md b/Tools/README.md index aa47116..0e7bc4a 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -17,11 +17,76 @@ nothing that consumes one at play time. > **Tools emit data; they never write to a live save.** A generator that could touch live server > state is a corruption risk for no benefit. -## Empty this phase — Phase 1 fills it +## What is here -The only thing here now is `Scenes/UserDirProbe.tscn` + `Scripts/UserDirProbe.cs`, a print-and-quit -diagnostic that confirms this project's `user://` directory is its own and not the old prototype's. -It generates nothing. +### The generator — pass 1 (Phase 1) + +**Ported from the reference's `MapGenerator.GenerateTopography` pass-1 loop** at tag +`pre-rewrite-reference` (`ab78883`). This is the crown-jewel port: **working code and its tuned +constants, carried over verbatim — not re-derived from a design summary** (→ D-050, +`Design - Rewrite - Extraction Manifest.md`). + +| File | What it is | +|---|---| +| `Scripts/TerrainNoise.cs` | ⭐ The FastNoiseLite config, **every fractal property pinned explicitly** | +| `Scripts/Topography.cs` | ⭐⭐ Pass 1 — the six elements, in the reference's execution order | +| `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/TerrainGenTool.cs` | Batch entry point (ladder + seed batch + `INDEX.md`) | +| `Scenes/TerrainGenTool.tscn` | Run this | + +```bash +Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ + --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/TerrainGenTool.tscn +``` + +`ISLA_MAPSIZE` (default 2048) · `ISLA_SEEDS` (comma-separated, positive) · `ISLA_BATCH` · +`ISLA_SKIP_RAW=1` · `ISLA_LADDER=0` + +**The six ported elements, in order** — the order is load-bearing, not an implementation detail: + +1. **wobbled latitude scalar** — `y/MapSize` plus a ±0.1 low-frequency wobble +2. **island falloff/mask** — squircle + ellipse, 50/50, then `Pow(·, 2.5f)` +3. **edge roughness** — `noise(x·2.5, y·2.5)`, modulated by the squircle so it bites only at the rim +4. **southern sinker** — bottom 25%, **before** the power (its effect is superlinear) +5. **the Trench** — map-anchored, additive on both axes, **after** the power +6. **mountain spine** — `SmoothAbs` crest, cubic, southern fade, amplitude `0.6f` + +> ### ⚠ The latitude scalar is NOT climate temperature. +> +> The reference called it `temperature` and let biomes read the same array — a large part of how +> climate and terrain got fused. Here it is a **stage-1-local latitude field that terrain geometry +> reads**, and nothing else. Climate is **stage 3** and classifies finished shape (D-049 §2, D-056). +> Do not alias, store, or rename this into a climate map. + +**Deferred to Phase 2, with the seam already open:** the submarine **coast shelf** and the +**offshore islets** (reference ~:621-664). Both act only below sea level and are judged once water +renders. `Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and +`Pass1Result.HMaxSeed` is carried for the seed-dependent redistribution curve. + +**Not here at all:** the curve, erosion, rivers, water bodies, the crater, biomes, roads, the mesher. + +### ⚠ The render writes a `Godot.Image` directly — not 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 exactly why unattended runs need `xvfb-run` and why `--headless` hangs on +it. + +**Phase 1 has no overlays.** A heightmap is a raster, so it is written per-pixel into an `Image` and +saved: no viewport, no frame awaits, no display. **Do not reintroduce the capture path by habit** — +it becomes correct the moment something vector needs compositing, and not before. + +### Diagnostics + +- `Scenes/UserDirProbe.tscn` — confirms this project's `user://` is its own, not the old + prototype's. Exits non-zero on a clash. +- `Scenes/NoiseDefaultsProbe.tscn` — prints this engine's `FastNoiseLite` constructor defaults and + checks them against the recorded baseline. **Run it after any Godot upgrade.** It cannot change + the terrain (`TerrainNoise` pins every value); it makes a drifting default *visible* instead of + silent. --- diff --git a/Tools/Scenes/NoiseDefaultsProbe.tscn b/Tools/Scenes/NoiseDefaultsProbe.tscn new file mode 100644 index 0000000..80e98da --- /dev/null +++ b/Tools/Scenes/NoiseDefaultsProbe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bqp1x2v3isla1"] + +[ext_resource type="Script" path="res://Tools/Scripts/NoiseDefaultsProbe.cs" id="1_ndp"] + +[node name="NoiseDefaultsProbe" type="Node"] +script = ExtResource("1_ndp") diff --git a/Tools/Scenes/TerrainGenTool.tscn b/Tools/Scenes/TerrainGenTool.tscn new file mode 100644 index 0000000..d4f0b27 --- /dev/null +++ b/Tools/Scenes/TerrainGenTool.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bqp1x2v3isla2"] + +[ext_resource type="Script" path="res://Tools/Scripts/TerrainGenTool.cs" id="1_tgt"] + +[node name="TerrainGenTool" type="Node"] +script = ExtResource("1_tgt") diff --git a/Tools/Scripts/HeightMapRenderer.cs b/Tools/Scripts/HeightMapRenderer.cs new file mode 100644 index 0000000..d7c3d63 --- /dev/null +++ b/Tools/Scripts/HeightMapRenderer.cs @@ -0,0 +1,148 @@ +using Godot; + +namespace IslaApocalypse.Tools +{ + /// + /// 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. + /// + public static class HeightMapRenderer + { + /// + /// 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. + /// + public const float RampTopHeight = 1.45f; + + /// The deepest height the bathymetric ramp resolves. Below this, flat abyss colour. + 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 + }; + + /// Render and save. Returns the absolute path written. + 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; + } + + /// + /// 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". + /// + 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; + } + } +} diff --git a/Tools/Scripts/HeightMapRenderer.cs.uid b/Tools/Scripts/HeightMapRenderer.cs.uid new file mode 100644 index 0000000..088495a --- /dev/null +++ b/Tools/Scripts/HeightMapRenderer.cs.uid @@ -0,0 +1 @@ +uid://tufk8dg4g14g diff --git a/Tools/Scripts/IslandFalloff.cs b/Tools/Scripts/IslandFalloff.cs new file mode 100644 index 0000000..6afc47c --- /dev/null +++ b/Tools/Scripts/IslandFalloff.cs @@ -0,0 +1,51 @@ +using System; + +namespace IslaApocalypse.Tools +{ + /// + /// Shape helpers for the island mask, ported from the reference's + /// Tools/Scripts/IslandFalloff.cs. + /// + /// ⚠ ONLY THE PASS-1 PARTS ARE HERE. The reference file also carries the submarine COAST SHELF + /// (SHELF_STRENGTH / SHELF_SCALE_M / CoastShelf) and the OFFSHORE ISLET layer (OffshoreBlob, + /// OffshoreZoneWeight, CalibrateThreshold, and their constants). Both are DEFERRED to Phase 2 — + /// they act on below-sea height and are judged once water renders. They will port into THIS + /// file, which is why it keeps the reference's name and shape. + /// + /// This type is pure math and engine-free. It sits in Tools/ rather than Core/ so the pass-1 + /// port stays auditable as one unit against one reference file, and so the deferred Phase-2 + /// halves land beside their siblings rather than in a second location. + /// + public static class IslandFalloff + { + /// + /// The rounding width of the spine crest, in normalized axis-distance units. + /// READ from the reference: IslandFalloff.CREST_EPSILON = 0.03f (~:34). + /// + public const float CREST_EPSILON = 0.03f; + + /// + /// A smooth absolute value: zero AT zero, with zero slope there, converging to |d| away from it. + /// READ from the reference (~:42-46), verbatim: + /// a = |d|; return a*a / sqrt(a*a + eps*eps); + /// + /// ═══ WHY IT EXISTS — do not "simplify" it back to Abs ═══ + /// + /// The spine's ridge axis is the line x = centre, and a plain 1 - |x - cx| peaks there + /// with a SLOPE DISCONTINUITY. On real terrain that put the four columns at the centre axis + /// in the top four slope-step locations out of 5,999 — a visible crease running the whole + /// height of the island, measured at ~170x the off-axis controls. It was blamed on the + /// falloff blend for a long time; transect measurement ruled that out and found this. + /// + /// SmoothAbs(0) = 0, so the crest keeps its FULL HEIGHT — it rounds without dropping. + /// Measured on the reference: the 1-px kink fell 99.3%, the centre column dropped from + /// rank 1 of 5,999 to rank 1,364, and nothing anywhere was lowered. + /// → `Design - Terrain - Mountain Spine.md`. + /// + public static float SmoothAbs(float d, float epsilon) + { + float a = Math.Abs(d); + return a * a / MathF.Sqrt(a * a + epsilon * epsilon); + } + } +} diff --git a/Tools/Scripts/IslandFalloff.cs.uid b/Tools/Scripts/IslandFalloff.cs.uid new file mode 100644 index 0000000..2d02a5b --- /dev/null +++ b/Tools/Scripts/IslandFalloff.cs.uid @@ -0,0 +1 @@ +uid://k4rjv4ck6l6j diff --git a/Tools/Scripts/NoiseDefaultsProbe.cs b/Tools/Scripts/NoiseDefaultsProbe.cs new file mode 100644 index 0000000..0d126d0 --- /dev/null +++ b/Tools/Scripts/NoiseDefaultsProbe.cs @@ -0,0 +1,86 @@ +using Godot; + +namespace IslaApocalypse.Tools +{ + /// + /// A print-and-quit diagnostic: reports this engine build's + /// CONSTRUCTOR DEFAULTS, and checks them against the values the ported terrain expects. + /// + /// ═══ WHY THIS EXISTS ═══ + /// + /// The reference prototype set only three noise properties — NoiseType, Seed, Frequency — and + /// left fractal type, octaves, gain, lacunarity and weighted-strength untouched. So the island's + /// entire fractal character was Godot 4.7.1's CONSTRUCTOR DEFAULTS, not code. Nothing in that + /// repo recorded what they were. (chat1/00 §4.1.) + /// + /// now pins every one of them explicitly, so the terrain no longer + /// depends on an engine default. This probe is the other half of that guarantee: it makes a + /// silent default change VISIBLE on an engine upgrade instead of quietly reshaping the island. + /// + /// Run it after any Godot version bump: + /// + /// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ + /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/NoiseDefaultsProbe.tscn + /// + /// Exits non-zero if this engine's defaults have drifted from the recorded baseline — so it + /// works as a pre-flight check, not just by eye. A non-zero exit is NOT "the terrain is broken": + /// the terrain is pinned and fine. It means the baseline note below needs updating, and that + /// anyone reading old code which relied on defaults should be warned. + /// + public partial class NoiseDefaultsProbe : Node + { + // The defaults measured on Godot 4.7.2.stable.mono (this project's pinned engine), which + // match the Godot 4 documented defaults the reference implicitly rode on 4.7.1. + private const FastNoiseLite.FractalTypeEnum BaselineFractalType = FastNoiseLite.FractalTypeEnum.Fbm; + private const int BaselineOctaves = 5; + private const float BaselineGain = 0.5f; + private const float BaselineLacunarity = 2.0f; + private const float BaselineWeightedStrength = 0.0f; + + public override void _Ready() + { + var n = new FastNoiseLite(); + + GD.Print("=================================================================="); + GD.Print(" FastNoiseLite CONSTRUCTOR DEFAULTS — this engine build"); + GD.Print("=================================================================="); + GD.Print($"engine : {Engine.GetVersionInfo()["string"]}"); + GD.Print("------------------------------------------------------------------"); + GD.Print($"NoiseType : {n.NoiseType}"); + GD.Print($"Frequency : {n.Frequency}"); + GD.Print($"FractalType : {n.FractalType}"); + GD.Print($"FractalOctaves : {n.FractalOctaves}"); + GD.Print($"FractalGain : {n.FractalGain}"); + GD.Print($"FractalLacunarity : {n.FractalLacunarity}"); + GD.Print($"FractalWeightedStrength : {n.FractalWeightedStrength}"); + GD.Print($"Seed : {n.Seed}"); + GD.Print("------------------------------------------------------------------"); + GD.Print(" DRIFT CHECK vs the recorded 4.7.2 baseline"); + GD.Print("------------------------------------------------------------------"); + + bool drift = false; + drift |= Check("FractalType", n.FractalType.ToString(), BaselineFractalType.ToString()); + drift |= Check("FractalOctaves", n.FractalOctaves.ToString(), BaselineOctaves.ToString()); + drift |= Check("FractalGain", n.FractalGain.ToString(), BaselineGain.ToString()); + drift |= Check("FractalLacunarity", n.FractalLacunarity.ToString(), BaselineLacunarity.ToString()); + drift |= Check("FractalWeightedStrength", n.FractalWeightedStrength.ToString(), BaselineWeightedStrength.ToString()); + + GD.Print("------------------------------------------------------------------"); + GD.Print(drift + ? " ⚠ DRIFT — this engine's defaults differ from the recorded baseline.\n" + + " The PORTED TERRAIN IS UNAFFECTED: TerrainNoise pins every value explicitly.\n" + + " Update the baseline constants in this probe, and note the delta." + : " ✓ NO DRIFT — defaults match the recorded baseline."); + GD.Print("=================================================================="); + + GetTree().Quit(drift ? 1 : 0); + } + + private static bool Check(string name, string actual, string expected) + { + bool differs = actual != expected; + GD.Print($" {(differs ? "✗" : "✓")} {name,-24} actual={actual,-12} baseline={expected}"); + return differs; + } + } +} diff --git a/Tools/Scripts/NoiseDefaultsProbe.cs.uid b/Tools/Scripts/NoiseDefaultsProbe.cs.uid new file mode 100644 index 0000000..caa8cdc --- /dev/null +++ b/Tools/Scripts/NoiseDefaultsProbe.cs.uid @@ -0,0 +1 @@ +uid://b7vf6kp8041ie diff --git a/Tools/Scripts/Pass1Result.cs b/Tools/Scripts/Pass1Result.cs new file mode 100644 index 0000000..941114b --- /dev/null +++ b/Tools/Scripts/Pass1Result.cs @@ -0,0 +1,88 @@ +namespace IslaApocalypse.Tools +{ + /// + /// Everything pass 1 produces. → . + /// + /// ⭐ THREE OF THESE FIELDS ARE THE PHASE-2 SEAM. The reference computed them in pass 1 and + /// consumed them in pass 2; exposing them now is what keeps the deferred ports clean, and it + /// costs nothing to carry. + /// + public sealed class Pass1Result + { + /// Map side in columns. + public readonly int MapSize; + + /// The resolved seed this was generated with. Always positive, always recorded. + public readonly int Seed; + + /// + /// The raw pass-1 height field, [x, y]. Pre-curve, pre-erosion, pre-crater. + /// The reference's finalH (~:619) written to _heightMap (~:666). + /// + public readonly float[,] Height; + + /// + /// ⭐ PHASE-2 SEAM — the falloff value captured BEFORE the Pow(·, 2.5f) and BEFORE the + /// Trench wall (reference ~:591, preTrenchFalloff). + /// + /// It is the "how far past the island body are we" number, uncontaminated by the power's + /// curvature and by the Trench's additive ramp. The DEFERRED offshore-islet layer reads it + /// as its distance mask (OffshoreZoneWeight), which is the only reason it is captured + /// mid-computation rather than recomputed. + /// + /// ⚠ A port that "tidies" the falloff chain into one expression destroys this capture point, + /// and the loss would not show up until Phase 2's offshore mask moved. It is exposed here so + /// that cannot happen quietly. + /// + public readonly float[,] PreTrenchFalloff; + + /// + /// ⭐ PHASE-2 SEAM — the wobbled latitude scalar, [x, y]. + /// + /// ⚠ THIS IS NOT A CLIMATE TEMPERATURE MAP. See for the full note. + /// It is a stage-1-local latitude field that terrain GEOMETRY reads. Phase 2 needs it if the + /// latitude sea-level model is ever switched on (SeaLevelModel = "field"); under the + /// shipped flat model it has no consumer beyond the spine. + /// + public readonly float[,] LatitudeField; + + /// + /// ⭐ PHASE-2 SEAM — the maximum height over the whole map (reference _hMaxSeed, ~:665). + /// + /// The reference's v2+ redistribution curve is SEED-DEPENDENT: its spike maps + /// [t4, hMaxSeed] onto the peak band, so pass 2 cannot start until pass 1 has scanned + /// every pixel. That is precisely why the pass-1/pass-2 boundary is a hard one and not an + /// interleave. Carried here so Phase 2 inherits it rather than rediscovering the dependency. + /// + public readonly float HMaxSeed; + + /// The minimum height. Not a reference field — carried for the renderer's ramp and the report. + public readonly float HMinSeed; + + /// Wall-clock milliseconds the pass took. + public readonly ulong ElapsedMs; + + public Pass1Result(int mapSize, int seed, float[,] height, float[,] preTrenchFalloff, + float[,] latitudeField, float hMaxSeed, float hMinSeed, ulong elapsedMs) + { + MapSize = mapSize; + Seed = seed; + Height = height; + PreTrenchFalloff = preTrenchFalloff; + LatitudeField = latitudeField; + HMaxSeed = hMaxSeed; + HMinSeed = hMinSeed; + ElapsedMs = elapsedMs; + } + + /// Fraction of the map at or above the sea threshold. A cheap shape sanity number. + public float LandFraction(float seaLevel) + { + long land = 0; + for (int x = 0; x < MapSize; x++) + for (int y = 0; y < MapSize; y++) + if (Height[x, y] >= seaLevel) land++; + return land / (float)((long)MapSize * MapSize); + } + } +} diff --git a/Tools/Scripts/Pass1Result.cs.uid b/Tools/Scripts/Pass1Result.cs.uid new file mode 100644 index 0000000..df97667 --- /dev/null +++ b/Tools/Scripts/Pass1Result.cs.uid @@ -0,0 +1 @@ +uid://dxtoqjudaokbe diff --git a/Tools/Scripts/TerrainGenConfig.cs b/Tools/Scripts/TerrainGenConfig.cs new file mode 100644 index 0000000..b5f3313 --- /dev/null +++ b/Tools/Scripts/TerrainGenConfig.cs @@ -0,0 +1,104 @@ +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// The generator's configuration, including the per-element ABLATION TOGGLES. + /// + /// ═══ WHY EVERY PASS-1 ELEMENT IS GATED ═══ + /// + /// This is the standing A/B discipline: every shaping change ships behind a gate with the + /// previous behaviour surviving on the other side, so changes stay comparable as a pair rather + /// than as a memory of last week's render, and a rejected change costs a flipped default rather + /// than a reverted commit. → `Design - Tooling - Iteration and Batching.md`. + /// + /// For THIS task the gates do a second job: they are the PORT-FIDELITY CHECK. Generating + /// base-noise-only, then adding one element at a time, shows each ported element doing what the + /// reference's did — rather than judging six simultaneous changes by their sum. + /// + /// ⚠ Lives in Tools/, not Core/. It configures the generator specifically; Core carries the + /// world's data contracts and the scaling rule, not a tool's dials. + /// + public sealed class TerrainGenConfig + { + // ---- world ---------------------------------------------------------- + + /// + /// Map side in columns. A GENERATION PARAMETER — never baked in. + /// Iteration runs small (2048–4096); the shape is scale-invariant by construction, so the + /// island reads the same at any size and a full-size pass is confirmation, not iteration. + /// + public int MapSize = 2048; + + /// + /// The noise seed. POSITIVE ONLY. Zero or negative means "pick one and print it" — a run + /// whose seed is not recorded is a run that cannot be reproduced, so the resolved seed is + /// always printed and always in the filename. + /// + public int Seed = 0; + + // ---- island shape (reference ConfigManager defaults, READ from source) ---- + + /// Falloff X axis ratio. Reference: ConfigManager.LEGACY_AXIS_X = 1.15f. + public float IslandAxisX = 1.15f; + + /// Falloff Y axis ratio. Reference: ConfigManager.LEGACY_AXIS_Y = 0.90f. + public float IslandAxisY = 0.90f; + + /// + /// Multiplier on the falloff term in the combine. + /// Reference: [Export] public float FalloffStrength = 1.0f; (MapGenerator ~:11), + /// and NOT overridden in MapPreview.tscn — so 1.0f is the value the island was tuned at. + /// + public float FalloffStrength = 1.0f; + + /// + /// The flat sea level, in raw height units. Reference: ConfigManager.SeaLevelValue = 0.15f + /// with SeaLevelModel = "flat", so GetSeaLevel ignores latitude entirely. + /// + /// ⚠ PHASE 1 USES THIS AS A VISUALIZATION THRESHOLD ONLY — the boundary between the + /// bathymetric and hypsometric colour ramps. No water is modelled, no water bodies are + /// identified, nothing floods. That is Phase 2. + /// + public float SeaLevel = 0.15f; + + // ---- ABLATION TOGGLES — the pass-1 ladder --------------------------- + + /// Rung 1: the base terrain noise, (noise(x,y)+1)/2. Off = flat zero. + public bool BaseNoise = true; + + /// + /// Rung 2: the island falloff/mask — squircle + ellipse blend, and the Pow(·, 2.5f). + /// ⚠ Off also disables rungs 3–5 in effect: edge noise, the sinker and the Trench are all + /// modifiers OF the falloff, so with no falloff there is nothing for them to modify. The + /// toggles stay independent so the ladder reads honestly; the report says so. + /// + public bool IslandFalloff = true; + + /// Rung 3: coastline edge roughness, modulated by the squircle. + public bool EdgeNoise = true; + + /// Rung 4: the southern sinker — extra sinking pressure in the bottom 25%. + public bool SouthernSinker = true; + + /// Rung 5: the Trench — the map-anchored outer-band wall that guarantees an ocean border. + public bool Trench = true; + + /// Rung 6: the mountain spine up the centre-X axis. + public bool MountainSpine = true; + + /// A short label for this variant, used in output filenames. E.g. "full", "base_only". + public string VariantLabel = "full"; + + /// The scale object every distance and frequency in the generator derives from. + public GenerationScale Scale => new GenerationScale(MapSize); + + public TerrainGenConfig Clone() => (TerrainGenConfig)MemberwiseClone(); + + public override string ToString() => + $"MapSize={MapSize} Seed={Seed} axis={IslandAxisX:F2}x/{IslandAxisY:F2}y " + + $"falloffStrength={FalloffStrength:F2} sea={SeaLevel:F2} variant={VariantLabel} " + + $"[base={BaseNoise} falloff={IslandFalloff} edge={EdgeNoise} sinker={SouthernSinker} " + + $"trench={Trench} spine={MountainSpine}]"; + } +} diff --git a/Tools/Scripts/TerrainGenConfig.cs.uid b/Tools/Scripts/TerrainGenConfig.cs.uid new file mode 100644 index 0000000..6e86a24 --- /dev/null +++ b/Tools/Scripts/TerrainGenConfig.cs.uid @@ -0,0 +1 @@ +uid://da5dt7r145j67 diff --git a/Tools/Scripts/TerrainGenTool.cs b/Tools/Scripts/TerrainGenTool.cs new file mode 100644 index 0000000..c5c4d67 --- /dev/null +++ b/Tools/Scripts/TerrainGenTool.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// The Phase-1 generation entry point: runs a seed batch plus the ablation ladder, writes + /// hypsometric PNGs and raw height dumps into the batch layout, and quits. + /// + /// ═══ RUNNING IT ═══ + /// + /// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ + /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/TerrainGenTool.tscn + /// + /// ⚠ `--headless` is correct HERE and will stay correct as long as the renderer writes a + /// Godot.Image directly. It awaits no render frames. The moment anything composites a `_Draw` + /// overlay through a SubViewport, this becomes an `xvfb-run` job — see Tools/README.md. + /// + /// ═══ CONFIGURATION (all optional; every path is env-overridable per the tooling rails) ═══ + /// + /// 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_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) + /// + public partial class TerrainGenTool : Node + { + /// + /// PINNED POSITIVE SEEDS. Pinned, not random, so a batch is reproducible and two batches are + /// comparable — a standing convention. Positive only. + /// + private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 }; + + private const int DefaultMapSize = 2048; + + public override void _Ready() + { + ToolingPaths.Configure(OS.GetUserDataDir()); + + int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize); + int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); + string batch = EnvStr("ISLA_BATCH", "01_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); + string scratch = ToolingPaths.BatchScratch(batchRoot); + DirAccess.MakeDirRecursiveAbsolute(batchRoot); + DirAccess.MakeDirRecursiveAbsolute(scratch); // persistent; never cleaned + + GD.Print("=================================================================="); + GD.Print(" PASS-1 TERRAIN GENERATION — the crown-jewel port (Phase 1)"); + GD.Print("=================================================================="); + GD.Print($"MapSize : {mapSize} (scaleFactor {scale.ScaleFactor:F3})"); + GD.Print($"noise : {TerrainNoise.Describe(0, scale).Replace(" · seed 0", "")}"); + GD.Print($"seeds : {string.Join(", ", seeds)}"); + GD.Print($"batch : {batchRoot}"); + GD.Print("------------------------------------------------------------------"); + GD.Print(ToolingPaths.Describe()); + GD.Print("=================================================================="); + + var rows = new List(); + + // ═══ 1. THE ABLATION LADDER — first seed only, one element added per rung ═══ + // + // This is the port-fidelity check: six simultaneous changes cannot be judged by their + // sum, so each element is switched on in the reference's own order and looked at alone. + if (ladder) + { + GD.Print("\n--- ABLATION LADDER (seed " + seeds[0] + ") ---"); + foreach (var (label, mutate) in Ladder()) + { + var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seeds[0], VariantLabel = label }; + mutate(cfg); + rows.Add(RunOne(cfg, batchRoot, skipRaw)); + } + } + else GD.Print("\n--- ablation ladder skipped (ISLA_LADDER=0) ---"); + + // ═══ 2. THE SEED BATCH — the full six-element config across pinned seeds ═══ + GD.Print("\n--- SEED BATCH (full pass-1) ---"); + foreach (int seed in seeds) + { + var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" }; + rows.Add(RunOne(cfg, batchRoot, skipRaw)); + } + + WriteIndex(batchRoot, mapSize, scale, seeds, rows); + + GD.Print("\n=================================================================="); + GD.Print($" DONE — {rows.Count} maps in {batchRoot}"); + GD.Print("=================================================================="); + GetTree().Quit(0); + } + + /// + /// The ablation rungs, in the reference's execution order. Each rung ADDS one element to the + /// one before it, so a difference between adjacent images is attributable to exactly one + /// ported element. + /// + private static List<(string, Action)> Ladder() => new() + { + ("ab1_base_only", c => { c.IslandFalloff = false; c.EdgeNoise = false; c.SouthernSinker = false; c.Trench = false; c.MountainSpine = false; }), + ("ab2_falloff", c => { c.EdgeNoise = false; c.SouthernSinker = false; c.Trench = false; c.MountainSpine = false; }), + ("ab3_edge", c => { c.SouthernSinker = false; c.Trench = false; c.MountainSpine = false; }), + ("ab4_sinker", c => { c.Trench = false; c.MountainSpine = false; }), + ("ab5_trench", c => { c.MountainSpine = false; }), + ("ab6_spine_full", c => { /* everything on — identical to the "full" variant */ }), + }; + + private static string RunOne(TerrainGenConfig cfg, string batchRoot, bool skipRaw) + { + Pass1Result r = Topography.Generate(cfg); + + // batches/NN_/_/ + 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")); + + float land = r.LandFraction(cfg.SeaLevel); + GD.Print($" {cfg.VariantLabel,-16} seed {cfg.Seed,-11} " + + $"h[{r.HMinSeed,7:F3} .. {r.HMaxSeed,6:F3}] land {land * 100,5:F1}% {r.ElapsedMs,5} ms"); + + return $"| `{cfg.Seed}_{cfg.VariantLabel}` | {cfg.Seed} | {cfg.VariantLabel} | " + + $"{r.HMinSeed:F3} | {r.HMaxSeed:F3} | {land * 100:F1}% | {r.ElapsedMs} ms |"; + } + + private static void WriteIndex(string batchRoot, int mapSize, GenerationScale scale, int[] seeds, List rows) + { + var sb = new StringBuilder(); + sb.AppendLine("# Batch — pass-1 port (Phase 1)"); + sb.AppendLine(); + sb.AppendLine("Raw pass-1 terrain from the ported crown-jewel noise. **No curve, no erosion, no"); + sb.AppendLine("rivers, no water, no crater, no biomes.** Each folder holds `height.png` (hypsometric)"); + sb.AppendLine("and `height.f32` (raw float32, x-major, for byte-level comparison)."); + sb.AppendLine(); + 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}"); + sb.AppendLine($"- **Seeds:** {string.Join(", ", seeds)}"); + sb.AppendLine(); + sb.AppendLine("## What to look at"); + sb.AppendLine(); + sb.AppendLine("**The ablation ladder** (`ab1`..`ab6`, all on seed " + seeds[0] + ") adds one ported element per"); + sb.AppendLine("rung, in the reference's execution order — so any difference between adjacent images is"); + sb.AppendLine("attributable to exactly one element:"); + sb.AppendLine(); + sb.AppendLine("| Rung | Adds | Expect to see |"); + sb.AppendLine("|---|---|---|"); + sb.AppendLine("| `ab1_base_only` | base noise | featureless fractal field, no island, no coast |"); + sb.AppendLine("| `ab2_falloff` | island mask + `Pow(·,2.5)` | an island appears, smooth-edged |"); + sb.AppendLine("| `ab3_edge` | edge roughness | the coastline goes jagged — **and only the coastline** |"); + sb.AppendLine("| `ab4_sinker` | southern sinker | the bottom 25% sinks; southern land bridges break up |"); + sb.AppendLine("| `ab5_trench` | the Trench | a hard ocean border on all four edges |"); + sb.AppendLine("| `ab6_spine_full` | mountain spine | a ridge up the centre, fading out toward the south |"); + sb.AppendLine(); + sb.AppendLine("**The seed batch** (`_full`) is the complete six-element pass-1 across pinned seeds."); + sb.AppendLine(); + sb.AppendLine("> ⚠ **The seabed reads raw, and that is expected.** The submarine coast shelf and the"); + sb.AppendLine("> offshore islets are DEFERRED to Phase 2 — they act only below sea level and are judged"); + sb.AppendLine("> once water renders. Steep, plain bathymetry here is not a bug."); + sb.AppendLine(); + sb.AppendLine("## Results"); + sb.AppendLine(); + sb.AppendLine("| Folder | Seed | Variant | h min | h max | land % | time |"); + sb.AppendLine("|---|---|---|---|---|---|---|"); + foreach (string row in rows) sb.AppendLine(row); + sb.AppendLine(); + sb.AppendLine("`scratch/` is persistent and is never cleaned."); + + string index = Path.Combine(batchRoot, "INDEX.md"); + using var f = Godot.FileAccess.Open(index, Godot.FileAccess.ModeFlags.Write); + if (f == null) { GD.PrintErr($"could not write {index}"); return; } + f.StoreString(sb.ToString()); + } + + // ---- env helpers ---------------------------------------------------- + + 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 outp = new List(); + foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s); + return outp.Count > 0 ? outp.ToArray() : fallback; + } + } +} diff --git a/Tools/Scripts/TerrainGenTool.cs.uid b/Tools/Scripts/TerrainGenTool.cs.uid new file mode 100644 index 0000000..e10d225 --- /dev/null +++ b/Tools/Scripts/TerrainGenTool.cs.uid @@ -0,0 +1 @@ +uid://e2oef4cf0vsl diff --git a/Tools/Scripts/TerrainNoise.cs b/Tools/Scripts/TerrainNoise.cs new file mode 100644 index 0000000..b1ee0f2 --- /dev/null +++ b/Tools/Scripts/TerrainNoise.cs @@ -0,0 +1,93 @@ +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// ⭐⭐ THE CROWN JEWEL — the terrain noise configuration, ported and PINNED. + /// → D-050 ("port, don't re-derive"), `Design - Rewrite - Extraction Manifest.md`. + /// + /// ═══ WHAT THE REFERENCE ACTUALLY SET, AND WHAT IT DID NOT ═══ + /// + /// The reference (MapGenerator._Ready ~:141-144) set exactly THREE properties: + /// + /// _noise = new FastNoiseLite(); + /// _noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed; + /// _noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; + /// _noise.Frequency = 0.004f / scaleFactor; + /// + /// That is all four lines. There is no fifth. Fractal type, octaves, gain, lacunarity and + /// weighted-strength were NEVER SET anywhere in that codebase — so the island's entire fractal + /// character was Godot 4.7.1's `FastNoiseLite` CONSTRUCTOR DEFAULTS, and nothing recorded what + /// they were. (chat1/00 §2.1, §4.1.) + /// + /// ═══ ⚠⚠ WHY EVERY VALUE IS PINNED EXPLICITLY BELOW ═══ + /// + /// Because a default is not a decision, and this project no longer rides one. The terrain must + /// not change when the engine version changes, and it must not change when this code is ported + /// off Godot's FastNoiseLite to a C++ or hand-rolled implementation — which D-049 explicitly + /// anticipates ("C++-ready seams... port path-by-path when settled and hot"). An unpinned + /// default would move the island silently, and nothing in the source would say what moved. + /// + /// Measured on Godot 4.7.2.stable.mono (Tools/Scenes/NoiseDefaultsProbe.tscn): + /// FractalType Fbm · Octaves 5 · Gain 0.5 · Lacunarity 2.0 · WeightedStrength 0.0 + /// These MATCH the Godot 4 documented defaults the reference rode on 4.7.1 — so pinning them + /// reproduces the reference look exactly, with no delta to reconcile. + /// + /// ⚠ Note NoiseType is the one place the reference did NOT ride the default: the constructor + /// default is SimplexSmooth, and the reference explicitly chose Simplex. Different noise. + /// + /// Run NoiseDefaultsProbe after any engine upgrade. It cannot change the terrain — this file + /// pins it — but it makes a drifting default visible instead of silent. + /// + public static class TerrainNoise + { + // ---- the pinned fractal character (was 4.7.1 constructor defaults) ---- + public const FastNoiseLite.NoiseTypeEnum PinnedNoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; + public const FastNoiseLite.FractalTypeEnum PinnedFractalType = FastNoiseLite.FractalTypeEnum.Fbm; + public const int PinnedOctaves = 5; + public const float PinnedGain = 0.5f; + public const float PinnedLacunarity = 2.0f; + public const float PinnedWeightedStrength = 0.0f; + + /// + /// The base frequency, stated at the 1024 baseline. The reference wrote + /// 0.004f / scaleFactor; here the division is 's + /// job, so the literal never reaches a call site. → `Design - Tooling - Scaling Discipline.md`. + /// + public const float BaselineFrequency = 0.004f; + + /// + /// Build the one terrain noise field. ⚠ ONE INSTANCE, deliberately. + /// + /// The reference sampled the latitude wobble, the coastline edge roughness and the base + /// height from the SAME `_noise` object — three correlated slices of one field, at three + /// different coordinate transforms. That correlation is part of the look, so the port keeps + /// one instance rather than "tidying" it into three seeded fields. + /// + public static FastNoiseLite Create(int seed, GenerationScale scale) + { + var noise = new FastNoiseLite(); + + // --- ported verbatim from the reference --- + noise.Seed = seed; + noise.NoiseType = PinnedNoiseType; + noise.Frequency = scale.NoiseFrequency(BaselineFrequency); // = 0.004f / scaleFactor + + // --- PINNED: the reference left these to the engine. We do not. --- + noise.FractalType = PinnedFractalType; + noise.FractalOctaves = PinnedOctaves; + noise.FractalGain = PinnedGain; + noise.FractalLacunarity = PinnedLacunarity; + noise.FractalWeightedStrength = PinnedWeightedStrength; + + return noise; + } + + /// The pinned configuration as one line, for a run header. + public static string Describe(int seed, GenerationScale scale) => + $"Simplex · Fbm · octaves {PinnedOctaves} · gain {PinnedGain} · lacunarity {PinnedLacunarity} · " + + $"weighted {PinnedWeightedStrength} · freq {scale.NoiseFrequency(BaselineFrequency):G6} " + + $"(= {BaselineFrequency} / {scale.ScaleFactor:F3}) · seed {seed}"; + } +} diff --git a/Tools/Scripts/TerrainNoise.cs.uid b/Tools/Scripts/TerrainNoise.cs.uid new file mode 100644 index 0000000..ddf3973 --- /dev/null +++ b/Tools/Scripts/TerrainNoise.cs.uid @@ -0,0 +1 @@ +uid://bvyldfs65ouuh diff --git a/Tools/Scripts/Topography.cs b/Tools/Scripts/Topography.cs new file mode 100644 index 0000000..19c58c9 --- /dev/null +++ b/Tools/Scripts/Topography.cs @@ -0,0 +1,292 @@ +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// ⭐⭐ PASS 1 — the raw island height field. THE CROWN-JEWEL PORT. + /// → D-050 ("port, don't re-derive"), `Design - Rewrite - Extraction Manifest.md`. + /// + /// Ported from the reference's MapGenerator.GenerateTopography pass-1 loop + /// (Tools/Scripts/MapGenerator.cs ~:553-619 at tag pre-rewrite-reference, + /// commit ab78883). Constants are READ FROM THAT SOURCE, not re-derived from a design + /// summary — that distinction is the whole point of D-050. + /// + /// ═══ THE SIX ELEMENTS, IN THE REFERENCE'S EXECUTION ORDER ═══ + /// + /// 1. wobbled latitude scalar (ref ~:559-562) + /// 2. island falloff / mask (ref ~:567-574, 593) + /// 3. edge / coastline roughness (ref ~:577-578) + /// 4. southern sinker (ref ~:582-587) + /// 5. the Trench (ref ~:595-598) + /// 6. mountain spine (ref ~:605-612) + /// → combine and write (ref ~:618-619, 665-666) + /// + /// ⚠⚠ THE ORDER IS LOAD-BEARING AND IS NOT AN IMPLEMENTATION DETAIL. + /// The sinker lands BEFORE the power, so its effect is superlinear. The Trench lands AFTER it, + /// so it is a raw additive wall the exponent never softens. preTrenchFalloff is captured + /// between them. Reordering any of it changes the island. + /// + /// ═══ ⚠ WHAT IS DELIBERATELY NOT PORTED HERE ═══ + /// + /// The reference's pass-1 loop continues past the height write with two more task-11 passes: + /// the submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). Both are + /// DEFERRED to Phase 2 by the developer's ruling — they act only on below-sea height and are + /// judged once water renders. is exposed for them. + /// + /// Nothing from pass 2 is here at all: no redistribution curve, no shelf detail, no erosion, + /// no rivers, no water bodies, no crater carve, no biomes. + /// + public static class Topography + { + // ═══ CONSTANTS, ALL READ FROM THE REFERENCE SOURCE ═══ + // Named rather than inlined so each one is greppable and has a home for its reason. + + /// The squircle/ellipse blend weight. Reference: Mathf.Lerp(ellipse, squircle, 0.5f) (~:574). + private const float BlendSquircleWeight = 0.5f; + + /// + /// The ellipse's scale divisor: ellipticalPos.Length() / (MapSize / 1.3f) (~:572). + /// + /// ⚠ AN UNCOMMENTED SHAPE DIAL IN THE REFERENCE, PORTED AS ONE. It sets the ellipse's + /// absolute scale against the squircle's, so it moves the coastline — but the reference + /// carried no comment and the vault's "50/50 blend of two falloffs" description does not + /// expose it. Ported verbatim because the island was tuned with it; named here so the next + /// reader knows it is a dial and not arithmetic. (chat1/00 §2.2.) + /// + private const float EllipseScaleDivisor = 1.3f; + + /// Edge-noise coordinate multiplier. Reference: GetNoise2D(x * 2.5f, y * 2.5f) (~:577). + private const float EdgeNoiseCoordScale = 2.5f; + + /// Edge-noise amplitude. Reference: finalFalloff += (edgeNoise * 0.15f) * squircleFalloff (~:578). + private const float EdgeNoiseAmplitude = 0.15f; + + /// Southern sinker threshold, as a fraction of the map. Reference: MapSize * 0.75f (~:582). + private const float SouthThresholdFraction = 0.75f; + + /// Southern sinker depth. Reference: finalFalloff += southDepth * 0.6f (~:586). + private const float SouthSinkAmount = 0.6f; + + /// The falloff exponent. Reference: Mathf.Pow(finalFalloff, 2.5f) (~:593). + private const float FalloffExponent = 2.5f; + + /// Trench onset, as a fraction of the half-axis. Reference: if (distX > 0.90f) (~:597). + private const float TrenchOnset = 0.90f; + + /// Trench slope. Reference: finalFalloff += (distX - 0.90f) * 15.0f (~:597-598). + private const float TrenchSlope = 15.0f; + + /// Spine gate / fade anchor. Reference: if (temperature < 0.65f) and (0.65f - temperature) (~:606, 610). + private const float SpineLatitudeAnchor = 0.65f; + + /// Spine southern-fade slope. Reference: Clamp((0.65f - temperature) * 4.0f, 0, 1) (~:610). + private const float SpineFadeSlope = 4.0f; + + /// Spine cubic concentration. Reference: Mathf.Pow(mountainSpine, 3.0f) (~:611). + private const float SpineConcentration = 3.0f; + + /// Spine amplitude in raw height units. Reference: * 0.6f (~:611). + private const float SpineAmplitude = 0.6f; + + /// Latitude wobble amplitude. Reference: temperature += (tempNoise * 0.2f) - 0.1f (~:561) — i.e. ±0.1. + private const float LatitudeWobbleSpan = 0.2f; + + /// + /// The latitude noise's decorrelation offset, IN MAP WIDTHS. + /// + /// ⚠⚠ THE ONE PLACE THIS PORT DELIBERATELY DOES NOT COPY THE REFERENCE'S LITERAL. + /// + /// The reference wrote GetNoise2D(x + 1000, y + 1000) — an offset in RAW PIXELS. + /// Because frequency already carries a 1/MapSize normalization, a pixel offset does not hold + /// still across map sizes: 1000 px is 1.0 normalized units at 4K but 0.5 at 8K, so a resize + /// silently samples a DIFFERENT SLICE of the noise field. Same feature scale, different + /// realization — the same seed gives a different world at a different size, for no reason + /// anyone wrote down. (chat1/00 §4.2; the §6 tightening carried in from chat1/01.) + /// + /// 1000 px at the reference's canonical MapSize of 8192 = 0.1220703125 map widths. Pinning + /// THAT reproduces the reference's realization exactly at 8K, and holds it still everywhere + /// else — which the reference did not. + /// + private const float LatitudeNoiseOffsetMapWidths = 1000f / 8192f; + + /// + /// Generate the raw pass-1 height field. + /// + public static Pass1Result Generate(TerrainGenConfig cfg) + { + ulong t0 = Time.GetTicksMsec(); + + GenerationScale scale = cfg.Scale; + int mapSize = cfg.MapSize; + FastNoiseLite noise = TerrainNoise.Create(cfg.Seed, scale); + + var height = new float[mapSize, mapSize]; + var preTrenchFalloff = new float[mapSize, mapSize]; + var latitudeField = new float[mapSize, mapSize]; + + // ⚠ MAP-CENTRED, not crater-centred. Reference ~:550: + // Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f); + // The crater's centre (_impactCenter) is a separate, randomly placed field used only in + // pass 2 and the biome pass. The spine and the mask both key off the MAP centre — the + // spine is a map-centred ridge, per `Design - Terrain - Mountain Spine.md`. Confirmed + // against source; no crater coupling exists in pass 1. + float centerX = mapSize / 2.0f; + float centerY = mapSize / 2.0f; + + float axisX = cfg.IslandAxisX; + float axisY = cfg.IslandAxisY; + + // Precomputed once — the reference recomputed these per pixel; identical results. + float latitudeNoiseOffset = scale.OffsetInMapWidths(LatitudeNoiseOffsetMapWidths); + float southThreshold = scale.Fraction(SouthThresholdFraction); + float halfSpan = mapSize / 2.0f; + + float hMax = float.MinValue; + float hMin = float.MaxValue; + + // ⚠ x IS THE OUTER LOOP, as in the reference. Numerically irrelevant here, but a + // PARALLEL port must reduce hMax/hMin rather than share them — noted before someone + // reaches for Parallel.For and quietly races on the running max. + for (int x = 0; x < mapSize; x++) + { + for (int y = 0; y < mapSize; y++) + { + // ═══ 1. WOBBLED LATITUDE SCALAR (ref ~:559-562) ═══ + // + // ⚠⚠ THE REFERENCE CALLED THIS "temperature". IT IS NOT CLIMATE, AND THIS PORT + // WILL NOT CALL IT THAT. + // + // It is a stage-1-local LATITUDE FIELD that terrain GEOMETRY reads — the spine's + // southern fade is its only pass-1 consumer. The prototype named it temperature, + // then let biomes read the same array, and that shared name is a large part of + // how climate and terrain got fused in the first place. In the rewrite, + // CLIMATE IS STAGE 3 and classifies finished shape (D-049 §2, D-056); this + // scalar is stage 1 and must never be stored as, aliased to, or mistaken for it. + // + // The ±0.1 WOBBLE IS KEPT, and it is load-bearing: it makes the spine's southern + // terminus a low-frequency wave rather than a ruler-straight latitude line. + // Replacing this with a bare y/MapSize would straighten it — the exact opposite + // of the filed spine-meander want. (chat1/00 §2.3.) + float lat = (float)y / mapSize; + float latNoise = (noise.GetNoise2D(x + latitudeNoiseOffset, y + latitudeNoiseOffset) + 1.0f) / 2.0f; + lat += (latNoise * LatitudeWobbleSpan) - (LatitudeWobbleSpan / 2.0f); + latitudeField[x, y] = lat; + + // ═══ 2. THE ISLAND FALLOFF / MASK (ref ~:567-574) ═══ + float finalFalloff = 0f; + float squircleFalloff = 0f; + + if (cfg.IslandFalloff) + { + // Squircle — Max(nx, ny), giving squared-off corners. ISLAND-anchored: + // the axis ratios are applied here. + float nx = Mathf.Abs(x - centerX) / (halfSpan * axisX); + float ny = Mathf.Abs(y - centerY) / (halfSpan * axisY); + squircleFalloff = Mathf.Max(nx, ny); + + // Ellipse — vector length, giving a rounded shape. + var ellipticalPos = new Vector2((x - centerX) / axisX, (y - centerY) / axisY); + float ellipticalFalloff = ellipticalPos.Length() / (mapSize / EllipseScaleDivisor); + + // 50/50 blend. + finalFalloff = Mathf.Lerp(ellipticalFalloff, squircleFalloff, BlendSquircleWeight); + } + + // ═══ 3. EDGE / COASTLINE ROUGHNESS (ref ~:577-578) ═══ + // + // ⚠ THE `* squircleFalloff` MODULATION IS PART OF IT AND IS EASY TO DROP. + // It makes the roughness ZERO AT THE ISLAND CENTRE and strongest at the rim — + // which is why it reads as coastline jitter instead of interior grain. The + // vault's description of this element omits it. (chat1/00 §2.2, §4.5.) + // + // The x2.5 coordinate multiplier is NOT an unscaled quantity: it multiplies + // coordinates that the already-normalized frequency then scales, so it is a + // fixed 2.5x RATIO to the base noise at every map size. (chat1/00 §4.2 — + // which corrected the vault on exactly this point.) + if (cfg.IslandFalloff && cfg.EdgeNoise) + { + float edgeNoise = (noise.GetNoise2D(x * EdgeNoiseCoordScale, y * EdgeNoiseCoordScale) + 1.0f) / 2.0f; + finalFalloff += (edgeNoise * EdgeNoiseAmplitude) * squircleFalloff; + } + + // ═══ 4. THE SOUTHERN SINKER (ref ~:582-587) ═══ + // + // Sinks the stretched land bridges in the bottom 25%. ⚠ BEFORE the power, so its + // effect is superlinear — +0.6 on a falloff already near 1 costs far more height + // than +0.6 near 0. Moving it after the power would change the southern coast. + if (cfg.IslandFalloff && cfg.SouthernSinker && y > southThreshold) + { + float southDepth = (y - southThreshold) / (mapSize - southThreshold); + finalFalloff += southDepth * SouthSinkAmount; + } + + // ═══ ⭐ THE PHASE-2 SEAM — captured BEFORE the power and BEFORE the Trench ═══ + // (ref ~:591). See Pass1Result.PreTrenchFalloff for why this exact point. + preTrenchFalloff[x, y] = finalFalloff; + + if (cfg.IslandFalloff) + finalFalloff = Mathf.Pow(finalFalloff, FalloffExponent); + + // ═══ 5. THE TRENCH (ref ~:595-598) ═══ + // + // ⚠⚠ MAP-ANCHORED, NOT ISLAND-ANCHORED — distX/distY carry NO axis ratio, unlike + // nx/ny above. That is deliberate and load-bearing: the Trench is a guarantee + // about the MAP BORDER, not about the island's ellipse. It is what makes the + // flood fill's unchecked (0,0) ocean seed safe — the corner is guaranteed + // underwater by a x15 wall. DO NOT "unify" it with the mask's normalization. + // → `Design - Terrain - Island Mask.md`, chat1/00 §4.5. + // + // ⚠ The two clauses are ADDITIVE — a corner pixel takes both. + // ⚠ AFTER the power, so it is a raw additive wall the exponent never softens. + if (cfg.IslandFalloff && cfg.Trench) + { + float distX = Mathf.Abs(x - centerX) / halfSpan; + float distY = Mathf.Abs(y - centerY) / halfSpan; + if (distX > TrenchOnset) finalFalloff += (distX - TrenchOnset) * TrenchSlope; + if (distY > TrenchOnset) finalFalloff += (distY - TrenchOnset) * TrenchSlope; + } + + // ═══ 6. THE MOUNTAIN SPINE (ref ~:605-612) ═══ + // + // ⚠ THE REFERENCE'S `if (temperature < 0.65f)` GATE IS DROPPED, AND THE RESULT IS + // BIT-IDENTICAL. southernFade = Clamp((0.65 - lat) * 4, 0, 1) already reaches + // EXACTLY ZERO at lat = 0.65 — the same constant the gate tested — so the gate + // guarded a term that had already faded to nothing. It introduced no + // discontinuity and skipped no visible work. southernFade, not the gate, is what + // actually shapes the spine's southern end. (chat1/00 §2.3.) + // + // The gate is expressed below as `fade > 0`, which is the same predicate stated + // where it is true rather than where it is incidental. + float mountainSpine = 0f; + if (cfg.MountainSpine) + { + float southernFade = Mathf.Clamp((SpineLatitudeAnchor - lat) * SpineFadeSlope, 0.0f, 1.0f); + if (southernFade > 0f) + { + // ISLAND-anchored: axisX applies here (ref ~:608). + float distanceToCenterX = Mathf.Abs(x - centerX) / (halfSpan * axisX); + float crest = 1.0f - IslandFalloff.SmoothAbs(distanceToCenterX, IslandFalloff.CREST_EPSILON); + mountainSpine = Mathf.Pow(crest, SpineConcentration) * southernFade * SpineAmplitude; + } + } + + // ═══ COMBINE AND WRITE (ref ~:618-619, 665-666) ═══ + float rawBase = cfg.BaseNoise ? (noise.GetNoise2D(x, y) + 1.0f) / 2.0f : 0f; + float finalH = rawBase + mountainSpine - (finalFalloff * cfg.FalloffStrength); + + if (finalH > hMax) hMax = finalH; + if (finalH < hMin) hMin = finalH; + height[x, y] = finalH; + + // ⚠ THE REFERENCE'S PASS 1 CONTINUES HERE with the coast shelf (~:621-640) and + // the offshore islets (~:641-664). Both DEFERRED to Phase 2 — below-sea only, + // judged once water renders. preTrenchFalloff above is their inlet. + } + } + + return new Pass1Result(mapSize, cfg.Seed, height, preTrenchFalloff, latitudeField, + hMax, hMin, Time.GetTicksMsec() - t0); + } + } +} diff --git a/Tools/Scripts/Topography.cs.uid b/Tools/Scripts/Topography.cs.uid new file mode 100644 index 0000000..a9f51fe --- /dev/null +++ b/Tools/Scripts/Topography.cs.uid @@ -0,0 +1 @@ +uid://cwaxsloo2ncr8