islaApocalypse-v2/Tools/Scripts/Topography.cs
beezm e8571b2778 chat2/06: offshore islands — revert the forced floor, tune the organic layer for coverage
The seeded floor from chat2/05 — FloorNorth/FloorSouth, the PCG32 placement, StampWeight,
StampRadiusFrac/StampCoreFrac/StampEdgeJitter, the centre separation and land gap, the
floor self-check and oracle (h), the centre rings on the tag overlay — is reverted out
whole, as a forward commit. It looked stamped. It is one checkout away at 3b96e06. What
remains is ONE island mechanism: the organic noise-field layer, with chat2/05 stage 2's
shape untouched (freq 16, crest 24 m pre-curve, core 0.25, sharpness 2.5, corners on,
moat + falloff test + outer bound unmoved), now OffshoreSettings.Organic(). The count per
hemisphere is a statistical outcome of the tuning, read off a table, never guaranteed.

The south was measured before anything moved (OffshoreDiagnosis, over task 01's pool at
2048): the premise that the south generates fewer islands is not what the field says.
The NORTH has 1.75x less island-eligible ocean (56 % of its ocean is zone vs 69 %; 233
zone rows per column vs 407) and under half the island candidates (1,183 over-threshold
peaks in zone vs 2,422). Per cell the south is richer too (484 vs 414 candidates per
Mcell). The gate that loses candidates is the moat in the north and the falloff test in
the south — the bulging south coast pushes the 0.72 contour out — but neither binds
enough to suppress it. So the fix is density, and the binding constraint for "a couple
north, consistently" is the north.

The knobs: Density (the main one, the calibration quantile) and SouthWeight (south density
= Density x weight — applied to the quantile, so a south island looks exactly like a north
one; there are just more). Three levels batched on 12 seeds at 4096, SouthWeight 1.25:
0.016 N 2/4.4/10 S 8/13.8/21 (every seed clears N >= 2, S >= 3, but N's minimum IS 2),
0.022 N 3/6.3/12 S 15/19.9/29 (the preset — the first level whose north minimum sits
comfortably above the target), 0.030 N 3/7.8/15 S 19/25.2/33 (the too-many bookend). The
south weight is the developer's stated preference, not a fix; the SouthWeight 1.0 tables
(S 6/10.9/16 at 0.022) are in the batch's scratch/ for the comparison.

The guards, so more density does not buy slop, each a revert by component membership:
specks (< 3e-5 of the map's area, ~500 cells at 4096 — 70-100 per field at these
densities, the noise caps the reference also surfaced), clusters (the smaller of two
islands whose shores are within 0.8 % of the map width — 0-7 per field), blobs (> 6e-4 —
never bit). And the reference's submerged humps, now attributed by HUMP rather than by
bounding box: the first 4096 plate probe showed hollow ghost rings beside islands — a
guard-dropped island's cap went back to seabed but its rim survived inside its
neighbour's bbox keep-region. A hump (one connected raised region) now stays only if it
holds a kept island, and a dropped island's own cap goes even inside a kept hump.

Oracle, all passing: a1 (Phase-1 dump), a3 (task-03 dump), a4 NEW — offshore OFF at 8192
bit-identical to the terrain-curve-v1 tag's own 04 gallery dump over 67,108,864 cells —
j0 shelf inert on land, and per field moat / mainland unmoved / tag-coastline / HMaxSeed
(unchanged, 1.289169) / classify on all 36 table fields and 4 plates. Batch:
BatchRoot(6, "offshore_organic_tune") — exactly 4 plates (three densities on 1063685222,
density_mid on 424242, the table's sparsest south) + count_table.md/.csv + diagnosis.md.

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

325 lines
16 KiB
C#

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);
if (offshore != 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;
}
}
return new Pass1Result(mapSize, cfg.Seed, height, preTrenchFalloff, latitudeField,
hMax, hMin, Time.GetTicksMsec() - t0,
hMaxSeedBeforeOffshore: hMaxBeforeOffshore,
isOffshoreIsland: offshore?.Tag,
islandHemisphere: offshore?.Hemi,
offshoreLiftedCells: offshore == null ? 0 : offshore.LiftedOrganic - offshore.LiftedReverted,
notes: offshore?.Notes,
offshoreLedger: offshore?.ToLedger());
}
}
}