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:
parent
527fab41e5
commit
35b4818e9e
29 changed files with 2855 additions and 18 deletions
|
|
@ -31,7 +31,12 @@ resolution and the file-safety rails. Constants and contracts.
|
|||
| `Scripts/BuildingMaterial.cs` | A row in the building schema (D-054). |
|
||||
| `Scripts/MaterialRegistry.cs` | Both registries, append-only, seed rows only. |
|
||||
| `Scripts/RecipeRegistry.cs` | The transformation seam — **deliberately empty**. |
|
||||
| `Scripts/GenerationScale.cs` | ⭐ The scaling discipline. `MapSize`, `ScaleFactor`, normalized offsets. |
|
||||
| `Scripts/GenerationScale.cs` | ⭐ The scaling discipline. `MapSize`, `ScaleFactor`, normalized offsets, the two frequency conventions. |
|
||||
| `Scripts/WorldScale.cs` | ⭐ **The vertical yardstick.** The ONE metres↔raw conversion (251 m/unit). No literal `251f` anywhere else. |
|
||||
| `Scripts/HeightCurve.cs` | ⭐⭐ The height-redistribution curve (v5), 7 bands. **Identity at and below sea.** |
|
||||
| `Scripts/CurveKnots.cs` | The six INPUT knots — percentiles of the measured land CDF, plus the reference's for comparison. |
|
||||
| `Scripts/CurveAnchors.cs` | The OUTPUT anchors — the storm-ladder elevations each band lands at. |
|
||||
| `Scripts/TerrainDetailPass.cs` | Shelf micro-relief + the shelf-edge **knot warp**. Output-height only. |
|
||||
| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
|
||||
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |
|
||||
|
||||
|
|
@ -43,13 +48,20 @@ resolution and the file-safety rails. Constants and contracts.
|
|||
→ `Scripts/MaterialRegistry.cs`.
|
||||
3. **Nothing uses a raw pixel number.** Every distance is a fraction of `MapSize`.
|
||||
→ `Scripts/GenerationScale.cs`.
|
||||
4. **There is one metres-per-raw-unit number.** The prototype scattered `251` across three
|
||||
duplicate constants and ~20 literals, and the generator never used the derived one at all.
|
||||
→ `Scripts/WorldScale.cs`.
|
||||
|
||||
## Not here, and not by accident
|
||||
|
||||
- **No water.** Water is an overlay over the columns (levels-not-cells), never a band.
|
||||
- **No biomes.** Biomes are a later *classification* of finished shape, not an input to it (D-049).
|
||||
- **No algorithms.** Phase 0 is shape. Stratigraphy, feature passes, meshing and run-splitting on
|
||||
dig are later phases.
|
||||
- **No algorithms** *beyond the height curve*. Phase 2 added `HeightCurve` and
|
||||
`TerrainDetailPass` here because they are pure, engine-free, C++-candidate math that defines the
|
||||
world's elevation profile — a contract, not a tool's dial. Stratigraphy, feature passes, meshing
|
||||
and run-splitting on dig are still later phases.
|
||||
- **No erosion, rivers, water bodies, crater carve, coast shelf or offshore islets.** Later chat2
|
||||
tasks; the curve is deliberately the only pass-2 element present.
|
||||
|
||||
→ `Design - Data - Column Model.md`, `Design - Data - Material Schema.md`,
|
||||
`Design - Tooling - Scaling Discipline.md`
|
||||
|
|
|
|||
142
Core/Scripts/CurveAnchors.cs
Normal file
142
Core/Scripts/CurveAnchors.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
1
Core/Scripts/CurveAnchors.cs.uid
Normal file
1
Core/Scripts/CurveAnchors.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://by6kmr31oiodj
|
||||
120
Core/Scripts/CurveKnots.cs
Normal file
120
Core/Scripts/CurveKnots.cs
Normal 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 <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})";
|
||||
}
|
||||
}
|
||||
1
Core/Scripts/CurveKnots.cs.uid
Normal file
1
Core/Scripts/CurveKnots.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://f3vitjt1itci
|
||||
|
|
@ -101,6 +101,28 @@ namespace IslaApocalypse.Core
|
|||
/// </summary>
|
||||
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>
|
||||
/// ⭐ A decorrelation offset for a noise coordinate, declared in MAP WIDTHS.
|
||||
///
|
||||
|
|
|
|||
249
Core/Scripts/HeightCurve.cs
Normal file
249
Core/Scripts/HeightCurve.cs
Normal 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(2−u) ease-out
|
||||
/// red [K1, K2) Orange → RedCeil linear
|
||||
/// foothill riser [K2, k3) RedCeil → benchLo 0.1u + 0.9·smoothstep
|
||||
/// bench [k3, k4) benchLo → benchTop linear
|
||||
/// mid riser [k4, k5) benchTop → plateauLo 0.1u + 0.9·smoothstep
|
||||
/// plateau [k5, K6) plateauLo→ plateauTop linear
|
||||
/// summit spike [K6, sMax) plateauTop → PeakCap 0.05u + 0.95·u⁴
|
||||
/// tail [sMax, ∞) PeakCap + (h−sMax)·TailSlope linear
|
||||
///
|
||||
/// The riser and spike blends are the reference's "corner fixes" and are FROZEN: the 0.1 riser
|
||||
/// floor makes climbs decelerate into shelves and accelerate out of them (no machined edges), and
|
||||
/// the 0.05 spike floor lets the summit leave the plateau gently (no hard skirt under the peaks).
|
||||
///
|
||||
/// ═══ ⚠⚠ THE LOAD-BEARING LINE ═══
|
||||
///
|
||||
/// if (h <= a.Sea) return h;
|
||||
///
|
||||
/// THE CURVE IS IDENTITY AT AND BELOW SEA. Everything downstream rests on it: the waterline
|
||||
/// cannot move, the Trench's ocean-border guarantee survives, and — when water lands — the
|
||||
/// classify/render split agrees everywhere outside the crater, because a monotonic curve that
|
||||
/// fixes sea means <c>Apply(raw) < sea</c> exactly when <c>raw < 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}).";
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Core/Scripts/HeightCurve.cs.uid
Normal file
1
Core/Scripts/HeightCurve.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://7d68p2nuge8x
|
||||
182
Core/Scripts/TerrainDetailPass.cs
Normal file
182
Core/Scripts/TerrainDetailPass.cs
Normal 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 < K3±d, K5±d < 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Core/Scripts/TerrainDetailPass.cs.uid
Normal file
1
Core/Scripts/TerrainDetailPass.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c7q3lm57y55ib
|
||||
80
Core/Scripts/WorldScale.cs
Normal file
80
Core/Scripts/WorldScale.cs
Normal 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)";
|
||||
}
|
||||
}
|
||||
1
Core/Scripts/WorldScale.cs.uid
Normal file
1
Core/Scripts/WorldScale.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b01o7jxa174xr
|
||||
41
README.md
41
README.md
|
|
@ -3,8 +3,13 @@
|
|||
The rewrite (**D-049**). A post-apocalyptic survival voxel game: one island, generated once offline,
|
||||
loaded by everyone.
|
||||
|
||||
**Status: Phase 0 — foundation.** This repo is a *skeleton*. It builds, it resolves its own runtime
|
||||
directory, and it carries the two data contracts and the tooling rails. **It generates nothing.**
|
||||
**Status: Phase 2 — shaping.** Phase 0 laid the contracts and the tooling rails; Phase 1 ported the
|
||||
crown-jewel noise and pass 1 (the island's raw shape); chat2/01 added **pass 2a — the height
|
||||
redistribution curve and shelf detail**, re-calibrated against this repo's own output, with the
|
||||
two-height-field split and an automatic oracle.
|
||||
|
||||
**Still no** erosion, rivers, water bodies, crater carve, coast shelf, offshore islets, biomes,
|
||||
roads or mesher.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -50,9 +55,9 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
|
|||
|
||||
```
|
||||
Core/ math + data only, ENGINE-FREE — depends on nothing above it
|
||||
Server/ authoritative logic — may use Core (empty: Phase 0)
|
||||
Client/ rendering — may use Core (empty: Phase 0)
|
||||
Tools/ the offline generator — ⚠ may NOT use Client (Phase 1 fills it)
|
||||
Server/ authoritative logic — may use Core (empty: not yet its phase)
|
||||
Client/ rendering — may use Core (empty: not yet its phase)
|
||||
Tools/ the offline generator — ⚠ may NOT use Client
|
||||
```
|
||||
|
||||
Each layer has a `README.md` stating its role and its boundary. Read the one for the layer you are
|
||||
|
|
@ -68,6 +73,12 @@ about to write in — they carry the reasons, not just the rules.
|
|||
per-origin, never a material property. → `Core/Scripts/MaterialRegistry.cs`
|
||||
- **The scaling discipline** — nothing uses a raw pixel number.
|
||||
→ `Core/Scripts/GenerationScale.cs`
|
||||
- **The vertical yardstick** — ONE metres-per-raw-unit number, not three constants and twenty
|
||||
literals as the prototype had. → `Core/Scripts/WorldScale.cs`
|
||||
- **The elevation profile** — the 7-band redistribution curve, **identity at and below sea**, its
|
||||
knots measured rather than chosen. → `Core/Scripts/HeightCurve.cs`
|
||||
- **The two-height-field split** (D-046) — `heightClassify` is raw and is the oracle;
|
||||
`height` is curved and is what gets drawn. → `Tools/Scripts/Pass2Result.cs`
|
||||
- **Tooling safety** — every path env-overridable, deletion refused by code.
|
||||
→ `Core/Scripts/ToolingPaths.cs`, `Core/Scripts/FileSafety.cs`
|
||||
|
||||
|
|
@ -83,9 +94,21 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless --import --path .
|
|||
> The reference repo is Godot 4.7.1. This repo deliberately targets **4.7.2** — clean rewrite,
|
||||
> current tooling. 4.7.1 is the *port source*, not a constraint on new code.
|
||||
|
||||
> ### ⚠ `<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
|
||||
|
||||
No generation, no mesher, no stratigraphy, no biomes, no water, no algorithms of any kind. The
|
||||
pipeline is **2D-maps-first** (D-056): stages 1–5 are flat maps, gotten completely right before a
|
||||
single 3D vertex exists. Building the mesher before the data is right is the specific trap this
|
||||
rewrite exists to undo.
|
||||
No mesher, no stratigraphy, no biomes, no water, no rivers, no erosion. The pipeline is
|
||||
**2D-maps-first** (D-056): stages 1–5 are flat maps, gotten completely right before a single 3D
|
||||
vertex exists. Building the mesher before the data is right is the specific trap this rewrite
|
||||
exists to undo.
|
||||
|
||||
**And the curve is a BASELINE, not a verdict.** It is ported faithfully and calibrated honestly so
|
||||
that later reshaping has a control to be judged against. Whether this elevation profile — 60 % of
|
||||
land in the bottom 14 m, 4 % above the plateau — is the one the world wants is an open question the
|
||||
histograms exist to inform, not one this port has answered.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,64 @@ constants, carried over verbatim — not re-derived from a design summary** (→
|
|||
| `Scripts/TerrainGenTool.cs` | Batch entry point (ladder + seed batch + `INDEX.md`) |
|
||||
| `Scenes/TerrainGenTool.tscn` | Run this |
|
||||
|
||||
### The generator — pass 2a: the redistribution curve (Phase 2, chat2/01)
|
||||
|
||||
**Ported from `REFERENCE:Tools/Scripts/HeightCurve.cs` + `TerrainDetailPass.cs`** at the same tag.
|
||||
The curve itself lives in `Core/` (engine-free, a named C++-candidate seam); `Tools/` carries the
|
||||
wiring, the measuring instrument and the batch.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `Scripts/Shaping.cs` | ⭐⭐ Pass 2a — curve + detail per column, producing **the two height fields** |
|
||||
| `Scripts/Pass2Result.cs` | The render field, the classify field, and `FieldsAreAliased` |
|
||||
| `Scripts/LandHistogram.cs` | ⭐ The land CDF — **the calibration engine AND the diagnostic** |
|
||||
| `Scripts/HistogramRenderer.cs` | The labelled distribution plot (clipped axis, marked) |
|
||||
| `Scripts/ShapingOracle.cs` | ⭐⭐ The four automatic correctness checks |
|
||||
| `Scripts/CurveBaselineTool.cs` | The chat2/01 batch: calibrate → variants → histograms → oracle |
|
||||
| `Scenes/CurveBaselineTool.tscn` | Run this |
|
||||
|
||||
> ### ⭐⭐ THE TWO-FIELD SPLIT (D-046) STARTS HERE
|
||||
>
|
||||
> `heightClassify` is **raw, uncurved, un-detailed** — bit-for-bit the pass-1 field.
|
||||
> `height` is **curved and detailed**, and is what gets drawn and (later) eroded and carved.
|
||||
>
|
||||
> Everything that CLASSIFIES the world (biomes, water bodies, the ocean fill) will read the classify
|
||||
> field; everything that DRAWS it reads the render field. Nothing consumes the classify field yet —
|
||||
> it is established at the curve because **the curve is where the second field is born**, and
|
||||
> retrofitting a classify path after three passes have run on one array is how the two silently
|
||||
> diverge. `Pass2Result.FieldsAreAliased` is true when the curve is off (both point at one array, as
|
||||
> the reference did) — a later pass that writes one while reading the other must read both into
|
||||
> locals first.
|
||||
|
||||
> ### ⭐ THE KNOTS ARE MEASURED, NOT CHOSEN
|
||||
>
|
||||
> The curve's six input knots are **P60/73/83/88/96/99 of the pooled land height distribution**,
|
||||
> which is what makes the band shares 60/13/10/5/8/3/1 exact by construction. The reference shipped
|
||||
> the six resulting literals and threw the instrument away — so its knots could never be re-derived,
|
||||
> only trusted. `LandHistogram` is that instrument, rebuilt.
|
||||
>
|
||||
> ⚠ **A knot set is only valid for the distribution it was measured on.** Re-run
|
||||
> `CurveBaselineTool` after any change to pass 1.
|
||||
|
||||
```bash
|
||||
xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||
--path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CurveBaselineTool.tscn
|
||||
```
|
||||
|
||||
`ISLA_MAPSIZE` (default 2048) · `ISLA_SEEDS` (6 pinned) · `ISLA_SHOWPIECE_SIZE` (8192) ·
|
||||
`ISLA_SHOWPIECE=0` · `ISLA_VARIANTS=0` (calibration-only probe) · `ISLA_PHASE1_SOURCE` ·
|
||||
`ISLA_SKIP_RAW=1`
|
||||
|
||||
**The oracle runs before anything is looked at**, and the tool exits non-zero if any check fails:
|
||||
|
||||
| | Check |
|
||||
|---|---|
|
||||
| `a` | curve OFF is bit-identical to Phase 1's pass-1 output |
|
||||
| `a′` | pass 1 is bit-identical to Phase 1's `.f32` dump |
|
||||
| `b` | the classify field is bit-identical to the raw pre-curve field, curve on or off |
|
||||
| `c` | the effective per-seed curve is strictly monotonic (24-corner sweep, throws otherwise) |
|
||||
| `d` | realized land shares match 60/13/10/5/8/3/1 |
|
||||
|
||||
### The map renders — presentation (Phase 1, the look)
|
||||
|
||||
**Presentation only** (→ `Design - Rendering - Roughness Is Presentation.md`): it changes how height
|
||||
|
|
@ -139,12 +197,19 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
|
|||
> reads**, and nothing else. Climate is **stage 3** and classifies finished shape (D-049 §2, D-056).
|
||||
> Do not alias, store, or rename this into a climate map.
|
||||
|
||||
**Deferred to Phase 2, with the seam already open:** the submarine **coast shelf** and the
|
||||
**offshore islets** (reference ~:621-664). Both act only below sea level and are judged once water
|
||||
renders. `Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and
|
||||
**Deferred, with the seam already open:** the submarine **coast shelf** and the **offshore islets**
|
||||
(reference ~:621-664). Both act only below sea level and are judged once water renders.
|
||||
`Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and
|
||||
`Pass1Result.HMaxSeed` is carried for the seed-dependent redistribution curve.
|
||||
|
||||
**Not here at all:** the curve, erosion, rivers, water bodies, the crater, biomes, roads, the mesher.
|
||||
> ⚠⚠ **When the coast shelf and islets land, `HMaxSeed` must move with them.** The reference takes
|
||||
> its map-wide max *after* both layers have already modified the height, inside the same pass-1 loop
|
||||
> (`MapGenerator.cs:665`). v2 currently takes it before, because the layers do not exist. Since
|
||||
> `HMaxSeed` normalizes the curve's summit spike, porting those layers without moving the max
|
||||
> computation below them changes the world for a given seed — silently, with no throw and no failed
|
||||
> assertion. → chat2/00 report, Drift §2.
|
||||
|
||||
**Not here at all:** erosion, rivers, water bodies, the crater carve, biomes, roads, the mesher.
|
||||
|
||||
### ⚠ The render writes a `Godot.Image` directly — not the capture path
|
||||
|
||||
|
|
|
|||
6
Tools/Scenes/CurveBaselineTool.tscn
Normal file
6
Tools/Scenes/CurveBaselineTool.tscn
Normal 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")
|
||||
658
Tools/Scripts/CurveBaselineTool.cs
Normal file
658
Tools/Scripts/CurveBaselineTool.cs
Normal 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 max−min range of any single knot across a set of measurements — "how much does
|
||||
/// K_n move if you just change which seed you looked at". The yardstick every other knot
|
||||
/// delta in this batch has to be judged against.
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/CurveBaselineTool.cs.uid
Normal file
1
Tools/Scripts/CurveBaselineTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b0cmaubwul6fl
|
||||
337
Tools/Scripts/HistogramRenderer.cs
Normal file
337
Tools/Scripts/HistogramRenderer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/HistogramRenderer.cs.uid
Normal file
1
Tools/Scripts/HistogramRenderer.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cmrft1fsxfj0r
|
||||
234
Tools/Scripts/LandHistogram.cs
Normal file
234
Tools/Scripts/LandHistogram.cs
Normal 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>> SeaLevel</c>, matching the curve's own <c>h <= 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})";
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/LandHistogram.cs.uid
Normal file
1
Tools/Scripts/LandHistogram.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c1ri5puqulvge
|
||||
130
Tools/Scripts/Pass2Result.cs
Normal file
130
Tools/Scripts/Pass2Result.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/Pass2Result.cs.uid
Normal file
1
Tools/Scripts/Pass2Result.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ccjbd838sb6jc
|
||||
205
Tools/Scripts/Shaping.cs
Normal file
205
Tools/Scripts/Shaping.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/Shaping.cs.uid
Normal file
1
Tools/Scripts/Shaping.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bk7r2ku7blrj3
|
||||
238
Tools/Scripts/ShapingOracle.cs
Normal file
238
Tools/Scripts/ShapingOracle.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/ShapingOracle.cs.uid
Normal file
1
Tools/Scripts/ShapingOracle.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c4yc8p1ygkcau
|
||||
|
|
@ -87,18 +87,92 @@ namespace IslaApocalypse.Tools
|
|||
/// <summary>Rung 6: the mountain spine up the centre-X axis.</summary>
|
||||
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>
|
||||
public string VariantLabel = "full";
|
||||
|
||||
/// <summary>The scale object every distance and frequency in the generator derives from.</summary>
|
||||
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() =>
|
||||
$"MapSize={MapSize} Seed={Seed} axis={IslandAxisX:F2}x/{IslandAxisY:F2}y " +
|
||||
$"falloffStrength={FalloffStrength:F2} sea={SeaLevel:F2} variant={VariantLabel} " +
|
||||
$"[base={BaseNoise} falloff={IslandFalloff} edge={EdgeNoise} sinker={SouthernSinker} " +
|
||||
$"trench={Trench} spine={MountainSpine}]";
|
||||
$"trench={Trench} spine={MountainSpine}] " +
|
||||
$"[curve={Curve} detail={ShelfDetail} relief={ShelfReliefAmpM:F1}m edge={ShelfEdgeVariationM:F1}m " +
|
||||
$"knots={(Knots == null ? "-" : Knots.Name)}]";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,55 @@ namespace IslaApocalypse.Tools
|
|||
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>
|
||||
public static string Describe(int seed, GenerationScale scale) =>
|
||||
$"Simplex · Fbm · octaves {PinnedOctaves} · gain {PinnedGain} · lacunarity {PinnedLacunarity} · " +
|
||||
|
|
|
|||
Loading…
Reference in a new issue