From 492b56a87cb6b3592c4af304c0140c33c303cbc4 Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 20:31:41 -0400 Subject: [PATCH] feat: shelf-edge variation -- the red-line scalloping (terrain-water task 10) PASS B, replacing the reverted incision. The developer's sketch asked for the shelf/riser BOUNDARY to be organic, not for water: notches, coves and small peninsulas where a flat shelf meets its riser, instead of the clean oval contour the curve produces. Mechanism (the task's preferred "boundary warp", in its most monotonic-safe form): every shelf/riser boundary is the contour where the raw height crosses K3, K4 or K5, so the boundary is warped by SLIDING THOSE THREE KNOTS per column -- edgeShift = simplex(resolvedSeed + 7507, 12 per island width) x ShelfEdgeVariation. The contours then wander in and out of the terrain instead of tracing an iso-height line. Noise-warped by construction, so there is no grid direction for an artifact to line up on -- the failure mode of the thing this replaces. Why slide knots rather than perturb a weight or the input height: monotonicity becomes structural instead of conditional. The curve is strictly monotonic for ANY ordered knot set, so no derivative bound, no amplitude-vs-feather-width tuning, no way for a dial to invert a column. MaxEdgeShift keeps the set ordered (half the smallest margin to a fixed knot = 12.3 m of input height for the v5 knots); the config dial is clamped to it, loudly. AssertMonotonic now sweeps 24 corners -- the 8 modulation extremes x {-max, 0, +max} shift -- and checks the bound first. K1, K2 and K6 never move, which buys the guarantees exactly rather than statistically: below K2 and above K6 a warped column is bit-identical to an unwarped one, so the red ceiling still floors every shelf edge (storm ladder safe), the 420 m cap still caps, and the toe and summit spikes are untouched. The block slides rigidly, so the bench and mid-riser keep their exact widths -- shelf interiors stay flat, riser interiors keep their profile, and only the foothill riser and plateau stretch to absorb it. The micro-relief mask takes the same shift, so pass A's skin follows the shelf wherever pass B moved its edge. Dial ShelfEdgeVariation (default 5 m of INPUT height -- a boundary displacement, not an elevation change) under the existing TerrainDetail gate; both passes stay one judged unit. TDTL v2 body records relief and edge amp/frequency/seed-offset plus the applied clamp bound; writer, parser and harness follow. No blueprint was written between the revert and this commit, so v2 only ever means relief + edge warp on disk. Co-Authored-By: Claude Opus 5 (1M context) --- Core/Scripts/BlueprintWriter.cs | 3 + Core/Scripts/ConfigManager.cs | 15 ++++- Core/Scripts/MapDataParser.cs | 22 ++++--- Tools/Scripts/HeightCurve.cs | 96 ++++++++++++++++++++---------- Tools/Scripts/MapGenerator.cs | 60 ++++++++++++++----- Tools/Scripts/RoundTripHarness.cs | 4 +- Tools/Scripts/TerrainDetailPass.cs | 51 ++++++++++++---- 7 files changed, 182 insertions(+), 69 deletions(-) diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index e7ff721..25a7854 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -158,6 +158,9 @@ namespace IslaApocalypse.Core writer.Write(d.Version); // u16 writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32 writer.Write(d.ReliefSeedOffset); // i32 + writer.Write(d.EdgeAmpM); writer.Write(d.EdgeFreqIslands); // 2 × f32 + writer.Write(d.EdgeSeedOffset); // i32 + writer.Write(d.EdgeMaxShiftM); // f32 } private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index d0387b5..78bf933 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -33,11 +33,16 @@ 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 (requires the - // curve; no-op when it is off), "off" disables it. ShelfReliefAmp is the - // micro-relief amplitude in metres. Defaults: v1, 3 m. + // Terrain detail passes (task 10): "v1" = shelf micro-relief + shelf-edge + // variation as one judged unit (requires the curve; no-op when it is off); + // "off" disables both. ShelfReliefAmp is the micro-relief amplitude in metres + // of OUTPUT height. ShelfEdgeVariation is the shelf-edge warp amplitude in + // metres of INPUT height — how far the shelf/riser boundary contour is + // displaced, not an elevation change; it is clamped at load time to the + // largest shift that keeps the curve's knots ordered. Defaults: v1, 3 m, 5 m. public static string TerrainDetail = "v1"; public static float ShelfReliefAmp = 3.0f; + public static float ShelfEdgeVariation = 5.0f; public static void LoadConfig() { @@ -137,6 +142,10 @@ namespace IslaApocalypse.Core // Change this if your namespace is different { ShelfReliefAmp = (float)data["ShelfReliefAmp"]; } + if (data.ContainsKey("ShelfEdgeVariation")) + { + ShelfEdgeVariation = (float)data["ShelfEdgeVariation"]; + } switch (profile) { diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index c5f7754..78243ff 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -89,16 +89,19 @@ namespace IslaApocalypse.Core /// /// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section, - /// terrain-water task 10): the shelf micro-relief parameters. Null when detail - /// was off. Metadata only — heights are already detailed. Body version 1 (the - /// reverted relief + D8-incision layout) is longer and differently shaped; the - /// parser refuses it rather than misreading it. + /// terrain-water task 10): shelf micro-relief + shelf-edge variation parameters. + /// Null when detail was off. Metadata only — heights are already detailed. + /// Version 1 (relief + the reverted D8 incision) never left the task-10 batch + /// tree; the parser rejects it rather than misreading its longer payload. /// public class TerrainDetailInfo { public ushort Version; public float ReliefAmpM, ReliefFreqIslands; public int ReliefSeedOffset; + public float EdgeAmpM, EdgeFreqIslands; + public int EdgeSeedOffset; + public float EdgeMaxShiftM; } public class WorldBlueprint @@ -454,14 +457,19 @@ namespace IslaApocalypse.Core d.Version = reader.ReadUInt16(); if (d.Version != BlueprintFormat.TDTL_VERSION) { - // Skip the body and leave TerrainDetail null. The heights are still - // whatever they are — we simply refuse to describe them wrongly. - GD.PrintErr($"[MapDataParser] ⚠ TDTL body version {d.Version} is not the current {BlueprintFormat.TDTL_VERSION} — section skipped, detail metadata unavailable."); + // A TDTL v1 payload (the reverted relief+incision layout) is LONGER and + // laid out differently; reading it as v2 would silently mint plausible + // nonsense. Skip the body and leave TerrainDetail null — the heights are + // still whatever they are, we just refuse to describe them wrongly. + GD.PrintErr($"[MapDataParser] ⚠ TDTL version {d.Version} is not the current {BlueprintFormat.TDTL_VERSION} — section skipped, detail metadata unavailable."); reader.BaseStream.Seek((long)payloadLength - 2L, SeekOrigin.Current); return true; } d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle(); d.ReliefSeedOffset = reader.ReadInt32(); + d.EdgeAmpM = reader.ReadSingle(); d.EdgeFreqIslands = reader.ReadSingle(); + d.EdgeSeedOffset = reader.ReadInt32(); + d.EdgeMaxShiftM = reader.ReadSingle(); blueprint.TerrainDetail = d; return true; } diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs index 2e18ab0..591a950 100644 --- a/Tools/Scripts/HeightCurve.cs +++ b/Tools/Scripts/HeightCurve.cs @@ -42,6 +42,11 @@ public sealed class CurveKnots /// 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. +/// +/// The curve SHAPE is frozen at v5. Task 10 adds no band, anchor or slope — only +/// a per-column `edgeShift` parameter on Apply, which slides the shelf/riser knot +/// block K3/K4/K5 so those three boundaries stop being clean iso-height contours. +/// It is a new input to the same curve, not a new curve. /// public static class HeightCurve { @@ -87,11 +92,29 @@ public static class HeightCurve return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f)); } + /// + /// The curve for ONE column. (task 10 pass B) slides + /// the shelf/riser knot BLOCK — K3/K4/K5 — up or down by a per-column amount, + /// leaving K1/K2/K6 fixed. Every shelf↔riser boundary is the contour where the + /// raw height crosses one of those three knots, so shifting them makes those + /// contours wander instead of tracing a clean iso-height line: the shelf edge + /// scallops. Because the block moves rigidly, the bench and mid-riser bands keep + /// their exact widths (their interior shapes are translated, not distorted); only + /// the foothill riser and the plateau stretch or compress to absorb the shift. + /// Monotonicity is structural, not conditional — the curve is monotonic for ANY + /// strictly ordered knot set, and TerrainDetailPass.MaxEdgeShift keeps the set + /// ordered by construction. Below K2 and above K6 the output is bit-identical to + /// an unwarped column, which is what makes the red-ceiling floor and the 420 m + /// peak cap exact under the warp. + /// public static float Apply(float h, float hMaxSeed, - float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k) + float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k, + float edgeShift) { if (h <= SEA) return h; + float k3 = k.K3 + edgeShift, k4 = k.K4 + edgeShift, k5 = k.K5 + edgeShift; + float u, s; if (h < k.K1) { @@ -104,27 +127,27 @@ public static class HeightCurve u = (h - k.K1) / (k.K2 - k.K1); return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise } - if (h < k.K3) + if (h < k3) { - u = (h - k.K2) / (k.K3 - k.K2); + u = (h - k.K2) / (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 < k.K4) + if (h < k4) { - u = (h - k.K3) / (k.K4 - k.K3); + u = (h - k3) / (k4 - k3); return benchLo + u * benchSpan; // bench (min span 6 m — fix 3) } float benchTop = benchLo + benchSpan; - if (h < k.K5) + if (h < k5) { - u = (h - k.K4) / (k.K5 - k.K4); + u = (h - k4) / (k5 - k4); s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1 return benchTop + s * (plateauLo - benchTop); } if (h < k.K6) { - u = (h - k.K5) / (k.K6 - k.K5); + u = (h - k5) / (k.K6 - k5); return plateauLo + u * plateauSpan; // plateau } float plateauTop = plateauLo + plateauSpan; @@ -140,15 +163,25 @@ public static class HeightCurve /// /// 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. + /// the selected preset: all 8 modulation-extreme corners × the shelf-edge warp + /// extremes (±maxEdgeShift and 0) × per-seed spikeMax — 24 corners. The corner + /// fixes lower the slope floors (risers 0.1, spike base 0.05) and the warp + /// squeezes the foothill riser and the plateau; the sweep proves every slope + /// stays strictly positive at the extremes of both. Also checks the knot set + /// itself stays strictly ordered under the warp. Loud throw on failure. /// - public static void AssertMonotonic(float hMaxSeed, CurveKnots k) + public static void AssertMonotonic(float hMaxSeed, CurveKnots k, float maxEdgeShift) { + if (maxEdgeShift < 0f || k.K2 + maxEdgeShift >= k.K3 || k.K5 + maxEdgeShift >= k.K6) + throw new System.InvalidOperationException( + $"[HeightCurve] EDGE-SHIFT BOUND VIOLATION: maxEdgeShift={maxEdgeShift} does not keep K2 0f + ? new float[] { -maxEdgeShift, 0f, maxEdgeShift } + : new float[] { 0f }; foreach (float bl in benchLos) { @@ -156,28 +189,31 @@ public static class HeightCurve { foreach (float sp in spans) { - float prevH = -7f; - float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k); - - void Check(double hd) + foreach (float es in edgeShifts) { - float h = (float)hd; - if (h <= prevH) return; // dedupe float32 samples (task-05 fix) - float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k); - if (v <= prev) - throw new System.InvalidOperationException( - $"[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; - } + float prevH = -7f; + float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, es); - 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); + 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, k, es); + if (v <= prev) + throw new System.InvalidOperationException( + $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}, edgeShift={es}): {v} <= {prev}. Refusing to generate."); + prev = v; + prevH = h; + } + + 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} preset '{k.Name}', 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6})."); + GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners × edge shifts ±{maxEdgeShift:F6}, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6})."); } } diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index e4aecb1..9ae2d6e 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -49,11 +49,16 @@ public partial class MapGenerator : TextureRect // v5: the selected knot preset; null = curve off. private CurveKnots _curveKnots; - // Task-10 detail pass (shelf micro-relief): gated by TerrainDetail, active only - // with the curve on (the mask is curve-band defined). The relief noise seeds - // from resolvedSeed + 7409. + // Task-10 detail passes (shelf micro-relief + shelf-edge variation): gated by + // TerrainDetail, active only with the curve on (both are defined in terms of + // the curve's bands). The relief noise seeds from resolvedSeed + 7409, the + // edge-warp noise from resolvedSeed + 7507. _edgeAmpRaw is the config dial in + // raw height units, already clamped to _maxEdgeShiftRaw. private bool _detailOn; private FastNoiseLite _reliefNoise; + private FastNoiseLite _edgeNoise; + private float _edgeAmpRaw; + private float _maxEdgeShiftRaw; // 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 @@ -119,7 +124,23 @@ public partial class MapGenerator : TextureRect } _detailOn = _curveOn && ConfigManager.TerrainDetail == "v1"; if (_detailOn) + { _reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS); + _edgeNoise = MakeModulationNoise(TerrainDetailPass.EDGE_SEED_OFFSET, TerrainDetailPass.EDGE_FREQ_ISLANDS); + + // The edge warp slides K3/K4/K5; the dial is clamped to the largest shift + // that keeps the knot set strictly ordered, so monotonicity can never be + // a tuning question. Clamping is loud — a silently ignored dial is worse + // than a refused one. + _maxEdgeShiftRaw = TerrainDetailPass.MaxEdgeShift(_curveKnots); + _edgeAmpRaw = Mathf.Max(ConfigManager.ShelfEdgeVariation, 0f) / 251f; + if (_edgeAmpRaw > _maxEdgeShiftRaw) + { + GD.PrintErr($"[MapGenerator] ShelfEdgeVariation {ConfigManager.ShelfEdgeVariation:F2} m exceeds the preset's safe bound {_maxEdgeShiftRaw * 251f:F2} m — clamping."); + _edgeAmpRaw = _maxEdgeShiftRaw; + } + GD.Print($"[MapGenerator] TerrainDetail v1: relief ±{ConfigManager.ShelfReliefAmp:F1} m @ {TerrainDetailPass.RELIEF_FREQ_ISLANDS:F0}/island, edge warp ±{_edgeAmpRaw * 251f:F2} m of input height @ {TerrainDetailPass.EDGE_FREQ_ISLANDS:F0}/island (bound {_maxEdgeShiftRaw * 251f:F2} m)."); + } else if (ConfigManager.TerrainDetail == "v1" && !_curveOn) GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off."); @@ -314,7 +335,11 @@ public partial class MapGenerator : TextureRect Version = TerrainDetailPass.VERSION, ReliefAmpM = ConfigManager.ShelfReliefAmp, ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS, - ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET + ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET, + EdgeAmpM = _edgeAmpRaw * 251f, // as APPLIED (post-clamp), not as configured + EdgeFreqIslands = TerrainDetailPass.EDGE_FREQ_ISLANDS, + EdgeSeedOffset = TerrainDetailPass.EDGE_SEED_OFFSET, + EdgeMaxShiftM = _maxEdgeShiftRaw * 251f } : null, FormatVersion = 2, Params = new BlueprintParams @@ -471,16 +496,20 @@ 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, _curveKnots); + if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots, _detailOn ? _edgeAmpRaw : 0f); - // --- PASS 2: curve (task 05/06) + crater carve --- + // --- PASS 2: curve (task 05/06) + detail (task 10) + crater carve --- // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so // the carve cuts into curved terrain and the rim/bowl shape is untouched by // the curve. Identity at and below sea + this ordering preserve the // Trench/ocean-border guarantee and the crater by construction. classifyH // stays uncurved — see _heightMapClassify; hMaxSeed never touches it. // classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on, - // the shelf-ness-weighted noise skin (risers and peaks untouched). + // the shelf-edge warp (pass B — the knot block slides per column, so the + // shelf/riser boundary contours scallop) and the shelf-ness-weighted noise + // skin (pass A — risers and peaks untouched). Both are read-only consumers of + // `raw`; neither can move a column below the red ceiling or above the cap, + // because K2 and K6 never move and the curve is monotonic between them. float reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f; float physicalCraterRadius = _impactRadius * 0.80f; for (int x = 0; x < MapSize; x++) @@ -492,16 +521,19 @@ public partial class MapGenerator : TextureRect float curvedH; if (_curveOn) { - // per-column shelf modulation (v4) — anchors and strength from the - // low-frequency fields; ordering safety by construction. + // 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, _curveKnots); + + float edgeShift = _detailOn ? _edgeNoise.GetNoise2D(x, y) * _edgeAmpRaw : 0f; + curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots, edgeShift); if (_detailOn) { - float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots); + float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots, edgeShift); if (wShelf > 0f) curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf; } @@ -512,10 +544,8 @@ public partial class MapGenerator : TextureRect } // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- - // Both maps are carved from LOCALS and written once each, so the - // curve-off aliasing (classify IS the height array) cannot double-carve - // — the failure mode that the separate carve loop introduced and that - // the task-10 continuity oracle caught (fix 1b98fb5, now structural). + // Both maps are carved from LOCALS and written once, so the curve-off + // aliasing (classify and height are the same array) cannot double-carve. float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); // We only carve the physical hole at 80% of the radius to guarantee a landbridge! diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index 803a16c..1d57827 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -229,8 +229,8 @@ public partial class RoundTripHarness : Node return false; } var da = a.TerrainDetail; var db = b.TerrainDetail; - float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.ReliefSeedOffset }; - float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.ReliefSeedOffset }; + float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.ReliefSeedOffset, da.EdgeAmpM, da.EdgeFreqIslands, da.EdgeSeedOffset, da.EdgeMaxShiftM }; + float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.ReliefSeedOffset, db.EdgeAmpM, db.EdgeFreqIslands, db.EdgeSeedOffset, db.EdgeMaxShiftM }; for (int i = 0; i < fa.Length; i++) if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) { diff --git a/Tools/Scripts/TerrainDetailPass.cs b/Tools/Scripts/TerrainDetailPass.cs index 4a9d70c..3b6f439 100644 --- a/Tools/Scripts/TerrainDetailPass.cs +++ b/Tools/Scripts/TerrainDetailPass.cs @@ -8,16 +8,19 @@ using Godot; /// default 3 m) weighted by shelf-ness, so the compressed shelves get their /// rolling texture back while risers and peaks stay untouched. /// -/// Output-height only; the classify map never sees it. +/// PASS B — shelf-edge variation: a per-column shift of the shelf/riser KNOT +/// BLOCK (K3/K4/K5) by a low-frequency noise field, so the boundary where a +/// shelf meets its riser wanders in and out instead of tracing a clean height +/// contour — organic notches, coves and peninsulas at the shelf edge. /// -/// NOTE — what this file deliberately no longer contains: the task-10 draft's D8 -/// steepest-descent flow routing, accumulation and drainage incision. It shipped, -/// and it produced the canonical grid artifact — thousands of straight, -/// disconnected, pooling scratches running along the eight D8 neighbour -/// directions, because per-cell steepest descent on a regular grid can only ever -/// route along those eight headings. It is reverted whole. Rivers and erosion are -/// Phase C work: a hydraulic-erosion pass over FINAL terrain, not a routing carve -/// on a grid mid-pipeline. +/// Both are output-height only; the classify map never sees either of them. +/// +/// NOTE — what this file deliberately does NOT contain: the task-10 draft's D8 +/// flow routing / accumulation / drainage incision. It shipped, produced the +/// canonical grid artifact (thousands of straight, disconnected, pooling +/// scratches along the D8 neighbour directions) and was reverted whole. Rivers +/// and erosion are Phase C work — a hydraulic-erosion pass over FINAL terrain, +/// not a per-cell steepest-descent carve on a grid. /// public static class TerrainDetailPass { @@ -29,14 +32,38 @@ public static class TerrainDetailPass public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features) public const int RELIEF_SEED_OFFSET = 7409; + // Pass B — shelf-edge variation. The amplitude is stated in metres of INPUT + // height (raw × 251): it is how far, in raw-height terms, a shelf boundary + // contour is displaced — not an output elevation change. Lateral wander on + // the map is that displacement divided by the local raw gradient. + public const float EDGE_AMP_DEFAULT_M = 5f; // config dial: ShelfEdgeVariation (metres of input height) + public const float EDGE_FREQ_ISLANDS = 12f; // ~12 undulations per island width — a handful of notches + public const int EDGE_SEED_OFFSET = 7507; // per shelf perimeter, not hundreds of teeth + public const float EDGE_SAFETY_FRACTION = 0.5f; // shift ≤ half the smallest margin to a fixed knot + + /// + /// The largest per-column knot shift this preset can take while keeping the + /// knot set strictly ordered (K2 < K3+d, K5+d < K6) with margin to spare. + /// K1/K2/K6 never move, so the toe, the orange/red bands and the summit spike + /// are bit-identical whatever the warp does — which is what makes the + /// red-ceiling and peak-height guarantees exact rather than statistical. + /// + public static float MaxEdgeShift(CurveKnots k) + { + return EDGE_SAFETY_FRACTION * Mathf.Min(k.K3 - k.K2, k.K6 - k.K5); + } + /// /// 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. + /// shelf edge). Covers both shelves. is the same + /// per-column warp the curve is evaluated with, so the micro-relief skin + /// follows the shelf wherever pass B has moved its boundary. /// - public static float ShelfWeight(float raw, CurveKnots k) + public static float ShelfWeight(float raw, CurveKnots k, float edgeShift) { - return Mathf.Max(BandBump(raw, k.K3, k.K4), BandBump(raw, k.K5, k.K6)); + return Mathf.Max(BandBump(raw, k.K3 + edgeShift, k.K4 + edgeShift), + BandBump(raw, k.K5 + edgeShift, k.K6)); } private static float BandBump(float h, float lo, float hi)