Stage 1 fills the IslandFalloff.cs stub from the reference, verbatim: the coast shelf (below-sea only, depth-preserving, held strictly below sea by MathF.BitDecrement — the clamp that makes "cannot move the waterline" exact rather than statistical) and the islet layer (OffshoreBlob, CalibrateThreshold against the field's ACTUAL distribution, the OffshoreZoneWeight moat + pre-Trench-falloff test). Both run as pass 1b — OffshorePass, a second sweep over the finished pass-1 arrays with the same per-pixel arithmetic in the same order — and HMaxSeed is retaken AFTER them, as the reference did. That closes chat2/00 Drift §2. The value did not move on any of 15 runs; the order is now right by construction and oracle (l) prints it every time. Stage 2 is the reshape, OffshoreSettings.Hybrid(): a seeded floor of ≥2 N / ≥4 S islands placed by a PCG32 off the world seed — min-separated, clear of ALL existing land by a gap so each stamp is its own connected component by construction, fully inside the zone so the moat and falloff protections gate the floor exactly as they gate the organic layer — plus the noise layer on top, smaller (freq 16), lower (24 m pre-curve, which the preserved toe squashes to ~5 m and keeps there across curve tweaks), flatter (core 0.25), crisper (edge sharpness 2.5, stamp rim jittered so it is rigid without being a compass disc), south-weighted (0.007 N / 0.012 S, blended across the midline), corners allowed (trench mask 0.90→0.97). Every lifted cell is tagged with its hemisphere — NORTH is rows [0, N/2), y runs south, read off the spine's southern fade and the southern sinker, not invented — and carried through Pass1Result → Pass2Result for a consumer that does not exist yet. Two things the probes taught, both now in the code: the first organic densities produced 183 blobs of which 174 were noise debris (a six-config sweep fixed that), and the reference's lerp-to-crest leaves shallow humps across the seabed wherever a blob fails to surface (a guard reverts them outside any surviving island's skirt; specks by component membership, bumps by location). The faithful control keeps both, because it is the reference — its islets measure min 1 cell, median 28. Oracle, all hard checks passing: floor met on every hybrid seed (N 2–4, S 9–16); zero land bridges; every offshore-OFF land cell bit-identical with offshore ON; tag ↔ coastline consistent in both fields; curve-off still bit-identical to Phase 1's dump and continuous_restored to task 03's. Both gates default OFF, deliberately: flipping them is the act that retires the Phase-1 regression dumps, and that belongs in a task that re-baselines the oracles. The faithful control on seed 8675309 produced one north island. That is the gap the floor exists to close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
325 lines
16 KiB
C#
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 > 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 < 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
|
|
// seeded floor needs the whole depth/falloff field to exist first. → 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,
|
|
offshoreCentres: offshore?.Centres,
|
|
offshoreLiftedCells: offshore == null ? 0 : offshore.LiftedOrganic + offshore.LiftedSeeded - offshore.LiftedReverted,
|
|
notes: offshore?.Notes);
|
|
}
|
|
}
|
|
}
|