diff --git a/Core/Scripts/ClimbCalibration.cs b/Core/Scripts/ClimbCalibration.cs
new file mode 100644
index 0000000..05272d8
--- /dev/null
+++ b/Core/Scripts/ClimbCalibration.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Text;
+
+namespace IslaApocalypse.Core
+{
+ ///
+ /// ⭐⭐ THE CLIMB'S CONTROL POINTS, MEASURED FROM THE STAIRCASE (chat2/03) — "the staircase's
+ /// mountain with the terraces melted out".
+ ///
+ /// ═══ THE MISTAKE THIS TYPE CORRECTS ═══
+ ///
+ /// chat2/02 built the climb from ANALYTIC control points (a feather and a drama knob) and got a
+ /// bottom-heavy curve: land above 100 m fell from ~15 % to ~4.8 %. That report concluded the loss
+ /// was STRUCTURAL — that a no-magnet monotone curve must preserve the raw distribution's
+ /// bottom-heavy shape, so only a Phase-1 noise change could restore the mountain.
+ ///
+ /// > ### ⚠ THAT CONCLUSION WAS WRONG, AND THIS TYPE IS THE PROOF.
+ /// >
+ /// > A monotone curve is a free reparametrization: it may be gentle in one place and steep in
+ /// > another, and can LIFT bottom-heavy input into a substantial mid-massif without ever going
+ /// > flat. **No-flats and lift-the-mass are compatible.** The area of land above a given height
+ /// > is set by where the percentile→height mapping CROSSES that height, and that mapping is
+ /// > entirely ours to choose.
+ /// >
+ /// > The 02 sweep that "proved" the loss structural varied climbFeather, which shapes the
+ /// > JOIN, not the mass distribution. It was the wrong knob, and the conclusion generalized from
+ /// > it was too strong.
+ ///
+ /// ═══ WHAT THE STAIRCASE'S BENCHES ACTUALLY DID ═══
+ ///
+ /// They did not CREATE highland. They LIFTED land to 100 m and 220 m. The same ~27 % of land
+ /// above the ceiling exists in both curves; 02's analytic climb simply placed it low. So the fix
+ /// is not to make more high land — it is to put the land that is already there back where the
+ /// staircase had it, as a smooth slope.
+ ///
+ /// ═══ THE METHOD — the same percentile idea as task 01's knots, one level up ═══
+ ///
+ /// Task 01 measured percentiles of the raw distribution to place the curve's INPUT knots. This
+ /// measures percentiles of the staircase's ABOVE-CEILING land to place the climb's OUTPUT
+ /// heights:
+ ///
+ /// for each p in {10, 30, 50, 70, 85, 95}:
+ /// u_p = normalized RAW position of above-ceiling land at percentile p
+ /// v_p = normalized OUTPUT height of above-ceiling land at percentile p (staircase)
+ ///
+ /// PCHIP through (0,0), (u_p, v_p)…, (1,1) reproduces the staircase's elevation envelope —
+ /// the same land ends up at the same heights, so the mountain mass returns — while the flat bench
+ /// and plateau INTERIORS become smooth grade.
+ ///
+ /// ═══ ⚠ WHERE THE STAIRCASE WAS FLAT, WE MUST DEVIATE — AND THAT IS THE POINT ═══
+ ///
+ /// A bench maps a wide input band onto a narrow output band, so two adjacent percentiles land at
+ /// nearly the same height and their secant is near zero. Reproducing THAT would rebuild the
+ /// bench. floors every segment's grade and renormalizes, so the
+ /// curve passes THROUGH the bench height with slope instead of running ALONG it. The floor bites
+ /// only where the staircase was flat; everywhere else the calibration is reproduced.
+ ///
+ public sealed class ClimbCalibration
+ {
+ ///
+ /// The above-ceiling land percentiles sampled. Six is a handful — enough to carry the
+ /// staircase's envelope, few enough that PCHIP interpolates smoothly between them rather
+ /// than tracing every wobble of the bench.
+ ///
+ public static readonly double[] DefaultPercentiles = { 10.0, 30.0, 50.0, 70.0, 85.0, 95.0 };
+
+ ///
+ /// The no-bench floor: no segment's grade may fall below this fraction of the climb's average
+ /// grade (1.0 = average). 0.35 is comfortably above 's own
+ /// near-flat tripwire and well below the grades the calibration produces outside the benches,
+ /// so it is a repair for the flats and a no-op everywhere else.
+ ///
+ public const float MinNormalizedSecant = 0.35f;
+
+ /// Iterations of floor-then-renormalize. It converges in a few; 24 is free insurance.
+ private const int RepairIterations = 24;
+
+ /// Normalized control points, strictly increasing in both. Includes (0,0) and (1,1).
+ public readonly float[] U, V;
+
+ /// The percentiles sampled, and the raw/output heights measured at each — for the report.
+ public readonly double[] Percentiles;
+ public readonly float[] RawAt, TargetHeightAt;
+
+ /// The knobs this calibration was shaped with.
+ public readonly float MountainLift, PeakSharpness;
+
+ ///
+ /// Normalized u of the summit onset — the LAST measured percentile. Above it,
+ /// reshapes; below it, nothing does. That is the decoupling.
+ ///
+ public readonly float SummitOnsetU;
+
+ /// How many segments the no-bench floor had to lift. Zero means the staircase had no flats.
+ public readonly int SegmentsFloored;
+
+ private ClimbCalibration(float[] u, float[] v, double[] pcts, float[] rawAt, float[] targetAt,
+ float lift, float sharp, float onsetU, int floored)
+ {
+ U = u; V = v; Percentiles = pcts; RawAt = rawAt; TargetHeightAt = targetAt;
+ MountainLift = lift; PeakSharpness = sharp; SummitOnsetU = onsetU; SegmentsFloored = floored;
+ }
+
+ ///
+ /// Build the calibration from measured quantiles.
+ ///
+ /// ⚠ Takes plain arrays, not a histogram: LandHistogram lives in Tools/ and Core
+ /// depends on nothing above it. The caller measures; this shapes.
+ ///
+ /// The percentiles sampled, ascending.
+ /// Above-ceiling RAW height at each percentile.
+ /// Above-ceiling STAIRCASE OUTPUT height at each percentile.
+ ///
+ /// 1.0 = reproduce the staircase's mountain. >1 lifts the mid-massif higher; <1 lowers it
+ /// toward chat2/02's bottom-heavy default. Applied as v ← v^(1/lift), which is monotone
+ /// and fixes both endpoints, so it can move the massif without touching sea level or the cap.
+ ///
+ ///
+ /// ⭐ ACTS ONLY ABOVE THE LAST MEASURED PERCENTILE. 1.0 = a straight run to the cap; higher
+ /// defers the rise so the final approach steepens and the peak reads pointy.
+ /// ⚠ Unlike chat2/02's summitDrama, it CANNOT lower the massif — the onset's height is
+ /// fixed by the calibration before this is applied. That is the §3 fix.
+ ///
+ public static ClimbCalibration FromPercentiles(
+ double[] percentiles, float[] rawQuantiles, float[] outQuantiles,
+ float ceilingRaw, float spikeMax, float ceilingOut, float peakCap,
+ float mountainLift, float peakSharpness)
+ {
+ int n = percentiles.Length;
+ if (rawQuantiles.Length != n || outQuantiles.Length != n)
+ throw new ArgumentException("[ClimbCalibration] percentile/raw/output arrays must be the same length.");
+ if (mountainLift <= 0f)
+ throw new ArgumentOutOfRangeException(nameof(mountainLift), mountainLift, "mountainLift must be positive.");
+ if (peakSharpness < 1f)
+ throw new ArgumentOutOfRangeException(nameof(peakSharpness), peakSharpness,
+ "peakSharpness < 1 would make the summit's final approach SHALLOWER than its own average — a ramp, not a peak.");
+
+ float spanRaw = spikeMax - ceilingRaw;
+ float spanOut = peakCap - ceilingOut;
+ if (spanRaw <= 0f || spanOut <= 0f)
+ throw new InvalidOperationException("[ClimbCalibration] the climb has no room — ceiling meets the summit.");
+
+ // ---- normalize the measured points, plus the two exact endpoints ----
+ var u = new float[n + 2];
+ var v = new float[n + 2];
+ u[0] = 0f; v[0] = 0f;
+ u[n + 1] = 1f; v[n + 1] = 1f;
+
+ for (int i = 0; i < n; i++)
+ {
+ u[i + 1] = Math.Clamp((rawQuantiles[i] - ceilingRaw) / spanRaw, 0f, 1f);
+ v[i + 1] = Math.Clamp((outQuantiles[i] - ceilingOut) / spanOut, 0f, 1f);
+ }
+
+ // ⚠ u must be STRICTLY increasing for PCHIP. Percentiles of a continuous distribution
+ // give that naturally; a degenerate seed (a plateau in the raw CDF) could not. Nudge
+ // rather than throw — a hair of u-spacing is not a shape decision.
+ const float minDu = 1e-4f;
+ for (int i = 1; i < u.Length; i++)
+ if (u[i] <= u[i - 1] + minDu) u[i] = u[i - 1] + minDu;
+ // Renormalize back onto [0,1] if the nudging pushed past the end.
+ if (u[u.Length - 1] > 1f)
+ {
+ float s = 1f / u[u.Length - 1];
+ for (int i = 1; i < u.Length; i++) u[i] *= s;
+ u[u.Length - 1] = 1f;
+ }
+
+ // ---- mountainLift: v ← v^(1/lift). Monotone, endpoints fixed. ----
+ if (Math.Abs(mountainLift - 1f) > 1e-6f)
+ {
+ float e = 1f / mountainLift;
+ for (int i = 1; i <= n; i++) v[i] = MathF.Pow(v[i], e);
+ }
+
+ // ---- the no-bench repair: floor every grade, renormalize to keep v(1) = 1 ----
+ float onsetU = u[n]; // the last measured percentile
+ int floored = RepairSecants(u, v, out _);
+
+ // ---- peakSharpness: reshape ONLY the segment above the onset ----
+ // Insert a midpoint whose height defers the rise, so the final approach steepens.
+ // v_mid = v_onset + (1 - v_onset) * 0.5^sharpness ⇒ sharpness 1 is exactly linear.
+ if (peakSharpness > 1f + 1e-6f)
+ {
+ float uS = u[n], vS = v[n];
+ float uMid = (uS + 1f) * 0.5f;
+ float vMid = vS + (1f - vS) * MathF.Pow(0.5f, peakSharpness);
+
+ var u2 = new float[u.Length + 1];
+ var v2 = new float[v.Length + 1];
+ Array.Copy(u, u2, n + 1); Array.Copy(v, v2, n + 1);
+ u2[n + 1] = uMid; v2[n + 1] = vMid;
+ u2[n + 2] = 1f; v2[n + 2] = 1f;
+ u = u2; v = v2;
+
+ // ⚠ The deferred first half must still not be a bench. Re-floor ONLY that segment,
+ // leaving the calibrated massif below the onset untouched — re-running the global
+ // repair here would renormalize the massif and undo the decoupling.
+ float du = uMid - uS;
+ float minDv = MinNormalizedSecant * du;
+ if (vMid - vS < minDv) v[n + 1] = vS + minDv;
+ }
+
+ var rawAt = (float[])rawQuantiles.Clone();
+ var outAt = (float[])outQuantiles.Clone();
+
+ var cal = new ClimbCalibration(u, v, (double[])percentiles.Clone(), rawAt, outAt,
+ mountainLift, peakSharpness, onsetU, floored);
+
+ cal.AssertUsable();
+ return cal;
+ }
+
+ ///
+ /// Floor every segment's normalized grade at and renormalize
+ /// so the last point still lands exactly on 1. Iterated, because renormalizing can push a
+ /// floored segment back under the floor; it converges as long as the un-floored segments have
+ /// room to absorb the excess.
+ ///
+ private static int RepairSecants(float[] u, float[] v, out float minSecant)
+ {
+ int m = u.Length;
+ var s = new float[m - 1];
+ var du = new float[m - 1];
+ for (int i = 0; i < m - 1; i++)
+ {
+ du[i] = u[i + 1] - u[i];
+ s[i] = (v[i + 1] - v[i]) / du[i];
+ }
+
+ int flooredCount = 0;
+ for (int it = 0; it < RepairIterations; it++)
+ {
+ int hit = 0;
+ for (int i = 0; i < s.Length; i++)
+ if (s[i] < MinNormalizedSecant) { s[i] = MinNormalizedSecant; hit++; }
+ flooredCount = hit;
+
+ float total = 0f;
+ for (int i = 0; i < s.Length; i++) total += s[i] * du[i];
+ if (Math.Abs(total - 1f) < 1e-6f) break;
+ for (int i = 0; i < s.Length; i++) s[i] /= total;
+ }
+
+ // Rebuild v from the repaired grades.
+ minSecant = float.MaxValue;
+ v[0] = 0f;
+ for (int i = 0; i < s.Length; i++)
+ {
+ if (s[i] < minSecant) minSecant = s[i];
+ v[i + 1] = v[i] + s[i] * du[i];
+ }
+ v[m - 1] = 1f; // exact, against accumulated float drift
+ return flooredCount;
+ }
+
+ ///
+ /// The invariants a calibration must satisfy before it is allowed to shape terrain. Throws
+ /// and refuses, rather than producing a curve nobody checked.
+ ///
+ private void AssertUsable()
+ {
+ for (int i = 1; i < U.Length; i++)
+ {
+ if (U[i] <= U[i - 1])
+ throw new InvalidOperationException(
+ $"[ClimbCalibration] control point {i} is not strictly right of its predecessor " +
+ $"(u {U[i - 1]} → {U[i]}). Refusing to generate.");
+ if (V[i] <= V[i - 1])
+ throw new InvalidOperationException(
+ $"[ClimbCalibration] control point {i} does not RISE (v {V[i - 1]} → {V[i]}) — that is a " +
+ $"bench, which is the artifact this mode exists to remove. Refusing to generate.");
+ }
+ if (Math.Abs(U[0]) > 1e-6f || Math.Abs(V[0]) > 1e-6f
+ || Math.Abs(U[U.Length - 1] - 1f) > 1e-6f || Math.Abs(V[V.Length - 1] - 1f) > 1e-6f)
+ throw new InvalidOperationException(
+ "[ClimbCalibration] the endpoints must be exactly (0,0) and (1,1) — the lowland handover and " +
+ "the peak cap are not negotiable. Refusing to generate.");
+ }
+
+ /// The calibration as one line for the INDEX, the log and the report.
+ public string Describe()
+ {
+ var sb = new StringBuilder();
+ sb.Append($"lift {MountainLift:F2} sharp {PeakSharpness:F2} onsetU {SummitOnsetU:F3} " +
+ $"floored {SegmentsFloored} · uv ");
+ for (int i = 0; i < U.Length; i++) sb.Append($"({U[i]:F3},{V[i]:F3}) ");
+ return sb.ToString().TrimEnd();
+ }
+
+ /// The measured percentile table, for the report.
+ public string DescribeMeasured(float ceilingOut, float peakCap)
+ {
+ var sb = new StringBuilder();
+ for (int i = 0; i < Percentiles.Length; i++)
+ sb.Append($"P{Percentiles[i]:F0}→{WorldScale.MetresFromRaw(TargetHeightAt[i] - 0.15f):F0}m ");
+ return sb.ToString().TrimEnd();
+ }
+ }
+}
diff --git a/Core/Scripts/ClimbCalibration.cs.uid b/Core/Scripts/ClimbCalibration.cs.uid
new file mode 100644
index 0000000..fbd968d
--- /dev/null
+++ b/Core/Scripts/ClimbCalibration.cs.uid
@@ -0,0 +1 @@
+uid://cl0hijnaw76jq
diff --git a/Core/Scripts/ContinuousCurve.cs b/Core/Scripts/ContinuousCurve.cs
index dc4df02..dc016e1 100644
--- a/Core/Scripts/ContinuousCurve.cs
+++ b/Core/Scripts/ContinuousCurve.cs
@@ -109,18 +109,106 @@ namespace IslaApocalypse.Core
/// The knob values this spline was built from, for the INDEX and the report.
public readonly float LowlandCeilingM, ClimbFeather, SummitDrama;
+ ///
+ /// ⭐ The measured calibration this climb was shaped from (chat2/03), or null when the climb
+ /// came from chat2/02's ANALYTIC feather/drama points.
+ ///
+ /// Non-null is the current default: "the staircase's mountain with the terraces melted out".
+ /// Null survives so the 02 curve stays reproducible as a contrast variant — it is the "before"
+ /// in the three-way histogram story, not a fallback.
+ ///
+ public readonly ClimbCalibration Calibration;
+
+ /// Where the summit begins, normalized — the calibration's onset when calibrated, else the constant.
+ public float EffectiveSummitOnset => Calibration?.SummitOnsetU ?? SummitOnset;
+
// Control points (raw x, out y) and the Fritsch–Carlson tangents. x strictly increasing.
private readonly float[] _x, _y, _m;
private ContinuousCurve(CurveKnots k, CurveAnchors a, float ceilingRaw, float ceilingOut,
float spikeMax, float joinSlopeRaw, float lowlandCeilingM, float climbFeather,
- float summitDrama, float[] x, float[] y, float[] m)
+ float summitDrama, float[] x, float[] y, float[] m, ClimbCalibration calibration = null)
{
Knots = k; Anchors = a;
CeilingRaw = ceilingRaw; CeilingOut = ceilingOut; SpikeMax = spikeMax;
JoinSlopeRaw = joinSlopeRaw;
LowlandCeilingM = lowlandCeilingM; ClimbFeather = climbFeather; SummitDrama = summitDrama;
- _x = x; _y = y; _m = m;
+ _x = x; _y = y; _m = m; Calibration = calibration;
+ }
+
+ ///
+ /// ⭐⭐ THE CALIBRATED CLIMB (chat2/03) — control points MEASURED from the staircase's
+ /// above-ceiling elevation distribution rather than invented from two shape knobs.
+ /// → for the method and for the chat2/02 mistake it corrects.
+ ///
+ /// Everything outside the climb is identical to : the same lowland
+ /// handover pinned to the exact anchors, the same C¹ join to the red band's exit slope, the
+ /// same per-seed , the same tail. Only the interior shape changes.
+ ///
+ /// ⚠ THIS PATH DOES NOT REQUIRE STRICTLY-INCREASING SECANTS, and that is deliberate. The 02
+ /// analytic path enforced a convex control polygon as its no-magnet rule. A curve calibrated
+ /// to real terrain is WAVY — gentler where the staircase had a bench, steeper through its
+ /// risers — so convexity is the wrong invariant here. The no-magnet guarantee instead comes
+ /// from , which floors every grade: the
+ /// curve may slow down, but never to a bench.
+ ///
+ public static ContinuousCurve BuildCalibrated(CurveKnots k, CurveAnchors a, float spikeMax,
+ float lowlandCeilingM, ClimbCalibration calibration)
+ {
+ if (calibration == null) throw new ArgumentNullException(nameof(calibration));
+
+ var (ceilingRaw, ceilingOut, redSlope) = ResolveHandover(k, a, lowlandCeilingM);
+
+ if (ceilingRaw >= spikeMax - 1e-3f)
+ throw new InvalidOperationException(
+ $"[ContinuousCurve] lowland ceiling (raw {ceilingRaw:F4}) reaches this seed's summit " +
+ $"(spikeMax {spikeMax:F4}) — no room for a climb. Refusing.");
+
+ float spanRaw = spikeMax - ceilingRaw;
+ float spanOut = a.PeakCap - ceilingOut;
+
+ int n = calibration.U.Length;
+ var x = new float[n];
+ var y = new float[n];
+ for (int i = 0; i < n; i++)
+ {
+ x[i] = ceilingRaw + calibration.U[i] * spanRaw;
+ y[i] = ceilingOut + calibration.V[i] * spanOut;
+ }
+
+ float[] m = FritschCarlsonTangents(x, y, startTangent: redSlope);
+
+ return new ContinuousCurve(k, a, ceilingRaw, ceilingOut, spikeMax, redSlope,
+ lowlandCeilingM, climbFeather: float.NaN, summitDrama: float.NaN, x, y, m, calibration);
+ }
+
+ ///
+ /// Where the preserved lowland hands over to the climb, and the red band's exit slope.
+ ///
+ /// ⚠ THE FLOOD LINE IS PINNED TO THE EXACT ANCHORS, and "30 m" is NOMINAL: RED_CEIL − SEA is
+ /// 0.12 raw = 30.12 m. Any requested ceiling at or below the red ceiling hands over at
+ /// EXACTLY (K2, RED_CEIL) — no derived floats — so the linear extension is empty by
+ /// construction and the preserved toe+red band can never be cut by a rounding. (chat2/02's
+ /// first run refused its own default over that 0.12 m gap; pinning is the fix, not a wider
+ /// tolerance.)
+ ///
+ private static (float ceilingRaw, float ceilingOut, float redSlope) ResolveHandover(
+ CurveKnots k, CurveAnchors a, float lowlandCeilingM)
+ {
+ float redSlope = (a.RedCeil - a.OrangeCeil) / (k.K2 - k.K1);
+
+ if (lowlandCeilingM > MaxLowlandCeilingM)
+ throw new InvalidOperationException(
+ $"[ContinuousCurve] lowlandCeiling {lowlandCeilingM:F1} m is above the {MaxLowlandCeilingM:F0} m " +
+ "bound — close enough to the old bench (100±12 m) to preserve a flat one, which is the " +
+ "artifact this mode exists to remove. Refusing.");
+
+ float redCeilM = WorldScale.MetresFromRaw(a.RedCeil - a.Sea);
+ if (lowlandCeilingM <= redCeilM + 0.01f)
+ return (k.K2, a.RedCeil, redSlope);
+
+ float ceilingOut = a.Sea + WorldScale.RawFromMetres(lowlandCeilingM);
+ return (k.K2 + (ceilingOut - a.RedCeil) / redSlope, ceilingOut, redSlope);
}
///
@@ -134,35 +222,8 @@ namespace IslaApocalypse.Core
public static ContinuousCurve Build(CurveKnots k, CurveAnchors a, float spikeMax,
float lowlandCeilingM, float climbFeather, float summitDrama)
{
- // ---- the preserved lowland's edge ----
- float redSlope = (a.RedCeil - a.OrangeCeil) / (k.K2 - k.K1);
-
- if (lowlandCeilingM > MaxLowlandCeilingM)
- throw new InvalidOperationException(
- $"[ContinuousCurve] lowlandCeiling {lowlandCeilingM:F1} m is above the {MaxLowlandCeilingM:F0} m " +
- "bound — close enough to the old bench (100±12 m) to preserve a flat one, which is the " +
- "artifact this mode exists to remove. Refusing.");
-
- // ⚠ THE FLOOD LINE IS THE FLOOR, and "30 m" is NOMINAL: RED_CEIL − SEA = 0.12 raw is
- // actually 30.12 m through the yardstick. Any requested ceiling at or below the red
- // ceiling means "hand over exactly where the preserved lowland ends", and that handover
- // is pinned to THE EXACT ANCHORS — (K2, RED_CEIL), no derived floats — so the extension
- // region is empty by construction and the toe+red band can never be cut. (The first
- // probe run refused its own default over this 0.12 m nominal gap; pinning is the fix,
- // not widening a tolerance.)
- float redCeilM = WorldScale.MetresFromRaw(a.RedCeil - a.Sea);
- float ceilingOut, ceilingRaw;
- if (lowlandCeilingM <= redCeilM + 0.01f)
- {
- ceilingOut = a.RedCeil;
- ceilingRaw = k.K2;
- }
- else
- {
- ceilingOut = a.Sea + WorldScale.RawFromMetres(lowlandCeilingM);
- // Where the linear red-slope extension reaches that output.
- ceilingRaw = k.K2 + (ceilingOut - a.RedCeil) / redSlope;
- }
+ // ---- the preserved lowland's edge — shared with BuildCalibrated ----
+ var (ceilingRaw, ceilingOut, redSlope) = ResolveHandover(k, a, lowlandCeilingM);
if (ceilingRaw >= spikeMax - 1e-3f)
throw new InvalidOperationException(
@@ -360,7 +421,7 @@ namespace IslaApocalypse.Core
if (v <= prev)
throw new InvalidOperationException(
$"[ContinuousCurve] MONOTONICITY VIOLATION at h={h}: {v} <= {prev} " +
- $"(ceiling {LowlandCeilingM:F0} m, feather {ClimbFeather:F2}, drama {SummitDrama:F2}). Refusing to generate.");
+ $"(ceiling {LowlandCeilingM:F0} m, {KnobSummary()}). Refusing to generate.");
prev = v;
prevH = h;
}
@@ -380,7 +441,7 @@ namespace IslaApocalypse.Core
float spanRaw = SpikeMax - CeilingRaw;
float spanOut = Anchors.PeakCap - CeilingOut;
float toN = spanRaw / spanOut; // raw slope → normalized
- float onsetRaw = CeilingRaw + SummitOnset * spanRaw;
+ float onsetRaw = CeilingRaw + EffectiveSummitOnset * spanRaw;
float s0N = JoinSlopeRaw * toN;
float minN = float.MaxValue, maxN = float.MinValue, minAt = 0f, maxAt = 0f;
@@ -400,15 +461,24 @@ namespace IslaApocalypse.Core
/// Raw height where the summit onset sits, and its output — for histogram overlays.
public (float raw, float outp) SummitOnsetPoint()
{
- float r = CeilingRaw + SummitOnset * (SpikeMax - CeilingRaw);
+ float r = CeilingRaw + EffectiveSummitOnset * (SpikeMax - CeilingRaw);
return (r, Apply(r));
}
+ ///
+ /// The shaping knobs, named for whichever path built this curve — chat2/02's analytic
+ /// feather/drama or chat2/03's measured lift/sharpness. ⚠ The analytic fields are NaN on a
+ /// calibrated curve, so nothing may print them unconditionally.
+ ///
+ public string KnobSummary() => Calibration != null
+ ? $"lift {Calibration.MountainLift:F2} sharp {Calibration.PeakSharpness:F2} (calibrated)"
+ : $"feather {ClimbFeather:F2} drama {SummitDrama:F2} (analytic 02)";
+
/// The control points as one line for the INDEX and the report.
public string DescribeControlPoints()
{
var sb = new StringBuilder();
- sb.Append($"ceiling {LowlandCeilingM:F0}m feather {ClimbFeather:F2} drama {SummitDrama:F2} · points ");
+ sb.Append($"ceiling {LowlandCeilingM:F0}m {KnobSummary()} · points ");
for (int i = 0; i < _x.Length; i++)
sb.Append($"({_x[i]:F4},{_y[i]:F4}{(i == 0 ? " C1" : "")}) ");
sb.Append($"· join slope {JoinSlopeRaw:F4} raw");
diff --git a/Tools/Scenes/MountainRestoreTool.tscn b/Tools/Scenes/MountainRestoreTool.tscn
new file mode 100644
index 0000000..2c9769a
--- /dev/null
+++ b/Tools/Scenes/MountainRestoreTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://cmtnrestore03isla"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/MountainRestoreTool.cs" id="1_mrt"]
+
+[node name="MountainRestoreTool" type="Node"]
+script = ExtResource("1_mrt")
diff --git a/Tools/Scripts/LandHistogram.cs b/Tools/Scripts/LandHistogram.cs
index dc39665..59d608e 100644
--- a/Tools/Scripts/LandHistogram.cs
+++ b/Tools/Scripts/LandHistogram.cs
@@ -134,6 +134,42 @@ namespace IslaApocalypse.Tools
FieldsPooled++;
}
+ ///
+ /// Pool one field's samples in, but only where a SECOND field clears a threshold — "the
+ /// output heights of the cells whose raw height is above the climb's ceiling".
+ ///
+ /// ⚠ The gate is a different field from the values. That is the whole point: chat2/03
+ /// calibrates the climb against the staircase's OUTPUT distribution restricted to
+ /// ABOVE-CEILING land, and "above the ceiling" is a fact about the RAW height. Gating on the
+ /// values themselves would select a different population — output above the ceiling includes
+ /// nothing extra here, but only because the curve is monotone, and relying on that silently
+ /// would break the moment a caller gated a non-monotone pair.
+ ///
+ /// The sea test still applies to the VALUES, so this stays a land histogram.
+ ///
+ public void AccumulateWhere(float[,] field, float[,] gate, int mapSize, float gateAbove)
+ {
+ for (int x = 0; x < mapSize; x++)
+ {
+ for (int y = 0; y < mapSize; y++)
+ {
+ if (gate[x, y] <= gateAbove) continue;
+
+ 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++;
+ }
+
///
/// The quantile at (0..100) — a raw height, interpolated inside
/// its bin so the answer is not quantized to .
diff --git a/Tools/Scripts/MountainRestoreTool.cs b/Tools/Scripts/MountainRestoreTool.cs
new file mode 100644
index 0000000..afb4d56
--- /dev/null
+++ b/Tools/Scripts/MountainRestoreTool.cs
@@ -0,0 +1,524 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ THE MOUNTAIN-RESTORE BATCH (chat2/03) — put the mountain back, as a smooth slope.
+ ///
+ /// ═══ THE STORY THIS BATCH TELLS, IN THREE HISTOGRAMS ═══
+ ///
+ /// staircase the mass is there, but parked in two spikes (bench 100 m, plateau 220 m)
+ /// continuous_02 the spikes are gone — and so is the mass. It fell to 30–90 m.
+ /// continuous_restored ⭐ the same mass as the staircase, spread as one smooth grade.
+ ///
+ /// That contrast is the point, so the three are named to sort adjacent.
+ ///
+ /// ═══ HOW THE RESTORATION IS MEASURED ═══
+ ///
+ /// The climb's control points are no longer invented from shape knobs. They are MEASURED off the
+ /// staircase itself, on the same 6-seed pool tasks 01/02 use:
+ ///
+ /// for p in {10,30,50,70,85,95} of ABOVE-CEILING land:
+ /// u_p ← that percentile of the RAW height (normalized into the climb's span)
+ /// v_p ← that percentile of the OUTPUT height (staircase, normalized)
+ ///
+ /// PCHIP through those points reproduces the staircase's elevation envelope; the flat bench and
+ /// plateau interiors become grade because ClimbCalibration.MinNormalizedSecant floors
+ /// every segment. → .
+ ///
+ /// ⚠ The two quantile sets are paired by percentile across the SAME cell population, which is
+ /// exact only if the staircase were strictly monotone per column. It is monotone in raw, but the
+ /// per-column bench/plateau modulation (±12 / ±20 m) blurs the pairing by about that much. That
+ /// is well inside the envelope being targeted, and calibrating on the MEASURED output (rather
+ /// than a nominal unmodulated curve) is what makes oracle (g)'s land-above-100 m figure the thing
+ /// actually being aimed at.
+ ///
+ /// ═══ RUNNING IT ═══
+ ///
+ /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
+ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/MountainRestoreTool.tscn
+ ///
+ /// ISLA_TASK / ISLA_BATCH / ISLA_MAPSIZE / ISLA_SEEDS / ISLA_SHOWPIECE_SIZE / ISLA_SHOWPIECE
+ /// ISLA_PHASE1_SOURCE (default "02_pass1_port") · ISLA_T01_SOURCE (default "01_curve_baseline")
+ /// ISLA_SKIP_RAW
+ /// ISLA_LIFT_BIG probe: the `continuous_bigger` lift (default 1.35)
+ /// ISLA_SHARP probe: the `continuous_sharper_peak` knob (default 2.5)
+ ///
+ public partial class MountainRestoreTool : Node
+ {
+ private static readonly int[] DefaultSeeds = { 1063685222, 777001 };
+
+ /// ⚠ Task 01's pool, verbatim — the knots' identity, and with it the staircase control's.
+ private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
+
+ private const int DefaultMapSize = 2048;
+ private const int DefaultShowpieceSize = 8192;
+
+ /// Oracle (g)'s PASS/NOTE threshold, percentage points of land above 100 m. Reported either way.
+ private const double MountainTolerancePp = 2.0;
+
+ public override void _Ready()
+ {
+ 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", 3);
+ string descr = EnvStr("ISLA_BATCH", "mountain_restore");
+ 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";
+ string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
+ string t01Source = EnvStr("ISLA_T01_SOURCE", "01_curve_baseline");
+ bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
+ float liftBig = EnvFloat("ISLA_LIFT_BIG", 1.35f);
+ float sharpKnob = EnvFloat("ISLA_SHARP", 2.5f);
+
+ string batchRoot = ToolingPaths.BatchRoot(task, descr);
+ DirAccess.MakeDirRecursiveAbsolute(batchRoot);
+ DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
+
+ var anchors = CurveAnchors.Default;
+ float sea = 0.15f;
+ int primary = seeds[0];
+
+ GD.Print("==================================================================");
+ GD.Print(" MOUNTAIN RESTORE (chat2/03) — the staircase's mountain,");
+ GD.Print(" de-terraced. Calibrated, not invented.");
+ GD.Print("==================================================================");
+ GD.Print($"MapSize : {mapSize} showpiece {(showpiece ? showSize.ToString() : "off")}");
+ GD.Print($"yardstick : {WorldScale.Describe()}");
+ GD.Print($"seeds : {string.Join(", ", seeds)} (calibration pool: {string.Join(", ", CalibrationSeeds)})");
+ GD.Print($"batch : {batchRoot}");
+ GD.Print("==================================================================");
+
+ // ═══ 0. KNOTS — task 01's pool, re-measured for bit-identity ═══
+ GD.Print("\n--- 0. KNOTS ---");
+ var rawPool = new LandHistogram(sea);
+ var pass1 = new Dictionary();
+ foreach (int seed in CalibrationSeeds)
+ {
+ var p1 = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed });
+ pass1[seed] = p1;
+ rawPool.Accumulate(p1.Height, mapSize);
+ }
+ var knots = new CurveKnots(2, "v2_balanced",
+ rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
+ rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
+ rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
+ GD.Print($" {rawPool}");
+ GD.Print($" {knots}");
+
+ // ═══ 1. CALIBRATE — measure the staircase's above-ceiling elevation distribution ═══
+ //
+ // ⚠ The ceiling is the DEFAULT 30 m handover, which is exactly (K2, RED_CEIL). So
+ // "above-ceiling" is simply "raw > K2" — no derived float, and the same population the
+ // climb will later be responsible for.
+ GD.Print("\n--- 1. CALIBRATION (staircase above-ceiling distribution) ---");
+ float ceilingRaw = knots.K2;
+
+ var rawAbove = new LandHistogram(sea);
+ var outAbove = new LandHistogram(sea);
+ var stairPool = new Dictionary();
+
+ foreach (int seed in CalibrationSeeds)
+ {
+ var scfg = MakeConfig(mapSize, seed, knots, anchors, "staircase");
+ scfg.CurveMode = CurveModeKind.Staircase;
+ scfg.ShelfDetail = true;
+ Pass2Result st = Shaping.Shape(pass1[seed], scfg);
+ stairPool[seed] = st;
+
+ rawAbove.AccumulateWhere(pass1[seed].Height, pass1[seed].Height, mapSize, ceilingRaw);
+ outAbove.AccumulateWhere(st.Height, pass1[seed].Height, mapSize, ceilingRaw);
+ }
+
+ double shareAbove = 100.0 * rawAbove.TotalLand / rawPool.TotalLand;
+ GD.Print($" above-ceiling land: {rawAbove.TotalLand:N0} cells = {shareAbove:F1}% of all land");
+
+ var pcts = ClimbCalibration.DefaultPercentiles;
+ var rawQ = new float[pcts.Length];
+ var outQ = new float[pcts.Length];
+ GD.Print(" percentile → raw → staircase output");
+ for (int i = 0; i < pcts.Length; i++)
+ {
+ rawQ[i] = rawAbove.Quantile(pcts[i]);
+ outQ[i] = outAbove.Quantile(pcts[i]);
+ GD.Print($" P{pcts[i],-4:F0} raw {rawQ[i]:F4} → {WorldScale.MetresFromRaw(outQ[i] - sea),6:F1} m");
+ }
+
+ ClimbCalibration Calib(float lift, float sharp) => ClimbCalibration.FromPercentiles(
+ pcts, rawQ, outQ, ceilingRaw, HeightCurve.EffectiveSpikeMax(pass1[primary].HMaxSeed, knots, anchors),
+ anchors.RedCeil, anchors.PeakCap, lift, sharp);
+
+ // ⚠ ONE calibration object per knob pair, shared across seeds. spikeMax differs slightly
+ // per seed, but the calibration is NORMALIZED (u, v in [0,1]) — BuildCalibrated
+ // denormalizes against each seed's own spikeMax. So the shape is shared; the extent is
+ // per-seed, exactly as the per-seed peak normalization requires.
+ var calRestored = Calib(1.0f, 1.0f);
+ var calBigger = Calib(liftBig, 1.0f);
+ var calSharper = Calib(1.0f, sharpKnob);
+ GD.Print($" restored: {calRestored.Describe()}");
+ GD.Print($" bigger : {calBigger.Describe()}");
+ GD.Print($" sharper : {calSharper.Describe()}");
+
+ // ═══ 2. VARIANTS ═══
+ var variants = new List<(string label, Action mutate)>
+ {
+ ("staircase", c => { c.CurveMode = CurveModeKind.Staircase; c.ShelfDetail = true; }),
+ ("continuous_02default", c => { c.CurveMode = CurveModeKind.Continuous;
+ c.ClimbCalibration = null; // the analytic 02 curve
+ c.ClimbFeather = 0.4f; c.SummitDrama = 2.5f; }),
+ ("continuous_restored", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calRestored; }),
+ ("continuous_bigger", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calBigger; }),
+ ("continuous_sharper_peak", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calSharper; }),
+ };
+
+ GD.Print("\n--- 2. VARIANTS ---");
+ var results = new Dictionary<(int, string), Pass2Result>();
+ var offs = new Dictionary();
+ var rows = new List();
+ bool notesPrinted = false;
+
+ foreach (int seed in seeds)
+ {
+ var offCfg = MakeConfig(mapSize, seed, knots, anchors, "curve_off");
+ offCfg.Curve = false;
+ offs[seed] = Shaping.Shape(pass1[seed], offCfg);
+
+ foreach (var (label, mutate) in variants)
+ {
+ var cfg = MakeConfig(mapSize, seed, knots, anchors, label);
+ mutate(cfg);
+ Pass2Result p2 = Shaping.Shape(pass1[seed], cfg);
+ results[(seed, label)] = p2;
+ if (!notesPrinted) foreach (string nt in p2.Notes) GD.Print(" " + nt);
+ rows.Add(WriteVariant(batchRoot, p2, sea, anchors, skipRaw));
+ }
+ notesPrinted = true;
+ }
+
+ // ═══ 3. ORACLE ═══
+ GD.Print("\n--- 3. ORACLE ---");
+ var hard = new List();
+ var soft = new List();
+
+ string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
+ string t01Dump = Path.Combine(ToolingPaths.BatchesRoot, t01Source, $"{primary}_curve_on", "height.f32");
+ hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
+ offs[primary].Height, HeightField.Load(p1Dump, mapSize), mapSize, p1Dump));
+ hard.Add(ShapingOracle.DumpRegression("a2", "staircase == task-01 curve_on .f32 dump",
+ results[(primary, "staircase")].Height, HeightField.Load(t01Dump, mapSize), mapSize, t01Dump));
+
+ long bFail = 0;
+ foreach (int seed in seeds)
+ foreach (var (label, _) in variants)
+ if (!ShapingOracle.ClassifyFidelity(pass1[seed], results[(seed, label)]).Passed) bFail++;
+ hard.Add(new ShapingOracle.Check
+ {
+ Id = "b", Name = "classify == raw, all seeds × all variants",
+ Passed = bFail == 0,
+ Detail = bFail == 0 ? $"bit-identical on {seeds.Length} seeds × {variants.Count} variants"
+ : $"{bFail} (seed, variant) pairs drifted",
+ });
+
+ bool cOk = results[(primary, "continuous_restored")].Notes
+ .Exists(n => n.Contains("strict-increase sample passed"));
+ hard.Add(new ShapingOracle.Check
+ {
+ Id = "c", Name = "monotone — Fritsch–Carlson + per-seed sampled",
+ Passed = cOk,
+ Detail = cOk ? "confirmed on the calibrated climb (throws and refuses on violation)"
+ : "no strict-increase confirmation recorded",
+ });
+
+ string[] continuous = { "continuous_02default", "continuous_restored", "continuous_bigger", "continuous_sharper_peak" };
+ foreach (int seed in seeds)
+ foreach (string label in continuous)
+ {
+ var d = ShapingOracle.LowlandsPreserved(pass1[seed], results[(seed, "staircase")], results[(seed, label)]);
+ d.Name += $" [seed {seed}]";
+ hard.Add(d);
+
+ var e = ShapingOracle.UpperClimbProfile(results[(seed, label)]);
+ e.Name += $" [seed {seed}]";
+ soft.Add(e);
+ }
+
+ foreach (int seed in seeds)
+ foreach (var (label, _) in variants)
+ {
+ var f = ShapingOracle.SeaIdentity(offs[seed], results[(seed, label)], sea);
+ f.Name += $" [seed {seed}]";
+ hard.Add(f);
+ }
+
+ // (g) the restoration, measured — reported for every variant, gated for none.
+ var mountain = new List();
+ foreach (int seed in seeds)
+ foreach (string label in continuous)
+ {
+ var g = ShapingOracle.MountainRestored(results[(seed, label)], results[(seed, "staircase")],
+ sea, MountainTolerancePp);
+ g.Name += $" [seed {seed}]";
+ mountain.Add(g);
+ }
+
+ foreach (var c in hard) GD.Print(" " + c);
+ foreach (var c in soft) { if (c.Passed) GD.Print(" " + c); else GD.PrintErr(" ⚠ SLOPE: " + c); }
+ GD.Print(" --- (g) mountain, reported not gated ---");
+ foreach (var c in mountain) GD.Print(" " + c);
+
+ bool hardOk = hard.TrueForAll(c => c.Passed);
+ GD.Print($" ORACLE: {(hardOk ? "ALL HARD CHECKS PASS" : "*** HARD FAILURES ***")}");
+
+ // ═══ 4. HISTOGRAMS — the three-way contrast, adjacent by filename ═══
+ GD.Print("\n--- 4. HISTOGRAMS ---");
+ foreach (int seed in seeds)
+ {
+ var rawSeed = new LandHistogram(sea);
+ rawSeed.Accumulate(pass1[seed].Height, mapSize);
+ int order = 1;
+ foreach (var (label, _) in variants)
+ DrawShaped(results[(seed, label)], anchors, seed, mapSize, batchRoot, order++, sea);
+ }
+
+ // ═══ 5. SHOWPIECE ═══
+ string showNote = "skipped (ISLA_SHOWPIECE=0)";
+ if (showpiece)
+ {
+ GD.Print($"\n--- 5. SHOWPIECE at {showSize} (continuous_restored, seed {primary}) ---");
+ var cfg = MakeConfig(showSize, primary, knots, anchors, "continuous_restored_showpiece");
+ cfg.CurveMode = CurveModeKind.Continuous;
+ cfg.ClimbCalibration = calRestored;
+
+ Pass1Result p1 = Topography.Generate(cfg);
+ Pass2Result big = Shaping.Shape(p1, cfg);
+ foreach (string nt in big.Notes) GD.Print(" " + nt);
+
+ var cb = ShapingOracle.ClassifyFidelity(p1, big);
+ var offBigCfg = MakeConfig(showSize, primary, knots, anchors, "off"); offBigCfg.Curve = false;
+ var cf = ShapingOracle.SeaIdentity(Shaping.Shape(p1, offBigCfg), big, sea);
+ GD.Print($" {cb}");
+ GD.Print($" {cf}");
+ if (!cb.Passed || !cf.Passed) hardOk = false;
+
+ var (b100, b220) = ShapingOracle.LandAbove(big, sea);
+ GD.Print($" land >100 m {b100:F2}% >220 m {b220:F2}% (at {showSize})");
+ rows.Add(WriteVariant(batchRoot, big, sea, anchors, skipRaw));
+ showNote = $"seed {primary} at {showSize}; >100 m {b100:F2}%, >220 m {b220:F2}%";
+ }
+
+ WriteIndex(batchRoot, mapSize, showSize, seeds, primary, anchors, results, calRestored,
+ calBigger, calSharper, pcts, rawQ, outQ, hard, soft, mountain, rows, hardOk, showNote, sea);
+
+ GD.Print("\n==================================================================");
+ GD.Print($" DONE — {batchRoot}");
+ GD.Print($" ORACLE {(hardOk ? "HARD CHECKS ALL PASS" : "*** HARD FAILURES ***")}");
+ GD.Print("==================================================================");
+ GetTree().Quit(hardOk ? 0 : 3);
+ }
+
+ private static TerrainGenConfig MakeConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
+ => new TerrainGenConfig
+ {
+ MapSize = mapSize, Seed = seed, VariantLabel = label,
+ Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
+ LowlandCeilingM = 30f,
+ };
+
+ // ---- output ---------------------------------------------------------
+
+ private static string WriteVariant(string batchRoot, Pass2Result p2, float sea,
+ CurveAnchors anchors, bool skipRaw)
+ {
+ string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
+ DirAccess.MakeDirRecursiveAbsolute(dir);
+
+ 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 = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
+ ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
+ HillshadeStrength = 0.30f, SeaLevel = sea,
+ };
+ Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
+ LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, p2.VariantLabel.ToUpperInvariant())
+ .SavePng(Path.Combine(dir, "relief.png"));
+
+ var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
+ GD.Print($" {p2.VariantLabel,-30} seed {p2.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " +
+ $" >100m {a100,5:F2}% >220m {a220,5:F2}% {p2.ElapsedMs,5} ms");
+
+ return $"| `{p2.Seed}_{p2.VariantLabel}` | {p2.Seed} | {p2.VariantLabel} | {p2.HMin:F3} | {p2.HMax:F3} | " +
+ $"{a100:F2}% | {a220:F2}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |";
+ }
+
+ private static void DrawShaped(Pass2Result p2, CurveAnchors a, int seed, int mapSize,
+ string batchRoot, int order, float sea)
+ {
+ var shaped = new LandHistogram(sea);
+ shaped.Accumulate(p2.Height, mapSize);
+
+ float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f;
+ var display = shaped.Rebin((top - shaped.SeaLevel) / 360f);
+ var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
+
+ var o = new HistogramRenderer.Options
+ {
+ Title = $"{p2.VariantLabel.ToUpperInvariant()} - SEED {seed}",
+ Subtitle = $"LAND ABOVE 100M {a100:F2} PCT - ABOVE 220M {a220:F2} PCT",
+ XAxisLabel = "RAW HEIGHT (POST-CURVE)",
+ XTop = top,
+ Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize}",
+ };
+
+ // The two heights the restoration is measured at, on every plate, so the three-way
+ // contrast can be read off the same reference lines.
+ o.Markers.Add(new HistogramRenderer.Marker { Value = a.Sea + WorldScale.RawFromMetres(100f), Label = "100M" });
+ o.Markers.Add(new HistogramRenderer.Marker { Value = a.Sea + WorldScale.RawFromMetres(220f), Label = "220M" });
+ o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M", Strong = false });
+ if (p2.Continuous != null)
+ o.Markers.Add(new HistogramRenderer.Marker { Value = p2.Continuous.CeilingOut, Label = "LOWLAND", Strong = false });
+
+ string file = $"hist_{seed}_{order}_{p2.VariantLabel}.png";
+ HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, file));
+ GD.Print($" {file}");
+ }
+
+ private static void WriteIndex(string batchRoot, int mapSize, int showSize, int[] seeds, int primary,
+ CurveAnchors a, Dictionary<(int, string), Pass2Result> results,
+ ClimbCalibration calRestored, ClimbCalibration calBigger, ClimbCalibration calSharper,
+ double[] pcts, float[] rawQ, float[] outQ,
+ List hard, List soft, List mountain,
+ List rows, bool hardOk, string showNote, float sea)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("# Batch 03 — restore the mountain, as a smooth slope");
+ sb.AppendLine();
+ sb.AppendLine("The continuous climb's control points are now **measured off the staircase** instead of");
+ sb.AppendLine("invented from shape knobs. Same mountain mass, zero terraces. The lowlands are still");
+ sb.AppendLine("preserved bit-for-bit (oracle d).");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ Open this first");
+ sb.AppendLine();
+ sb.AppendLine($"1. **`{primary}_continuous_restored_showpiece/relief.png`** — the centerpiece ({showNote}).");
+ sb.AppendLine("2. **The three-way histogram contrast**, adjacent by filename:");
+ sb.AppendLine($" - `hist_{primary}_1_staircase.png` — the mass, parked in two spikes");
+ sb.AppendLine($" - `hist_{primary}_2_continuous_02default.png` — spikes gone, **and so is the mass**");
+ sb.AppendLine($" - `hist_{primary}_3_continuous_restored.png` — ⭐ **the mass back, spread smooth**");
+ sb.AppendLine(" Every plate carries the same 100 m / 220 m reference lines.");
+ sb.AppendLine();
+ sb.AppendLine("## The restoration, measured");
+ sb.AppendLine();
+ sb.AppendLine("| Variant | land >100 m | land >220 m |");
+ sb.AppendLine("|---|---|---|");
+ foreach (string label in new[] { "staircase", "continuous_02default", "continuous_restored", "continuous_bigger", "continuous_sharper_peak" })
+ {
+ var (x100, x220) = ShapingOracle.LandAbove(results[(primary, label)], sea);
+ string star = label == "continuous_restored" ? " ⭐" : label == "staircase" ? " *(target)*" : "";
+ sb.AppendLine($"| `{label}`{star} | {x100:F2} % | {x220:F2} % |");
+ }
+ sb.AppendLine();
+ sb.AppendLine($"*(seed {primary} at {mapSize}; per-seed rows in Results below.)*");
+ sb.AppendLine();
+ sb.AppendLine("## The calibration");
+ sb.AppendLine();
+ sb.AppendLine("Measured on the 6-seed pool, above-ceiling land only:");
+ sb.AppendLine();
+ sb.AppendLine("| percentile | raw | staircase output |");
+ sb.AppendLine("|---|---|---|");
+ for (int i = 0; i < pcts.Length; i++)
+ sb.AppendLine($"| P{pcts[i]:F0} | {rawQ[i]:F4} | **{WorldScale.MetresFromRaw(outQ[i] - sea):F0} m** |");
+ sb.AppendLine();
+ sb.AppendLine("| Variant | knobs | control points (u,v) |");
+ sb.AppendLine("|---|---|---|");
+ sb.AppendLine($"| `continuous_restored` | {calRestored.Describe().Split('·')[0].Trim()} | `{calRestored.Describe().Split('·')[1].Trim()}` |");
+ sb.AppendLine($"| `continuous_bigger` | {calBigger.Describe().Split('·')[0].Trim()} | `{calBigger.Describe().Split('·')[1].Trim()}` |");
+ sb.AppendLine($"| `continuous_sharper_peak` | {calSharper.Describe().Split('·')[0].Trim()} | `{calSharper.Describe().Split('·')[1].Trim()}` |");
+ sb.AppendLine();
+ sb.AppendLine($"`floored` counts segments the no-bench floor had to lift — i.e. where the staircase was flat.");
+ sb.AppendLine();
+ sb.AppendLine("## ⚠ The palette is PROVISIONAL");
+ sb.AppendLine();
+ sb.AppendLine("`ProvisionalEven` — the CostaRica colours re-spaced evenly SEA → 420 m. Final calibration");
+ sb.AppendLine("waits for the chosen profile. **Grayscale + the histograms are the honest instruments.**");
+ sb.AppendLine();
+ sb.AppendLine("## The oracle");
+ sb.AppendLine();
+ sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
+ sb.AppendLine($"**{(hardOk ? "ALL HARD CHECKS PASS" : "⚠⚠ HARD FAILURES — do not judge this batch")}**");
+ sb.AppendLine();
+ sb.AppendLine("Soft — upper climb slope profile (e):");
+ sb.AppendLine();
+ sb.AppendLine(ShapingOracle.ToMarkdownTable(soft));
+ sb.AppendLine("(g) mountain restored — **reported, not gated** (it is a taste target the developer tunes):");
+ sb.AppendLine();
+ sb.AppendLine(ShapingOracle.ToMarkdownTable(mountain));
+ sb.AppendLine("## Disposability");
+ sb.AppendLine();
+ sb.AppendLine("| Artifact | Keep? |");
+ sb.AppendLine("|---|---|");
+ sb.AppendLine("| `relief.png`, `hist_*.png`, `INDEX.md` | **keep** |");
+ sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
+ sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code (the byte-level oracle) |");
+ sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
+ sb.AppendLine();
+ sb.AppendLine("## Results");
+ sb.AppendLine();
+ sb.AppendLine("| Folder | Seed | Variant | h min | h max | >100 m | >220 m | grayscale range | time |");
+ sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
+ foreach (string row in rows) sb.AppendLine(row);
+ sb.AppendLine();
+ sb.AppendLine($"MapSize {mapSize}, showpiece {showSize}, seeds {string.Join(", ", seeds)}. {WorldScale.Describe()}.");
+
+ 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 float EnvFloat(string k, float fallback)
+ => float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out float 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();
+ 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;
+ }
+ }
+}
diff --git a/Tools/Scripts/MountainRestoreTool.cs.uid b/Tools/Scripts/MountainRestoreTool.cs.uid
new file mode 100644
index 0000000..13e06be
--- /dev/null
+++ b/Tools/Scripts/MountainRestoreTool.cs.uid
@@ -0,0 +1 @@
+uid://dsg3y4a75vwmu
diff --git a/Tools/Scripts/Shaping.cs b/Tools/Scripts/Shaping.cs
index 252e702..c53add1 100644
--- a/Tools/Scripts/Shaping.cs
+++ b/Tools/Scripts/Shaping.cs
@@ -238,10 +238,15 @@ namespace IslaApocalypse.Tools
// stays hard for the same reason.
float spikeMax = HeightCurve.EffectiveSpikeMax(p1.HMaxSeed, knots, anchors);
- // Build throws (refusing the generation) on any config that cannot hit the target
- // silhouette; the tool's _Ready catches and Quit(2)s.
- var curve = ContinuousCurve.Build(knots, anchors, spikeMax,
- cfg.LowlandCeilingM, cfg.ClimbFeather, cfg.SummitDrama);
+ // ⭐ CALIBRATED when a measurement is supplied (chat2/03 — the staircase's mountain with
+ // the terraces melted out), ANALYTIC otherwise (chat2/02's feather/drama curve, kept as
+ // the "before" contrast). Both throw and refuse rather than degrade; the tool's _Ready
+ // catches and Quit(2)s.
+ var curve = cfg.ClimbCalibration != null
+ ? ContinuousCurve.BuildCalibrated(knots, anchors, spikeMax,
+ cfg.LowlandCeilingM, cfg.ClimbCalibration)
+ : ContinuousCurve.Build(knots, anchors, spikeMax,
+ cfg.LowlandCeilingM, cfg.ClimbFeather, cfg.SummitDrama);
// Monotone by construction — and proven anyway, per seed, because "cannot fail" is
// exactly the claim worth a millisecond of checking.
diff --git a/Tools/Scripts/ShapingOracle.cs b/Tools/Scripts/ShapingOracle.cs
index e7a9527..4a7ee73 100644
--- a/Tools/Scripts/ShapingOracle.cs
+++ b/Tools/Scripts/ShapingOracle.cs
@@ -349,6 +349,57 @@ namespace IslaApocalypse.Tools
return c;
}
+ ///
+ /// (g) ⭐ MOUNTAIN RESTORED (chat2/03) — how much land ends up above 100 m and 220 m,
+ /// against the staircase's own figures.
+ ///
+ /// ═══ ⚠ REPORTED, NOT GATED ═══
+ ///
+ /// This is a taste target the developer tunes, so a miss is a FINDING, not a build failure —
+ /// gating it would make `mountainLift` unusable as a knob, since every value but one would
+ /// fail the run. What it must never do is stay silent: chat2/02 lost two thirds of the
+ /// mountain and only found out because someone went looking at the dumps afterwards. This
+ /// check is that look, made automatic.
+ ///
+ /// only decides whether the row reads PASS or NOTE; the
+ /// numbers are always printed.
+ ///
+ public static Check MountainRestored(Pass2Result variant, Pass2Result staircase,
+ float seaLevel, double tolerancePp)
+ {
+ var c = new Check { Id = "g", Name = $"mountain restored vs staircase [{variant.VariantLabel}]" };
+
+ var (v100, v220) = LandAbove(variant, seaLevel);
+ var (s100, s220) = LandAbove(staircase, seaLevel);
+
+ double d100 = v100 - s100;
+ c.Passed = Math.Abs(d100) <= tolerancePp;
+ c.Detail = $">100 m: {v100:F2} % vs staircase {s100:F2} % ({d100:+0.00;-0.00} pp) · " +
+ $">220 m: {v220:F2} % vs {s220:F2} % ({v220 - s220:+0.00;-0.00} pp)";
+ return c;
+ }
+
+ /// Percentage of LAND above 100 m and 220 m of world height. Land = at/above sea.
+ public static (double above100, double above220) LandAbove(Pass2Result p2, float seaLevel)
+ {
+ float t100 = seaLevel + WorldScale.RawFromMetres(100f);
+ float t220 = seaLevel + WorldScale.RawFromMetres(220f);
+
+ long land = 0, a100 = 0, a220 = 0;
+ for (int x = 0; x < p2.MapSize; x++)
+ {
+ for (int y = 0; y < p2.MapSize; y++)
+ {
+ float h = p2.Height[x, y];
+ if (h < seaLevel) continue;
+ land++;
+ if (h > t100) a100++;
+ if (h > t220) a220++;
+ }
+ }
+ return land == 0 ? (0.0, 0.0) : (100.0 * a100 / land, 100.0 * a220 / land);
+ }
+
/// Render the whole oracle as a markdown table for the INDEX and the report.
public static string ToMarkdownTable(IEnumerable checks)
{
diff --git a/Tools/Scripts/TerrainGenConfig.cs b/Tools/Scripts/TerrainGenConfig.cs
index 855840f..ad2d14f 100644
--- a/Tools/Scripts/TerrainGenConfig.cs
+++ b/Tools/Scripts/TerrainGenConfig.cs
@@ -145,13 +145,51 @@ namespace IslaApocalypse.Tools
public float ClimbFeather = 0.4f;
///
- /// The summit's steepening, ≥ 1: the secant slope of the top 15 % of the climb, in units of
- /// the climb's average grade. 1 = a ramp (refused); 2.5 = the default pointed peak; higher =
- /// more dramatic. The peak reads pointy, never a needle-on-a-hump — there is no plateau
- /// under it any more.
+ /// ⚠ chat2/02's ANALYTIC summit knob — SUPERSEDED by .
+ ///
+ /// It steepened the peak by pulling the summit ONSET DOWN, which lowered the whole mid-massif
+ /// with it: at 4.5 the p99 land height collapsed from 199 m to 121 m. A bad trade, and the
+ /// bug chat2/03 §3 exists to fix. It survives ONLY so the 02 curve stays reproducible as a
+ /// contrast variant; it is read only when is null.
///
public float SummitDrama = 2.5f;
+ // ---- chat2/03: the CALIBRATED climb ---------------------------------
+
+ ///
+ /// ⭐ The measured climb calibration. Non-null ⇒ the climb reproduces the staircase's
+ /// above-ceiling elevation distribution as a smooth slope. Null ⇒ chat2/02's analytic
+ /// feather/drama curve (kept only as the "before" contrast).
+ ///
+ /// ⚠ Not a value knob — it is MEASURED, per calibration pool, by the batch tool. Two configs
+ /// may share one instance safely: it is immutable.
+ ///
+ public ClimbCalibration ClimbCalibration = null;
+
+ ///
+ /// ⭐ How big the mountain is, relative to the staircase's.
+ ///
+ /// 1.0 reproduce the staircase's mountain (the default — the least-surprising baseline)
+ /// >1 lift the mid-massif higher: more land at 150–300 m
+ /// <1 a smaller mountain, toward chat2/02's bottom-heavy climb
+ ///
+ /// Applied as v ← v^(1/lift) on the calibrated control points: monotone, and it fixes
+ /// both endpoints, so it moves the massif without touching the lowland handover or the cap.
+ /// ⚠ It scales the CLIMB only. It cannot move a lowland cell — oracle (d) proves that.
+ ///
+ public float MountainLift = 1.0f;
+
+ ///
+ /// ⭐ How pointy the summit is — and, unlike , nothing else.
+ ///
+ /// It reshapes only the span above the last measured percentile, leaving that percentile's
+ /// height fixed. Raising it therefore cannot reduce the land below the onset: peak sharpness
+ /// and mountain mass are independent knobs. → chat2/03 §3.
+ ///
+ /// 1.0 = a straight run to the cap; higher defers the rise so the final approach steepens.
+ ///
+ public float PeakSharpness = 1.0f;
+
///
/// ⭐ Pass 2a rung 2: the shelf detail passes — micro-relief skin + shelf-edge knot warp.
/// ⚠ REQUIRES : the edge warp slides the CURVE's knots, so with no curve