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