islaApocalypse-v2/Tools/Scripts/IslandFalloff.cs
beezm 3b96e06c1a chat2/05: offshore islands — rejoin the stub faithfully, then reshape to a guaranteed hybrid
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
2026-08-21 05:14:53 -04:00

257 lines
14 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;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Shape helpers for the island mask, ported from the reference's
/// <c>Tools/Scripts/IslandFalloff.cs</c> — now the WHOLE file, in three parts:
///
/// 1. the spine crest (<see cref="SmoothAbs"/>) — Phase 1, chat1
/// 2. the submarine COAST SHELF (<see cref="CoastShelf"/>) — chat2/05 stage 1
/// 3. the OFFSHORE ISLET layer (<see cref="OffshoreBlob"/>,
/// <see cref="OffshoreZoneWeight"/>, <see cref="CalibrateThreshold"/>) — chat2/05 stage 1
/// + the RESHAPE helpers (<see cref="RigidBlob"/>, <see cref="StampWeight"/>) — stage 2
///
/// The faithful functions keep the reference's names, constants and arithmetic verbatim (D-050);
/// the parameterized overloads beside them exist so the reshape can move a dial without touching
/// the faithful path — the faithful overload CALLS the parameterized one with the reference's
/// constants, so the two cannot drift apart.
///
/// The reference's own separability argument (verbatim): "Every one of these is monotone in the
/// sign of (sea height): none of them can turn water into land or land into water ON ITS OWN."
/// That holds for the shelf, which is why it is invisible until water renders. ⚠ It does NOT hold
/// for the islets, which exist precisely to turn water into land — see the note on
/// <see cref="OffshoreBlob"/>.
///
/// This type is pure math and engine-free. It sits in Tools/ rather than Core/ so the pass-1
/// port stays auditable as one unit against one reference file.
/// </summary>
public static class IslandFalloff
{
/// <summary>
/// The rounding width of the spine crest, in normalized axis-distance units.
/// READ from the reference: <c>IslandFalloff.CREST_EPSILON = 0.03f</c> (~:34).
/// </summary>
public const float CREST_EPSILON = 0.03f;
/// <summary>
/// A smooth absolute value: zero AT zero, with zero slope there, converging to |d| away from it.
/// READ from the reference (~:42-46), verbatim:
/// <code>a = |d|; return a*a / sqrt(a*a + eps*eps);</code>
///
/// ═══ WHY IT EXISTS — do not "simplify" it back to Abs ═══
///
/// The spine's ridge axis is the line x = centre, and a plain <c>1 - |x - cx|</c> peaks there
/// with a SLOPE DISCONTINUITY. On real terrain that put the four columns at the centre axis
/// in the top four slope-step locations out of 5,999 — a visible crease running the whole
/// height of the island, measured at ~170x the off-axis controls. It was blamed on the
/// falloff blend for a long time; transect measurement ruled that out and found this.
///
/// SmoothAbs(0) = 0, so the crest keeps its FULL HEIGHT — it rounds without dropping.
/// Measured on the reference: the 1-px kink fell 99.3%, the centre column dropped from
/// rank 1 of 5,999 to rank 1,364, and nothing anywhere was lowered.
/// → `Design - Terrain - Mountain Spine.md`.
/// </summary>
public static float SmoothAbs(float d, float epsilon)
{
float a = Math.Abs(d);
return a * a / MathF.Sqrt(a * a + epsilon * epsilon);
}
// ═══════════════════════════════════════════════════════════════════════
// 2. THE COAST SHELF — chat2/05 stage 1, faithful (reference ~:48-62)
// ═══════════════════════════════════════════════════════════════════════
//
// depth' = depth · (1 STRENGTH · exp(depth / SCALE_M))
//
// The height curve is identity at and below sea, so it never reached the seabed. Measured
// on the reference: land rises from the shoreline at 0.038 m/px while the seabed drops at
// 0.258 m/px — a shelf on the land side and a ramp on the sea side. This compresses shallow
// depth so the shallows extend much further out, leaving deep water and the Trench alone.
// At the shoreline the seabed starts at (1 STRENGTH) = 22.5 % of its former gradient.
//
// C^∞ everywhere and STRICTLY POSITIVE for positive depth — in exact arithmetic it cannot
// move the waterline by one pixel. ⚠ In float32 it can (see the call site's BitDecrement
// clamp), which is why "cannot" is enforced at the call site and not assumed here.
//
// ⚠ INVISIBLE UNTIL WATER RENDERS. Nothing in Phase 2's hypsometric plates shows it; it is
// ported faithfully now, wired in now, and judged when the water pass lands.
/// <summary>Reference: 0 = off, →1 = a flat lagoon.</summary>
public const float SHELF_STRENGTH = 0.775f;
/// <summary>Reference: metres of depth over which the shelf relaxes back to the raw seabed.</summary>
public const float SHELF_SCALE_M = 100f;
/// <summary>Remaps a positive depth in metres. Returns the new depth in metres. THE FAITHFUL FORM.</summary>
public static float CoastShelf(float depthMetres)
=> CoastShelf(depthMetres, SHELF_STRENGTH, SHELF_SCALE_M);
/// <summary>The parameterized form. With the reference constants it IS the reference — same floats, same order.</summary>
public static float CoastShelf(float depthMetres, float strength, float scaleM)
{
if (depthMetres <= 0f) return depthMetres;
return depthMetres * (1f - strength * MathF.Exp(-depthMetres / scaleM));
}
// ═══════════════════════════════════════════════════════════════════════
// 3. THE OFFSHORE ISLETS — chat2/05 stage 1, faithful (reference ~:64-142)
// ═══════════════════════════════════════════════════════════════════════
//
// Islets are placed by LERPING the seabed TOWARD a target height, not by adding to it, so
// they surface at any ambient depth instead of only where the seafloor happens to be shallow.
//
// ⚠⚠ THIS IS THE ONE LAYER IN PASS 1 THAT TURNS WATER INTO LAND. Every other shaping element
// is monotone in (sea height). Islets add above-sea land, which means they CHANGE
// CLASSIFICATION — new land is new biome/water pixels downstream. That is exactly why they
// belong in the base shape before classification runs: tweaking an island dial later and
// regenerating re-runs classification consistently. It is a known property, not a surprise.
// → chat2/05 report, "modularity".
/// <summary>Reference: ~585 px blobs at 8K — few and sizeable, not a scatter of 50 px debris.</summary>
public const float OFFSHORE_FREQ_ISLANDS = 14f;
/// <summary>Reference: the islet noise field's seed offset. A SEED offset, not a coordinate offset.</summary>
public const int OFFSHORE_SEED_OFFSET = 7607;
/// <summary>Reference: target crest, metres above sea, PRE-CURVE. The curve's toe squashes it lower.</summary>
public const float OFFSHORE_ISLAND_H_M = 34f;
/// <summary>Reference: fraction of a blob's excess over threshold that saturates to full weight.</summary>
public const float OFFSHORE_CORE = 0.45f;
/// <summary>
/// ⭐ THE MOAT. Reference: the raise is EXACTLY zero wherever the ambient water is shallower
/// than this, so the ring of water between the mainland shore and any islet cannot be
/// bridged — a continuous path from shore to islet must cross this depth contour, and every
/// pixel on it is untouched water.
/// </summary>
public const float OFFSHORE_MIN_DEPTH_M = 14f;
/// <summary>Reference: the moat's feather width, metres.</summary>
public const float OFFSHORE_DEPTH_FEATHER_M = 10f;
/// <summary>Reference: the Trench mask — zone fades from INNER to zero at OUTER (the Trench ramp starts at 0.90).</summary>
public const float OFFSHORE_TRENCH_INNER = 0.78f;
public const float OFFSHORE_TRENCH_OUTER = 0.86f;
/// <summary>
/// ⭐ THE "ACTUALLY OFFSHORE" TEST. Reference: depth alone is not enough — a deep LAKE or the
/// carved crater bay is also below sea level, and islets have no business in either. The
/// pre-Trench falloff is the honest discriminator: the mainland coast sits near f = 0.66, and
/// inland water is far below that whatever the axis ratios are, because elongation moves
/// WHERE a given f occurs, not the f at which land ends.
/// → this is precisely why <c>Pass1Result.PreTrenchFalloff</c> exists.
/// </summary>
public const float OFFSHORE_MIN_FALLOFF = 0.72f;
/// <summary>Reference: the falloff test's feather width.</summary>
public const float OFFSHORE_FALLOFF_FEATHER = 0.06f;
/// <summary>
/// Blob weight in [0,1] for one ocean column. THE FAITHFUL FORM.
///
/// ⚠ <paramref name="threshold"/> comes from <see cref="CalibrateThreshold"/>, NOT from the
/// density directly. Reference: "Simplex output is concentrated well inside [1,1] (in
/// practice it rarely passes ±0.87), so treating density as a fraction of the theoretical
/// range produces a threshold almost nothing clears. That bug shipped in the first task-11
/// build and raised 171 pixels on the whole map, none of them above sea."
/// </summary>
public static float OffshoreBlob(float noise01, float threshold)
=> OffshoreBlob(noise01, threshold, OFFSHORE_CORE);
/// <summary>
/// The parameterized form. <paramref name="coreFraction"/> is the fraction of the excess
/// over threshold that saturates: SMALLER ⇒ more of the blob at full weight ⇒ FLATTER top
/// and a sharper base. (The reshape's "flatter" lever lowers this, not raises it.)
/// </summary>
public static float OffshoreBlob(float noise01, float threshold, float coreFraction)
{
if (noise01 <= threshold) return 0f;
float core = MathF.Max((1f - threshold) * coreFraction, 1e-4f);
float k = Math.Clamp((noise01 - threshold) / core, 0f, 1f);
return k * k * (3f - 2f * k);
}
/// <summary>
/// The noise value that <paramref name="density"/> of <paramref name="samples"/> exceed.
/// Sorts a COPY, so the caller's array is left alone. Verbatim.
/// </summary>
public static float CalibrateThreshold(float[] samples, float density)
{
if (samples.Length == 0 || density <= 0f) return 1f;
float[] s = (float[])samples.Clone();
Array.Sort(s);
int idx = (int)((1f - Math.Clamp(density, 0f, 1f)) * (s.Length - 1));
return s[Math.Clamp(idx, 0, s.Length - 1)];
}
/// <summary>
/// How much of the blob is allowed here: zero in shallow water near the mainland (the moat),
/// zero anywhere not genuinely outside the island body, zero in and near the Trench ramp,
/// full in the open ocean between. THE FAITHFUL FORM.
/// </summary>
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
float distX01, float distY01)
=> OffshoreZoneWeight(ambientDepthMetres, preTrenchFalloff, distX01, distY01,
OFFSHORE_MIN_DEPTH_M, OFFSHORE_DEPTH_FEATHER_M,
OFFSHORE_MIN_FALLOFF, OFFSHORE_FALLOFF_FEATHER,
OFFSHORE_TRENCH_INNER, OFFSHORE_TRENCH_OUTER);
/// <summary>
/// The parameterized form. ⚠ <paramref name="distX01"/>/<paramref name="distY01"/> are
/// MAP-anchored (|x cx| / halfSpan, no axis ratio) — the same normalization the Trench
/// itself uses, because the mask's job is to stay off the Trench, not off the island ellipse.
/// </summary>
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
float distX01, float distY01,
float minDepthM, float depthFeatherM, float minFalloff, float falloffFeather,
float trenchInner, float trenchOuter)
{
if (ambientDepthMetres < minDepthM) return 0f;
if (preTrenchFalloff < minFalloff) return 0f;
float w = Math.Clamp((ambientDepthMetres - minDepthM) / depthFeatherM, 0f, 1f);
w *= Math.Clamp((preTrenchFalloff - minFalloff) / falloffFeather, 0f, 1f);
float d = MathF.Max(distX01, distY01);
if (d >= trenchOuter) return 0f;
if (d > trenchInner)
w *= 1f - (d - trenchInner) / (trenchOuter - trenchInner);
return w;
}
// ═══════════════════════════════════════════════════════════════════════
// 3b. THE RESHAPE HELPERS — chat2/05 stage 2. Not in the reference.
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// The reshaped organic blob: the faithful smoothstep, then its weight pushed toward
/// saturation by <paramref name="edgeSharpness"/> — <c>1 (1 w)^sharpness</c>. At
/// sharpness 1 this IS <see cref="OffshoreBlob"/>. Higher values keep the same footprint but
/// make the top flatter and the crest-to-sea transition narrower: a distinct flat-topped
/// landmass instead of a gentle noise bump. C¹ at both ends, so it cannot alias.
/// </summary>
public static float RigidBlob(float noise01, float threshold, float coreFraction, float edgeSharpness)
{
float w = OffshoreBlob(noise01, threshold, coreFraction);
if (w <= 0f || edgeSharpness <= 1f) return w;
return 1f - MathF.Pow(1f - w, edgeSharpness);
}
/// <summary>
/// The seeded-floor stamp: a flat disc of radius <c>radius · coreFraction</c> at full weight,
/// then a smoothstep rim down to zero at <paramref name="radius"/>. Higher
/// <paramref name="coreFraction"/> ⇒ flatter top, crisper shore. This is the "rigid, defined
/// coastline" the reshape asks for, in its simplest honest form.
/// </summary>
public static float StampWeight(float dist, float radius, float coreFraction)
{
if (dist >= radius) return 0f;
float core = radius * coreFraction;
if (dist <= core) return 1f;
float t = (dist - core) / (radius - core);
return 1f - t * t * (3f - 2f * t);
}
}
}