diff --git a/Core/README.md b/Core/README.md
index 65159b9..b3e4cc5 100644
--- a/Core/README.md
+++ b/Core/README.md
@@ -31,7 +31,12 @@ resolution and the file-safety rails. Constants and contracts.
| `Scripts/BuildingMaterial.cs` | A row in the building schema (D-054). |
| `Scripts/MaterialRegistry.cs` | Both registries, append-only, seed rows only. |
| `Scripts/RecipeRegistry.cs` | The transformation seam — **deliberately empty**. |
-| `Scripts/GenerationScale.cs` | ⭐ The scaling discipline. `MapSize`, `ScaleFactor`, normalized offsets. |
+| `Scripts/GenerationScale.cs` | ⭐ The scaling discipline. `MapSize`, `ScaleFactor`, normalized offsets, the two frequency conventions. |
+| `Scripts/WorldScale.cs` | ⭐ **The vertical yardstick.** The ONE metres↔raw conversion (251 m/unit). No literal `251f` anywhere else. |
+| `Scripts/HeightCurve.cs` | ⭐⭐ The height-redistribution curve (v5), 7 bands. **Identity at and below sea.** |
+| `Scripts/CurveKnots.cs` | The six INPUT knots — percentiles of the measured land CDF, plus the reference's for comparison. |
+| `Scripts/CurveAnchors.cs` | The OUTPUT anchors — the storm-ladder elevations each band lands at. |
+| `Scripts/TerrainDetailPass.cs` | Shelf micro-relief + the shelf-edge **knot warp**. Output-height only. |
| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |
@@ -43,13 +48,20 @@ resolution and the file-safety rails. Constants and contracts.
→ `Scripts/MaterialRegistry.cs`.
3. **Nothing uses a raw pixel number.** Every distance is a fraction of `MapSize`.
→ `Scripts/GenerationScale.cs`.
+4. **There is one metres-per-raw-unit number.** The prototype scattered `251` across three
+ duplicate constants and ~20 literals, and the generator never used the derived one at all.
+ → `Scripts/WorldScale.cs`.
## Not here, and not by accident
- **No water.** Water is an overlay over the columns (levels-not-cells), never a band.
- **No biomes.** Biomes are a later *classification* of finished shape, not an input to it (D-049).
-- **No algorithms.** Phase 0 is shape. Stratigraphy, feature passes, meshing and run-splitting on
- dig are later phases.
+- **No algorithms** *beyond the height curve*. Phase 2 added `HeightCurve` and
+ `TerrainDetailPass` here because they are pure, engine-free, C++-candidate math that defines the
+ world's elevation profile — a contract, not a tool's dial. Stratigraphy, feature passes, meshing
+ and run-splitting on dig are still later phases.
+- **No erosion, rivers, water bodies, crater carve, coast shelf or offshore islets.** Later chat2
+ tasks; the curve is deliberately the only pass-2 element present.
→ `Design - Data - Column Model.md`, `Design - Data - Material Schema.md`,
`Design - Tooling - Scaling Discipline.md`
diff --git a/Core/Scripts/CurveAnchors.cs b/Core/Scripts/CurveAnchors.cs
new file mode 100644
index 0000000..8c00013
--- /dev/null
+++ b/Core/Scripts/CurveAnchors.cs
@@ -0,0 +1,142 @@
+namespace IslaApocalypse.Core
+{
+ ///
+ /// The redistribution curve's fixed OUTPUT anchors — the elevations the bands are mapped ONTO.
+ ///
+ /// ═══ INPUT KNOTS vs OUTPUT ANCHORS — the distinction the whole curve rests on ═══
+ ///
+ /// — WHERE the land distribution is cut. Percentiles. Measured.
+ /// — WHAT HEIGHT each cut lands at. Storm-ladder. Chosen.
+ ///
+ /// Re-measuring the knots moves how much land is in each band. Moving the anchors moves how HIGH
+ /// each band sits. They are independent, and conflating them is how a "recalibration" turns into
+ /// an unnoticed reshape.
+ ///
+ /// ═══ ⚠ WHY THIS IS A PARAMETER OBJECT AND NOT A WALL OF CONSTANTS ═══
+ ///
+ /// The reference held these as const fields on HeightCurve and passed only the
+ /// per-column modulated values (benchLo, benchSpan, …) as arguments. That made the
+ /// storm-ladder anchors unreachable from config: A/B-ing the 420 m cap meant editing and
+ /// rebuilding.
+ ///
+ /// Here every anchor is an explicit parameter, in the spirit of D-035 ("every per-column input
+ /// and the knot set are explicit PARAMETERS"). reproduces the reference's
+ /// constants bit-for-bit, so this is an exposure, not a change.
+ ///
+ /// ⚠⚠ THE BAND COUNT AND THE SEGMENT SHAPES ARE NOT EXPOSED, DELIBERATELY. Adding a knot,
+ /// steepening a segment or reallocating the shares is the RESHAPE — a later task with its own
+ /// gate. This type exposes the existing seven-band curve's dials and nothing more.
+ ///
+ /// Every metre-denominated anchor is derived through —
+ /// the single yardstick — never a literal /251f.
+ ///
+ public sealed class CurveAnchors
+ {
+ // ---- the frozen band ceilings (raw height units) ---------------------
+
+ ///
+ /// Sea level in raw units. ⚠ ALSO THE CURVE'S IDENTITY THRESHOLD: at and below this the
+ /// curve returns its input untouched, which is what keeps the waterline, the Trench
+ /// guarantee and (later) every water body invariant under the curve. Reference: 0.15f.
+ ///
+ public float Sea = 0.15f;
+
+ /// Top of the toe/orange band. Reference: 0.206f.
+ public float OrangeCeil = 0.206f;
+
+ /// Top of the red band — the floor the foothill riser climbs from. Reference: 0.27f.
+ public float RedCeil = 0.27f;
+
+ // ---- the modulated shelf anchors -------------------------------------
+
+ /// Bench centre, raw. Reference: SEA + 100 m.
+ public float BenchBase = 0.15f + WorldScale.RawFromMetres(100f);
+
+ /// Bench modulation amplitude, raw. Reference: ±12 m.
+ public float BenchAmp = WorldScale.RawFromMetres(12f);
+
+ /// Plateau centre, raw. Reference: SEA + 220 m.
+ public float PlateauBase = 0.15f + WorldScale.RawFromMetres(220f);
+
+ /// Plateau modulation amplitude, raw. Reference: ±20 m.
+ public float PlateauAmp = WorldScale.RawFromMetres(20f);
+
+ ///
+ /// Narrowest a shelf band may be, raw. Reference: 6 m (v4 was 2 m — "corner fix 3":
+ /// a pronounced shelf keeps a gentle tilt, flat to build on but never snooker-table flat).
+ ///
+ public float ShelfSpanMin = WorldScale.RawFromMetres(6f);
+
+ /// Widest a shelf band may be, raw. Reference: 0.10f (≈ 25 m).
+ public float ShelfSpanMax = 0.10f;
+
+ // ---- the ceiling and its tail ---------------------------------------
+
+ ///
+ /// The peak cap, raw. Reference: SEA + 420 m. The summit spike maps
+ /// [K6, spikeMax] onto [plateauTop, PeakCap], so this is the island's
+ /// nominal ceiling — exact, not statistical, because K6 never moves under the edge warp.
+ ///
+ public float PeakCap = 0.15f + WorldScale.RawFromMetres(420f);
+
+ ///
+ /// Slope above spikeMax. Reference: 0.25f. ⚠ A gentle TAIL, not a hard clip — a seed
+ /// whose max exceeds the spike range still rises, just slowly.
+ ///
+ public float TailSlope = 0.25f;
+
+ ///
+ /// Minimum spike span, raw. Reference: 0.01f. Guarantees a non-degenerate summit band on a
+ /// seed whose map-wide max lands at or below K6.
+ ///
+ public float SpikeMinSpan = 0.01f;
+
+ // ---- the modulation fields' identity ---------------------------------
+ //
+ // ⚠ THESE ARE SEED OFFSETS, NOT COORDINATE OFFSETS. The reference decorrelated its curve
+ // modulation fields by seeding each one at `resolvedSeed + offset` and sampling all of them
+ // at the bare (x, y) — there is no `GetNoise2D(x + 1000, …)` anywhere in this path. So the
+ // raw-pixel-offset hazard GenerationScale warns about does NOT apply here, and there is
+ // nothing to normalize. (Verified against every MakeModulationNoise call site; recorded
+ // because the absence of a bug is only reassuring if someone checked.)
+
+ /// Bench-anchor field seed offset. Reference: 7101.
+ public int BenchSeedOffset = 7101;
+
+ /// Plateau-anchor field seed offset. Reference: 7207.
+ public int PlateauSeedOffset = 7207;
+
+ /// Shelf-strength field seed offset. Reference: 7303.
+ public int StrengthSeedOffset = 7303;
+
+ ///
+ /// Anchor-field frequency, in periods per MAP WIDTH. Reference: 3.0f — a very low frequency,
+ /// so the bench and plateau elevations drift across the island rather than flickering.
+ /// ⚠ Already scale-safe by construction: stated per map width, not per pixel.
+ ///
+ public float ElevFreqPerMapWidth = 3.0f;
+
+ /// Shelf-strength field frequency, periods per map width. Reference: 5.0f.
+ public float StrengthFreqPerMapWidth = 5.0f;
+
+ ///
+ /// The reference's shipped anchors, reproduced bit-for-bit. Every metre value goes through
+ /// , which divides — matching the reference's
+ /// 420f / 251f exactly rather than approximating it with a reciprocal multiply.
+ ///
+ public static CurveAnchors Default => new CurveAnchors();
+
+ public CurveAnchors Clone() => (CurveAnchors)MemberwiseClone();
+
+ ///
+ /// The anchors as the storm ladder states them — metres above sea. For a run header, where
+ /// raw units mean nothing to a reader.
+ ///
+ public string DescribeMetres() =>
+ $"sea {Sea:F3} raw · orange {WorldScale.MetresFromRaw(OrangeCeil - Sea):F0} m · " +
+ $"red {WorldScale.MetresFromRaw(RedCeil - Sea):F0} m · " +
+ $"bench {WorldScale.MetresFromRaw(BenchBase - Sea):F0}±{WorldScale.MetresFromRaw(BenchAmp):F0} m · " +
+ $"plateau {WorldScale.MetresFromRaw(PlateauBase - Sea):F0}±{WorldScale.MetresFromRaw(PlateauAmp):F0} m · " +
+ $"cap {WorldScale.MetresFromRaw(PeakCap - Sea):F0} m";
+ }
+}
diff --git a/Core/Scripts/CurveAnchors.cs.uid b/Core/Scripts/CurveAnchors.cs.uid
new file mode 100644
index 0000000..599f926
--- /dev/null
+++ b/Core/Scripts/CurveAnchors.cs.uid
@@ -0,0 +1 @@
+uid://by6kmr31oiodj
diff --git a/Core/Scripts/CurveKnots.cs b/Core/Scripts/CurveKnots.cs
new file mode 100644
index 0000000..54d01f9
--- /dev/null
+++ b/Core/Scripts/CurveKnots.cs
@@ -0,0 +1,120 @@
+namespace IslaApocalypse.Core
+{
+ ///
+ /// The six INPUT knots of the redistribution curve — thresholds on the RAW pre-curve height that
+ /// cut the land distribution into the curve's seven bands.
+ ///
+ /// ═══ ⚠⚠ THESE ARE A CALIBRATION ARTEFACT, NOT A DESIGN CONSTANT ═══
+ ///
+ /// They are literal floats in source, but they were never CHOSEN as numbers. Each is a PERCENTILE
+ /// of the measured land-height distribution, baked down to a literal:
+ ///
+ /// K1..K6 = P60 / P73 / P83 / P88 / P96 / P99 of the land CDF
+ ///
+ /// which is what makes the band SHARES — 60/13/10/5/8/3/1 % of land — exact by construction.
+ /// The shares are the design decision; the knots are whatever percentiles land on THIS
+ /// generator's distribution.
+ ///
+ /// > ### ⚠ A KNOT SET IS ONLY VALID FOR THE DISTRIBUTION IT WAS MEASURED ON.
+ /// > Copying knots across a change to pass 1 silently reallocates the bands. That is why
+ /// > is kept beside rather than replaced by it:
+ /// > the DELTA between them is the port-fidelity check.
+ ///
+ /// Monotonicity does not depend on the values: the curve is monotonic for ANY strictly ordered
+ /// knot set, and the shelf-edge warp's bound keeps the set ordered by construction. So a
+ /// recalibration cannot break the curve — it can only move where the bands sit.
+ ///
+ public sealed class CurveKnots
+ {
+ /// Preset id, carried into the blueprint's curve metadata when that lands.
+ public readonly byte PresetId;
+
+ /// Short name, for logs and batch folders.
+ public readonly string Name;
+
+ /// The six input knots, strictly ascending. K1..K6.
+ public readonly float K1, K2, K3, K4, K5, K6;
+
+ public CurveKnots(byte id, string name, float k1, float k2, float k3, float k4, float k5, float k6)
+ {
+ PresetId = id; Name = name;
+ K1 = k1; K2 = k2; K3 = k3; K4 = k4; K5 = k5; K6 = k6;
+ }
+
+ ///
+ /// The quantiles the knots ARE, in percent. The band shares follow by differencing:
+ /// 60 / 13 / 10 / 5 / 8 / 3 / 1.
+ ///
+ /// ⚠ THE SHARE TARGETS ARE THE M3 VALUES AND ARE HELD FIXED BY THIS PORT. Reallocating them
+ /// is the reshape, and the reshape is a later task.
+ ///
+ public static readonly double[] Percentiles = { 60.0, 73.0, 83.0, 88.0, 96.0, 99.0 };
+
+ /// The land-share target per output band, in percent, in band order.
+ public static readonly double[] BandShareTargets = { 60.0, 13.0, 10.0, 5.0, 8.0, 3.0, 1.0 };
+
+ /// Band names, in curve order. For tables and plot overlays.
+ public static readonly string[] BandNames =
+ { "toe/orange", "red", "foothill riser", "bench", "mid riser", "plateau", "summit spike" };
+
+ ///
+ /// ⛔ THE REFERENCE'S SHIPPED KNOTS, kept verbatim as the fidelity yardstick — NOT for use.
+ ///
+ /// Read from REFERENCE:Tools/Scripts/HeightCurve.cs:57-58 at tag
+ /// pre-rewrite-reference (ab78883): preset BALANCED (id 2), the task-09 taste
+ /// gate's winner. Calibrated 2026-08-08 from the pooled batch-04 flat-sea land CDF,
+ /// 340,618,126 samples. COMPACT (id 1) retired with that verdict.
+ ///
+ /// ⚠ The CDF that produced these does not exist in the reference repo — no sampler, no
+ /// histogram, no percentile helper survives at the tag. Only the six outputs were committed,
+ /// which is precisely why v2 had to rebuild the measuring instrument rather than copy them.
+ ///
+ public static readonly CurveKnots Reference = new CurveKnots(2, "reference_balanced",
+ 0.515899f, 0.612157f, 0.710472f, 0.784045f, 0.962922f, 1.119118f);
+
+ ///
+ /// ⭐ THE FAITHFUL v2 BASELINE — the same percentiles, re-measured on v2's own pass-1 output.
+ ///
+ /// Measured by chat2/01 (Tools/Scenes/CurveBaselineTool.tscn) over a 6-seed pool at
+ /// MapSize 2048 — seeds 1063685222, 20260819, 777001, 424242, 90210, 31337 —
+ /// 12,854,486 land samples, fine-histogram quantiles at 1e-4 raw resolution with
+ /// in-bin linear interpolation. Land range [0.1500 .. 1.4146] raw, zero overflow.
+ ///
+ /// ⚠ These are DELIBERATELY not the reference literals. The delta against
+ /// is the port's fidelity evidence, and it is SMALL — the knots agree
+ /// to within +5.6 / −4.2 metres of world height across all six:
+ ///
+ /// K1 P60 0.529113 (ref 0.515899, +3.32 m)
+ /// K2 P73 0.634439 (ref 0.612157, +5.59 m)
+ /// K3 P83 0.732487 (ref 0.710472, +5.53 m)
+ /// K4 P88 0.796957 (ref 0.784045, +3.24 m)
+ /// K5 P96 0.960301 (ref 0.962922, −0.66 m)
+ /// K6 P99 1.102473 (ref 1.119118, −4.18 m)
+ ///
+ /// v2's land distribution is very slightly FATTER in the middle and SHORTER in the tail than
+ /// the reference's — consistent with a faithful pass-1 port measured on six seeds rather than
+ /// the reference's own pooled batch, not with a divergence. → `output/chat2/01_*.report.md`.
+ ///
+ /// ⚠ Quoted to six decimals, which is float32's honest precision; the stored values differ
+ /// from the raw measurement by <1e-7 raw (2.5e-5 m). The batch tool always re-measures for
+ /// its own run, so this constant is the default for OTHER callers, never the batch's input.
+ ///
+ /// Re-measure by running Tools/Scenes/CurveBaselineTool.tscn; it prints this table.
+ ///
+ public static readonly CurveKnots V2Baseline = new CurveKnots(2, "v2_balanced",
+ 0.529113f, 0.634439f, 0.732487f, 0.796957f, 0.960301f, 1.102473f);
+
+ /// Strictly ascending? The precondition every other guarantee rests on.
+ public bool IsStrictlyOrdered => K1 < K2 && K2 < K3 && K3 < K4 && K4 < K5 && K5 < K6;
+
+ /// Indexed access, K1..K6 as [0..5]. For tables and sweeps.
+ public float this[int i] => i switch
+ {
+ 0 => K1, 1 => K2, 2 => K3, 3 => K4, 4 => K5, 5 => K6,
+ _ => throw new System.IndexOutOfRangeException($"A curve has six knots; asked for {i}.")
+ };
+
+ public override string ToString() =>
+ $"{Name}(K1={K1:F6} K2={K2:F6} K3={K3:F6} K4={K4:F6} K5={K5:F6} K6={K6:F6})";
+ }
+}
diff --git a/Core/Scripts/CurveKnots.cs.uid b/Core/Scripts/CurveKnots.cs.uid
new file mode 100644
index 0000000..1b3f4aa
--- /dev/null
+++ b/Core/Scripts/CurveKnots.cs.uid
@@ -0,0 +1 @@
+uid://f3vitjt1itci
diff --git a/Core/Scripts/GenerationScale.cs b/Core/Scripts/GenerationScale.cs
index b2ce7a6..8e8092f 100644
--- a/Core/Scripts/GenerationScale.cs
+++ b/Core/Scripts/GenerationScale.cs
@@ -101,6 +101,28 @@ namespace IslaApocalypse.Core
///
public float NoiseFrequency(float baselineFrequency) => baselineFrequency / ScaleFactor;
+ ///
+ /// A noise frequency stated as PERIODS PER MAP WIDTH — the reference's second frequency
+ /// convention, used by every curve/detail modulation field.
+ ///
+ /// NoiseFrequencyPerMapWidth(40f) gives ~40 undulations across the island at any map
+ /// size, which is exactly what the reference's periodsPerIsland / MapSize computed.
+ ///
+ /// ═══ ⚠ WHY THIS IS NOT A SECOND BASELINE ═══
+ ///
+ /// It carries no baseline at all, so it does not reopen the /1024-vs-/4096 question this type
+ /// exists to close. The two conventions answer different questions and both are scale-safe:
+ ///
+ /// "the frequency that looked right at 1024 columns"
+ /// — a tuned number, normalized by ScaleFactor.
+ /// "this many features across the island"
+ /// — a stated intent, already size-independent.
+ ///
+ /// Reach for this one when the feature COUNT across the map is the thing being specified, and
+ /// for when porting a frequency someone tuned by eye.
+ ///
+ public float NoiseFrequencyPerMapWidth(float periodsPerMapWidth) => periodsPerMapWidth / MapSize;
+
///
/// ⭐ A decorrelation offset for a noise coordinate, declared in MAP WIDTHS.
///
diff --git a/Core/Scripts/HeightCurve.cs b/Core/Scripts/HeightCurve.cs
new file mode 100644
index 0000000..f9190e6
--- /dev/null
+++ b/Core/Scripts/HeightCurve.cs
@@ -0,0 +1,249 @@
+using System;
+
+namespace IslaApocalypse.Core
+{
+ ///
+ /// ⭐⭐ THE HEIGHT-REDISTRIBUTION CURVE — pass 2's first act, and the shape of the island's
+ /// elevation profile. Ported from REFERENCE:Tools/Scripts/HeightCurve.cs (v5) at tag
+ /// pre-rewrite-reference (ab78883). → D-050 ("port, don't re-derive").
+ ///
+ /// ═══ WHAT IT IS FOR ═══
+ ///
+ /// Raw fractal noise is Gaussian-ish: almost all land sits in a narrow mid-band and there is no
+ /// coastal plain, no shelf, no distinguishable summit. The curve REDISTRIBUTES that distribution
+ /// onto a designed elevation profile — a wide low plain, two shelves, risers between them, and a
+ /// thin summit band under a hard cap. It changes WHERE heights land, never WHICH pixel is higher
+ /// than which: the curve is strictly monotonic, so the terrain's topology is untouched.
+ ///
+ /// ═══ THE SEVEN BANDS (input knot → output anchor) ═══
+ ///
+ /// band input output shape
+ /// ─────────────── ─────────── ──────────────────────────── ──────────────────────────
+ /// toe / orange [SEA, K1) Sea → OrangeCeil 0.3u + 0.7·u(2−u) ease-out
+ /// red [K1, K2) Orange → RedCeil linear
+ /// foothill riser [K2, k3) RedCeil → benchLo 0.1u + 0.9·smoothstep
+ /// bench [k3, k4) benchLo → benchTop linear
+ /// mid riser [k4, k5) benchTop → plateauLo 0.1u + 0.9·smoothstep
+ /// plateau [k5, K6) plateauLo→ plateauTop linear
+ /// summit spike [K6, sMax) plateauTop → PeakCap 0.05u + 0.95·u⁴
+ /// tail [sMax, ∞) PeakCap + (h−sMax)·TailSlope linear
+ ///
+ /// The riser and spike blends are the reference's "corner fixes" and are FROZEN: the 0.1 riser
+ /// floor makes climbs decelerate into shelves and accelerate out of them (no machined edges), and
+ /// the 0.05 spike floor lets the summit leave the plateau gently (no hard skirt under the peaks).
+ ///
+ /// ═══ ⚠⚠ THE LOAD-BEARING LINE ═══
+ ///
+ /// if (h <= a.Sea) return h;
+ ///
+ /// THE CURVE IS IDENTITY AT AND BELOW SEA. Everything downstream rests on it: the waterline
+ /// cannot move, the Trench's ocean-border guarantee survives, and — when water lands — the
+ /// classify/render split agrees everywhere outside the crater, because a monotonic curve that
+ /// fixes sea means Apply(raw) < sea exactly when raw < sea. Delete this line
+ /// and the whole separability argument goes with it.
+ ///
+ /// ═══ ⚠ SEED-DEPENDENT BY CONSTRUCTION ═══
+ ///
+ /// The summit spike maps [K6, hMaxSeed] onto the peak band, so the curve cannot be
+ /// evaluated until pass 1 has scanned every pixel. That is why the pass-1/pass-2 boundary is a
+ /// hard one and not an interleave. → ,
+ /// Tools/Pass1Result.HMaxSeed.
+ ///
+ /// ═══ PORT NOTES ═══
+ ///
+ /// • Engine-free: the reference used Godot.Mathf only for arithmetic, so this lives in
+ /// Core/ as a named C++-candidate seam (D-049, D-060). reproduces
+ /// Mathf.Lerp's exact expression, so the port is bit-faithful and not merely equivalent.
+ /// • Anchors are a parameter object () rather than consts, so the
+ /// storm-ladder values are A/B-able from config. CurveAnchors.Default reproduces the
+ /// reference's constants exactly.
+ /// • The reference's two-preset machinery (COMPACT vs BALANCED) is NOT carried: COMPACT was
+ /// retired by the task-09 verdict and exists only in that task's report. One knot set, named.
+ /// • returns its confirmation line instead of printing it — Core
+ /// has no GD.Print. The caller logs it.
+ ///
+ /// ⚠⚠ THE SHAPE IS FROZEN AT v5. This port adds no band, no anchor and no slope. Reshaping is a
+ /// later, gated task; if you are here to steepen something, you are in the wrong file.
+ ///
+ public static class HeightCurve
+ {
+ /// Curve body version — the identity of the segment layout, not of the knots.
+ public const ushort Version = 5;
+
+ ///
+ /// Mathf.Lerp, reproduced as the reference's engine wrote it:
+ /// from + (to - from) * weight. ⚠ Written out rather than "simplified" because a
+ /// different association of the same algebra is a different float32 result, and this port's
+ /// fidelity claim is bit-level.
+ ///
+ private static float Lerp(float from, float to, float weight) => from + (to - from) * weight;
+
+ ///
+ /// The per-seed summit ceiling: the raw height the spike band's top maps to PeakCap.
+ ///
+ /// Max(hMaxSeed, K6 + SpikeMinSpan) — the floor guarantees a non-degenerate band on a
+ /// seed whose map-wide maximum lands at or below K6, which would otherwise divide by zero.
+ ///
+ public static float EffectiveSpikeMax(float hMaxSeed, CurveKnots k, CurveAnchors a)
+ => MathF.Max(hMaxSeed, k.K6 + a.SpikeMinSpan);
+
+ ///
+ /// Shelf band width from the per-column strength field.
+ ///
+ /// ⚠ THE LERP IS INVERTED, AND THAT IS THE REFERENCE'S INTENT: higher "strength" means a
+ /// MORE PRONOUNCED shelf, which means a NARROWER input band mapped across the same output
+ /// span — i.e. flatter ground. strength 0 → SpanMax, strength 1 → SpanMin.
+ ///
+ public static float ShelfSpan(float strength01, CurveAnchors a)
+ => Lerp(a.ShelfSpanMax, a.ShelfSpanMin, Math.Clamp(strength01, 0f, 1f));
+
+ ///
+ /// The curve, for ONE column.
+ ///
+ /// Raw pre-curve height.
+ /// The map-wide raw maximum for this seed. → Pass1Result.HMaxSeed.
+ /// This column's bench floor (base ± the anchor field).
+ /// This column's bench output span.
+ /// This column's plateau floor.
+ /// This column's plateau output span.
+ /// The input knot set.
+ /// The output anchors.
+ ///
+ /// The shelf-edge warp (TerrainDetailPass pass B): slides the K3/K4/K5 BLOCK for this
+ /// column. K1/K2/K6 never move, which is what keeps the red-ceiling floor and the peak cap
+ /// EXACT under the warp rather than statistical. Zero when detail is off.
+ ///
+ public static float Apply(float h, float hMaxSeed,
+ float benchLo, float benchSpan, float plateauLo, float plateauSpan,
+ CurveKnots k, CurveAnchors a, float edgeShift)
+ {
+ // ⭐ IDENTITY AT AND BELOW SEA. See the type header — this line is the invariant.
+ if (h <= a.Sea) return h;
+
+ // The knot BLOCK slides rigidly: bench and mid-riser keep their exact widths (their
+ // interiors are translated, not distorted); only the foothill riser and the plateau
+ // stretch or compress to absorb the shift.
+ float k3 = k.K3 + edgeShift, k4 = k.K4 + edgeShift, k5 = k.K5 + edgeShift;
+
+ float u, s;
+
+ if (h < k.K1)
+ {
+ u = (h - a.Sea) / (k.K1 - a.Sea);
+ s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe
+ return a.Sea + s * (a.OrangeCeil - a.Sea);
+ }
+ if (h < k.K2)
+ {
+ u = (h - k.K1) / (k.K2 - k.K1);
+ return a.OrangeCeil + u * (a.RedCeil - a.OrangeCeil); // frozen linear rise
+ }
+ if (h < k3)
+ {
+ u = (h - k.K2) / (k3 - k.K2);
+ s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1
+ return a.RedCeil + s * (benchLo - a.RedCeil);
+ }
+ if (h < k4)
+ {
+ u = (h - k3) / (k4 - k3);
+ return benchLo + u * benchSpan; // bench — corner fix 3 floors the span
+ }
+
+ float benchTop = benchLo + benchSpan;
+ if (h < k5)
+ {
+ u = (h - k4) / (k5 - k4);
+ s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
+ return benchTop + s * (plateauLo - benchTop);
+ }
+ if (h < k.K6)
+ {
+ u = (h - k5) / (k.K6 - k5);
+ return plateauLo + u * plateauSpan; // plateau
+ }
+
+ float plateauTop = plateauLo + plateauSpan;
+ float spikeMax = EffectiveSpikeMax(hMaxSeed, k, a);
+ if (h < spikeMax)
+ {
+ u = (h - k.K6) / (spikeMax - k.K6);
+ s = 0.05f * u + 0.95f * (u * u * u * u); // summit spike — corner fix 2
+ return plateauTop + s * (a.PeakCap - plateauTop);
+ }
+ return a.PeakCap + (h - spikeMax) * a.TailSlope; // gentle tail, not a clip
+ }
+
+ ///
+ /// Per-generation numeric strict-monotonicity proof of the EFFECTIVE curve — run once per
+ /// seed, between the two passes, before any pixel is curved.
+ ///
+ /// ═══ WHY A NUMERIC SWEEP AND NOT AN ARGUMENT ═══
+ ///
+ /// Monotonicity is structural in the algebra, but the curve as EVALUATED depends on three
+ /// per-column fields and a per-column warp, and the corner fixes lowered the slope floors
+ /// (risers 0.1, spike base 0.05) while the warp squeezes the foothill riser and the plateau.
+ /// "It should be fine" is not the standard: the sweep proves every slope stays strictly
+ /// positive at the extremes of BOTH, on this seed's actual spikeMax.
+ ///
+ /// 24 corners: 2 bench extremes × 2 plateau extremes × 2 span extremes × 3 edge shifts
+ /// (−max, 0, +max).
+ ///
+ /// ⚠ THROWS AND REFUSES on violation, rather than warning. A non-monotonic curve inverts
+ /// terrain — a peak becomes a pit — and that is not something to discover in a render.
+ ///
+ /// A one-line confirmation for the run log. Core cannot print; the caller does.
+ public static string AssertMonotonic(float hMaxSeed, CurveKnots k, CurveAnchors a, float maxEdgeShift)
+ {
+ if (!k.IsStrictlyOrdered)
+ throw new InvalidOperationException(
+ $"[HeightCurve] KNOT ORDER VIOLATION: {k} is not strictly ascending. Refusing to generate.");
+
+ if (maxEdgeShift < 0f || k.K2 + maxEdgeShift >= k.K3 || k.K5 + maxEdgeShift >= k.K6)
+ throw new InvalidOperationException(
+ $"[HeightCurve] EDGE-SHIFT BOUND VIOLATION: maxEdgeShift={maxEdgeShift} does not keep " +
+ $"K2 < K3±d and K5±d < K6 (preset {k.Name}). Refusing to generate.");
+
+ float[] benchLos = { a.BenchBase - a.BenchAmp, a.BenchBase + a.BenchAmp };
+ float[] plateauLos = { a.PlateauBase - a.PlateauAmp, a.PlateauBase + a.PlateauAmp };
+ float[] spans = { a.ShelfSpanMin, a.ShelfSpanMax };
+ float[] edgeShifts = maxEdgeShift > 0f
+ ? new[] { -maxEdgeShift, 0f, maxEdgeShift }
+ : new[] { 0f };
+
+ foreach (float bl in benchLos)
+ foreach (float pl in plateauLos)
+ foreach (float sp in spans)
+ foreach (float es in edgeShifts)
+ {
+ float prevH = -7f;
+ float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, a, es);
+
+ void Check(double hd)
+ {
+ float h = (float)hd;
+ // Dedupe float32 samples: a fine double-precision step can land on the same
+ // float twice, and "not greater" is not a violation when it is the same input.
+ if (h <= prevH) return;
+ float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k, a, es);
+ if (v <= prev)
+ throw new InvalidOperationException(
+ $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, " +
+ $"hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}, edgeShift={es}): " +
+ $"{v} <= {prev}. Refusing to generate.");
+ prev = v;
+ prevH = h;
+ }
+
+ double top = Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k, a) + 0.5);
+ for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh); // the below-sea identity run
+ for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh); // every band, finely
+ for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh); // the tail
+ }
+
+ return $"[HeightCurve] Monotonicity assertion passed (v{Version} preset '{k.Name}', " +
+ $"8 modulation corners × edge shifts ±{maxEdgeShift:F6}, " +
+ $"effective spikeMax {EffectiveSpikeMax(hMaxSeed, k, a):F6}).";
+ }
+ }
+}
diff --git a/Core/Scripts/HeightCurve.cs.uid b/Core/Scripts/HeightCurve.cs.uid
new file mode 100644
index 0000000..30d2aed
--- /dev/null
+++ b/Core/Scripts/HeightCurve.cs.uid
@@ -0,0 +1 @@
+uid://7d68p2nuge8x
diff --git a/Core/Scripts/TerrainDetailPass.cs b/Core/Scripts/TerrainDetailPass.cs
new file mode 100644
index 0000000..56ffaec
--- /dev/null
+++ b/Core/Scripts/TerrainDetailPass.cs
@@ -0,0 +1,182 @@
+using System;
+
+namespace IslaApocalypse.Core
+{
+ ///
+ /// The terrain DETAIL passes — the two things that make a redistributed shelf read as ground
+ /// rather than as a terrace. Ported from REFERENCE:Tools/Scripts/TerrainDetailPass.cs
+ /// (v1) at tag pre-rewrite-reference (ab78883).
+ ///
+ /// ═══ TWO PASSES, AND THEY DO DIFFERENT KINDS OF THING ═══
+ ///
+ /// PASS A — SHELF MICRO-RELIEF. A medium-frequency noise skin (±3 m by default) added to the
+ /// OUTPUT height, weighted by shelf-ness. The curve compresses the shelves flat; this gives
+ /// them their rolling texture back. Risers and peaks are untouched by construction.
+ ///
+ /// PASS B — SHELF-EDGE VARIATION. A per-column shift of the shelf/riser KNOT BLOCK (K3/K4/K5)
+ /// by a low-frequency field.
+ ///
+ /// > ### ⚠⚠ PASS B IS NOT HEIGHT PERTURBATION, AND THE DIFFERENCE IS THE WHOLE POINT.
+ /// >
+ /// > Pass A moves a column's OUTPUT HEIGHT. Pass B moves WHERE THE BANDS ARE for that column.
+ /// >
+ /// > Every shelf↔riser boundary is the contour where the raw height crosses one of K3/K4/K5. Slide
+ /// > those knots per column and the contour stops tracing a clean iso-height line: the shelf edge
+ /// > scallops into coves, notches and peninsulas. Do it by perturbing height instead and you get
+ /// > a fuzzy terrace edge, not an organic one — and you lose monotonicity as a STRUCTURAL
+ /// > property, because the curve is monotonic for any ordered knot set but nothing is monotonic
+ /// > after arbitrary additive noise.
+ /// >
+ /// > The amplitude is therefore stated in METRES OF INPUT HEIGHT (raw × the yardstick): how far a
+ /// > boundary contour is displaced in raw-height terms, NOT an output elevation change. What the
+ /// > eye sees is the LATERAL wander — that displacement divided by the local raw gradient.
+ /// > Measured on the reference (seed 1375359975): |∇raw| at the K3/K4/K5 contours is p50 0.00088
+ /// > raw/px, so 12 m of input height buys a median peak displacement of ~54 px and a mean of ~8 px
+ /// > along the boundary. 5 m — the first attempt — moved it a mean 3.7 px and was invisible at
+ /// > map scale.
+ ///
+ /// ═══ ⚠ OUTPUT-HEIGHT ONLY. THE CLASSIFY FIELD NEVER SEES EITHER PASS. ═══
+ ///
+ /// Both are consumers of the raw height, never producers of it. That is what keeps the classify
+ /// map — and every biome and water body that will later be derived from it — invariant under
+ /// every detail change.
+ ///
+ /// ═══ WHAT THIS FILE DELIBERATELY DOES NOT CONTAIN ═══
+ ///
+ /// The reference's task-10 draft also carried D8 flow routing, accumulation and drainage
+ /// INCISION. It shipped, produced the canonical grid artifact — thousands of straight,
+ /// disconnected, pooling scratches along the D8 neighbour directions — and was reverted whole.
+ /// → `Design - Terrain - D8 Incision Revert.md`. D8 returns later as ANALYSIS only.
+ ///
+ /// Engine-free (System.MathF), so it sits in Core/ beside : it
+ /// reads and is meaningless apart from the curve it warps.
+ ///
+ public static class TerrainDetailPass
+ {
+ /// Detail body version — the identity of this pass's layout.
+ public const ushort Version = 1;
+
+ // ---- pass A: micro-relief -------------------------------------------
+
+ /// Micro-relief amplitude, metres of OUTPUT height. Reference default: 3 m.
+ public const float ReliefAmpDefaultM = 3f;
+
+ ///
+ /// Micro-relief frequency, periods per MAP WIDTH. Reference: 40 — about 40 undulations
+ /// across the island (~200 m features at 8K). ⚠ Scale-safe by construction: stated per map
+ /// width, so the feature SIZE in metres holds at every map profile.
+ ///
+ public const float ReliefFreqPerMapWidth = 40f;
+
+ /// Micro-relief field seed offset. Reference: 7409. ⚠ A SEED offset, not a coordinate offset.
+ public const int ReliefSeedOffset = 7409;
+
+ // ---- pass B: shelf-edge variation -----------------------------------
+
+ /// Edge-warp amplitude, metres of INPUT height. Reference default: 12 m. See the type header.
+ public const float EdgeAmpDefaultM = 12f;
+
+ ///
+ /// Edge-warp frequency, periods per MAP WIDTH. Reference: 20 — ~410 px wavelength at 8K,
+ /// coves and notches at the scale of the developer's sketch, not a fringe of teeth.
+ ///
+ public const float EdgeFreqPerMapWidth = 20f;
+
+ /// Edge-warp field seed offset. Reference: 7507. ⚠ A SEED offset, not a coordinate offset.
+ public const int EdgeSeedOffset = 7507;
+
+ ///
+ /// The warp bound, as a fraction of the smaller adjacent band. Reference: 2/3.
+ ///
+ /// ⚠ THE REAL CONSTRAINT IS BAND SQUEEZE; KNOT ORDERING FOLLOWS FROM IT. The shift compresses
+ /// whichever of the foothill riser / plateau it moves into. Bounding it at 2/3 of the smaller
+ /// band means that band never compresses below a THIRD of its nominal width — its slope never
+ /// more than triples, even where peak noise lands exactly on a boundary. Ordering
+ /// (K2 < K3±d, K5±d < K6) is then automatic, with a third of each band to spare.
+ ///
+ public const float EdgeSafetyFraction = 2f / 3f;
+
+ // ---- the crater's precedence ----------------------------------------
+ //
+ // ⚠ INERT UNTIL THE CRATER CARVE LANDS. No crater exists in this phase, so
+ // CraterDetailWeight is called with a non-positive radius and returns 1 everywhere — detail
+ // applies unmasked. The logic is ported now rather than later because it is part of THIS
+ // pass's contract, and bolting it on after the carve arrives is how the reference's 532-px
+ // bug happened in the first place. It is exercised when the carve lands.
+
+ ///
+ /// Detail exclusion radius, as a factor of CraterRadius. Reference: 0.80 — EXACTLY the
+ /// carve's own extent (CRATER_CARVE_FACTOR), so the two agree by construction rather
+ /// than by two constants that happen to match.
+ ///
+ public const float CraterDetailExclFactor = 0.80f;
+
+ /// Detail feather-to-full radius, factor of CraterRadius. Reference: 1.05.
+ public const float CraterDetailFeatherFactor = 1.05f;
+
+ ///
+ /// Detail weight from distance to the impact centre: 0 inside the carve, 1 well outside it,
+ /// linear between.
+ ///
+ /// ═══ WHY IT EXISTS — measured, not precautionary ═══
+ ///
+ /// Without it, detail moves a column's PRE-carve height, the carve's Lerp passes a fraction
+ /// of that through, and columns sitting a metre or two above sea inside the bowl get pushed
+ /// UNDER it: 532 px on reference seed 1158286446 in the first batch — terrain below the sea
+ /// scalar that the (classify-driven, and correctly unchanged) water grid calls dry. The carve
+ /// is the final authority on its own terrain; detail yields to it.
+ ///
+ ///
+ /// The configured crater radius. ⚠ Non-positive means NO CRATER EXISTS — returns 1
+ /// (detail unmasked). That is this phase's state, and it is a defined case, not a fallthrough.
+ ///
+ public static float CraterDetailWeight(float distToCrater, float craterRadius)
+ {
+ if (craterRadius <= 0f) return 1f; // no crater in this phase — see the note above
+
+ float excl = craterRadius * CraterDetailExclFactor;
+ if (distToCrater <= excl) return 0f;
+
+ float feather = craterRadius * CraterDetailFeatherFactor;
+ if (distToCrater >= feather) return 1f;
+
+ return (distToCrater - excl) / (feather - excl);
+ }
+
+ ///
+ /// The largest per-column knot shift this knot set allows — see .
+ /// K1/K2/K6 never move, so the toe, the orange/red bands and the summit spike are
+ /// bit-identical whatever the warp does; that is what makes the red-ceiling floor and the
+ /// peak cap exact rather than statistical.
+ ///
+ public static float MaxEdgeShift(CurveKnots k)
+ => EdgeSafetyFraction * MathF.Min(k.K3 - k.K2, k.K6 - k.K5);
+
+ ///
+ /// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0 through the
+ /// risers. Covers both shelves (bench and plateau).
+ ///
+ /// ⚠ must be the SAME per-column warp the curve was evaluated
+ /// with, so the micro-relief skin follows the shelf wherever pass B moved its boundary.
+ /// Passing 0 here while the curve got a shift puts the skin on the wrong ground.
+ ///
+ /// ⚠ Note the asymmetry in the second call: K6 is NOT shifted, because K6 never moves.
+ ///
+ public static float ShelfWeight(float raw, CurveKnots k, float edgeShift)
+ => MathF.Max(BandBump(raw, k.K3 + edgeShift, k.K4 + edgeShift),
+ BandBump(raw, k.K5 + edgeShift, k.K6));
+
+ ///
+ /// A trapezoid over one band: full inside the central 60 %, linear feather to zero at 130 %
+ /// of the band half-width — so the skin dies out inside the risers rather than at the exact
+ /// band edge, which would put a visible seam on the boundary the warp is busy hiding.
+ ///
+ private static float BandBump(float h, float lo, float hi)
+ {
+ float half = (hi - lo) * 0.5f;
+ if (half <= 0f) return 0f; // degenerate band — no skin, no divide by zero
+ float t = MathF.Abs(h - (lo + half)) / half; // 0 at centre, 1 at the band edge
+ return Math.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f);
+ }
+ }
+}
diff --git a/Core/Scripts/TerrainDetailPass.cs.uid b/Core/Scripts/TerrainDetailPass.cs.uid
new file mode 100644
index 0000000..bad94f5
--- /dev/null
+++ b/Core/Scripts/TerrainDetailPass.cs.uid
@@ -0,0 +1 @@
+uid://c7q3lm57y55ib
diff --git a/Core/Scripts/WorldScale.cs b/Core/Scripts/WorldScale.cs
new file mode 100644
index 0000000..90d61d3
--- /dev/null
+++ b/Core/Scripts/WorldScale.cs
@@ -0,0 +1,80 @@
+namespace IslaApocalypse.Core
+{
+ ///
+ /// ⭐ THE WORLD'S VERTICAL YARDSTICK — the single metres↔raw-height conversion for the rewrite.
+ ///
+ /// ═══ THE RULE ═══
+ ///
+ /// THERE IS EXACTLY ONE METRES-PER-RAW-UNIT NUMBER, AND IT LIVES HERE.
+ /// No literal 251f anywhere. No second M_PER_UNIT. Ever.
+ ///
+ /// ═══ ⚠⚠ WHY THIS TYPE EXISTS — THE PROTOTYPE'S SCATTER ═══
+ ///
+ /// The reference had this number in ONE derived place and then wrote it out by hand everywhere:
+ ///
+ /// • Core/Constants.cs: HEIGHT_SCALE = CHUNK_HEIGHT - 5 — the only DERIVED
+ /// definition, and the only one the runtime (ServerChunkManager) actually used.
+ /// • THREE independent hardcoded copies: HydraulicErosion.M_PER_UNIT = 251f,
+ /// DrainageAnalysis.M_PER_UNIT = 251f, RiverCarvePass.M_PER_UNIT = 251f.
+ /// • ~20 bare 251f literals across HeightCurve and MapGenerator.
+ ///
+ /// So the GENERATOR never used the derived constant at all. Retuning the chunk height would have
+ /// moved the runtime's yardstick and left every generated constant behind — silently, because a
+ /// literal does not throw. (chat2/00 report § C.7.)
+ ///
+ /// ═══ ⚠⚠ WHERE 251 CAME FROM, AND WHAT IS STILL UNDECIDED ═══
+ ///
+ /// In the prototype this equalled CHUNK_HEIGHT - 5 = 256 - 5: a VOXEL-COLUMN BUDGET the
+ /// MESHER owned — the renderable height band, not a fact about the world. Every "420 m peak",
+ /// "220 m plateau" and "±3 m relief skin" in the generator was therefore denominated in a unit
+ /// defined by a rendering constant.
+ ///
+ /// > ### ⚑ HERE IT IS A STANDALONE WORLD CONSTANT.
+ /// > **Whether it stays coupled to a future chunk height is a DEFERRED DESIGN DECISION for the
+ /// > vault, and this port does not settle it.** The value 251 is carried because the curve's
+ /// > anchors were tuned against it and D-050 says port, don't re-derive — not because a
+ /// > 256-voxel chunk has been decided on. If the vault later rules that the world's vertical
+ /// > scale is its own number, only this file changes.
+ ///
+ /// The vault currently records only "roughly 251 m per raw height unit"
+ /// (`Design - Water - Storm Ladder.md`) and does not record the chunk-height derivation at all.
+ /// That gap is flagged for graduation, not fixed here — only master writes the vault.
+ ///
+ public static class WorldScale
+ {
+ ///
+ /// Metres of world height per raw height unit.
+ ///
+ /// ⚠ Ported value, not a re-derivation: the redistribution curve's storm-ladder anchors
+ /// (420 m cap, 220 m plateau, 100 m bench) were calibrated against exactly this number, so
+ /// changing it reshapes the island. → D-050.
+ ///
+ public const float MetresPerRawUnit = 251f;
+
+ ///
+ /// The inverse, for callers that genuinely want a multiplier.
+ ///
+ /// ⚠⚠ NOT INTERCHANGEABLE WITH . In float32,
+ /// 420f * (1f/251f) and 420f / 251f are DIFFERENT NUMBERS — they differ in the
+ /// last bits. The reference wrote the division (420f / 251f), so every anchor this
+ /// repo derives must divide too, or the port is off by an ulp at every knot and no oracle
+ /// can prove fidelity. Use unless you specifically need the
+ /// reciprocal.
+ ///
+ public const float RawUnitsPerMetre = 1f / MetresPerRawUnit;
+
+ ///
+ /// Metres → raw height units. **Divides**, bit-for-bit as the reference wrote it
+ /// (420f / 251f) — see the warning on .
+ ///
+ public static float RawFromMetres(float metres) => metres / MetresPerRawUnit;
+
+ /// Raw height units → metres. The reference's × 251f.
+ public static float MetresFromRaw(float raw) => raw * MetresPerRawUnit;
+
+ /// One line for a run header. Print it; a yardstick worth having is worth stating.
+ public static string Describe() =>
+ $"1 raw height unit = {MetresPerRawUnit:F0} m (single source: Core/WorldScale; " +
+ "chunk-height coupling is a DEFERRED vault decision)";
+ }
+}
diff --git a/Core/Scripts/WorldScale.cs.uid b/Core/Scripts/WorldScale.cs.uid
new file mode 100644
index 0000000..cb66efd
--- /dev/null
+++ b/Core/Scripts/WorldScale.cs.uid
@@ -0,0 +1 @@
+uid://b01o7jxa174xr
diff --git a/README.md b/README.md
index 20b8df8..41617c5 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,13 @@
The rewrite (**D-049**). A post-apocalyptic survival voxel game: one island, generated once offline,
loaded by everyone.
-**Status: Phase 0 — foundation.** This repo is a *skeleton*. It builds, it resolves its own runtime
-directory, and it carries the two data contracts and the tooling rails. **It generates nothing.**
+**Status: Phase 2 — shaping.** Phase 0 laid the contracts and the tooling rails; Phase 1 ported the
+crown-jewel noise and pass 1 (the island's raw shape); chat2/01 added **pass 2a — the height
+redistribution curve and shelf detail**, re-calibrated against this repo's own output, with the
+two-height-field split and an automatic oracle.
+
+**Still no** erosion, rivers, water bodies, crater carve, coast shelf, offshore islets, biomes,
+roads or mesher.
---
@@ -50,9 +55,9 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
```
Core/ math + data only, ENGINE-FREE — depends on nothing above it
-Server/ authoritative logic — may use Core (empty: Phase 0)
-Client/ rendering — may use Core (empty: Phase 0)
-Tools/ the offline generator — ⚠ may NOT use Client (Phase 1 fills it)
+Server/ authoritative logic — may use Core (empty: not yet its phase)
+Client/ rendering — may use Core (empty: not yet its phase)
+Tools/ the offline generator — ⚠ may NOT use Client
```
Each layer has a `README.md` stating its role and its boundary. Read the one for the layer you are
@@ -68,6 +73,12 @@ about to write in — they carry the reasons, not just the rules.
per-origin, never a material property. → `Core/Scripts/MaterialRegistry.cs`
- **The scaling discipline** — nothing uses a raw pixel number.
→ `Core/Scripts/GenerationScale.cs`
+- **The vertical yardstick** — ONE metres-per-raw-unit number, not three constants and twenty
+ literals as the prototype had. → `Core/Scripts/WorldScale.cs`
+- **The elevation profile** — the 7-band redistribution curve, **identity at and below sea**, its
+ knots measured rather than chosen. → `Core/Scripts/HeightCurve.cs`
+- **The two-height-field split** (D-046) — `heightClassify` is raw and is the oracle;
+ `height` is curved and is what gets drawn. → `Tools/Scripts/Pass2Result.cs`
- **Tooling safety** — every path env-overridable, deletion refused by code.
→ `Core/Scripts/ToolingPaths.cs`, `Core/Scripts/FileSafety.cs`
@@ -83,9 +94,21 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless --import --path .
> The reference repo is Godot 4.7.1. This repo deliberately targets **4.7.2** — clean rewrite,
> current tooling. 4.7.1 is the *port source*, not a constraint on new code.
+> ### ⚠ `Major` in the `.csproj` is load-bearing.
+>
+> The target is `net8.0` but this machine carries only a .NET 10 runtime. Without that property the
+> generated `runtimeconfig.json` rolls forward by MINOR only and every run fails with *"You must
+> install .NET 8"*. **This bites at run time, not build time** — the build succeeds either way — so
+> re-check the emitted `runtimeconfig.json` after any `.csproj` change.
+
## What this phase is NOT
-No generation, no mesher, no stratigraphy, no biomes, no water, no algorithms of any kind. The
-pipeline is **2D-maps-first** (D-056): stages 1–5 are flat maps, gotten completely right before a
-single 3D vertex exists. Building the mesher before the data is right is the specific trap this
-rewrite exists to undo.
+No mesher, no stratigraphy, no biomes, no water, no rivers, no erosion. The pipeline is
+**2D-maps-first** (D-056): stages 1–5 are flat maps, gotten completely right before a single 3D
+vertex exists. Building the mesher before the data is right is the specific trap this rewrite
+exists to undo.
+
+**And the curve is a BASELINE, not a verdict.** It is ported faithfully and calibrated honestly so
+that later reshaping has a control to be judged against. Whether this elevation profile — 60 % of
+land in the bottom 14 m, 4 % above the plateau — is the one the world wants is an open question the
+histograms exist to inform, not one this port has answered.
diff --git a/Tools/README.md b/Tools/README.md
index f6e628e..c5bafbd 100644
--- a/Tools/README.md
+++ b/Tools/README.md
@@ -37,6 +37,64 @@ constants, carried over verbatim — not re-derived from a design summary** (→
| `Scripts/TerrainGenTool.cs` | Batch entry point (ladder + seed batch + `INDEX.md`) |
| `Scenes/TerrainGenTool.tscn` | Run this |
+### The generator — pass 2a: the redistribution curve (Phase 2, chat2/01)
+
+**Ported from `REFERENCE:Tools/Scripts/HeightCurve.cs` + `TerrainDetailPass.cs`** at the same tag.
+The curve itself lives in `Core/` (engine-free, a named C++-candidate seam); `Tools/` carries the
+wiring, the measuring instrument and the batch.
+
+| File | What it is |
+|---|---|
+| `Scripts/Shaping.cs` | ⭐⭐ Pass 2a — curve + detail per column, producing **the two height fields** |
+| `Scripts/Pass2Result.cs` | The render field, the classify field, and `FieldsAreAliased` |
+| `Scripts/LandHistogram.cs` | ⭐ The land CDF — **the calibration engine AND the diagnostic** |
+| `Scripts/HistogramRenderer.cs` | The labelled distribution plot (clipped axis, marked) |
+| `Scripts/ShapingOracle.cs` | ⭐⭐ The four automatic correctness checks |
+| `Scripts/CurveBaselineTool.cs` | The chat2/01 batch: calibrate → variants → histograms → oracle |
+| `Scenes/CurveBaselineTool.tscn` | Run this |
+
+> ### ⭐⭐ THE TWO-FIELD SPLIT (D-046) STARTS HERE
+>
+> `heightClassify` is **raw, uncurved, un-detailed** — bit-for-bit the pass-1 field.
+> `height` is **curved and detailed**, and is what gets drawn and (later) eroded and carved.
+>
+> Everything that CLASSIFIES the world (biomes, water bodies, the ocean fill) will read the classify
+> field; everything that DRAWS it reads the render field. Nothing consumes the classify field yet —
+> it is established at the curve because **the curve is where the second field is born**, and
+> retrofitting a classify path after three passes have run on one array is how the two silently
+> diverge. `Pass2Result.FieldsAreAliased` is true when the curve is off (both point at one array, as
+> the reference did) — a later pass that writes one while reading the other must read both into
+> locals first.
+
+> ### ⭐ THE KNOTS ARE MEASURED, NOT CHOSEN
+>
+> The curve's six input knots are **P60/73/83/88/96/99 of the pooled land height distribution**,
+> which is what makes the band shares 60/13/10/5/8/3/1 exact by construction. The reference shipped
+> the six resulting literals and threw the instrument away — so its knots could never be re-derived,
+> only trusted. `LandHistogram` is that instrument, rebuilt.
+>
+> ⚠ **A knot set is only valid for the distribution it was measured on.** Re-run
+> `CurveBaselineTool` after any change to pass 1.
+
+```bash
+xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
+ --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CurveBaselineTool.tscn
+```
+
+`ISLA_MAPSIZE` (default 2048) · `ISLA_SEEDS` (6 pinned) · `ISLA_SHOWPIECE_SIZE` (8192) ·
+`ISLA_SHOWPIECE=0` · `ISLA_VARIANTS=0` (calibration-only probe) · `ISLA_PHASE1_SOURCE` ·
+`ISLA_SKIP_RAW=1`
+
+**The oracle runs before anything is looked at**, and the tool exits non-zero if any check fails:
+
+| | Check |
+|---|---|
+| `a` | curve OFF is bit-identical to Phase 1's pass-1 output |
+| `a′` | pass 1 is bit-identical to Phase 1's `.f32` dump |
+| `b` | the classify field is bit-identical to the raw pre-curve field, curve on or off |
+| `c` | the effective per-seed curve is strictly monotonic (24-corner sweep, throws otherwise) |
+| `d` | realized land shares match 60/13/10/5/8/3/1 |
+
### The map renders — presentation (Phase 1, the look)
**Presentation only** (→ `Design - Rendering - Roughness Is Presentation.md`): it changes how height
@@ -139,12 +197,19 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
> 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
+**Deferred, 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.
+> ⚠⚠ **When the coast shelf and islets land, `HMaxSeed` must move with them.** The reference takes
+> its map-wide max *after* both layers have already modified the height, inside the same pass-1 loop
+> (`MapGenerator.cs:665`). v2 currently takes it before, because the layers do not exist. Since
+> `HMaxSeed` normalizes the curve's summit spike, porting those layers without moving the max
+> computation below them changes the world for a given seed — silently, with no throw and no failed
+> assertion. → chat2/00 report, Drift §2.
+
+**Not here at all:** erosion, rivers, water bodies, the crater carve, biomes, roads, the mesher.
### ⚠ The render writes a `Godot.Image` directly — not the capture path
diff --git a/Tools/Scenes/CurveBaselineTool.tscn b/Tools/Scenes/CurveBaselineTool.tscn
new file mode 100644
index 0000000..daa17cb
--- /dev/null
+++ b/Tools/Scenes/CurveBaselineTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://cvbaseline01isla"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/CurveBaselineTool.cs" id="1_cbt"]
+
+[node name="CurveBaselineTool" type="Node"]
+script = ExtResource("1_cbt")
diff --git a/Tools/Scripts/CurveBaselineTool.cs b/Tools/Scripts/CurveBaselineTool.cs
new file mode 100644
index 0000000..c2e3a77
--- /dev/null
+++ b/Tools/Scripts/CurveBaselineTool.cs
@@ -0,0 +1,658 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ THE FAITHFUL-CURVE BASELINE BATCH (chat2/01) — the control the reshape will be judged
+ /// against, plus the instrument that decides what the reshape should be.
+ ///
+ /// ═══ WHAT IT DOES, IN ORDER ═══
+ ///
+ /// 1. CALIBRATE — pool the LAND distribution across several seeds and take
+ /// P60/73/83/88/96/99. Those quantiles ARE the curve's knots, which is what makes the band
+ /// shares 60/13/10/5/8/3/1 exact by construction. Reports them against the reference's
+ /// shipped literals: that delta is the port-fidelity evidence.
+ /// 2. VARIANTS — per seed, `curve_off` (the control) and `curve_on` (the faithful baseline).
+ /// Grayscale + raw `.f32` + relief, because a pretty render alone cannot be argued with.
+ /// 3. HISTOGRAMS — raw and shaped land distributions for the showpiece seed, with the knots and
+ /// the output anchors overlaid, plus the per-band mass table.
+ /// 4. ORACLE — the four automatic checks, run before anything is looked at.
+ /// 5. SHOWPIECE — one render at a larger profile for the judging plate.
+ ///
+ /// ═══ ⚠ CALIBRATE SMALL, CONFIRM BIG ═══
+ ///
+ /// Knots are measured at the iteration profile (2K) and used at every profile. That rests on the
+ /// land distribution's SHAPE being scale-invariant — which Phase 1 established by construction
+ /// (every frequency and distance is a fraction of MapSize). The tool does not take that on trust:
+ /// the showpiece re-measures its own quantiles and reports the delta, so the assumption is
+ /// evidence rather than a footnote.
+ ///
+ /// ═══ RUNNING IT ═══
+ ///
+ /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
+ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CurveBaselineTool.tscn
+ ///
+ /// (`--headless` also works — this tool awaits no render frames — but `xvfb-run` is correct in
+ /// both cases and costs nothing. → Tools/README.md §1.)
+ ///
+ /// ISLA_TASK authoring task number (default 1)
+ /// ISLA_BATCH descriptor, NO prefix (default "curve_baseline")
+ /// ISLA_MAPSIZE calibration/variant profile (default 2048)
+ /// ISLA_SEEDS comma-separated positive (default: the 6 pinned below)
+ /// ISLA_SHOWPIECE_SIZE larger confirmation profile (default 8192)
+ /// ISLA_SHOWPIECE "0" to skip the big render
+ /// ISLA_VARIANTS "0" to skip the per-seed variants (calibration-only probe)
+ /// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "02_pass1_port")
+ /// ISLA_SKIP_RAW "1" to skip the .f32 dumps
+ ///
+ public partial class CurveBaselineTool : Node
+ {
+ ///
+ /// PINNED POSITIVE SEEDS — the four Phase-1 seeds plus two, for a six-seed calibration pool.
+ /// Pinned, not random: a calibration you cannot reproduce is a magic number with a good story.
+ ///
+ private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
+
+ private const int DefaultMapSize = 2048;
+ private const int DefaultShowpieceSize = 8192;
+
+ ///
+ /// Band-share tolerance, percentage points. The knots come from the SAME histogram the shares
+ /// are measured on, so the only error is in-bin interpolation — tenths, not units. A loose
+ /// tolerance here would make check (d) unfalsifiable.
+ ///
+ private const double ShareTolerancePp = 0.25;
+
+ public override void _Ready()
+ {
+ // ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits there
+ // with no main loop to end it, so a misconfigured run HANGS instead of failing. A hang
+ // looks like slow work, which is worse than a crash. Catch, say what was refused, exit 2.
+ try { Run(); }
+ catch (Exception e)
+ {
+ GD.PrintErr("==================================================================");
+ GD.PrintErr($" REFUSED: {e.Message}");
+ GD.PrintErr("==================================================================");
+ GetTree().Quit(2);
+ }
+ }
+
+ private void Run()
+ {
+ ToolingPaths.Configure(OS.GetUserDataDir());
+
+ int task = EnvInt("ISLA_TASK", 1);
+ string descr = EnvStr("ISLA_BATCH", "curve_baseline");
+ int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
+ int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
+ int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
+ bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
+ bool variants = EnvStr("ISLA_VARIANTS", "1") == "1";
+ string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
+ bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
+
+ // ⚠ Composed by BatchRoot, never free-form — it refuses a descriptor carrying its own
+ // numeric prefix, which is how the counter drifted once already.
+ string batchRoot = ToolingPaths.BatchRoot(task, descr);
+ string scratch = ToolingPaths.BatchScratch(batchRoot);
+ DirAccess.MakeDirRecursiveAbsolute(batchRoot);
+ DirAccess.MakeDirRecursiveAbsolute(scratch);
+
+ var scale = new GenerationScale(mapSize);
+ var anchors = CurveAnchors.Default;
+ float sea = 0.15f;
+
+ GD.Print("==================================================================");
+ GD.Print(" CURVE BASELINE — faithful redistribution port + the histogram");
+ GD.Print("==================================================================");
+ GD.Print($"MapSize : {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
+ GD.Print($"yardstick : {WorldScale.Describe()}");
+ GD.Print($"anchors : {anchors.DescribeMetres()}");
+ GD.Print($"seeds : {string.Join(", ", seeds)}");
+ GD.Print($"batch : {batchRoot}");
+ GD.Print("------------------------------------------------------------------");
+ GD.Print(ToolingPaths.Describe());
+ GD.Print("==================================================================");
+
+ // ═══════════════════════════════════════════════════════════════════
+ // 1. CALIBRATE — the knots ARE percentiles of the pooled land CDF
+ // ═══════════════════════════════════════════════════════════════════
+ GD.Print("\n--- 1. CALIBRATION POOL ---");
+
+ var rawPool = new LandHistogram(sea);
+ var pass1 = new Dictionary();
+ var perSeedKnots = new List();
+
+ foreach (int seed in seeds)
+ {
+ var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed };
+ Pass1Result p1 = Topography.Generate(cfg);
+ pass1[seed] = p1;
+ rawPool.Accumulate(p1.Height, mapSize);
+
+ // Each seed's OWN knots too — the pool is the calibration, but the SPREAD across
+ // seeds is what tells us how much of any later delta is scale and how much is just
+ // which seed you happened to look at. Without it, the scale-invariance check below
+ // compares a 6-seed pool against one seed and calls the difference "scale".
+ var solo = new LandHistogram(sea);
+ solo.Accumulate(p1.Height, mapSize);
+ perSeedKnots.Add(MeasureKnots(solo, $"seed_{seed}"));
+
+ GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms");
+ }
+ GD.Print($" {rawPool}");
+
+ float seedSpread = KnotSpread(perSeedKnots);
+ GD.Print($" per-seed knot spread at {mapSize}: max range across the 6 seeds = " +
+ $"{seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m)");
+
+ CurveKnots measured = MeasureKnots(rawPool, "v2_balanced");
+ GD.Print("\n MEASURED KNOTS vs THE REFERENCE'S SHIPPED LITERALS");
+ GD.Print(" knot pct v2 measured reference delta delta (m)");
+ for (int i = 0; i < 6; i++)
+ {
+ float v2 = measured[i], rf = CurveKnots.Reference[i];
+ GD.Print($" K{i + 1} P{CurveKnots.Percentiles[i],-5:F0} {v2,11:F6} {rf,11:F6} " +
+ $"{v2 - rf,+9:F6} {WorldScale.MetresFromRaw(v2 - rf),+8:F2}");
+ }
+
+ double[] shares = ShapingOracle.RealizedShares(rawPool, measured);
+ GD.Print("\n REALIZED LAND SHARES (target 60/13/10/5/8/3/1)");
+ for (int i = 0; i < 7; i++)
+ GD.Print($" {CurveKnots.BandNames[i],-16} {shares[i],6:F2} % (target {CurveKnots.BandShareTargets[i],4:F0} %)");
+
+ // ═══════════════════════════════════════════════════════════════════
+ // 2. THE SHAPED POOL — what the curve actually produces
+ // ═══════════════════════════════════════════════════════════════════
+ GD.Print("\n--- 2. SHAPED POOL (curve on, detail on) ---");
+
+ var shapedPool = new LandHistogram(sea);
+ var shapedResults = new Dictionary();
+
+ foreach (int seed in seeds)
+ {
+ var cfg = OnConfig(mapSize, seed, measured, anchors);
+ Pass2Result p2 = Shaping.Shape(pass1[seed], cfg);
+ shapedResults[seed] = p2;
+ shapedPool.Accumulate(p2.Height, mapSize);
+ if (seed == seeds[0]) foreach (string n in p2.Notes) GD.Print(" " + n);
+ GD.Print($" shaped seed {seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] {p2.ElapsedMs,5} ms");
+ }
+ GD.Print($" {shapedPool}");
+
+ GD.Print("\n SHAPED LAND PERCENTILES (what the palette would need to be placed on)");
+ double[] paletteP = { 10, 25, 50, 75, 90, 95, 99, 99.9 };
+ var sb2 = new StringBuilder(" ");
+ foreach (double p in paletteP) sb2.Append($" p{p:G}={shapedPool.Quantile(p):F3}");
+ GD.Print(sb2.ToString());
+ GD.Print($" shaped max {shapedPool.MaxLand:F4} (peak cap {anchors.PeakCap:F4})");
+
+ // ⚠ THE PALETTE WAS CALIBRATED ON THE *RAW* DISTRIBUTION (Phase 1, chat1/03) and the
+ // curve moves that distribution wholesale. This is reported, NOT fixed: re-placing the
+ // stops now would make the render look more dramatic while the terrain is genuinely
+ // lower — i.e. it would disguise the exact finding the histograms exist to deliver.
+ // Whether to recalibrate is a PRESENTATION call the developer makes AFTER deciding
+ // whether this elevation profile is the one they want.
+ double rawLow = raw3Stop(rawPool), shapedLow = raw3Stop(shapedPool);
+ double rawSat = 1.0 - rawPool.FractionBelow(1.450f), shapedSat = 1.0 - shapedPool.FractionBelow(1.450f);
+ GD.Print("\n PALETTE FIT (CostaRica stops were placed on the RAW distribution)");
+ GD.Print($" land below stop 3 (0.310 raw): raw {rawLow * 100:F1} % -> shaped {shapedLow * 100:F1} %");
+ GD.Print($" land above top stop (1.450): raw {rawSat * 100:F3} % -> shaped {shapedSat * 100:F3} % (clamps to white)");
+
+ // ═══════════════════════════════════════════════════════════════════
+ // 3. THE ORACLE — before a single render is looked at
+ // ═══════════════════════════════════════════════════════════════════
+ GD.Print("\n--- 3. ORACLE ---");
+ var checks = new List();
+
+ int primary = seeds[0];
+ Pass1Result pp1 = pass1[primary];
+
+ Pass2Result offPrimary = Shaping.Shape(pp1, OffConfig(mapSize, primary));
+ checks.Add(ShapingOracle.RegressionCurveOff(pp1, offPrimary));
+
+ string dumpPath = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
+ float[,] phase1Dump = HeightField.Load(dumpPath, mapSize);
+ checks.Add(ShapingOracle.RegressionAgainstDump(pp1.Height, phase1Dump, mapSize, dumpPath));
+
+ checks.Add(ShapingOracle.ClassifyFidelity(pp1, shapedResults[primary]));
+ checks.Add(ShapingOracle.Monotonicity(shapedResults[primary]));
+ checks.Add(ShapingOracle.BandShares(rawPool, measured, ShareTolerancePp));
+
+ // Every seed's classify field, not just the showpiece's — the invariant is per-seed.
+ long classifyDrift = 0;
+ foreach (int seed in seeds)
+ {
+ var c = ShapingOracle.ClassifyFidelity(pass1[seed], shapedResults[seed]);
+ if (!c.Passed) { classifyDrift++; GD.PrintErr($" classify drift on seed {seed}: {c.Detail}"); }
+ }
+
+ foreach (var c in checks) GD.Print(" " + c);
+ bool oracleOk = checks.TrueForAll(c => c.Passed) && classifyDrift == 0;
+ GD.Print($" ORACLE: {(oracleOk ? "ALL PASS" : "FAILURES PRESENT")} " +
+ $"(classify checked on all {seeds.Length} seeds: {seeds.Length - classifyDrift} clean)");
+
+ // ═══════════════════════════════════════════════════════════════════
+ // 4. VARIANTS — plain data beside the pretty render
+ // ═══════════════════════════════════════════════════════════════════
+ var rows = new List();
+ if (variants)
+ {
+ GD.Print("\n--- 4. VARIANTS (curve_off / curve_on) ---");
+ foreach (int seed in seeds)
+ {
+ rows.Add(WriteVariant(batchRoot, pass1[seed], OffConfig(mapSize, seed), skipRaw, sea, 1.45f));
+ rows.Add(WriteVariant(batchRoot, pass1[seed], OnConfig(mapSize, seed, measured, anchors),
+ skipRaw, sea, anchors.PeakCap));
+ }
+ }
+ else GD.Print("\n--- variants skipped (ISLA_VARIANTS=0) ---");
+
+ // ═══════════════════════════════════════════════════════════════════
+ // 5. HISTOGRAMS — the diagnostic, for the showpiece seed
+ // ═══════════════════════════════════════════════════════════════════
+ GD.Print("\n--- 5. HISTOGRAMS ---");
+ var rawSeed = new LandHistogram(sea);
+ rawSeed.Accumulate(pass1[primary].Height, mapSize);
+ var shapedSeed = new LandHistogram(sea);
+ shapedSeed.Accumulate(shapedResults[primary].Height, mapSize);
+
+ DrawRawHistogram(rawSeed, measured, primary, mapSize, batchRoot);
+ DrawShapedHistogram(shapedSeed, rawSeed, measured, anchors, primary, mapSize, batchRoot);
+
+ // ═══════════════════════════════════════════════════════════════════
+ // 6. SHOWPIECE — the judging plate, and the scale-invariance check
+ // ═══════════════════════════════════════════════════════════════════
+ string showNote = "skipped (ISLA_SHOWPIECE=0)";
+ if (showpiece)
+ {
+ GD.Print($"\n--- 6. SHOWPIECE at {showSize} ---");
+ showNote = WriteShowpiece(batchRoot, primary, showSize, measured, anchors, sea, skipRaw, rows, seedSpread);
+ }
+ else GD.Print("\n--- showpiece skipped (ISLA_SHOWPIECE=0) ---");
+
+ WriteIndex(batchRoot, mapSize, showSize, scale, seeds, primary, measured, anchors,
+ rawPool, shapedPool, shares, checks, rows, oracleOk, showNote, variants, seedSpread);
+
+ GD.Print("\n==================================================================");
+ GD.Print($" DONE — {batchRoot}");
+ GD.Print($" ORACLE {(oracleOk ? "ALL PASS" : "*** FAILURES — see the table ***")}");
+ GD.Print("==================================================================");
+ GetTree().Quit(oracleOk ? 0 : 3);
+ }
+
+ // ---- configs --------------------------------------------------------
+
+ private static TerrainGenConfig OffConfig(int mapSize, int seed) =>
+ new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_off", Curve = false, ShelfDetail = false };
+
+ private static TerrainGenConfig OnConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a) =>
+ new TerrainGenConfig
+ {
+ MapSize = mapSize, Seed = seed, VariantLabel = "curve_on",
+ Curve = true, ShelfDetail = true, Knots = k, Anchors = a,
+ };
+
+ ///
+ /// Fraction of land below the CostaRica palette's third stop (0.310 raw). A blunt
+ /// "how much of the island is painted with the first two colours" number — the palette's own
+ /// stops are at measured RAW percentiles, so this is how far the curve moved the picture.
+ ///
+ private static double raw3Stop(LandHistogram h) => h.FractionBelow(0.310f);
+
+ ///
+ /// The widest max−min range of any single knot across a set of measurements — "how much does
+ /// K_n move if you just change which seed you looked at". The yardstick every other knot
+ /// delta in this batch has to be judged against.
+ ///
+ private static float KnotSpread(List sets)
+ {
+ float worst = 0f;
+ for (int i = 0; i < 6; i++)
+ {
+ float lo = float.MaxValue, hi = float.MinValue;
+ foreach (CurveKnots s in sets) { lo = MathF.Min(lo, s[i]); hi = MathF.Max(hi, s[i]); }
+ worst = MathF.Max(worst, hi - lo);
+ }
+ return worst;
+ }
+
+ /// The knots ARE the quantiles. This one method is the whole calibration.
+ private static CurveKnots MeasureKnots(LandHistogram pool, string name) => new CurveKnots(
+ 2, name,
+ pool.Quantile(CurveKnots.Percentiles[0]), pool.Quantile(CurveKnots.Percentiles[1]),
+ pool.Quantile(CurveKnots.Percentiles[2]), pool.Quantile(CurveKnots.Percentiles[3]),
+ pool.Quantile(CurveKnots.Percentiles[4]), pool.Quantile(CurveKnots.Percentiles[5]));
+
+ // ---- output ---------------------------------------------------------
+
+ private static string WriteVariant(string batchRoot, Pass1Result p1, TerrainGenConfig cfg,
+ bool skipRaw, float sea, float legendTop)
+ {
+ Pass2Result p2 = Shaping.Shape(p1, cfg);
+
+ string dir = Path.Combine(batchRoot, $"{cfg.Seed}_{cfg.VariantLabel}");
+ DirAccess.MakeDirRecursiveAbsolute(dir);
+
+ // ⭐ PLAIN DATA BESIDE THE PRETTY RENDER, always — the point is judging the terrain, and
+ // you cannot judge a distribution through a palette.
+ var (gMin, gMax) = GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
+ if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
+
+ var look = new LookConfig
+ {
+ Name = "gradient_flat", Palette = ReliefPalette.Kind.CostaRica,
+ HillshadeStrength = 0f, SeaLevel = sea,
+ };
+ Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
+ Image withLegend = LegendRenderer.WithLegend(map, look.Palette, sea, legendTop,
+ cfg.VariantLabel.ToUpperInvariant());
+ withLegend.SavePng(Path.Combine(dir, "relief.png"));
+
+ float land = p2.LandFraction(sea);
+ GD.Print($" {cfg.VariantLabel,-10} seed {cfg.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " +
+ $" land {land * 100,5:F1}% {p2.ElapsedMs,5} ms");
+
+ return $"| `{cfg.Seed}_{cfg.VariantLabel}` | {cfg.Seed} | {cfg.VariantLabel} | " +
+ $"{p2.HMin:F3} | {p2.HMax:F3} | {land * 100:F1}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |";
+ }
+
+ private static void DrawRawHistogram(LandHistogram raw, CurveKnots k, int seed, int mapSize, string batchRoot)
+ {
+ // Coarse enough to draw, fine enough to keep the shape: ~360 bins across the range.
+ float top = MathF.Ceiling(raw.MaxLand * 20f) / 20f;
+ var display = raw.Rebin((top - raw.SeaLevel) / 360f);
+
+ var o = new HistogramRenderer.Options
+ {
+ Title = $"RAW LAND HEIGHTS - SEED {seed}",
+ Subtitle = "PRE-CURVE. THE KNOTS ARE PERCENTILES OF THIS DISTRIBUTION.",
+ XAxisLabel = "RAW HEIGHT (PRE-CURVE)",
+ XTop = top,
+ Footer = $"{raw.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize} - BIN {display.BinWidth:F4} RAW",
+ };
+
+ float[] edges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6, top };
+ for (int i = 0; i < 7; i++)
+ o.Bands.Add(new HistogramRenderer.Band
+ {
+ Lo = edges[i], Hi = edges[i + 1],
+ Label = CurveKnots.BandNames[i],
+ SharePercent = i < 6
+ ? raw.FractionBetween(edges[i], edges[i + 1]) * 100.0
+ : Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0),
+ });
+
+ for (int i = 0; i < 6; i++)
+ o.Markers.Add(new HistogramRenderer.Marker
+ {
+ Value = k[i], Label = $"K{i + 1} P{CurveKnots.Percentiles[i]:F0}", Strong = true,
+ });
+
+ HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, "histogram_raw.png"));
+ GD.Print($" histogram_raw.png ({raw.TotalLand:N0} land columns, max {raw.MaxLand:F4})");
+ }
+
+ private static void DrawShapedHistogram(LandHistogram shaped, LandHistogram raw, CurveKnots k,
+ CurveAnchors a, int seed, int mapSize, string batchRoot)
+ {
+ float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f;
+ var display = shaped.Rebin((top - shaped.SeaLevel) / 360f);
+
+ var o = new HistogramRenderer.Options
+ {
+ Title = $"SHAPED LAND HEIGHTS - SEED {seed}",
+ Subtitle = "POST-CURVE. BANDS SIT AT THE STORM-LADDER OUTPUT ANCHORS.",
+ XAxisLabel = "RAW HEIGHT (POST-CURVE)",
+ XTop = top,
+ Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize} - BIN {display.BinWidth:F4} RAW",
+ };
+
+ // The OUTPUT bands: each input band's share, drawn where the curve puts it.
+ float benchTop = a.BenchBase + a.ShelfSpanMin;
+ float plateauTop = a.PlateauBase + a.ShelfSpanMin;
+ float[] outEdges = { a.Sea, a.OrangeCeil, a.RedCeil, a.BenchBase, benchTop, a.PlateauBase, plateauTop, top };
+ float[] inEdges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6 };
+
+ for (int i = 0; i < 7; i++)
+ o.Bands.Add(new HistogramRenderer.Band
+ {
+ Lo = outEdges[i], Hi = outEdges[i + 1],
+ Label = CurveKnots.BandNames[i],
+ SharePercent = i < 6
+ ? raw.FractionBetween(inEdges[i], inEdges[i + 1]) * 100.0
+ : Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0),
+ });
+
+ o.Markers.Add(new HistogramRenderer.Marker { Value = a.OrangeCeil, Label = "ORANGE" });
+ o.Markers.Add(new HistogramRenderer.Marker { Value = a.RedCeil, Label = "RED" });
+ o.Markers.Add(new HistogramRenderer.Marker { Value = a.BenchBase, Label = "BENCH" });
+ o.Markers.Add(new HistogramRenderer.Marker { Value = a.PlateauBase, Label = "PLATEAU" });
+ o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" });
+
+ HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, "histogram_shaped.png"));
+ GD.Print($" histogram_shaped.png ({shaped.TotalLand:N0} land columns, max {shaped.MaxLand:F4})");
+ }
+
+ private static string WriteShowpiece(string batchRoot, int seed, int showSize, CurveKnots k,
+ CurveAnchors a, float sea, bool skipRaw, List rows, float seedSpread)
+ {
+ var cfg = OnConfig(showSize, seed, k, a);
+ cfg.VariantLabel = "curve_on_showpiece";
+
+ Pass1Result p1 = Topography.Generate(cfg);
+ GD.Print($" pass1 {showSize}: h[{p1.HMinSeed:F3} .. {p1.HMaxSeed:F3}] {p1.ElapsedMs} ms");
+
+ // ⭐ THE SCALE-INVARIANCE CHECK. Knots were measured at the iteration profile; if the
+ // land distribution's shape really is scale-free, this profile's own quantiles land on
+ // the same numbers. Measured, not assumed.
+ var bigPool = new LandHistogram(sea);
+ bigPool.Accumulate(p1.Height, showSize);
+ var bigKnots = MeasureKnots(bigPool, $"at_{showSize}");
+
+ var deltas = new StringBuilder();
+ float worst = 0f;
+ for (int i = 0; i < 6; i++)
+ {
+ float d = bigKnots[i] - k[i];
+ if (MathF.Abs(d) > MathF.Abs(worst)) worst = d;
+ deltas.Append($" K{i + 1}{d:+0.0000;-0.0000}");
+ }
+ // ⚠ THE COMPARISON IS CONFOUNDED, AND SAYING SO IS THE POINT. This is ONE seed at the
+ // big profile against a SIX-SEED POOL at the small one, so the delta mixes scale effects
+ // with seed-to-seed variation. `seedSpread` is how far a knot moves from seed choice
+ // alone at a fixed size — if the delta sits inside it, scale is not what moved.
+ bool withinSeedNoise = MathF.Abs(worst) <= seedSpread;
+ GD.Print($" scale-invariance: knots re-measured at {showSize} differ by{deltas}");
+ GD.Print($" worst {worst:+0.000000;-0.000000} raw = {WorldScale.MetresFromRaw(worst):+0.00;-0.00} m " +
+ $"vs per-seed spread {seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m) " +
+ $"-> {(withinSeedNoise ? "WITHIN seed variation" : "EXCEEDS seed variation")}");
+
+ rows.Add(WriteVariant(batchRoot, p1, cfg, skipRaw, sea, a.PeakCap));
+
+ return $"seed {seed} at {showSize}; knots re-measured there differ by at most " +
+ $"{MathF.Abs(worst):F6} raw ({MathF.Abs(WorldScale.MetresFromRaw(worst)):F2} m), " +
+ $"{(withinSeedNoise ? "within" : "beyond")} the {WorldScale.MetresFromRaw(seedSpread):F2} m per-seed spread";
+ }
+
+ // ---- the index ------------------------------------------------------
+
+ private static void WriteIndex(string batchRoot, int mapSize, int showSize, GenerationScale scale,
+ int[] seeds, int primary, CurveKnots k, CurveAnchors a, LandHistogram rawPool,
+ LandHistogram shapedPool, double[] shares, List checks,
+ List rows, bool oracleOk, string showNote, bool variants, float seedSpread)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("# Batch 01 — the faithful curve baseline");
+ sb.AppendLine();
+ sb.AppendLine("The redistribution curve and shelf detail, ported faithfully and **re-calibrated against");
+ sb.AppendLine("v2's own pass-1 output**. This is the CONTROL every later reshape is judged against —");
+ sb.AppendLine("not a taste gate. No erosion, no rivers, no water, no crater, no coast shelf, no islets.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ Open this first");
+ sb.AppendLine();
+ sb.AppendLine($"1. **`{primary}_curve_on_showpiece/relief.png`** — the judging plate ({showNote}).");
+ sb.AppendLine($"2. **`histogram_raw.png`** and **`histogram_shaped.png`** — the diagnostic that says");
+ sb.AppendLine(" *why* the upper terrain looks the way it does. Read them before forming an opinion.");
+ sb.AppendLine($"3. **`{primary}_curve_off/relief.png`** vs **`{primary}_curve_on/relief.png`** — the A/B.");
+ sb.AppendLine();
+ sb.AppendLine("## Disposability");
+ sb.AppendLine();
+ sb.AppendLine("| Artifact | Keep? |");
+ sb.AppendLine("|---|---|");
+ sb.AppendLine("| `relief.png` | **keep** — the judging plates |");
+ sb.AppendLine("| `histogram_*.png` | **keep** — the finding |");
+ sb.AppendLine("| `INDEX.md` | **keep** |");
+ sb.AppendLine("| `grayscale.png` | ♻ **regenerable** from the `.f32` — safe to clear |");
+ sb.AppendLine("| `height.f32` | ♻ **regenerable** from seed + code — safe to clear, but it is the byte-level oracle |");
+ sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
+ sb.AppendLine();
+ sb.AppendLine("## Setup");
+ sb.AppendLine();
+ sb.AppendLine($"- **Calibration/variant profile:** {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
+ sb.AppendLine($"- **Showpiece profile:** {showSize}");
+ sb.AppendLine($"- **Seeds (pooled as one calibration set):** {string.Join(", ", seeds)}");
+ sb.AppendLine($"- **Yardstick:** {WorldScale.Describe()}");
+ sb.AppendLine($"- **Output anchors:** {a.DescribeMetres()}");
+ sb.AppendLine($"- **Curve:** v{HeightCurve.Version} · **detail:** v{TerrainDetailPass.Version}");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ The re-measured knots");
+ sb.AppendLine();
+ sb.AppendLine($"Pooled land CDF: **{rawPool.TotalLand:N0} samples** from {rawPool.FieldsPooled} seeds, ");
+ sb.AppendLine($"range [{rawPool.MinLand:F4} .. {rawPool.MaxLand:F4}] raw, bin {rawPool.BinWidth:G3}.");
+ sb.AppendLine();
+ sb.AppendLine("| Knot | Percentile | v2 measured | reference literal | delta (raw) | delta (m) |");
+ sb.AppendLine("|---|---|---|---|---|---|");
+ for (int i = 0; i < 6; i++)
+ {
+ float v2 = k[i], rf = CurveKnots.Reference[i];
+ sb.AppendLine($"| K{i + 1} | P{CurveKnots.Percentiles[i]:F0} | `{v2:F6}` | `{rf:F6}` | " +
+ $"{v2 - rf:+0.000000;-0.000000} | {WorldScale.MetresFromRaw(v2 - rf):+0.00;-0.00} |");
+ }
+ sb.AppendLine();
+ sb.AppendLine("> **This delta is the port-fidelity check.** Near-identical means v2's pass-1 height");
+ sb.AppendLine("> distribution matches the reference's — the crown-jewel port is faithful. A large gap");
+ sb.AppendLine("> would mean pass 1 diverged, and would be the finding rather than a nuisance.");
+ sb.AppendLine();
+ sb.AppendLine($"**Scale for judging any knot delta:** across the six seeds at {mapSize}, a single knot");
+ sb.AppendLine($"moves by up to **{seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m)** from seed choice alone.");
+ sb.AppendLine("Anything smaller than that is seed noise, not a difference.");
+ sb.AppendLine();
+ sb.AppendLine($"**Scale invariance:** {showNote}.");
+ sb.AppendLine();
+ sb.AppendLine("## Realized land shares");
+ sb.AppendLine();
+ sb.AppendLine("| Band | Realized | Target | Output lands at |");
+ sb.AppendLine("|---|---|---|---|");
+ float benchTop = a.BenchBase + a.ShelfSpanMin, plateauTop = a.PlateauBase + a.ShelfSpanMin;
+ string[] lands =
+ {
+ $"{WorldScale.MetresFromRaw(a.Sea - a.Sea):F0}–{WorldScale.MetresFromRaw(a.OrangeCeil - a.Sea):F0} m",
+ $"{WorldScale.MetresFromRaw(a.OrangeCeil - a.Sea):F0}–{WorldScale.MetresFromRaw(a.RedCeil - a.Sea):F0} m",
+ $"{WorldScale.MetresFromRaw(a.RedCeil - a.Sea):F0}–{WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m",
+ $"~{WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m (bench)",
+ $"{WorldScale.MetresFromRaw(benchTop - a.Sea):F0}–{WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m",
+ $"~{WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m (plateau)",
+ $"{WorldScale.MetresFromRaw(plateauTop - a.Sea):F0}–{WorldScale.MetresFromRaw(a.PeakCap - a.Sea):F0} m",
+ };
+ for (int i = 0; i < 7; i++)
+ sb.AppendLine($"| {CurveKnots.BandNames[i]} | {shares[i]:F2} % | {CurveKnots.BandShareTargets[i]:F0} % | {lands[i]} |");
+ sb.AppendLine();
+ sb.AppendLine($"Shaped land range: **[{shapedPool.MinLand:F4} .. {shapedPool.MaxLand:F4}] raw** " +
+ $"= {WorldScale.MetresFromRaw(shapedPool.MinLand - a.Sea):F0}–{WorldScale.MetresFromRaw(shapedPool.MaxLand - a.Sea):F0} m above sea.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ The elevation profile this produces");
+ sb.AppendLine();
+ sb.AppendLine("Land height percentiles, before and after the curve — **this is the finding**:");
+ sb.AppendLine();
+ sb.AppendLine("| Percentile | raw | shaped | shaped, metres above sea |");
+ sb.AppendLine("|---|---|---|---|");
+ foreach (double p in new[] { 10.0, 25.0, 50.0, 75.0, 90.0, 95.0, 99.0, 99.9 })
+ {
+ float r = rawPool.Quantile(p), s = shapedPool.Quantile(p);
+ sb.AppendLine($"| p{p:G} | {r:F3} | {s:F3} | **{WorldScale.MetresFromRaw(s - a.Sea):F0} m** |");
+ }
+ sb.AppendLine();
+ double belowBench = shapedPool.FractionBelow(a.BenchBase) * 100.0;
+ double belowPlateau = shapedPool.FractionBelow(a.PlateauBase) * 100.0;
+ sb.AppendLine($"- **{belowBench:F1} %** of land sits below the bench " +
+ $"({WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m).");
+ sb.AppendLine($"- **{belowPlateau:F1} %** of land sits below the plateau " +
+ $"({WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m).");
+ // ⚠ Do NOT state the RAW median in metres. Raw pre-curve height has no metre meaning —
+ // the yardstick applies to CURVED output, which is what the storm-ladder anchors define.
+ // Converting the raw median would invent a "before" elevation the world never had.
+ sb.AppendLine($"- The median land column ends up **{WorldScale.MetresFromRaw(shapedPool.Quantile(50) - a.Sea):F0} m** " +
+ $"above sea: the curve maps raw {rawPool.Quantile(50):F3} → {shapedPool.Quantile(50):F3}.");
+ sb.AppendLine();
+ sb.AppendLine("> ⚠ **Read `histogram_shaped.png` before concluding the terrain is broken.** The curve is");
+ sb.AppendLine("> doing exactly what its share targets say: 60 % of land into the bottom band, 73 % below");
+ sb.AppendLine("> the red ceiling, 4 % above the plateau. If the island reads flat, that is a decision");
+ sb.AppendLine("> showing up in a render — not a bug. Changing it is the RESHAPE, and the reshape is a");
+ sb.AppendLine("> later task with its own gate.");
+ sb.AppendLine();
+ sb.AppendLine("## ⚠ Palette fit — reported, not fixed");
+ sb.AppendLine();
+ sb.AppendLine("The CostaRica stops were placed on **measured percentiles of the RAW distribution**");
+ sb.AppendLine("(Phase 1). The curve moves that distribution, so the ramp no longer sits where the land is:");
+ sb.AppendLine();
+ sb.AppendLine("| | raw | shaped |");
+ sb.AppendLine("|---|---|---|");
+ sb.AppendLine($"| land below palette stop 3 (0.310 raw) | {raw3Stop(rawPool) * 100:F1} % | **{raw3Stop(shapedPool) * 100:F1} %** |");
+ sb.AppendLine($"| land above the top stop (1.450, clamps white) | {(1.0 - rawPool.FractionBelow(1.450f)) * 100:F3} % | {(1.0 - shapedPool.FractionBelow(1.450f)) * 100:F3} % |");
+ sb.AppendLine();
+ sb.AppendLine("**Deliberately left alone.** Re-placing the stops now would make the render look more");
+ sb.AppendLine("dramatic while the terrain is genuinely lower — it would disguise the very finding above.");
+ sb.AppendLine("Recalibrating the palette is a presentation call to make *after* the elevation profile is");
+ sb.AppendLine("settled, not before.");
+ sb.AppendLine();
+ sb.AppendLine("## The oracle");
+ sb.AppendLine();
+ sb.AppendLine(ShapingOracle.ToMarkdownTable(checks));
+ sb.AppendLine($"**{(oracleOk ? "ALL PASS" : "⚠⚠ FAILURES PRESENT — do not judge this batch until they are resolved")}**");
+ sb.AppendLine();
+ if (variants)
+ {
+ sb.AppendLine("## Results");
+ sb.AppendLine();
+ sb.AppendLine("| Folder | Seed | Variant | h min | h max | land % | grayscale range | 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/CurveBaselineTool.cs.uid b/Tools/Scripts/CurveBaselineTool.cs.uid
new file mode 100644
index 0000000..16af106
--- /dev/null
+++ b/Tools/Scripts/CurveBaselineTool.cs.uid
@@ -0,0 +1 @@
+uid://b0cmaubwul6fl
diff --git a/Tools/Scripts/HistogramRenderer.cs b/Tools/Scripts/HistogramRenderer.cs
new file mode 100644
index 0000000..f231104
--- /dev/null
+++ b/Tools/Scripts/HistogramRenderer.cs
@@ -0,0 +1,337 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// Draws a as a labelled plot — the picture the developer reads
+ /// before deciding what the curve should become.
+ ///
+ /// ═══ WHY A PLOT AND NOT JUST THE TABLE ═══
+ ///
+ /// The per-band mass table is the EVIDENCE; this is what makes the evidence obvious at a glance.
+ /// The three candidate causes of flat upper terrain have three different SHAPES here, and the
+ /// shape is recognisable in a second where a column of numbers takes a minute:
+ ///
+ /// • curve squashing — the SHAPED plot spikes hard at the bench and plateau anchors.
+ /// Mass that was spread out arrives stacked.
+ /// • noise empty up high — the RAW plot's right-hand tail is a long, flat, almost-invisible
+ /// sliver. There is nothing above P96 to redistribute.
+ /// • share allocation — neither plot is odd, and the band overlays simply show that the
+ /// top two bands were only ever allotted 4 % of the land.
+ ///
+ /// ⚠ LINEAR Y, DELIBERATELY. A log axis would make the upper tail look substantial — which is
+ /// precisely the question being asked. If the top of the distribution is a sliver, the plot must
+ /// show a sliver. The peak bin count is printed so the vertical scale is never a mystery.
+ ///
+ /// ⚠ Presentation only, and it cannot be otherwise: it is handed a histogram and returns a PNG.
+ /// It has no access to a height field and no way to produce one.
+ ///
+ /// Text is (5×7 bitmap), specifically so this does not drag in the
+ /// SubViewport capture path — which awaits render frames and is why `--headless` hangs.
+ /// ⚠ The font is uppercase, digits and . - : / ( ) only. Unsupported characters render as
+ /// blanks, so labels here say "PCT" rather than "%" and avoid commas.
+ ///
+ public static class HistogramRenderer
+ {
+ /// A vertical reference line — a knot, or an output anchor.
+ public sealed class Marker
+ {
+ public float Value;
+ public string Label;
+ /// Strong markers get a brighter line; use for the ones that carry the argument.
+ public bool Strong = true;
+ }
+
+ /// A shaded span between two values, labelled underneath. The curve's bands.
+ public sealed class Band
+ {
+ public float Lo, Hi;
+ public string Label;
+ /// Share of land in this band, in percent. Drawn under the label.
+ public double SharePercent;
+ }
+
+ public sealed class Options
+ {
+ public string Title = "LAND HEIGHT DISTRIBUTION";
+ public string Subtitle = "";
+ public string Footer = "";
+ public string XAxisLabel = "RAW HEIGHT";
+ /// Right edge of the x axis, raw. Defaults to the histogram's max land height.
+ public float XTop = 0f;
+ public int Width = 1800;
+ public int Height = 1000;
+ public List Markers = new();
+ public List Bands = new();
+ }
+
+ // A dark plate, so these sit beside the relief renders rather than glaring next to them.
+ private static readonly Color Paper = new(0.098f, 0.106f, 0.125f);
+ private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
+ private static readonly Color Faint = new(0.565f, 0.596f, 0.643f);
+ private static readonly Color Grid = new(0.192f, 0.208f, 0.243f);
+ private static readonly Color BarColor = new(0.380f, 0.760f, 0.780f); // the distribution itself
+ private static readonly Color MarkStrong = new(0.980f, 0.720f, 0.300f); // knots — the argument
+ private static readonly Color MarkSoft = new(0.620f, 0.560f, 0.780f); // anchors — context
+ private static readonly Color BandA = new(0.145f, 0.161f, 0.196f);
+ private static readonly Color BandB = new(0.118f, 0.129f, 0.157f);
+ private static readonly Color OverColor = new(0.980f, 0.560f, 0.290f); // bars that exceed the clipped axis
+
+ ///
+ /// The clipped y-axis ceiling: 3× the 90th-percentile non-empty bin, so ordinary structure
+ /// fills the plot while a single dominating spike is cut off and MARKED.
+ ///
+ /// ⚠ Returns unchanged when nothing dominates — a plot is only
+ /// clipped when clipping actually buys legibility, never as a default. The threshold is on
+ /// the bin DISTRIBUTION rather than a fixed number so it adapts to whatever is handed in.
+ ///
+ private static long ClipCap(LandHistogram h, long peak)
+ {
+ var nonEmpty = new List();
+ for (int i = 0; i < h.BinCount; i++)
+ if (h.BinCountAt(i) > 0) nonEmpty.Add(h.BinCountAt(i));
+
+ if (nonEmpty.Count < 8) return peak;
+ nonEmpty.Sort();
+
+ long p90 = nonEmpty[(int)(nonEmpty.Count * 0.90)];
+ long cap = Math.Max(1, p90 * 3);
+ // Only worth clipping if the spike really is off the scale of everything else. On a
+ // well-spread distribution (the RAW plot) this is false and the axis stays true.
+ return cap * 2 < peak ? cap : peak;
+ }
+
+ /// Render and save. Returns the path written.
+ public static string SavePng(LandHistogram h, Options o, string absolutePath)
+ {
+ Image img = Render(h, o);
+ Error err = img.SavePng(absolutePath);
+ if (err != Error.Ok) GD.PrintErr($"[HistogramRenderer] SavePng failed ({err}) for {absolutePath}");
+ return absolutePath;
+ }
+
+ public static Image Render(LandHistogram h, Options o)
+ {
+ int W = o.Width, H = o.Height;
+ var img = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
+ img.Fill(Paper);
+
+ // ---- layout, derived from text metrics rather than guessed fractions ----
+ const int titleScale = 4, labelScale = 2, tickScale = 2;
+ int marginL = 130, marginR = 50;
+ // Room for: title, subtitle, the clip banner, and TWO staggered rows of marker labels.
+ int marginT = 40 + TinyFont.Height(titleScale) + 18 + TinyFont.Height(labelScale) + 20
+ + (TinyFont.Height(labelScale) + 5) * 3;
+ // Room for: x ticks, then TWO staggered rows of two-line band labels.
+ int marginB = 30 + TinyFont.Height(tickScale) + 14
+ + ((TinyFont.Height(labelScale) + 4) * 2 + 6) * 2 + 16;
+
+ int plotX = marginL, plotY = marginT;
+ int plotW = W - marginL - marginR;
+ int plotH = H - marginT - marginB;
+ if (plotW < 64 || plotH < 64) return img; // absurd canvas — a broken plot is worse than none
+
+ float xLo = h.SeaLevel;
+ float xHi = o.XTop > xLo ? o.XTop : (h.MaxLand > xLo ? h.MaxLand : xLo + 1f);
+ float xSpan = xHi - xLo;
+
+ int Px(float v) => plotX + (int)MathF.Round((v - xLo) / xSpan * (plotW - 1));
+
+ // ---- band shading, behind everything ----
+ bool alt = false;
+ foreach (Band b in o.Bands)
+ {
+ int x0 = Math.Clamp(Px(b.Lo), plotX, plotX + plotW - 1);
+ int x1 = Math.Clamp(Px(b.Hi), plotX, plotX + plotW - 1);
+ Color c = alt ? BandA : BandB;
+ alt = !alt;
+ for (int x = x0; x <= x1; x++)
+ for (int y = plotY; y < plotY + plotH; y++)
+ img.SetPixel(x, y, c);
+ }
+
+ // ---- horizontal grid at quarters ----
+ for (int i = 1; i < 4; i++)
+ {
+ int gy = plotY + plotH - (int)(plotH * (i / 4.0));
+ for (int x = plotX; x < plotX + plotW; x++) img.SetPixel(x, gy, Grid);
+ }
+
+ // ---- the bars ----
+ //
+ // ⚠ THE VERTICAL SCALE IS CLIPPED, AND THE CLIP IS DRAWN. The curve piles 60 % of land
+ // into a 14 m band, so one bin can be 20× its neighbours; at full scale every other
+ // feature — the bench bump, the plateau bump, the whole upper tail — flattens to the
+ // axis and the plot shows one spike and nothing else. Clipping makes the rest readable;
+ // COLOURING the clipped part and printing both numbers is what keeps it honest. A
+ // silently truncated axis would be a lie told in the most trusted artefact in the batch.
+ long peak = h.PeakBinCount();
+ long cap = ClipCap(h, peak);
+ bool clipped = cap < peak;
+
+ if (cap > 0)
+ {
+ for (int i = 0; i < h.BinCount; i++)
+ {
+ long c = h.BinCountAt(i);
+ if (c == 0) continue;
+
+ float lo = h.BinLow(i), hi = lo + h.BinWidth;
+ if (hi < xLo || lo > xHi) continue;
+
+ int x0 = Math.Clamp(Px(lo), plotX, plotX + plotW - 1);
+ int x1 = Math.Clamp(Px(hi), plotX, plotX + plotW - 1);
+ if (x1 < x0) x1 = x0;
+
+ bool over = c > cap;
+ int barH = (int)MathF.Round((float)(Math.Min(c, cap) / (double)cap) * (plotH - 1));
+ // A non-empty bin always paints at least one pixel — otherwise the thin upper
+ // tail vanishes entirely and the plot argues the opposite of the data.
+ if (barH < 1) barH = 1;
+
+ Color c1 = over ? OverColor : BarColor;
+ for (int x = x0; x <= x1; x++)
+ for (int y = plotY + plotH - barH; y < plotY + plotH; y++)
+ img.SetPixel(x, y, c1);
+ }
+ }
+
+ // ---- axes ----
+ for (int x = plotX; x < plotX + plotW; x++) img.SetPixel(x, plotY + plotH, Faint);
+ for (int y = plotY; y <= plotY + plotH; y++) img.SetPixel(plotX, y, Faint);
+
+ // ---- markers (knots / anchors), over the bars ----
+ // ⚠ Same two-row stagger as the band labels, for the same reason: K3 and K4 are five
+ // percentiles apart and their labels would otherwise overprint into a false reading.
+ // The LINE is always drawn even when its label is staggered — the position is the data;
+ // the text is the convenience.
+ var mRowRight = new[] { int.MinValue, int.MinValue };
+ int mRowH = TinyFont.Height(labelScale) + 5;
+
+ for (int mi = 0; mi < o.Markers.Count; mi++)
+ {
+ Marker m = o.Markers[mi];
+ if (m.Value < xLo || m.Value > xHi) continue;
+ int mx = Math.Clamp(Px(m.Value), plotX, plotX + plotW - 1);
+ Color c = m.Strong ? MarkStrong : MarkSoft;
+
+ for (int y = plotY; y <= plotY + plotH; y++)
+ {
+ // Dashed for the soft ones, so a dense cluster stays readable.
+ if (!m.Strong && ((y / 6) & 1) == 0) continue;
+ img.SetPixel(mx, y, c);
+ }
+
+ int lw = TinyFont.Width(m.Label, labelScale);
+ int lx = mx - lw / 2;
+ int row = 0;
+ if (lx <= mRowRight[0] + 8)
+ {
+ row = 1;
+ if (lx <= mRowRight[1] + 8) continue; // both rows taken — the line still stands
+ }
+ lx = Math.Clamp(lx, plotX, plotX + plotW - lw);
+ TinyFont.Draw(img, m.Label, lx, plotY - TinyFont.Height(labelScale) - 6 - (1 - row) * mRowH,
+ labelScale, c);
+ mRowRight[row] = lx + lw;
+ }
+
+ // ---- band labels + shares, under the axis ----
+ //
+ // ⚠ STAGGERED ACROSS TWO ROWS, AND A COLLIDING LABEL IS DROPPED RATHER THAN OVERPRINTED.
+ // The curve's bench and plateau bands are ~6 m wide, so at map scale their labels sit
+ // almost on top of their neighbours: the first cut rendered "TOE/ORANGERED" and
+ // "59.9 PCT19 PCT", which is worse than no label because it reads as a value.
+ // A leader line ties each surviving label to its band, so a dropped one is visibly
+ // dropped rather than silently mis-attributed.
+ int bandLabelY = plotY + plotH + 12 + TinyFont.Height(tickScale) + 10;
+ int rowH = (TinyFont.Height(labelScale) + 4) * 2 + 6;
+ var rowRight = new[] { int.MinValue, int.MinValue };
+
+ for (int bi = 0; bi < o.Bands.Count; bi++)
+ {
+ Band b = o.Bands[bi];
+ int cx = (Px(b.Lo) + Px(b.Hi)) / 2;
+ string l1 = b.Label.ToUpperInvariant();
+ string l2 = $"{b.SharePercent:F1} PCT";
+ int w = Math.Max(TinyFont.Width(l1, labelScale), TinyFont.Width(l2, labelScale));
+
+ int row = bi & 1; // stagger: alternate rows first
+ int left = cx - w / 2;
+ if (left <= rowRight[row] + 8) // still colliding on that row? try the other
+ {
+ row ^= 1;
+ if (left <= rowRight[row] + 8) continue; // both taken — drop it, do not overprint
+ }
+
+ left = Math.Clamp(left, 2, W - w - 2);
+ int y = bandLabelY + row * rowH;
+
+ // Leader line from the band's centre down to its label.
+ for (int ly = plotY + plotH + 2; ly < y - 2; ly++)
+ if ((ly & 1) == 0) img.SetPixel(Math.Clamp(cx, 0, W - 1), ly, Grid);
+
+ TinyFont.Draw(img, l1, left, y, labelScale, Faint);
+ TinyFont.Draw(img, l2, left, y + TinyFont.Height(labelScale) + 4, labelScale, Ink);
+ rowRight[row] = left + w;
+ }
+
+ // ---- x ticks ----
+ int tickCount = 8;
+ for (int i = 0; i <= tickCount; i++)
+ {
+ float v = xLo + xSpan * i / tickCount;
+ int tx = Px(v);
+ for (int t = 0; t < 6; t++) img.SetPixel(tx, plotY + plotH + t, Faint);
+ string lab = v.ToString("0.00");
+ int lw = TinyFont.Width(lab, tickScale);
+ TinyFont.Draw(img, lab, Math.Clamp(tx - lw / 2, 2, W - lw - 2), plotY + plotH + 10, tickScale, Faint);
+ }
+
+ // ---- y axis: the scale, and the clip if there is one ----
+ TinyFont.Draw(img, Thousands(cap), 8, plotY - 4, tickScale, clipped ? OverColor : Faint);
+ TinyFont.Draw(img, "0", 8, plotY + plotH - TinyFont.Height(tickScale), tickScale, Faint);
+ TinyFont.Draw(img, "COUNT", 8, plotY + plotH / 2, tickScale, Faint);
+ if (clipped)
+ {
+ // Said twice, on the image, in the clip's own colour — because a reader who misses
+ // this misreads the whole plot.
+ TinyFont.Draw(img, "CLIPPED", 8, plotY + 6 + TinyFont.Height(tickScale), tickScale, OverColor);
+ TinyFont.Draw(img, $"TRUE PEAK {Thousands(peak)} - AMBER BARS EXCEED THE CLIPPED AXIS",
+ plotX, plotY - TinyFont.Height(labelScale) - 6 - mRowH * 2 - 6, labelScale, OverColor);
+ }
+
+ // ---- titles ----
+ TinyFont.Draw(img, o.Title, marginL, 40, titleScale, Ink);
+ if (!string.IsNullOrEmpty(o.Subtitle))
+ TinyFont.Draw(img, o.Subtitle, marginL, 40 + TinyFont.Height(titleScale) + 14, labelScale, Faint);
+
+ // ---- footer ----
+ if (!string.IsNullOrEmpty(o.Footer))
+ TinyFont.Draw(img, o.Footer, marginL, H - TinyFont.Height(labelScale) - 20, labelScale, Faint);
+
+ TinyFont.Draw(img, o.XAxisLabel, plotX + plotW - TinyFont.Width(o.XAxisLabel, labelScale),
+ H - TinyFont.Height(labelScale) - 20, labelScale, Faint);
+
+ return img;
+ }
+
+ ///
+ /// Group digits with spaces. ⚠ Not commas: the font has no comma glyph, so "1,234" would
+ /// render as "1 234" anyway — better to mean it than to have it happen.
+ ///
+ private static string Thousands(long v)
+ {
+ string s = v.ToString();
+ var sb = new System.Text.StringBuilder();
+ for (int i = 0; i < s.Length; i++)
+ {
+ if (i > 0 && (s.Length - i) % 3 == 0) sb.Append(' ');
+ sb.Append(s[i]);
+ }
+ return sb.ToString();
+ }
+ }
+}
diff --git a/Tools/Scripts/HistogramRenderer.cs.uid b/Tools/Scripts/HistogramRenderer.cs.uid
new file mode 100644
index 0000000..446f879
--- /dev/null
+++ b/Tools/Scripts/HistogramRenderer.cs.uid
@@ -0,0 +1 @@
+uid://cmrft1fsxfj0r
diff --git a/Tools/Scripts/LandHistogram.cs b/Tools/Scripts/LandHistogram.cs
new file mode 100644
index 0000000..dc39665
--- /dev/null
+++ b/Tools/Scripts/LandHistogram.cs
@@ -0,0 +1,234 @@
+using System;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ THE LAND-HEIGHT DISTRIBUTION — one instrument doing two jobs.
+ ///
+ /// ═══ JOB 1: THE CALIBRATION ENGINE ═══
+ ///
+ /// The redistribution curve's knots ARE percentiles of this distribution (P60/73/83/88/96/99).
+ /// This class measures them. That is what makes the band shares 60/13/10/5/8/3/1 exact by
+ /// construction rather than approximately right.
+ ///
+ /// > ⚠ The reference SHIPPED the six resulting literals and threw the instrument away. No
+ /// > sampler, no histogram, no percentile helper survives at the tag — so its knots could never
+ /// > be re-derived, only trusted. Rebuilding the measuring device is the point of this file:
+ /// > a calibration you cannot re-run is a magic number with a good story.
+ ///
+ /// ═══ JOB 2: THE DIAGNOSTIC ═══
+ ///
+ /// It is also what separates the three candidate causes of "flat, undramatic upper terrain",
+ /// which look identical in a render and completely different here:
+ ///
+ /// 1. CURVE SQUASHING — the SHAPED histogram piles mass at the bench/plateau output
+ /// heights. The curve is flattening ground that had relief.
+ /// 2. NOISE EMPTY UP HIGH — the RAW histogram's top (above ~P96) is a thin sliver spread over
+ /// a wide range. There is nothing up there to shape.
+ /// 3. SHARE ALLOCATION — little land is TARGETED into plateau+spike (3 % + 1 %) by
+ /// construction. The curve is doing exactly what it was told.
+ ///
+ /// These have different fixes — reshape the segments, change the noise, or reallocate the
+ /// shares — so guessing which one it is costs a whole task.
+ ///
+ /// ═══ WHY A HISTOGRAM AND NOT A SORTED SAMPLE ARRAY ═══
+ ///
+ /// Exact quantiles want every sample sorted; the reference pooled 340 M of them. At 4 bytes each
+ /// that is 1.3 GB and a sort to match. A fine fixed-width histogram with IN-BIN LINEAR
+ /// INTERPOLATION gives quantiles accurate to well under a bin, in flat memory, at any pool size,
+ /// and streams across seeds without holding a single sample. At the default
+ /// the resolution is 1e-4 raw ≈ 2.5 cm of world height —
+ /// four decimal places on a knot, against reference literals quoted to six.
+ ///
+ /// ⚠ Engine-free (System only). It sits in Tools/ rather than Core/ because it is a
+ /// MEASURING INSTRUMENT for the generator, not a contract about the world — the same reasoning
+ /// that keeps IslandFalloff here. Core carries what the world IS; Tools carries what we
+ /// point at it.
+ ///
+ public sealed class LandHistogram
+ {
+ ///
+ /// Bin width for CALIBRATION, in raw height units. 1e-4 raw ≈ 2.5 cm — finer than any knot
+ /// distinction that could matter, and 38,500 bins over the working range is 300 KB.
+ ///
+ public const float CalibrationBinWidth = 1e-4f;
+
+ ///
+ /// Top of the binned range, raw. Generous: pass-1 land maxes near 1.45 and the curve's tail
+ /// can exceed the 420 m cap. Anything above lands in the overflow bin and is REPORTED, never
+ /// silently dropped.
+ ///
+ public const float DefaultTop = 4.0f;
+
+ /// Heights at or below this are not land and are not counted. The curve's identity threshold.
+ public readonly float SeaLevel;
+
+ /// Bin width in raw height units.
+ public readonly float BinWidth;
+
+ /// Top of the binned range; samples above it go to .
+ public readonly float Top;
+
+ private readonly long[] _counts;
+
+ /// Land samples at or above . ⚠ Reported, not hidden.
+ public long OverflowCount { get; private set; }
+
+ /// Total land samples accumulated, overflow included.
+ public long TotalLand { get; private set; }
+
+ /// Lowest and highest land sample seen, exactly (not bin-quantized).
+ public float MinLand { get; private set; } = float.MaxValue;
+ public float MaxLand { get; private set; } = float.MinValue;
+
+ /// How many fields have been pooled in. The calibration pool's size.
+ public int FieldsPooled { get; private set; }
+
+ public LandHistogram(float seaLevel, float binWidth = CalibrationBinWidth, float top = DefaultTop)
+ {
+ if (binWidth <= 0f) throw new ArgumentOutOfRangeException(nameof(binWidth), binWidth, "Bin width must be positive.");
+ if (top <= seaLevel) throw new ArgumentOutOfRangeException(nameof(top), top, "Top must exceed sea level.");
+
+ SeaLevel = seaLevel;
+ BinWidth = binWidth;
+ Top = top;
+ _counts = new long[(int)MathF.Ceiling((top - seaLevel) / binWidth)];
+ }
+
+ /// Number of bins (excluding overflow).
+ public int BinCount => _counts.Length;
+
+ /// Raw height at the low edge of bin .
+ public float BinLow(int i) => SeaLevel + i * BinWidth;
+
+ /// Sample count in bin .
+ public long BinCountAt(int i) => _counts[i];
+
+ ///
+ /// Pool one field's LAND samples in. Call repeatedly to build a multi-seed pool — the
+ /// reference calibrated across a pooled batch, and one seed's distribution is not the
+ /// island's.
+ ///
+ /// ⚠ Strictly > SeaLevel, matching the curve's own h <= Sea → identity
+ /// test. A pixel exactly at sea is not land, and counting it would put a spike in bin 0 that
+ /// drags every low percentile down.
+ ///
+ public void Accumulate(float[,] field, int mapSize)
+ {
+ for (int x = 0; x < mapSize; x++)
+ {
+ for (int y = 0; y < mapSize; y++)
+ {
+ float h = field[x, y];
+ if (h <= SeaLevel) continue;
+
+ TotalLand++;
+ if (h < MinLand) MinLand = h;
+ if (h > MaxLand) MaxLand = h;
+
+ int bin = (int)((h - SeaLevel) / BinWidth);
+ if (bin >= _counts.Length) OverflowCount++;
+ else _counts[bin]++;
+ }
+ }
+ FieldsPooled++;
+ }
+
+ ///
+ /// The quantile at (0..100) — a raw height, interpolated inside
+ /// its bin so the answer is not quantized to .
+ ///
+ /// ⚠ Throws on an empty pool rather than returning sea level. An all-ocean seed silently
+ /// calibrating every knot to 0.15 is exactly the kind of quiet nonsense that ships.
+ ///
+ public float Quantile(double percent)
+ {
+ if (TotalLand == 0)
+ throw new InvalidOperationException(
+ "[LandHistogram] No land samples pooled — cannot take a quantile. " +
+ "Check the sea level and that pass 1 actually produced an island.");
+ if (percent < 0.0 || percent > 100.0)
+ throw new ArgumentOutOfRangeException(nameof(percent), percent, "A percentile is 0..100.");
+
+ double target = percent / 100.0 * TotalLand;
+ long cum = 0;
+
+ for (int i = 0; i < _counts.Length; i++)
+ {
+ long c = _counts[i];
+ if (c == 0) continue;
+ if (cum + c >= target)
+ {
+ // Linear position inside the bin: the samples in it are assumed uniform, which
+ // is the standard histogram-quantile assumption and is a sub-bin error.
+ double within = (target - cum) / c;
+ return BinLow(i) + (float)(within * BinWidth);
+ }
+ cum += c;
+ }
+
+ // Only reachable when the quantile falls in the overflow — a real answer we cannot
+ // resolve, so say so rather than returning Top as if it were measured.
+ throw new InvalidOperationException(
+ $"[LandHistogram] P{percent} falls above the binned range (top {Top}); " +
+ $"{OverflowCount} of {TotalLand} samples overflowed. Raise `top` and re-measure.");
+ }
+
+ /// Fraction of land strictly below , interpolated within the bin.
+ public double FractionBelow(float h)
+ {
+ if (TotalLand == 0) return 0.0;
+ if (h <= SeaLevel) return 0.0;
+
+ int bin = (int)((h - SeaLevel) / BinWidth);
+ if (bin >= _counts.Length) return 1.0;
+
+ long cum = 0;
+ for (int i = 0; i < bin; i++) cum += _counts[i];
+
+ double within = (h - BinLow(bin)) / BinWidth;
+ return (cum + within * _counts[bin]) / TotalLand;
+ }
+
+ /// Fraction of land in [lo, hi).
+ public double FractionBetween(float lo, float hi) => Math.Max(0.0, FractionBelow(hi) - FractionBelow(lo));
+
+ /// The tallest bin's count — the y-axis a plot needs.
+ public long PeakBinCount()
+ {
+ long peak = 0;
+ foreach (long c in _counts) if (c > peak) peak = c;
+ return peak;
+ }
+
+ ///
+ /// Re-bin into a coarser histogram for DISPLAY. The calibration histogram has 38,500 bins;
+ /// a plot has room for a few hundred, and drawing one bin per pixel column of a 1,200 px plot
+ /// would alias the distribution into noise.
+ ///
+ public LandHistogram Rebin(float displayBinWidth)
+ {
+ var outH = new LandHistogram(SeaLevel, displayBinWidth, Top)
+ {
+ TotalLand = TotalLand,
+ OverflowCount = OverflowCount,
+ MinLand = MinLand,
+ MaxLand = MaxLand,
+ FieldsPooled = FieldsPooled,
+ };
+ for (int i = 0; i < _counts.Length; i++)
+ {
+ if (_counts[i] == 0) continue;
+ // Bin centre, so a sample does not systematically bias to the low edge.
+ int j = (int)((BinLow(i) + BinWidth * 0.5f - SeaLevel) / displayBinWidth);
+ if (j >= outH._counts.Length) outH.OverflowCount += _counts[i];
+ else outH._counts[j] += _counts[i];
+ }
+ return outH;
+ }
+
+ public override string ToString() =>
+ $"LandHistogram({TotalLand:N0} land samples from {FieldsPooled} field(s), " +
+ $"[{MinLand:F4} .. {MaxLand:F4}] raw, bin {BinWidth:G3}, overflow {OverflowCount})";
+ }
+}
diff --git a/Tools/Scripts/LandHistogram.cs.uid b/Tools/Scripts/LandHistogram.cs.uid
new file mode 100644
index 0000000..d4e29c4
--- /dev/null
+++ b/Tools/Scripts/LandHistogram.cs.uid
@@ -0,0 +1 @@
+uid://c1ri5puqulvge
diff --git a/Tools/Scripts/Pass2Result.cs b/Tools/Scripts/Pass2Result.cs
new file mode 100644
index 0000000..d5cbe7d
--- /dev/null
+++ b/Tools/Scripts/Pass2Result.cs
@@ -0,0 +1,130 @@
+using System.Collections.Generic;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// Everything pass 2 produces: THE TWO HEIGHT FIELDS. → .
+ ///
+ /// ═══ ⭐⭐ THE TWO-FIELD SPLIT (D-046) — THE DISCIPLINE THIS CLASS EXISTS TO HOLD ═══
+ ///
+ /// RAW. Uncurved, un-detailed. The ORACLE.
+ /// RENDER. Curved, detailed, and later eroded and carved.
+ ///
+ /// Everything that CLASSIFIES the world — biomes, water bodies, the ocean flood fill — reads the
+ /// classify field. Everything that DRAWS or MESHES it reads the render field. The reference's
+ /// hardest-won lesson is that this split is what made five rounds of taste-iteration safe: the
+ /// biome and water maps stayed md5-identical across every shaping change, so correctness was
+ /// never being judged by eye. → `Design - Tooling - Iteration and Batching.md`,
+ /// "build the oracle before the taste-iteration".
+ ///
+ /// ⚠ NOTHING CONSUMES THE CLASSIFY FIELD YET. No water, no biomes exist in this phase. The split
+ /// is established HERE, at the curve, because the curve is where the second field is BORN — and
+ /// retrofitting a classify path after three passes already ran on one array is how the two
+ /// silently diverge. The field is produced and asserted now so that when water lands it has
+ /// something correct to read.
+ ///
+ /// ═══ ⚠ WHEN THE TWO FIELDS ARE THE SAME ARRAY ═══
+ ///
+ /// With the curve OFF there is nothing to separate, so both properties reference ONE array —
+ /// exactly as the reference did (_heightMapClassify = (_curveOn || _erosionOn) ? new float[…]
+ /// : _heightMap). says so out loud, because a later pass that
+ /// writes through one reference while reading the other MUST know: the reference's crater carve
+ /// reads both into locals before writing either for precisely this reason, and that is the trap
+ /// this flag is here to keep visible until the carve lands.
+ ///
+ public sealed class Pass2Result
+ {
+ /// Map side in columns.
+ public readonly int MapSize;
+
+ /// The resolved seed. Same seed, same two fields.
+ public readonly int Seed;
+
+ ///
+ /// ⭐ THE RENDER FIELD, [x, y] — curved and detailed. What gets drawn, dumped and
+ /// (later) eroded, carved and meshed.
+ ///
+ public readonly float[,] Height;
+
+ ///
+ /// ⭐ THE CLASSIFY FIELD, [x, y] — bit-for-bit the raw pre-curve pass-1 height.
+ ///
+ /// ⚠ Nothing may write to this after pass 2 except the crater carve, which is the one pass
+ /// that legitimately moves both fields. Erosion, rivers and detail are render-only.
+ ///
+ public readonly float[,] HeightClassify;
+
+ /// Was the curve applied? The primary A/B gate.
+ public readonly bool CurveOn;
+
+ /// Was shelf detail applied? Requires — it warps the curve's knots.
+ public readonly bool DetailOn;
+
+ /// The knot set used. Null when the curve is off.
+ public readonly CurveKnots Knots;
+
+ /// The output anchors used. Null when the curve is off.
+ public readonly CurveAnchors Anchors;
+
+ /// The per-seed spike input, carried through from pass 1.
+ public readonly float HMaxSeed;
+
+ /// The edge-warp amplitude actually APPLIED, raw units (post-clamp). Zero when detail is off.
+ public readonly float EdgeAmpRaw;
+
+ /// The knot set's safe warp bound, raw units — what was clamped to.
+ public readonly float MaxEdgeShiftRaw;
+
+ /// Render-field extremes after shaping. For the ramp and the report.
+ public readonly float HMin, HMax;
+
+ /// Wall-clock milliseconds pass 2 took.
+ public readonly ulong ElapsedMs;
+
+ ///
+ /// Lines worth printing: the monotonicity confirmation, any loud clamp. Collected rather than
+ /// printed inside the pass so the shaping code stays a pure function of its inputs and the
+ /// tool owns the console.
+ ///
+ public readonly List Notes;
+
+ public Pass2Result(int mapSize, int seed, float[,] height, float[,] heightClassify,
+ bool curveOn, bool detailOn, CurveKnots knots, CurveAnchors anchors, float hMaxSeed,
+ float edgeAmpRaw, float maxEdgeShiftRaw, float hMin, float hMax, ulong elapsedMs,
+ List notes)
+ {
+ MapSize = mapSize;
+ Seed = seed;
+ Height = height;
+ HeightClassify = heightClassify;
+ CurveOn = curveOn;
+ DetailOn = detailOn;
+ Knots = knots;
+ Anchors = anchors;
+ HMaxSeed = hMaxSeed;
+ EdgeAmpRaw = edgeAmpRaw;
+ MaxEdgeShiftRaw = maxEdgeShiftRaw;
+ HMin = hMin;
+ HMax = hMax;
+ ElapsedMs = elapsedMs;
+ Notes = notes;
+ }
+
+ ///
+ /// ⚠ True when the two fields ARE the same array (curve off). Any pass that writes one while
+ /// reading the other must read both into locals first. See the type header.
+ ///
+ public bool FieldsAreAliased => ReferenceEquals(Height, HeightClassify);
+
+ /// Fraction of the RENDER field at or above the sea threshold.
+ 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/Pass2Result.cs.uid b/Tools/Scripts/Pass2Result.cs.uid
new file mode 100644
index 0000000..eaf632a
--- /dev/null
+++ b/Tools/Scripts/Pass2Result.cs.uid
@@ -0,0 +1 @@
+uid://ccjbd838sb6jc
diff --git a/Tools/Scripts/Shaping.cs b/Tools/Scripts/Shaping.cs
new file mode 100644
index 0000000..844cae0
--- /dev/null
+++ b/Tools/Scripts/Shaping.cs
@@ -0,0 +1,205 @@
+using System.Collections.Generic;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ PASS 2a — the redistribution curve and the shelf detail, applied per column, producing the
+ /// TWO HEIGHT FIELDS. Ported from the reference's MapGenerator.GenerateTopography pass-2
+ /// loop (Tools/Scripts/MapGenerator.cs ~:692-735 at tag pre-rewrite-reference,
+ /// commit ab78883). → D-050.
+ ///
+ /// ═══ WHAT RUNS HERE, AND WHAT DELIBERATELY DOES NOT ═══
+ ///
+ /// The reference's pass 2 is three sub-passes in a fixed order:
+ ///
+ /// 2a curve + detail ← THIS FILE
+ /// 2b hydraulic erosion (render map only) — a later chat2 task
+ /// 2c the crater carve (both maps, last word) — a later chat2 task
+ ///
+ /// Only 2a is ported. The ORDER matters and is recorded here so the later two land in the right
+ /// place: erosion runs AFTER detail and BEFORE the carve, and the carve stays last because it is
+ /// the final authority on its own terrain.
+ ///
+ /// ═══ ⚠ THE PASS-1/PASS-2 BOUNDARY IS HARD, NOT AN INTERLEAVE ═══
+ ///
+ /// The curve's summit spike maps [K6, hMaxSeed] onto the peak band, so no pixel can be
+ /// curved until every pixel has been scanned. carries that
+ /// number across the boundary, and AssertMonotonic runs in between — after the max is
+ /// known, before the first column is shaped.
+ ///
+ /// ═══ ⚠ ONE DELIBERATE DEVIATION FROM THE REFERENCE, AND WHY ═══
+ ///
+ /// The reference shaped IN PLACE: _heightMap held pass-1 raw, then pass 2 overwrote it
+ /// column by column. This port leaves untouched and allocates
+ /// the render field.
+ ///
+ /// The arithmetic is identical — every column still reads raw and writes curvedH.
+ /// What changes is that the raw field SURVIVES the pass, which is what lets oracle checks (a) and
+ /// (b) compare against it directly instead of regenerating and trusting that the regeneration
+ /// matched. An invariant you can check by construction beats one you have to believe.
+ ///
+ /// (With the curve OFF nothing is allocated at all and both fields alias the pass-1 array,
+ /// exactly as the reference did — see .)
+ ///
+ public static class Shaping
+ {
+ ///
+ /// Apply pass 2a to a pass-1 field.
+ ///
+ /// ⚠ Pure with respect to : nothing here writes to its arrays.
+ ///
+ public static Pass2Result Shape(Pass1Result p1, TerrainGenConfig cfg)
+ {
+ ulong t0 = Time.GetTicksMsec();
+
+ int mapSize = p1.MapSize;
+ var notes = new List();
+
+ // ═══ THE OFF PATH — the A/B control ═══
+ //
+ // Nothing to separate, so nothing is allocated: both fields reference the pass-1 array,
+ // as the reference's `_heightMapClassify = (_curveOn || _erosionOn) ? new[…] : _heightMap`
+ // did. This path must be BIT-IDENTICAL to Phase 1's output — oracle (a).
+ if (!cfg.Curve)
+ {
+ notes.Add("[Shaping] curve OFF — render and classify alias the pass-1 field (the A/B control).");
+ // ⚠ Detail REQUIRES the curve — it slides the curve's KNOTS, so there is nothing to
+ // warp without one. The reference said so out loud rather than silently no-op'ing,
+ // because a dial that does nothing is worth a line in the log.
+ if (cfg.ShelfDetail)
+ notes.Add("[Shaping] shelf detail requested but the curve is off — no-op (detail warps the curve's knots).");
+ return new Pass2Result(mapSize, p1.Seed, p1.Height, p1.Height,
+ curveOn: false, detailOn: false, knots: null, anchors: null, hMaxSeed: p1.HMaxSeed,
+ edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed,
+ elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
+ }
+
+ CurveKnots knots = cfg.Knots;
+ CurveAnchors anchors = cfg.Anchors;
+ GenerationScale scale = cfg.Scale;
+
+ // ═══ THE MODULATION FIELDS ═══
+ //
+ // Three for the curve (bench anchor, plateau anchor, shelf strength), two for detail
+ // (micro-relief, edge warp). Each is decorrelated by SEED OFFSET and sampled at the bare
+ // (x, y) — there are no coordinate offsets in this path to normalize. → TerrainNoise.
+ FastNoiseLite benchNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.BenchSeedOffset, anchors.ElevFreqPerMapWidth, scale);
+ FastNoiseLite plateauNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.PlateauSeedOffset, anchors.ElevFreqPerMapWidth, scale);
+ FastNoiseLite strengthNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.StrengthSeedOffset, anchors.StrengthFreqPerMapWidth, scale);
+
+ // The curve-off case already returned above, so detail is simply on-or-off from here.
+ bool detailOn = cfg.ShelfDetail;
+
+ FastNoiseLite reliefNoise = null, edgeNoise = null;
+ float reliefAmpRaw = 0f, edgeAmpRaw = 0f;
+ float maxEdgeShiftRaw = TerrainDetailPass.MaxEdgeShift(knots);
+
+ if (detailOn)
+ {
+ reliefNoise = TerrainNoise.CreateModulation(p1.Seed, TerrainDetailPass.ReliefSeedOffset, TerrainDetailPass.ReliefFreqPerMapWidth, scale);
+ edgeNoise = TerrainNoise.CreateModulation(p1.Seed, TerrainDetailPass.EdgeSeedOffset, TerrainDetailPass.EdgeFreqPerMapWidth, scale);
+
+ reliefAmpRaw = WorldScale.RawFromMetres(Mathf.Max(cfg.ShelfReliefAmpM, 0f));
+ edgeAmpRaw = WorldScale.RawFromMetres(Mathf.Max(cfg.ShelfEdgeVariationM, 0f));
+
+ // ⚠ THE CLAMP IS LOUD. The warp is bounded to the largest shift that keeps the knot
+ // set strictly ordered, so monotonicity can never become a tuning question — but a
+ // silently ignored dial is worse than a refused one, because the developer A/Bs a
+ // number that never reached the terrain.
+ if (edgeAmpRaw > maxEdgeShiftRaw)
+ {
+ notes.Add($"[Shaping] ⚠ ShelfEdgeVariation {cfg.ShelfEdgeVariationM:F2} m exceeds this knot set's " +
+ $"safe bound {WorldScale.MetresFromRaw(maxEdgeShiftRaw):F2} m — CLAMPING.");
+ edgeAmpRaw = maxEdgeShiftRaw;
+ }
+
+ notes.Add($"[Shaping] detail v{TerrainDetailPass.Version}: relief ±{cfg.ShelfReliefAmpM:F1} m @ " +
+ $"{TerrainDetailPass.ReliefFreqPerMapWidth:F0}/map, edge warp " +
+ $"±{WorldScale.MetresFromRaw(edgeAmpRaw):F2} m of INPUT height @ " +
+ $"{TerrainDetailPass.EdgeFreqPerMapWidth:F0}/map (bound " +
+ $"{WorldScale.MetresFromRaw(maxEdgeShiftRaw):F2} m).");
+ }
+
+ // ═══ ⭐ THE MONOTONICITY PROOF — between the passes, before the first column ═══
+ notes.Add(HeightCurve.AssertMonotonic(p1.HMaxSeed, knots, anchors, detailOn ? edgeAmpRaw : 0f));
+
+ // ═══ THE CRATER SEAM — INERT THIS PHASE ═══
+ //
+ // No crater exists yet. CraterRadius defaults to 0, which makes CraterDetailWeight return
+ // 1 everywhere: detail applies unmasked, and the distance is not even computed. Ported
+ // now because the exclusion is part of THIS pass's contract — bolting it on after the
+ // carve arrives is exactly how the reference's 532-px below-sea bug happened.
+ float craterRadius = cfg.CraterRadius;
+ bool craterActive = craterRadius > 0f;
+ var craterCentre = new Vector2(cfg.CraterCenterX, cfg.CraterCenterY);
+
+ var height = new float[mapSize, mapSize];
+ var classify = new float[mapSize, mapSize];
+
+ float hMin = float.MaxValue, hMax = float.MinValue;
+
+ for (int x = 0; x < mapSize; x++)
+ {
+ for (int y = 0; y < mapSize; y++)
+ {
+ float raw = p1.Height[x, y];
+
+ // ⭐ THE CLASSIFY FIELD IS THE RAW FIELD. Not "approximately", not "before most
+ // things" — bit-for-bit, and asserted as such by oracle (b).
+ classify[x, y] = raw;
+
+ // ── the per-column shelf modulation (reference v4) ──
+ // Anchors and strength come from three very-low-frequency fields, so the bench
+ // and plateau elevations drift across the island instead of being one global
+ // terrace. Amplitudes are bounded and every extreme is swept by AssertMonotonic,
+ // so ordering safety is by construction rather than by hope.
+ float benchLo = anchors.BenchBase + benchNoise.GetNoise2D(x, y) * anchors.BenchAmp;
+ float plateauLo = anchors.PlateauBase + plateauNoise.GetNoise2D(x, y) * anchors.PlateauAmp;
+ float shelfSpan = HeightCurve.ShelfSpan((strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f, anchors);
+
+ // ── detail yields to the crater (inert until the carve lands) ──
+ float wCrater = 0f;
+ if (detailOn)
+ {
+ wCrater = craterActive
+ ? TerrainDetailPass.CraterDetailWeight(
+ new Vector2(x, y).DistanceTo(craterCentre), craterRadius)
+ : 1f;
+ }
+
+ // ── PASS B: the knot-block warp. Slides K3/K4/K5 for THIS column. ──
+ float edgeShift = detailOn ? edgeNoise.GetNoise2D(x, y) * edgeAmpRaw * wCrater : 0f;
+
+ float curvedH = HeightCurve.Apply(raw, p1.HMaxSeed,
+ benchLo, shelfSpan, plateauLo, shelfSpan, knots, anchors, edgeShift);
+
+ // ── PASS A: the micro-relief skin, on the shelves only ──
+ // ⚠ Fed the SAME edgeShift, so the skin follows the shelf wherever pass B moved
+ // its boundary. Passing 0 here would put the texture on the wrong ground.
+ if (detailOn && wCrater > 0f)
+ {
+ float wShelf = TerrainDetailPass.ShelfWeight(raw, knots, edgeShift);
+ if (wShelf > 0f)
+ curvedH += reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf * wCrater;
+ }
+
+ if (curvedH < hMin) hMin = curvedH;
+ if (curvedH > hMax) hMax = curvedH;
+ height[x, y] = curvedH;
+ }
+ }
+
+ // ⚠ THE REFERENCE'S PASS 2 CONTINUES HERE with erosion (~:737-805, render map only) and
+ // then the crater carve (~:807-833, both maps, last word). Both DEFERRED to later chat2
+ // tasks. The classify field above is finalized bar the carve — which is exactly the
+ // property that makes it an oracle.
+
+ return new Pass2Result(mapSize, p1.Seed, height, classify,
+ curveOn: true, detailOn: detailOn, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
+ edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax,
+ elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
+ }
+ }
+}
diff --git a/Tools/Scripts/Shaping.cs.uid b/Tools/Scripts/Shaping.cs.uid
new file mode 100644
index 0000000..71e5736
--- /dev/null
+++ b/Tools/Scripts/Shaping.cs.uid
@@ -0,0 +1 @@
+uid://bk7r2ku7blrj3
diff --git a/Tools/Scripts/ShapingOracle.cs b/Tools/Scripts/ShapingOracle.cs
new file mode 100644
index 0000000..d626d28
--- /dev/null
+++ b/Tools/Scripts/ShapingOracle.cs
@@ -0,0 +1,238 @@
+using System;
+using System.Collections.Generic;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ THE ORACLE — the automatic correctness checks that let the developer's eye judge ONLY
+ /// relief.
+ ///
+ /// ═══ WHY THIS EXISTS AT ALL ═══
+ ///
+ /// The prototype's single most valuable terrain lesson was not about terrain:
+ ///
+ /// > *"Five rounds of taste-iteration were safe BECAUSE correctness was not being judged by eye.
+ /// > Where a future phase has a subjective gate, ask first what the automatic invariant is."*
+ /// > — `Design - Tooling - Iteration and Batching.md`, "build the oracle before the
+ /// > taste-iteration, not after".
+ ///
+ /// The curve is a subjective gate. So before a single render is looked at, four things are
+ /// proven mechanically:
+ ///
+ /// (a) REGRESSION curve OFF is bit-identical to Phase 1's pass-1 output.
+ /// ⇒ the port disturbed nothing upstream.
+ /// (b) CLASSIFY FIDELITY the classify field is bit-identical to the raw pre-curve field,
+ /// curve on or off. ⇒ the oracle field is actually an oracle.
+ /// (c) MONOTONICITY the effective per-seed curve is strictly increasing everywhere.
+ /// ⇒ no peak has become a pit.
+ /// (d) BAND SHARES realized land shares match the 60/13/10/5/8/3/1 targets.
+ /// ⇒ the calibration did what it claimed.
+ ///
+ /// ⚠ BIT-IDENTICAL MEANS BIT-IDENTICAL. These compare IEEE-754 bit patterns, not values within
+ /// an epsilon. "Close enough" is how a drift becomes a fact — and the whole point of the `.f32`
+ /// dump is that two generators agree or their dumps differ.
+ ///
+ /// ⚠ Any failure fails the TASK, loudly. Nothing here papers over a mismatch: a check that
+ /// reports "mostly passed" is a check that has stopped working.
+ ///
+ public static class ShapingOracle
+ {
+ /// One check's verdict. carries the evidence either way.
+ public sealed class Check
+ {
+ public string Id; // "a", "b", "c", "d"
+ public string Name;
+ public bool Passed;
+ public string Detail;
+
+ public override string ToString() => $"[{(Passed ? "PASS" : "FAIL")}] ({Id}) {Name} — {Detail}";
+ }
+
+ ///
+ /// Compare two float fields for BIT equality. Returns the number of differing cells and the
+ /// first difference found, so a failure is actionable rather than just red.
+ ///
+ public static (long differing, string firstDiff) CompareBitwise(float[,] a, float[,] b, int mapSize)
+ {
+ long differing = 0;
+ string first = null;
+
+ for (int x = 0; x < mapSize; x++)
+ {
+ for (int y = 0; y < mapSize; y++)
+ {
+ int ba = BitConverter.SingleToInt32Bits(a[x, y]);
+ int bb = BitConverter.SingleToInt32Bits(b[x, y]);
+ if (ba == bb) continue;
+
+ differing++;
+ first ??= $"first at [{x},{y}]: {a[x, y]:G9} (0x{ba:X8}) vs {b[x, y]:G9} (0x{bb:X8})";
+ }
+ }
+ return (differing, first);
+ }
+
+ ///
+ /// (a) REGRESSION — with the curve off, shaping must return the pass-1 field untouched.
+ ///
+ /// ⚠ This is the WEAKER, always-available half of check (a): it proves pass 2 is a no-op when
+ /// gated off. The stronger half — that pass 1 ITSELF still matches Phase 1 byte-for-byte — is
+ /// , which needs a Phase-1 `.f32` on disk.
+ ///
+ public static Check RegressionCurveOff(Pass1Result p1, Pass2Result offResult)
+ {
+ var c = new Check { Id = "a", Name = "regression: curve OFF == pass-1 output" };
+
+ if (offResult.CurveOn)
+ {
+ c.Passed = false;
+ c.Detail = "the result handed in was generated with the curve ON — wrong variant.";
+ return c;
+ }
+
+ var (differing, firstDiff) = CompareBitwise(p1.Height, offResult.Height, p1.MapSize);
+ bool aliased = offResult.FieldsAreAliased;
+
+ c.Passed = differing == 0;
+ c.Detail = c.Passed
+ ? $"bit-identical over {(long)p1.MapSize * p1.MapSize:N0} cells" +
+ (aliased ? " (and the fields alias one array, as the reference did)" : "")
+ : $"{differing:N0} cells differ — {firstDiff}";
+ return c;
+ }
+
+ ///
+ /// (a′) REGRESSION against a Phase-1 `.f32` dump — the cross-run half.
+ ///
+ /// ⚠ A MISSING DUMP IS NOT A PASS. It is reported as INCONCLUSIVE and the caller says so; a
+ /// check that silently succeeds when its input is absent is worse than no check, because it
+ /// buys confidence that was never earned.
+ ///
+ public static Check RegressionAgainstDump(float[,] current, float[,] phase1Dump, int mapSize, string dumpPath)
+ {
+ var c = new Check { Id = "a′", Name = "regression: pass-1 == Phase-1 .f32 dump" };
+
+ if (phase1Dump == null)
+ {
+ c.Passed = false;
+ c.Detail = $"INCONCLUSIVE — no readable Phase-1 dump at {dumpPath} for this seed/size. " +
+ "Not counted as a pass; set ISLA_PHASE1_SOURCE to a batch that has one.";
+ return c;
+ }
+
+ var (differing, firstDiff) = CompareBitwise(phase1Dump, current, mapSize);
+ c.Passed = differing == 0;
+ c.Detail = c.Passed
+ ? $"bit-identical to {dumpPath} over {(long)mapSize * mapSize:N0} cells"
+ : $"{differing:N0} cells differ from {dumpPath} — {firstDiff}";
+ return c;
+ }
+
+ ///
+ /// (b) CLASSIFY FIDELITY — the classify field is the raw pre-curve field, bit-for-bit, with
+ /// the curve on or off.
+ ///
+ /// This is the invariant every later phase's oracle rests on: biomes and water will classify
+ /// from this field, so if it has drifted by even one ulp the "md5-identical across shaping
+ /// changes" guarantee is gone before it is ever used.
+ ///
+ public static Check ClassifyFidelity(Pass1Result p1, Pass2Result p2)
+ {
+ var c = new Check { Id = "b", Name = "classify field == raw pass-1 field" };
+
+ var (differing, firstDiff) = CompareBitwise(p1.Height, p2.HeightClassify, p1.MapSize);
+ c.Passed = differing == 0;
+ c.Detail = c.Passed
+ ? $"bit-identical over {(long)p1.MapSize * p1.MapSize:N0} cells (curve {(p2.CurveOn ? "ON" : "OFF")})"
+ : $"{differing:N0} cells differ — {firstDiff}";
+ return c;
+ }
+
+ ///
+ /// (c) MONOTONICITY — recorded rather than re-run.
+ ///
+ /// HeightCurve.AssertMonotonic THROWS on violation and is called inside
+ /// , so reaching this code at all means the sweep passed. The
+ /// check exists so the oracle table states it explicitly instead of leaving the strongest
+ /// guarantee implicit in the absence of a crash.
+ ///
+ public static Check Monotonicity(Pass2Result p2)
+ {
+ var c = new Check { Id = "c", Name = "curve strictly monotonic (24-corner sweep)" };
+
+ if (!p2.CurveOn)
+ {
+ c.Passed = true;
+ c.Detail = "curve off — nothing to prove (identity is trivially monotonic).";
+ return c;
+ }
+
+ string note = p2.Notes.Find(n => n.Contains("Monotonicity assertion passed"));
+ c.Passed = note != null;
+ c.Detail = note ?? "no monotonicity confirmation recorded — AssertMonotonic did not run.";
+ return c;
+ }
+
+ ///
+ /// (d) BAND SHARES — the realized land shares against the M3 targets.
+ ///
+ /// ⚠ MEASURED ON THE RAW (INPUT) DISTRIBUTION, because that is where the knots cut. The
+ /// shares are exact by construction IF the quantile machinery is right — so this check is
+ /// really a proof that measured what it claimed, which is the one
+ /// thing the reference could never verify about its own knots.
+ ///
+ ///
+ /// Allowed absolute deviation per band, in percentage points. The knots come from the SAME
+ /// histogram, so agreement is limited only by in-bin interpolation — tenths of a point, not
+ /// whole ones.
+ ///
+ public static Check BandShares(LandHistogram raw, CurveKnots k, double tolerancePercentagePoints)
+ {
+ var c = new Check { Id = "d", Name = "realized land band shares == 60/13/10/5/8/3/1 targets" };
+
+ double[] realized = RealizedShares(raw, k);
+ double worst = 0.0;
+ int worstBand = -1;
+
+ for (int i = 0; i < realized.Length; i++)
+ {
+ double d = Math.Abs(realized[i] - CurveKnots.BandShareTargets[i]);
+ if (d > worst) { worst = d; worstBand = i; }
+ }
+
+ c.Passed = worst <= tolerancePercentagePoints;
+ c.Detail = $"worst band '{CurveKnots.BandNames[worstBand]}' off by {worst:F3} pp " +
+ $"(tolerance {tolerancePercentagePoints:F2} pp); realized " +
+ string.Join("/", Array.ConvertAll(realized, v => v.ToString("F2")));
+ return c;
+ }
+
+ ///
+ /// The fraction of land, in percent, falling in each of the curve's seven INPUT bands.
+ /// Band edges are sea, K1..K6, +∞.
+ ///
+ public static double[] RealizedShares(LandHistogram raw, CurveKnots k)
+ {
+ float[] edges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6 };
+ var shares = new double[7];
+
+ for (int i = 0; i < 6; i++)
+ shares[i] = raw.FractionBetween(edges[i], edges[i + 1]) * 100.0;
+
+ shares[6] = Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0);
+ return shares;
+ }
+
+ /// Render the whole oracle as a markdown table for the INDEX and the report.
+ public static string ToMarkdownTable(IEnumerable checks)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("| | Check | Result | Evidence |");
+ sb.AppendLine("|---|---|---|---|");
+ foreach (Check c in checks)
+ sb.AppendLine($"| `{c.Id}` | {c.Name} | **{(c.Passed ? "PASS" : "FAIL")}** | {c.Detail} |");
+ return sb.ToString();
+ }
+ }
+}
diff --git a/Tools/Scripts/ShapingOracle.cs.uid b/Tools/Scripts/ShapingOracle.cs.uid
new file mode 100644
index 0000000..17756d1
--- /dev/null
+++ b/Tools/Scripts/ShapingOracle.cs.uid
@@ -0,0 +1 @@
+uid://c4yc8p1ygkcau
diff --git a/Tools/Scripts/TerrainGenConfig.cs b/Tools/Scripts/TerrainGenConfig.cs
index b5f3313..9e67d9c 100644
--- a/Tools/Scripts/TerrainGenConfig.cs
+++ b/Tools/Scripts/TerrainGenConfig.cs
@@ -87,18 +87,92 @@ namespace IslaApocalypse.Tools
/// Rung 6: the mountain spine up the centre-X axis.
public bool MountainSpine = true;
+ // ---- PASS 2a — the redistribution curve and shelf detail (Phase 2) ----
+ //
+ // ⚠ THE PRIMARY A/B OF THIS PHASE IS `Curve`. Off must reproduce Phase 1's pass-1 output
+ // BIT-IDENTICALLY — that is the regression oracle, not a figure of speech.
+
+ ///
+ /// ⭐ Pass 2a rung 1: the height-redistribution curve. → .
+ /// Off = raw pass-1 height, unshaped (the control half of every A/B in this phase).
+ ///
+ public bool Curve = true;
+
+ ///
+ /// ⭐ Pass 2a rung 2: the shelf detail passes — micro-relief skin + shelf-edge knot warp.
+ /// ⚠ REQUIRES : the edge warp slides the CURVE's knots, so with no curve
+ /// there is nothing to warp. Requesting it with the curve off is a logged no-op, not an error.
+ ///
+ public bool ShelfDetail = true;
+
+ ///
+ /// Micro-relief amplitude, in METRES of output height. Reference default: 3 m.
+ /// Converted through at the call site — never a literal /251.
+ ///
+ public float ShelfReliefAmpM = TerrainDetailPass.ReliefAmpDefaultM;
+
+ ///
+ /// Shelf-edge warp amplitude, in METRES OF INPUT HEIGHT (not output elevation — see
+ /// ). Reference default: 12 m.
+ ///
+ /// ⚠ CLAMPED, LOUDLY, to the knot set's safe bound (TerrainDetailPass.MaxEdgeShift).
+ /// Monotonicity is never a tuning question; an ignored dial is always reported.
+ ///
+ public float ShelfEdgeVariationM = TerrainDetailPass.EdgeAmpDefaultM;
+
+ ///
+ /// The input knot set — WHERE the land distribution is cut.
+ /// Default: , re-measured on v2's own pass-1 output.
+ /// is available for a fidelity A/B against the prototype's.
+ ///
+ public CurveKnots Knots = CurveKnots.V2Baseline;
+
+ ///
+ /// The output anchors — WHAT HEIGHT each cut lands at. Default: the storm-ladder values,
+ /// reproducing the reference's constants bit-for-bit.
+ ///
+ public CurveAnchors Anchors = CurveAnchors.Default;
+
+ // ---- the crater seam — INERT THIS PHASE -----------------------------
+
+ ///
+ /// Crater radius in columns. ⚠ 0 = NO CRATER, which is this phase's state. The detail
+ /// pass's crater exclusion is ported and wired, but with no crater it evaluates to "detail
+ /// everywhere" and the distance is never computed. It is exercised when the carve lands.
+ ///
+ public float CraterRadius = 0f;
+
+ /// Crater centre X, columns. Unused while is 0.
+ public float CraterCenterX = 0f;
+
+ /// Crater centre Y, columns. Unused while is 0.
+ public float CraterCenterY = 0f;
+
/// 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();
+ ///
+ /// ⚠ DEEP on . MemberwiseClone is shallow, so two configs cloned
+ /// from one parent would share a single mutable anchor object and an A/B that edited one
+ /// would silently move the other. The one reference type that is a DIAL gets copied; the one
+ /// that is immutable () does not need to be.
+ ///
+ public TerrainGenConfig Clone()
+ {
+ var c = (TerrainGenConfig)MemberwiseClone();
+ c.Anchors = Anchors?.Clone();
+ return c;
+ }
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}]";
+ $"trench={Trench} spine={MountainSpine}] " +
+ $"[curve={Curve} detail={ShelfDetail} relief={ShelfReliefAmpM:F1}m edge={ShelfEdgeVariationM:F1}m " +
+ $"knots={(Knots == null ? "-" : Knots.Name)}]";
}
}
diff --git a/Tools/Scripts/TerrainNoise.cs b/Tools/Scripts/TerrainNoise.cs
index b1ee0f2..f29aeb7 100644
--- a/Tools/Scripts/TerrainNoise.cs
+++ b/Tools/Scripts/TerrainNoise.cs
@@ -84,6 +84,55 @@ namespace IslaApocalypse.Tools
return noise;
}
+ ///
+ /// A MODULATION field — the curve's bench/plateau/strength anchors and the detail pass's
+ /// relief/edge fields. Ported from the reference's MapGenerator.MakeModulationNoise
+ /// (~:1041-1048).
+ ///
+ /// ═══ ⚠ DECORRELATION IS BY SEED, NOT BY COORDINATE OFFSET ═══
+ ///
+ /// The reference offset these fields from each other with Seed = resolvedSeed + offset
+ /// (7101 / 7207 / 7303 / 7409 / 7507 / 7607 / 9271) and then sampled every one of them at the
+ /// bare (x, y). There is no GetNoise2D(x + 1000, …) anywhere in this path.
+ ///
+ /// So the raw-pixel-offset hazard warns about — the one that
+ /// bit the latitude wobble in pass 1 — does not apply here, and there was nothing to
+ /// normalize on the port. Recorded explicitly because "we checked and it was fine" is
+ /// only worth anything if someone wrote down that they checked. (chat2/01.)
+ ///
+ /// ⚠ FREQUENCY IS STATED PER MAP WIDTH, not at the 1024 baseline — the reference wrote
+ /// periodsPerIsland / MapSize, which is already size-independent. →
+ /// .
+ ///
+ /// ⚠ THE FRACTAL PROPERTIES ARE PINNED HERE TOO. The reference left them to the engine on
+ /// these fields exactly as it did on the base noise, so the same argument applies: a default
+ /// is not a decision, and an engine upgrade must not move the island. The pinned values are
+ /// the ones measured on 4.7.2 and match what 4.7.1 supplied, so pinning reproduces the
+ /// reference with no delta.
+ ///
+ /// The run's resolved seed — the offset is added here, not by the caller.
+ /// The field's decorrelation offset (e.g. CurveAnchors.BenchSeedOffset).
+ /// How many undulations across the island.
+ public static FastNoiseLite CreateModulation(int seed, int seedOffset, float periodsPerMapWidth,
+ GenerationScale scale)
+ {
+ var noise = new FastNoiseLite();
+
+ // --- ported verbatim from the reference ---
+ noise.Seed = seed + seedOffset; // deterministic from the resolved seed
+ noise.NoiseType = PinnedNoiseType;
+ noise.Frequency = scale.NoiseFrequencyPerMapWidth(periodsPerMapWidth);
+
+ // --- PINNED: the reference left these to the engine here too. 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} · " +