islaApocalypse-v2/Tools/Scripts/Shaping.cs
beezm c32a3b177c chat2/07: the region-labeling layer — label all land, tag by construction, tunable speck revert
Core/Scripts/RegionLabeling.cs is the shared-infra contract, built to the letter: it runs on
the CLASSIFY (raw) field; land is 8-connected, the deliberate complement of water's 4 (a
diagonal isthmus joins; the water either side stays separate); a component is a maximal
8-connected set of land cells; the MAINLAND is the component containing the map centre —
not merely the largest, which a later fragmentation step could flip — with a flagged
fallback to the largest if the centre were ever water (asserted, never needed: oracle m);
every other component is an island; per component id / sizeCells / centroid / hemisphere
(by centroid, one label per island) / isMainland. Ids come from a fixed scan order and are
proven stable across two generations (oracle o, 16.8M cells). It knows nothing about
offshore or stamped. Engine-free, in Core as C++-candidate math; the hemisphere convention
moved there with it, OffshoreAnalysis aliases it.

Tools/Scripts/RegionPass.cs is pass 1c: label, revert, relabel, tag. The island tag
(renamed IsIsland; IslandHemisphere from the component's centroid; Pass1Result.Regions
carries the whole table) is now a CONSEQUENCE of labeling — every non-mainland component.
That is the fix for the chat2/06 overlay, which tagged only what the offshore pass raised:
1063685222 has 11 natural islands including a 94,511-cell detached mass, 20260821 has 19,
all grey in 06's tags.png and all coloured now. The offshore pass itself is untouched; its
internal Tag stays for its own guards and is no longer exported.

The speck revert (TerrainGenConfig.SpeckRevert / MinLandComponentFrac) lowers every
non-mainland component below the threshold to the mean of its ring of adjacent sea cells,
held strictly below sea. Origin-blind: a natural nub goes the same way as an offshore dot
(6 natural components / 273 cells on the bare 1063685222 field at threshold_mid — reported
as a3r, informational). Lower-only and component-only are asserted cell by cell in the
pass and re-proven on the finished fields by oracle n (mainland bit-identical filter OFF
vs ON; every changed cell in a sub-threshold island, lowered below sea); the mainland is
never a candidate and its size is asserted unchanged across the revert. A reverted
offshore island leaves its submerged skirt as a shoal — not this component, by the rule.
Classify/render consistency is by construction (pass 1, curve identity at sea) and
asserted by oracle k. Deliberately OFF in the bare TerrainGenConfig for the reason the
shelf and islets are: the raw field has natural specks, so default-ON would move the
calibration pool and every regression dump; the batch turns it on.

Thresholds swept on 8 seeds at 4096 (1e-5 / 3e-5 / 1e-4 of the map = 168 / 503 / 1,678
cells): low removes 0–6 nubs per seed, mid (the config default, equal to the offshore
guard) 2–11, high 26–36 — most of the offshore islands, the "fewer, bigger" bookend. The
count/size table carries natural / pre / post counts per hemisphere, min/median/mean/max
and a log-spaced size histogram — the instrument for the southern-stretch step.

Oracle, all passing: a1, a3, a4 (8192, 67M cells) with labeling ON + revert OFF; a6 NEW —
labeling ON + revert OFF on the 06 preset bit-identical to the 06 batch's render field
(labeling is pure analysis); j0; m, n, o, i, j, k, l, b per field. Batch:
BatchRoot(7, "region_labeling") — exactly 4 plates (three thresholds on 1063685222,
threshold_mid on 20260821, the table's most-natural-islands seed), each with grayscale /
.f32 / relief / the labeled-regions overlay / the tag overlay, plus count_size_table.md/.csv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 03:50:33 -04:00

343 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,
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
}
// ═══ 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,
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
}
/// <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);
// ⭐ CALIBRATED when a measurement is supplied (chat2/03 — the staircase's mountain with
// the terraces melted out), ANALYTIC otherwise (chat2/02's feather/drama curve, kept as
// the "before" contrast). Both throw and refuse rather than degrade; the tool's _Ready
// catches and Quit(2)s.
var curve = cfg.ClimbCalibration != null
? ContinuousCurve.BuildCalibrated(knots, anchors, spikeMax,
cfg.LowlandCeilingM, cfg.ClimbCalibration)
: 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,
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
}
/// <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,
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
}
}
}