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
249 lines
12 KiB
C#
249 lines
12 KiB
C#
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}).";
|
||
}
|
||
}
|
||
}
|