Ports the reference's v5 height curve and shelf-detail passes onto Phase 1's shape and re-calibrates them against this repo's actual pass-1 distribution. This is the BASELINE the reshape gets judged against, not the reshape. Core (engine-free, D-060): - WorldScale — THE vertical yardstick. One metres/raw number (251), replacing the prototype's three duplicate M_PER_UNIT constants and ~20 bare literals. The chunk-height coupling it had there is recorded as a DEFERRED vault decision, not inherited. RawFromMetres divides, matching the reference bit-for-bit. - HeightCurve — the 7 bands, the frozen corner-fix blends, the per-seed spike normalization, the 24-corner monotonicity sweep that throws and refuses. Identity at and below sea, which everything downstream rests on. - CurveKnots / CurveAnchors — input knots (measured percentiles) and output anchors (storm ladder) split apart and both made parameters, so the anchors are A/B-able without editing source. The reference's shipped knots are kept beside the measured ones as the fidelity yardstick. - TerrainDetailPass — micro-relief skin plus the shelf-edge KNOT warp (which slides K3/K4/K5, not height — that is what keeps monotonicity structural). The crater exclusion is ported and inert until the carve lands. Tools: - Shaping — pass 2a, producing the two height fields. classify is bit-for-bit the raw pass-1 field; render is curved and detailed. Aliased when the curve is off, as the reference did. Pass1Result is left immutable so the oracle can compare. - LandHistogram — the calibration engine AND the diagnostic. The reference shipped six knot literals and threw the measuring instrument away; this rebuilds it. - ShapingOracle + CurveBaselineTool — four automatic checks before anything is looked at, and the batch that runs them. Measured, not assumed: - Knots re-measured over a 6-seed / 12.8M-sample pool. They differ from the reference's by at most 5.6 m of world height, against a 44.7 m per-seed spread — the pass-1 port is faithful. - Oracle all pass, including pass 1 bit-identical to Phase 1's own .f32 dump. - Band shares land on 60/13/10/5/8/3/1 to 0.00 pp. - Knots hold across map size: the 8K delta (5.8 m) sits inside seed noise. The finding the histograms deliver: 83% of land ends below 100 m and 96% below 220 m, with the median column at 13 m. That is the share targets doing exactly what they say, not a bug — and it is the developer's call, which is why nothing here reshapes it and the palette was deliberately left mis-fitted rather than recalibrated to disguise it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
205 lines
10 KiB
C#
205 lines
10 KiB
C#
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, knots: null, anchors: null, hMaxSeed: p1.HMaxSeed,
|
|
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed,
|
|
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
|
|
}
|
|
|
|
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, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
|
|
edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax,
|
|
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
|
|
}
|
|
}
|
|
}
|