islaApocalypse/Tools/Scripts/HeightCurve.cs
beezm ac67c27952 feat: curve v2 — per-seed spike normalization, u4 wall, 420 m ceiling (terrain-water task 06)
Per the task-05 hillshade-gate verdict: lowlands frozen (identity/toe/
rise/shoulder/plateau byte-unchanged); the spike now maps [t4,
hMaxSeed] — each seed's own raw pre-curve maximum, computed in a new
pass-1 over GenerateTopography — onto the peak band, so every island
reaches the ceiling (v1's pooled-max domain left mid-range seeds at
110–175 m). Spike stiffened to 0.1u + 0.9u^4; peak cap raised to
420 m above sea (1.82869). Degenerate near-flat guard: spike domain
floored at t4 + 0.01. The curve is now SEED-DEPENDENT: hMaxSeed is a
pure parameter (D-035), recorded in TCRV (field renamed
HMaxCal -> SpikeMax, same byte layout; v1 semantics = pooled max, v2 =
per-seed), and the monotonicity assertion runs per generation against
the effective curve. TerrainCurve gate: "off"|"v2" (default v2);
"v1" retired with a loud config error (task-05 blueprints are
regenerable). Classify path untouched — the biome/water oracle must
hold unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 00:21:25 -04:00

135 lines
5.9 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 Godot;
/// <summary>
/// The height-redistribution curve, v2 (terrain-water task 06) — a pure, static,
/// monotonic piecewise map over raw blueprint heights (D-035: numbers in, numbers
/// out; the per-seed spike maximum is an explicit PARAMETER, not hidden state).
///
/// v2 changes (developer's task-05 hillshade-gate verdict; everything below the
/// plateau step is behaviorally byte-identical to v1):
/// - PER-SEED SPIKE NORMALIZATION: the spike's input domain runs from t4 to the
/// current seed's own raw pre-curve maximum (hMaxSeed), so every island's
/// tallest pixel reaches the ceiling — v1 mapped against the pooled
/// calibration max and mid-range seeds topped out at 110175 m.
/// - STIFFER SPIKE: ease-in 0.1u + 0.9·u⁴ (was 0.2u + 0.8·u³) — a wall, not a ramp.
/// - PEAK CEILING 420 m above sea (was 220 m).
///
/// Lower knots/bands are v1's, calibrated 2026-08-07 from batch 04's ten flat-sea
/// heightmaps (pooled above-sea land CDF, 340,618,126 samples): P75/P90/P93/P96.
///
/// Shape (strictly monotonic; every segment's normalized slope bounded below by a
/// positive constant; asserted numerically per generation against the EFFECTIVE
/// per-seed curve once hMaxSeed is known):
/// h ≤ sea (0.15) identity — water and the below-sea world untouched
/// sea → t1 smooth toe, ease-out blend (gentle rolling, never flat)
/// t1 → t2 linear rise into the red band
/// t2 → t3 smooth shoulder up to the plateau shelf
/// t3 → t4 near-flat plateau step (small positive slope)
/// t4 → spikeMax accelerating u⁴ spike to the 420 m peak cap (per-seed domain)
/// h > spikeMax linear tail (strict monotonicity, no clamp; reachable only
/// in the degenerate near-flat guard case)
/// </summary>
public static class HeightCurve
{
public const ushort VERSION = 2;
// Input knots — v1 calibration, unchanged (see class header).
public const float T1 = 0.628736f; // P75 — orange coverage boundary
public const float T2 = 0.819152f; // P90
public const float T3 = 0.879340f; // P93
public const float T4 = 0.962922f; // P96
// Output bands — the storm ladder. Lower anchors unchanged from v1.
public const float SEA = 0.15f;
public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling
public const float RED_CEIL = 0.27f; // biblical ceiling
public const float PLATEAU_LO = SEA + 50f / 251f; // ≈ 0.34924 (50 m above sea)
public const float PLATEAU_HI = PLATEAU_LO + 0.02f; // ≈ 0.36924 (~5 m step relief)
public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 (420 m above sea; v1: 220 m)
public const float TAIL_SLOPE = 0.25f; // above spikeMax (degenerate guard only)
// Degenerate/near-flat guard: the spike domain is [T4, max(hMaxSeed, T4 + SPIKE_MIN_SPAN)],
// so a pathological seed whose raw max sits at or below t4 still yields a positive,
// monotonic domain (its cap is then simply never reached; heights above spikeMax — none in
// practice — would ride the tail).
public const float SPIKE_MIN_SPAN = 0.01f;
/// <summary>The effective spike-domain top for a seed's raw maximum, guard applied.</summary>
public static float EffectiveSpikeMax(float hMaxSeed)
{
return Mathf.Max(hMaxSeed, T4 + SPIKE_MIN_SPAN);
}
/// <param name="h">Raw pre-curve height.</param>
/// <param name="hMaxSeed">The seed's raw pre-curve maximum (post noise/falloff/Trench/spine,
/// pre-carve) — the same field the curve consumes. Makes the map seed-dependent (v2).</param>
public static float Apply(float h, float hMaxSeed)
{
if (h <= SEA) return h;
float u, s;
if (h < T1)
{
u = (h - SEA) / (T1 - SEA);
s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out, slope ≥ 0.3
return SEA + s * (ORANGE_CEIL - SEA);
}
if (h < T2)
{
u = (h - T1) / (T2 - T1);
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear
}
if (h < T3)
{
u = (h - T2) / (T3 - T2);
s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // smoothstep blend, slope ≥ 0.2
return RED_CEIL + s * (PLATEAU_LO - RED_CEIL);
}
if (h < T4)
{
u = (h - T3) / (T4 - T3);
return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope
}
float spikeMax = EffectiveSpikeMax(hMaxSeed);
if (h < spikeMax)
{
u = (h - T4) / (spikeMax - T4);
s = 0.1f * u + 0.9f * (u * u * u * u); // ease-in u⁴ wall, slope ≥ 0.1
return PLATEAU_HI + s * (PEAK_CAP - PLATEAU_HI);
}
return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE;
}
/// <summary>
/// Numeric strict-monotonicity check of the EFFECTIVE per-seed curve — call once
/// per generation after hMaxSeed is known, before the curve pass. A violation is
/// a build bug, not a data condition — fail loudly and refuse to generate.
/// </summary>
public static void AssertMonotonic(float hMaxSeed)
{
float prevH = -7f;
float prev = Apply(prevH, hMaxSeed);
// Successive double samples can round to the SAME float32 — only strictly
// increasing float samples are compared (task-05 incident fix, kept).
void Check(double hd)
{
float h = (float)hd;
if (h <= prevH) return;
float v = Apply(h, hMaxSeed);
if (v <= prev)
throw new System.InvalidOperationException(
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}): {v} <= {prev}. Refusing to generate.");
prev = v;
prevH = h;
}
// Coarse below the identity region, fine through every knot, out past the
// per-seed spike top and the tail.
double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5);
for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) Check(h);
for (double h = 0.10; h <= top; h += 0.0001) Check(h);
for (double h = top + 0.05; h <= top + 6.0; h += 0.05) Check(h);
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6}).");
}
}