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

243 lines
13 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 helper (<see cref="RigidBlob"/>) — 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 HELPER — chat2/05 stage 2. Not in the reference. (The seeded-floor
// stamp that sat beside it was reverted out in chat2/06 — git history has it.)
// ═══════════════════════════════════════════════════════════════════════
/// <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);
}
}
}