From ae7431ac870a3114e3608b56c3067bccb89dac8b Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 02:25:49 -0400 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20curve=20v3=20=E2=80=94=20terraced?= =?UTF-8?q?=20ascent,=20two=20benches,=20white=20plateau=20at=20220=20m=20?= =?UTF-8?q?(terrain-water=20task=2007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the task-06 ground-test verdict (floor and 420 m ceiling frozen; v2's ascent read flat-then-wall — 87% of the vertical budget in the last 4% of input): the ascent is rebuilt as a terraced climb. Knots recalibrated from the same pooled batch-04 land CDF at P59/72/82/87/95/98 (K=0.509179/0.604081/0.698485/0.767213/0.930304/ 1.050720), pooled land fractions exactly 59/13/10/5/8/3/2 (orange/red/ foothill-riser/bench/mid-riser/plateau/spike). Segments: frozen toe and rise (storm anchors), smoothstep foothill riser to a near-flat 100 m bench, smoothstep mid riser to the near-flat white plateau at 220 m (relocated from 50 m — mountain towns/snow belong there), per-seed-normalized u4 summit spike to 420 m (both retained from v2), linear tail. Per-seed effective-curve monotonicity assertion retained. TerrainCurve gate: "off"|"v3" (default v3); v1/v2 retired with a loud config error (old blueprints regenerable). TCRV: knot slots carry K1..K4 (K5/K6 are version constants), PlateauLo/Hi carry the two bench anchors; version byte selects the semantics. Co-Authored-By: Claude Fable 5 --- Core/Scripts/ConfigManager.cs | 18 ++--- Tools/Scripts/HeightCurve.cs | 147 ++++++++++++++++++---------------- Tools/Scripts/MapGenerator.cs | 20 +++-- 3 files changed, 102 insertions(+), 83 deletions(-) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index a28342c..d03b34b 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -26,12 +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 (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"; + // Height-redistribution curve (tasks 05/06/07, graduation M-7): "v3" applies + // the terraced-ascent curve with the per-seed peak spike (HeightCurve.cs); + // "off" is the raw legacy profile. "v1"/"v2" were retired by their successor + // recalibrations (old blueprints are regenerable). Biome classification is + // curve-invariant by construction either way. Default: v3. + public static string TerrainCurve = "v3"; public static void LoadConfig() { @@ -106,10 +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 == "v2") + if (curve == "off" || curve == "v3") 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 if (curve == "v1" || curve == "v2") + GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v3, task 07). Keeping '{TerrainCurve}' — use \"v3\" or \"off\"."); else GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); } diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs index bced8b8..aacfaf3 100644 --- a/Tools/Scripts/HeightCurve.cs +++ b/Tools/Scripts/HeightCurve.cs @@ -1,117 +1,132 @@ using Godot; /// -/// 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). +/// The height-redistribution curve, v3 — the TERRACED ASCENT (terrain-water task 07). +/// Pure, static, monotonic piecewise map over raw blueprint heights (D-035: numbers +/// in, numbers out; the per-seed spike maximum is an explicit PARAMETER). /// -/// 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). +/// v3 (developer's task-06 ground-test verdict — floor and 420 m ceiling frozen, the +/// ascent between them rebuilt): v2 spent ~87 % of the vertical budget in the last +/// ~4 % of input, reading as flat-then-wall. v3 climbs in TERRACES — two benches, +/// three risers — and relocates the white mountain-town plateau from 50 m to 220 m: /// -/// Lower knots/bands are v1's, calibrated 2026-08-07 from batch 04's ten flat-sea -/// heightmaps (pooled above-sea land CDF, 340,618,126 samples): P75/P90/P93/P96. +/// h ≤ sea (0.15) identity — water and the below-sea world untouched +/// sea → K1 toe, ease-out — the lowlands (orange coverage 59 %) +/// K1 → K2 linear rise into the red band (storm anchors frozen) +/// K2 → K3 FOOTHILL RISER — smoothstep climb to the 100 m bench +/// K3 → K4 BENCH 1 — near-flat walkable foothill shelf (~2 m rise) +/// K4 → K5 MID RISER — the big middle climb to the white plateau +/// K5 → K6 WHITE PLATEAU — near-flat shelf at 220 m (mountain +/// towns / snow band, where it was always intended) +/// K6 → spikeMax SUMMIT SPIKE — per-seed normalized (hMaxSeed), stiff +/// ease-in kept from v2: spires off the plateau to 420 m +/// h > spikeMax linear tail (strict monotonicity, no clamp) /// -/// Shape (strictly monotonic; every segment's normalized slope bounded below by a -/// positive constant; asserted numerically per generation against the EFFECTIVE -/// per-seed curve once hMaxSeed is known): -/// h ≤ sea (0.15) identity — water and the below-sea world untouched -/// sea → t1 smooth toe, ease-out blend (gentle rolling, never flat) -/// t1 → t2 linear rise into the red band -/// t2 → t3 smooth shoulder up to the plateau shelf -/// t3 → t4 near-flat plateau step (small positive slope) -/// t4 → spikeMax accelerating u⁴ spike to the 420 m peak cap (per-seed domain) -/// h > spikeMax linear tail (strict monotonicity, no clamp; reachable only -/// in the degenerate near-flat guard case) +/// Input knots recalibrated 2026-08-08 from the SAME pooled batch-04 flat-sea land +/// CDF as v1/v2 (340,618,126 samples): P59/P72/P82/P87/P95/P98, giving pooled land +/// fractions orange 59 / red 13 / foothill-riser 10 / bench 5 / mid-riser 8 / +/// plateau 3 / spike 2 (exact by construction). Per-seed proportions vary — the +/// wrinkle variety. +/// +/// 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. /// public static class HeightCurve { - public const ushort VERSION = 2; + public const ushort VERSION = 3; - // 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 + // Input knots — v3 calibration (see class header). K1..K4 are serialized in + // TCRV's four knot slots; K5/K6 are version constants documented in + // BLUEPRINT_FORMAT.md (the TCRV version byte selects the anchor set). + public const float K1 = 0.509179f; // P59 — orange coverage boundary + public const float K2 = 0.604081f; // P72 — red top + public const float K3 = 0.698485f; // P82 — foothill riser top + public const float K4 = 0.767213f; // P87 — bench 1 top + public const float K5 = 0.930304f; // P95 — mid riser top + public const float K6 = 1.050720f; // P98 — plateau top / spike foot - // Output bands — the storm ladder. Lower anchors unchanged from v1. + // Output anchors — storm ladder (frozen) + the terraces. public const float SEA = 0.15f; - public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling - public const float RED_CEIL = 0.27f; // biblical ceiling - public const float PLATEAU_LO = SEA + 50f / 251f; // ≈ 0.34924 (50 m above sea) - public const float PLATEAU_HI = PLATEAU_LO + 0.02f; // ≈ 0.36924 (~5 m step relief) - public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 (420 m above sea; v1: 220 m) - public const float TAIL_SLOPE = 0.25f; // above spikeMax (degenerate guard only) + public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling (frozen) + public const float RED_CEIL = 0.27f; // biblical ceiling (frozen) + public const float FOOTHILL_BENCH = SEA + 100f / 251f; // ≈ 0.54841 (100 m above sea) + public const float BENCH_STEP = 0.008f; // ~2 m rise across each bench + public const float FOOTHILL_TOP = FOOTHILL_BENCH + BENCH_STEP; + public const float PLATEAU = SEA + 220f / 251f; // ≈ 1.02649 (220 m — the white plateau) + public const float PLATEAU_TOP = PLATEAU + BENCH_STEP; + public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 (frozen from v2) + public const float TAIL_SLOPE = 0.25f; - // 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). + // Degenerate/near-flat guard (unchanged from v2): spike domain floored at + // K6 + SPIKE_MIN_SPAN so it stays positive and monotonic on any input. 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); + return Mathf.Max(hMaxSeed, K6 + 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). + /// The seed's raw pre-curve maximum — per-seed spike normalizer. public static float Apply(float h, float hMaxSeed) { if (h <= SEA) return h; float u, s; - if (h < T1) + if (h < K1) { - u = (h - SEA) / (T1 - SEA); - s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out, slope ≥ 0.3 + u = (h - SEA) / (K1 - SEA); + s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out toe, slope ≥ 0.3 return SEA + s * (ORANGE_CEIL - SEA); } - if (h < T2) + if (h < K2) { - u = (h - T1) / (T2 - T1); - return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear + u = (h - K1) / (K2 - K1); + return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear rise } - if (h < T3) + if (h < K3) { - u = (h - T2) / (T3 - T2); - s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // smoothstep blend, slope ≥ 0.2 - return RED_CEIL + s * (PLATEAU_LO - RED_CEIL); + u = (h - K2) / (K3 - K2); + s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // foothill riser, slope ≥ 0.2 + return RED_CEIL + s * (FOOTHILL_BENCH - RED_CEIL); } - if (h < T4) + if (h < K4) { - u = (h - T3) / (T4 - T3); - return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope + u = (h - K3) / (K4 - K3); + return FOOTHILL_BENCH + u * BENCH_STEP; // bench 1, near-flat + } + if (h < K5) + { + u = (h - K4) / (K5 - K4); + s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // mid riser, slope ≥ 0.2 + return FOOTHILL_TOP + s * (PLATEAU - FOOTHILL_TOP); + } + if (h < K6) + { + u = (h - K5) / (K6 - K5); + return PLATEAU + u * BENCH_STEP; // white plateau, near-flat } float spikeMax = EffectiveSpikeMax(hMaxSeed); if (h < spikeMax) { - u = (h - T4) / (spikeMax - T4); - s = 0.1f * u + 0.9f * (u * u * u * u); // ease-in u⁴ wall, slope ≥ 0.1 - return PLATEAU_HI + s * (PEAK_CAP - PLATEAU_HI); + u = (h - K6) / (spikeMax - K6); + s = 0.1f * u + 0.9f * (u * u * u * u); // summit spike (v2 shape kept), slope ≥ 0.1 + return PLATEAU_TOP + s * (PEAK_CAP - PLATEAU_TOP); } return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE; } /// /// 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. + /// per generation after hMaxSeed is known. Loud throw, refuses to generate. /// public static void AssertMonotonic(float hMaxSeed) { float prevH = -7f; float prev = Apply(prevH, hMaxSeed); - // Successive double samples can round to the SAME float32 — only strictly - // increasing float samples are compared (task-05 incident fix, kept). + // Only strictly increasing float32 samples are compared (task-05 incident fix). void Check(double hd) { float h = (float)hd; @@ -124,8 +139,6 @@ public static class HeightCurve prevH = h; } - // Coarse below the identity region, fine through every knot, out past the - // per-seed spike top and the tail. double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5); for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) Check(h); for (double h = 0.10; h <= top; h += 0.0001) Check(h); diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 5206a53..6547792 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -75,7 +75,7 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; - _curveOn = ConfigManager.TerrainCurve == "v2"; + _curveOn = ConfigManager.TerrainCurve == "v3"; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; @@ -256,13 +256,16 @@ public partial class MapGenerator : TextureRect TrailRoads = _trailPaths, WaterBodyIds = _waterBodyIds, WaterBodies = _waterBodies ?? new List(), + // TCRV under curve v3: the four knot slots carry K1..K4 (K5/K6 are version + // constants, documented in BLUEPRINT_FORMAT.md); PlateauLo/Hi carry the two + // bench anchors (foothill 100 m / white plateau 220 m). TerrainCurve = _curveOn ? new TerrainCurveInfo { Version = HeightCurve.VERSION, - T1 = HeightCurve.T1, T2 = HeightCurve.T2, T3 = HeightCurve.T3, T4 = HeightCurve.T4, - SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed (v2) + T1 = HeightCurve.K1, T2 = HeightCurve.K2, T3 = HeightCurve.K3, T4 = HeightCurve.K4, + SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, - PlateauLo = HeightCurve.PLATEAU_LO, PlateauHi = HeightCurve.PLATEAU_HI, + PlateauLo = HeightCurve.FOOTHILL_BENCH, PlateauHi = HeightCurve.PLATEAU, PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE } : null, FormatVersion = 2, @@ -800,7 +803,8 @@ public partial class MapGenerator : TextureRect Color shallowSea = new Color(0.25f, 0.45f, 0.65f); Color green = new Color(0.44f, 0.62f, 0.36f); // orange band terrain: lowland green Color tan = new Color(0.76f, 0.70f, 0.46f); // red band: tan - Color brown = new Color(0.55f, 0.41f, 0.28f); // shoulder + plateau: brown + Color brown = new Color(0.55f, 0.41f, 0.28f); // foothill riser + bench: brown + Color darkBrown = new Color(0.42f, 0.32f, 0.24f); // mid riser: darker brown Color white = new Color(0.97f, 0.97f, 0.98f); // peaks Color grey = new Color(0.72f, 0.72f, 0.70f); @@ -820,10 +824,12 @@ public partial class MapGenerator : TextureRect } else if (h < HeightCurve.ORANGE_CEIL) tint = green; else if (h < HeightCurve.RED_CEIL) tint = tan; - else if (h < HeightCurve.PLATEAU_HI) tint = brown; + else if (h < HeightCurve.FOOTHILL_TOP) tint = brown; // foothill riser + 100 m bench + else if (h < HeightCurve.PLATEAU) tint = darkBrown; // the mid riser else { - float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU_HI) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_HI), 0f, 1f); + // White plateau (220 m) through the summit spike to the 420 m cap. + float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU), 0f, 1f); tint = grey.Lerp(white, t2); } From a046486a585d0544e240e806af73940affdbc4bc Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 02:27:30 -0400 Subject: [PATCH 02/11] =?UTF-8?q?docs:=20curve=20v3=20=E2=80=94=20TCRV=20p?= =?UTF-8?q?er-version=20anchor=20semantics,=20TerrainCurve=20v3=20default?= =?UTF-8?q?=20(terrain-water=20task=2007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- Core/Scripts/BLUEPRINT_FORMAT.md | 15 ++++++++------- Tools/Scripts/README.md | 13 +++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md index 87eb253..91e8f7b 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -55,7 +55,7 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`. | `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. | | `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. | | `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. | -| `TCRV` / `0x56524354` | 50 B: `u16 curveVersion` · input knots `f32 t1..t4` · `f32 spikeMax` · output bands `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 plateauLo` · `f32 plateauHi` · `f32 peakCap` · `f32 tailSlope` | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v2"`); absent = raw legacy profile. **`spikeMax` semantics by version:** v1 = the pooled calibration max (identical every seed); **v2 = the SEED'S own effective raw pre-curve maximum** — the per-seed spike normalizer, which is why blueprints are no longer reproducible from curve constants alone and the value is recorded here. **Metadata only:** exported heights are already curved; nothing re-applies the map. The curve lives in `Tools/Scripts/HeightCurve.cs` (calibration provenance in its header + the task-05/06 reports). | +| `TCRV` / `0x56524354` | 50 B: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope` | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). **v3 (current)** — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, **benchLo = the 100 m foothill bench, benchHi = the 220 m white plateau**; bench step (+0.008) and segment shapes are version constants in `HeightCurve.cs`. Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. | | `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. | | `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. | | `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q − 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. | @@ -106,12 +106,13 @@ body). which model produced it (the `WBTB` levels are the observable). - **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the road-section table note). -- **`TerrainCurve`** (`"v2"` | `"off"`, default `"v2"`; `"v1"` retired by the task-06 - recalibration and rejected loudly) — whether the height-redistribution curve (storm ladder: - 75 % orange coverage, 50 m plateau, **per-seed-normalized 420 m peaks**) shapes `HGTS`. - Biome/water classification is curve-invariant by construction (it classifies the retained - uncurved heights); town positions and everything 3D follow the curved terrain. When on, - `TCRV` records the effective parameters including the per-seed `spikeMax`. +- **`TerrainCurve`** (`"v3"` | `"off"`, default `"v3"`; `"v1"`/`"v2"` retired by successor + recalibrations and rejected loudly) — whether the height-redistribution curve (the terraced + ascent: 59 % orange lowlands, 100 m foothill bench, 220 m white plateau, per-seed-normalized + 420 m summit spires) shapes `HGTS`. Biome/water classification is curve-invariant by + construction (it classifies the retained uncurved heights); town positions and everything 3D + follow the curved terrain. When on, `TCRV` records the effective parameters including the + per-seed `spikeMax`. ### Deliberately NOT a section: basins (`BSIN`) diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 0fb6401..4ecd533 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -11,12 +11,13 @@ Generates the entire 2D blueprint. Roughly in order: 2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor` (`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile. - When `TerrainCurve: "v2"` (the default), the calibrated height-redistribution curve - (`HeightCurve.cs` — flat farmable lowlands, 50 m plateau shelf, and a per-seed-normalized - spike to the 420 m peak cap: every island's tallest point reaches the ceiling) reshapes - above-sea terrain after noise/falloff/Trench and before the crater carve; biome - classification reads a retained uncurved map, so biomes are identical either way. Drops the - `0_height` hillshade snapshot (hypsometric bands × NW hillshade) in both modes. + When `TerrainCurve: "v3"` (the default), the calibrated height-redistribution curve + (`HeightCurve.cs` — the terraced ascent: flat farmable lowlands, a walkable 100 m foothill + bench, the white mountain-town plateau at 220 m, and a per-seed-normalized summit spike to + the 420 m cap: every island's tallest point reaches the ceiling) reshapes above-sea terrain + after noise/falloff/Trench and before the crater carve; biome classification reads a + retained uncurved map, so biomes are identical either way. Drops the `0_height` hillshade + snapshot (hypsometric bands × NW hillshade) in both modes. 3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar — the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass. From 4d2e024b2f2deeaabb6a6d34876fa5641f901a22 Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 03:30:27 -0400 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20curve=20v4=20=E2=80=94=20spatiall?= =?UTF-8?q?y=20modulated=20shelves=20(terrain-water=20task=2008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the task-07 gate: the terraces work but uniform anchors put a flat ring at exactly 100 m and 220 m on every mountain. v4 keeps v3's structure (frozen toe/rise, three risers, per-seed u4 spike to 420 m) and turns the shelf anchors into smooth spatial fields: bench 100+-12 m and plateau 220+-20 m via two decorrelated very-low-freq Simplex fields (~3 undulations per island width), plus a strength field (~5/island) blending each shelf's output span between ~2 m (pronounced flat) and ~25 m (barely a hint) — the optional strength modulation shipped, ordering-safe by construction (worst-case bench top 0.6962 < plateau min 0.9468). Field seeds derive from the RESOLVED noise seed + fixed offsets (7101/7207/7303) — no config knob. Apply is per-column with all modulated values as pure parameters (D-035); the classify path never sees them. Monotonicity assertion now sweeps all 8 modulation-extreme corners x per-seed spikeMax. TerrainCurve gate "off"|"v4" (default v4); v1-v3 retired loudly. TCRV extended (+36 B when version>=4: amplitudes, span range, frequencies, seed offsets — length-framed section makes the layout change safe; parser dispatches on the curve version byte; harness compares the extension). Note: MapGenerator.cs also carries the developer's editor whitespace normalization (spaces->tabs, git diff -w empty before this change). Co-Authored-By: Claude Fable 5 --- Core/Scripts/BlueprintWriter.cs | 10 ++ Core/Scripts/ConfigManager.cs | 18 +-- Core/Scripts/MapDataParser.cs | 17 +++ Tools/Scripts/HeightCurve.cs | 193 ++++++++++++++----------- Tools/Scripts/MapGenerator.cs | 229 ++++++++++++++++++------------ Tools/Scripts/RoundTripHarness.cs | 8 +- 6 files changed, 292 insertions(+), 183 deletions(-) diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index 56bc07c..4ed218d 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -132,6 +132,16 @@ namespace IslaApocalypse.Core 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); + + // v4+ extension: shelf-modulation parameters. + if (c.Version >= 4) + { + writer.Write(c.BenchAmp); writer.Write(c.PlateauAmp); + writer.Write(c.ShelfSpanMin); writer.Write(c.ShelfSpanMax); + writer.Write(c.ElevFreqIslands); writer.Write(c.StrengthFreqIslands); + writer.Write(c.BenchSeedOffset); writer.Write(c.PlateauSeedOffset); + writer.Write(c.StrengthSeedOffset); + } } private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index d03b34b..6030555 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -26,12 +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 (tasks 05/06/07, graduation M-7): "v3" applies - // the terraced-ascent curve with the per-seed peak spike (HeightCurve.cs); - // "off" is the raw legacy profile. "v1"/"v2" were retired by their successor - // recalibrations (old blueprints are regenerable). Biome classification is - // curve-invariant by construction either way. Default: v3. - public static string TerrainCurve = "v3"; + // Height-redistribution curve (tasks 05–08, graduation M-7): "v4" applies the + // terraced-ascent curve with spatially modulated shelves and the per-seed peak + // spike (HeightCurve.cs); "off" is the raw legacy profile. "v1"–"v3" were + // retired by successor recalibrations (old blueprints are regenerable). Biome + // classification is curve-invariant by construction either way. Default: v4. + public static string TerrainCurve = "v4"; public static void LoadConfig() { @@ -106,10 +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 == "v3") + if (curve == "off" || curve == "v4") TerrainCurve = curve; - else if (curve == "v1" || curve == "v2") - GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v3, task 07). Keeping '{TerrainCurve}' — use \"v3\" or \"off\"."); + else if (curve == "v1" || curve == "v2" || curve == "v3") + GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v4, task 08). Keeping '{TerrainCurve}' — use \"v4\" or \"off\"."); else GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); } diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index c40ec40..00e03a6 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -74,6 +74,12 @@ namespace IslaApocalypse.Core // 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 + + // v4 extension — shelf-modulation parameters (record layout versioned by the + // curve Version byte; zero-valued when parsing an older record). + public float BenchAmp, PlateauAmp, ShelfSpanMin, ShelfSpanMax; + public float ElevFreqIslands, StrengthFreqIslands; + public int BenchSeedOffset, PlateauSeedOffset, StrengthSeedOffset; } public class WorldBlueprint @@ -399,6 +405,17 @@ namespace IslaApocalypse.Core c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle(); c.PlateauHi = reader.ReadSingle(); c.PeakCap = reader.ReadSingle(); c.TailSlope = reader.ReadSingle(); + + // v4+ extension: shelf-modulation parameters (older records simply end here — + // the section is length-framed, so the version byte is the dispatcher). + if (c.Version >= 4) + { + c.BenchAmp = reader.ReadSingle(); c.PlateauAmp = reader.ReadSingle(); + c.ShelfSpanMin = reader.ReadSingle(); c.ShelfSpanMax = reader.ReadSingle(); + c.ElevFreqIslands = reader.ReadSingle(); c.StrengthFreqIslands = reader.ReadSingle(); + c.BenchSeedOffset = reader.ReadInt32(); c.PlateauSeedOffset = reader.ReadInt32(); + c.StrengthSeedOffset = reader.ReadInt32(); + } blueprint.TerrainCurve = c; return true; } diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs index aacfaf3..fd6f1ed 100644 --- a/Tools/Scripts/HeightCurve.cs +++ b/Tools/Scripts/HeightCurve.cs @@ -1,75 +1,89 @@ using Godot; /// -/// The height-redistribution curve, v3 — the TERRACED ASCENT (terrain-water task 07). -/// Pure, static, monotonic piecewise map over raw blueprint heights (D-035: numbers -/// in, numbers out; the per-seed spike maximum is an explicit PARAMETER). +/// The height-redistribution curve, v4 — SPATIALLY MODULATED SHELVES (terrain-water +/// task 08). Pure, static, monotonic piecewise map; every per-column input is an +/// explicit PARAMETER (D-035): the seed spike max and the four modulated shelf +/// values. The generator samples the modulation fields; this class never touches +/// noise. /// -/// v3 (developer's task-06 ground-test verdict — floor and 420 m ceiling frozen, the -/// ascent between them rebuilt): v2 spent ~87 % of the vertical budget in the last -/// ~4 % of input, reading as flat-then-wall. v3 climbs in TERRACES — two benches, -/// three risers — and relocates the white mountain-town plateau from 50 m to 220 m: +/// v4 (developer's task-07 gate finding: the terraces work, but uniform anchors put +/// a flat ring at exactly 100 m and exactly 220 m on every mountain — the bathtub +/// rings): the bench and plateau OUTPUT anchors become smooth spatial fields — +/// benchLo = 100 m ± 12 m and plateauLo = 220 m ± 20 m via two decorrelated +/// very-low-frequency noise fields — and a third field modulates SHELF STRENGTH, +/// blending each shelf between "pronounced flat" (output span ~2 m) and "barely a +/// hint" (~25 m of gentle slope), so not every flank at shelf height develops the +/// full terrace. Shelves stay locally flat; the two magic altitudes stop existing. /// -/// h ≤ sea (0.15) identity — water and the below-sea world untouched -/// sea → K1 toe, ease-out — the lowlands (orange coverage 59 %) -/// K1 → K2 linear rise into the red band (storm anchors frozen) -/// K2 → K3 FOOTHILL RISER — smoothstep climb to the 100 m bench -/// K3 → K4 BENCH 1 — near-flat walkable foothill shelf (~2 m rise) -/// K4 → K5 MID RISER — the big middle climb to the white plateau -/// K5 → K6 WHITE PLATEAU — near-flat shelf at 220 m (mountain -/// towns / snow band, where it was always intended) -/// K6 → spikeMax SUMMIT SPIKE — per-seed normalized (hMaxSeed), stiff -/// ease-in kept from v2: spires off the plateau to 420 m -/// h > spikeMax linear tail (strict monotonicity, no clamp) +/// Structure otherwise v3's, unchanged: identity ≤ sea, frozen toe/rise (storm +/// ladder), foothill riser → bench → mid riser → plateau → per-seed-normalized +/// stiff spike (u⁴, 420 m cap) → tail. Input knots are v3's calibration. /// -/// Input knots recalibrated 2026-08-08 from the SAME pooled batch-04 flat-sea land -/// CDF as v1/v2 (340,618,126 samples): P59/P72/P82/P87/P95/P98, giving pooled land -/// fractions orange 59 / red 13 / foothill-riser 10 / bench 5 / mid-riser 8 / -/// plateau 3 / spike 2 (exact by construction). Per-seed proportions vary — the -/// wrinkle variety. -/// -/// 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. +/// ORDERING SAFETY BY CONSTRUCTION (asserted): with the amplitudes below, at every +/// column: red ceiling 0.27 < benchLo−… (bench min 0.5006), bench top max 0.6962 < +/// plateauLo min 0.9468, plateau top max 1.2062 < peak cap 1.8287. The numeric +/// assertion additionally sweeps all 8 modulation-extreme corners per generation. /// public static class HeightCurve { - public const ushort VERSION = 3; + public const ushort VERSION = 4; - // Input knots — v3 calibration (see class header). K1..K4 are serialized in - // TCRV's four knot slots; K5/K6 are version constants documented in - // BLUEPRINT_FORMAT.md (the TCRV version byte selects the anchor set). - public const float K1 = 0.509179f; // P59 — orange coverage boundary - public const float K2 = 0.604081f; // P72 — red top - public const float K3 = 0.698485f; // P82 — foothill riser top - public const float K4 = 0.767213f; // P87 — bench 1 top - public const float K5 = 0.930304f; // P95 — mid riser top - public const float K6 = 1.050720f; // P98 — plateau top / spike foot + // Input knots — v3 calibration (pooled batch-04 land CDF, P59/72/82/87/95/98). + public const float K1 = 0.509179f; + public const float K2 = 0.604081f; + public const float K3 = 0.698485f; + public const float K4 = 0.767213f; + public const float K5 = 0.930304f; + public const float K6 = 1.050720f; - // Output anchors — storm ladder (frozen) + the terraces. + // Fixed output anchors — storm ladder + ceiling (frozen). public const float SEA = 0.15f; - public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling (frozen) - public const float RED_CEIL = 0.27f; // biblical ceiling (frozen) - public const float FOOTHILL_BENCH = SEA + 100f / 251f; // ≈ 0.54841 (100 m above sea) - public const float BENCH_STEP = 0.008f; // ~2 m rise across each bench - public const float FOOTHILL_TOP = FOOTHILL_BENCH + BENCH_STEP; - public const float PLATEAU = SEA + 220f / 251f; // ≈ 1.02649 (220 m — the white plateau) - public const float PLATEAU_TOP = PLATEAU + BENCH_STEP; - public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 (frozen from v2) + public const float ORANGE_CEIL = 0.206f; + public const float RED_CEIL = 0.27f; + public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 public const float TAIL_SLOPE = 0.25f; - - // Degenerate/near-flat guard (unchanged from v2): spike domain floored at - // K6 + SPIKE_MIN_SPAN so it stays positive and monotonic on any input. public const float SPIKE_MIN_SPAN = 0.01f; + // Modulated shelf anchors: base ± amplitude (raw units; 251 m per unit). + public const float BENCH_BASE = SEA + 100f / 251f; // ≈ 0.54841 (100 m) + public const float BENCH_AMP = 12f / 251f; // ± 12 m + public const float PLATEAU_BASE = SEA + 220f / 251f; // ≈ 1.02649 (220 m) + public const float PLATEAU_AMP = 20f / 251f; // ± 20 m + + // Shelf strength: output span of each shelf segment, blended by the strength + // field. Strong (t=1) → SPAN_MIN (~2 m, pronounced flat). Weak (t=0) → + // SPAN_MAX (~25 m, barely a hint of a shelf). + public const float SHELF_SPAN_MIN = 0.008f; + public const float SHELF_SPAN_MAX = 0.10f; + + // Modulation-field derivation (generator-side, recorded in TCRV): field seed = + // RESOLVED WorldSeed + offset; frequency = (periods per island width) / MapSize. + public const int BENCH_SEED_OFFSET = 7101; + public const int PLATEAU_SEED_OFFSET = 7207; + public const int STRENGTH_SEED_OFFSET = 7303; + public const float ELEV_FREQ_ISLANDS = 3.0f; // ~3 undulations across the island + public const float STRENGTH_FREQ_ISLANDS = 5.0f; // finer patchiness for shelf strength + public static float EffectiveSpikeMax(float hMaxSeed) { return Mathf.Max(hMaxSeed, K6 + SPIKE_MIN_SPAN); } + /// Shelf output span for a strength sample t ∈ [0,1]. + public static float ShelfSpan(float strength01) + { + return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f)); + } + /// Raw pre-curve height. - /// The seed's raw pre-curve maximum — per-seed spike normalizer. - public static float Apply(float h, float hMaxSeed) + /// Seed's raw pre-curve maximum (per-seed spike normalizer). + /// This column's bench anchor (BENCH_BASE ± BENCH_AMP). + /// This column's bench output span (ShelfSpan of the strength field). + /// This column's plateau anchor (PLATEAU_BASE ± PLATEAU_AMP). + /// This column's plateau output span. + public static float Apply(float h, float hMaxSeed, + float benchLo, float benchSpan, float plateauLo, float plateauSpan) { if (h <= SEA) return h; @@ -77,72 +91,89 @@ public static class HeightCurve if (h < K1) { u = (h - SEA) / (K1 - SEA); - s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out toe, slope ≥ 0.3 + s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe return SEA + s * (ORANGE_CEIL - SEA); } if (h < K2) { u = (h - K1) / (K2 - K1); - return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear rise + return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise } if (h < K3) { u = (h - K2) / (K3 - K2); - s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // foothill riser, slope ≥ 0.2 - return RED_CEIL + s * (FOOTHILL_BENCH - RED_CEIL); + s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // foothill riser + return RED_CEIL + s * (benchLo - RED_CEIL); } if (h < K4) { u = (h - K3) / (K4 - K3); - return FOOTHILL_BENCH + u * BENCH_STEP; // bench 1, near-flat + return benchLo + u * benchSpan; // bench — modulated } + float benchTop = benchLo + benchSpan; if (h < K5) { u = (h - K4) / (K5 - K4); - s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // mid riser, slope ≥ 0.2 - return FOOTHILL_TOP + s * (PLATEAU - FOOTHILL_TOP); + s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // mid riser + return benchTop + s * (plateauLo - benchTop); } if (h < K6) { u = (h - K5) / (K6 - K5); - return PLATEAU + u * BENCH_STEP; // white plateau, near-flat + return plateauLo + u * plateauSpan; // plateau — modulated } + float plateauTop = plateauLo + plateauSpan; float spikeMax = EffectiveSpikeMax(hMaxSeed); if (h < spikeMax) { u = (h - K6) / (spikeMax - K6); - s = 0.1f * u + 0.9f * (u * u * u * u); // summit spike (v2 shape kept), slope ≥ 0.1 - return PLATEAU_TOP + s * (PEAK_CAP - PLATEAU_TOP); + s = 0.1f * u + 0.9f * (u * u * u * u); // summit spike (v2/v3 shape) + return plateauTop + s * (PEAK_CAP - plateauTop); } return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE; } /// - /// Numeric strict-monotonicity check of the EFFECTIVE per-seed curve — call once - /// per generation after hMaxSeed is known. Loud throw, refuses to generate. + /// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve: + /// sweeps the full domain at every one of the 8 modulation-extreme corners + /// (bench anchor ±, plateau anchor ±, strength min/max) with the per-seed + /// spikeMax — the adversarial corner set for the ordering constraints. Loud + /// throw, refuses to generate. /// public static void AssertMonotonic(float hMaxSeed) { - float prevH = -7f; - float prev = Apply(prevH, hMaxSeed); + float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP }; + float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP }; + float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX }; - // Only strictly increasing float32 samples are compared (task-05 incident fix). - void Check(double hd) + foreach (float bl in benchLos) { - float h = (float)hd; - if (h <= prevH) return; - float v = Apply(h, hMaxSeed); - if (v <= prev) - throw new System.InvalidOperationException( - $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}): {v} <= {prev}. Refusing to generate."); - prev = v; - prevH = h; - } + foreach (float pl in plateauLos) + { + foreach (float sp in spans) + { + float prevH = -7f; + float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp); - double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5); - for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) Check(h); - for (double h = 0.10; h <= top; h += 0.0001) Check(h); - for (double h = top + 0.05; h <= top + 6.0; h += 0.05) Check(h); - GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6})."); + void Check(double hd) + { + float h = (float)hd; + if (h <= prevH) return; // dedupe float32 samples (task-05 fix) + float v = Apply(h, hMaxSeed, bl, sp, pl, sp); + if (v <= prev) + throw new System.InvalidOperationException( + $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate."); + prev = v; + prevH = h; + } + + double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5); + for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh); + for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh); + for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh); + } + } + } + GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6})."); } } diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 6547792..b311779 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -37,6 +37,15 @@ public partial class MapGenerator : TextureRect private float[,] _heightMapClassify; private bool _curveOn; + // Curve-v4 shelf-modulation fields (task 08): two decorrelated elevation fields + // (bench 100±12 m, plateau 220±20 m) plus a strength field blending each shelf + // between pronounced-flat and barely-a-hint. Seeds derive from the RESOLVED + // noise seed + fixed offsets (no config knob); frequencies scale with MapSize. + // Sampled per column in pass 2 only — the classify path never sees them. + private FastNoiseLite _benchNoise; + private FastNoiseLite _plateauNoise; + private FastNoiseLite _strengthNoise; + // 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). @@ -75,7 +84,7 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; - _curveOn = ConfigManager.TerrainCurve == "v3"; + _curveOn = ConfigManager.TerrainCurve == "v4"; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; @@ -86,11 +95,18 @@ public partial class MapGenerator : TextureRect float scaleFactor = MapSize / 1024f; // Equals 4 - // --- MASTER SEED & DETAILED NOISE (Restored!) --- + // --- MASTER SEED & DETAILED NOISE (Restored!) --- _noise = new FastNoiseLite(); - _noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed; + _noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed; _noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; - _noise.Frequency = 0.004f / scaleFactor; + _noise.Frequency = 0.004f / scaleFactor; + + if (_curveOn) + { + _benchNoise = MakeModulationNoise(HeightCurve.BENCH_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS); + _plateauNoise = MakeModulationNoise(HeightCurve.PLATEAU_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS); + _strengthNoise = MakeModulationNoise(HeightCurve.STRENGTH_SEED_OFFSET, HeightCurve.STRENGTH_FREQ_ISLANDS); + } // THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!) // Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast! @@ -256,17 +272,23 @@ public partial class MapGenerator : TextureRect TrailRoads = _trailPaths, WaterBodyIds = _waterBodyIds, WaterBodies = _waterBodies ?? new List(), - // TCRV under curve v3: the four knot slots carry K1..K4 (K5/K6 are version - // constants, documented in BLUEPRINT_FORMAT.md); PlateauLo/Hi carry the two - // bench anchors (foothill 100 m / white plateau 220 m). + // TCRV under curve v4: knot slots carry K1..K4 (K5/K6 are version constants); + // the bench slots carry the two BASE anchors; the v4 extension record carries + // the modulation parameters (amplitudes, spans, frequencies, seed offsets) — + // the blueprint stays self-describing. TerrainCurve = _curveOn ? new TerrainCurveInfo { Version = HeightCurve.VERSION, T1 = HeightCurve.K1, T2 = HeightCurve.K2, T3 = HeightCurve.K3, T4 = HeightCurve.K4, SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, - PlateauLo = HeightCurve.FOOTHILL_BENCH, PlateauHi = HeightCurve.PLATEAU, - PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE + PlateauLo = HeightCurve.BENCH_BASE, PlateauHi = HeightCurve.PLATEAU_BASE, + PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE, + BenchAmp = HeightCurve.BENCH_AMP, PlateauAmp = HeightCurve.PLATEAU_AMP, + ShelfSpanMin = HeightCurve.SHELF_SPAN_MIN, ShelfSpanMax = HeightCurve.SHELF_SPAN_MAX, + ElevFreqIslands = HeightCurve.ELEV_FREQ_ISLANDS, StrengthFreqIslands = HeightCurve.STRENGTH_FREQ_ISLANDS, + BenchSeedOffset = HeightCurve.BENCH_SEED_OFFSET, PlateauSeedOffset = HeightCurve.PLATEAU_SEED_OFFSET, + StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET } : null, FormatVersion = 2, Params = new BlueprintParams @@ -438,7 +460,21 @@ public partial class MapGenerator : TextureRect { float raw = _heightMap[x, y]; float classifyH = raw; - float curvedH = _curveOn ? HeightCurve.Apply(raw, _hMaxSeed) : raw; + float curvedH; + if (_curveOn) + { + // v4: per-column shelf modulation — anchors and strength from the + // low-frequency fields; ordering safety by construction (amplitudes + // bounded; asserted at all 8 field-extreme corners per generation). + float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP; + float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP; + float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f); + curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan); + } + else + { + curvedH = raw; + } // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); @@ -523,6 +559,15 @@ public partial class MapGenerator : TextureRect // grid and the water-body grid holds BY CONSTRUCTION, not by parallel // implementations agreeing. // ===================================================================== + private FastNoiseLite MakeModulationNoise(int seedOffset, float periodsPerIsland) + { + var n = new FastNoiseLite(); + n.Seed = _noise.Seed + seedOffset; // deterministic from the RESOLVED seed + n.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; + n.Frequency = periodsPerIsland / MapSize; // frequency stated in island-widths + return n; + } + private bool IsWaterPixel(int x, int y) => _heightMapClassify[x, y] < GetSeaLevel(_tempMap[x, y]); private bool IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y]; private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y]; @@ -824,12 +869,14 @@ public partial class MapGenerator : TextureRect } else if (h < HeightCurve.ORANGE_CEIL) tint = green; else if (h < HeightCurve.RED_CEIL) tint = tan; - else if (h < HeightCurve.FOOTHILL_TOP) tint = brown; // foothill riser + 100 m bench - else if (h < HeightCurve.PLATEAU) tint = darkBrown; // the mid riser + // v4 note: shelf anchors are spatially modulated, so these tint bands + // use the base±amplitude envelopes — approximate banding, visualization only. + else if (h < HeightCurve.BENCH_BASE + HeightCurve.BENCH_AMP) tint = brown; // riser + bench envelope + else if (h < HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP) tint = darkBrown; // mid riser else { - // White plateau (220 m) through the summit spike to the 420 m cap. - float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU), 0f, 1f); + // Plateau envelope through the summit spike to the 420 m cap. + float t2 = Mathf.Clamp((h - (HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP)) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_BASE + HeightCurve.PLATEAU_AMP), 0f, 1f); tint = grey.Lerp(white, t2); } @@ -1373,40 +1420,40 @@ public partial class MapGenerator : TextureRect } private void SetBaseAStarWeights(AStarGrid2D astar) { - for (int x = 0; x < MapSize; x++) { - for (int y = 0; y < MapSize; y++) { - Biome b = _biomeMap[x, y]; - - // 1. CRITICAL FIX: Make the crater physically impassable first! - if (b == Biome.Crater) { - astar.SetPointSolid(new Vector2I(x, y), true); - continue; // "Skip the rest, go to next pixel" - } + for (int x = 0; x < MapSize; x++) { + for (int y = 0; y < MapSize; y++) { + Biome b = _biomeMap[x, y]; + + // 1. CRITICAL FIX: Make the crater physically impassable first! + if (b == Biome.Crater) { + astar.SetPointSolid(new Vector2I(x, y), true); + continue; // "Skip the rest, go to next pixel" + } - // 2. Existing water/mainland checks - if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { - astar.SetPointSolid(new Vector2I(x, y), true); - continue; // "Skip the rest, go to next pixel" - } - - // 3. We only reach this point if it's mainland AND not a crater! - float h = _heightMap[x, y]; - float localSea = GetSeaLevel(_tempMap[x, y]); - float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); + // 2. Existing water/mainland checks + if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { + astar.SetPointSolid(new Vector2I(x, y), true); + continue; // "Skip the rest, go to next pixel" + } + + // 3. We only reach this point if it's mainland AND not a crater! + float h = _heightMap[x, y]; + float localSea = GetSeaLevel(_tempMap[x, y]); + float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); - // Simple, steep curve to avoid mountains - float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; + // Simple, steep curve to avoid mountains + float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; - // Push off the beach (not an insane wall, just enough to prefer grass) - if (b == Biome.Beach) weight += 15.0f; - - // Let it path through the wasteland normally - if (b == Biome.Wasteland) weight += 3.0f; + // Push off the beach (not an insane wall, just enough to prefer grass) + if (b == Biome.Beach) weight += 15.0f; + + // Let it path through the wasteland normally + if (b == Biome.Wasteland) weight += 3.0f; - astar.SetPointWeightScale(new Vector2I(x, y), weight); - } - } - } + astar.SetPointWeightScale(new Vector2I(x, y), weight); + } + } + } private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius) { @@ -1419,7 +1466,7 @@ public partial class MapGenerator : TextureRect int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1); var cell = new Vector2I(nx, ny); if (!astar.IsPointSolid(cell)) { - // The true Iron Curtain + // The true Iron Curtain astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f); } } @@ -1494,67 +1541,67 @@ public partial class MapGenerator : TextureRect { float scaleMod = MapSize / 1024f; // equals 4 at 4096 - // Trails: Thin brown - foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); - - // Rugged: Thicker dark brown - foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); + // Trails: Thin brown + foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); + + // Rugged: Thicker dark brown + foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); - // Main Roads: Solid Black - foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); + // Main Roads: Solid Black + foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); - // Highway: Thick Red - foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); + // Highway: Thick Red + foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); - // Draw Towns - foreach (var town in _towns) { - float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); - radius *= scaleMod; // Scale the circles up! - - Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); - if (town.Tier == TownTier.IslandLoot) c = Colors.Red; - if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } + // Draw Towns + foreach (var town in _towns) { + float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); + radius *= scaleMod; // Scale the circles up! + + Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); + if (town.Tier == TownTier.IslandLoot) c = Colors.Red; + if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } - DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); - DrawCircle(town.Position, radius, c); - } + DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); + DrawCircle(town.Position, radius, c); + } } } // Place this at the very bottom of the file! public partial class MapDrawProxy : Control { - public MapGenerator Source; + public MapGenerator Source; - public override void _Draw() - { - if (Source == null) return; - - float scaleMod = Source.MapSize / 1024f; + public override void _Draw() + { + if (Source == null) return; + + float scaleMod = Source.MapSize / 1024f; - foreach (var path in Source._trailPaths) - DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); + foreach (var path in Source._trailPaths) + DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); - foreach (var path in Source._ruggedPaths) - DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); + foreach (var path in Source._ruggedPaths) + DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); - foreach (var path in Source._branchPaths) - DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); + foreach (var path in Source._branchPaths) + DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); - foreach (var path in Source._highwayPaths) - DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); + foreach (var path in Source._highwayPaths) + DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); - foreach (var town in Source._towns) - { - float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); - radius *= scaleMod; + foreach (var town in Source._towns) + { + float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); + radius *= scaleMod; - Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); - if (town.Tier == TownTier.IslandLoot) c = Colors.Red; - if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } + Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); + if (town.Tier == TownTier.IslandLoot) c = Colors.Red; + if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } - DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); - DrawCircle(town.Position, radius, c); - } - } -} \ No newline at end of file + DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); + DrawCircle(town.Position, radius, c); + } + } +} diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index 7b2393a..d912926 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -200,8 +200,12 @@ 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.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 }; + 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, + ca.BenchAmp, ca.PlateauAmp, ca.ShelfSpanMin, ca.ShelfSpanMax, ca.ElevFreqIslands, ca.StrengthFreqIslands, + ca.BenchSeedOffset, ca.PlateauSeedOffset, ca.StrengthSeedOffset }; + 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, + cb.BenchAmp, cb.PlateauAmp, cb.ShelfSpanMin, cb.ShelfSpanMax, cb.ElevFreqIslands, cb.StrengthFreqIslands, + cb.BenchSeedOffset, cb.PlateauSeedOffset, cb.StrengthSeedOffset }; 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; } From 68f7bca8e06785969c521d248eb9b36b9ece841f Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 03:31:13 -0400 Subject: [PATCH 04/11] =?UTF-8?q?docs:=20curve=20v4=20=E2=80=94=20TCRV=20m?= =?UTF-8?q?odulation=20extension,=20TerrainCurve=20v4=20default=20(terrain?= =?UTF-8?q?-water=20task=2008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- Core/Scripts/BLUEPRINT_FORMAT.md | 9 +++++---- Tools/Scripts/README.md | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md index 91e8f7b..f03b0bc 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -55,7 +55,7 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`. | `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. | | `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. | | `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. | -| `TCRV` / `0x56524354` | 50 B: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope` | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). **v3 (current)** — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, **benchLo = the 100 m foothill bench, benchHi = the 220 m white plateau**; bench step (+0.008) and segment shapes are version constants in `HeightCurve.cs`. Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. | +| `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset` (record total 86 B; the length-framed section + version byte make the layout change safe for old readers of old files) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. | | `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. | | `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. | | `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q − 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. | @@ -106,10 +106,11 @@ body). which model produced it (the `WBTB` levels are the observable). - **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the road-section table note). -- **`TerrainCurve`** (`"v3"` | `"off"`, default `"v3"`; `"v1"`/`"v2"` retired by successor +- **`TerrainCurve`** (`"v4"` | `"off"`, default `"v4"`; `"v1"`–`"v3"` retired by successor recalibrations and rejected loudly) — whether the height-redistribution curve (the terraced - ascent: 59 % orange lowlands, 100 m foothill bench, 220 m white plateau, per-seed-normalized - 420 m summit spires) shapes `HGTS`. Biome/water classification is curve-invariant by + ascent with spatially modulated shelves: 59 % orange lowlands, foothill bench 100±12 m, white + plateau 220±20 m, per-flank shelf strength, per-seed-normalized 420 m summit spires) shapes + `HGTS`. Biome/water classification is curve-invariant by construction (it classifies the retained uncurved heights); town positions and everything 3D follow the curved terrain. When on, `TCRV` records the effective parameters including the per-seed `spikeMax`. diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 4ecd533..35c00c9 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -11,10 +11,11 @@ Generates the entire 2D blueprint. Roughly in order: 2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor` (`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile. - When `TerrainCurve: "v3"` (the default), the calibrated height-redistribution curve - (`HeightCurve.cs` — the terraced ascent: flat farmable lowlands, a walkable 100 m foothill - bench, the white mountain-town plateau at 220 m, and a per-seed-normalized summit spike to - the 420 m cap: every island's tallest point reaches the ceiling) reshapes above-sea terrain + When `TerrainCurve: "v4"` (the default), the calibrated height-redistribution curve + (`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable + lowlands, a walkable foothill bench at 100±12 m, the white mountain-town plateau at + 220±20 m — shelf heights and strength drift across the island so no two flanks wear the same + ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain after noise/falloff/Trench and before the crater carve; biome classification reads a retained uncurved map, so biomes are identical either way. Drops the `0_height` hillshade snapshot (hypsometric bands × NW hillshade) in both modes. From 02577872f237002124c15dc3da757251f330b1c4 Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 04:11:53 -0400 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20curve=20v5=20=E2=80=94=20corner?= =?UTF-8?q?=20fixes=20+=20COMPACT/BALANCED=20taste=20presets=20(terrain-wa?= =?UTF-8?q?ter=20task=2009)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three corner fixes (both presets): riser endpoint slope floors 0.2 -> 0.1 (0.1u + 0.9*smoothstep — climbs decelerate into shelves and accelerate out); summit-spike base floor 0.1 -> 0.05 (0.05u + 0.95*u^4 — no hard skirt under the peaks); SHELF_SPAN_MIN 2 m -> 6 m (pronounced shelves keep a gentle tilt). Knots are preset-parameterized (CurveKnots, explicit parameter — pure, D-035), calibrated from the same pooled batch-04 CDF: COMPACT P65/77/84/88/94/97 -> 65/12/7/4/6/3/3 land split BALANCED P60/73/83/88/96/99 -> 60/13/10/5/8/3/1 Gate: "off"|"v5-compact"|"v5-balanced" (batch-only tri-state; interim default v5-balanced pending the developer's verdict — the winner becomes plain "v5" in a follow-up commit); v1-v4 retired loudly. Assertion runs per preset (8 corners x per-seed spikeMax; the lowered floors stay strictly positive). TCRV v5 extension (+9 B): preset id + K5/K6 — the effective curve is unambiguous from the record; old records parse (version-dispatched); harness compares. Co-Authored-By: Claude Fable 5 --- Core/Scripts/BlueprintWriter.cs | 7 ++ Core/Scripts/ConfigManager.cs | 19 ++-- Core/Scripts/MapDataParser.cs | 10 ++ Tools/Scripts/HeightCurve.cs | 173 +++++++++++++++--------------- Tools/Scripts/MapGenerator.cs | 23 ++-- Tools/Scripts/RoundTripHarness.cs | 6 +- 6 files changed, 137 insertions(+), 101 deletions(-) diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index 4ed218d..ed2de85 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -142,6 +142,13 @@ namespace IslaApocalypse.Core writer.Write(c.BenchSeedOffset); writer.Write(c.PlateauSeedOffset); writer.Write(c.StrengthSeedOffset); } + + // v5+ extension: knot preset id + K5/K6. + if (c.Version >= 5) + { + writer.Write(c.PresetId); + writer.Write(c.K5); writer.Write(c.K6); + } } private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index 6030555..4d13fa4 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -26,12 +26,13 @@ namespace IslaApocalypse.Core // Change this if your namespace is different public static string SeaLevelModel = "flat"; public static float SeaLevelValue = 0.15f; - // Height-redistribution curve (tasks 05–08, graduation M-7): "v4" applies the - // terraced-ascent curve with spatially modulated shelves and the per-seed peak - // spike (HeightCurve.cs); "off" is the raw legacy profile. "v1"–"v3" were - // retired by successor recalibrations (old blueprints are regenerable). Biome - // classification is curve-invariant by construction either way. Default: v4. - public static string TerrainCurve = "v4"; + // Height-redistribution curve (tasks 05–09, graduation M-7): the task-09 taste + // batch runs a tri-state — "v5-compact" (maximum lowland) vs "v5-balanced" + // (task-07/08 lineage), both with the corner fixes; "off" is the raw legacy + // profile. The loser retires after the developer's gate and the winner becomes + // plain "v5". "v1"–"v4" retired. INTERIM default pending the verdict: + // v5-balanced. + public static string TerrainCurve = "v5-balanced"; public static void LoadConfig() { @@ -106,10 +107,10 @@ namespace IslaApocalypse.Core // Change this if your namespace is different if (data.ContainsKey("TerrainCurve")) { string curve = (string)data["TerrainCurve"]; - if (curve == "off" || curve == "v4") + if (curve == "off" || curve == "v5-compact" || curve == "v5-balanced") TerrainCurve = curve; - else if (curve == "v1" || curve == "v2" || curve == "v3") - GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v4, task 08). Keeping '{TerrainCurve}' — use \"v4\" or \"off\"."); + else if (curve == "v1" || curve == "v2" || curve == "v3" || curve == "v4") + GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v5, task 09). Keeping '{TerrainCurve}' — use \"v5-compact\", \"v5-balanced\" or \"off\"."); else GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); } diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 00e03a6..aadc5df 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -80,6 +80,11 @@ namespace IslaApocalypse.Core public float BenchAmp, PlateauAmp, ShelfSpanMin, ShelfSpanMax; public float ElevFreqIslands, StrengthFreqIslands; public int BenchSeedOffset, PlateauSeedOffset, StrengthSeedOffset; + + // v5 extension — knot preset (1 = compact, 2 = balanced) and the two knots the + // four base slots cannot carry; with these the effective curve is unambiguous. + public byte PresetId; + public float K5, K6; } public class WorldBlueprint @@ -416,6 +421,11 @@ namespace IslaApocalypse.Core c.BenchSeedOffset = reader.ReadInt32(); c.PlateauSeedOffset = reader.ReadInt32(); c.StrengthSeedOffset = reader.ReadInt32(); } + if (c.Version >= 5) + { + c.PresetId = reader.ReadByte(); + c.K5 = reader.ReadSingle(); c.K6 = reader.ReadSingle(); + } blueprint.TerrainCurve = c; return true; } diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs index fd6f1ed..65916b9 100644 --- a/Tools/Scripts/HeightCurve.cs +++ b/Tools/Scripts/HeightCurve.cs @@ -1,146 +1,151 @@ using Godot; /// -/// The height-redistribution curve, v4 — SPATIALLY MODULATED SHELVES (terrain-water -/// task 08). Pure, static, monotonic piecewise map; every per-column input is an -/// explicit PARAMETER (D-035): the seed spike max and the four modulated shelf -/// values. The generator samples the modulation fields; this class never touches -/// noise. +/// One preset's input knots for the v5 curve. Two presets exist for the task-09 +/// taste batch — COMPACT (maximum lowland, cordillera-from-plains) and BALANCED +/// (the task-07/08 lineage, gradual highland approach). The loser retires after +/// the developer's gate; the winner becomes plain "v5". +/// +public sealed class CurveKnots +{ + public readonly byte PresetId; + public readonly string Name; + public readonly float K1, K2, K3, K4, K5, K6; + + public CurveKnots(byte id, string name, float k1, float k2, float k3, float k4, float k5, float k6) + { + PresetId = id; Name = name; + K1 = k1; K2 = k2; K3 = k3; K4 = k4; K5 = k5; K6 = k6; + } +} + +/// +/// The height-redistribution curve, v5 (terrain-water task 09) — v4's spatially +/// modulated shelves plus the THREE CORNER FIXES, with PRESET-PARAMETERIZED knots. +/// Pure, static, monotonic; every per-column input and the knot set are explicit +/// PARAMETERS (D-035). /// -/// v4 (developer's task-07 gate finding: the terraces work, but uniform anchors put -/// a flat ring at exactly 100 m and exactly 220 m on every mountain — the bathtub -/// rings): the bench and plateau OUTPUT anchors become smooth spatial fields — -/// benchLo = 100 m ± 12 m and plateauLo = 220 m ± 20 m via two decorrelated -/// very-low-frequency noise fields — and a third field modulates SHELF STRENGTH, -/// blending each shelf between "pronounced flat" (output span ~2 m) and "barely a -/// hint" (~25 m of gentle slope), so not every flank at shelf height develops the -/// full terrace. Shelves stay locally flat; the two magic altitudes stop existing. +/// The corner fixes (both presets — the naturalness work): +/// 1. Riser endpoint slope floor 0.2 → 0.1 (blend 0.1u + 0.9·smoothstep): climbs +/// decelerate into shelves and accelerate out of them — no machined edges. +/// 2. Summit-spike base floor 0.1 → 0.05 (blend 0.05u + 0.95·u⁴): the spike +/// leaves the plateau gently — no hard skirt under the peaks. +/// 3. SHELF_SPAN_MIN 2 m → 6 m: pronounced shelves keep a gentle tilt — flat to +/// build on, never snooker-table flat. /// -/// Structure otherwise v3's, unchanged: identity ≤ sea, frozen toe/rise (storm -/// ladder), foothill riser → bench → mid riser → plateau → per-seed-normalized -/// stiff spike (u⁴, 420 m cap) → tail. Input knots are v3's calibration. +/// Presets (input land-fraction targets; knots calibrated 2026-08-08 from the same +/// pooled batch-04 flat-sea land CDF as tasks 05/07, 340,618,126 samples; achieved +/// fractions exact by construction): +/// COMPACT — 65/12/7/4/6/3/3 (orange/red/foothill-riser/bench/mid-riser/plateau/spike) +/// BALANCED — 60/13/10/5/8/3/1 /// -/// ORDERING SAFETY BY CONSTRUCTION (asserted): with the amplitudes below, at every -/// column: red ceiling 0.27 < benchLo−… (bench min 0.5006), bench top max 0.6962 < -/// plateauLo min 0.9468, plateau top max 1.2062 < peak cap 1.8287. The numeric -/// assertion additionally sweeps all 8 modulation-extreme corners per generation. +/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m, +/// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization, +/// modulation fields/seed offsets, the classify-map invariant. /// public static class HeightCurve { - public const ushort VERSION = 4; + public const ushort VERSION = 5; - // Input knots — v3 calibration (pooled batch-04 land CDF, P59/72/82/87/95/98). - public const float K1 = 0.509179f; - public const float K2 = 0.604081f; - public const float K3 = 0.698485f; - public const float K4 = 0.767213f; - public const float K5 = 0.930304f; - public const float K6 = 1.050720f; + public static readonly CurveKnots COMPACT = new CurveKnots(1, "compact", + 0.550982f, 0.646528f, 0.723145f, 0.784045f, 0.903025f, 1.003079f); // P65/77/84/88/94/97 + + public static readonly CurveKnots BALANCED = new CurveKnots(2, "balanced", + 0.515899f, 0.612157f, 0.710472f, 0.784045f, 0.962922f, 1.119118f); // P60/73/83/88/96/99 // Fixed output anchors — storm ladder + ceiling (frozen). public const float SEA = 0.15f; public const float ORANGE_CEIL = 0.206f; public const float RED_CEIL = 0.27f; - public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 + public const float PEAK_CAP = SEA + 420f / 251f; public const float TAIL_SLOPE = 0.25f; public const float SPIKE_MIN_SPAN = 0.01f; - // Modulated shelf anchors: base ± amplitude (raw units; 251 m per unit). - public const float BENCH_BASE = SEA + 100f / 251f; // ≈ 0.54841 (100 m) - public const float BENCH_AMP = 12f / 251f; // ± 12 m - public const float PLATEAU_BASE = SEA + 220f / 251f; // ≈ 1.02649 (220 m) - public const float PLATEAU_AMP = 20f / 251f; // ± 20 m + // Modulated shelf anchors (unchanged from v4). + public const float BENCH_BASE = SEA + 100f / 251f; + public const float BENCH_AMP = 12f / 251f; + public const float PLATEAU_BASE = SEA + 220f / 251f; + public const float PLATEAU_AMP = 20f / 251f; - // Shelf strength: output span of each shelf segment, blended by the strength - // field. Strong (t=1) → SPAN_MIN (~2 m, pronounced flat). Weak (t=0) → - // SPAN_MAX (~25 m, barely a hint of a shelf). - public const float SHELF_SPAN_MIN = 0.008f; + // Corner fix 3: pronounced shelves keep ~6 m of tilt across the shelf band. + public const float SHELF_SPAN_MIN = 6f / 251f; // ≈ 0.0239 (v4: 0.008 ≈ 2 m) public const float SHELF_SPAN_MAX = 0.10f; - // Modulation-field derivation (generator-side, recorded in TCRV): field seed = - // RESOLVED WorldSeed + offset; frequency = (periods per island width) / MapSize. + // Modulation-field derivation (unchanged from v4). public const int BENCH_SEED_OFFSET = 7101; public const int PLATEAU_SEED_OFFSET = 7207; public const int STRENGTH_SEED_OFFSET = 7303; - public const float ELEV_FREQ_ISLANDS = 3.0f; // ~3 undulations across the island - public const float STRENGTH_FREQ_ISLANDS = 5.0f; // finer patchiness for shelf strength + public const float ELEV_FREQ_ISLANDS = 3.0f; + public const float STRENGTH_FREQ_ISLANDS = 5.0f; - public static float EffectiveSpikeMax(float hMaxSeed) + public static float EffectiveSpikeMax(float hMaxSeed, CurveKnots k) { - return Mathf.Max(hMaxSeed, K6 + SPIKE_MIN_SPAN); + return Mathf.Max(hMaxSeed, k.K6 + SPIKE_MIN_SPAN); } - /// Shelf output span for a strength sample t ∈ [0,1]. public static float ShelfSpan(float strength01) { return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f)); } - /// Raw pre-curve height. - /// Seed's raw pre-curve maximum (per-seed spike normalizer). - /// This column's bench anchor (BENCH_BASE ± BENCH_AMP). - /// This column's bench output span (ShelfSpan of the strength field). - /// This column's plateau anchor (PLATEAU_BASE ± PLATEAU_AMP). - /// This column's plateau output span. public static float Apply(float h, float hMaxSeed, - float benchLo, float benchSpan, float plateauLo, float plateauSpan) + float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k) { if (h <= SEA) return h; float u, s; - if (h < K1) + if (h < k.K1) { - u = (h - SEA) / (K1 - SEA); - s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe + u = (h - SEA) / (k.K1 - SEA); + s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe return SEA + s * (ORANGE_CEIL - SEA); } - if (h < K2) + if (h < k.K2) { - u = (h - K1) / (K2 - K1); - return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise + u = (h - k.K1) / (k.K2 - k.K1); + return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise } - if (h < K3) + if (h < k.K3) { - u = (h - K2) / (K3 - K2); - s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // foothill riser + u = (h - k.K2) / (k.K3 - k.K2); + s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1 return RED_CEIL + s * (benchLo - RED_CEIL); } - if (h < K4) + if (h < k.K4) { - u = (h - K3) / (K4 - K3); - return benchLo + u * benchSpan; // bench — modulated + u = (h - k.K3) / (k.K4 - k.K3); + return benchLo + u * benchSpan; // bench (min span 6 m — fix 3) } float benchTop = benchLo + benchSpan; - if (h < K5) + if (h < k.K5) { - u = (h - K4) / (K5 - K4); - s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // mid riser + u = (h - k.K4) / (k.K5 - k.K4); + s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1 return benchTop + s * (plateauLo - benchTop); } - if (h < K6) + if (h < k.K6) { - u = (h - K5) / (K6 - K5); - return plateauLo + u * plateauSpan; // plateau — modulated + u = (h - k.K5) / (k.K6 - k.K5); + return plateauLo + u * plateauSpan; // plateau } float plateauTop = plateauLo + plateauSpan; - float spikeMax = EffectiveSpikeMax(hMaxSeed); + float spikeMax = EffectiveSpikeMax(hMaxSeed, k); if (h < spikeMax) { - u = (h - K6) / (spikeMax - K6); - s = 0.1f * u + 0.9f * (u * u * u * u); // summit spike (v2/v3 shape) + u = (h - k.K6) / (spikeMax - k.K6); + s = 0.05f * u + 0.95f * (u * u * u * u); // summit spike — corner fix 2 return plateauTop + s * (PEAK_CAP - plateauTop); } return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE; } /// - /// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve: - /// sweeps the full domain at every one of the 8 modulation-extreme corners - /// (bench anchor ±, plateau anchor ±, strength min/max) with the per-seed - /// spikeMax — the adversarial corner set for the ordering constraints. Loud - /// throw, refuses to generate. + /// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for + /// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax. + /// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the + /// sweep proves they stay strictly positive everywhere. Loud throw on failure. /// - public static void AssertMonotonic(float hMaxSeed) + public static void AssertMonotonic(float hMaxSeed, CurveKnots k) { float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP }; float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP }; @@ -153,27 +158,27 @@ public static class HeightCurve foreach (float sp in spans) { float prevH = -7f; - float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp); + float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k); void Check(double hd) { float h = (float)hd; if (h <= prevH) return; // dedupe float32 samples (task-05 fix) - float v = Apply(h, hMaxSeed, bl, sp, pl, sp); + float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k); if (v <= prev) throw new System.InvalidOperationException( - $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate."); + $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate."); prev = v; prevH = h; } - double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5); + double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k) + 0.5); for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh); for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh); for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh); } } } - GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6})."); + GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6})."); } } diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index b311779..b7e3dc6 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -46,6 +46,10 @@ public partial class MapGenerator : TextureRect private FastNoiseLite _plateauNoise; private FastNoiseLite _strengthNoise; + // v5: the selected knot preset (task-09 taste batch: COMPACT or BALANCED); + // null = curve off. + private CurveKnots _curveKnots; + // 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). @@ -84,7 +88,10 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; - _curveOn = ConfigManager.TerrainCurve == "v4"; + _curveKnots = ConfigManager.TerrainCurve == "v5-compact" ? HeightCurve.COMPACT + : ConfigManager.TerrainCurve == "v5-balanced" ? HeightCurve.BALANCED + : null; + _curveOn = _curveKnots != null; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; @@ -276,11 +283,14 @@ public partial class MapGenerator : TextureRect // the bench slots carry the two BASE anchors; the v4 extension record carries // the modulation parameters (amplitudes, spans, frequencies, seed offsets) — // the blueprint stays self-describing. + // TCRV v5: knot slots carry the PRESET's K1..K4; the extension carries the + // modulation params plus preset id and K5/K6 — the effective curve is + // unambiguous from the record alone. TerrainCurve = _curveOn ? new TerrainCurveInfo { Version = HeightCurve.VERSION, - T1 = HeightCurve.K1, T2 = HeightCurve.K2, T3 = HeightCurve.K3, T4 = HeightCurve.K4, - SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed + T1 = _curveKnots.K1, T2 = _curveKnots.K2, T3 = _curveKnots.K3, T4 = _curveKnots.K4, + SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed, _curveKnots), // per-seed Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, PlateauLo = HeightCurve.BENCH_BASE, PlateauHi = HeightCurve.PLATEAU_BASE, PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE, @@ -288,7 +298,8 @@ public partial class MapGenerator : TextureRect ShelfSpanMin = HeightCurve.SHELF_SPAN_MIN, ShelfSpanMax = HeightCurve.SHELF_SPAN_MAX, ElevFreqIslands = HeightCurve.ELEV_FREQ_ISLANDS, StrengthFreqIslands = HeightCurve.STRENGTH_FREQ_ISLANDS, BenchSeedOffset = HeightCurve.BENCH_SEED_OFFSET, PlateauSeedOffset = HeightCurve.PLATEAU_SEED_OFFSET, - StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET + StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET, + PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6 } : null, FormatVersion = 2, Params = new BlueprintParams @@ -445,7 +456,7 @@ public partial class MapGenerator : TextureRect // 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); + if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots); // --- PASS 2: curve (task 05/06) + crater carve --- // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so @@ -469,7 +480,7 @@ public partial class MapGenerator : TextureRect float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP; float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP; float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f); - curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan); + curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots); } else { diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index d912926..9c0a5cc 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -202,10 +202,12 @@ public partial class RoundTripHarness : Node bool same = ca.Version == cb.Version; 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, ca.BenchAmp, ca.PlateauAmp, ca.ShelfSpanMin, ca.ShelfSpanMax, ca.ElevFreqIslands, ca.StrengthFreqIslands, - ca.BenchSeedOffset, ca.PlateauSeedOffset, ca.StrengthSeedOffset }; + ca.BenchSeedOffset, ca.PlateauSeedOffset, ca.StrengthSeedOffset, + ca.PresetId, ca.K5, ca.K6 }; 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, cb.BenchAmp, cb.PlateauAmp, cb.ShelfSpanMin, cb.ShelfSpanMax, cb.ElevFreqIslands, cb.StrengthFreqIslands, - cb.BenchSeedOffset, cb.PlateauSeedOffset, cb.StrengthSeedOffset }; + cb.BenchSeedOffset, cb.PlateauSeedOffset, cb.StrengthSeedOffset, + cb.PresetId, cb.K5, cb.K6 }; 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; } From 4797086a13f15c76753c9c63ba97bf5a07e4a6dc Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 04:19:58 -0400 Subject: [PATCH 06/11] =?UTF-8?q?docs:=20curve=20v5=20=E2=80=94=20TCRV=20p?= =?UTF-8?q?reset=20extension,=20tri-state=20gate=20(terrain-water=20task?= =?UTF-8?q?=2009)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- Core/Scripts/BLUEPRINT_FORMAT.md | 13 +++++++------ Tools/Scripts/README.md | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md index f03b0bc..da4d9f9 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -55,7 +55,7 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`. | `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. | | `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. | | `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. | -| `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset` (record total 86 B; the length-framed section + version byte make the layout change safe for old readers of old files) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. | +| `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset`. **When `curveVersion ≥ 5`, a 9 B preset extension follows:** `u8 presetId` (1 = compact, 2 = balanced) · `f32 k5` · `f32 k6` — with the four base knot slots this makes the effective curve unambiguous from the record alone (record total 95 B; the length-framed section + version byte keep every layout change safe) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. | | `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. | | `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. | | `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q − 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. | @@ -106,11 +106,12 @@ body). which model produced it (the `WBTB` levels are the observable). - **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the road-section table note). -- **`TerrainCurve`** (`"v4"` | `"off"`, default `"v4"`; `"v1"`–`"v3"` retired by successor - recalibrations and rejected loudly) — whether the height-redistribution curve (the terraced - ascent with spatially modulated shelves: 59 % orange lowlands, foothill bench 100±12 m, white - plateau 220±20 m, per-flank shelf strength, per-seed-normalized 420 m summit spires) shapes - `HGTS`. Biome/water classification is curve-invariant by +- **`TerrainCurve`** (`"v5-compact"` | `"v5-balanced"` | `"off"`; interim default + `"v5-balanced"` — the task-09 taste batch's tri-state, resolved when the developer's verdict + picks the winner as plain `"v5"`; `"v1"`–`"v4"` retired and rejected loudly) — whether the + height-redistribution curve (terraced ascent, corner-eased junctions, spatially modulated + shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires; COMPACT = 65 % + lowland cordillera-from-plains, BALANCED = 60 % lowland gradual approach) shapes `HGTS`. Biome/water classification is curve-invariant by construction (it classifies the retained uncurved heights); town positions and everything 3D follow the curved terrain. When on, `TCRV` records the effective parameters including the per-seed `spikeMax`. diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 35c00c9..22799ee 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -11,7 +11,8 @@ Generates the entire 2D blueprint. Roughly in order: 2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor` (`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile. - When `TerrainCurve: "v4"` (the default), the calibrated height-redistribution curve + When `TerrainCurve` is a `"v5-*"` preset (interim default `"v5-balanced"`; the task-09 taste + batch resolves the tri-state), the calibrated height-redistribution curve (`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable lowlands, a walkable foothill bench at 100±12 m, the white mountain-town plateau at 220±20 m — shelf heights and strength drift across the island so no two flanks wear the same From c71026fd9459bda09db33c1644b075d5518009cb Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 18:00:21 -0400 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20task-09=20verdict=20=E2=80=94=20B?= =?UTF-8?q?ALANCED=20becomes=20plain=20"v5",=20COMPACT=20retires=20(terrai?= =?UTF-8?q?n-water=20task=2010=20prerequisite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented one-liner the task-09 gate triggers: TerrainCurve gate is now "off"|"v5" (default v5 = the BALANCED preset, id 2); "v5-compact" retired with a verdict-specific error, "v5-balanced" redirects to plain "v5". Knots/TCRV encoding unchanged. Co-Authored-By: Claude Fable 5 --- Core/Scripts/ConfigManager.cs | 21 ++++++++++++--------- Tools/Scripts/HeightCurve.cs | 7 +++---- Tools/Scripts/MapGenerator.cs | 4 +--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index 4d13fa4..c9a0033 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -26,13 +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 (tasks 05–09, graduation M-7): the task-09 taste - // batch runs a tri-state — "v5-compact" (maximum lowland) vs "v5-balanced" - // (task-07/08 lineage), both with the corner fixes; "off" is the raw legacy - // profile. The loser retires after the developer's gate and the winner becomes - // plain "v5". "v1"–"v4" retired. INTERIM default pending the verdict: - // v5-balanced. - public static string TerrainCurve = "v5-balanced"; + // Height-redistribution curve (tasks 05–10, graduation M-7): "v5" is the + // task-09 gate's winner — the BALANCED terraced ascent with corner easing and + // modulated shelves; "off" is the raw legacy profile. "v1"–"v4" and the + // taste-batch tri-state ("v5-compact" lost, "v5-balanced" became plain "v5") + // are retired. Default: v5. + public static string TerrainCurve = "v5"; public static void LoadConfig() { @@ -107,10 +106,14 @@ namespace IslaApocalypse.Core // Change this if your namespace is different if (data.ContainsKey("TerrainCurve")) { string curve = (string)data["TerrainCurve"]; - if (curve == "off" || curve == "v5-compact" || curve == "v5-balanced") + if (curve == "off" || curve == "v5") TerrainCurve = curve; + else if (curve == "v5-balanced") + GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-balanced' won the task-09 gate and is now plain \"v5\". Keeping '{TerrainCurve}'."); + else if (curve == "v5-compact") + GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-compact' was retired by the task-09 verdict (BALANCED won). Keeping '{TerrainCurve}' — use \"v5\" or \"off\"."); else if (curve == "v1" || curve == "v2" || curve == "v3" || curve == "v4") - GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v5, task 09). Keeping '{TerrainCurve}' — use \"v5-compact\", \"v5-balanced\" or \"off\"."); + GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v5, tasks 09/10). Keeping '{TerrainCurve}' — use \"v5\" or \"off\"."); else GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); } diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs index 65916b9..2e18ab0 100644 --- a/Tools/Scripts/HeightCurve.cs +++ b/Tools/Scripts/HeightCurve.cs @@ -47,10 +47,9 @@ public static class HeightCurve { public const ushort VERSION = 5; - public static readonly CurveKnots COMPACT = new CurveKnots(1, "compact", - 0.550982f, 0.646528f, 0.723145f, 0.784045f, 0.903025f, 1.003079f); // P65/77/84/88/94/97 - - public static readonly CurveKnots BALANCED = new CurveKnots(2, "balanced", + // The task-09 taste gate's WINNER: BALANCED (id 2). COMPACT retired with the + // verdict; its knots survive only in the task-09 report/batch for the record. + public static readonly CurveKnots V5 = new CurveKnots(2, "balanced", 0.515899f, 0.612157f, 0.710472f, 0.784045f, 0.962922f, 1.119118f); // P60/73/83/88/96/99 // Fixed output anchors — storm ladder + ceiling (frozen). diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index b7e3dc6..961b38a 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -88,9 +88,7 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; - _curveKnots = ConfigManager.TerrainCurve == "v5-compact" ? HeightCurve.COMPACT - : ConfigManager.TerrainCurve == "v5-balanced" ? HeightCurve.BALANCED - : null; + _curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null; _curveOn = _curveKnots != null; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) From 45fe2ce034bb6a35ed955f43744a9f8895da3c4f Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 18:08:28 -0400 Subject: [PATCH 08/11] feat: shelf micro-relief + drainage incision (terrain-water task 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paired detail passes on the locked v5 skeleton, output-heights only (classify path untouched): PASS A — shelf micro-relief: +-ShelfReliefAmp (default 3 m) Simplex skin (seed resolved+7409, ~40 undulations/island ~ 200 m features), weighted by shelf-ness (full mid-shelf, feathered to zero 30% of a band half-width into the risers) — both shelves get their rolling texture back; risers and peaks untouched. PASS B — drainage incision: D8 steepest-descent routing + height- ordered flow accumulation over the curved+relieved land (TerrainDetailPass — pure array machinery, named C++ candidate per D-035); depth = K*accum^p*slope (K=0.78, p=0.45 concave, cap 30 m), masked full on risers / 30% on shelves / zero on the toe, above the plateau top, and within 1.2x CraterRadius (feathered to 1.4x); hard clamp: carved height >= sea + 1 m. Ordering: curve -> relief -> incision -> crater carve (the carve stays the final authority; carve moved to its own pass 2c, bit-identical expression on both maps). The pass prints its own MEASURED depth distribution. Gate TerrainDetail "off"|"v1" (default v1; both passes one judged unit; no-op without the curve) + ShelfReliefAmp dial. New TDTL section (38 B: version + relief/incision params + seed offset) across writer/parser/harness — blueprints stay self-describing. Co-Authored-By: Claude Fable 5 --- Core/Scripts/BlueprintFormat.cs | 1 + Core/Scripts/BlueprintWriter.cs | 12 +++ Core/Scripts/ConfigManager.cs | 21 +++++ Core/Scripts/MapDataParser.cs | 31 +++++++ Tools/Scripts/MapGenerator.cs | 133 ++++++++++++++++++++++++--- Tools/Scripts/RoundTripHarness.cs | 26 ++++++ Tools/Scripts/TerrainDetailPass.cs | 143 +++++++++++++++++++++++++++++ 7 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 Tools/Scripts/TerrainDetailPass.cs diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs index 99d4597..5aad3c7 100644 --- a/Core/Scripts/BlueprintFormat.cs +++ b/Core/Scripts/BlueprintFormat.cs @@ -33,6 +33,7 @@ namespace IslaApocalypse.Core public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB" public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF" public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV" + public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL" // WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L // (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index ed2de85..27fe859 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -33,6 +33,8 @@ namespace IslaApocalypse.Core WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp)); if (bp.TerrainCurve != null) WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve)); + if (bp.TerrainDetail != null) + WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail)); WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp)); WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp)); @@ -151,6 +153,16 @@ namespace IslaApocalypse.Core } } + private static void WriteTerrainDetail(BinaryWriter writer, TerrainDetailInfo d) + { + writer.Write(d.Version); + writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); + writer.Write(d.IncK); writer.Write(d.IncP); writer.Write(d.IncCapM); + writer.Write(d.SeaClampRaw); writer.Write(d.CraterExclFactor); + writer.Write(d.ShelfIncWeight); + writer.Write(d.ReliefSeedOffset); + } + private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) { int n = bp.MapSize; diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index c9a0033..14e233e 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -33,6 +33,13 @@ namespace IslaApocalypse.Core // Change this if your namespace is different // are retired. Default: v5. public static string TerrainCurve = "v5"; + // Terrain detail passes (task 10): "v1" = shelf micro-relief + drainage + // incision as one judged unit (requires the curve; no-op when it is off); + // "off" disables both. ShelfReliefAmp is the micro-relief amplitude in + // metres. Defaults: v1, 3 m. + public static string TerrainDetail = "v1"; + public static float ShelfReliefAmp = 3.0f; + public static void LoadConfig() { string path = "res://ServerConfig.json"; @@ -118,6 +125,20 @@ namespace IslaApocalypse.Core // Change this if your namespace is different GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); } + // Extract the terrain-detail gate + relief amplitude + if (data.ContainsKey("TerrainDetail")) + { + string detail = (string)data["TerrainDetail"]; + if (detail == "off" || detail == "v1") + TerrainDetail = detail; + else + GD.PrintErr($"[ConfigManager] Unknown TerrainDetail '{detail}'. Keeping '{TerrainDetail}'."); + } + if (data.ContainsKey("ShelfReliefAmp")) + { + ShelfReliefAmp = (float)data["ShelfReliefAmp"]; + } + switch (profile) { case "4K": diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index aadc5df..35e4695 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -87,6 +87,20 @@ namespace IslaApocalypse.Core public float K5, K6; } + /// + /// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section, + /// terrain-water task 10): shelf micro-relief + drainage incision parameters. + /// Null when detail was off. Metadata only — heights are already detailed. + /// + public class TerrainDetailInfo + { + public ushort Version; + public float ReliefAmpM, ReliefFreqIslands; + public float IncK, IncP, IncCapM; + public float SeaClampRaw, CraterExclFactor, ShelfIncWeight; + public int ReliefSeedOffset; + } + public class WorldBlueprint { public int MapSize; @@ -114,6 +128,9 @@ namespace IslaApocalypse.Core // The curve that shaped HeightMap (v2 TCRV section); null = raw legacy profile. public TerrainCurveInfo TerrainCurve; + + // The detail passes that shaped HeightMap (TDTL section); null = no detail. + public TerrainDetailInfo TerrainDetail; } // 2. The Parser Utility @@ -237,6 +254,7 @@ namespace IslaApocalypse.Core else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true); else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint); else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint); + else if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(reader, blueprint); else { // The property the redesign exists to buy: future sections (water, @@ -430,6 +448,19 @@ namespace IslaApocalypse.Core return true; } + private static bool ParseTerrainDetail(BinaryReader reader, WorldBlueprint blueprint) + { + var d = new TerrainDetailInfo(); + d.Version = reader.ReadUInt16(); + d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle(); + d.IncK = reader.ReadSingle(); d.IncP = reader.ReadSingle(); d.IncCapM = reader.ReadSingle(); + d.SeaClampRaw = reader.ReadSingle(); d.CraterExclFactor = reader.ReadSingle(); + d.ShelfIncWeight = reader.ReadSingle(); + d.ReliefSeedOffset = reader.ReadInt32(); + blueprint.TerrainDetail = d; + return true; + } + private static bool ParseRoadTier(BinaryReader reader, List into) { int pathCount = reader.ReadInt32(); diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 961b38a..ccbc7fb 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -46,10 +46,15 @@ public partial class MapGenerator : TextureRect private FastNoiseLite _plateauNoise; private FastNoiseLite _strengthNoise; - // v5: the selected knot preset (task-09 taste batch: COMPACT or BALANCED); - // null = curve off. + // v5: the selected knot preset; null = curve off. private CurveKnots _curveKnots; + // Task-10 detail passes (shelf micro-relief + drainage incision): gated by + // TerrainDetail, active only with the curve on (the masks are curve-band + // defined). The relief noise seeds from resolvedSeed + 7409. + private bool _detailOn; + private FastNoiseLite _reliefNoise; + // 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). @@ -112,6 +117,11 @@ public partial class MapGenerator : TextureRect _plateauNoise = MakeModulationNoise(HeightCurve.PLATEAU_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS); _strengthNoise = MakeModulationNoise(HeightCurve.STRENGTH_SEED_OFFSET, HeightCurve.STRENGTH_FREQ_ISLANDS); } + _detailOn = _curveOn && ConfigManager.TerrainDetail == "v1"; + if (_detailOn) + _reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS); + else if (ConfigManager.TerrainDetail == "v1" && !_curveOn) + GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off."); // THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!) // Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast! @@ -299,6 +309,18 @@ public partial class MapGenerator : TextureRect StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET, PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6 } : null, + TerrainDetail = _detailOn ? new TerrainDetailInfo + { + Version = TerrainDetailPass.VERSION, + ReliefAmpM = ConfigManager.ShelfReliefAmp, + ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS, + IncK = TerrainDetailPass.INC_K, IncP = TerrainDetailPass.INC_P, + IncCapM = TerrainDetailPass.INC_CAP_M, + SeaClampRaw = TerrainDetailPass.SEA_CLAMP, + CraterExclFactor = TerrainDetailPass.CRATER_EXCL_FACTOR, + ShelfIncWeight = TerrainDetailPass.SHELF_INC_WEIGHT, + ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET + } : null, FormatVersion = 2, Params = new BlueprintParams { @@ -462,30 +484,54 @@ public partial class MapGenerator : TextureRect // 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; + // --- PASS 2a: curve + shelf micro-relief (task 10 pass A) --- + // classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on, + // the shelf-ness-weighted noise skin (risers and peaks untouched). + float reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f; for (int x = 0; x < MapSize; x++) { for (int y = 0; y < MapSize; y++) { float raw = _heightMap[x, y]; - float classifyH = raw; float curvedH; if (_curveOn) { - // v4: per-column shelf modulation — anchors and strength from the - // low-frequency fields; ordering safety by construction (amplitudes - // bounded; asserted at all 8 field-extreme corners per generation). + // per-column shelf modulation (v4) — anchors and strength from the + // low-frequency fields; ordering safety by construction. float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP; float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP; float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f); curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots); + + if (_detailOn) + { + float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots); + if (wShelf > 0f) + curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf; + } } else { curvedH = raw; } - // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- + _heightMapClassify[x, y] = raw; // uncurved; carve joins in pass 2c + _heightMap[x, y] = curvedH; + } + } + + // --- PASS 2b: drainage incision (task 10 pass B) --- + if (_curveOn && _detailOn) + RunIncisionPass(); + + // --- PASS 2c: THE CRATER CARVE (The Flooded Bay & Landbridge Fix!) --- + // The carve remains the FINAL authority on its own terrain: applied after + // curve/relief/incision, to both maps, with the original expression. + float physicalCraterRadius = _impactRadius * 0.80f; + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); // We only carve the physical hole at 80% of the radius to guarantee a landbridge! @@ -494,16 +540,77 @@ public partial class MapGenerator : TextureRect float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); // Dialed back to -0.15f as per your excellent instinct! float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; - classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); - curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); + _heightMapClassify[x, y] = Mathf.Lerp(_heightMapClassify[x, y], carveTarget, craterDepth * 0.9f); + _heightMap[x, y] = Mathf.Lerp(_heightMap[x, y], carveTarget, craterDepth * 0.9f); } - - _heightMapClassify[x, y] = classifyH; - _heightMap[x, y] = curvedH; } } } + /// + /// Task-10 pass B: D8 flow accumulation over the curved+relieved land, then + /// depth = K · accum^p · slope, masked to the risers (shelves feathered to 30 %, + /// toe and peaks zero, crater excluded), capped, and clamped to sea + 1 m. + /// Prints its own MEASURED depth distribution — the tuning/report source. + /// + private void RunIncisionPass() + { + ulong t0 = Time.GetTicksMsec(); + int n = MapSize; + int total = n * n; + + float[] flat = new float[total]; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + flat[x * n + y] = _heightMap[x, y]; + + int[] accum = TerrainDetailPass.FlowAccumulation(flat, n, out float[] drop); + double accumSeconds = (Time.GetTicksMsec() - t0) / 1000.0; + + float capRaw = TerrainDetailPass.INC_CAP_M / 251f; + float exclR = _impactRadius * TerrainDetailPass.CRATER_EXCL_FACTOR; + float featherR = _impactRadius * TerrainDetailPass.CRATER_FEATHER_FACTOR; + long incised = 0, clampHits = 0; + var depthsM = new System.Collections.Generic.List(1 << 20); + + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + float raw = _heightMapClassify[x, y]; + float w = TerrainDetailPass.IncisionWeight(raw, _curveKnots); + if (w <= 0f) continue; + + float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); + if (distToCrater < exclR) continue; + if (distToCrater < featherR) + w *= (distToCrater - exclR) / (featherR - exclR); + + int i = x * n + y; + float depth = TerrainDetailPass.INC_K + * Mathf.Pow(accum[i], TerrainDetailPass.INC_P) * drop[i]; + depth = Mathf.Min(depth, capRaw) * w; + if (depth <= 0f) continue; + + float nh = _heightMap[x, y] - depth; + if (nh < TerrainDetailPass.SEA_CLAMP) + { + nh = TerrainDetailPass.SEA_CLAMP; + clampHits++; + } + float realized = _heightMap[x, y] - nh; + if (realized * 251f >= 0.5f) { incised++; depthsM.Add(realized * 251f); } + _heightMap[x, y] = nh; + } + } + + depthsM.Sort(); + float P(double q) => depthsM.Count == 0 ? 0 : depthsM[Mathf.Clamp((int)(q * depthsM.Count), 0, depthsM.Count - 1)]; + double totalSeconds = (Time.GetTicksMsec() - t0) / 1000.0; + GD.Print($"{T()} [Incision] accumulation {accumSeconds:F1}s, total {totalSeconds:F1}s."); + GD.Print($"{T()} [Incision] incised cells (≥0.5 m): {incised}; depth m: p50 {P(0.5):F1}, p90 {P(0.9):F1}, p99 {P(0.99):F1}, max {(depthsM.Count > 0 ? depthsM[depthsM.Count - 1] : 0):F1}; sea-clamp hits {clampHits}."); + } + private void CalculateTrueOcean() { Queue queue = new Queue(); diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index 9c0a5cc..10128cb 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -121,6 +121,7 @@ public partial class RoundTripHarness : Node ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads); ok &= CompareWater(a, b); ok &= CompareTerrainCurve(a, b); + ok &= CompareTerrainDetail(a, b); if (ok) GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + @@ -215,6 +216,31 @@ public partial class RoundTripHarness : Node return true; } + private bool CompareTerrainDetail(WorldBlueprint a, WorldBlueprint b) + { + if (a.TerrainDetail == null && b.TerrainDetail == null) + { + GD.Print("[Harness] TDTL: absent in source — nothing to compare (and none reappeared)."); + return true; + } + if (a.TerrainDetail == null || b.TerrainDetail == null) + { + GD.PrintErr("[Harness] TDTL presence mismatch between source and reread."); + return false; + } + var da = a.TerrainDetail; var db = b.TerrainDetail; + float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.IncK, da.IncP, da.IncCapM, da.SeaClampRaw, da.CraterExclFactor, da.ShelfIncWeight, da.ReliefSeedOffset }; + float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.IncK, db.IncP, db.IncCapM, db.SeaClampRaw, db.CraterExclFactor, db.ShelfIncWeight, db.ReliefSeedOffset }; + for (int i = 0; i < fa.Length; i++) + if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) + { + GD.PrintErr("[Harness] TDTL fields differ."); + return false; + } + GD.Print($"[Harness] TDTL equal (detail v{da.Version})."); + return true; + } + private bool CompareRoads(string tier, List a, List b) { if (a.Count != b.Count) diff --git a/Tools/Scripts/TerrainDetailPass.cs b/Tools/Scripts/TerrainDetailPass.cs new file mode 100644 index 0000000..756160d --- /dev/null +++ b/Tools/Scripts/TerrainDetailPass.cs @@ -0,0 +1,143 @@ +using Godot; +using System; + +/// +/// The terrain DETAIL passes (terrain-water task 10) — pure numeric array machinery +/// (D-035; a named future C++ candidate, kept standalone): +/// +/// PASS A — shelf micro-relief: a medium-frequency noise skin (±ShelfReliefAmp, +/// default 3 m) weighted by shelf-ness, so the compressed shelves get their +/// rolling texture back while risers and peaks stay untouched. +/// +/// PASS B — drainage incision: D8 steepest-descent flow routing + accumulation +/// over the curved terrain; depth = K · accum^p · localSlope (capped), masked to +/// the risers (feathered ~30 % onto shelves, zero on the toe and above the +/// plateau top, zero near the crater), clamped so carved terrain never drops +/// below sea + 1 m. The channels double as the future river routes (Phase C). +/// +/// Ordering (enforced by the caller): curve → micro-relief → incision → crater +/// carve. The classify map never sees any of it. +/// +public static class TerrainDetailPass +{ + public const ushort VERSION = 1; + + // Pass A — micro-relief. + public const float RELIEF_AMP_DEFAULT_M = 3f; // config dial: ShelfReliefAmp (metres) + public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features) + public const int RELIEF_SEED_OFFSET = 7409; + + // Pass B — incision. K/p tuned against the depth targets (gullies 8–15 m, + // trunks ~25 m, cap 30 m); the tuning run's achieved distribution is in the + // task-10 report. + public const float INC_K = 0.78f; + public const float INC_P = 0.45f; // concave: many fingers, few deep trunks + public const float INC_CAP_M = 30f; // IncisionMax + public const float SEA_CLAMP = 0.15f + 1f / 251f; // carved height ≥ sea + 1 m + public const float SHELF_INC_WEIGHT = 0.3f; // shelves get washes, not gorges + public const float CRATER_EXCL_FACTOR = 1.2f; // zero incision inside this × CraterRadius + public const float CRATER_FEATHER_FACTOR = 1.4f; // ...feathering to full by this × CraterRadius + + /// + /// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0 + /// through the risers (feather extends 30 % of the band half-width past each + /// shelf edge). Covers both shelves. + /// + public static float ShelfWeight(float raw, CurveKnots k) + { + return Mathf.Max(BandBump(raw, k.K3, k.K4), BandBump(raw, k.K5, k.K6)); + } + + private static float BandBump(float h, float lo, float hi) + { + float half = (hi - lo) * 0.5f; + float t = Mathf.Abs(h - (lo + half)) / half; // 0 centre, 1 at band edge + // full inside 60 % of the band, linear feather to zero at 130 % + return Mathf.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f); + } + + /// + /// Incision mask from the RAW input height: 0 below the red-ceiling input (K2) + /// and above the plateau top (K6); 1 on the riser bands; SHELF_INC_WEIGHT on the + /// shelf bands; smooth feathers (15 % of the local band width) at every boundary. + /// + public static float IncisionWeight(float raw, CurveKnots k) + { + if (raw <= k.K2 || raw >= k.K6) return 0f; + if (raw < k.K3) // foothill riser: feather in from K2, feather toward shelf weight at K3 + return EdgeBlend(raw, k.K2, k.K3, 0f, 1f, SHELF_INC_WEIGHT); + if (raw < k.K4) // bench + return SHELF_INC_WEIGHT; + if (raw < k.K5) // mid riser + return EdgeBlend(raw, k.K4, k.K5, SHELF_INC_WEIGHT, 1f, SHELF_INC_WEIGHT); + // plateau band: shelf weight, feathering to zero at K6 + float w = (k.K6 - raw) / ((k.K6 - k.K5) * 0.15f); + return Mathf.Min(SHELF_INC_WEIGHT, Mathf.Clamp(w, 0f, 1f) * SHELF_INC_WEIGHT); + } + + private static float EdgeBlend(float h, float lo, float hi, float wIn, float wMid, float wOut) + { + float f = (hi - lo) * 0.15f; + if (h < lo + f) return Mathf.Lerp(wIn, wMid, (h - lo) / f); + if (h > hi - f) return Mathf.Lerp(wMid, wOut, (h - (hi - f)) / f); + return wMid; + } + + /// + /// D8 flow accumulation over a height field (row-major idx = x·n + y). + /// Steepest-descent routing (drop / distance, diagonals ÷√2), deterministic + /// tie-break (fixed neighbour order, first winner). Cells with no lower + /// neighbour are pits/outlets (no outflow). accum = upslope contributing cells + /// including self; steepestDrop = drop per pixel toward the chosen neighbour. + /// + public static int[] FlowAccumulation(float[] h, int n, out float[] steepestDrop) + { + int total = n * n; + int[] downstream = new int[total]; + steepestDrop = new float[total]; + int[] dx = { 1, -1, 0, 0, 1, 1, -1, -1 }; + int[] dy = { 0, 0, 1, -1, 1, -1, 1, -1 }; + float[] invDist = { 1f, 1f, 1f, 1f, 0.7071068f, 0.7071068f, 0.7071068f, 0.7071068f }; + + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + int i = x * n + y; + float hc = h[i]; + float best = 0f; + int bestIdx = -1; + for (int d = 0; d < 8; d++) + { + int nx = x + dx[d], ny = y + dy[d]; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + float grade = (hc - h[ni]) * invDist[d]; + if (grade > best) + { + best = grade; + bestIdx = ni; + } + } + downstream[i] = bestIdx; + steepestDrop[i] = best; + } + } + + // Height-descending order: each cell pushes its accumulated count downstream. + float[] keys = (float[])h.Clone(); + int[] order = new int[total]; + for (int i = 0; i < total; i++) order[i] = i; + Array.Sort(keys, order); // ascending + + int[] accum = new int[total]; + for (int i = 0; i < total; i++) accum[i] = 1; + for (int i = total - 1; i >= 0; i--) + { + int c = order[i]; + int d = downstream[c]; + if (d >= 0) accum[d] += accum[c]; + } + return accum; + } +} From 21dc3d16ce462e88e77e00566366c080f6dc775c Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 18:13:58 -0400 Subject: [PATCH 09/11] docs: TDTL section, TerrainDetail gate, v5 winner config notes (terrain-water task 10) Co-Authored-By: Claude Fable 5 --- Core/Scripts/BLUEPRINT_FORMAT.md | 15 +++++++++------ Tools/Scripts/README.md | 11 +++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md index da4d9f9..6c0da54 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -56,6 +56,7 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`. | `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. | | `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. | | `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset`. **When `curveVersion ≥ 5`, a 9 B preset extension follows:** `u8 presetId` (1 = compact, 2 = balanced) · `f32 k5` · `f32 k6` — with the four base knot slots this makes the effective curve unambiguous from the record alone (record total 95 B; the length-framed section + version byte keep every layout change safe) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. | +| `TDTL` / `0x4C544454` | 38 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `f32 incK` · `f32 incP` · `f32 incCapM` · `f32 seaClampRaw` · `f32 craterExclFactor` · `f32 shelfIncWeight` · `i32 reliefSeedOffset` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve): shelf micro-relief (±reliefAmpM, shelf-ness weighted) and D8 drainage incision (`depth = K·accum^p·slope`, capped, riser-masked, sea+1 m clamped, crater-excluded). **Metadata only** — heights are already detailed; the incision channels are the designated future river routes (Phase C). Machinery: `Tools/Scripts/TerrainDetailPass.cs`. | | `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. | | `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. | | `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q − 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. | @@ -106,12 +107,14 @@ body). which model produced it (the `WBTB` levels are the observable). - **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the road-section table note). -- **`TerrainCurve`** (`"v5-compact"` | `"v5-balanced"` | `"off"`; interim default - `"v5-balanced"` — the task-09 taste batch's tri-state, resolved when the developer's verdict - picks the winner as plain `"v5"`; `"v1"`–`"v4"` retired and rejected loudly) — whether the - height-redistribution curve (terraced ascent, corner-eased junctions, spatially modulated - shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires; COMPACT = 65 % - lowland cordillera-from-plains, BALANCED = 60 % lowland gradual approach) shapes `HGTS`. Biome/water classification is curve-invariant by +- **`TerrainCurve`** (`"v5"` | `"off"`, default `"v5"` — the task-09 gate's winner, the + BALANCED preset; the tri-state and `"v1"`–`"v4"` are retired and rejected loudly) — whether + the height-redistribution curve (terraced ascent, corner-eased junctions, spatially modulated + shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires, 60 % lowland) shapes + `HGTS`. +- **`TerrainDetail`** (`"v1"` | `"off"`, default `"v1"`; no-op without the curve) — the task-10 + detail passes as one judged unit: shelf micro-relief (`ShelfReliefAmp` metres, default 3) and + drainage incision. When on, `TDTL` records the parameters. Biome/water classification is curve-invariant by construction (it classifies the retained uncurved heights); town positions and everything 3D follow the curved terrain. When on, `TCRV` records the effective parameters including the per-seed `spikeMax`. diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 22799ee..7b5b18a 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -11,15 +11,18 @@ Generates the entire 2D blueprint. Roughly in order: 2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor` (`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile. - When `TerrainCurve` is a `"v5-*"` preset (interim default `"v5-balanced"`; the task-09 taste - batch resolves the tri-state), the calibrated height-redistribution curve + When `TerrainCurve: "v5"` (the default — the task-09 gate's BALANCED winner), the calibrated + height-redistribution curve (`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable lowlands, a walkable foothill bench at 100±12 m, the white mountain-town plateau at 220±20 m — shelf heights and strength drift across the island so no two flanks wear the same ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain after noise/falloff/Trench and before the crater carve; biome classification reads a - retained uncurved map, so biomes are identical either way. Drops the `0_height` hillshade - snapshot (hypsometric bands × NW hillshade) in both modes. + retained uncurved map, so biomes are identical either way. With `TerrainDetail: "v1"` (the + default) two detail passes follow the curve: shelf micro-relief (±3 m rolling skin on the + benches/plateaus) and D8 drainage incision (rain-cut ravines in the risers — the future + river routes; `TerrainDetailPass.cs`). Drops the `0_height` hillshade snapshot (hypsometric + bands × NW hillshade) in both modes. 3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar — the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass. From 822c1530c3deaf9164471031ea18e12af36534d2 Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 18:20:16 -0400 Subject: [PATCH 10/11] tune: INC_K 0.78 -> 1.40 from the measured depth distribution (terrain-water task 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First tuning run (seed 1375359975): p50 2.3 / p90 6.1 / p99 13.6 / max 30.0 m — typical gullies under the 8-15 m target. K=1.40 confirmed: p50 3.3 / p90 10.2 / p99 22.9 / max 30.0, zero sea-clamp hits. Co-Authored-By: Claude Fable 5 --- Tools/Scripts/TerrainDetailPass.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/Scripts/TerrainDetailPass.cs b/Tools/Scripts/TerrainDetailPass.cs index 756160d..b7cf84f 100644 --- a/Tools/Scripts/TerrainDetailPass.cs +++ b/Tools/Scripts/TerrainDetailPass.cs @@ -30,7 +30,7 @@ public static class TerrainDetailPass // Pass B — incision. K/p tuned against the depth targets (gullies 8–15 m, // trunks ~25 m, cap 30 m); the tuning run's achieved distribution is in the // task-10 report. - public const float INC_K = 0.78f; + public const float INC_K = 1.40f; public const float INC_P = 0.45f; // concave: many fingers, few deep trunks public const float INC_CAP_M = 30f; // IncisionMax public const float SEA_CLAMP = 0.15f + 1f / 251f; // carved height ≥ sea + 1 m From 1b98fb5a17e60fc2bb32593a8b24b03452038153 Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 19:52:33 -0400 Subject: [PATCH 11/11] fix: double crater carve on aliased maps when the curve is off (terrain-water task 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass-2c restructure carved sequentially through both map references; with TerrainCurve off the classify map aliases the height map, so the second Lerp re-carved already-carved cells — a deeper bowl and shifted crater-adjacent water/biomes on curve-off runs only. Caught by the task-10 continuity oracle (md5 vs the batch-08 curve-off baseline); curve-on runs were unaffected (separate arrays, oracle 24/24). Both values are now read before either write. Co-Authored-By: Claude Fable 5 --- Tools/Scripts/MapGenerator.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index ccbc7fb..40528cb 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -540,8 +540,13 @@ public partial class MapGenerator : TextureRect float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); // Dialed back to -0.15f as per your excellent instinct! float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; - _heightMapClassify[x, y] = Mathf.Lerp(_heightMapClassify[x, y], carveTarget, craterDepth * 0.9f); - _heightMap[x, y] = Mathf.Lerp(_heightMap[x, y], carveTarget, craterDepth * 0.9f); + // Read BOTH before writing EITHER: with the curve off the two maps + // alias the same array, and a sequential read-modify-write carved + // the crater twice (caught by the task-10 continuity oracle). + float preClassify = _heightMapClassify[x, y]; + float preCurved = _heightMap[x, y]; + _heightMapClassify[x, y] = Mathf.Lerp(preClassify, carveTarget, craterDepth * 0.9f); + _heightMap[x, y] = Mathf.Lerp(preCurved, carveTarget, craterDepth * 0.9f); } } }