islaApocalypse-v2/Tools/Scripts/Topography.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

344 lines
17 KiB
C#

using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ PASS 1 — the raw island height field. THE CROWN-JEWEL PORT.
/// → D-050 ("port, don't re-derive"), `Design - Rewrite - Extraction Manifest.md`.
///
/// Ported from the reference's <c>MapGenerator.GenerateTopography</c> pass-1 loop
/// (<c>Tools/Scripts/MapGenerator.cs</c> ~:553-619 at tag <c>pre-rewrite-reference</c>,
/// commit <c>ab78883</c>). Constants are READ FROM THAT SOURCE, not re-derived from a design
/// summary — that distinction is the whole point of D-050.
///
/// ═══ THE SIX ELEMENTS, IN THE REFERENCE'S EXECUTION ORDER ═══
///
/// 1. wobbled latitude scalar (ref ~:559-562)
/// 2. island falloff / mask (ref ~:567-574, 593)
/// 3. edge / coastline roughness (ref ~:577-578)
/// 4. southern sinker (ref ~:582-587)
/// 5. the Trench (ref ~:595-598)
/// 6. mountain spine (ref ~:605-612)
/// → combine and write (ref ~:618-619, 665-666)
///
/// ⚠⚠ THE ORDER IS LOAD-BEARING AND IS NOT AN IMPLEMENTATION DETAIL.
/// The sinker lands BEFORE the power, so its effect is superlinear. The Trench lands AFTER it,
/// so it is a raw additive wall the exponent never softens. preTrenchFalloff is captured
/// between them. Reordering any of it changes the island.
///
/// ═══ PASS 1b — THE SHELF AND THE ISLETS (chat2/05) ═══
///
/// The reference's pass-1 loop continues past the height write with two more task-11 passes:
/// the submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). v2 runs them
/// as a second sweep over the finished arrays — <see cref="OffshorePass"/> — with the same
/// per-pixel arithmetic in the same order, and then recomputes <c>HMaxSeed</c> AFTER them, as
/// the reference did. Config-gated; both default off (see <c>TerrainGenConfig</c> for why).
///
/// Nothing from pass 2 is here at all: no redistribution curve, no shelf detail, no erosion,
/// no rivers, no water bodies, no crater carve, no biomes.
/// </summary>
public static class Topography
{
// ═══ CONSTANTS, ALL READ FROM THE REFERENCE SOURCE ═══
// Named rather than inlined so each one is greppable and has a home for its reason.
/// <summary>The squircle/ellipse blend weight. Reference: <c>Mathf.Lerp(ellipse, squircle, 0.5f)</c> (~:574).</summary>
private const float BlendSquircleWeight = 0.5f;
/// <summary>
/// The ellipse's scale divisor: <c>ellipticalPos.Length() / (MapSize / 1.3f)</c> (~:572).
///
/// ⚠ AN UNCOMMENTED SHAPE DIAL IN THE REFERENCE, PORTED AS ONE. It sets the ellipse's
/// absolute scale against the squircle's, so it moves the coastline — but the reference
/// carried no comment and the vault's "50/50 blend of two falloffs" description does not
/// expose it. Ported verbatim because the island was tuned with it; named here so the next
/// reader knows it is a dial and not arithmetic. (chat1/00 §2.2.)
/// </summary>
private const float EllipseScaleDivisor = 1.3f;
/// <summary>Edge-noise coordinate multiplier. Reference: <c>GetNoise2D(x * 2.5f, y * 2.5f)</c> (~:577).</summary>
private const float EdgeNoiseCoordScale = 2.5f;
/// <summary>Edge-noise amplitude. Reference: <c>finalFalloff += (edgeNoise * 0.15f) * squircleFalloff</c> (~:578).</summary>
private const float EdgeNoiseAmplitude = 0.15f;
/// <summary>Southern sinker threshold, as a fraction of the map. Reference: <c>MapSize * 0.75f</c> (~:582).</summary>
private const float SouthThresholdFraction = 0.75f;
/// <summary>Southern sinker depth. Reference: <c>finalFalloff += southDepth * 0.6f</c> (~:586).</summary>
private const float SouthSinkAmount = 0.6f;
/// <summary>The falloff exponent. Reference: <c>Mathf.Pow(finalFalloff, 2.5f)</c> (~:593).</summary>
private const float FalloffExponent = 2.5f;
/// <summary>Trench onset, as a fraction of the half-axis. Reference: <c>if (distX &gt; 0.90f)</c> (~:597).</summary>
private const float TrenchOnset = 0.90f;
/// <summary>Trench slope. Reference: <c>finalFalloff += (distX - 0.90f) * 15.0f</c> (~:597-598).</summary>
private const float TrenchSlope = 15.0f;
/// <summary>Spine gate / fade anchor. Reference: <c>if (temperature &lt; 0.65f)</c> and <c>(0.65f - temperature)</c> (~:606, 610).</summary>
private const float SpineLatitudeAnchor = 0.65f;
/// <summary>Spine southern-fade slope. Reference: <c>Clamp((0.65f - temperature) * 4.0f, 0, 1)</c> (~:610).</summary>
private const float SpineFadeSlope = 4.0f;
/// <summary>Spine cubic concentration. Reference: <c>Mathf.Pow(mountainSpine, 3.0f)</c> (~:611).</summary>
private const float SpineConcentration = 3.0f;
/// <summary>Spine amplitude in raw height units. Reference: <c>* 0.6f</c> (~:611).</summary>
private const float SpineAmplitude = 0.6f;
/// <summary>Latitude wobble amplitude. Reference: <c>temperature += (tempNoise * 0.2f) - 0.1f</c> (~:561) — i.e. ±0.1.</summary>
private const float LatitudeWobbleSpan = 0.2f;
/// <summary>
/// The latitude noise's decorrelation offset, IN MAP WIDTHS.
///
/// ⚠⚠ THE ONE PLACE THIS PORT DELIBERATELY DOES NOT COPY THE REFERENCE'S LITERAL.
///
/// The reference wrote <c>GetNoise2D(x + 1000, y + 1000)</c> — an offset in RAW PIXELS.
/// Because frequency already carries a 1/MapSize normalization, a pixel offset does not hold
/// still across map sizes: 1000 px is 1.0 normalized units at 4K but 0.5 at 8K, so a resize
/// silently samples a DIFFERENT SLICE of the noise field. Same feature scale, different
/// realization — the same seed gives a different world at a different size, for no reason
/// anyone wrote down. (chat1/00 §4.2; the §6 tightening carried in from chat1/01.)
///
/// 1000 px at the reference's canonical MapSize of 8192 = 0.1220703125 map widths. Pinning
/// THAT reproduces the reference's realization exactly at 8K, and holds it still everywhere
/// else — which the reference did not.
/// </summary>
private const float LatitudeNoiseOffsetMapWidths = 1000f / 8192f;
/// <summary>
/// Generate the raw pass-1 height field.
/// </summary>
public static Pass1Result Generate(TerrainGenConfig cfg)
{
ulong t0 = Time.GetTicksMsec();
GenerationScale scale = cfg.Scale;
int mapSize = cfg.MapSize;
FastNoiseLite noise = TerrainNoise.Create(cfg.Seed, scale);
var height = new float[mapSize, mapSize];
var preTrenchFalloff = new float[mapSize, mapSize];
var latitudeField = new float[mapSize, mapSize];
// ⚠ MAP-CENTRED, not crater-centred. Reference ~:550:
// Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f);
// The crater's centre (_impactCenter) is a separate, randomly placed field used only in
// pass 2 and the biome pass. The spine and the mask both key off the MAP centre — the
// spine is a map-centred ridge, per `Design - Terrain - Mountain Spine.md`. Confirmed
// against source; no crater coupling exists in pass 1.
float centerX = mapSize / 2.0f;
float centerY = mapSize / 2.0f;
float axisX = cfg.IslandAxisX;
float axisY = cfg.IslandAxisY;
// Precomputed once — the reference recomputed these per pixel; identical results.
float latitudeNoiseOffset = scale.OffsetInMapWidths(LatitudeNoiseOffsetMapWidths);
float southThreshold = scale.Fraction(SouthThresholdFraction);
float halfSpan = mapSize / 2.0f;
float hMax = float.MinValue;
float hMin = float.MaxValue;
// ⚠ x IS THE OUTER LOOP, as in the reference. Numerically irrelevant here, but a
// PARALLEL port must reduce hMax/hMin rather than share them — noted before someone
// reaches for Parallel.For and quietly races on the running max.
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
// ═══ 1. WOBBLED LATITUDE SCALAR (ref ~:559-562) ═══
//
// ⚠⚠ THE REFERENCE CALLED THIS "temperature". IT IS NOT CLIMATE, AND THIS PORT
// WILL NOT CALL IT THAT.
//
// It is a stage-1-local LATITUDE FIELD that terrain GEOMETRY reads — the spine's
// southern fade is its only pass-1 consumer. The prototype named it temperature,
// then let biomes read the same array, and that shared name is a large part of
// how climate and terrain got fused in the first place. In the rewrite,
// CLIMATE IS STAGE 3 and classifies finished shape (D-049 §2, D-056); this
// scalar is stage 1 and must never be stored as, aliased to, or mistaken for it.
//
// The ±0.1 WOBBLE IS KEPT, and it is load-bearing: it makes the spine's southern
// terminus a low-frequency wave rather than a ruler-straight latitude line.
// Replacing this with a bare y/MapSize would straighten it — the exact opposite
// of the filed spine-meander want. (chat1/00 §2.3.)
float lat = (float)y / mapSize;
float latNoise = (noise.GetNoise2D(x + latitudeNoiseOffset, y + latitudeNoiseOffset) + 1.0f) / 2.0f;
lat += (latNoise * LatitudeWobbleSpan) - (LatitudeWobbleSpan / 2.0f);
latitudeField[x, y] = lat;
// ═══ 2. THE ISLAND FALLOFF / MASK (ref ~:567-574) ═══
float finalFalloff = 0f;
float squircleFalloff = 0f;
if (cfg.IslandFalloff)
{
// Squircle — Max(nx, ny), giving squared-off corners. ISLAND-anchored:
// the axis ratios are applied here.
float nx = Mathf.Abs(x - centerX) / (halfSpan * axisX);
float ny = Mathf.Abs(y - centerY) / (halfSpan * axisY);
squircleFalloff = Mathf.Max(nx, ny);
// Ellipse — vector length, giving a rounded shape.
var ellipticalPos = new Vector2((x - centerX) / axisX, (y - centerY) / axisY);
float ellipticalFalloff = ellipticalPos.Length() / (mapSize / EllipseScaleDivisor);
// 50/50 blend.
finalFalloff = Mathf.Lerp(ellipticalFalloff, squircleFalloff, BlendSquircleWeight);
}
// ═══ 3. EDGE / COASTLINE ROUGHNESS (ref ~:577-578) ═══
//
// ⚠ THE `* squircleFalloff` MODULATION IS PART OF IT AND IS EASY TO DROP.
// It makes the roughness ZERO AT THE ISLAND CENTRE and strongest at the rim —
// which is why it reads as coastline jitter instead of interior grain. The
// vault's description of this element omits it. (chat1/00 §2.2, §4.5.)
//
// The x2.5 coordinate multiplier is NOT an unscaled quantity: it multiplies
// coordinates that the already-normalized frequency then scales, so it is a
// fixed 2.5x RATIO to the base noise at every map size. (chat1/00 §4.2 —
// which corrected the vault on exactly this point.)
if (cfg.IslandFalloff && cfg.EdgeNoise)
{
float edgeNoise = (noise.GetNoise2D(x * EdgeNoiseCoordScale, y * EdgeNoiseCoordScale) + 1.0f) / 2.0f;
finalFalloff += (edgeNoise * EdgeNoiseAmplitude) * squircleFalloff;
}
// ═══ 4. THE SOUTHERN SINKER (ref ~:582-587) ═══
//
// Sinks the stretched land bridges in the bottom 25%. ⚠ BEFORE the power, so its
// effect is superlinear — +0.6 on a falloff already near 1 costs far more height
// than +0.6 near 0. Moving it after the power would change the southern coast.
if (cfg.IslandFalloff && cfg.SouthernSinker && y > southThreshold)
{
float southDepth = (y - southThreshold) / (mapSize - southThreshold);
finalFalloff += southDepth * SouthSinkAmount;
}
// ═══ ⭐ THE PHASE-2 SEAM — captured BEFORE the power and BEFORE the Trench ═══
// (ref ~:591). See Pass1Result.PreTrenchFalloff for why this exact point.
preTrenchFalloff[x, y] = finalFalloff;
if (cfg.IslandFalloff)
finalFalloff = Mathf.Pow(finalFalloff, FalloffExponent);
// ═══ 5. THE TRENCH (ref ~:595-598) ═══
//
// ⚠⚠ MAP-ANCHORED, NOT ISLAND-ANCHORED — distX/distY carry NO axis ratio, unlike
// nx/ny above. That is deliberate and load-bearing: the Trench is a guarantee
// about the MAP BORDER, not about the island's ellipse. It is what makes the
// flood fill's unchecked (0,0) ocean seed safe — the corner is guaranteed
// underwater by a x15 wall. DO NOT "unify" it with the mask's normalization.
// → `Design - Terrain - Island Mask.md`, chat1/00 §4.5.
//
// ⚠ The two clauses are ADDITIVE — a corner pixel takes both.
// ⚠ AFTER the power, so it is a raw additive wall the exponent never softens.
if (cfg.IslandFalloff && cfg.Trench)
{
float distX = Mathf.Abs(x - centerX) / halfSpan;
float distY = Mathf.Abs(y - centerY) / halfSpan;
if (distX > TrenchOnset) finalFalloff += (distX - TrenchOnset) * TrenchSlope;
if (distY > TrenchOnset) finalFalloff += (distY - TrenchOnset) * TrenchSlope;
}
// ═══ 6. THE MOUNTAIN SPINE (ref ~:605-612) ═══
//
// ⚠ THE REFERENCE'S `if (temperature < 0.65f)` GATE IS DROPPED, AND THE RESULT IS
// BIT-IDENTICAL. southernFade = Clamp((0.65 - lat) * 4, 0, 1) already reaches
// EXACTLY ZERO at lat = 0.65 — the same constant the gate tested — so the gate
// guarded a term that had already faded to nothing. It introduced no
// discontinuity and skipped no visible work. southernFade, not the gate, is what
// actually shapes the spine's southern end. (chat1/00 §2.3.)
//
// The gate is expressed below as `fade > 0`, which is the same predicate stated
// where it is true rather than where it is incidental.
float mountainSpine = 0f;
if (cfg.MountainSpine)
{
float southernFade = Mathf.Clamp((SpineLatitudeAnchor - lat) * SpineFadeSlope, 0.0f, 1.0f);
if (southernFade > 0f)
{
// ISLAND-anchored: axisX applies here (ref ~:608).
float distanceToCenterX = Mathf.Abs(x - centerX) / (halfSpan * axisX);
float crest = 1.0f - IslandFalloff.SmoothAbs(distanceToCenterX, IslandFalloff.CREST_EPSILON);
mountainSpine = Mathf.Pow(crest, SpineConcentration) * southernFade * SpineAmplitude;
}
}
// ═══ COMBINE AND WRITE (ref ~:618-619, 665-666) ═══
float rawBase = cfg.BaseNoise ? (noise.GetNoise2D(x, y) + 1.0f) / 2.0f : 0f;
float finalH = rawBase + mountainSpine - (finalFalloff * cfg.FalloffStrength);
if (finalH > hMax) hMax = finalH;
if (finalH < hMin) hMin = finalH;
height[x, y] = finalH;
// The reference's pass 1 CONTINUED HERE with the coast shelf (~:621-640) and the
// offshore islets (~:641-664). v2 runs them as PASS 1b, a second sweep over these
// arrays, immediately below — same per-pixel arithmetic, same order, and the
// slop guards need the whole field to see whole islands. → OffshorePass.
}
}
// ═══ PASS 1b — THE COAST SHELF + OFFSHORE ISLETS (chat2/05) ═══
//
// In place, on `height`. Config-gated; returns null when both are off, in which case
// nothing above is touched and this pass-1 output is bit-identical to Phase 1's.
//
// ⚠⚠ HMaxSeed IS RECOMPUTED AFTER THIS — closing chat2/00 Drift §2. The reference took
// `_hMaxSeed` after the shelf and islets inside the same loop; v2 used to take it before
// they existed. The curve normalizes its summit spike against this value, so the order is
// load-bearing even when the number does not move (an islet crest is ~34 m; a peak is
// ~290 m). Both values are carried so the report states whether it moved, not guesses.
float hMaxBeforeOffshore = hMax;
OffshorePass.Result offshore = OffshorePass.Apply(height, preTrenchFalloff, mapSize, cfg.Seed, cfg.SeaLevel, cfg);
// ═══ PASS 1c — REGION LABELING + THE SPECK REVERT + THE ISLAND TAG (chat2/07) ═══
//
// The general region layer over the classify field (this array), then the origin-blind
// speck revert (config-gated, lower-only, component-only), then the island tag BY
// CONSTRUCTION from the finished labeling. Labeling alone changes nothing; only the revert
// may, and only downward, and only inside a sub-threshold non-mainland component. → RegionPass.
RegionPass.Result regions = cfg.RegionLabeling
? RegionPass.Apply(height, mapSize, cfg.SeaLevel, cfg)
: null;
if (offshore != null || regions != null)
{
hMax = float.MinValue;
hMin = float.MaxValue;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
{
float h = height[x, y];
if (h > hMax) hMax = h;
if (h < hMin) hMin = h;
}
}
var notes = new List<string>();
if (offshore != null) notes.AddRange(offshore.Notes);
if (regions != null) notes.AddRange(regions.Notes);
return new Pass1Result(mapSize, cfg.Seed, height, preTrenchFalloff, latitudeField,
hMax, hMin, Time.GetTicksMsec() - t0,
hMaxSeedBeforeOffshore: hMaxBeforeOffshore,
isIsland: regions?.IsIsland,
islandHemisphere: regions?.IslandHemisphere,
offshoreLiftedCells: offshore == null ? 0 : offshore.LiftedOrganic - offshore.LiftedReverted,
notes: notes,
offshoreLedger: offshore?.ToLedger(),
regions: regions?.Labels,
regionLedger: regions?.Ledger,
regionsPre: regions?.LabelsPre);
}
}
}