Phase 2a: the faithful redistribution curve, re-measured against v2's own output

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
This commit is contained in:
Stewart Howe 2026-08-20 01:38:10 -04:00
parent 527fab41e5
commit 35b4818e9e
29 changed files with 2855 additions and 18 deletions

View file

@ -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/BuildingMaterial.cs` | A row in the building schema (D-054). |
| `Scripts/MaterialRegistry.cs` | Both registries, append-only, seed rows only. | | `Scripts/MaterialRegistry.cs` | Both registries, append-only, seed rows only. |
| `Scripts/RecipeRegistry.cs` | The transformation seam — **deliberately empty**. | | `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/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. | | `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`. `Scripts/MaterialRegistry.cs`.
3. **Nothing uses a raw pixel number.** Every distance is a fraction of `MapSize`. 3. **Nothing uses a raw pixel number.** Every distance is a fraction of `MapSize`.
`Scripts/GenerationScale.cs`. `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 ## Not here, and not by accident
- **No water.** Water is an overlay over the columns (levels-not-cells), never a band. - **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 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 - **No algorithms** *beyond the height curve*. Phase 2 added `HeightCurve` and
dig are later phases. `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 - Data - Column Model.md`, `Design - Data - Material Schema.md`,
`Design - Tooling - Scaling Discipline.md` `Design - Tooling - Scaling Discipline.md`

View file

@ -0,0 +1,142 @@
namespace IslaApocalypse.Core
{
/// <summary>
/// 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 ═══
///
/// <see cref="CurveKnots"/> — WHERE the land distribution is cut. Percentiles. Measured.
/// <see cref="CurveAnchors"/> — 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 <c>const</c> fields on <c>HeightCurve</c> and passed only the
/// per-column modulated values (<c>benchLo</c>, <c>benchSpan</c>, …) 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"). <see cref="Default"/> 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 <see cref="WorldScale.RawFromMetres"/> —
/// the single yardstick — never a literal <c>/251f</c>.
/// </summary>
public sealed class CurveAnchors
{
// ---- the frozen band ceilings (raw height units) ---------------------
/// <summary>
/// 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.
/// </summary>
public float Sea = 0.15f;
/// <summary>Top of the toe/orange band. Reference: 0.206f.</summary>
public float OrangeCeil = 0.206f;
/// <summary>Top of the red band — the floor the foothill riser climbs from. Reference: 0.27f.</summary>
public float RedCeil = 0.27f;
// ---- the modulated shelf anchors -------------------------------------
/// <summary>Bench centre, raw. Reference: <c>SEA + 100 m</c>.</summary>
public float BenchBase = 0.15f + WorldScale.RawFromMetres(100f);
/// <summary>Bench modulation amplitude, raw. Reference: <c>±12 m</c>.</summary>
public float BenchAmp = WorldScale.RawFromMetres(12f);
/// <summary>Plateau centre, raw. Reference: <c>SEA + 220 m</c>.</summary>
public float PlateauBase = 0.15f + WorldScale.RawFromMetres(220f);
/// <summary>Plateau modulation amplitude, raw. Reference: <c>±20 m</c>.</summary>
public float PlateauAmp = WorldScale.RawFromMetres(20f);
/// <summary>
/// Narrowest a shelf band may be, raw. Reference: <c>6 m</c> (v4 was 2 m — "corner fix 3":
/// a pronounced shelf keeps a gentle tilt, flat to build on but never snooker-table flat).
/// </summary>
public float ShelfSpanMin = WorldScale.RawFromMetres(6f);
/// <summary>Widest a shelf band may be, raw. Reference: 0.10f (≈ 25 m).</summary>
public float ShelfSpanMax = 0.10f;
// ---- the ceiling and its tail ---------------------------------------
/// <summary>
/// The peak cap, raw. Reference: <c>SEA + 420 m</c>. The summit spike maps
/// <c>[K6, spikeMax]</c> onto <c>[plateauTop, PeakCap]</c>, so this is the island's
/// nominal ceiling — exact, not statistical, because K6 never moves under the edge warp.
/// </summary>
public float PeakCap = 0.15f + WorldScale.RawFromMetres(420f);
/// <summary>
/// Slope above <c>spikeMax</c>. Reference: 0.25f. ⚠ A gentle TAIL, not a hard clip — a seed
/// whose max exceeds the spike range still rises, just slowly.
/// </summary>
public float TailSlope = 0.25f;
/// <summary>
/// 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.
/// </summary>
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.)
/// <summary>Bench-anchor field seed offset. Reference: 7101.</summary>
public int BenchSeedOffset = 7101;
/// <summary>Plateau-anchor field seed offset. Reference: 7207.</summary>
public int PlateauSeedOffset = 7207;
/// <summary>Shelf-strength field seed offset. Reference: 7303.</summary>
public int StrengthSeedOffset = 7303;
/// <summary>
/// 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.
/// </summary>
public float ElevFreqPerMapWidth = 3.0f;
/// <summary>Shelf-strength field frequency, periods per map width. Reference: 5.0f.</summary>
public float StrengthFreqPerMapWidth = 5.0f;
/// <summary>
/// The reference's shipped anchors, reproduced bit-for-bit. Every metre value goes through
/// <see cref="WorldScale.RawFromMetres"/>, which divides — matching the reference's
/// <c>420f / 251f</c> exactly rather than approximating it with a reciprocal multiply.
/// </summary>
public static CurveAnchors Default => new CurveAnchors();
public CurveAnchors Clone() => (CurveAnchors)MemberwiseClone();
/// <summary>
/// The anchors as the storm ladder states them — metres above sea. For a run header, where
/// raw units mean nothing to a reader.
/// </summary>
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";
}
}

View file

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

120
Core/Scripts/CurveKnots.cs Normal file
View file

@ -0,0 +1,120 @@
namespace IslaApocalypse.Core
{
/// <summary>
/// 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
/// > <see cref="Reference"/> is kept beside <see cref="V2Baseline"/> 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.
/// </summary>
public sealed class CurveKnots
{
/// <summary>Preset id, carried into the blueprint's curve metadata when that lands.</summary>
public readonly byte PresetId;
/// <summary>Short name, for logs and batch folders.</summary>
public readonly string Name;
/// <summary>The six input knots, strictly ascending. K1..K6.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static readonly double[] Percentiles = { 60.0, 73.0, 83.0, 88.0, 96.0, 99.0 };
/// <summary>The land-share target per output band, in percent, in band order.</summary>
public static readonly double[] BandShareTargets = { 60.0, 13.0, 10.0, 5.0, 8.0, 3.0, 1.0 };
/// <summary>Band names, in curve order. For tables and plot overlays.</summary>
public static readonly string[] BandNames =
{ "toe/orange", "red", "foothill riser", "bench", "mid riser", "plateau", "summit spike" };
/// <summary>
/// ⛔ THE REFERENCE'S SHIPPED KNOTS, kept verbatim as the fidelity yardstick — NOT for use.
///
/// Read from <c>REFERENCE:Tools/Scripts/HeightCurve.cs:57-58</c> at tag
/// <c>pre-rewrite-reference</c> (<c>ab78883</c>): 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.
/// </summary>
public static readonly CurveKnots Reference = new CurveKnots(2, "reference_balanced",
0.515899f, 0.612157f, 0.710472f, 0.784045f, 0.962922f, 1.119118f);
/// <summary>
/// ⭐ THE FAITHFUL v2 BASELINE — the same percentiles, re-measured on v2's own pass-1 output.
///
/// Measured by chat2/01 (<c>Tools/Scenes/CurveBaselineTool.tscn</c>) over a 6-seed pool at
/// MapSize 2048 — seeds 1063685222, 20260819, 777001, 424242, 90210, 31337 —
/// <b>12,854,486 land samples</b>, 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
/// <see cref="Reference"/> 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 &lt;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 <c>Tools/Scenes/CurveBaselineTool.tscn</c>; it prints this table.
/// </summary>
public static readonly CurveKnots V2Baseline = new CurveKnots(2, "v2_balanced",
0.529113f, 0.634439f, 0.732487f, 0.796957f, 0.960301f, 1.102473f);
/// <summary>Strictly ascending? The precondition every other guarantee rests on.</summary>
public bool IsStrictlyOrdered => K1 < K2 && K2 < K3 && K3 < K4 && K4 < K5 && K5 < K6;
/// <summary>Indexed access, K1..K6 as [0..5]. For tables and sweeps.</summary>
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})";
}
}

View file

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

View file

@ -101,6 +101,28 @@ namespace IslaApocalypse.Core
/// </summary> /// </summary>
public float NoiseFrequency(float baselineFrequency) => baselineFrequency / ScaleFactor; public float NoiseFrequency(float baselineFrequency) => baselineFrequency / ScaleFactor;
/// <summary>
/// A noise frequency stated as PERIODS PER MAP WIDTH — the reference's second frequency
/// convention, used by every curve/detail modulation field.
///
/// <c>NoiseFrequencyPerMapWidth(40f)</c> gives ~40 undulations across the island at any map
/// size, which is exactly what the reference's <c>periodsPerIsland / MapSize</c> 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:
///
/// <see cref="NoiseFrequency"/> "the frequency that looked right at 1024 columns"
/// — a tuned number, normalized by ScaleFactor.
/// <see cref="NoiseFrequencyPerMapWidth"/> "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 <see cref="NoiseFrequency"/> when porting a frequency someone tuned by eye.
/// </summary>
public float NoiseFrequencyPerMapWidth(float periodsPerMapWidth) => periodsPerMapWidth / MapSize;
/// <summary> /// <summary>
/// ⭐ A decorrelation offset for a noise coordinate, declared in MAP WIDTHS. /// ⭐ A decorrelation offset for a noise coordinate, declared in MAP WIDTHS.
/// ///

249
Core/Scripts/HeightCurve.cs Normal file
View file

@ -0,0 +1,249 @@
using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐⭐ THE HEIGHT-REDISTRIBUTION CURVE — pass 2's first act, and the shape of the island's
/// elevation profile. Ported from <c>REFERENCE:Tools/Scripts/HeightCurve.cs</c> (v5) at tag
/// <c>pre-rewrite-reference</c> (<c>ab78883</c>). → 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(2u) 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 + (hsMax)·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 &lt;= 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 <c>Apply(raw) &lt; sea</c> exactly when <c>raw &lt; sea</c>. Delete this line
/// and the whole separability argument goes with it.
///
/// ═══ ⚠ SEED-DEPENDENT BY CONSTRUCTION ═══
///
/// The summit spike maps <c>[K6, hMaxSeed]</c> 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. → <see cref="EffectiveSpikeMax"/>,
/// <c>Tools/Pass1Result.HMaxSeed</c>.
///
/// ═══ PORT NOTES ═══
///
/// • Engine-free: the reference used <c>Godot.Mathf</c> only for arithmetic, so this lives in
/// <c>Core/</c> as a named C++-candidate seam (D-049, D-060). <see cref="Lerp"/> reproduces
/// <c>Mathf.Lerp</c>'s exact expression, so the port is bit-faithful and not merely equivalent.
/// • Anchors are a parameter object (<see cref="CurveAnchors"/>) rather than consts, so the
/// storm-ladder values are A/B-able from config. <c>CurveAnchors.Default</c> 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.
/// • <see cref="AssertMonotonic"/> returns its confirmation line instead of printing it — Core
/// has no <c>GD.Print</c>. 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.
/// </summary>
public static class HeightCurve
{
/// <summary>Curve body version — the identity of the segment layout, not of the knots.</summary>
public const ushort Version = 5;
/// <summary>
/// <c>Mathf.Lerp</c>, reproduced as the reference's engine wrote it:
/// <c>from + (to - from) * weight</c>. ⚠ 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.
/// </summary>
private static float Lerp(float from, float to, float weight) => from + (to - from) * weight;
/// <summary>
/// The per-seed summit ceiling: the raw height the spike band's top maps to <c>PeakCap</c>.
///
/// <c>Max(hMaxSeed, K6 + SpikeMinSpan)</c> — 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.
/// </summary>
public static float EffectiveSpikeMax(float hMaxSeed, CurveKnots k, CurveAnchors a)
=> MathF.Max(hMaxSeed, k.K6 + a.SpikeMinSpan);
/// <summary>
/// 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. <c>strength 0 → SpanMax</c>, <c>strength 1 → SpanMin</c>.
/// </summary>
public static float ShelfSpan(float strength01, CurveAnchors a)
=> Lerp(a.ShelfSpanMax, a.ShelfSpanMin, Math.Clamp(strength01, 0f, 1f));
/// <summary>
/// The curve, for ONE column.
/// </summary>
/// <param name="h">Raw pre-curve height.</param>
/// <param name="hMaxSeed">The map-wide raw maximum for this seed. → <c>Pass1Result.HMaxSeed</c>.</param>
/// <param name="benchLo">This column's bench floor (base ± the anchor field).</param>
/// <param name="benchSpan">This column's bench output span.</param>
/// <param name="plateauLo">This column's plateau floor.</param>
/// <param name="plateauSpan">This column's plateau output span.</param>
/// <param name="k">The input knot set.</param>
/// <param name="a">The output anchors.</param>
/// <param name="edgeShift">
/// The shelf-edge warp (<c>TerrainDetailPass</c> 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.
/// </param>
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
}
/// <summary>
/// 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 <c>spikeMax</c>.
///
/// 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.
/// </summary>
/// <returns>A one-line confirmation for the run log. Core cannot print; the caller does.</returns>
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}).";
}
}
}

View file

@ -0,0 +1 @@
uid://7d68p2nuge8x

View file

@ -0,0 +1,182 @@
using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// The terrain DETAIL passes — the two things that make a redistributed shelf read as ground
/// rather than as a terrace. Ported from <c>REFERENCE:Tools/Scripts/TerrainDetailPass.cs</c>
/// (v1) at tag <c>pre-rewrite-reference</c> (<c>ab78883</c>).
///
/// ═══ 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 <c>Core/</c> beside <see cref="HeightCurve"/>: it
/// reads <see cref="CurveKnots"/> and is meaningless apart from the curve it warps.
/// </summary>
public static class TerrainDetailPass
{
/// <summary>Detail body version — the identity of this pass's layout.</summary>
public const ushort Version = 1;
// ---- pass A: micro-relief -------------------------------------------
/// <summary>Micro-relief amplitude, metres of OUTPUT height. Reference default: 3 m.</summary>
public const float ReliefAmpDefaultM = 3f;
/// <summary>
/// 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.
/// </summary>
public const float ReliefFreqPerMapWidth = 40f;
/// <summary>Micro-relief field seed offset. Reference: 7409. ⚠ A SEED offset, not a coordinate offset.</summary>
public const int ReliefSeedOffset = 7409;
// ---- pass B: shelf-edge variation -----------------------------------
/// <summary>Edge-warp amplitude, metres of INPUT height. Reference default: 12 m. See the type header.</summary>
public const float EdgeAmpDefaultM = 12f;
/// <summary>
/// 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.
/// </summary>
public const float EdgeFreqPerMapWidth = 20f;
/// <summary>Edge-warp field seed offset. Reference: 7507. ⚠ A SEED offset, not a coordinate offset.</summary>
public const int EdgeSeedOffset = 7507;
/// <summary>
/// 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 &lt; K3±d, K5±d &lt; K6) is then automatic, with a third of each band to spare.
/// </summary>
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.
/// <summary>
/// Detail exclusion radius, as a factor of CraterRadius. Reference: 0.80 — EXACTLY the
/// carve's own extent (<c>CRATER_CARVE_FACTOR</c>), so the two agree by construction rather
/// than by two constants that happen to match.
/// </summary>
public const float CraterDetailExclFactor = 0.80f;
/// <summary>Detail feather-to-full radius, factor of CraterRadius. Reference: 1.05.</summary>
public const float CraterDetailFeatherFactor = 1.05f;
/// <summary>
/// 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.
/// </summary>
/// <param name="craterRadius">
/// The configured crater radius. ⚠ <b>Non-positive means NO CRATER EXISTS</b> — returns 1
/// (detail unmasked). That is this phase's state, and it is a defined case, not a fallthrough.
/// </param>
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);
}
/// <summary>
/// The largest per-column knot shift this knot set allows — see <see cref="EdgeSafetyFraction"/>.
/// 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.
/// </summary>
public static float MaxEdgeShift(CurveKnots k)
=> EdgeSafetyFraction * MathF.Min(k.K3 - k.K2, k.K6 - k.K5);
/// <summary>
/// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0 through the
/// risers. Covers both shelves (bench and plateau).
///
/// ⚠ <paramref name="edgeShift"/> 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.
/// </summary>
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));
/// <summary>
/// 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.
/// </summary>
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);
}
}
}

View file

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

View file

@ -0,0 +1,80 @@
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐ 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 <c>251f</c> anywhere. No second <c>M_PER_UNIT</c>. 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:
///
/// • <c>Core/Constants.cs</c>: <c>HEIGHT_SCALE = CHUNK_HEIGHT - 5</c> — the only DERIVED
/// definition, and the only one the runtime (<c>ServerChunkManager</c>) actually used.
/// • THREE independent hardcoded copies: <c>HydraulicErosion.M_PER_UNIT = 251f</c>,
/// <c>DrainageAnalysis.M_PER_UNIT = 251f</c>, <c>RiverCarvePass.M_PER_UNIT = 251f</c>.
/// • ~20 bare <c>251f</c> literals across <c>HeightCurve</c> and <c>MapGenerator</c>.
///
/// 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 <c>CHUNK_HEIGHT - 5</c> = 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.
/// </summary>
public static class WorldScale
{
/// <summary>
/// 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.
/// </summary>
public const float MetresPerRawUnit = 251f;
/// <summary>
/// The inverse, for callers that genuinely want a multiplier.
///
/// ⚠⚠ NOT INTERCHANGEABLE WITH <see cref="RawFromMetres"/>. In float32,
/// <c>420f * (1f/251f)</c> and <c>420f / 251f</c> are DIFFERENT NUMBERS — they differ in the
/// last bits. The reference wrote the division (<c>420f / 251f</c>), 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 <see cref="RawFromMetres"/> unless you specifically need the
/// reciprocal.
/// </summary>
public const float RawUnitsPerMetre = 1f / MetresPerRawUnit;
/// <summary>
/// Metres → raw height units. **Divides**, bit-for-bit as the reference wrote it
/// (<c>420f / 251f</c>) — see the warning on <see cref="RawUnitsPerMetre"/>.
/// </summary>
public static float RawFromMetres(float metres) => metres / MetresPerRawUnit;
/// <summary>Raw height units → metres. The reference's <c>× 251f</c>.</summary>
public static float MetresFromRaw(float raw) => raw * MetresPerRawUnit;
/// <summary>One line for a run header. Print it; a yardstick worth having is worth stating.</summary>
public static string Describe() =>
$"1 raw height unit = {MetresPerRawUnit:F0} m (single source: Core/WorldScale; " +
"chunk-height coupling is a DEFERRED vault decision)";
}
}

View file

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

View file

@ -3,8 +3,13 @@
The rewrite (**D-049**). A post-apocalyptic survival voxel game: one island, generated once offline, The rewrite (**D-049**). A post-apocalyptic survival voxel game: one island, generated once offline,
loaded by everyone. loaded by everyone.
**Status: Phase 0 — foundation.** This repo is a *skeleton*. It builds, it resolves its own runtime **Status: Phase 2 — shaping.** Phase 0 laid the contracts and the tooling rails; Phase 1 ported the
directory, and it carries the two data contracts and the tooling rails. **It generates nothing.** 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 Core/ math + data only, ENGINE-FREE — depends on nothing above it
Server/ authoritative logic — may use Core (empty: Phase 0) Server/ authoritative logic — may use Core (empty: not yet its phase)
Client/ rendering — may use Core (empty: Phase 0) Client/ rendering — may use Core (empty: not yet its phase)
Tools/ the offline generator — ⚠ may NOT use Client (Phase 1 fills it) 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 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` per-origin, never a material property. → `Core/Scripts/MaterialRegistry.cs`
- **The scaling discipline** — nothing uses a raw pixel number. - **The scaling discipline** — nothing uses a raw pixel number.
`Core/Scripts/GenerationScale.cs` `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. - **Tooling safety** — every path env-overridable, deletion refused by code.
`Core/Scripts/ToolingPaths.cs`, `Core/Scripts/FileSafety.cs` `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, > 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. > current tooling. 4.7.1 is the *port source*, not a constraint on new code.
> ### ⚠ `<RollForward>Major</RollForward>` 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 ## What this phase is NOT
No generation, no mesher, no stratigraphy, no biomes, no water, no algorithms of any kind. The No mesher, no stratigraphy, no biomes, no water, no rivers, no erosion. The pipeline is
pipeline is **2D-maps-first** (D-056): stages 15 are flat maps, gotten completely right before a **2D-maps-first** (D-056): stages 15 are flat maps, gotten completely right before a single 3D
single 3D vertex exists. Building the mesher before the data is right is the specific trap this vertex exists. Building the mesher before the data is right is the specific trap this rewrite
rewrite exists to undo. 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.

View file

@ -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`) | | `Scripts/TerrainGenTool.cs` | Batch entry point (ladder + seed batch + `INDEX.md`) |
| `Scenes/TerrainGenTool.tscn` | Run this | | `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) ### The map renders — presentation (Phase 1, the look)
**Presentation only** (→ `Design - Rendering - Roughness Is Presentation.md`): it changes how height **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). > 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. > 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 **Deferred, with the seam already open:** the submarine **coast shelf** and the **offshore islets**
**offshore islets** (reference ~:621-664). Both act only below sea level and are judged once water (reference ~:621-664). Both act only below sea level and are judged once water renders.
renders. `Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and `Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and
`Pass1Result.HMaxSeed` is carried for the seed-dependent redistribution curve. `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 ### ⚠ The render writes a `Godot.Image` directly — not the capture path

View file

@ -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")

View file

@ -0,0 +1,658 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ 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
/// </summary>
public partial class CurveBaselineTool : Node
{
/// <summary>
/// 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.
/// </summary>
private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 2048;
private const int DefaultShowpieceSize = 8192;
/// <summary>
/// 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.
/// </summary>
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<int, Pass1Result>();
var perSeedKnots = new List<CurveKnots>();
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<int, Pass2Result>();
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<ShapingOracle.Check>();
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<string>();
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,
};
/// <summary>
/// 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.
/// </summary>
private static double raw3Stop(LandHistogram h) => h.FractionBelow(0.310f);
/// <summary>
/// The widest maxmin 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.
/// </summary>
private static float KnotSpread(List<CurveKnots> 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;
}
/// <summary>The knots ARE the quantiles. This one method is the whole calibration.</summary>
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<string> 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<ShapingOracle.Check> checks,
List<string> 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<int>();
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;
}
}
}

View file

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

View file

@ -0,0 +1,337 @@
using System;
using System.Collections.Generic;
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Draws a <see cref="LandHistogram"/> 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 <see cref="TinyFont"/> (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 <c>. - : / ( )</c> only. Unsupported characters render as
/// blanks, so labels here say "PCT" rather than "%" and avoid commas.
/// </summary>
public static class HistogramRenderer
{
/// <summary>A vertical reference line — a knot, or an output anchor.</summary>
public sealed class Marker
{
public float Value;
public string Label;
/// <summary>Strong markers get a brighter line; use for the ones that carry the argument.</summary>
public bool Strong = true;
}
/// <summary>A shaded span between two values, labelled underneath. The curve's bands.</summary>
public sealed class Band
{
public float Lo, Hi;
public string Label;
/// <summary>Share of land in this band, in percent. Drawn under the label.</summary>
public double SharePercent;
}
public sealed class Options
{
public string Title = "LAND HEIGHT DISTRIBUTION";
public string Subtitle = "";
public string Footer = "";
public string XAxisLabel = "RAW HEIGHT";
/// <summary>Right edge of the x axis, raw. Defaults to the histogram's max land height.</summary>
public float XTop = 0f;
public int Width = 1800;
public int Height = 1000;
public List<Marker> Markers = new();
public List<Band> 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
/// <summary>
/// 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 <paramref name="peak"/> 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.
/// </summary>
private static long ClipCap(LandHistogram h, long peak)
{
var nonEmpty = new List<long>();
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;
}
/// <summary>Render and save. Returns the path written.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
}

View file

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

View file

@ -0,0 +1,234 @@
using System;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ 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
/// <see cref="CalibrationBinWidth"/> 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 <c>Tools/</c> rather than <c>Core/</c> because it is a
/// MEASURING INSTRUMENT for the generator, not a contract about the world — the same reasoning
/// that keeps <c>IslandFalloff</c> here. Core carries what the world IS; Tools carries what we
/// point at it.
/// </summary>
public sealed class LandHistogram
{
/// <summary>
/// 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.
/// </summary>
public const float CalibrationBinWidth = 1e-4f;
/// <summary>
/// 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.
/// </summary>
public const float DefaultTop = 4.0f;
/// <summary>Heights at or below this are not land and are not counted. The curve's identity threshold.</summary>
public readonly float SeaLevel;
/// <summary>Bin width in raw height units.</summary>
public readonly float BinWidth;
/// <summary>Top of the binned range; samples above it go to <see cref="OverflowCount"/>.</summary>
public readonly float Top;
private readonly long[] _counts;
/// <summary>Land samples at or above <see cref="Top"/>. ⚠ Reported, not hidden.</summary>
public long OverflowCount { get; private set; }
/// <summary>Total land samples accumulated, overflow included.</summary>
public long TotalLand { get; private set; }
/// <summary>Lowest and highest land sample seen, exactly (not bin-quantized).</summary>
public float MinLand { get; private set; } = float.MaxValue;
public float MaxLand { get; private set; } = float.MinValue;
/// <summary>How many fields have been pooled in. The calibration pool's size.</summary>
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)];
}
/// <summary>Number of bins (excluding overflow).</summary>
public int BinCount => _counts.Length;
/// <summary>Raw height at the low edge of bin <paramref name="i"/>.</summary>
public float BinLow(int i) => SeaLevel + i * BinWidth;
/// <summary>Sample count in bin <paramref name="i"/>.</summary>
public long BinCountAt(int i) => _counts[i];
/// <summary>
/// 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 <c>&gt; SeaLevel</c>, matching the curve's own <c>h &lt;= Sea → identity</c>
/// 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.
/// </summary>
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++;
}
/// <summary>
/// The quantile at <paramref name="percent"/> (0..100) — a raw height, interpolated inside
/// its bin so the answer is not quantized to <see cref="BinWidth"/>.
///
/// ⚠ 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.
/// </summary>
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.");
}
/// <summary>Fraction of land strictly below <paramref name="h"/>, interpolated within the bin.</summary>
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;
}
/// <summary>Fraction of land in <c>[lo, hi)</c>.</summary>
public double FractionBetween(float lo, float hi) => Math.Max(0.0, FractionBelow(hi) - FractionBelow(lo));
/// <summary>The tallest bin's count — the y-axis a plot needs.</summary>
public long PeakBinCount()
{
long peak = 0;
foreach (long c in _counts) if (c > peak) peak = c;
return peak;
}
/// <summary>
/// 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.
/// </summary>
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})";
}
}

View file

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

View file

@ -0,0 +1,130 @@
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Everything pass 2 produces: THE TWO HEIGHT FIELDS. → <see cref="Shaping"/>.
///
/// ═══ ⭐⭐ THE TWO-FIELD SPLIT (D-046) — THE DISCIPLINE THIS CLASS EXISTS TO HOLD ═══
///
/// <see cref="HeightClassify"/> RAW. Uncurved, un-detailed. The ORACLE.
/// <see cref="Height"/> 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 (<c>_heightMapClassify = (_curveOn || _erosionOn) ? new float[…]
/// : _heightMap</c>). <see cref="FieldsAreAliased"/> 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.
/// </summary>
public sealed class Pass2Result
{
/// <summary>Map side in columns.</summary>
public readonly int MapSize;
/// <summary>The resolved seed. Same seed, same two fields.</summary>
public readonly int Seed;
/// <summary>
/// ⭐ THE RENDER FIELD, <c>[x, y]</c> — curved and detailed. What gets drawn, dumped and
/// (later) eroded, carved and meshed.
/// </summary>
public readonly float[,] Height;
/// <summary>
/// ⭐ THE CLASSIFY FIELD, <c>[x, y]</c> — 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.
/// </summary>
public readonly float[,] HeightClassify;
/// <summary>Was the curve applied? The primary A/B gate.</summary>
public readonly bool CurveOn;
/// <summary>Was shelf detail applied? Requires <see cref="CurveOn"/> — it warps the curve's knots.</summary>
public readonly bool DetailOn;
/// <summary>The knot set used. Null when the curve is off.</summary>
public readonly CurveKnots Knots;
/// <summary>The output anchors used. Null when the curve is off.</summary>
public readonly CurveAnchors Anchors;
/// <summary>The per-seed spike input, carried through from pass 1.</summary>
public readonly float HMaxSeed;
/// <summary>The edge-warp amplitude actually APPLIED, raw units (post-clamp). Zero when detail is off.</summary>
public readonly float EdgeAmpRaw;
/// <summary>The knot set's safe warp bound, raw units — what <see cref="EdgeAmpRaw"/> was clamped to.</summary>
public readonly float MaxEdgeShiftRaw;
/// <summary>Render-field extremes after shaping. For the ramp and the report.</summary>
public readonly float HMin, HMax;
/// <summary>Wall-clock milliseconds pass 2 took.</summary>
public readonly ulong ElapsedMs;
/// <summary>
/// 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.
/// </summary>
public readonly List<string> 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<string> 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;
}
/// <summary>
/// ⚠ 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.
/// </summary>
public bool FieldsAreAliased => ReferenceEquals(Height, HeightClassify);
/// <summary>Fraction of the RENDER field at or above the sea threshold.</summary>
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);
}
}
}

View file

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

205
Tools/Scripts/Shaping.cs Normal file
View file

@ -0,0 +1,205 @@
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ PASS 2a — the redistribution curve and the shelf detail, applied per column, producing the
/// TWO HEIGHT FIELDS. Ported from the reference's <c>MapGenerator.GenerateTopography</c> pass-2
/// loop (<c>Tools/Scripts/MapGenerator.cs</c> ~:692-735 at tag <c>pre-rewrite-reference</c>,
/// commit <c>ab78883</c>). → 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 <c>[K6, hMaxSeed]</c> onto the peak band, so no pixel can be
/// curved until every pixel has been scanned. <see cref="Pass1Result.HMaxSeed"/> carries that
/// number across the boundary, and <c>AssertMonotonic</c> 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: <c>_heightMap</c> held pass-1 raw, then pass 2 overwrote it
/// column by column. This port leaves <see cref="Pass1Result.Height"/> untouched and allocates
/// the render field.
///
/// The arithmetic is identical — every column still reads <c>raw</c> and writes <c>curvedH</c>.
/// 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 <see cref="Pass2Result.FieldsAreAliased"/>.)
/// </summary>
public static class Shaping
{
/// <summary>
/// Apply pass 2a to a pass-1 field.
///
/// ⚠ Pure with respect to <paramref name="p1"/>: nothing here writes to its arrays.
/// </summary>
public static Pass2Result Shape(Pass1Result p1, TerrainGenConfig cfg)
{
ulong t0 = Time.GetTicksMsec();
int mapSize = p1.MapSize;
var notes = new List<string>();
// ═══ 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);
}
}
}

View file

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

View file

@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ 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.
/// </summary>
public static class ShapingOracle
{
/// <summary>One check's verdict. <see cref="Detail"/> carries the evidence either way.</summary>
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}";
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// (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
/// <see cref="RegressionAgainstDump"/>, which needs a Phase-1 `.f32` on disk.
/// </summary>
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;
}
/// <summary>
/// (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.
/// </summary>
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;
}
/// <summary>
/// (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.
/// </summary>
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;
}
/// <summary>
/// (c) MONOTONICITY — recorded rather than re-run.
///
/// <c>HeightCurve.AssertMonotonic</c> THROWS on violation and is called inside
/// <see cref="Shaping.Shape"/>, 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.
/// </summary>
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;
}
/// <summary>
/// (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 <see cref="LandHistogram"/> measured what it claimed, which is the one
/// thing the reference could never verify about its own knots.
/// </summary>
/// <param name="tolerancePercentagePoints">
/// 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.
/// </param>
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;
}
/// <summary>
/// The fraction of land, in percent, falling in each of the curve's seven INPUT bands.
/// Band edges are sea, K1..K6, +∞.
/// </summary>
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;
}
/// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary>
public static string ToMarkdownTable(IEnumerable<Check> 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();
}
}
}

View file

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

View file

@ -87,18 +87,92 @@ namespace IslaApocalypse.Tools
/// <summary>Rung 6: the mountain spine up the centre-X axis.</summary> /// <summary>Rung 6: the mountain spine up the centre-X axis.</summary>
public bool MountainSpine = true; 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.
/// <summary>
/// ⭐ Pass 2a rung 1: the height-redistribution curve. → <see cref="HeightCurve"/>.
/// Off = raw pass-1 height, unshaped (the control half of every A/B in this phase).
/// </summary>
public bool Curve = true;
/// <summary>
/// ⭐ Pass 2a rung 2: the shelf detail passes — micro-relief skin + shelf-edge knot warp.
/// ⚠ REQUIRES <see cref="Curve"/>: 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.
/// </summary>
public bool ShelfDetail = true;
/// <summary>
/// Micro-relief amplitude, in METRES of output height. Reference default: 3 m.
/// Converted through <see cref="WorldScale"/> at the call site — never a literal /251.
/// </summary>
public float ShelfReliefAmpM = TerrainDetailPass.ReliefAmpDefaultM;
/// <summary>
/// Shelf-edge warp amplitude, in METRES OF INPUT HEIGHT (not output elevation — see
/// <see cref="TerrainDetailPass"/>). Reference default: 12 m.
///
/// ⚠ CLAMPED, LOUDLY, to the knot set's safe bound (<c>TerrainDetailPass.MaxEdgeShift</c>).
/// Monotonicity is never a tuning question; an ignored dial is always reported.
/// </summary>
public float ShelfEdgeVariationM = TerrainDetailPass.EdgeAmpDefaultM;
/// <summary>
/// The input knot set — WHERE the land distribution is cut.
/// Default: <see cref="CurveKnots.V2Baseline"/>, re-measured on v2's own pass-1 output.
/// <see cref="CurveKnots.Reference"/> is available for a fidelity A/B against the prototype's.
/// </summary>
public CurveKnots Knots = CurveKnots.V2Baseline;
/// <summary>
/// The output anchors — WHAT HEIGHT each cut lands at. Default: the storm-ladder values,
/// reproducing the reference's constants bit-for-bit.
/// </summary>
public CurveAnchors Anchors = CurveAnchors.Default;
// ---- the crater seam — INERT THIS PHASE -----------------------------
/// <summary>
/// Crater radius in columns. ⚠ <b>0 = NO CRATER, which is this phase's state.</b> 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.
/// </summary>
public float CraterRadius = 0f;
/// <summary>Crater centre X, columns. Unused while <see cref="CraterRadius"/> is 0.</summary>
public float CraterCenterX = 0f;
/// <summary>Crater centre Y, columns. Unused while <see cref="CraterRadius"/> is 0.</summary>
public float CraterCenterY = 0f;
/// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary> /// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary>
public string VariantLabel = "full"; public string VariantLabel = "full";
/// <summary>The scale object every distance and frequency in the generator derives from.</summary> /// <summary>The scale object every distance and frequency in the generator derives from.</summary>
public GenerationScale Scale => new GenerationScale(MapSize); public GenerationScale Scale => new GenerationScale(MapSize);
public TerrainGenConfig Clone() => (TerrainGenConfig)MemberwiseClone(); /// <summary>
/// ⚠ DEEP on <see cref="Anchors"/>. <c>MemberwiseClone</c> 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 (<see cref="CurveKnots"/>) does not need to be.
/// </summary>
public TerrainGenConfig Clone()
{
var c = (TerrainGenConfig)MemberwiseClone();
c.Anchors = Anchors?.Clone();
return c;
}
public override string ToString() => public override string ToString() =>
$"MapSize={MapSize} Seed={Seed} axis={IslandAxisX:F2}x/{IslandAxisY:F2}y " + $"MapSize={MapSize} Seed={Seed} axis={IslandAxisX:F2}x/{IslandAxisY:F2}y " +
$"falloffStrength={FalloffStrength:F2} sea={SeaLevel:F2} variant={VariantLabel} " + $"falloffStrength={FalloffStrength:F2} sea={SeaLevel:F2} variant={VariantLabel} " +
$"[base={BaseNoise} falloff={IslandFalloff} edge={EdgeNoise} sinker={SouthernSinker} " + $"[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)}]";
} }
} }

View file

@ -84,6 +84,55 @@ namespace IslaApocalypse.Tools
return noise; return noise;
} }
/// <summary>
/// A MODULATION field — the curve's bench/plateau/strength anchors and the detail pass's
/// relief/edge fields. Ported from the reference's <c>MapGenerator.MakeModulationNoise</c>
/// (~:1041-1048).
///
/// ═══ ⚠ DECORRELATION IS BY SEED, NOT BY COORDINATE OFFSET ═══
///
/// The reference offset these fields from each other with <c>Seed = resolvedSeed + offset</c>
/// (7101 / 7207 / 7303 / 7409 / 7507 / 7607 / 9271) and then sampled every one of them at the
/// bare <c>(x, y)</c>. There is no <c>GetNoise2D(x + 1000, …)</c> anywhere in this path.
///
/// So the raw-pixel-offset hazard <see cref="GenerationScale"/> warns about — the one that
/// bit the latitude wobble in pass 1 — <b>does not apply here, and there was nothing to
/// normalize on the port.</b> 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
/// <c>periodsPerIsland / MapSize</c>, which is already size-independent. →
/// <see cref="GenerationScale.NoiseFrequencyPerMapWidth"/>.
///
/// ⚠ 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.
/// </summary>
/// <param name="seed">The run's resolved seed — the offset is added here, not by the caller.</param>
/// <param name="seedOffset">The field's decorrelation offset (e.g. <c>CurveAnchors.BenchSeedOffset</c>).</param>
/// <param name="periodsPerMapWidth">How many undulations across the island.</param>
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;
}
/// <summary>The pinned configuration as one line, for a run header.</summary> /// <summary>The pinned configuration as one line, for a run header.</summary>
public static string Describe(int seed, GenerationScale scale) => public static string Describe(int seed, GenerationScale scale) =>
$"Simplex · Fbm · octaves {PinnedOctaves} · gain {PinnedGain} · lacunarity {PinnedLacunarity} · " + $"Simplex · Fbm · octaves {PinnedOctaves} · gain {PinnedGain} · lacunarity {PinnedLacunarity} · " +