islaApocalypse-v2/Core/Scripts/HeightCurve.cs
beezm 35b4818e9e 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
2026-08-20 01:38:10 -04:00

249 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐⭐ THE HEIGHT-REDISTRIBUTION CURVE — pass 2's first act, and the shape of the island's
/// elevation profile. Ported from <c>REFERENCE:Tools/Scripts/HeightCurve.cs</c> (v5) at tag
/// <c>pre-rewrite-reference</c> (<c>ab78883</c>). → D-050 ("port, don't re-derive").
///
/// ═══ WHAT IT IS FOR ═══
///
/// Raw fractal noise is Gaussian-ish: almost all land sits in a narrow mid-band and there is no
/// coastal plain, no shelf, no distinguishable summit. The curve REDISTRIBUTES that distribution
/// onto a designed elevation profile — a wide low plain, two shelves, risers between them, and a
/// thin summit band under a hard cap. It changes WHERE heights land, never WHICH pixel is higher
/// than which: the curve is strictly monotonic, so the terrain's topology is untouched.
///
/// ═══ THE SEVEN BANDS (input knot → output anchor) ═══
///
/// band input output shape
/// ─────────────── ─────────── ──────────────────────────── ──────────────────────────
/// toe / orange [SEA, K1) Sea → OrangeCeil 0.3u + 0.7·u(2u) ease-out
/// red [K1, K2) Orange → RedCeil linear
/// foothill riser [K2, k3) RedCeil → benchLo 0.1u + 0.9·smoothstep
/// bench [k3, k4) benchLo → benchTop linear
/// mid riser [k4, k5) benchTop → plateauLo 0.1u + 0.9·smoothstep
/// plateau [k5, K6) plateauLo→ plateauTop linear
/// summit spike [K6, sMax) plateauTop → PeakCap 0.05u + 0.95·u⁴
/// tail [sMax, ∞) PeakCap + (hsMax)·TailSlope linear
///
/// The riser and spike blends are the reference's "corner fixes" and are FROZEN: the 0.1 riser
/// floor makes climbs decelerate into shelves and accelerate out of them (no machined edges), and
/// the 0.05 spike floor lets the summit leave the plateau gently (no hard skirt under the peaks).
///
/// ═══ ⚠⚠ THE LOAD-BEARING LINE ═══
///
/// if (h &lt;= a.Sea) return h;
///
/// THE CURVE IS IDENTITY AT AND BELOW SEA. Everything downstream rests on it: the waterline
/// cannot move, the Trench's ocean-border guarantee survives, and — when water lands — the
/// classify/render split agrees everywhere outside the crater, because a monotonic curve that
/// fixes sea means <c>Apply(raw) &lt; sea</c> exactly when <c>raw &lt; sea</c>. Delete this line
/// and the whole separability argument goes with it.
///
/// ═══ ⚠ SEED-DEPENDENT BY CONSTRUCTION ═══
///
/// The summit spike maps <c>[K6, hMaxSeed]</c> onto the peak band, so the curve cannot be
/// evaluated until pass 1 has scanned every pixel. That is why the pass-1/pass-2 boundary is a
/// hard one and not an interleave. → <see cref="EffectiveSpikeMax"/>,
/// <c>Tools/Pass1Result.HMaxSeed</c>.
///
/// ═══ PORT NOTES ═══
///
/// • Engine-free: the reference used <c>Godot.Mathf</c> only for arithmetic, so this lives in
/// <c>Core/</c> as a named C++-candidate seam (D-049, D-060). <see cref="Lerp"/> reproduces
/// <c>Mathf.Lerp</c>'s exact expression, so the port is bit-faithful and not merely equivalent.
/// • Anchors are a parameter object (<see cref="CurveAnchors"/>) rather than consts, so the
/// storm-ladder values are A/B-able from config. <c>CurveAnchors.Default</c> reproduces the
/// reference's constants exactly.
/// • The reference's two-preset machinery (COMPACT vs BALANCED) is NOT carried: COMPACT was
/// retired by the task-09 verdict and exists only in that task's report. One knot set, named.
/// • <see cref="AssertMonotonic"/> returns its confirmation line instead of printing it — Core
/// has no <c>GD.Print</c>. The caller logs it.
///
/// ⚠⚠ THE SHAPE IS FROZEN AT v5. This port adds no band, no anchor and no slope. Reshaping is a
/// later, gated task; if you are here to steepen something, you are in the wrong file.
/// </summary>
public static class HeightCurve
{
/// <summary>Curve body version — the identity of the segment layout, not of the knots.</summary>
public const ushort Version = 5;
/// <summary>
/// <c>Mathf.Lerp</c>, reproduced as the reference's engine wrote it:
/// <c>from + (to - from) * weight</c>. ⚠ Written out rather than "simplified" because a
/// different association of the same algebra is a different float32 result, and this port's
/// fidelity claim is bit-level.
/// </summary>
private static float Lerp(float from, float to, float weight) => from + (to - from) * weight;
/// <summary>
/// The per-seed summit ceiling: the raw height the spike band's top maps to <c>PeakCap</c>.
///
/// <c>Max(hMaxSeed, K6 + SpikeMinSpan)</c> — the floor guarantees a non-degenerate band on a
/// seed whose map-wide maximum lands at or below K6, which would otherwise divide by zero.
/// </summary>
public static float EffectiveSpikeMax(float hMaxSeed, CurveKnots k, CurveAnchors a)
=> MathF.Max(hMaxSeed, k.K6 + a.SpikeMinSpan);
/// <summary>
/// Shelf band width from the per-column strength field.
///
/// ⚠ THE LERP IS INVERTED, AND THAT IS THE REFERENCE'S INTENT: higher "strength" means a
/// MORE PRONOUNCED shelf, which means a NARROWER input band mapped across the same output
/// span — i.e. flatter ground. <c>strength 0 → SpanMax</c>, <c>strength 1 → SpanMin</c>.
/// </summary>
public static float ShelfSpan(float strength01, CurveAnchors a)
=> Lerp(a.ShelfSpanMax, a.ShelfSpanMin, Math.Clamp(strength01, 0f, 1f));
/// <summary>
/// The curve, for ONE column.
/// </summary>
/// <param name="h">Raw pre-curve height.</param>
/// <param name="hMaxSeed">The map-wide raw maximum for this seed. → <c>Pass1Result.HMaxSeed</c>.</param>
/// <param name="benchLo">This column's bench floor (base ± the anchor field).</param>
/// <param name="benchSpan">This column's bench output span.</param>
/// <param name="plateauLo">This column's plateau floor.</param>
/// <param name="plateauSpan">This column's plateau output span.</param>
/// <param name="k">The input knot set.</param>
/// <param name="a">The output anchors.</param>
/// <param name="edgeShift">
/// The shelf-edge warp (<c>TerrainDetailPass</c> pass B): slides the K3/K4/K5 BLOCK for this
/// column. K1/K2/K6 never move, which is what keeps the red-ceiling floor and the peak cap
/// EXACT under the warp rather than statistical. Zero when detail is off.
/// </param>
public static float Apply(float h, float hMaxSeed,
float benchLo, float benchSpan, float plateauLo, float plateauSpan,
CurveKnots k, CurveAnchors a, float edgeShift)
{
// ⭐ IDENTITY AT AND BELOW SEA. See the type header — this line is the invariant.
if (h <= a.Sea) return h;
// The knot BLOCK slides rigidly: bench and mid-riser keep their exact widths (their
// interiors are translated, not distorted); only the foothill riser and the plateau
// stretch or compress to absorb the shift.
float k3 = k.K3 + edgeShift, k4 = k.K4 + edgeShift, k5 = k.K5 + edgeShift;
float u, s;
if (h < k.K1)
{
u = (h - a.Sea) / (k.K1 - a.Sea);
s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe
return a.Sea + s * (a.OrangeCeil - a.Sea);
}
if (h < k.K2)
{
u = (h - k.K1) / (k.K2 - k.K1);
return a.OrangeCeil + u * (a.RedCeil - a.OrangeCeil); // frozen linear rise
}
if (h < k3)
{
u = (h - k.K2) / (k3 - k.K2);
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1
return a.RedCeil + s * (benchLo - a.RedCeil);
}
if (h < k4)
{
u = (h - k3) / (k4 - k3);
return benchLo + u * benchSpan; // bench — corner fix 3 floors the span
}
float benchTop = benchLo + benchSpan;
if (h < k5)
{
u = (h - k4) / (k5 - k4);
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
return benchTop + s * (plateauLo - benchTop);
}
if (h < k.K6)
{
u = (h - k5) / (k.K6 - k5);
return plateauLo + u * plateauSpan; // plateau
}
float plateauTop = plateauLo + plateauSpan;
float spikeMax = EffectiveSpikeMax(hMaxSeed, k, a);
if (h < spikeMax)
{
u = (h - k.K6) / (spikeMax - k.K6);
s = 0.05f * u + 0.95f * (u * u * u * u); // summit spike — corner fix 2
return plateauTop + s * (a.PeakCap - plateauTop);
}
return a.PeakCap + (h - spikeMax) * a.TailSlope; // gentle tail, not a clip
}
/// <summary>
/// Per-generation numeric strict-monotonicity proof of the EFFECTIVE curve — run once per
/// seed, between the two passes, before any pixel is curved.
///
/// ═══ WHY A NUMERIC SWEEP AND NOT AN ARGUMENT ═══
///
/// Monotonicity is structural in the algebra, but the curve as EVALUATED depends on three
/// per-column fields and a per-column warp, and the corner fixes lowered the slope floors
/// (risers 0.1, spike base 0.05) while the warp squeezes the foothill riser and the plateau.
/// "It should be fine" is not the standard: the sweep proves every slope stays strictly
/// positive at the extremes of BOTH, on this seed's actual <c>spikeMax</c>.
///
/// 24 corners: 2 bench extremes × 2 plateau extremes × 2 span extremes × 3 edge shifts
/// (max, 0, +max).
///
/// ⚠ THROWS AND REFUSES on violation, rather than warning. A non-monotonic curve inverts
/// terrain — a peak becomes a pit — and that is not something to discover in a render.
/// </summary>
/// <returns>A one-line confirmation for the run log. Core cannot print; the caller does.</returns>
public static string AssertMonotonic(float hMaxSeed, CurveKnots k, CurveAnchors a, float maxEdgeShift)
{
if (!k.IsStrictlyOrdered)
throw new InvalidOperationException(
$"[HeightCurve] KNOT ORDER VIOLATION: {k} is not strictly ascending. Refusing to generate.");
if (maxEdgeShift < 0f || k.K2 + maxEdgeShift >= k.K3 || k.K5 + maxEdgeShift >= k.K6)
throw new InvalidOperationException(
$"[HeightCurve] EDGE-SHIFT BOUND VIOLATION: maxEdgeShift={maxEdgeShift} does not keep " +
$"K2 < K3±d and K5±d < K6 (preset {k.Name}). Refusing to generate.");
float[] benchLos = { a.BenchBase - a.BenchAmp, a.BenchBase + a.BenchAmp };
float[] plateauLos = { a.PlateauBase - a.PlateauAmp, a.PlateauBase + a.PlateauAmp };
float[] spans = { a.ShelfSpanMin, a.ShelfSpanMax };
float[] edgeShifts = maxEdgeShift > 0f
? new[] { -maxEdgeShift, 0f, maxEdgeShift }
: new[] { 0f };
foreach (float bl in benchLos)
foreach (float pl in plateauLos)
foreach (float sp in spans)
foreach (float es in edgeShifts)
{
float prevH = -7f;
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, a, es);
void Check(double hd)
{
float h = (float)hd;
// Dedupe float32 samples: a fine double-precision step can land on the same
// float twice, and "not greater" is not a violation when it is the same input.
if (h <= prevH) return;
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k, a, es);
if (v <= prev)
throw new InvalidOperationException(
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, " +
$"hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}, edgeShift={es}): " +
$"{v} <= {prev}. Refusing to generate.");
prev = v;
prevH = h;
}
double top = Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k, a) + 0.5);
for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh); // the below-sea identity run
for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh); // every band, finely
for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh); // the tail
}
return $"[HeightCurve] Monotonicity assertion passed (v{Version} preset '{k.Name}', " +
$"8 modulation corners × edge shifts ±{maxEdgeShift:F6}, " +
$"effective spikeMax {EffectiveSpikeMax(hMaxSeed, k, a):F6}).";
}
}
}