diff --git a/Core/Scripts/ContinuousCurve.cs b/Core/Scripts/ContinuousCurve.cs new file mode 100644 index 0000000..dc4df02 --- /dev/null +++ b/Core/Scripts/ContinuousCurve.cs @@ -0,0 +1,418 @@ +using System; +using System.Text; + +namespace IslaApocalypse.Core +{ + /// + /// ⭐⭐ THE CONTINUOUS-GRADE CURVE (chat2/02) — smooth the UPPER staircase, preserve the lowlands. + /// + /// ═══ WHAT THIS IS, AND WHAT IT REFUSES TO BE ═══ + /// + /// The faithful v5 staircase () terraces the island above the flood + /// tiers: foothill riser → bench → mid riser → plateau → summit needle. The developer's verdict + /// on the 01 baseline: the LOWLANDS ARE GOOD — the broad low plain, ~75 % of land below 30 m, is + /// the thing to keep. The fault is entirely ABOVE them: flat benches read as authored terraces + /// and the summit reads as a needle on a hump. + /// + /// So this curve is PIECEWISE, and the pieces have different loyalties: + /// + /// raw ≤ SEA IDENTITY. The coastline must not move. (Same line as v5.) + /// SEA < raw ≤ K2 ⭐ THE STAIRCASE'S OWN toe+red mapping, BY DELEGATION — the same + /// code path, so the lowland output is BIT-IDENTICAL to task 01's. + /// Not "equivalent": the same floats. Oracle (d) holds this. + /// K2 < raw ≤ ceilingRaw the red band's exit slope, CONTINUED LINEARLY — only non-empty + /// when the ceiling is raised above the default 30 m, extending the + /// current gentle low grade before the climb begins. + /// ceilingRaw < raw ≤ spikeMax + /// ⭐ THE NEW CLIMB — one smooth monotone Fritsch–Carlson (PCHIP) + /// spline from the lowland ceiling to PEAK_CAP. No bench, no + /// plateau, no needle: a coherent massif steepening to a peak. + /// raw > spikeMax the gentle tail, as v5: PEAK_CAP + (raw − spikeMax) · TAIL_SLOPE. + /// + /// ═══ ⚠⚠ WHAT IS DELIBERATELY DROPPED, AND WHAT DELIBERATELY SURVIVES ═══ + /// + /// DROPPED: BENCH_BASE/AMP, PLATEAU_BASE/AMP, SHELF_SPAN_* and their three + /// modulation noise fields — the above-flood decorative terracing. That is the entire point of + /// this mode. + /// + /// SURVIVES: SEA, ORANGE_CEIL (14 m) and RED_CEIL (30 m), because they are + /// the STORM-LADDER FLOOD TIERS and they live inside the preserved lowland — D-036's + /// terrain-shelves-at-flood-tiers is intact where it carries meaning. PEAK_CAP (420 m) + /// survives as the summit ceiling, with the per-seed spikeMax normalization unchanged. + /// + /// ═══ ⚠ MONOTONE BY CONSTRUCTION — WHY THE 24-CORNER SWEEP RETIRES HERE ═══ + /// + /// Fritsch–Carlson tangent limiting guarantees a monotone interpolant for ANY monotone control + /// points: every tangent is clamped into the region where the Hermite cubic cannot overshoot. + /// The staircase needed a numeric sweep because its effective shape depended on three modulation + /// fields and a per-column warp; this curve has no per-column inputs at all — one spline per + /// seed. still runs a cheap dense sample per seed, + /// because "cannot fail" is exactly the claim worth spending a millisecond checking. + /// + /// ═══ THE TWO KNOBS (plus the ceiling) — ALL ACT ABOVE THE CEILING ONLY ═══ + /// + /// lowlandCeiling where the preserved low grade hands over to the climb (config, metres; + /// default 30 = RED_CEIL, i.e. the flood line — hand over exactly where the + /// staircase's lowland ends). + /// climbFeather how long the climb hugs the lowland's exit slope before steepening. + /// summitDrama how hard the top ~15 % steepens, so the peak reads pointy, not a ramp. + /// + /// ⚠ NO CONTROL POINT MAY ACT AS A MAGNET. The generator enforces strictly INCREASING segment + /// secants below the summit: mass can never pile at an interior point the way it piled at the + /// bench, because no interval maps wide-in to narrow-out below the summit onset. + /// + /// Engine-free (System.MathF), beside — the two modes are one seam. + /// + public sealed class ContinuousCurve + { + // ═══ shape constants (not config — the knobs above are the config surface) ═══ + + /// Where "the summit" begins, as a fraction of the climb's raw span. The top 15 %. + public const float SummitOnset = 0.85f; + + /// + /// The ceiling knob's hard bound, metres. The bench sat at 100±12 m; a lowland ceiling at or + /// above it could preserve a flat bench, which is the one thing this mode exists to remove. + /// 80 m keeps clear air below the old bench's lowest excursion (88 m). + /// + public const float MaxLowlandCeilingM = 80f; + + /// + /// Oracle (e) tripwires, in NORMALIZED climb slope (1 = the climb's average grade). + /// Floor: a slope this far below the join slope reads as a bench — the artifact this mode + /// removes. Ceiling: a slope this steep below the summit onset reads as a cliff. + /// Warn-and-report, not throw — this is an exploration batch. + /// + public const float NearFlatFactor = 0.25f; // × the normalized join slope + public const float CliffCeilingN = 3.5f; + + // ═══ the built spline ═══ + + /// The knot set — only K1/K2 are consumed (the preserved toe+red). + public readonly CurveKnots Knots; + + /// The anchors — Sea/Orange/Red/PeakCap/TailSlope consumed; bench/plateau ignored. + public readonly CurveAnchors Anchors; + + /// Raw height where the preserved lowland hands over to the climb. + public readonly float CeilingRaw; + + /// Output height at the handover — the top of the preserved lowland. + public readonly float CeilingOut; + + /// This seed's raw summit: EffectiveSpikeMax(hMaxSeed). The climb's right edge. + public readonly float SpikeMax; + + /// The red band's exit slope — the climb's C¹ join tangent (raw out per raw in). + public readonly float JoinSlopeRaw; + + /// The knob values this spline was built from, for the INDEX and the report. + public readonly float LowlandCeilingM, ClimbFeather, SummitDrama; + + // 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) + { + Knots = k; Anchors = a; + CeilingRaw = ceilingRaw; CeilingOut = ceilingOut; SpikeMax = spikeMax; + JoinSlopeRaw = joinSlopeRaw; + LowlandCeilingM = lowlandCeilingM; ClimbFeather = climbFeather; SummitDrama = summitDrama; + _x = x; _y = y; _m = m; + } + + /// + /// Build the per-seed spline. ⚠ PER SEED, because is per seed — + /// exactly the same reason the staircase's monotonicity sweep ran per seed. + /// + /// Throws (refusing the generation) on any configuration that cannot produce the target + /// silhouette: a ceiling at bench height, a drama that would fold the summit under its own + /// onset, a ceiling above the seed's summit. + /// + 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; + } + + 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."); + if (climbFeather < 0f || climbFeather > 1f) + throw new InvalidOperationException($"[ContinuousCurve] climbFeather {climbFeather} is outside [0,1]. Refusing."); + if (summitDrama < 1f) + throw new InvalidOperationException($"[ContinuousCurve] summitDrama {summitDrama} < 1 would make the summit SHALLOWER than the climb's average — that is a ramp, not a peak. Refusing."); + + // ---- control points, in normalized climb space ---- + // u = (raw − ceilingRaw)/(spikeMax − ceilingRaw), v = (out − ceilingOut)/(PeakCap − ceilingOut). + float spanRaw = spikeMax - ceilingRaw; + float spanOut = a.PeakCap - ceilingOut; + float s0 = redSlope * spanRaw / spanOut; // the join slope, normalized + + // The feather point: hug the join slope until u_f, then lift. Larger feather = longer hug. + float uF = 0.20f + 0.35f * climbFeather; + float vF = s0 * uF * 1.05f; // fractionally above the pure hug, so + // the secant already rises — no dip + + // The summit onset: its secant to (1,1) IS the drama. v_s = 1 − drama·(1 − u_s). + float uS = SummitOnset; + float vS = 1f - summitDrama * (1f - uS); + if (vS <= vF + 0.02f) + throw new InvalidOperationException( + $"[ContinuousCurve] summitDrama {summitDrama:F2} folds the summit onset (v={vS:F3}) " + + $"under the feather point (v={vF:F3}) — the mid-climb would have to be flat or " + + "descending to compensate. Lower the drama or the feather. Refusing."); + + // A mid point keeps the feather→onset transition smooth, on a gently convex path so the + // segment secants stay strictly INCREASING — the no-magnet guarantee. + float uM = (uF + uS) * 0.5f; + float vM = vF + (vS - vF) * MathF.Pow((uM - uF) / (uS - uF), 1.35f); + + float[] u = { 0f, uF, uM, uS, 1f }; + float[] v = { 0f, vF, vM, vS, 1f }; + + // ⚠ THE NO-MAGNET CHECK, enforced rather than assumed: every secant below the summit + // must be strictly greater than the one before it. A wide-in→narrow-out interval below + // the onset is a bench in the making. + float prevSecant = 0f; + for (int i = 1; i < u.Length; i++) + { + float sec = (v[i] - v[i - 1]) / (u[i] - u[i - 1]); + if (sec <= prevSecant) + throw new InvalidOperationException( + $"[ContinuousCurve] control-point secants are not strictly increasing at segment {i} " + + $"({sec:F4} after {prevSecant:F4}) with feather={climbFeather:F2}, drama={summitDrama:F2} — " + + "an interior point would act as a magnet. Refusing."); + prevSecant = sec; + } + + // ---- denormalize and fit ---- + int n = u.Length; + var x = new float[n]; + var y = new float[n]; + for (int i = 0; i < n; i++) + { + x[i] = ceilingRaw + u[i] * spanRaw; + y[i] = ceilingOut + v[i] * spanOut; + } + + float[] m = FritschCarlsonTangents(x, y, startTangent: redSlope); + + return new ContinuousCurve(k, a, ceilingRaw, ceilingOut, spikeMax, redSlope, + lowlandCeilingM, climbFeather, summitDrama, x, y, m); + } + + /// + /// Fritsch–Carlson (1980) monotone tangents, with a PRESCRIBED start tangent for the C¹ + /// join. The weighted-harmonic-mean interior tangents already satisfy the monotonicity + /// region; the prescribed start is clamped into [0, 3·Δ₀], which is the classical + /// sufficient bound — so the join is C¹ wherever the lowland's exit slope permits, and + /// safely limited where it does not (which is then reported by the slope sampler, not + /// hidden). + /// + private static float[] FritschCarlsonTangents(float[] x, float[] y, float startTangent) + { + int n = x.Length; + var h = new float[n - 1]; // interval widths + var d = new float[n - 1]; // secants + for (int i = 0; i < n - 1; i++) + { + h[i] = x[i + 1] - x[i]; + d[i] = (y[i + 1] - y[i]) / h[i]; + } + + var m = new float[n]; + + // Start: the C¹ join, clamped into the monotone region. + m[0] = Math.Clamp(startTangent, 0f, 3f * d[0]); + + // Interior: weighted harmonic mean — zero if the secants disagree in sign (they cannot + // here, both positive, but the guard is the algorithm's own and stays). + for (int i = 1; i < n - 1; i++) + { + if (d[i - 1] * d[i] <= 0f) { m[i] = 0f; continue; } + float w1 = 2f * h[i] + h[i - 1]; + float w2 = h[i] + 2f * h[i - 1]; + m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i]); + } + + // End: one-sided three-point estimate, clamped like the start. The summit's entry + // steepness comes from the last secant (the drama), not from an extrapolated spike. + float mEnd = ((2f * h[n - 2] + (n > 2 ? h[n - 3] : h[n - 2])) * d[n - 2] + - h[n - 2] * (n > 2 ? d[n - 3] : d[n - 2])) + / (h[n - 2] + (n > 2 ? h[n - 3] : h[n - 2])); + if (mEnd < 0f) mEnd = 0f; + m[n - 1] = MathF.Min(mEnd, 3f * d[n - 2]); + + return m; + } + + /// + /// The curve, for one column. Handles every range: sea identity, the preserved lowland + /// (BY DELEGATION to — the same code path, hence the same + /// bits), the linear extension, the climb, the tail. + /// + public float Apply(float h) + { + // ⭐ IDENTITY AT AND BELOW SEA — the same load-bearing line as v5. + if (h <= Anchors.Sea) return h; + + // ⭐ THE PRESERVED LOWLAND: delegate to the staircase's own toe+red branches. Below K2, + // HeightCurve.Apply never reads the bench/plateau/edge parameters, so any values pass — + // and the output is bit-identical to task 01's staircase, which oracle (d) asserts. + if (h < Knots.K2) + return HeightCurve.Apply(h, SpikeMax, + Anchors.BenchBase, Anchors.ShelfSpanMin, Anchors.PlateauBase, Anchors.ShelfSpanMin, + Knots, Anchors, edgeShift: 0f); + + // The red band's grade, continued. Empty at the default ceiling (CeilingRaw == K2); + // at h == K2 exactly this is RED_CEIL + 0 — the same value the staircase's foothill + // riser produces at its own u = 0. + if (h <= CeilingRaw) + return Anchors.RedCeil + (h - Knots.K2) * JoinSlopeRaw; + + // The gentle tail, as v5 — a slope, not a clip. + if (h >= SpikeMax) + return Anchors.PeakCap + (h - SpikeMax) * Anchors.TailSlope; + + // ⭐ THE CLIMB: cubic Hermite on the Fritsch–Carlson tangents. + int i = FindInterval(h); + float dx = _x[i + 1] - _x[i]; + float t = (h - _x[i]) / dx; + float t2 = t * t, t3 = t2 * t; + + return (2f * t3 - 3f * t2 + 1f) * _y[i] + + (t3 - 2f * t2 + t) * dx * _m[i] + + (-2f * t3 + 3f * t2) * _y[i + 1] + + (t3 - t2) * dx * _m[i + 1]; + } + + /// The climb's derivative at a raw height inside (CeilingRaw, SpikeMax). + public float SlopeAt(float h) + { + if (h <= CeilingRaw || h >= SpikeMax) return JoinSlopeRaw; // outside the spline proper + int i = FindInterval(h); + float dx = _x[i + 1] - _x[i]; + float t = (h - _x[i]) / dx; + float t2 = t * t; + + return (6f * t2 - 6f * t) * (_y[i] - _y[i + 1]) / dx + + (3f * t2 - 4f * t + 1f) * _m[i] + + (3f * t2 - 2f * t) * _m[i + 1]; + } + + private int FindInterval(float h) + { + // Four intervals — a linear scan beats a binary search at this size. + for (int i = _x.Length - 2; i > 0; i--) + if (h >= _x[i]) return i; + return 0; + } + + /// + /// The cheap per-seed proof that "monotone by construction" held in float32 too: a dense + /// strict-increase sample over the whole range, sea to past the tail. Throws and refuses on + /// violation, exactly as the staircase's sweep did. ~10k samples, sub-millisecond. + /// + /// A one-line confirmation for the run log. + public string AssertStrictlyIncreasing() + { + float prevH = Anchors.Sea; + float prev = Apply(prevH); + double top = SpikeMax + 0.5; + double step = (top - Anchors.Sea) / 10000.0; + + for (double hd = Anchors.Sea + step; hd <= top; hd += step) + { + float h = (float)hd; + if (h <= prevH) continue; // float32 dedupe, as the staircase's sweep + float v = Apply(h); + 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."); + prev = v; + prevH = h; + } + + return $"[ContinuousCurve] strict-increase sample passed (10k points, ceiling {LowlandCeilingM:F0} m, " + + $"feather {ClimbFeather:F2}, drama {SummitDrama:F2}, spikeMax {SpikeMax:F6})."; + } + + /// + /// Oracle (e)'s instrument: sample the climb's slope densely and report it in NORMALIZED + /// units (1 = the climb's average grade). Returns the extremes and where they sit, plus the + /// tripwire verdicts — the caller decides how loudly to say it. + /// + public (float minN, float minAtRaw, float maxBelowOnsetN, float maxAtRaw, bool nearFlat, bool cliff) + SampleClimbSlopes(int samples = 2000) + { + float spanRaw = SpikeMax - CeilingRaw; + float spanOut = Anchors.PeakCap - CeilingOut; + float toN = spanRaw / spanOut; // raw slope → normalized + float onsetRaw = CeilingRaw + SummitOnset * spanRaw; + float s0N = JoinSlopeRaw * toN; + + float minN = float.MaxValue, maxN = float.MinValue, minAt = 0f, maxAt = 0f; + for (int i = 1; i < samples; i++) + { + float h = CeilingRaw + spanRaw * i / samples; + float sN = SlopeAt(h) * toN; + if (sN < minN) { minN = sN; minAt = h; } + if (h < onsetRaw && sN > maxN) { maxN = sN; maxAt = h; } + } + + bool nearFlat = minN < s0N * NearFlatFactor; + bool cliff = maxN > CliffCeilingN; + return (minN, minAt, maxN, maxAt, nearFlat, cliff); + } + + /// 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); + return (r, Apply(r)); + } + + /// 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 "); + 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"); + return sb.ToString(); + } + } +} diff --git a/Core/Scripts/ContinuousCurve.cs.uid b/Core/Scripts/ContinuousCurve.cs.uid new file mode 100644 index 0000000..23fa91c --- /dev/null +++ b/Core/Scripts/ContinuousCurve.cs.uid @@ -0,0 +1 @@ +uid://cfyqb4kr3w1mu diff --git a/Tools/Scenes/CurveContinuousTool.tscn b/Tools/Scenes/CurveContinuousTool.tscn new file mode 100644 index 0000000..c7c638e --- /dev/null +++ b/Tools/Scenes/CurveContinuousTool.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://cvcontin02isla"] + +[ext_resource type="Script" path="res://Tools/Scripts/CurveContinuousTool.cs" id="1_cct"] + +[node name="CurveContinuousTool" type="Node"] +script = ExtResource("1_cct") diff --git a/Tools/Scripts/CurveBaselineTool.cs b/Tools/Scripts/CurveBaselineTool.cs index c2e3a77..54c1835 100644 --- a/Tools/Scripts/CurveBaselineTool.cs +++ b/Tools/Scripts/CurveBaselineTool.cs @@ -297,6 +297,10 @@ namespace IslaApocalypse.Tools { MapSize = mapSize, Seed = seed, VariantLabel = "curve_on", Curve = true, ShelfDetail = true, Knots = k, Anchors = a, + // ⚠ PINNED, not defaulted: this tool exists to reproduce the task-01 staircase + // bit-for-bit. chat2/02 moved the config DEFAULT to Continuous for its exploration; + // a control batch must not move with a default. (chat2/02.) + CurveMode = CurveModeKind.Staircase, }; /// diff --git a/Tools/Scripts/CurveContinuousTool.cs b/Tools/Scripts/CurveContinuousTool.cs new file mode 100644 index 0000000..3fd1b31 --- /dev/null +++ b/Tools/Scripts/CurveContinuousTool.cs @@ -0,0 +1,580 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// ⭐ THE CONTINUOUS-GRADE EXPLORATION BATCH (chat2/02, rev 3) — smooth the upper staircase, + /// preserve the lowlands, and prove both claims before anyone looks at a render. + /// + /// ═══ THE TARGET SILHOUETTE (judge every shaped histogram against THIS) ═══ + /// + /// The bottom shoulder stays exactly where it is — the broad low pile, ~75 % of land below + /// 30 m, UNLIFTED. Above it, the bench and plateau spikes dissolve and the empty valleys fill + /// in: one smooth continuous falloff out to a thin dramatic peak at 420 m. Only the top ~25 % + /// of land moves. + /// + /// ═══ THE VARIANTS — tight, one axis each ═══ + /// + /// staircase the faithful M3 control (task 01, bit-identical — oracle a2) + /// continuous_default lowland to 30 m, feather 0.40, drama 2.5 — the centerpiece + /// continuous_hold_higher lowland ceiling raised to 50 m — the primary axis explored up + /// continuous_dramatic_peak drama 4.5 — how pointy the peak gets + /// continuous_lifted_WRONG ⚠ the even whole-island lift — DELIBERATELY the wrong direction, + /// a contrast bookend. Its oracle-(d) failure is EXPECTED and is + /// reported as confirmation of why the direction is wrong. + /// + /// ═══ ⚠ WHY THIS TOOL RE-MEASURES THE KNOTS INSTEAD OF USING CurveKnots.V2Baseline ═══ + /// + /// Task 01's batch was generated with the knots MEASURED IN THAT RUN, at full float precision. + /// The baked V2Baseline constants are those values quoted to six decimals — off by <1e-7 raw, + /// which is invisible to every consumer EXCEPT a bit-identity oracle. Oracle (a2) demands the + /// staircase reproduce task 01's `.f32` byte-for-byte, so this tool re-runs the identical + /// 6-seed calibration (same seeds, same size, same instrument) and uses the measured knots. + /// It prints the measured-vs-baked deltas so the equivalence is evidence, not assumption. + /// + /// ═══ RUNNING IT ═══ + /// + /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \ + /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CurveContinuousTool.tscn + /// + /// ISLA_TASK authoring task number (default 2) + /// ISLA_BATCH descriptor, NO prefix (default "curve_continuous") + /// ISLA_MAPSIZE variant profile (default 2048) + /// ISLA_SEEDS variant seeds, comma-separated (default: the 2 pinned below) + /// ISLA_SHOWPIECE_SIZE the big confirmation render (default 8192) + /// ISLA_SHOWPIECE "0" to skip it + /// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "02_pass1_port") + /// ISLA_T01_SOURCE batch holding task-01 .f32 (default "01_curve_baseline") + /// ISLA_SKIP_RAW "1" to skip the .f32 dumps + /// ISLA_CEILING_M probe override: lowland ceiling, metres (default 30) + /// ISLA_FEATHER probe override: climb feather, 0..1 (default 0.4) + /// ISLA_DRAMA probe override: summit drama, >= 1 (default 2.5) + /// + /// ⚠ The three knob overrides move the BASE config only. Each batch variant sets its own axis + /// explicitly in its mutator, so a probe env can shift `continuous_default` but can never + /// silently move `hold_higher`'s ceiling or `dramatic_peak`'s drama out from under their names. + /// + public partial class CurveContinuousTool : Node + { + /// Two representative variant seeds — the primary Phase-1 seed and the tallest one. + private static readonly int[] DefaultSeeds = { 1063685222, 777001 }; + + /// + /// ⚠ THE TASK-01 CALIBRATION POOL, VERBATIM — same six seeds, so the measured knots (and + /// with them the staircase control) reproduce task 01 bit-for-bit. Do not "simplify" this + /// to the variant seeds; the pool is part of the knots' identity. + /// + private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 }; + + private const int DefaultMapSize = 2048; + private const int DefaultShowpieceSize = 8192; + + public override void _Ready() + { + // ⚠ An exception out of _Ready does not stop Godot — it logs and the process HANGS with + // no main loop to end it. Catch, say what was refused, exit 2. (The standing rule.) + 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", 2); + string descr = EnvStr("ISLA_BATCH", "curve_continuous"); + 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"; + + string batchRoot = ToolingPaths.BatchRoot(task, descr); // composed, never free-form + DirAccess.MakeDirRecursiveAbsolute(batchRoot); + DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); + + var anchors = CurveAnchors.Default; + float sea = 0.15f; + int primary = seeds[0]; + + GD.Print("=================================================================="); + GD.Print(" CURVE CONTINUOUS (rev 3) — smooth the upper staircase,"); + GD.Print(" preserve the lowlands. The staircase rides along as the control."); + 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("------------------------------------------------------------------"); + GD.Print(ToolingPaths.Describe()); + GD.Print("=================================================================="); + + // ═══ 0. RE-MEASURE THE KNOTS — task 01's calibration, verbatim (see the type header) ═══ + GD.Print("\n--- 0. CALIBRATION (the task-01 pool, re-run for bit-identity) ---"); + 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); + GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms"); + } + 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(" measured vs baked V2Baseline (6-decimal roundings — expect <1e-6):"); + for (int i = 0; i < 6; i++) + GD.Print($" K{i + 1}: measured {knots[i]:G9} baked {CurveKnots.V2Baseline[i]:G9} " + + $"delta {knots[i] - CurveKnots.V2Baseline[i]:E2}"); + + // ═══ 1. THE VARIANTS ═══ + var variants = new List<(string label, Action mutate)> + { + ("staircase", c => { c.CurveMode = CurveModeKind.Staircase; c.ShelfDetail = true; }), + ("continuous_default", c => { c.CurveMode = CurveModeKind.Continuous; }), + ("continuous_hold_higher", c => { c.CurveMode = CurveModeKind.Continuous; c.LowlandCeilingM = 50f; }), + ("continuous_dramatic_peak", c => { c.CurveMode = CurveModeKind.Continuous; c.SummitDrama = 4.5f; }), + ("continuous_lifted_WRONG", c => { c.CurveMode = CurveModeKind.LiftedWrong; }), + }; + + GD.Print("\n--- 1. VARIANTS ---"); + var results = new Dictionary<(int seed, string label), Pass2Result>(); + var offs = new Dictionary(); + var rows = new List(); + bool notesPrinted = false; + + foreach (int seed in seeds) + { + Pass1Result p1 = pass1[seed]; + offs[seed] = Shaping.Shape(p1, BaseConfig(mapSize, seed, knots, anchors, "curve_off", off: true)); + + foreach (var (label, mutate) in variants) + { + var cfg = BaseConfig(mapSize, seed, knots, anchors, label, off: false); + mutate(cfg); + Pass2Result p2 = Shaping.Shape(p1, cfg); + results[(seed, label)] = p2; + + if (!notesPrinted) foreach (string n in p2.Notes) GD.Print(" " + n); + + rows.Add(WriteVariant(batchRoot, p2, sea, anchors, skipRaw)); + } + notesPrinted = true; + } + + // ═══ 2. THE ORACLE — before anything is looked at ═══ + GD.Print("\n--- 2. ORACLE ---"); + var hard = new List(); + var soft = new List(); + ShapingOracle.Check bookend; + + { + Pass1Result pp1 = pass1[primary]; + + // (a1) curve off == Phase 1's own dump. + string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32"); + hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump", + offs[primary].Height, HeightField.Load(p1Dump, mapSize), mapSize, p1Dump)); + + // (a2) staircase == task 01's own dump — the control is the control. + string t01Dump = Path.Combine(ToolingPaths.BatchesRoot, t01Source, $"{primary}_curve_on", "height.f32"); + hard.Add(ShapingOracle.DumpRegression("a2", "staircase mode == task-01 curve_on .f32 dump", + results[(primary, "staircase")].Height, HeightField.Load(t01Dump, mapSize), mapSize, t01Dump)); + + // (b) classify == raw, every seed × every variant. + long bFail = 0; + foreach (int seed in seeds) + foreach (var (label, _) in variants) + { + var c = ShapingOracle.ClassifyFidelity(pass1[seed], results[(seed, label)]); + if (!c.Passed) { bFail++; GD.PrintErr($" classify drift: seed {seed} {label}: {c.Detail}"); } + } + 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", + }); + + // (c) monotone — the staircase's sweep and the continuous strict-increase sample + // both throw on violation, so reaching here means they passed; state it explicitly. + bool cStair = results[(primary, "staircase")].Notes.Exists(n => n.Contains("Monotonicity assertion passed")); + bool cCont = results[(primary, "continuous_default")].Notes.Exists(n => n.Contains("strict-increase sample passed")); + hard.Add(new ShapingOracle.Check + { + Id = "c", Name = "monotone — staircase sweep + continuous strict-increase sample", + Passed = cStair && cCont, + Detail = $"staircase sweep {(cStair ? "confirmed" : "MISSING")}, " + + $"continuous sample {(cCont ? "confirmed" : "MISSING")} (both throw-and-refuse on violation)", + }); + + // (d) ⭐ lowlands preserved — every continuous variant, both seeds. HARD. + foreach (int seed in seeds) + foreach (string label in new[] { "continuous_default", "continuous_hold_higher", "continuous_dramatic_peak" }) + { + var c = ShapingOracle.LowlandsPreserved(pass1[seed], results[(seed, "staircase")], results[(seed, label)]); + c.Name += $" [seed {seed}]"; + hard.Add(c); + } + + // (d, bookend) the WRONG lift is EXPECTED to fail this — its failure is the point. + bookend = ShapingOracle.LowlandsPreserved(pp1, results[(primary, "staircase")], + results[(primary, "continuous_lifted_WRONG")]); + + // (e) upper climb profile — SOFT (exploration): warn loudly, do not gate the exit. + foreach (int seed in seeds) + foreach (string label in new[] { "continuous_default", "continuous_hold_higher", "continuous_dramatic_peak" }) + { + var c = ShapingOracle.UpperClimbProfile(results[(seed, label)]); + c.Name += $" [seed {seed}]"; + soft.Add(c); + } + + // (f) sea identity — every variant, both seeds, per cell. HARD. + foreach (int seed in seeds) + foreach (var (label, _) in variants) + { + var c = ShapingOracle.SeaIdentity(offs[seed], results[(seed, label)], sea); + c.Name += $" [seed {seed}]"; + hard.Add(c); + } + } + + foreach (var c in hard) GD.Print(" " + c); + foreach (var c in soft) + { + if (c.Passed) GD.Print(" " + c); + else GD.PrintErr(" ⚠ SLOPE PROFILE: " + c); + } + GD.Print($" bookend (expected FAIL): {bookend}"); + if (bookend.Passed) + GD.PrintErr(" ⚠⚠ the lifted_WRONG bookend PRESERVED the lowlands — it is not doing its job as a contrast."); + + bool hardOk = hard.TrueForAll(c => c.Passed) && !bookend.Passed; + GD.Print($" ORACLE: {(hardOk ? "ALL HARD CHECKS PASS" : "*** HARD FAILURES ***")}" + + $"{(soft.TrueForAll(c => c.Passed) ? "" : " (soft slope warnings present — see above)")}"); + + // ═══ 3. HISTOGRAMS — the contrast, adjacent by filename ═══ + GD.Print("\n--- 3. HISTOGRAMS ---"); + foreach (int seed in seeds) + { + var rawSeed = new LandHistogram(sea); + rawSeed.Accumulate(pass1[seed].Height, mapSize); + DrawRawHist(rawSeed, knots, seed, mapSize, batchRoot); + + int order = 1; + foreach (var (label, _) in variants) + { + DrawShapedHist(results[(seed, label)], rawSeed, anchors, seed, mapSize, batchRoot, order++); + } + } + + // ═══ 4. SHOWPIECE — continuous_default at the big profile ═══ + string showNote = "skipped (ISLA_SHOWPIECE=0)"; + if (showpiece) + { + GD.Print($"\n--- 4. SHOWPIECE at {showSize} (continuous_default, seed {primary}) ---"); + var cfg = BaseConfig(showSize, primary, knots, anchors, "continuous_default_showpiece", off: false); + cfg.CurveMode = CurveModeKind.Continuous; + + Pass1Result p1 = Topography.Generate(cfg); + GD.Print($" pass1 {showSize}: h[{p1.HMinSeed:F3} .. {p1.HMaxSeed:F3}] {p1.ElapsedMs} ms"); + + Pass2Result big = Shaping.Shape(p1, cfg); + foreach (string n in big.Notes) GD.Print(" " + n); + + // The cheap checks that transfer to the big profile: classify fidelity + sea identity. + var cb = ShapingOracle.ClassifyFidelity(p1, big); + var offBig = Shaping.Shape(p1, BaseConfig(showSize, primary, knots, anchors, "off", off: true)); + var cf = ShapingOracle.SeaIdentity(offBig, big, sea); + GD.Print($" {cb}"); + GD.Print($" {cf}"); + if (!cb.Passed || !cf.Passed) hardOk = false; + + rows.Add(WriteVariant(batchRoot, big, sea, anchors, skipRaw)); + showNote = $"seed {primary} at {showSize} — classify + sea identity re-verified there"; + } + + WriteIndex(batchRoot, mapSize, showSize, seeds, primary, knots, anchors, + results, hard, soft, bookend, rows, hardOk, showNote); + + GD.Print("\n=================================================================="); + GD.Print($" DONE — {batchRoot}"); + GD.Print($" ORACLE {(hardOk ? "HARD CHECKS ALL PASS" : "*** HARD FAILURES — see the table ***")}"); + GD.Print("=================================================================="); + GetTree().Quit(hardOk ? 0 : 3); + } + + // ---- configs ---------------------------------------------------------- + + private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, + string label, bool off) => new TerrainGenConfig + { + MapSize = mapSize, Seed = seed, VariantLabel = label, + Curve = !off, ShelfDetail = false, Knots = k, Anchors = a, + // Continuous knob defaults; variant mutators override per axis. + // + // ⚠ ENV-OVERRIDABLE so the knobs can be probed WITHOUT editing source and rebuilding — + // they are "eye-iteration knobs" per the task, and a knob you have to recompile to turn + // is not one. A probe run redirects ISLA_OUTPUT_DIR to scratch and sweeps these; the + // BATCH variants set them explicitly in their mutators, so a probe env cannot silently + // move the batch of record. + LowlandCeilingM = EnvFloat("ISLA_CEILING_M", 30f), + ClimbFeather = EnvFloat("ISLA_FEATHER", 0.4f), + SummitDrama = EnvFloat("ISLA_DRAMA", 2.5f), + }; + + // ---- output ----------------------------------------------------------- + + private static string WriteVariant(string batchRoot, Pass2Result p2, float sea, + CurveAnchors anchors, bool skipRaw) + { + string dir = Path.Combine(batchRoot, $"{p2.Seed}_{LabelOf(p2)}"); + DirAccess.MakeDirRecursiveAbsolute(dir); + + // Plain data beside the pretty render, always. + 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")); + + // ⚠ HILLSHADED, deliberately — the continuous grade is the first terrain with coherent + // shape worth lighting (Phase 1's rule was "flat is the hero" BECAUSE raw noise fuzzes; + // that reasoning inverts once the climb is smooth). Subtle settings, and the palette is + // the PROVISIONAL even ramp — flagged as such in the palette, the INDEX and the report. + 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); + Image withLegend = LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, + LabelOf(p2).ToUpperInvariant()); + withLegend.SavePng(Path.Combine(dir, "relief.png")); + + float land = p2.LandFraction(sea); + GD.Print($" {LabelOf(p2),-26} seed {p2.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " + + $" land {land * 100,5:F1}% {p2.ElapsedMs,5} ms"); + + return $"| `{p2.Seed}_{LabelOf(p2)}` | {p2.Seed} | {LabelOf(p2)} | {p2.HMin:F3} | {p2.HMax:F3} | " + + $"{land * 100:F1}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |"; + } + + /// The variant folder name — read off the result, never re-derived. + private static string LabelOf(Pass2Result p2) => p2.VariantLabel; + + private static void DrawRawHist(LandHistogram raw, CurveKnots k, int seed, int mapSize, string batchRoot) + { + float top = MathF.Ceiling(raw.MaxLand * 20f) / 20f; + var display = raw.Rebin((top - raw.SeaLevel) / 360f); + + var o = new HistogramRenderer.Options + { + Title = $"RAW LAND HEIGHTS - SEED {seed}", + Subtitle = "PRE-CURVE. K2 IS THE LOWLAND CEILING - EVERYTHING LEFT OF IT IS PRESERVED.", + XAxisLabel = "RAW HEIGHT (PRE-CURVE)", + XTop = top, + Footer = $"{raw.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize}", + }; + o.Markers.Add(new HistogramRenderer.Marker { Value = k.K2, Label = "K2 LOWLAND CEIL" }); + o.Bands.Add(new HistogramRenderer.Band + { + Lo = raw.SeaLevel, Hi = k.K2, Label = "preserved lowland", + SharePercent = raw.FractionBelow(k.K2) * 100.0, + }); + o.Bands.Add(new HistogramRenderer.Band + { + Lo = k.K2, Hi = top, Label = "the climb's input", + SharePercent = (1.0 - raw.FractionBelow(k.K2)) * 100.0, + }); + + HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, $"hist_{seed}_0_raw.png")); + GD.Print($" hist_{seed}_0_raw.png"); + } + + private static void DrawShapedHist(Pass2Result p2, LandHistogram rawSeed, CurveAnchors a, + int seed, int mapSize, string batchRoot, int order) + { + var shaped = new LandHistogram(rawSeed.SeaLevel); + shaped.Accumulate(p2.Height, mapSize); + + float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f; + var display = shaped.Rebin((top - shaped.SeaLevel) / 360f); + string label = LabelOf(p2); + + var o = new HistogramRenderer.Options + { + Title = $"SHAPED - {label.ToUpperInvariant()} - SEED {seed}", + XAxisLabel = "RAW HEIGHT (POST-CURVE)", + XTop = top, + Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize}", + }; + + if (p2.Continuous != null) + { + var c = p2.Continuous; + var (onsetRaw, onsetOut) = c.SummitOnsetPoint(); + o.Subtitle = "TARGET: BOTTOM PILE UNMOVED - ABOVE IT ONE SMOOTH FALLOFF TO A THIN PEAK."; + o.Markers.Add(new HistogramRenderer.Marker { Value = c.CeilingOut, Label = $"LOWLAND {c.LowlandCeilingM:F0}M" }); + o.Markers.Add(new HistogramRenderer.Marker { Value = onsetOut, Label = "SUMMIT ONSET", Strong = false }); + o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" }); + o.Bands.Add(new HistogramRenderer.Band + { + Lo = rawSeed.SeaLevel, Hi = c.CeilingOut, Label = "preserved lowland", + SharePercent = rawSeed.FractionBelow(c.CeilingRaw) * 100.0, + }); + o.Bands.Add(new HistogramRenderer.Band + { + Lo = c.CeilingOut, Hi = onsetOut, Label = "the climb", + SharePercent = rawSeed.FractionBetween(c.CeilingRaw, onsetRaw) * 100.0, + }); + o.Bands.Add(new HistogramRenderer.Band + { + Lo = onsetOut, Hi = top, Label = "summit", + SharePercent = (1.0 - rawSeed.FractionBelow(onsetRaw)) * 100.0, + }); + } + else if (p2.CurveModeLabel == "staircase") + { + o.Subtitle = "THE CONTROL: THE M3 STAIRCASE - BENCH AND PLATEAU SPIKES BY DESIGN."; + o.Markers.Add(new HistogramRenderer.Marker { Value = a.OrangeCeil, Label = "ORANGE", Strong = false }); + o.Markers.Add(new HistogramRenderer.Marker { Value = a.RedCeil, Label = "RED", Strong = false }); + o.Markers.Add(new HistogramRenderer.Marker { Value = a.BenchBase, Label = "BENCH" }); + o.Markers.Add(new HistogramRenderer.Marker { Value = a.PlateauBase, Label = "PLATEAU" }); + o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" }); + } + else + { + o.Subtitle = "THE WRONG DIRECTION: THE WHOLE ISLAND LIFTED OFF ITS SHORELINE. A BOOKEND."; + o.Markers.Add(new HistogramRenderer.Marker { Value = a.RedCeil, Label = "OLD 30M LINE", Strong = false }); + o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" }); + } + + string file = $"hist_{seed}_{order}_{label}.png"; + HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, file)); + GD.Print($" {file}"); + } + + // ---- the index ---------------------------------------------------------- + + private static void WriteIndex(string batchRoot, int mapSize, int showSize, int[] seeds, + int primary, CurveKnots knots, CurveAnchors a, + Dictionary<(int, string), Pass2Result> results, + List hard, List soft, + ShapingOracle.Check bookend, List rows, bool hardOk, string showNote) + { + var sb = new StringBuilder(); + sb.AppendLine("# Batch 02 — continuous grade: smooth the upper staircase, preserve the lowlands"); + sb.AppendLine(); + sb.AppendLine("**rev 3.** The lowlands are GOOD and are preserved bit-for-bit (oracle d). Everything"); + sb.AppendLine("above the 30 m flood line is replaced by one smooth monotone climb to the 420 m cap."); + sb.AppendLine("The staircase rides along as the control; the WRONG-direction whole-island lift rides"); + sb.AppendLine("along as a contrast bookend. **Exploration, not convergence** — a tuning pass follows"); + sb.AppendLine("once a direction is picked."); + sb.AppendLine(); + sb.AppendLine("## ⭐ Open this first"); + sb.AppendLine(); + sb.AppendLine($"1. **`{primary}_continuous_default_showpiece/relief.png`** — the centerpiece ({showNote})."); + sb.AppendLine($"2. **`hist_{primary}_1_staircase.png` → `hist_{primary}_2_continuous_default.png`** — the"); + sb.AppendLine(" contrast, adjacent by filename: bottom pile unchanged, the bench/plateau spikes above"); + sb.AppendLine(" it dissolved into a smooth falloff."); + sb.AppendLine($"3. **`hist_{primary}_5_continuous_lifted_WRONG.png`** — the bookend: the whole pile shoved"); + sb.AppendLine(" up off the shoreline. This is what \"make the island stand up\" would have done."); + sb.AppendLine($"4. `{primary}_staircase/relief.png` vs `{primary}_continuous_default/relief.png` — the A/B."); + sb.AppendLine(); + sb.AppendLine("## The variants and their knobs"); + sb.AppendLine(); + sb.AppendLine("| Variant | Mode | Knobs | Control points |"); + sb.AppendLine("|---|---|---|---|"); + sb.AppendLine("| `staircase` | staircase | task-01 defaults (ShelfDetail on) | the v5 bands |"); + foreach (string label in new[] { "continuous_default", "continuous_hold_higher", "continuous_dramatic_peak" }) + { + var c = results[(primary, label)].Continuous; + sb.AppendLine($"| `{label}` | continuous | ceiling {c.LowlandCeilingM:F0} m · feather {c.ClimbFeather:F2} · " + + $"drama {c.SummitDrama:F2} | {c.DescribeControlPoints()} |"); + } + sb.AppendLine("| `continuous_lifted_WRONG` | lifted | none — an even ×~1.47 lift of all land | n/a |"); + sb.AppendLine(); + sb.AppendLine("## ⚠ The palette is PROVISIONAL"); + sb.AppendLine(); + sb.AppendLine("`ProvisionalEven` — the CostaRica colours re-spaced **evenly** SEA → 420 m, so equal"); + sb.AppendLine("colour = equal height and nothing is emphasized. Final palette calibration waits for the"); + sb.AppendLine("chosen curve 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 slope profile (e) — warn-and-report, exploration:"); + sb.AppendLine(); + sb.AppendLine(ShapingOracle.ToMarkdownTable(soft)); + sb.AppendLine($"**The bookend, expected to FAIL (d):** {bookend.Detail}"); + sb.AppendLine(); + sb.AppendLine("## Disposability"); + sb.AppendLine(); + sb.AppendLine("| Artifact | Keep? |"); + sb.AppendLine("|---|---|"); + sb.AppendLine("| `relief.png`, `hist_*.png`, `INDEX.md` | **keep** — the judging plates and the finding |"); + sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |"); + sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code (it is 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 | land % | grayscale range | time |"); + sb.AppendLine("|---|---|---|---|---|---|---|---|"); + foreach (string row in rows) sb.AppendLine(row); + sb.AppendLine(); + sb.AppendLine($"Setup: MapSize {mapSize}, showpiece {showSize}, seeds {string.Join(", ", seeds)}, " + + $"knots re-measured on the task-01 pool. {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/CurveContinuousTool.cs.uid b/Tools/Scripts/CurveContinuousTool.cs.uid new file mode 100644 index 0000000..26a4ee4 --- /dev/null +++ b/Tools/Scripts/CurveContinuousTool.cs.uid @@ -0,0 +1 @@ +uid://dmnl1sp8vyioe diff --git a/Tools/Scripts/Pass2Result.cs b/Tools/Scripts/Pass2Result.cs index d5cbe7d..d0c7a20 100644 --- a/Tools/Scripts/Pass2Result.cs +++ b/Tools/Scripts/Pass2Result.cs @@ -58,6 +58,31 @@ namespace IslaApocalypse.Tools /// Was the curve applied? The primary A/B gate. public readonly bool CurveOn; + /// + /// Which curve shaped the render field: "off", "staircase", "continuous" or "lifted_WRONG". + /// A label, not logic — the oracle and the INDEX read it so a result can never be mistaken + /// for the wrong mode's. + /// + public readonly string CurveModeLabel; + + /// + /// The batch variant this result belongs to, carried straight from + /// . + /// + /// ⚠ CARRIED, NOT INFERRED. The first cut of the chat2/02 tool reconstructed this from the + /// knob values ("ceiling > 31 ⇒ hold_higher"), which works only for exactly today's + /// variant set: add a second variant sharing a knob value and two different runs silently + /// write to one folder. The label is an identity, so it travels with the result. + /// + public readonly string VariantLabel; + + /// + /// The per-seed continuous spline, when is "continuous" — the + /// oracle samples its slopes (check e) and the INDEX prints its control points. Null for + /// every other mode. + /// + public readonly ContinuousCurve Continuous; + /// Was shelf detail applied? Requires — it warps the curve's knots. public readonly bool DetailOn; @@ -90,7 +115,8 @@ namespace IslaApocalypse.Tools public readonly List Notes; public Pass2Result(int mapSize, int seed, float[,] height, float[,] heightClassify, - bool curveOn, bool detailOn, CurveKnots knots, CurveAnchors anchors, float hMaxSeed, + bool curveOn, bool detailOn, string curveModeLabel, string variantLabel, + ContinuousCurve continuous, CurveKnots knots, CurveAnchors anchors, float hMaxSeed, float edgeAmpRaw, float maxEdgeShiftRaw, float hMin, float hMax, ulong elapsedMs, List notes) { @@ -100,6 +126,9 @@ namespace IslaApocalypse.Tools HeightClassify = heightClassify; CurveOn = curveOn; DetailOn = detailOn; + CurveModeLabel = curveModeLabel; + VariantLabel = variantLabel; + Continuous = continuous; Knots = knots; Anchors = anchors; HMaxSeed = hMaxSeed; diff --git a/Tools/Scripts/ReliefPalette.cs b/Tools/Scripts/ReliefPalette.cs index e669106..48c4375 100644 --- a/Tools/Scripts/ReliefPalette.cs +++ b/Tools/Scripts/ReliefPalette.cs @@ -46,8 +46,11 @@ namespace IslaApocalypse.Tools /// public static class ReliefPalette { - /// Named palettes. Gated so the look can be A/B'd like any other change. - public enum Kind { Atlas, Dusk, CostaRica } + /// + /// Named palettes. Gated so the look can be A/B'd like any other change. + /// ⚠ ProvisionalEven is chat2/02's stopgap for the continuous curve — see its array's note. + /// + public enum Kind { Atlas, Dusk, CostaRica, ProvisionalEven } /// /// Bathymetric compression constant, in raw height units. The sea ramp is indexed by @@ -153,6 +156,29 @@ namespace IslaApocalypse.Tools (1.000f, new Color(0.016f, 0.063f, 0.165f)), // abyss — flat }; + // ---- PROVISIONAL EVEN (chat2/02): the CostaRica colours, re-spaced evenly SEA → PEAK_CAP -- + // + // ⚠⚠ PROVISIONAL, AND FLAGGED AS SUCH EVERYWHERE IT APPEARS. The continuous curve spreads + // land across the whole 0–420 m band, so the raw-percentile CostaRica stops no longer sit + // where the land is. This ramp is deliberately UNOPINIONATED: the same colour sequence at + // EVEN height spacing, so equal colour distance = equal height distance and nothing is + // emphasized. Final palette calibration waits until a curve profile is CHOSEN — placing + // stops on a distribution still being explored would bake taste into the instrument. + // Grayscale + the histogram stay the honest instruments; this is only so the relief plates + // are readable meanwhile. + private static readonly (float h, Color c)[] ProvisionalEvenLand = BuildProvisionalEven(); + + private static (float h, Color c)[] BuildProvisionalEven() + { + // SEA → PEAK_CAP through the single yardstick — no new literal for the cap. + float lo = 0.15f; + float hi = 0.15f + IslaApocalypse.Core.WorldScale.RawFromMetres(420f); + var stops = new (float h, Color c)[CostaRicaLand.Length]; + for (int i = 0; i < CostaRicaLand.Length; i++) + stops[i] = (lo + (hi - lo) * i / (CostaRicaLand.Length - 1), CostaRicaLand[i].c); + return stops; + } + /// The hypsometric tint for a height, before any relief shading. public static Color Tint(Kind kind, float height, float seaLevel) { @@ -169,9 +195,10 @@ namespace IslaApocalypse.Tools private static ((float h, Color c)[] land, (float h, Color c)[] sea) Ramps(Kind kind) => kind switch { - Kind.Dusk => (DuskLand, DuskSea), - Kind.CostaRica => (CostaRicaLand, CostaRicaSea), - _ => (AtlasLand, AtlasSea), + Kind.Dusk => (DuskLand, DuskSea), + Kind.CostaRica => (CostaRicaLand, CostaRicaSea), + Kind.ProvisionalEven => (ProvisionalEvenLand, CostaRicaSea), // same sea; land re-spaced + _ => (AtlasLand, AtlasSea), }; /// The land stops of a palette, for drawing a legend. diff --git a/Tools/Scripts/Shaping.cs b/Tools/Scripts/Shaping.cs index 844cae0..252e702 100644 --- a/Tools/Scripts/Shaping.cs +++ b/Tools/Scripts/Shaping.cs @@ -71,11 +71,21 @@ namespace IslaApocalypse.Tools if (cfg.ShelfDetail) notes.Add("[Shaping] shelf detail requested but the curve is off — no-op (detail warps the curve's knots)."); return new Pass2Result(mapSize, p1.Seed, p1.Height, p1.Height, - curveOn: false, detailOn: false, knots: null, anchors: null, hMaxSeed: p1.HMaxSeed, + curveOn: false, detailOn: false, curveModeLabel: "off", variantLabel: cfg.VariantLabel, continuous: null, + knots: null, anchors: null, hMaxSeed: p1.HMaxSeed, edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed, elapsedMs: Time.GetTicksMsec() - t0, notes: notes); } + // ═══ WHICH CURVE (chat2/02) ═══ + // + // Staircase falls through to the faithful task-01 path below, UNTOUCHED — it is the + // control, and controls do not get refactored while they are being compared against. + if (cfg.CurveMode == CurveModeKind.Continuous) + return ShapeContinuous(p1, cfg, notes, t0); + if (cfg.CurveMode == CurveModeKind.LiftedWrong) + return ShapeLifted(p1, cfg, notes, t0); + CurveKnots knots = cfg.Knots; CurveAnchors anchors = cfg.Anchors; GenerationScale scale = cfg.Scale; @@ -197,9 +207,128 @@ namespace IslaApocalypse.Tools // property that makes it an oracle. return new Pass2Result(mapSize, p1.Seed, height, classify, - curveOn: true, detailOn: detailOn, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, + curveOn: true, detailOn: detailOn, curveModeLabel: "staircase", variantLabel: cfg.VariantLabel, continuous: null, + knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax, elapsedMs: Time.GetTicksMsec() - t0, notes: notes); } + + /// + /// ⭐ THE CONTINUOUS MODE (chat2/02): the staircase's toe+red lowland preserved bit-for-bit, + /// everything above it replaced by one smooth monotone Fritsch–Carlson climb. + /// → for the shape and its refusals. + /// + /// ═══ ⚠ WHY THIS LOOP IS SO MUCH SIMPLER THAN THE STAIRCASE'S ═══ + /// + /// No bench/plateau/strength modulation fields — the anchors they modulated are dropped. + /// No shelf detail — the flat benches it existed to de-slab no longer exist above the + /// lowlands, and the preserved lowland never needed it. Micro-relief/roughness on the + /// continuous upper grade is DEFERRED: erosion is the real detail source, and painting + /// noise on the climb now would pre-judge what erosion should carve. So: one spline per + /// seed, one Apply per column, and the classify field untouched as always. + /// + private static Pass2Result ShapeContinuous(Pass1Result p1, TerrainGenConfig cfg, + List notes, ulong t0) + { + int mapSize = p1.MapSize; + CurveKnots knots = cfg.Knots; + CurveAnchors anchors = cfg.Anchors; + + // Same per-seed summit normalization as the staircase — the pass-1/pass-2 boundary + // 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); + + // Monotone by construction — and proven anyway, per seed, because "cannot fail" is + // exactly the claim worth a millisecond of checking. + notes.Add(curve.AssertStrictlyIncreasing()); + notes.Add($"[Shaping] continuous: {curve.DescribeControlPoints()}"); + + if (cfg.ShelfDetail) + notes.Add("[Shaping] ⚠ shelf detail requested but FORCED OFF in continuous mode — the flat " + + "benches it de-slabbed no longer exist, and detail on the climb is deferred to erosion."); + + var height = new float[mapSize, mapSize]; + var classify = new float[mapSize, mapSize]; + float hMin = float.MaxValue, hMax = float.MinValue; + + for (int x = 0; x < mapSize; x++) + { + for (int y = 0; y < mapSize; y++) + { + float raw = p1.Height[x, y]; + classify[x, y] = raw; // ⭐ the oracle field, as always + + float h = curve.Apply(raw); // sea identity + lowland delegation + // + climb + tail, all inside + if (h < hMin) hMin = h; + if (h > hMax) hMax = h; + height[x, y] = h; + } + } + + return new Pass2Result(mapSize, p1.Seed, height, classify, + curveOn: true, detailOn: false, curveModeLabel: "continuous", variantLabel: cfg.VariantLabel, continuous: curve, + knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, + edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax, + elapsedMs: Time.GetTicksMsec() - t0, notes: notes); + } + + /// + /// ⚠⚠ THE DELIBERATELY-WRONG BOOKEND (chat2/02): an even linear remap of ALL land — + /// [SEA, spikeMax] → [SEA, PEAK_CAP]. Every land column above sea is lifted by the + /// same ×~1.5 factor, which hoists the entire island off its shoreline and destroys the + /// broad low plain the developer likes. + /// + /// It exists so the preserved-lowland variants can be seen AGAINST the mistake — the batch's + /// contrast anchor, per the rev-3 task. It is not a candidate, it takes no knobs, and its + /// oracle-(d) FAILURE is expected and reported as confirmation, not as a bug. + /// + private static Pass2Result ShapeLifted(Pass1Result p1, TerrainGenConfig cfg, + List notes, ulong t0) + { + int mapSize = p1.MapSize; + CurveKnots knots = cfg.Knots; + CurveAnchors anchors = cfg.Anchors; + + float spikeMax = HeightCurve.EffectiveSpikeMax(p1.HMaxSeed, knots, anchors); + float scale = (anchors.PeakCap - anchors.Sea) / (spikeMax - anchors.Sea); + + notes.Add($"[Shaping] ⚠⚠ LIFTED_WRONG: even remap [sea, {spikeMax:F4}] → [sea, {anchors.PeakCap:F4}] " + + $"(×{scale:F3} on every land height) — the WRONG direction, kept as the contrast bookend."); + + var height = new float[mapSize, mapSize]; + var classify = new float[mapSize, mapSize]; + float hMin = float.MaxValue, hMax = float.MinValue; + + for (int x = 0; x < mapSize; x++) + { + for (int y = 0; y < mapSize; y++) + { + float raw = p1.Height[x, y]; + classify[x, y] = raw; + + float h; + if (raw <= anchors.Sea) h = raw; // even the wrong direction keeps the + // coastline — sea identity is not optional + else if (raw >= spikeMax) h = anchors.PeakCap + (raw - spikeMax) * anchors.TailSlope; + else h = anchors.Sea + (raw - anchors.Sea) * scale; + + if (h < hMin) hMin = h; + if (h > hMax) hMax = h; + height[x, y] = h; + } + } + + return new Pass2Result(mapSize, p1.Seed, height, classify, + curveOn: true, detailOn: false, curveModeLabel: "lifted_WRONG", variantLabel: cfg.VariantLabel, continuous: null, + knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, + edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax, + elapsedMs: Time.GetTicksMsec() - t0, notes: notes); + } } } diff --git a/Tools/Scripts/ShapingOracle.cs b/Tools/Scripts/ShapingOracle.cs index d626d28..e7a9527 100644 --- a/Tools/Scripts/ShapingOracle.cs +++ b/Tools/Scripts/ShapingOracle.cs @@ -224,6 +224,131 @@ namespace IslaApocalypse.Tools return shares; } + /// + /// A bit-regression against any `.f32` dump, with the caller naming the check — the + /// chat2/02 generalization of , used to hold the + /// staircase mode against task 01's own batch output. The same rule applies: a missing dump + /// is INCONCLUSIVE and counted as a failure, never as a pass. + /// + public static Check DumpRegression(string id, string name, float[,] current, float[,] dump, + int mapSize, string dumpPath) + { + var c = new Check { Id = id, Name = name }; + + if (dump == null) + { + c.Passed = false; + c.Detail = $"INCONCLUSIVE — no readable dump at {dumpPath} for this seed/size. Not counted as a pass."; + return c; + } + + var (differing, firstDiff) = CompareBitwise(dump, current, mapSize); + c.Passed = differing == 0; + c.Detail = c.Passed + ? $"bit-identical to {dumpPath} over {(long)mapSize * mapSize:N0} cells" + : $"{differing:N0} cells differ from {dumpPath} — {firstDiff}"; + return c; + } + + /// + /// (d) ⭐ LOWLANDS PRESERVED — the rev-3 task's load-bearing check. For every cell whose RAW + /// height is at or below K2 (the toe+red band the developer likes), the continuous mode's + /// output must be BIT-IDENTICAL to the staircase's. This mechanically enforces "do not lift + /// the lowlands": the low pile cannot move if its every column is the same float. + /// + /// The identity is by construction — the continuous curve DELEGATES to the staircase's own + /// toe+red code path below K2 — and this check is what keeps that construction honest + /// against refactoring, float re-association, or a ceiling knob bug. + /// + /// ⚠ Run against the LIFTED_WRONG bookend this check is EXPECTED to fail — the caller + /// reports that failure as confirmation of the wrong direction, not as a defect. + /// + public static Check LowlandsPreserved(Pass1Result p1, Pass2Result staircase, Pass2Result variant) + { + var c = new Check { Id = "d", Name = $"lowlands (raw ≤ K2) bit-identical to staircase [{variant.VariantLabel}]" }; + + float k2 = staircase.Knots.K2; + long compared = 0, differing = 0; + string first = null; + + for (int x = 0; x < p1.MapSize; x++) + { + for (int y = 0; y < p1.MapSize; y++) + { + if (p1.Height[x, y] > k2) continue; + compared++; + int ba = BitConverter.SingleToInt32Bits(staircase.Height[x, y]); + int bb = BitConverter.SingleToInt32Bits(variant.Height[x, y]); + if (ba == bb) continue; + differing++; + first ??= $"first at [{x},{y}] raw {p1.Height[x, y]:G9}: " + + $"staircase {staircase.Height[x, y]:G9} vs {variant.Height[x, y]:G9}"; + } + } + + c.Passed = differing == 0; + c.Detail = c.Passed + ? $"bit-identical over all {compared:N0} lowland cells (raw ≤ K2 = {k2:F6})" + : $"{differing:N0} of {compared:N0} lowland cells differ — {first}"; + return c; + } + + /// + /// (e) UPPER CONTINUOUS — sample the climb's slope densely; no near-flat anywhere (a bench + /// reborn), no cliff below the summit onset (the summit itself may steepen — that is the + /// dramatic peak). ⚠ SOFT: this is an exploration batch, so a violation warns loudly and + /// lands in the report rather than failing the run — the tool decides the exit code. + /// + public static Check UpperClimbProfile(Pass2Result p2) + { + var c = new Check { Id = "e", Name = $"upper climb continuous — no flats, no low-mid cliffs [{p2.VariantLabel}]" }; + + if (p2.Continuous == null) + { + c.Passed = false; + c.Detail = "no continuous spline on this result — wrong mode handed in."; + return c; + } + + var (minN, minAt, maxN, maxAt, nearFlat, cliff) = p2.Continuous.SampleClimbSlopes(); + c.Passed = !nearFlat && !cliff; + c.Detail = $"slope (normalized, 1 = climb average): min {minN:F3} at raw {minAt:F4}" + + $"{(nearFlat ? " ⚠ NEAR-FLAT (a bench reborn)" : "")}, " + + $"max below onset {maxN:F3} at raw {maxAt:F4}" + + $"{(cliff ? " ⚠ CLIFF below the summit onset" : "")}"; + return c; + } + + /// + /// (f) SEA IDENTITY — per cell, not per count: a column is land in the variant exactly when + /// it is land with the curve off. Counting alone could hide two errors that cancel; this + /// cannot. The coastline is the one thing every mode, including the wrong one, must keep. + /// + public static Check SeaIdentity(Pass2Result off, Pass2Result variant, float seaLevel) + { + var c = new Check { Id = "f", Name = $"sea identity — per-cell landness unchanged [{variant.VariantLabel}]" }; + + long mismatches = 0; + string first = null; + for (int x = 0; x < off.MapSize; x++) + { + for (int y = 0; y < off.MapSize; y++) + { + bool a = off.Height[x, y] >= seaLevel; + bool b = variant.Height[x, y] >= seaLevel; + if (a == b) continue; + mismatches++; + first ??= $"first at [{x},{y}]: off {off.Height[x, y]:G9} vs {variant.Height[x, y]:G9}"; + } + } + + c.Passed = mismatches == 0; + c.Detail = c.Passed + ? $"per-cell landness identical over {(long)off.MapSize * off.MapSize:N0} cells" + : $"{mismatches:N0} cells changed sides of the waterline — {first}"; + return c; + } + /// 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 9e67d9c..855840f 100644 --- a/Tools/Scripts/TerrainGenConfig.cs +++ b/Tools/Scripts/TerrainGenConfig.cs @@ -2,6 +2,22 @@ using IslaApocalypse.Core; namespace IslaApocalypse.Tools { + /// + /// Which redistribution curve pass 2a applies (chat2/02). One seam, three occupants: + /// + /// Staircase ⭐ the faithful v5 port (task 01) — toe/red/riser/bench/riser/plateau/spike. + /// THE CONTROL. Bit-identical to task 01's output, always present in a batch. + /// Continuous ⭐ the rev-3 redesign — the staircase's toe+red lowland PRESERVED bit-for-bit, + /// everything above it replaced by one smooth monotone climb to the 420 m cap. + /// → Core/ContinuousCurve. + /// LiftedWrong ⚠⚠ DELIBERATELY THE WRONG DIRECTION — an even linear remap of ALL land onto + /// [SEA, PEAK_CAP], which lifts the entire island off its shoreline and destroys + /// the low plain the developer likes. It exists as a CONTRAST BOOKEND so the + /// preserved-lowland variants can be judged against the mistake, and for no other + /// purpose. Do not ship it, do not tune it, do not "fix" it. + /// + public enum CurveModeKind { Staircase, Continuous, LiftedWrong } + /// /// The generator's configuration, including the per-element ABLATION TOGGLES. /// @@ -98,6 +114,44 @@ namespace IslaApocalypse.Tools /// public bool Curve = true; + /// + /// ⭐ WHICH curve (chat2/02). → . + /// + /// Default CONTINUOUS per the rev-3 task — the exploration direction. Tools that exist to + /// reproduce the task-01 staircase (CurveBaselineTool) set Staircase EXPLICITLY, so the + /// default changing does not silently move a control batch. + /// + public CurveModeKind CurveMode = CurveModeKind.Continuous; + + // ---- the continuous climb's knobs — ALL act above the lowland ceiling only ---- + + /// + /// How high the preserved lowland holds before the climb takes over, in METRES of output + /// height above sea. Default 30 = RED_CEIL, the flood line — the exact top of the + /// staircase's toe+red band, so nothing at all is re-mapped below it. + /// + /// ⚠ Constrained to [30, 80] m by : below 30 would cut + /// the preserved band; at ~100 it could preserve a flat bench, the artifact this mode + /// exists to remove. Raising it extends the red band's gentle grade linearly before the + /// climb begins. THE PRIMARY VARIANT AXIS. + /// + public float LowlandCeilingM = 30f; + + /// + /// The shape of the climb's departure from the lowland, 0..1: how long it hugs the red + /// band's exit slope before steepening. Replaces the old ambiguous "bow". Acts only above + /// the ceiling; CANNOT touch the low band. + /// + 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. + /// + public float SummitDrama = 2.5f; + /// /// ⭐ 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