diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index 5762829..56bc07c 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -128,7 +128,7 @@ namespace IslaApocalypse.Core { writer.Write(c.Version); writer.Write(c.T1); writer.Write(c.T2); writer.Write(c.T3); writer.Write(c.T4); - writer.Write(c.HMaxCal); + writer.Write(c.SpikeMax); writer.Write(c.Sea); writer.Write(c.OrangeCeil); writer.Write(c.RedCeil); writer.Write(c.PlateauLo); writer.Write(c.PlateauHi); writer.Write(c.PeakCap); writer.Write(c.TailSlope); diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index cd5d861..a28342c 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -26,11 +26,12 @@ namespace IslaApocalypse.Core // Change this if your namespace is different public static string SeaLevelModel = "flat"; public static float SeaLevelValue = 0.15f; - // Height-redistribution curve (task 05, graduation M-7): "v1" applies the - // calibrated storm-ladder curve to above-sea terrain (see HeightCurve.cs); - // "off" is the raw legacy profile. Biome classification is curve-invariant - // by construction either way. Default: v1. - public static string TerrainCurve = "v1"; + // Height-redistribution curve (tasks 05/06, graduation M-7): "v2" applies the + // calibrated storm-ladder curve with the per-seed peak spike (HeightCurve.cs); + // "off" is the raw legacy profile. "v1" was dropped with the v2 recalibration + // (task-05 blueprints are regenerable). Biome classification is curve-invariant + // by construction either way. Default: v2. + public static string TerrainCurve = "v2"; public static void LoadConfig() { @@ -105,8 +106,10 @@ namespace IslaApocalypse.Core // Change this if your namespace is different if (data.ContainsKey("TerrainCurve")) { string curve = (string)data["TerrainCurve"]; - if (curve == "off" || curve == "v1") + if (curve == "off" || curve == "v2") TerrainCurve = curve; + else if (curve == "v1") + GD.PrintErr($"[ConfigManager] TerrainCurve 'v1' was retired by the v2 recalibration (task 06). Keeping '{TerrainCurve}' — use \"v2\" or \"off\"."); else GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); } diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 67749d5..c40ec40 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -68,7 +68,11 @@ namespace IslaApocalypse.Core public class TerrainCurveInfo { public ushort Version; - public float T1, T2, T3, T4, HMaxCal; // input knots + // Input knots. SpikeMax is the spike domain's top: under curve v1 it was the + // pooled calibration max (identical every seed); from v2 it is the SEED'S own + // effective raw maximum — blueprints are no longer reproducible from curve + // constants alone, which is exactly why it is recorded here. + public float T1, T2, T3, T4, SpikeMax; public float Sea, OrangeCeil, RedCeil, PlateauLo, PlateauHi, PeakCap, TailSlope; // output bands } @@ -390,7 +394,7 @@ namespace IslaApocalypse.Core c.Version = reader.ReadUInt16(); c.T1 = reader.ReadSingle(); c.T2 = reader.ReadSingle(); c.T3 = reader.ReadSingle(); c.T4 = reader.ReadSingle(); - c.HMaxCal = reader.ReadSingle(); + c.SpikeMax = reader.ReadSingle(); c.Sea = reader.ReadSingle(); c.OrangeCeil = reader.ReadSingle(); c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle(); c.PlateauHi = reader.ReadSingle(); c.PeakCap = reader.ReadSingle(); diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs index c4dd534..bced8b8 100644 --- a/Tools/Scripts/HeightCurve.cs +++ b/Tools/Scripts/HeightCurve.cs @@ -1,48 +1,69 @@ using Godot; /// -/// The height-redistribution curve (terrain-water task 05, graduation M-7) — a pure, -/// static, monotonic piecewise map over raw blueprint heights (D-035: numbers in, -/// numbers out, no lifecycle). +/// The height-redistribution curve, v2 (terrain-water task 06) — a pure, static, +/// monotonic piecewise map over raw blueprint heights (D-035: numbers in, numbers +/// out; the per-seed spike maximum is an explicit PARAMETER, not hidden state). /// -/// OUTPUT bands are fixed by design (the storm ladder; developer-approved -/// 75 % orange coverage / plateau 50 m above sea / peaks 220 m above sea). -/// INPUT knots were calibrated ONCE from measured data — the pooled CDF of -/// above-sea land heights across batch 04's ten flat-sea seeds (340,618,126 -/// samples, 2026-08-07): P75 / P90 / P93 / P96 / max. The same knots apply to -/// every seed; per-seed band proportions vary a few points by design. +/// v2 changes (developer's task-05 hillshade-gate verdict; everything below the +/// plateau step is behaviorally byte-identical to v1): +/// - PER-SEED SPIKE NORMALIZATION: the spike's input domain runs from t4 to the +/// current seed's own raw pre-curve maximum (hMaxSeed), so every island's +/// tallest pixel reaches the ceiling — v1 mapped against the pooled +/// calibration max and mid-range seeds topped out at 110–175 m. +/// - STIFFER SPIKE: ease-in 0.1u + 0.9·u⁴ (was 0.2u + 0.8·u³) — a wall, not a ramp. +/// - PEAK CEILING 420 m above sea (was 220 m). /// -/// Shape, monotonic by construction (every segment's normalized slope is bounded -/// below by a positive constant) and asserted numerically at startup: +/// Lower knots/bands are v1's, calibrated 2026-08-07 from batch 04's ten flat-sea +/// heightmaps (pooled above-sea land CDF, 340,618,126 samples): P75/P90/P93/P96. +/// +/// Shape (strictly monotonic; every segment's normalized slope bounded below by a +/// positive constant; asserted numerically per generation against the EFFECTIVE +/// per-seed curve once hMaxSeed is known): /// h ≤ sea (0.15) identity — water and the below-sea world untouched /// sea → t1 smooth toe, ease-out blend (gentle rolling, never flat) /// t1 → t2 linear rise into the red band /// t2 → t3 smooth shoulder up to the plateau shelf /// t3 → t4 near-flat plateau step (small positive slope) -/// t4 → hmaxCal accelerating spike to the peak cap -/// h > hmaxCal linear tail (keeps strict monotonicity, no clamp) +/// t4 → spikeMax accelerating u⁴ spike to the 420 m peak cap (per-seed domain) +/// h > spikeMax linear tail (strict monotonicity, no clamp; reachable only +/// in the degenerate near-flat guard case) /// public static class HeightCurve { - public const ushort VERSION = 1; + public const ushort VERSION = 2; - // Input knots — calibrated from batch 04 B-flat pooled land CDF (see report). + // Input knots — v1 calibration, unchanged (see class header). public const float T1 = 0.628736f; // P75 — orange coverage boundary public const float T2 = 0.819152f; // P90 public const float T3 = 0.879340f; // P93 public const float T4 = 0.962922f; // P96 - public const float HMAX_CAL = 1.452219f; // pooled max - // Output bands — the storm ladder. + // Output bands — the storm ladder. Lower anchors unchanged from v1. public const float SEA = 0.15f; public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling public const float RED_CEIL = 0.27f; // biblical ceiling public const float PLATEAU_LO = SEA + 50f / 251f; // ≈ 0.34924 (50 m above sea) public const float PLATEAU_HI = PLATEAU_LO + 0.02f; // ≈ 0.36924 (~5 m step relief) - public const float PEAK_CAP = SEA + 220f / 251f; // ≈ 1.02649 (220 m above sea) - public const float TAIL_SLOPE = 0.25f; // above HMAX_CAL + public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 (420 m above sea; v1: 220 m) + public const float TAIL_SLOPE = 0.25f; // above spikeMax (degenerate guard only) - public static float Apply(float h) + // Degenerate/near-flat guard: the spike domain is [T4, max(hMaxSeed, T4 + SPIKE_MIN_SPAN)], + // so a pathological seed whose raw max sits at or below t4 still yields a positive, + // monotonic domain (its cap is then simply never reached; heights above spikeMax — none in + // practice — would ride the tail). + public const float SPIKE_MIN_SPAN = 0.01f; + + /// The effective spike-domain top for a seed's raw maximum, guard applied. + public static float EffectiveSpikeMax(float hMaxSeed) + { + return Mathf.Max(hMaxSeed, T4 + SPIKE_MIN_SPAN); + } + + /// Raw pre-curve height. + /// The seed's raw pre-curve maximum (post noise/falloff/Trench/spine, + /// pre-carve) — the same field the curve consumes. Makes the map seed-dependent (v2). + public static float Apply(float h, float hMaxSeed) { if (h <= SEA) return h; @@ -69,44 +90,46 @@ public static class HeightCurve u = (h - T3) / (T4 - T3); return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope } - if (h < HMAX_CAL) + float spikeMax = EffectiveSpikeMax(hMaxSeed); + if (h < spikeMax) { - u = (h - T4) / (HMAX_CAL - T4); - s = 0.2f * u + 0.8f * (u * u * u); // ease-in spike, slope ≥ 0.2 + u = (h - T4) / (spikeMax - T4); + s = 0.1f * u + 0.9f * (u * u * u * u); // ease-in u⁴ wall, slope ≥ 0.1 return PLATEAU_HI + s * (PEAK_CAP - PLATEAU_HI); } - return PEAK_CAP + (h - HMAX_CAL) * TAIL_SLOPE; + return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE; } /// - /// Numeric strict-monotonicity check across the whole plausible domain. - /// Cheap (runs once at generator start); a violation is a build bug, not a - /// data condition — fail loudly and refuse to generate. + /// Numeric strict-monotonicity check of the EFFECTIVE per-seed curve — call once + /// per generation after hMaxSeed is known, before the curve pass. A violation is + /// a build bug, not a data condition — fail loudly and refuse to generate. /// - public static void AssertMonotonic() + public static void AssertMonotonic(float hMaxSeed) { float prevH = -7f; - float prev = Apply(prevH); + float prev = Apply(prevH, hMaxSeed); - // Successive double samples can round to the SAME float32 (the two loops - // meeting at 0.10 did exactly that and tripped the strict check against - // itself) — so only strictly increasing float samples are compared. + // Successive double samples can round to the SAME float32 — only strictly + // increasing float samples are compared (task-05 incident fix, kept). void Check(double hd) { float h = (float)hd; if (h <= prevH) return; - float v = Apply(h); + float v = Apply(h, hMaxSeed); if (v <= prev) throw new System.InvalidOperationException( - $"[HeightCurve] MONOTONICITY VIOLATION at h={h}: {v} <= {prev}. Refusing to generate."); + $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}): {v} <= {prev}. Refusing to generate."); prev = v; prevH = h; } - // Coarse below the identity region, fine through every knot, out past the tail. + // Coarse below the identity region, fine through every knot, out past the + // per-seed spike top and the tail. + double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5); for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) Check(h); - for (double h = 0.10; h <= 2.0; h += 0.0001) Check(h); - for (double h = 2.05; h <= 8.0; h += 0.05) Check(h); - GD.Print("[HeightCurve] Monotonicity assertion passed (v" + VERSION + ")."); + for (double h = 0.10; h <= top; h += 0.0001) Check(h); + for (double h = top + 0.05; h <= top + 6.0; h += 0.05) Check(h); + GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6})."); } } diff --git a/Tools/Scripts/HeightCurve.cs.uid b/Tools/Scripts/HeightCurve.cs.uid new file mode 100644 index 0000000..c9c7559 --- /dev/null +++ b/Tools/Scripts/HeightCurve.cs.uid @@ -0,0 +1 @@ +uid://ljv7x1fwm3os diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index d62a542..5206a53 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -37,6 +37,11 @@ public partial class MapGenerator : TextureRect private float[,] _heightMapClassify; private bool _curveOn; + // The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine, + // pre-carve) — the v2 curve's per-seed spike normalizer. Computed in + // GenerateTopography pass 1; recorded in TCRV (effective, guard applied). + private float _hMaxSeed = float.MinValue; + private float[,] _tempMap; private Biome[,] _biomeMap; private bool[,] _isTrueOcean; @@ -70,8 +75,9 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; - _curveOn = ConfigManager.TerrainCurve == "v1"; - if (_curveOn) HeightCurve.AssertMonotonic(); + _curveOn = ConfigManager.TerrainCurve == "v2"; + // (The monotonicity assertion now runs inside GenerateTopography, against the + // effective per-seed curve, once hMaxSeed is known.) _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; _tempMap = new float[MapSize, MapSize]; _biomeMap = new Biome[MapSize, MapSize]; @@ -254,7 +260,7 @@ public partial class MapGenerator : TextureRect { Version = HeightCurve.VERSION, T1 = HeightCurve.T1, T2 = HeightCurve.T2, T3 = HeightCurve.T3, T4 = HeightCurve.T4, - HMaxCal = HeightCurve.HMAX_CAL, + SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed (v2) Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, PlateauLo = HeightCurve.PLATEAU_LO, PlateauHi = HeightCurve.PLATEAU_HI, PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE @@ -401,28 +407,45 @@ public partial class MapGenerator : TextureRect } // --- 4. COMBINE HEIGHT --- + // PASS 1 stores the RAW pre-curve height and tracks the seed maximum; + // the curve (which is per-seed in v2 — its spike normalizes against + // hMaxSeed) and the crater carve are applied in PASS 2 below. float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f; float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength); + if (finalH > _hMaxSeed) _hMaxSeed = finalH; + _heightMap[x, y] = finalH; + } + } - // --- 4b. THE REDISTRIBUTION CURVE (task 05) --- - // Applied AFTER noise + falloff + Trench, BEFORE the crater carve, so - // the carve cuts into curved terrain and the rim/bowl shape is - // untouched by the curve. Identity at and below sea + this ordering - // preserve the Trench/ocean-border guarantee and the crater by - // construction. classifyH stays uncurved — see _heightMapClassify. - float classifyH = finalH; - float curvedH = _curveOn ? HeightCurve.Apply(finalH) : finalH; + // The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak + // band, so the monotonicity assertion must run against the EFFECTIVE per-seed + // curve — after hMaxSeed is known, before any pixel is curved. + if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed); + + // --- PASS 2: curve (task 05/06) + crater carve --- + // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so + // the carve cuts into curved terrain and the rim/bowl shape is untouched by + // the curve. Identity at and below sea + this ordering preserve the + // Trench/ocean-border guarantee and the crater by construction. classifyH + // stays uncurved — see _heightMapClassify; hMaxSeed never touches it. + float physicalCraterRadius = _impactRadius * 0.80f; + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { + float raw = _heightMap[x, y]; + float classifyH = raw; + float curvedH = _curveOn ? HeightCurve.Apply(raw, _hMaxSeed) : raw; // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); // We only carve the physical hole at 80% of the radius to guarantee a landbridge! - float physicalCraterRadius = _impactRadius * 0.80f; if (distToCrater < physicalCraterRadius) { float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); // Dialed back to -0.15f as per your excellent instinct! - float carveTarget = GetSeaLevel(temperature) - 0.15f; + float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); } diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index f547633..7b2393a 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -200,8 +200,8 @@ public partial class RoundTripHarness : Node } var ca = a.TerrainCurve; var cb = b.TerrainCurve; bool same = ca.Version == cb.Version; - float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.HMaxCal, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope }; - float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.HMaxCal, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope }; + float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.SpikeMax, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope }; + float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.SpikeMax, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope }; for (int i = 0; i < fa.Length; i++) if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false; if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; }