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) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-08 20:31:41 -04:00
parent 3b6d166458
commit 492b56a87c
7 changed files with 182 additions and 69 deletions

View file

@ -158,6 +158,9 @@ namespace IslaApocalypse.Core
writer.Write(d.Version); // u16 writer.Write(d.Version); // u16
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32 writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32
writer.Write(d.ReliefSeedOffset); // i32 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) private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)

View file

@ -33,11 +33,16 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
// are retired. Default: v5. // are retired. Default: v5.
public static string TerrainCurve = "v5"; public static string TerrainCurve = "v5";
// Terrain detail passes (task 10): "v1" = shelf micro-relief (requires the // Terrain detail passes (task 10): "v1" = shelf micro-relief + shelf-edge
// curve; no-op when it is off), "off" disables it. ShelfReliefAmp is the // variation as one judged unit (requires the curve; no-op when it is off);
// micro-relief amplitude in metres. Defaults: v1, 3 m. // "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 string TerrainDetail = "v1";
public static float ShelfReliefAmp = 3.0f; public static float ShelfReliefAmp = 3.0f;
public static float ShelfEdgeVariation = 5.0f;
public static void LoadConfig() public static void LoadConfig()
{ {
@ -137,6 +142,10 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
{ {
ShelfReliefAmp = (float)data["ShelfReliefAmp"]; ShelfReliefAmp = (float)data["ShelfReliefAmp"];
} }
if (data.ContainsKey("ShelfEdgeVariation"))
{
ShelfEdgeVariation = (float)data["ShelfEdgeVariation"];
}
switch (profile) switch (profile)
{ {

View file

@ -89,16 +89,19 @@ namespace IslaApocalypse.Core
/// <summary> /// <summary>
/// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section, /// 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 /// terrain-water task 10): shelf micro-relief + shelf-edge variation parameters.
/// was off. Metadata only — heights are already detailed. Body version 1 (the /// Null when detail was off. Metadata only — heights are already detailed.
/// reverted relief + D8-incision layout) is longer and differently shaped; the /// Version 1 (relief + the reverted D8 incision) never left the task-10 batch
/// parser refuses it rather than misreading it. /// tree; the parser rejects it rather than misreading its longer payload.
/// </summary> /// </summary>
public class TerrainDetailInfo public class TerrainDetailInfo
{ {
public ushort Version; public ushort Version;
public float ReliefAmpM, ReliefFreqIslands; public float ReliefAmpM, ReliefFreqIslands;
public int ReliefSeedOffset; public int ReliefSeedOffset;
public float EdgeAmpM, EdgeFreqIslands;
public int EdgeSeedOffset;
public float EdgeMaxShiftM;
} }
public class WorldBlueprint public class WorldBlueprint
@ -454,14 +457,19 @@ namespace IslaApocalypse.Core
d.Version = reader.ReadUInt16(); d.Version = reader.ReadUInt16();
if (d.Version != BlueprintFormat.TDTL_VERSION) if (d.Version != BlueprintFormat.TDTL_VERSION)
{ {
// Skip the body and leave TerrainDetail null. The heights are still // A TDTL v1 payload (the reverted relief+incision layout) is LONGER and
// whatever they are — we simply refuse to describe them wrongly. // laid out differently; reading it as v2 would silently mint plausible
GD.PrintErr($"[MapDataParser] ⚠ TDTL body version {d.Version} is not the current {BlueprintFormat.TDTL_VERSION} — section skipped, detail metadata unavailable."); // 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); reader.BaseStream.Seek((long)payloadLength - 2L, SeekOrigin.Current);
return true; return true;
} }
d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle(); d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle();
d.ReliefSeedOffset = reader.ReadInt32(); d.ReliefSeedOffset = reader.ReadInt32();
d.EdgeAmpM = reader.ReadSingle(); d.EdgeFreqIslands = reader.ReadSingle();
d.EdgeSeedOffset = reader.ReadInt32();
d.EdgeMaxShiftM = reader.ReadSingle();
blueprint.TerrainDetail = d; blueprint.TerrainDetail = d;
return true; return true;
} }

View file

@ -42,6 +42,11 @@ public sealed class CurveKnots
/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m, /// 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, /// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization,
/// modulation fields/seed offsets, the classify-map invariant. /// 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.
/// </summary> /// </summary>
public static class HeightCurve 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)); return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f));
} }
/// <summary>
/// The curve for ONE column. <paramref name="edgeShift"/> (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.
/// </summary>
public static float Apply(float h, float hMaxSeed, 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; if (h <= SEA) return h;
float k3 = k.K3 + edgeShift, k4 = k.K4 + edgeShift, k5 = k.K5 + edgeShift;
float u, s; float u, s;
if (h < k.K1) if (h < k.K1)
{ {
@ -104,27 +127,27 @@ public static class HeightCurve
u = (h - k.K1) / (k.K2 - k.K1); u = (h - k.K1) / (k.K2 - k.K1);
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise 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 s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1
return RED_CEIL + s * (benchLo - RED_CEIL); 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) return benchLo + u * benchSpan; // bench (min span 6 m — fix 3)
} }
float benchTop = benchLo + benchSpan; 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 s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
return benchTop + s * (plateauLo - benchTop); return benchTop + s * (plateauLo - benchTop);
} }
if (h < k.K6) if (h < k.K6)
{ {
u = (h - k.K5) / (k.K6 - k.K5); u = (h - k5) / (k.K6 - k5);
return plateauLo + u * plateauSpan; // plateau return plateauLo + u * plateauSpan; // plateau
} }
float plateauTop = plateauLo + plateauSpan; float plateauTop = plateauLo + plateauSpan;
@ -140,33 +163,45 @@ public static class HeightCurve
/// <summary> /// <summary>
/// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for /// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for
/// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax. /// the selected preset: all 8 modulation-extreme corners × the shelf-edge warp
/// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the /// extremes (±maxEdgeShift and 0) × per-seed spikeMax — 24 corners. The corner
/// sweep proves they stay strictly positive everywhere. Loud throw on failure. /// 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.
/// </summary> /// </summary>
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<K3±d and K5±d<K6 (preset {k.Name}). Refusing to generate.");
float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP }; float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP };
float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP }; float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP };
float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX }; float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX };
float[] edgeShifts = maxEdgeShift > 0f
? new float[] { -maxEdgeShift, 0f, maxEdgeShift }
: new float[] { 0f };
foreach (float bl in benchLos) foreach (float bl in benchLos)
{ {
foreach (float pl in plateauLos) foreach (float pl in plateauLos)
{ {
foreach (float sp in spans) foreach (float sp in spans)
{
foreach (float es in edgeShifts)
{ {
float prevH = -7f; float prevH = -7f;
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k); float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, es);
void Check(double hd) void Check(double hd)
{ {
float h = (float)hd; float h = (float)hd;
if (h <= prevH) return; // dedupe float32 samples (task-05 fix) if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k); float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k, es);
if (v <= prev) if (v <= prev)
throw new System.InvalidOperationException( 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."); $"[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; prev = v;
prevH = h; prevH = h;
} }
@ -178,6 +213,7 @@ public static class HeightCurve
} }
} }
} }
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}).");
} }
} }

View file

@ -49,11 +49,16 @@ public partial class MapGenerator : TextureRect
// v5: the selected knot preset; null = curve off. // v5: the selected knot preset; null = curve off.
private CurveKnots _curveKnots; private CurveKnots _curveKnots;
// Task-10 detail pass (shelf micro-relief): gated by TerrainDetail, active only // Task-10 detail passes (shelf micro-relief + shelf-edge variation): gated by
// with the curve on (the mask is curve-band defined). The relief noise seeds // TerrainDetail, active only with the curve on (both are defined in terms of
// from resolvedSeed + 7409. // 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 bool _detailOn;
private FastNoiseLite _reliefNoise; 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, // 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 // 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"; _detailOn = _curveOn && ConfigManager.TerrainDetail == "v1";
if (_detailOn) if (_detailOn)
{
_reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS); _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) else if (ConfigManager.TerrainDetail == "v1" && !_curveOn)
GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off."); 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, Version = TerrainDetailPass.VERSION,
ReliefAmpM = ConfigManager.ShelfReliefAmp, ReliefAmpM = ConfigManager.ShelfReliefAmp,
ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS, 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, } : null,
FormatVersion = 2, FormatVersion = 2,
Params = new BlueprintParams 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 // 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 // band, so the monotonicity assertion must run against the EFFECTIVE per-seed
// curve — after hMaxSeed is known, before any pixel is curved. // 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 // 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 carve cuts into curved terrain and the rim/bowl shape is untouched by
// the curve. Identity at and below sea + this ordering preserve the // the curve. Identity at and below sea + this ordering preserve the
// Trench/ocean-border guarantee and the crater by construction. classifyH // Trench/ocean-border guarantee and the crater by construction. classifyH
// stays uncurved — see _heightMapClassify; hMaxSeed never touches it. // stays uncurved — see _heightMapClassify; hMaxSeed never touches it.
// classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on, // 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 reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f;
float physicalCraterRadius = _impactRadius * 0.80f; float physicalCraterRadius = _impactRadius * 0.80f;
for (int x = 0; x < MapSize; x++) for (int x = 0; x < MapSize; x++)
@ -492,16 +521,19 @@ public partial class MapGenerator : TextureRect
float curvedH; float curvedH;
if (_curveOn) if (_curveOn)
{ {
// per-column shelf modulation (v4) — anchors and strength from the // v4: per-column shelf modulation — anchors and strength from the
// low-frequency fields; ordering safety by construction. // 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 benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP;
float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP; float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP;
float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f); 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) if (_detailOn)
{ {
float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots); float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots, edgeShift);
if (wShelf > 0f) if (wShelf > 0f)
curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf; 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!) --- // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
// Both maps are carved from LOCALS and written once each, so the // Both maps are carved from LOCALS and written once, so the curve-off
// curve-off aliasing (classify IS the height array) cannot double-carve // aliasing (classify and height are the same 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).
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
// We only carve the physical hole at 80% of the radius to guarantee a landbridge! // We only carve the physical hole at 80% of the radius to guarantee a landbridge!

View file

@ -229,8 +229,8 @@ public partial class RoundTripHarness : Node
return false; return false;
} }
var da = a.TerrainDetail; var db = b.TerrainDetail; var da = a.TerrainDetail; var db = b.TerrainDetail;
float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.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 }; 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++) for (int i = 0; i < fa.Length; i++)
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i]))
{ {

View file

@ -8,16 +8,19 @@ using Godot;
/// default 3 m) weighted by shelf-ness, so the compressed shelves get their /// default 3 m) weighted by shelf-ness, so the compressed shelves get their
/// rolling texture back while risers and peaks stay untouched. /// 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 /// Both are output-height only; the classify map never sees either of them.
/// steepest-descent flow routing, accumulation and drainage incision. It shipped, ///
/// and it produced the canonical grid artifact — thousands of straight, /// NOTE — what this file deliberately does NOT contain: the task-10 draft's D8
/// disconnected, pooling scratches running along the eight D8 neighbour /// flow routing / accumulation / drainage incision. It shipped, produced the
/// directions, because per-cell steepest descent on a regular grid can only ever /// canonical grid artifact (thousands of straight, disconnected, pooling
/// route along those eight headings. It is reverted whole. Rivers and erosion are /// scratches along the D8 neighbour directions) and was reverted whole. Rivers
/// Phase C work: a hydraulic-erosion pass over FINAL terrain, not a routing carve /// and erosion are Phase C work — a hydraulic-erosion pass over FINAL terrain,
/// on a grid mid-pipeline. /// not a per-cell steepest-descent carve on a grid.
/// </summary> /// </summary>
public static class TerrainDetailPass 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 float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features)
public const int RELIEF_SEED_OFFSET = 7409; 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
/// <summary>
/// The largest per-column knot shift this preset can take while keeping the
/// knot set strictly ordered (K2 &lt; K3+d, K5+d &lt; 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.
/// </summary>
public static float MaxEdgeShift(CurveKnots k)
{
return EDGE_SAFETY_FRACTION * Mathf.Min(k.K3 - k.K2, k.K6 - k.K5);
}
/// <summary> /// <summary>
/// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0 /// 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 /// through the risers (feather extends 30 % of the band half-width past each
/// shelf edge). Covers both shelves. /// shelf edge). Covers both shelves. <paramref name="edgeShift"/> 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.
/// </summary> /// </summary>
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) private static float BandBump(float h, float lo, float hi)