From 35b4818e9efc88823a02383c34bdf7d3a759cd07 Mon Sep 17 00:00:00 2001 From: beezm Date: Thu, 20 Aug 2026 01:38:10 -0400 Subject: [PATCH] Phase 2a: the faithful redistribution curve, re-measured against v2's own output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the reference's v5 height curve and shelf-detail passes onto Phase 1's shape and re-calibrates them against this repo's actual pass-1 distribution. This is the BASELINE the reshape gets judged against, not the reshape. Core (engine-free, D-060): - WorldScale — THE vertical yardstick. One metres/raw number (251), replacing the prototype's three duplicate M_PER_UNIT constants and ~20 bare literals. The chunk-height coupling it had there is recorded as a DEFERRED vault decision, not inherited. RawFromMetres divides, matching the reference bit-for-bit. - HeightCurve — the 7 bands, the frozen corner-fix blends, the per-seed spike normalization, the 24-corner monotonicity sweep that throws and refuses. Identity at and below sea, which everything downstream rests on. - CurveKnots / CurveAnchors — input knots (measured percentiles) and output anchors (storm ladder) split apart and both made parameters, so the anchors are A/B-able without editing source. The reference's shipped knots are kept beside the measured ones as the fidelity yardstick. - TerrainDetailPass — micro-relief skin plus the shelf-edge KNOT warp (which slides K3/K4/K5, not height — that is what keeps monotonicity structural). The crater exclusion is ported and inert until the carve lands. Tools: - Shaping — pass 2a, producing the two height fields. classify is bit-for-bit the raw pass-1 field; render is curved and detailed. Aliased when the curve is off, as the reference did. Pass1Result is left immutable so the oracle can compare. - LandHistogram — the calibration engine AND the diagnostic. The reference shipped six knot literals and threw the measuring instrument away; this rebuilds it. - ShapingOracle + CurveBaselineTool — four automatic checks before anything is looked at, and the batch that runs them. Measured, not assumed: - Knots re-measured over a 6-seed / 12.8M-sample pool. They differ from the reference's by at most 5.6 m of world height, against a 44.7 m per-seed spread — the pass-1 port is faithful. - Oracle all pass, including pass 1 bit-identical to Phase 1's own .f32 dump. - Band shares land on 60/13/10/5/8/3/1 to 0.00 pp. - Knots hold across map size: the 8K delta (5.8 m) sits inside seed noise. The finding the histograms deliver: 83% of land ends below 100 m and 96% below 220 m, with the median column at 13 m. That is the share targets doing exactly what they say, not a bug — and it is the developer's call, which is why nothing here reshapes it and the palette was deliberately left mis-fitted rather than recalibrated to disguise it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt --- Core/README.md | 18 +- Core/Scripts/CurveAnchors.cs | 142 ++++++ Core/Scripts/CurveAnchors.cs.uid | 1 + Core/Scripts/CurveKnots.cs | 120 +++++ Core/Scripts/CurveKnots.cs.uid | 1 + Core/Scripts/GenerationScale.cs | 22 + Core/Scripts/HeightCurve.cs | 249 ++++++++++ Core/Scripts/HeightCurve.cs.uid | 1 + Core/Scripts/TerrainDetailPass.cs | 182 +++++++ Core/Scripts/TerrainDetailPass.cs.uid | 1 + Core/Scripts/WorldScale.cs | 80 +++ Core/Scripts/WorldScale.cs.uid | 1 + README.md | 41 +- Tools/README.md | 73 ++- Tools/Scenes/CurveBaselineTool.tscn | 6 + Tools/Scripts/CurveBaselineTool.cs | 658 +++++++++++++++++++++++++ Tools/Scripts/CurveBaselineTool.cs.uid | 1 + Tools/Scripts/HistogramRenderer.cs | 337 +++++++++++++ Tools/Scripts/HistogramRenderer.cs.uid | 1 + Tools/Scripts/LandHistogram.cs | 234 +++++++++ Tools/Scripts/LandHistogram.cs.uid | 1 + Tools/Scripts/Pass2Result.cs | 130 +++++ Tools/Scripts/Pass2Result.cs.uid | 1 + Tools/Scripts/Shaping.cs | 205 ++++++++ Tools/Scripts/Shaping.cs.uid | 1 + Tools/Scripts/ShapingOracle.cs | 238 +++++++++ Tools/Scripts/ShapingOracle.cs.uid | 1 + Tools/Scripts/TerrainGenConfig.cs | 78 ++- Tools/Scripts/TerrainNoise.cs | 49 ++ 29 files changed, 2855 insertions(+), 18 deletions(-) create mode 100644 Core/Scripts/CurveAnchors.cs create mode 100644 Core/Scripts/CurveAnchors.cs.uid create mode 100644 Core/Scripts/CurveKnots.cs create mode 100644 Core/Scripts/CurveKnots.cs.uid create mode 100644 Core/Scripts/HeightCurve.cs create mode 100644 Core/Scripts/HeightCurve.cs.uid create mode 100644 Core/Scripts/TerrainDetailPass.cs create mode 100644 Core/Scripts/TerrainDetailPass.cs.uid create mode 100644 Core/Scripts/WorldScale.cs create mode 100644 Core/Scripts/WorldScale.cs.uid create mode 100644 Tools/Scenes/CurveBaselineTool.tscn create mode 100644 Tools/Scripts/CurveBaselineTool.cs create mode 100644 Tools/Scripts/CurveBaselineTool.cs.uid create mode 100644 Tools/Scripts/HistogramRenderer.cs create mode 100644 Tools/Scripts/HistogramRenderer.cs.uid create mode 100644 Tools/Scripts/LandHistogram.cs create mode 100644 Tools/Scripts/LandHistogram.cs.uid create mode 100644 Tools/Scripts/Pass2Result.cs create mode 100644 Tools/Scripts/Pass2Result.cs.uid create mode 100644 Tools/Scripts/Shaping.cs create mode 100644 Tools/Scripts/Shaping.cs.uid create mode 100644 Tools/Scripts/ShapingOracle.cs create mode 100644 Tools/Scripts/ShapingOracle.cs.uid 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} · " +