islaApocalypse-v2/Tools/Scripts/Shaping.cs
beezm 8e55326a84 Phase 2a: continuous grade — smooth the upper staircase, preserve the lowlands
rev 3 of the curve redesign. The developer's verdict on the 01 baseline was that the
LOWLANDS ARE GOOD; the fault is the terracing above them. So this adds a second curve
mode that preserves the low plain bit-for-bit and replaces everything above the flood
line with one smooth monotone climb.

Core/ContinuousCurve — piecewise, and the pieces have different loyalties:
- at/below sea: identity, as ever.
- above sea to K2: DELEGATES to HeightCurve's own toe+red branches. Not "equivalent" —
  the same code path, so the same floats. Oracle (d) holds it to that.
- above the ceiling: a Fritsch-Carlson (PCHIP) monotone spline to the 420 m cap,
  C1-joined to the red band's exit slope. Monotone by construction for any ordered
  control points, which retires the 24-corner sweep; a 10k strict-increase sample runs
  per seed anyway, because "cannot fail" is worth a millisecond.
- Build() REFUSES rather than degrades: a ceiling near the old bench, a drama that folds
  the summit under its own onset, control-point secants that are not strictly increasing
  (the no-magnet rule, enforced rather than hoped for).

Only BENCH_*/PLATEAU_* are dropped. SEA/ORANGE_CEIL/RED_CEIL survive because they are
the storm-ladder FLOOD TIERS and they live inside the preserved lowland; PEAK_CAP and
the per-seed spikeMax normalization survive as the summit.

Shelf detail is forced off in continuous mode: the flat benches it de-slabbed no longer
exist, and painting noise on the climb now would pre-judge what erosion should carve.

Oracle, all hard checks passing:
- (a1) curve off is bit-identical to Phase 1's dump.
- (a2) staircase mode is bit-identical to TASK 01's dump — the control is provably the
  control, not a re-derivation. (CurveBaselineTool is pinned to Staircase so the config
  default moving to Continuous cannot drift it.)
- (d) lowlands bit-identical to the staircase over 3.6M cells, every continuous variant,
  both seeds. The lifted_WRONG bookend fails it on 1.6M cells, as intended.
- (f) sea identity per CELL, not per count, including 67M cells at 8192.

The finding, measured and recorded in the batch scratch: the massif SHRANK. Land above
100 m goes 14.9% -> 4.8%, above 220 m 4.5% -> 0.6%. A feather sweep to the practical
floor recovers ~1.3 points, so this is structural, not a tuning miss: the staircase's
highland area was an artifact of the bench and plateau acting as magnets, and a curve
with no magnets preserves the raw distribution's bottom-heavy shape. "No terraces" and
"the same land up high" are not both available from curve work alone.

Exploration batch, not convergence. A tuning pass follows once a direction is picked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
2026-08-20 03:41:27 -04:00

334 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ PASS 2a — the redistribution curve and the shelf detail, applied per column, producing the
/// TWO HEIGHT FIELDS. Ported from the reference's <c>MapGenerator.GenerateTopography</c> pass-2
/// loop (<c>Tools/Scripts/MapGenerator.cs</c> ~:692-735 at tag <c>pre-rewrite-reference</c>,
/// commit <c>ab78883</c>). → D-050.
///
/// ═══ WHAT RUNS HERE, AND WHAT DELIBERATELY DOES NOT ═══
///
/// The reference's pass 2 is three sub-passes in a fixed order:
///
/// 2a curve + detail ← THIS FILE
/// 2b hydraulic erosion (render map only) — a later chat2 task
/// 2c the crater carve (both maps, last word) — a later chat2 task
///
/// Only 2a is ported. The ORDER matters and is recorded here so the later two land in the right
/// place: erosion runs AFTER detail and BEFORE the carve, and the carve stays last because it is
/// the final authority on its own terrain.
///
/// ═══ ⚠ THE PASS-1/PASS-2 BOUNDARY IS HARD, NOT AN INTERLEAVE ═══
///
/// The curve's summit spike maps <c>[K6, hMaxSeed]</c> onto the peak band, so no pixel can be
/// curved until every pixel has been scanned. <see cref="Pass1Result.HMaxSeed"/> carries that
/// number across the boundary, and <c>AssertMonotonic</c> runs in between — after the max is
/// known, before the first column is shaped.
///
/// ═══ ⚠ ONE DELIBERATE DEVIATION FROM THE REFERENCE, AND WHY ═══
///
/// The reference shaped IN PLACE: <c>_heightMap</c> held pass-1 raw, then pass 2 overwrote it
/// column by column. This port leaves <see cref="Pass1Result.Height"/> untouched and allocates
/// the render field.
///
/// The arithmetic is identical — every column still reads <c>raw</c> and writes <c>curvedH</c>.
/// What changes is that the raw field SURVIVES the pass, which is what lets oracle checks (a) and
/// (b) compare against it directly instead of regenerating and trusting that the regeneration
/// matched. An invariant you can check by construction beats one you have to believe.
///
/// (With the curve OFF nothing is allocated at all and both fields alias the pass-1 array,
/// exactly as the reference did — see <see cref="Pass2Result.FieldsAreAliased"/>.)
/// </summary>
public static class Shaping
{
/// <summary>
/// Apply pass 2a to a pass-1 field.
///
/// ⚠ Pure with respect to <paramref name="p1"/>: nothing here writes to its arrays.
/// </summary>
public static Pass2Result Shape(Pass1Result p1, TerrainGenConfig cfg)
{
ulong t0 = Time.GetTicksMsec();
int mapSize = p1.MapSize;
var notes = new List<string>();
// ═══ THE OFF PATH — the A/B control ═══
//
// Nothing to separate, so nothing is allocated: both fields reference the pass-1 array,
// as the reference's `_heightMapClassify = (_curveOn || _erosionOn) ? new[…] : _heightMap`
// did. This path must be BIT-IDENTICAL to Phase 1's output — oracle (a).
if (!cfg.Curve)
{
notes.Add("[Shaping] curve OFF — render and classify alias the pass-1 field (the A/B control).");
// ⚠ Detail REQUIRES the curve — it slides the curve's KNOTS, so there is nothing to
// warp without one. The reference said so out loud rather than silently no-op'ing,
// because a dial that does nothing is worth a line in the log.
if (cfg.ShelfDetail)
notes.Add("[Shaping] shelf detail requested but the curve is off — no-op (detail warps the curve's knots).");
return new Pass2Result(mapSize, p1.Seed, p1.Height, p1.Height,
curveOn: false, detailOn: false, curveModeLabel: "off", variantLabel: cfg.VariantLabel, continuous: null,
knots: null, anchors: null, hMaxSeed: p1.HMaxSeed,
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed,
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
}
// ═══ WHICH CURVE (chat2/02) ═══
//
// Staircase falls through to the faithful task-01 path below, UNTOUCHED — it is the
// control, and controls do not get refactored while they are being compared against.
if (cfg.CurveMode == CurveModeKind.Continuous)
return ShapeContinuous(p1, cfg, notes, t0);
if (cfg.CurveMode == CurveModeKind.LiftedWrong)
return ShapeLifted(p1, cfg, notes, t0);
CurveKnots knots = cfg.Knots;
CurveAnchors anchors = cfg.Anchors;
GenerationScale scale = cfg.Scale;
// ═══ THE MODULATION FIELDS ═══
//
// Three for the curve (bench anchor, plateau anchor, shelf strength), two for detail
// (micro-relief, edge warp). Each is decorrelated by SEED OFFSET and sampled at the bare
// (x, y) — there are no coordinate offsets in this path to normalize. → TerrainNoise.
FastNoiseLite benchNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.BenchSeedOffset, anchors.ElevFreqPerMapWidth, scale);
FastNoiseLite plateauNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.PlateauSeedOffset, anchors.ElevFreqPerMapWidth, scale);
FastNoiseLite strengthNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.StrengthSeedOffset, anchors.StrengthFreqPerMapWidth, scale);
// The curve-off case already returned above, so detail is simply on-or-off from here.
bool detailOn = cfg.ShelfDetail;
FastNoiseLite reliefNoise = null, edgeNoise = null;
float reliefAmpRaw = 0f, edgeAmpRaw = 0f;
float maxEdgeShiftRaw = TerrainDetailPass.MaxEdgeShift(knots);
if (detailOn)
{
reliefNoise = TerrainNoise.CreateModulation(p1.Seed, TerrainDetailPass.ReliefSeedOffset, TerrainDetailPass.ReliefFreqPerMapWidth, scale);
edgeNoise = TerrainNoise.CreateModulation(p1.Seed, TerrainDetailPass.EdgeSeedOffset, TerrainDetailPass.EdgeFreqPerMapWidth, scale);
reliefAmpRaw = WorldScale.RawFromMetres(Mathf.Max(cfg.ShelfReliefAmpM, 0f));
edgeAmpRaw = WorldScale.RawFromMetres(Mathf.Max(cfg.ShelfEdgeVariationM, 0f));
// ⚠ THE CLAMP IS LOUD. The warp is bounded to the largest shift that keeps the knot
// set strictly ordered, so monotonicity can never become a tuning question — but a
// silently ignored dial is worse than a refused one, because the developer A/Bs a
// number that never reached the terrain.
if (edgeAmpRaw > maxEdgeShiftRaw)
{
notes.Add($"[Shaping] ⚠ ShelfEdgeVariation {cfg.ShelfEdgeVariationM:F2} m exceeds this knot set's " +
$"safe bound {WorldScale.MetresFromRaw(maxEdgeShiftRaw):F2} m — CLAMPING.");
edgeAmpRaw = maxEdgeShiftRaw;
}
notes.Add($"[Shaping] detail v{TerrainDetailPass.Version}: relief ±{cfg.ShelfReliefAmpM:F1} m @ " +
$"{TerrainDetailPass.ReliefFreqPerMapWidth:F0}/map, edge warp " +
$"±{WorldScale.MetresFromRaw(edgeAmpRaw):F2} m of INPUT height @ " +
$"{TerrainDetailPass.EdgeFreqPerMapWidth:F0}/map (bound " +
$"{WorldScale.MetresFromRaw(maxEdgeShiftRaw):F2} m).");
}
// ═══ ⭐ THE MONOTONICITY PROOF — between the passes, before the first column ═══
notes.Add(HeightCurve.AssertMonotonic(p1.HMaxSeed, knots, anchors, detailOn ? edgeAmpRaw : 0f));
// ═══ THE CRATER SEAM — INERT THIS PHASE ═══
//
// No crater exists yet. CraterRadius defaults to 0, which makes CraterDetailWeight return
// 1 everywhere: detail applies unmasked, and the distance is not even computed. Ported
// now because the exclusion is part of THIS pass's contract — bolting it on after the
// carve arrives is exactly how the reference's 532-px below-sea bug happened.
float craterRadius = cfg.CraterRadius;
bool craterActive = craterRadius > 0f;
var craterCentre = new Vector2(cfg.CraterCenterX, cfg.CraterCenterY);
var height = new float[mapSize, mapSize];
var classify = new float[mapSize, mapSize];
float hMin = float.MaxValue, hMax = float.MinValue;
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
float raw = p1.Height[x, y];
// ⭐ THE CLASSIFY FIELD IS THE RAW FIELD. Not "approximately", not "before most
// things" — bit-for-bit, and asserted as such by oracle (b).
classify[x, y] = raw;
// ── the per-column shelf modulation (reference v4) ──
// Anchors and strength come from three very-low-frequency fields, so the bench
// and plateau elevations drift across the island instead of being one global
// terrace. Amplitudes are bounded and every extreme is swept by AssertMonotonic,
// so ordering safety is by construction rather than by hope.
float benchLo = anchors.BenchBase + benchNoise.GetNoise2D(x, y) * anchors.BenchAmp;
float plateauLo = anchors.PlateauBase + plateauNoise.GetNoise2D(x, y) * anchors.PlateauAmp;
float shelfSpan = HeightCurve.ShelfSpan((strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f, anchors);
// ── detail yields to the crater (inert until the carve lands) ──
float wCrater = 0f;
if (detailOn)
{
wCrater = craterActive
? TerrainDetailPass.CraterDetailWeight(
new Vector2(x, y).DistanceTo(craterCentre), craterRadius)
: 1f;
}
// ── PASS B: the knot-block warp. Slides K3/K4/K5 for THIS column. ──
float edgeShift = detailOn ? edgeNoise.GetNoise2D(x, y) * edgeAmpRaw * wCrater : 0f;
float curvedH = HeightCurve.Apply(raw, p1.HMaxSeed,
benchLo, shelfSpan, plateauLo, shelfSpan, knots, anchors, edgeShift);
// ── PASS A: the micro-relief skin, on the shelves only ──
// ⚠ Fed the SAME edgeShift, so the skin follows the shelf wherever pass B moved
// its boundary. Passing 0 here would put the texture on the wrong ground.
if (detailOn && wCrater > 0f)
{
float wShelf = TerrainDetailPass.ShelfWeight(raw, knots, edgeShift);
if (wShelf > 0f)
curvedH += reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf * wCrater;
}
if (curvedH < hMin) hMin = curvedH;
if (curvedH > hMax) hMax = curvedH;
height[x, y] = curvedH;
}
}
// ⚠ THE REFERENCE'S PASS 2 CONTINUES HERE with erosion (~:737-805, render map only) and
// then the crater carve (~:807-833, both maps, last word). Both DEFERRED to later chat2
// tasks. The classify field above is finalized bar the carve — which is exactly the
// property that makes it an oracle.
return new Pass2Result(mapSize, p1.Seed, height, classify,
curveOn: true, detailOn: detailOn, curveModeLabel: "staircase", variantLabel: cfg.VariantLabel, continuous: null,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax,
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
}
/// <summary>
/// ⭐ THE CONTINUOUS MODE (chat2/02): the staircase's toe+red lowland preserved bit-for-bit,
/// everything above it replaced by one smooth monotone FritschCarlson climb.
/// → <see cref="ContinuousCurve"/> for the shape and its refusals.
///
/// ═══ ⚠ WHY THIS LOOP IS SO MUCH SIMPLER THAN THE STAIRCASE'S ═══
///
/// No bench/plateau/strength modulation fields — the anchors they modulated are dropped.
/// No shelf detail — the flat benches it existed to de-slab no longer exist above the
/// lowlands, and the preserved lowland never needed it. Micro-relief/roughness on the
/// continuous upper grade is DEFERRED: erosion is the real detail source, and painting
/// noise on the climb now would pre-judge what erosion should carve. So: one spline per
/// seed, one Apply per column, and the classify field untouched as always.
/// </summary>
private static Pass2Result ShapeContinuous(Pass1Result p1, TerrainGenConfig cfg,
List<string> notes, ulong t0)
{
int mapSize = p1.MapSize;
CurveKnots knots = cfg.Knots;
CurveAnchors anchors = cfg.Anchors;
// Same per-seed summit normalization as the staircase — the pass-1/pass-2 boundary
// stays hard for the same reason.
float spikeMax = HeightCurve.EffectiveSpikeMax(p1.HMaxSeed, knots, anchors);
// Build throws (refusing the generation) on any config that cannot hit the target
// silhouette; the tool's _Ready catches and Quit(2)s.
var curve = ContinuousCurve.Build(knots, anchors, spikeMax,
cfg.LowlandCeilingM, cfg.ClimbFeather, cfg.SummitDrama);
// Monotone by construction — and proven anyway, per seed, because "cannot fail" is
// exactly the claim worth a millisecond of checking.
notes.Add(curve.AssertStrictlyIncreasing());
notes.Add($"[Shaping] continuous: {curve.DescribeControlPoints()}");
if (cfg.ShelfDetail)
notes.Add("[Shaping] ⚠ shelf detail requested but FORCED OFF in continuous mode — the flat " +
"benches it de-slabbed no longer exist, and detail on the climb is deferred to erosion.");
var height = new float[mapSize, mapSize];
var classify = new float[mapSize, mapSize];
float hMin = float.MaxValue, hMax = float.MinValue;
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
float raw = p1.Height[x, y];
classify[x, y] = raw; // ⭐ the oracle field, as always
float h = curve.Apply(raw); // sea identity + lowland delegation
// + climb + tail, all inside
if (h < hMin) hMin = h;
if (h > hMax) hMax = h;
height[x, y] = h;
}
}
return new Pass2Result(mapSize, p1.Seed, height, classify,
curveOn: true, detailOn: false, curveModeLabel: "continuous", variantLabel: cfg.VariantLabel, continuous: curve,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax,
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
}
/// <summary>
/// ⚠⚠ THE DELIBERATELY-WRONG BOOKEND (chat2/02): an even linear remap of ALL land —
/// <c>[SEA, spikeMax] → [SEA, PEAK_CAP]</c>. Every land column above sea is lifted by the
/// same ×~1.5 factor, which hoists the entire island off its shoreline and destroys the
/// broad low plain the developer likes.
///
/// It exists so the preserved-lowland variants can be seen AGAINST the mistake — the batch's
/// contrast anchor, per the rev-3 task. It is not a candidate, it takes no knobs, and its
/// oracle-(d) FAILURE is expected and reported as confirmation, not as a bug.
/// </summary>
private static Pass2Result ShapeLifted(Pass1Result p1, TerrainGenConfig cfg,
List<string> notes, ulong t0)
{
int mapSize = p1.MapSize;
CurveKnots knots = cfg.Knots;
CurveAnchors anchors = cfg.Anchors;
float spikeMax = HeightCurve.EffectiveSpikeMax(p1.HMaxSeed, knots, anchors);
float scale = (anchors.PeakCap - anchors.Sea) / (spikeMax - anchors.Sea);
notes.Add($"[Shaping] ⚠⚠ LIFTED_WRONG: even remap [sea, {spikeMax:F4}] → [sea, {anchors.PeakCap:F4}] " +
$"(×{scale:F3} on every land height) — the WRONG direction, kept as the contrast bookend.");
var height = new float[mapSize, mapSize];
var classify = new float[mapSize, mapSize];
float hMin = float.MaxValue, hMax = float.MinValue;
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
float raw = p1.Height[x, y];
classify[x, y] = raw;
float h;
if (raw <= anchors.Sea) h = raw; // even the wrong direction keeps the
// coastline — sea identity is not optional
else if (raw >= spikeMax) h = anchors.PeakCap + (raw - spikeMax) * anchors.TailSlope;
else h = anchors.Sea + (raw - anchors.Sea) * scale;
if (h < hMin) hMin = h;
if (h > hMax) hMax = h;
height[x, y] = h;
}
}
return new Pass2Result(mapSize, p1.Seed, height, classify,
curveOn: true, detailOn: false, curveModeLabel: "lifted_WRONG", variantLabel: cfg.VariantLabel, continuous: null,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax,
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
}
}
}