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>
135 lines
5.9 KiB
C#
135 lines
5.9 KiB
C#
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 110–175 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}).");
|
||
}
|
||
}
|