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
This commit is contained in:
Stewart Howe 2026-08-21 05:14:53 -04:00
parent 5d160511fd
commit 3b96e06c1a
19 changed files with 1904 additions and 33 deletions

View file

@ -30,7 +30,12 @@ constants, carried over verbatim — not re-derived from a design summary** (→
|---|---| |---|---|
| `Scripts/TerrainNoise.cs` | ⭐ The FastNoiseLite config, **every fractal property pinned explicitly** | | `Scripts/TerrainNoise.cs` | ⭐ The FastNoiseLite config, **every fractal property pinned explicitly** |
| `Scripts/Topography.cs` | ⭐⭐ Pass 1 — the six elements, in the reference's execution order | | `Scripts/Topography.cs` | ⭐⭐ Pass 1 — the six elements, in the reference's execution order |
| `Scripts/IslandFalloff.cs` | `SmoothAbs` + `CREST_EPSILON` (pass-1 half of the reference file) | | `Scripts/IslandFalloff.cs` | The whole reference file now: `SmoothAbs`, the **coast shelf**, the **offshore islets** + the reshape helpers |
| `Scripts/OffshorePass.cs` | ⭐ **Pass 1b** — shelf + islets over the finished pass-1 arrays, then `HMaxSeed` is retaken (chat2/05) |
| `Scripts/OffshoreSettings.cs` | Every islet dial in one object; `Faithful()` (the reference) and `Hybrid()` (the reshape) |
| `Scripts/OffshoreAnalysis.cs` | Island components, N/S counts, the moat check, **the hemisphere convention** |
| `Scripts/TagOverlayRenderer.cs` | The island-tag / hemisphere debug overlay |
| `Scripts/OffshoreIslandsTool.cs` + `Scenes/OffshoreIslandsTool.tscn` | The chat2/05 batch |
| `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** | | `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** |
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles | | `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
| `Scripts/HeightField.cs` | Raw `.f32` save/load — **the generation/presentation seam** | | `Scripts/HeightField.cs` | Raw `.f32` save/load — **the generation/presentation seam** |
@ -197,17 +202,35 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
> reads**, and nothing else. Climate is **stage 3** and classifies finished shape (D-049 §2, D-056). > reads**, and nothing else. Climate is **stage 3** and classifies finished shape (D-049 §2, D-056).
> Do not alias, store, or rename this into a climate map. > Do not alias, store, or rename this into a climate map.
**Deferred, with the seam already open:** the submarine **coast shelf** and the **offshore islets** **Pass 1b — the coast shelf and the offshore islets (chat2/05).** The reference continued its pass-1
(reference ~:621-664). Both act only below sea level and are judged once water renders. loop with the submarine **coast shelf** and the **offshore islet** layer (~:621-664); v2 runs them as a
`Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and second sweep over the finished arrays — `OffshorePass` — with the same per-pixel arithmetic in the same
`Pass1Result.HMaxSeed` is carried for the seed-dependent redistribution curve. order, then **retakes `HMaxSeed` after them**, as the reference did (chat2/00 Drift §2, closed). The
islets exist in two presets: `OffshoreSettings.Faithful()` (the reference, verbatim — the control) and
`OffshoreSettings.Hybrid()` (the reshape: small / low / flat / rigid, a seeded floor of ≥2 N / ≥4 S
islands whose positions vary per seed, organic extras weighted south, corners allowed).
> ⚠⚠ **When the coast shelf and islets land, `HMaxSeed` must move with them.** The reference takes > ### ⚠ Both default OFF — deliberately, and that is a decision to revisit.
> its map-wide max *after* both layers have already modified the height, inside the same pass-1 loop >
> (`MapGenerator.cs:665`). v2 currently takes it before, because the layers do not exist. Since > Every oracle in this phase holds pass 1 against Phase 1's `.f32` dumps. The shelf changes every
> `HMaxSeed` normalizes the curve's summit spike, porting those layers without moving the max > below-sea cell and the islets ADD LAND, so defaulting either ON stales every regression anchor at
> computation below them changes the world for a given seed — silently, with no throw and no failed > once. Batch tools turn them on explicitly. **Flipping the defaults is the act that retires the
> assertion. → chat2/00 report, Drift §2. > Phase-1 dumps — do it in a task that re-baselines the oracles.** → `TerrainGenConfig`.
> ### ⭐ The island tag — data, set here, read by nothing yet.
>
> `Pass1Result.IsOffshoreIsland` / `IslandHemisphere` (carried through `Pass2Result`) mark every cell
> the islet layer lifted above sea, with its hemisphere. **NORTH = rows `[0, MapSize/2)`, SOUTH =
> `[MapSize/2, MapSize)`** — y runs south, as the spine's southern fade and the southern sinker
> already encode. A biome / fertility / placement pass reads it from there and never re-derives
> island-land from geometry. Null when offshore is off.
> ### ⚠ The one honest coupling: islets TURN WATER INTO LAND.
>
> Unlike the curve, which moves heights but not the waterline, the islet layer adds above-sea cells —
> new classification downstream. That is why it runs in the base shape before anything classifies:
> change an island dial, regenerate, and classification re-runs consistently. A known property, not a
> surprise.
**Not here at all:** erosion, rivers, water bodies, the crater carve, biomes, roads, the mesher. **Not here at all:** erosion, rivers, water bodies, the crater carve, biomes, roads, the mesher.

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://coffshore05isla"]
[ext_resource type="Script" path="res://Tools/Scripts/OffshoreIslandsTool.cs" id="1_oit"]
[node name="OffshoreIslandsTool" type="Node"]
script = ExtResource("1_oit")

View file

@ -4,17 +4,27 @@ namespace IslaApocalypse.Tools
{ {
/// <summary> /// <summary>
/// Shape helpers for the island mask, ported from the reference's /// Shape helpers for the island mask, ported from the reference's
/// <c>Tools/Scripts/IslandFalloff.cs</c>. /// <c>Tools/Scripts/IslandFalloff.cs</c> — now the WHOLE file, in three parts:
/// ///
/// ⚠ ONLY THE PASS-1 PARTS ARE HERE. The reference file also carries the submarine COAST SHELF /// 1. the spine crest (<see cref="SmoothAbs"/>) — Phase 1, chat1
/// (SHELF_STRENGTH / SHELF_SCALE_M / CoastShelf) and the OFFSHORE ISLET layer (OffshoreBlob, /// 2. the submarine COAST SHELF (<see cref="CoastShelf"/>) — chat2/05 stage 1
/// OffshoreZoneWeight, CalibrateThreshold, and their constants). Both are DEFERRED to Phase 2 — /// 3. the OFFSHORE ISLET layer (<see cref="OffshoreBlob"/>,
/// they act on below-sea height and are judged once water renders. They will port into THIS /// <see cref="OffshoreZoneWeight"/>, <see cref="CalibrateThreshold"/>) — chat2/05 stage 1
/// file, which is why it keeps the reference's name and shape. /// + 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 /// 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, and so the deferred Phase-2 /// port stays auditable as one unit against one reference file.
/// halves land beside their siblings rather than in a second location.
/// </summary> /// </summary>
public static class IslandFalloff public static class IslandFalloff
{ {
@ -47,5 +57,201 @@ namespace IslaApocalypse.Tools
float a = Math.Abs(d); float a = Math.Abs(d);
return a * a / MathF.Sqrt(a * a + epsilon * epsilon); 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);
}
} }
} }

View file

@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Tools
{
/// <summary>One connected island of tagged offshore land, as the analysis sees it.</summary>
public sealed class IslandComponent
{
public int Id;
public long Cells;
public double CentroidX, CentroidY;
public int MinX, MinY, MaxX, MaxY;
/// <summary>Hemisphere by CENTROID (an island straddling the midline is counted once, where its mass is).</summary>
public byte Hemisphere;
/// <summary>
/// ⚠ True if any cell of this island is 8-adjacent to land that is NOT tagged offshore —
/// i.e. the island touches the mainland. The moat exists to make this impossible; this is
/// the check that it did.
/// </summary>
public bool BridgedToMainland;
}
/// <summary>
/// ⭐ THE OFFSHORE ANALYSIS — counts islands, reads their hemisphere, and catches a land bridge.
/// Engine-free; used by the pass (to prove its own floor) and by the oracle (to prove it again,
/// independently, on the finished field).
///
/// ═══ THE HEMISPHERE CONVENTION — read from the code, not invented ═══
///
/// Pass 1's latitude scalar is <c>y / MapSize</c> (+ a ±0.1 wobble). The spine fades out where
/// that scalar exceeds 0.65 — "the southern fade" — and the "southern sinker" bites in the
/// BOTTOM 25 % of rows. So in this codebase, and in the lore it encodes (snow-town north,
/// shipwreck south): <b>y increases SOUTHWARD. North is the top half of the image.</b>
///
/// NORTH y ∈ [0, MapSize/2)
/// SOUTH y ∈ [MapSize/2, MapSize)
///
/// ⚠ The tag uses the clean row midline, NOT the wobbled latitude field. A hemisphere tag keyed
/// to a field that wanders ±10 % of the map would put the same island in different hemispheres
/// on different seeds for no geographic reason. The field's ORIENTATION is what is borrowed; its
/// wobble is not.
/// </summary>
public static class OffshoreAnalysis
{
public const byte HemiNone = 0;
public const byte HemiNorth = 1;
public const byte HemiSouth = 2;
/// <summary>The convention, in one place. Every consumer of the tag reads hemisphere through this.</summary>
public static byte HemisphereOfRow(int y, int mapSize) => y < mapSize / 2 ? HemiNorth : HemiSouth;
public static string HemisphereName(byte h) => h switch
{
HemiNorth => "north", HemiSouth => "south", _ => "none",
};
// 8-connectivity, fixed order.
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
/// <summary>
/// Label the 8-connected components of tagged offshore land, and for each, whether it
/// touches untagged land (a bridge). <paramref name="height"/> + <paramref name="sea"/>
/// define "land"; <paramref name="tag"/> defines "offshore". Both are needed: the bridge test
/// is "tagged cell next to a land cell that is not tagged".
/// </summary>
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize)
=> Components(tag, height, sea, mapSize, out _);
/// <summary>
/// As above, also returning the per-cell component id map (<c>x * mapSize + y</c>; 0 = not
/// tagged) — the debris guard needs membership, not just the list.
/// </summary>
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize,
out int[] idMap)
{
var comps = new List<IslandComponent>();
int n = mapSize;
var id = new int[n * n]; // 0 = unvisited / not tagged
idMap = id;
if (tag == null) return comps;
var stack = new Stack<int>();
int next = 0;
for (int sx = 0; sx < n; sx++)
{
for (int sy = 0; sy < n; sy++)
{
if (!tag[sx, sy] || id[sx * n + sy] != 0) continue;
var c = new IslandComponent
{
Id = ++next, MinX = sx, MaxX = sx, MinY = sy, MaxY = sy,
};
double sumX = 0, sumY = 0;
id[sx * n + sy] = c.Id;
stack.Push(sx * n + sy);
while (stack.Count > 0)
{
int cur = stack.Pop();
int cx = cur / n, cy = cur % n;
c.Cells++; sumX += cx; sumY += cy;
if (cx < c.MinX) c.MinX = cx; if (cx > c.MaxX) c.MaxX = cx;
if (cy < c.MinY) c.MinY = cy; if (cy > c.MaxY) c.MaxY = cy;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
if (tag[nx, ny])
{
int ni = nx * n + ny;
if (id[ni] != 0) continue;
id[ni] = c.Id;
stack.Push(ni);
}
else if (height[nx, ny] >= sea)
{
// Land, not tagged offshore ⇒ mainland (or a lake-shore) touching
// this island. The moat should have made this impossible.
c.BridgedToMainland = true;
}
}
}
c.CentroidX = sumX / c.Cells;
c.CentroidY = sumY / c.Cells;
c.Hemisphere = HemisphereOfRow((int)Math.Round(c.CentroidY), mapSize);
comps.Add(c);
}
}
return comps;
}
/// <summary>Island counts per hemisphere, by component centroid.</summary>
public static (int north, int south) CountByHemisphere(List<IslandComponent> comps)
{
int nN = 0, nS = 0;
foreach (var c in comps)
{
if (c.Hemisphere == HemiNorth) nN++;
else if (c.Hemisphere == HemiSouth) nS++;
}
return (nN, nS);
}
/// <summary>
/// Island SIZE statistics — the thing a count alone hides. 189 islands averaging 66 cells is
/// noise debris, not an archipelago; 12 islands averaging 900 cells is what the developer
/// asked for. Cells are map cells (1 column = 1 m at the target scale).
/// </summary>
public static (long min, long median, double mean, long max, int belowThreshold)
SizeSummary(List<IslandComponent> comps, long threshold)
{
if (comps.Count == 0) return (0, 0, 0.0, 0, 0);
var sizes = new List<long>(comps.Count);
double sum = 0; int below = 0;
foreach (var c in comps) { sizes.Add(c.Cells); sum += c.Cells; if (c.Cells < threshold) below++; }
sizes.Sort();
return (sizes[0], sizes[sizes.Count / 2], sum / sizes.Count, sizes[sizes.Count - 1], below);
}
/// <summary>How many components touch the mainland. Zero is the only acceptable answer.</summary>
public static int BridgedCount(List<IslandComponent> comps)
{
int b = 0;
foreach (var c in comps) if (c.BridgedToMainland) b++;
return b;
}
}
}

View file

@ -0,0 +1 @@
uid://bw21tp5wa5vxt

View file

@ -0,0 +1,424 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE OFFSHORE-ISLANDS BATCH (chat2/05) — the faithful rejoin as the control, then the
/// reshape: small / low / flat / rigid, loose-guaranteed (≥2 N, ≥4 S), organic extras weighted
/// south, corners on. Judged on 2D maps, with the tag made visible.
///
/// ═══ THE VARIANTS ═══
///
/// faithful stage 1 — the reference's probabilistic islets, verbatim. Sparse, no corners,
/// no floor. THE CONTROL.
/// floor_only the reshape's seeded ≥2N/4S floor with the organic layer OFF — shows the
/// guaranteed minimum and that positions vary per seed.
/// hybrid ⭐ floor + organic extras, reshaped, corners on. THE DELIVERABLE. Six seeds.
/// dense hybrid with ×3 organic density — a bookend for "how many is too many".
///
/// The shelf is ON for every variant (it is stage 1's other half and is invisible on these
/// plates anyway); an offshore-OFF / shelf-OFF field is generated per seed for the oracle only.
///
/// ═══ THE CURVE IS THE TAGGED CURVE, UNCHANGED ═══
///
/// `continuous_restored` (tag terrain-curve-v1), calibrated on task 01's pool at the iteration
/// size WITH OFFSHORE OFF — islands are additive land on top of a curve that does not know they
/// exist. Their crest (2434 m pre-curve) lands inside the curve's preserved toe, which squashes
/// it to a few metres; because that toe is bit-preserved across curve tweaks, the islands' height
/// is stable however the upper climb is tuned later.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/OffshoreIslandsTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW
/// ISLA_MAPSIZE render size (default 4096 — islands need pixels to read)
/// ISLA_CALIB_SIZE curve calibration size (default 2048, task 01's)
/// ISLA_SEEDS_HYBRID the six hybrid seeds
/// ISLA_SEEDS_SMALL the three seeds for faithful / floor_only / dense
/// ISLA_PHASE1_SOURCE Phase-1 .f32 batch (default "02_pass1_port")
/// ISLA_T03_SOURCE task-03 .f32 batch (default "03_mountain_restore")
/// </summary>
public partial class OffshoreIslandsTool : Node
{
private static readonly int[] DefaultHybridSeeds = { 1063685222, 20260821, 8675309, 123456789, 271828182, 999999937 };
private static readonly int[] DefaultSmallSeeds = { 1063685222, 8675309, 999999937 };
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class Row
{
public string Variant; public int Seed;
public int CountN, CountS; public long Lifted; public float HMaxBefore, HMaxAfter;
public bool Ok;
public string Centres; // the seeded floor's (x,y) list — the "positions vary per seed" evidence
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 5);
string descr = EnvStr("ISLA_BATCH", "offshore_islands");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] hybridSeeds = EnvSeeds("ISLA_SEEDS_HYBRID", DefaultHybridSeeds);
int[] smallSeeds = EnvSeeds("ISLA_SEEDS_SMALL", DefaultSmallSeeds);
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
string only = EnvStr("ISLA_ONLY", null); // probe: comma list of variant labels to run
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
int primary = hybridSeeds[0];
GD.Print("==================================================================");
GD.Print(" OFFSHORE ISLANDS (chat2/05) — faithful rejoin, then the reshape");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize} (offshore OFF)");
GD.Print($"hybrid : {string.Join(", ", hybridSeeds)}");
GD.Print($"others : {string.Join(", ", smallSeeds)}");
GD.Print($"hemisphere: NORTH = rows [0, {mapSize / 2}) SOUTH = rows [{mapSize / 2}, {mapSize}) (y runs south)");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
// ═══ 0. THE CURVE — continuous_restored, calibrated with offshore off ═══
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
GD.Print($" {calibration.Describe()}");
// ═══ 1. REGRESSIONS at the calibration size — the things that must not have moved ═══
GD.Print($"\n--- 1. REGRESSIONS at {calibSize}, seed {primary} ---");
var hard = new List<ShapingOracle.Check>();
{
var offCfg = BaseConfig(calibSize, primary, knots, anchors, calibration, "off");
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
Pass2Result pOff = Shaping.Shape(p1, curveOff);
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF == Phase-1 .f32 dump",
pOff.Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
Pass2Result pRest = Shaping.Shape(p1, offCfg);
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{primary}_continuous_restored", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, offshore OFF == task-03 .f32 dump (lowlands + curve untouched)",
pRest.Height, HeightField.Load(t03Dump, calibSize), calibSize, t03Dump));
// Shelf ON, islets OFF: land must be bit-identical (the shelf touches only sea).
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
var j0 = ShapingOracle.MainlandUnmoved(p1, p1Shelf, sea);
j0.Name = "shelf alone: every land cell bit-identical (shelf is below-sea only)";
hard.Add(j0);
hard.Add(ShapingOracle.HMaxAfterOffshore(p1Shelf));
}
foreach (var c in hard) GD.Print(" " + c);
// ═══ 2. VARIANTS ═══
// ⚠ PROBE OVERRIDES on the reshape's organic knobs, so the hybrid can be swept in scratch
// without a rebuild. The defaults live in OffshoreSettings.Hybrid() — that is the
// preset of record; these move it only when set. Printed into the INDEX either way.
OffshoreSettings HybridTuned()
{
var h = OffshoreSettings.Hybrid();
h.FreqPerMapWidth = EnvFloat("ISLA_OFF_FREQ", h.FreqPerMapWidth);
h.DensityNorth = EnvFloat("ISLA_OFF_DENS_N", h.DensityNorth);
h.DensitySouth = EnvFloat("ISLA_OFF_DENS_S", h.DensitySouth);
h.CoreFraction = EnvFloat("ISLA_OFF_CORE", h.CoreFraction);
h.EdgeSharpness = EnvFloat("ISLA_OFF_SHARP", h.EdgeSharpness);
h.MinIslandAreaFrac = EnvFloat("ISLA_OFF_MINAREA", h.MinIslandAreaFrac);
h.CrestM = EnvFloat("ISLA_OFF_CREST", h.CrestM);
return h;
}
var variants = new List<(string label, int[] seeds, Action<TerrainGenConfig> mutate)>
{
("faithful", smallSeeds, c => { c.CoastShelf = true; c.Offshore = OffshoreSettings.Faithful(); }),
("floor_only", smallSeeds, c => { c.CoastShelf = true; c.Offshore = HybridTuned(); c.Offshore.Organic = false; }),
("hybrid", hybridSeeds, c => { c.CoastShelf = true; c.Offshore = HybridTuned(); }),
("dense", smallSeeds, c => { c.CoastShelf = true; c.Offshore = HybridTuned();
c.Offshore.DensityNorth *= 3f; c.Offshore.DensitySouth *= 3f; }),
};
if (!string.IsNullOrWhiteSpace(only))
{
var keep = new HashSet<string>(only.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
variants.RemoveAll(v => !keep.Contains(v.label));
GD.Print($" ⚠ ISLA_ONLY: running {string.Join(", ", keep)} only (a probe, not the batch of record)");
}
GD.Print($"\n--- 2. VARIANTS at {mapSize} ---");
var offFields = new Dictionary<int, Pass1Result>(); // per seed, shelf off + offshore off
var rows = new List<Row>();
var perVariantChecks = new List<ShapingOracle.Check>();
var knobs = new Dictionary<string, string>();
bool notesShown = false;
foreach (var (label, seeds, mutate) in variants)
{
foreach (int seed in seeds)
{
if (!offFields.TryGetValue(seed, out Pass1Result p1Off))
{
p1Off = Topography.Generate(BaseConfig(mapSize, seed, knots, anchors, calibration, "off"));
offFields[seed] = p1Off;
}
var cfg = BaseConfig(mapSize, seed, knots, anchors, calibration, label);
mutate(cfg);
knobs[label] = cfg.Offshore.Describe();
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
if (!notesShown || label == "hybrid" && seed == primary)
foreach (string n in p1.Notes) GD.Print(" " + n);
notesShown = true;
// ---- oracle, per field ----
var comps = OffshoreAnalysis.Components(p1.IsOffshoreIsland, p1.Height, sea, mapSize);
var (cn, cs) = OffshoreAnalysis.CountByHemisphere(comps);
var checks = new List<ShapingOracle.Check>();
if (cfg.Offshore.FloorNorth > 0 || cfg.Offshore.FloorSouth > 0)
checks.Add(ShapingOracle.OffshoreFloor(p1, cfg.Offshore.FloorNorth, cfg.Offshore.FloorSouth, sea, comps));
checks.Add(ShapingOracle.MoatIntact(p1, comps));
checks.Add(ShapingOracle.MainlandUnmoved(p1Off, p1, sea));
checks.Add(ShapingOracle.TagCoastlineConsistent(p2, sea));
checks.Add(ShapingOracle.HMaxAfterOffshore(p1));
checks.Add(ShapingOracle.ClassifyFidelity(p1, p2));
foreach (var c in checks) { c.Name += $" [{label} {seed}]"; perVariantChecks.Add(c); }
bool ok = checks.TrueForAll(c => c.Passed);
WriteVariant(batchRoot, p1, p2, sea, anchors, skipRaw, cn, cs);
var cl = new List<string>();
foreach (var (cx, cy, _) in p1.OffshoreCentres) cl.Add($"({cx},{cy})");
rows.Add(new Row
{
Variant = label, Seed = seed, CountN = cn, CountS = cs,
Lifted = p1.OffshoreLiftedCells, HMaxBefore = p1.HMaxSeedBeforeOffshore, HMaxAfter = p1.HMaxSeed, Ok = ok,
Centres = cl.Count == 0 ? "—" : string.Join(" ", cl),
});
GD.Print($" {label,-11} seed {seed,-11} islands N {cn,2} S {cs,2} lifted {p1.OffshoreLiftedCells,8:N0} " +
$"hMax {p1.HMaxSeedBeforeOffshore:F4}→{p1.HMaxSeed:F4} {(ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
}
bool allOk = hard.TrueForAll(c => c.Passed) && perVariantChecks.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perVariantChecks) if (!c.Passed) GD.PrintErr(" " + c);
WriteIndex(batchRoot, mapSize, calibSize, primary, hybridSeeds, smallSeeds, rows, knobs, hard, perVariantChecks, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the curve, measured exactly as tasks 03/04 did --------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s }); // offshore OFF by default
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length];
var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
ClimbCalibration cal, string label) => new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = k, Anchors = a, ClimbCalibration = cal, LowlandCeilingM = 30f,
CoastShelf = false, Offshore = new OffshoreSettings(), // OFF unless the variant turns it on
};
// ---- output -----------------------------------------------------------
private static void WriteVariant(string batchRoot, Pass1Result p1, Pass2Result p2, float sea,
CurveAnchors anchors, bool skipRaw, int countN, int countS)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
// ⭐ The tag overlay — the one artifact that shows the DATA this pass set.
TagOverlayRenderer.SavePng(p2.Height, p2.IsOffshoreIsland, p2.IslandHemisphere, p2.MapSize, sea,
p1.OffshoreCentres, countN, countS, Path.Combine(dir, "tags.png"));
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int primary,
int[] hybridSeeds, int[] smallSeeds, List<Row> rows, Dictionary<string, string> knobs,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perVariant, bool allOk)
{
var sb = new StringBuilder();
sb.AppendLine("# Batch 05 — offshore islands: the faithful rejoin, then the reshape");
sb.AppendLine();
sb.AppendLine("Stage 1 rejoins the reference's coast shelf + islets verbatim (`faithful`, the control).");
sb.AppendLine("Stage 2 reshapes: **small / low / flat / rigid**, a **seeded floor of ≥2 N / ≥4 S** islands");
sb.AppendLine("(min-separated, positions varying per seed, never fixed zones) plus **organic extras weighted");
sb.AppendLine("south**, corners and the outer edge allowed. Every tagged island cell carries a hemisphere.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{primary}_hybrid/tags.png`** — the tag overlay: grey mainland, cyan = island N, orange = island S,");
sb.AppendLine(" rings = the seeded floor. Count, N/S split and tag correctness in one glance.");
sb.AppendLine($"2. **`{primary}_hybrid/relief.png`** beside **`{primary}_faithful/relief.png`** — the reshape vs the reference.");
sb.AppendLine($"3. Then the other hybrid seeds' `tags.png` — the floor holds everywhere; the extras vary.");
sb.AppendLine();
sb.AppendLine($"**Hemisphere convention (from the code, not invented):** y runs SOUTH. NORTH = rows `[0, {mapSize / 2})`,");
sb.AppendLine($"SOUTH = rows `[{mapSize / 2}, {mapSize})`. The spine fades southward; the southern sinker bites the bottom 25 %;");
sb.AppendLine("snow-town north, shipwreck south. Component hemisphere is by centroid; the tag per cell is by row.");
sb.AppendLine();
sb.AppendLine("## ⭐ The count table — the floor, and the spread above it");
sb.AppendLine();
sb.AppendLine("| Variant | Seed | N | S | total | lifted cells | HMaxSeed before → after | oracle | seeded floor centres (x,y) |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Variant}` | `{r.Seed}` | **{r.CountN}** | **{r.CountS}** | {r.CountN + r.CountS} | {r.Lifted:N0} | " +
$"{r.HMaxBefore:F4} → {r.HMaxAfter:F4}{(r.HMaxBefore != r.HMaxAfter ? " " : "")} | {(r.Ok ? "pass" : "**FAIL**")} | {r.Centres} |");
sb.AppendLine();
sb.AppendLine("`floor_only` / `hybrid` / `dense` carry a floor of **≥2 N / ≥4 S**; `faithful` has no floor (the");
sb.AppendLine("reference's probabilistic layer) and is the control.");
sb.AppendLine();
sb.AppendLine("## The knobs per variant");
sb.AppendLine();
sb.AppendLine("| Variant | settings |");
sb.AppendLine("|---|---|");
foreach (var kv in knobs) sb.AppendLine($"| `{kv.Key}` | {kv.Value} |");
sb.AppendLine();
sb.AppendLine("The coast shelf is ON for every variant (strength 0.775, scale 100 m, BitDecrement-clamped). It is");
sb.AppendLine("invisible on these hypsometric plates — ported faithfully, judged when water renders.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven`, flagged. Grayscale + `tags.png` are the honest instruments here.");
sb.AppendLine();
sb.AppendLine("## The oracle");
sb.AppendLine();
sb.AppendLine("Regressions at the calibration size:");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per variant × seed (floor h · moat i · mainland unmoved j · tag/coastline k · HMaxSeed l · classify b):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perVariant));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `tags.png`, `relief.png`, `INDEX.md` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"MapSize {mapSize}, curve calibrated at {calibSize} with offshore off. {WorldScale.Describe()}.");
string index = Path.Combine(batchRoot, "INDEX.md");
using var f = Godot.FileAccess.Open(index, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {index}"); return; }
f.StoreString(sb.ToString());
}
// ---- env helpers --------------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://bvrfybmk7pvyn

View file

@ -0,0 +1,454 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ PASS 1b — THE COAST SHELF AND THE OFFSHORE ISLETS (chat2/05). Runs over the finished
/// pass-1 arrays, IN PLACE, before <c>HMaxSeed</c> is taken and before anything classifies.
///
/// ═══ WHERE THIS SITS, AND WHY IT IS A SECOND SWEEP ═══
///
/// The reference did all of this INSIDE the pass-1 pixel loop (<c>MapGenerator.cs:621-664</c>):
/// combine → shelf → islets → <c>_hMaxSeed</c> → write. v2 runs the same arithmetic as a second
/// sweep over the arrays the first loop produced. Per pixel the inputs are identical — the raw
/// height, the sea level, the pre-Trench falloff, (x, y) — and the operations are applied in the
/// same order on the same floats, so the FAITHFUL mode reproduces the reference cell for cell.
/// What the second sweep buys is the seeded floor: choosing island centres needs the whole
/// depth/falloff field to exist first, which a single pixel loop cannot provide.
///
/// ⚠⚠ THE ORDERING THAT CLOSES chat2/00 DRIFT §2: the caller recomputes <c>HMaxSeed</c> AFTER this
/// pass, as the reference did, so the curve's per-seed peak normalization sees the same maximum
/// the reference saw. Expected to be unchanged (a ~34 m crest is far below any peak) — reported,
/// not assumed.
///
/// ═══ THE THREE SUB-PASSES ═══
///
/// 1. SHELF every below-sea cell; depth-preserving; held strictly below sea by BitDecrement.
/// 2. ORGANIC the reference's noise layer — faithful, or reshaped (smaller / lower / flatter /
/// crisper, south-weighted) — every below-sea cell inside the zone mask.
/// 3. FLOOR (Hybrid only) a seed-derived RNG places ≥N north / ≥S south stamps, each
/// min-separated from the others AND clear of ALL existing land by a gap, so each
/// stamp is its own connected component BY CONSTRUCTION. Positions vary per seed;
/// there are no reserved zones. Refuses loudly if the floor cannot be placed.
///
/// ═══ THE TWO PROTECTIONS ARE APPLIED TO EVERYTHING, INCLUDING THE FLOOR ═══
///
/// The moat (min depth) and the "actually offshore" test (pre-Trench falloff) gate the organic
/// layer per pixel — that is the reference. They ALSO gate every seeded stamp: a centre must sit
/// fully inside the zone (weight exactly 1), and the stamp's every pixel is still multiplied by
/// the zone weight. A guaranteed island cannot be guaranteed onto the mainland or into a lake.
///
/// ═══ ⚠ THE ONE HONEST COUPLING ═══
///
/// Unlike the curve, this pass TURNS WATER INTO LAND. New land is new classification downstream
/// (biome/water pixels that did not exist before). That is precisely why it runs here, in the base
/// shape, before anything classifies: change an island dial, regenerate, and classification
/// re-runs consistently. Known property, stated in code, not a surprise.
///
/// Every cell lifted from below sea to at/above sea is TAGGED (<c>Result.Tag</c>) with its
/// hemisphere (<c>Result.Hemi</c>). That tag is data the shape pass sets and carries; nothing in
/// this phase reads it. → <see cref="OffshoreAnalysis"/> for the hemisphere convention.
/// </summary>
public static class OffshorePass
{
public sealed class Result
{
public bool[,] Tag; // null when the islet layer is off (shelf only)
public byte[,] Hemi;
public long ShelfCells, LiftedOrganic, LiftedSeeded;
public float ThresholdNorth = float.NaN, ThresholdSouth = float.NaN;
public List<(int x, int y, int r)> Centres = new();
public List<string> Notes = new();
public int CountNorth, CountSouth, Bridged;
public long LiftedReverted; // specks the guard put back
public long RaisedReverted; // submerged debris bumps the guard put back
internal List<(int x, int y, float h0)> LiftedOrigin = new(); // every SURFACED lift
internal List<(int x, int y, float h0)> RaisedOrigin = new(); // every organic raise, surfaced or not (guard on)
}
/// <summary>
/// Apply the shelf and/or islets to <paramref name="height"/> IN PLACE. Returns null when
/// both are off (nothing touched, nothing allocated).
/// </summary>
public static Result Apply(float[,] height, float[,] preTrench, int mapSize, int seed,
float sea, TerrainGenConfig cfg)
{
OffshoreSettings s = cfg.Offshore;
bool shelfOn = cfg.CoastShelf;
bool isletsOn = s != null && s.Mode != OffshoreMode.Off;
if (!shelfOn && !isletsOn) return null;
var r = new Result();
GenerationScale scale = cfg.Scale;
// ⚠ MAP-anchored, exactly as the reference: `center = MapSize / 2.0f`, `distX = |x cx| / (MapSize / 2.0f)`.
float centerX = mapSize / 2.0f, centerY = mapSize / 2.0f;
float halfSpan = mapSize / 2.0f;
// ═══ 1. THE COAST SHELF ═══
if (shelfOn)
{
// ⭐ THE CLAMP THAT MAKES "CANNOT MOVE THE WATERLINE" EXACT. Reference: the remap is
// strictly positive on positive depth, so in exact arithmetic the waterline cannot
// move; in float32 it can — a pixel a few microns under water rounds back up to
// exactly sea and `h < sea` then calls it land. That cost 5 px of 67 M on the
// reference's first batch. Hold the result strictly below sea and the invariant is
// exact. Do not port this without the clamp.
float strictlyBelowSea = MathF.BitDecrement(sea);
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
float h = height[x, y];
if (h >= sea) continue; // below-sea ONLY
float depthM = WorldScale.MetresFromRaw(sea - h); // (seaHere finalH) * 251f
height[x, y] = MathF.Min(
sea - WorldScale.RawFromMetres(IslandFalloff.CoastShelf(depthM, cfg.ShelfStrength, cfg.ShelfScaleM)),
strictlyBelowSea);
r.ShelfCells++;
}
}
r.Notes.Add($"[Offshore] coast shelf: {r.ShelfCells:N0} below-sea cells remapped " +
$"(strength {cfg.ShelfStrength:F3}, scale {cfg.ShelfScaleM:F0} m) — held strictly below sea.");
}
if (!isletsOn) return r;
r.Tag = new bool[mapSize, mapSize];
r.Hemi = new byte[mapSize, mapSize];
bool faithful = s.Mode == OffshoreMode.Faithful;
bool guardOn = !faithful && s.MinIslandAreaFrac > 0f;
float crest = sea + WorldScale.RawFromMetres(s.CrestM); // seaHere + OFFSHORE_ISLAND_H_M / 251f
// ═══ 2. THE ORGANIC LAYER ═══
var liftedOrigin = r.LiftedOrigin; // every lift, for the debris guard
if (s.Organic)
{
FastNoiseLite noise = TerrainNoise.CreateModulation(seed, s.SeedOffset, s.FreqPerMapWidth, scale);
// ⭐ Calibrate against the field's ACTUAL distribution, not the theoretical [1,1]:
// sample on a stride grid and take the quantile. Deterministic from the seed, and it
// makes the density dial mean what it says whatever FastNoiseLite's range turns out
// to be. Verbatim: stride 8, (side)² samples, (noise + 1) * 0.5.
const int stride = 8;
int side = mapSize / stride;
var samples = new float[side * side];
for (int i = 0; i < side; i++)
for (int j = 0; j < side; j++)
samples[i * side + j] = (noise.GetNoise2D(i * stride, j * stride) + 1f) * 0.5f;
float thrN = IslandFalloff.CalibrateThreshold(samples, s.DensityNorth);
float thrS = faithful ? thrN : IslandFalloff.CalibrateThreshold(samples, s.DensitySouth);
r.ThresholdNorth = thrN; r.ThresholdSouth = thrS;
float mid = mapSize * 0.5f;
float band = MathF.Max(1f, s.HemisphereBlendHalfWidth * mapSize);
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
float h = height[x, y];
if (h >= sea) continue; // below-sea ONLY — never touches land
float ambientDepthM = WorldScale.MetresFromRaw(sea - h);
float zone = IslandFalloff.OffshoreZoneWeight(
ambientDepthM, preTrench[x, y],
MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan,
s.MinDepthM, s.DepthFeatherM, s.MinFalloff, s.FalloffFeather,
s.TrenchInner, s.TrenchOuter);
if (zone <= 0f) continue;
float v = (noise.GetNoise2D(x, y) + 1.0f) * 0.5f;
// The threshold: one number in faithful mode; north/south blended smoothly
// across the midline in the reshape, so a straddling island is not sliced.
float thr;
if (faithful) thr = thrN;
else
{
float t = Math.Clamp((y - mid) / band * 0.5f + 0.5f, 0f, 1f);
t = t * t * (3f - 2f * t);
thr = thrN + (thrS - thrN) * t;
}
float blob = (faithful
? IslandFalloff.OffshoreBlob(v, thr)
: IslandFalloff.RigidBlob(v, thr, s.CoreFraction, s.EdgeSharpness)) * zone;
if (blob <= 0f) continue;
// ⭐ LERP TOWARD THE CREST, never add — surfaces at any ambient depth.
float before = h;
h = h + (crest - h) * blob; // Mathf.Lerp, written out
height[x, y] = h;
if (guardOn) r.RaisedOrigin.Add((x, y, before)); // for the debris guard (reshape only)
if (before < sea && h >= sea)
{
r.LiftedOrganic++;
r.Tag[x, y] = true;
r.Hemi[x, y] = OffshoreAnalysis.HemisphereOfRow(y, mapSize);
liftedOrigin.Add((x, y, before));
}
}
}
r.Notes.Add($"[Offshore] organic ({(faithful ? "faithful" : "reshaped")}): threshold N {thrN:F4}" +
$"{(faithful ? "" : $" S {thrS:F4}")} from {samples.Length:N0} samples " +
$"(range {Min(samples):F3}..{Max(samples):F3}); lifted {r.LiftedOrganic:N0} cells above sea.");
}
// ═══ 3. THE SEEDED FLOOR (Hybrid only) ═══
if (s.Mode == OffshoreMode.Hybrid && (s.FloorNorth > 0 || s.FloorSouth > 0))
{
var rng = new Pcg32(seed + s.PlacementSeedOffset);
int radius = Math.Max(2, (int)MathF.Round(s.StampRadiusFrac * mapSize));
// Rim jitter field: fine enough to put a few lobes around a stamp's rim (wavelength
// ≈ radius / 1.5), stated per map width so the look holds at every size. Seeded off
// the islet offset so it is decorrelated from the organic field but just as deterministic.
FastNoiseLite jitterNoise = s.StampEdgeJitter > 0f
? TerrainNoise.CreateModulation(seed, s.SeedOffset + 1, 1.5f / MathF.Max(s.StampRadiusFrac, 1e-4f), scale)
: null;
int reachRadius = (int)MathF.Ceiling(radius * (1f + s.StampEdgeJitter));
int gap = Math.Max(1, (int)MathF.Round(s.LandGapFrac * mapSize));
float sepPx = s.SeparationFrac * mapSize;
float zoneFloorDepth = s.MinDepthM + s.DepthFeatherM; // zone weight exactly 1
float zoneFloorFalloff = s.MinFalloff + s.FalloffFeather;
var placed = new List<(int x, int y)>();
foreach (byte hemi in new[] { OffshoreAnalysis.HemiNorth, OffshoreAnalysis.HemiSouth })
{
int need = hemi == OffshoreAnalysis.HemiNorth ? s.FloorNorth : s.FloorSouth;
if (need <= 0) continue;
string name = OffshoreAnalysis.HemisphereName(hemi);
int y0 = hemi == OffshoreAnalysis.HemiNorth ? 0 : mapSize / 2;
int y1 = hemi == OffshoreAnalysis.HemiNorth ? mapSize / 2 : mapSize;
int got = 0, attempts = 0;
while (got < need)
{
if (++attempts > s.MaxPlacementAttempts)
throw new InvalidOperationException(
$"[OffshorePass] could not place the seeded floor: {name} needed {need}, placed {got} " +
$"after {attempts - 1} attempts. The valid ocean zone is too small or too crowded " +
$"for sep {s.SeparationFrac:F3} / gap {s.LandGapFrac:F3} / trench ≤ {s.TrenchInner:F2}. " +
"Refusing rather than under-delivering a guaranteed floor.");
int cx = rng.NextInt(mapSize);
int cy = y0 + rng.NextInt(y1 - y0);
// Valid ocean: below sea, FULLY inside the zone (moat + falloff + trench),
// so the centre's own weight is exactly 1 and the stamp surfaces.
float hc = height[cx, cy];
if (hc >= sea) continue;
if (WorldScale.MetresFromRaw(sea - hc) < zoneFloorDepth) continue;
if (preTrench[cx, cy] < zoneFloorFalloff) continue;
float dTr = MathF.Max(MathF.Abs(cx - centerX) / halfSpan, MathF.Abs(cy - centerY) / halfSpan);
if (dTr > s.TrenchInner) continue;
// Separation from the other seeded centres.
bool tooClose = false;
foreach (var (px, py) in placed)
{
float ddx = px - cx, ddy = py - cy;
if (ddx * ddx + ddy * ddy < sepPx * sepPx) { tooClose = true; break; }
}
if (tooClose) continue;
// ⭐ THE LAND GAP — the guarantee's mechanism. No land of ANY kind (mainland or
// organic) within radius + gap of the centre, so this stamp can touch nothing
// and is its own connected component by construction.
if (LandWithin(height, mapSize, cx, cy, reachRadius + gap, sea)) continue;
long lifted = Stamp(height, preTrench, r, cx, cy, radius, s, sea, crest, mapSize, centerX, centerY, halfSpan, jitterNoise);
if (lifted == 0)
throw new InvalidOperationException(
$"[OffshorePass] a seeded stamp at ({cx},{cy}) lifted no cells — the zone test and the stamp disagree. Refusing.");
placed.Add((cx, cy));
r.Centres.Add((cx, cy, radius));
r.LiftedSeeded += lifted;
got++;
r.Notes.Add($"[Offshore] floor {name} #{got} at ({cx},{cy}) r{radius}: lifted {lifted:N0} cells (attempt {attempts})");
}
}
}
// ═══ 3b. THE DEBRIS GUARD — reshape only; runs LAST, over everything the pass raised ═══
//
// Two kinds of debris, one rule. (a) SPECKS: a local maximum that barely clears the
// threshold surfaces a cap of a few cells — not an island. (b) SUBMERGED BUMPS: every
// blob with any weight lerps the seabed toward the crest whether or not it surfaces, so
// the ocean fills with shallow humps — which the first probe plate showed as a field of
// pale speckles across the southern sea, and which the water pass would later draw as a
// reef field nobody asked for. (The faithful control keeps both; that is the reference.)
//
// The rule: find the surviving islands (components at or above the minimum area), take
// each one's bounding box with a margin as its KEEP region, and put every organic raise
// OUTSIDE all keep regions back to the height it had — surfaced or not, tagged or not. A
// real island keeps its own submerged skirt; everything else goes back to seabed.
//
// ⚠ Runs after the floor so a stamp's rim satellite (w·zone crossing the waterline
// non-monotonically) is caught too; the stamp bodies are ~800+ cells and survive by
// construction. The floor is still asserted below.
if (guardOn && (r.RaisedOrigin.Count > 0 || r.LiftedOrigin.Count > 0))
{
long minCells = Math.Max(1L, (long)Math.Round(s.MinIslandAreaFrac * (double)mapSize * mapSize));
var pre = OffshoreAnalysis.Components(r.Tag, height, sea, mapSize, out int[] compId);
// Rule 1 — SPECKS go by MEMBERSHIP: any surfaced cell whose component is below the
// minimum is reverted and untagged, wherever it sits (a speck inside a real island's
// skirt is still a speck — the first keep-box cut let those survive as 1-cell islands).
// Rule 2 — SUBMERGED BUMPS go by LOCATION: a raise outside every surviving island's
// skirt (bbox + margin) goes back to seabed; inside, it is that island's own slope.
var small = new HashSet<int>();
var keep = new List<(int x0, int y0, int x1, int y1)>();
foreach (var c in pre)
{
if (c.Cells < minCells) { small.Add(c.Id); continue; }
int w = c.MaxX - c.MinX + 1, hgt = c.MaxY - c.MinY + 1;
int margin = Math.Max(4, Math.Max(w, hgt) / 2);
keep.Add((c.MinX - margin, c.MinY - margin, c.MaxX + margin, c.MaxY + margin));
}
bool Kept(int x, int y)
{
foreach (var (x0, y0, x1, y1) in keep)
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return true;
return false;
}
long revertedRaised = 0, revertedLifted = 0;
foreach (var (x, y, h0) in r.LiftedOrigin)
{
if (!small.Contains(compId[x * mapSize + y])) continue;
height[x, y] = h0; r.Tag[x, y] = false; r.Hemi[x, y] = OffshoreAnalysis.HemiNone;
revertedLifted++;
}
foreach (var (x, y, h0) in r.RaisedOrigin)
{
if (r.Tag[x, y]) continue; // a kept island's own surfaced cell
if (Kept(x, y)) continue; // inside a kept island's skirt
if (height[x, y] > h0) { height[x, y] = h0; revertedRaised++; }
}
r.LiftedReverted = revertedLifted;
r.RaisedReverted = revertedRaised;
r.Notes.Add($"[Offshore] debris guard: {small.Count} of {pre.Count} islands below {minCells:N0} cells reverted " +
$"({revertedLifted:N0} surfaced cells), plus {revertedRaised:N0} submerged bump cells outside any kept island's skirt.");
}
// ═══ PROVE THE FLOOR ON THE FINISHED FIELD — in the pass, before anyone looks ═══
var comps = OffshoreAnalysis.Components(r.Tag, height, sea, mapSize);
(r.CountNorth, r.CountSouth) = OffshoreAnalysis.CountByHemisphere(comps);
r.Bridged = OffshoreAnalysis.BridgedCount(comps);
var (szMin, szMed, szMean, szMax, _) = OffshoreAnalysis.SizeSummary(comps, 0);
r.Notes.Add($"[Offshore] islands: {r.CountNorth} north, {r.CountSouth} south ({comps.Count} components, " +
$"{r.Bridged} bridged to mainland); lifted {r.LiftedOrganic + r.LiftedSeeded - r.LiftedReverted:N0} cells net" +
$"{(r.RaisedReverted > 0 ? $", {r.RaisedReverted:N0} submerged debris cells reverted" : "")}; " +
$"size cells min {szMin} median {szMed} mean {szMean:F0} max {szMax}.");
if (s.Mode == OffshoreMode.Hybrid && (r.CountNorth < s.FloorNorth || r.CountSouth < s.FloorSouth))
throw new InvalidOperationException(
$"[OffshorePass] FLOOR VIOLATION after placement: {r.CountNorth} N / {r.CountSouth} S against " +
$"{s.FloorNorth} N / {s.FloorSouth} S. The land gap should have made this impossible. Refusing.");
if (r.Bridged > 0)
throw new InvalidOperationException(
$"[OffshorePass] MOAT VIOLATION: {r.Bridged} island(s) touch mainland land. Refusing.");
return r;
}
/// <summary>One seeded stamp: a flat-topped disc lerped toward the crest, zone-masked per pixel.</summary>
private static long Stamp(float[,] height, float[,] preTrench, Result r, int cx, int cy, int radius,
OffshoreSettings s, float sea, float crest, int mapSize, float centerX, float centerY, float halfSpan,
FastNoiseLite jitterNoise)
{
long lifted = 0;
int reach = (int)MathF.Ceiling(radius * (1f + s.StampEdgeJitter));
for (int dx = -reach; dx <= reach; dx++)
{
int px = cx + dx;
if (px < 0 || px >= mapSize) continue;
for (int dy = -reach; dy <= reach; dy++)
{
int py = cy + dy;
if (py < 0 || py >= mapSize) continue;
// The rim wanders: the effective radius at this pixel is the nominal one scaled
// by ±jitter from a fine noise field. Flat top and crisp shore are kept; the
// compass-drawn outline is not.
float rEff = radius;
if (jitterNoise != null)
rEff = radius * (1f + s.StampEdgeJitter * jitterNoise.GetNoise2D(px, py));
float dist = MathF.Sqrt(dx * dx + dy * dy);
float w = IslandFalloff.StampWeight(dist, rEff, s.StampCoreFrac);
if (w <= 0f) continue;
float h = height[px, py];
if (h >= sea) continue; // never touches land
// ⭐ The moat and the falloff test apply to the FLOOR too. Absolute.
float zone = IslandFalloff.OffshoreZoneWeight(
WorldScale.MetresFromRaw(sea - h), preTrench[px, py],
MathF.Abs(px - centerX) / halfSpan, MathF.Abs(py - centerY) / halfSpan,
s.MinDepthM, s.DepthFeatherM, s.MinFalloff, s.FalloffFeather,
s.TrenchInner, s.TrenchOuter);
float t = w * zone;
if (t <= 0f) continue;
float before = h;
h = h + (crest - h) * t;
height[px, py] = h;
if (before < sea && h >= sea)
{
lifted++;
r.Tag[px, py] = true;
r.Hemi[px, py] = OffshoreAnalysis.HemisphereOfRow(py, mapSize);
r.LiftedOrigin.Add((px, py, before));
}
}
}
return lifted;
}
/// <summary>Any land cell (at/above sea) within a Chebyshev box of <paramref name="reach"/>? Conservative by design.</summary>
private static bool LandWithin(float[,] height, int mapSize, int cx, int cy, int reach, float sea)
{
int x0 = Math.Max(0, cx - reach), x1 = Math.Min(mapSize - 1, cx + reach);
int y0 = Math.Max(0, cy - reach), y1 = Math.Min(mapSize - 1, cy + reach);
for (int x = x0; x <= x1; x++)
for (int y = y0; y <= y1; y++)
if (height[x, y] >= sea) return true;
return false;
}
private static float Min(float[] a) { float m = float.MaxValue; foreach (float v in a) if (v < m) m = v; return m; }
private static float Max(float[] a) { float m = float.MinValue; foreach (float v in a) if (v > m) m = v; return m; }
/// <summary>
/// PCG32 (O'Neill) — the same tiny deterministic generator the reference's erosion pass
/// used. Seeded from the world seed + an offset, so island POSITIONS vary per world and
/// reproduce exactly for one. Trivially portable to C++.
/// </summary>
private struct Pcg32
{
private ulong _state;
public Pcg32(int seed) { _state = 0; NextU(); _state += (ulong)(uint)seed; NextU(); }
public uint NextU()
{
ulong old = _state;
_state = old * 6364136223846793005UL + 1442695040888963407UL;
uint xorshifted = (uint)(((old >> 18) ^ old) >> 27);
int rot = (int)(old >> 59);
return (xorshifted >> rot) | (xorshifted << (-rot & 31));
}
public int NextInt(int n) => (int)(NextU() % (uint)n);
}
}
}

View file

@ -0,0 +1 @@
uid://cseifxt2bd820

View file

@ -0,0 +1,210 @@
using System.Text;
namespace IslaApocalypse.Tools
{
/// <summary>Which offshore-islet system pass 1 runs. → <see cref="OffshoreSettings"/>.</summary>
public enum OffshoreMode
{
/// <summary>No islets. The shelf is gated separately (<c>TerrainGenConfig.CoastShelf</c>).</summary>
Off,
/// <summary>
/// ⭐ chat2/05 stage 1 — the reference's probabilistic layer, verbatim: one noise field, one
/// calibrated threshold, the faithful blob, the faithful zone mask. Sparse, no corners, no
/// floor. THE CONTROL.
/// </summary>
Faithful,
/// <summary>
/// ⭐ chat2/05 stage 2 — the loose-guaranteed hybrid: a seeded floor of ≥N north / ≥S south
/// islands (min-separated, positions varying per seed, never fixed zones) PLUS the organic
/// layer on top, reshaped small / low / flat / rigid, south-weighted, corners allowed.
/// </summary>
Hybrid,
}
/// <summary>
/// ⭐ EVERY OFFSHORE-ISLET DIAL, IN ONE OBJECT — config-gated and isolated, per the developer's
/// standing modularity concern. Nothing above <see cref="OffshorePass"/> hardcodes an island
/// specific; the whole system is this object + <c>IslandFalloff</c> + <c>OffshorePass</c>.
///
/// ═══ TWO PRESETS, AND WHY BOTH EXIST ═══
///
/// <see cref="Faithful"/> the reference's constants, verbatim. The control in every batch.
/// <see cref="Hybrid"/> the reshape — the deliverable.
///
/// The reshape does not EDIT the faithful constants; it sets different values on the same
/// dials. So <c>Faithful()</c> stays bit-reproducible however far the hybrid is tuned.
///
/// ═══ THE TWO PROTECTIONS THAT ARE NOT DIALS ═══
///
/// <see cref="MinDepthM"/> (the moat) and <see cref="MinFalloff"/> (the "actually offshore" test)
/// are exposed here because every knob is, but they are the MAIN-ISLAND AND LAKE PROTECTIONS and
/// the presets do not move them. They are what makes "no island can bridge to shore" and "no
/// island in a lake or the crater bay" true by construction — and <c>OffshorePass</c> applies
/// them to the SEEDED stamps as well, so a guaranteed island cannot be guaranteed into the wrong
/// place.
/// </summary>
public sealed class OffshoreSettings
{
public OffshoreMode Mode = OffshoreMode.Off;
// ---- the organic (noise) layer ----------------------------------------
/// <summary>Run the noise layer at all. Off ⇒ only the seeded floor (the `floor_only` contrast).</summary>
public bool Organic = true;
/// <summary>Islet noise frequency, periods per map width. Higher ⇒ SMALLER blobs. Reference 14.</summary>
public float FreqPerMapWidth = IslandFalloff.OFFSHORE_FREQ_ISLANDS;
/// <summary>Islet noise seed offset. Reference 7607.</summary>
public int SeedOffset = IslandFalloff.OFFSHORE_SEED_OFFSET;
/// <summary>
/// Organic density — the fraction of the noise field's ACTUAL sampled distribution that
/// clears the threshold — per hemisphere. Equal ⇒ the reference's single dial. South &gt;
/// north ⇒ the developer's "weighted south". Reference 0.02 / 0.02.
/// </summary>
public float DensityNorth = 0.02f;
public float DensitySouth = 0.02f;
/// <summary>
/// Half-width of the smooth threshold blend across the hemisphere midline, as a fraction of
/// the map. ⚠ Without it a north/south density difference would cut any organic island that
/// straddles the midline along a dead-straight line. 0 ⇒ hard step (never wanted).
/// </summary>
public float HemisphereBlendHalfWidth = 0.05f;
/// <summary>Islet crest, metres above sea, PRE-CURVE. Reference 34. The curve's toe squashes it lower.</summary>
public float CrestM = IslandFalloff.OFFSHORE_ISLAND_H_M;
/// <summary>Fraction of a blob's excess over threshold that saturates. LOWER ⇒ flatter. Reference 0.45.</summary>
public float CoreFraction = IslandFalloff.OFFSHORE_CORE;
/// <summary>Crest-to-sea edge sharpening exponent. 1 ⇒ the faithful smoothstep. Higher ⇒ crisper shore.</summary>
public float EdgeSharpness = 1f;
/// <summary>
/// ⚠ THE DEBRIS GUARD. An organic island smaller than this fraction of the map's area is
/// noise debris — a local maximum that barely cleared the threshold and surfaced a cap of a
/// few cells — and is reverted to seabed and untagged. 0 ⇒ off (the reference had no such
/// rule and its "~585 px blobs" comment was about the NOISE wavelength, not the caps).
/// Stated as a fraction so the rule is scale-free: 3e-5 is ~126 cells at 2048, ~2,000 cells
/// (a ~50 m islet) at 8192.
/// </summary>
public float MinIslandAreaFrac = 0f;
// ---- the zone mask: protections + the Trench bound --------------------
/// <summary>⭐ THE MOAT. Reference 14 m. The presets do not move it.</summary>
public float MinDepthM = IslandFalloff.OFFSHORE_MIN_DEPTH_M;
public float DepthFeatherM = IslandFalloff.OFFSHORE_DEPTH_FEATHER_M;
/// <summary>⭐ THE "ACTUALLY OFFSHORE" TEST on the pre-Trench falloff. Reference 0.72. The presets do not move it.</summary>
public float MinFalloff = IslandFalloff.OFFSHORE_MIN_FALLOFF;
public float FalloffFeather = IslandFalloff.OFFSHORE_FALLOFF_FEATHER;
/// <summary>
/// The outer bound, as a fraction of the half-span (map-anchored, like the Trench). Zone
/// fades from INNER to zero at OUTER. Reference 0.78 / 0.86 keeps islets well off the
/// Trench ramp (which starts at 0.90) and out of the corners. The reshape pushes both OUT so
/// islands populate the corners and may sit over the outer edge — the developer accepts
/// islands clipped by the map edge. OUTER &lt; 1 keeps every island's centre on the playable
/// map.
/// </summary>
public float TrenchInner = IslandFalloff.OFFSHORE_TRENCH_INNER;
public float TrenchOuter = IslandFalloff.OFFSHORE_TRENCH_OUTER;
// ---- the seeded floor (Hybrid only) -----------------------------------
/// <summary>⭐ The guaranteed minimum island count per hemisphere. 0 / 0 ⇒ no floor (Faithful).</summary>
public int FloorNorth = 0;
public int FloorSouth = 0;
/// <summary>Stamp radius, fraction of the map. Small and low is the target.</summary>
public float StampRadiusFrac = 0.012f;
/// <summary>Fraction of the stamp radius that is flat top. Higher ⇒ flatter, crisper shore.</summary>
public float StampCoreFrac = 0.60f;
/// <summary>
/// Radial jitter on the stamp's rim, as a fraction of the radius, from a fine noise field:
/// the outline wanders ±this much around the circle. 0 ⇒ a compass-drawn disc (the first
/// probe plate: rigid, but visibly geometric). 0.25 keeps the crisp shore and the flat top
/// while the coastline stops looking stamped.
/// </summary>
public float StampEdgeJitter = 0f;
/// <summary>Minimum centre-to-centre separation between seeded islands, fraction of the map.</summary>
public float SeparationFrac = 0.08f;
/// <summary>
/// Minimum clear water between a seeded stamp's rim and ANY existing land (mainland or
/// organic), fraction of the map. This is what makes every seeded island its own connected
/// component by construction — and therefore what makes the floor COUNT hold, not just the
/// number of stamps.
/// </summary>
public float LandGapFrac = 0.010f;
/// <summary>The placement RNG's seed offset. A SEED offset; positions vary per world seed.</summary>
public int PlacementSeedOffset = 7703;
/// <summary>Rejection-sampling budget per hemisphere before the pass REFUSES (loudly) rather than under-delivers.</summary>
public int MaxPlacementAttempts = 40000;
// ---- presets ----------------------------------------------------------
/// <summary>The reference, verbatim. No floor, single density, original footprint/crest/mask.</summary>
public static OffshoreSettings Faithful() => new OffshoreSettings
{
Mode = OffshoreMode.Faithful,
Organic = true,
FreqPerMapWidth = IslandFalloff.OFFSHORE_FREQ_ISLANDS,
SeedOffset = IslandFalloff.OFFSHORE_SEED_OFFSET,
DensityNorth = 0.02f, DensitySouth = 0.02f, // ConfigManager.OffshoreIslandDensity
CrestM = IslandFalloff.OFFSHORE_ISLAND_H_M,
CoreFraction = IslandFalloff.OFFSHORE_CORE,
EdgeSharpness = 1f,
TrenchInner = IslandFalloff.OFFSHORE_TRENCH_INNER,
TrenchOuter = IslandFalloff.OFFSHORE_TRENCH_OUTER,
FloorNorth = 0, FloorSouth = 0,
};
/// <summary>
/// ⭐ The reshape: small / low / flatter / more rigid, loose-guaranteed (≥2 N, ≥4 S), organic
/// extras weighted south, corners and the outer edge allowed.
/// </summary>
public static OffshoreSettings Hybrid() => new OffshoreSettings
{
Mode = OffshoreMode.Hybrid,
Organic = true,
// ⚠ Chosen by SWEEP, not by feel (chat2/05 scratch): at freq 24 / 0.010 / 0.025 the field
// produced 183 organic blobs of which 174 were debris. freq 16 with these densities gives
// the floor plus ~+12 N / +68 S genuine extras of a few hundred cells each at 2048.
FreqPerMapWidth = 16f, // a little smaller than the reference's 14
DensityNorth = 0.007f, DensitySouth = 0.012f, // weighted south
CrestM = 24f, // lower than the reference's 34 (pre-curve)
CoreFraction = 0.25f, // flatter than 0.45
EdgeSharpness = 2.5f, // crisper shore than the faithful smoothstep
MinIslandAreaFrac = 3e-5f, // no noise debris
TrenchInner = 0.90f, TrenchOuter = 0.97f, // corners + outer edge allowed; centre stays on-map
FloorNorth = 2, FloorSouth = 4,
StampRadiusFrac = 0.012f, StampCoreFrac = 0.60f, StampEdgeJitter = 0.25f,
SeparationFrac = 0.08f, LandGapFrac = 0.010f,
};
public OffshoreSettings Clone() => (OffshoreSettings)MemberwiseClone();
public string Describe()
{
if (Mode == OffshoreMode.Off) return "offshore OFF";
var sb = new StringBuilder();
sb.Append($"{Mode}: organic {(Organic ? "on" : "OFF")} freq {FreqPerMapWidth:F0}/map density N {DensityNorth:F3} S {DensitySouth:F3} · ");
sb.Append($"crest {CrestM:F0} m core {CoreFraction:F2} sharp {EdgeSharpness:F1} minArea {MinIslandAreaFrac:G2} · ");
sb.Append($"moat {MinDepthM:F0}+{DepthFeatherM:F0} m falloff {MinFalloff:F2}+{FalloffFeather:F2} trench {TrenchInner:F2}→{TrenchOuter:F2}");
if (FloorNorth > 0 || FloorSouth > 0)
sb.Append($" · floor N{FloorNorth} S{FloorSouth} r {StampRadiusFrac:F3} core {StampCoreFrac:F2} sep {SeparationFrac:F2} gap {LandGapFrac:F3}");
return sb.ToString();
}
}
}

View file

@ -0,0 +1 @@
uid://cv2f5uho1c3km

View file

@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace IslaApocalypse.Tools namespace IslaApocalypse.Tools
{ {
/// <summary> /// <summary>
@ -59,11 +61,54 @@ namespace IslaApocalypse.Tools
/// <summary>The minimum height. Not a reference field — carried for the renderer's ramp and the report.</summary> /// <summary>The minimum height. Not a reference field — carried for the renderer's ramp and the report.</summary>
public readonly float HMinSeed; public readonly float HMinSeed;
/// <summary>
/// ⚠ The map-wide max BEFORE the coast shelf and offshore islets ran (chat2/05). The
/// reference takes <c>_hMaxSeed</c> AFTER both, inside the same loop; v2 now does too —
/// <see cref="HMaxSeed"/> is the post-shelf/offshore value the curve normalizes against.
/// This one is carried so the report can state whether the two differed (expected: no, an
/// islet crest of ~34 m is far below any peak — but "expected" is measured, not assumed).
/// </summary>
public readonly float HMaxSeedBeforeOffshore;
// ═══ ⭐ THE OFFSHORE TAG — chat2/05's one forward-looking piece ═══
//
// DATA, set here, carried downstream, READ BY NOTHING IN THIS PHASE. It exists so a later
// pass (biome, fertility, placement) can find offshore-island land without re-deriving it
// from geometry. Downstream that ignores it is unaffected; downstream that reads it gets a
// clean flag. Both arrays are NULL when the offshore layer is off — a consumer checks for
// null, not for all-false.
/// <summary>
/// Per column: is this land an offshore island (as opposed to mainland)? Set for every cell
/// the islet layer lifted from below sea to at-or-above sea. Null when offshore is off.
/// </summary>
public readonly bool[,] IsOffshoreIsland;
/// <summary>
/// Per column: <see cref="OffshoreAnalysis.HemiNorth"/> / <see cref="OffshoreAnalysis.HemiSouth"/>
/// for tagged cells, <see cref="OffshoreAnalysis.HemiNone"/> otherwise. The convention is the
/// row midline — see <see cref="OffshoreAnalysis"/>. Null when offshore is off.
/// </summary>
public readonly byte[,] IslandHemisphere;
/// <summary>The seeded-floor stamp centres and radii (Hybrid only; empty otherwise). For the overlay and the report.</summary>
public readonly IReadOnlyList<(int x, int y, int r)> OffshoreCentres;
/// <summary>Cells the islet layer lifted above sea. The reference printed this too.</summary>
public readonly long OffshoreLiftedCells;
/// <summary>Lines worth printing from the shelf/offshore pass: thresholds, floor placement, lifted counts.</summary>
public readonly IReadOnlyList<string> Notes;
/// <summary>Wall-clock milliseconds the pass took.</summary> /// <summary>Wall-clock milliseconds the pass took.</summary>
public readonly ulong ElapsedMs; public readonly ulong ElapsedMs;
public Pass1Result(int mapSize, int seed, float[,] height, float[,] preTrenchFalloff, public Pass1Result(int mapSize, int seed, float[,] height, float[,] preTrenchFalloff,
float[,] latitudeField, float hMaxSeed, float hMinSeed, ulong elapsedMs) float[,] latitudeField, float hMaxSeed, float hMinSeed, ulong elapsedMs,
float hMaxSeedBeforeOffshore = float.NaN,
bool[,] isOffshoreIsland = null, byte[,] islandHemisphere = null,
IReadOnlyList<(int x, int y, int r)> offshoreCentres = null,
long offshoreLiftedCells = 0, IReadOnlyList<string> notes = null)
{ {
MapSize = mapSize; MapSize = mapSize;
Seed = seed; Seed = seed;
@ -73,8 +118,17 @@ namespace IslaApocalypse.Tools
HMaxSeed = hMaxSeed; HMaxSeed = hMaxSeed;
HMinSeed = hMinSeed; HMinSeed = hMinSeed;
ElapsedMs = elapsedMs; ElapsedMs = elapsedMs;
HMaxSeedBeforeOffshore = float.IsNaN(hMaxSeedBeforeOffshore) ? hMaxSeed : hMaxSeedBeforeOffshore;
IsOffshoreIsland = isOffshoreIsland;
IslandHemisphere = islandHemisphere;
OffshoreCentres = offshoreCentres ?? System.Array.Empty<(int, int, int)>();
OffshoreLiftedCells = offshoreLiftedCells;
Notes = notes ?? System.Array.Empty<string>();
} }
/// <summary>Whether the offshore layer ran on this field (the tag arrays are present).</summary>
public bool HasOffshoreTag => IsOffshoreIsland != null;
/// <summary>Fraction of the map at or above the sea threshold. A cheap shape sanity number.</summary> /// <summary>Fraction of the map at or above the sea threshold. A cheap shape sanity number.</summary>
public float LandFraction(float seaLevel) public float LandFraction(float seaLevel)
{ {

View file

@ -83,6 +83,24 @@ namespace IslaApocalypse.Tools
/// </summary> /// </summary>
public readonly ContinuousCurve Continuous; public readonly ContinuousCurve Continuous;
// ═══ ⭐ THE OFFSHORE TAG, CARRIED (chat2/05) ═══
//
// Pass 1 sets it; pass 2 carries it UNCHANGED beside the two height fields, because this is
// where the shaped-terrain result flows and where a downstream consumer would pick it up.
// The curve is identity at sea and monotone above, so a cell that was offshore-island LAND
// in pass 1 is still land in the render field — the tag stays valid for both fields without
// being recomputed (oracle: "classify/render coastline consistent").
//
// ⚠ NO LOGIC READS IT THIS PHASE. It is a data layer. A biome/fertility/placement pass reads
// it from here, checks for null (offshore off), and never re-derives island-land from
// geometry.
/// <summary>→ <see cref="Pass1Result.IsOffshoreIsland"/>, the same array. Null when offshore is off.</summary>
public readonly bool[,] IsOffshoreIsland;
/// <summary>→ <see cref="Pass1Result.IslandHemisphere"/>, the same array. Null when offshore is off.</summary>
public readonly byte[,] IslandHemisphere;
/// <summary>Was shelf detail applied? Requires <see cref="CurveOn"/> — it warps the curve's knots.</summary> /// <summary>Was shelf detail applied? Requires <see cref="CurveOn"/> — it warps the curve's knots.</summary>
public readonly bool DetailOn; public readonly bool DetailOn;
@ -118,8 +136,10 @@ namespace IslaApocalypse.Tools
bool curveOn, bool detailOn, string curveModeLabel, string variantLabel, bool curveOn, bool detailOn, string curveModeLabel, string variantLabel,
ContinuousCurve continuous, CurveKnots knots, CurveAnchors anchors, float hMaxSeed, ContinuousCurve continuous, CurveKnots knots, CurveAnchors anchors, float hMaxSeed,
float edgeAmpRaw, float maxEdgeShiftRaw, float hMin, float hMax, ulong elapsedMs, float edgeAmpRaw, float maxEdgeShiftRaw, float hMin, float hMax, ulong elapsedMs,
List<string> notes) List<string> notes, bool[,] isOffshoreIsland = null, byte[,] islandHemisphere = null)
{ {
IsOffshoreIsland = isOffshoreIsland;
IslandHemisphere = islandHemisphere;
MapSize = mapSize; MapSize = mapSize;
Seed = seed; Seed = seed;
Height = height; Height = height;

View file

@ -74,7 +74,8 @@ namespace IslaApocalypse.Tools
curveOn: false, detailOn: false, curveModeLabel: "off", variantLabel: cfg.VariantLabel, continuous: null, curveOn: false, detailOn: false, curveModeLabel: "off", variantLabel: cfg.VariantLabel, continuous: null,
knots: null, anchors: null, hMaxSeed: p1.HMaxSeed, knots: null, anchors: null, hMaxSeed: p1.HMaxSeed,
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed, edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed,
elapsedMs: Time.GetTicksMsec() - t0, notes: notes); elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
isOffshoreIsland: p1.IsOffshoreIsland, islandHemisphere: p1.IslandHemisphere);
} }
// ═══ WHICH CURVE (chat2/02) ═══ // ═══ WHICH CURVE (chat2/02) ═══
@ -210,7 +211,8 @@ namespace IslaApocalypse.Tools
curveOn: true, detailOn: detailOn, curveModeLabel: "staircase", variantLabel: cfg.VariantLabel, continuous: null, curveOn: true, detailOn: detailOn, curveModeLabel: "staircase", variantLabel: cfg.VariantLabel, continuous: null,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax, edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax,
elapsedMs: Time.GetTicksMsec() - t0, notes: notes); elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
isOffshoreIsland: p1.IsOffshoreIsland, islandHemisphere: p1.IslandHemisphere);
} }
/// <summary> /// <summary>
@ -280,7 +282,8 @@ namespace IslaApocalypse.Tools
curveOn: true, detailOn: false, curveModeLabel: "continuous", variantLabel: cfg.VariantLabel, continuous: curve, curveOn: true, detailOn: false, curveModeLabel: "continuous", variantLabel: cfg.VariantLabel, continuous: curve,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax, edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax,
elapsedMs: Time.GetTicksMsec() - t0, notes: notes); elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
isOffshoreIsland: p1.IsOffshoreIsland, islandHemisphere: p1.IslandHemisphere);
} }
/// <summary> /// <summary>
@ -333,7 +336,8 @@ namespace IslaApocalypse.Tools
curveOn: true, detailOn: false, curveModeLabel: "lifted_WRONG", variantLabel: cfg.VariantLabel, continuous: null, curveOn: true, detailOn: false, curveModeLabel: "lifted_WRONG", variantLabel: cfg.VariantLabel, continuous: null,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax, edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax,
elapsedMs: Time.GetTicksMsec() - t0, notes: notes); elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
isOffshoreIsland: p1.IsOffshoreIsland, islandHemisphere: p1.IslandHemisphere);
} }
} }
} }

View file

@ -400,6 +400,130 @@ namespace IslaApocalypse.Tools
return land == 0 ? (0.0, 0.0) : (100.0 * a100 / land, 100.0 * a220 / land); return land == 0 ? (0.0, 0.0) : (100.0 * a100 / land, 100.0 * a220 / land);
} }
// ═══ chat2/05 — the offshore checks ═══
/// <summary>
/// (h) ⭐ FLOOR HOLDS — at least <paramref name="needNorth"/> / <paramref name="needSouth"/>
/// offshore islands per hemisphere, counted as 8-connected components of TAGGED land by
/// centroid. The pass already refused to return without this; the oracle proves it again,
/// independently, on the finished field — a guarantee checked once is a guarantee checked by
/// the thing that might be wrong.
/// </summary>
public static Check OffshoreFloor(Pass1Result p1, int needNorth, int needSouth, float sea,
List<IslandComponent> comps)
{
var c = new Check { Id = "h", Name = $"offshore floor ≥{needNorth} N / ≥{needSouth} S" };
if (!p1.HasOffshoreTag)
{
c.Passed = false;
c.Detail = "no offshore tag on this field — offshore was off.";
return c;
}
var (n, s) = OffshoreAnalysis.CountByHemisphere(comps);
c.Passed = n >= needNorth && s >= needSouth;
c.Detail = $"{n} north, {s} south ({comps.Count} islands)";
return c;
}
/// <summary>
/// (i) ⭐ MOAT INTACT — no offshore island is 8-connected to mainland land. The moat exists
/// to make a land bridge impossible; this is the proof that it did.
/// </summary>
public static Check MoatIntact(Pass1Result p1, List<IslandComponent> comps)
{
var c = new Check { Id = "i", Name = "moat intact — no island touches the mainland" };
int bridged = OffshoreAnalysis.BridgedCount(comps);
c.Passed = p1.HasOffshoreTag && bridged == 0;
c.Detail = !p1.HasOffshoreTag ? "no offshore tag — nothing to check"
: bridged == 0 ? $"all {comps.Count} islands are separated from mainland by water"
: $"{bridged} island(s) BRIDGE to mainland land";
return c;
}
/// <summary>
/// (j) ⭐ MAINLAND UNMOVED — with offshore on vs off, every cell that was LAND with it off is
/// BIT-IDENTICAL with it on. The shelf touches only below-sea cells, the islets only lift
/// below-sea cells; neither may touch existing land. (The falloff test and the moat did their
/// job if this holds.) Reports how many sea cells the shelf moved and how many were lifted.
/// </summary>
public static Check MainlandUnmoved(Pass1Result off, Pass1Result on, float sea)
{
var c = new Check { Id = "j", Name = "mainland unmoved — every offshore-OFF land cell bit-identical with offshore ON" };
long land = 0, landDiff = 0, seaChanged = 0, lifted = 0;
string first = null;
for (int x = 0; x < off.MapSize; x++)
{
for (int y = 0; y < off.MapSize; y++)
{
float a = off.Height[x, y], b = on.Height[x, y];
if (a >= sea)
{
land++;
if (BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b))
{
landDiff++;
first ??= $"first at [{x},{y}]: {a:G9} → {b:G9}";
}
}
else
{
if (a != b) seaChanged++;
if (b >= sea) lifted++;
}
}
}
c.Passed = landDiff == 0;
c.Detail = landDiff == 0
? $"all {land:N0} land cells bit-identical; {seaChanged:N0} sea cells remapped by the shelf, {lifted:N0} lifted to land"
: $"{landDiff:N0} of {land:N0} land cells CHANGED — {first}";
return c;
}
/// <summary>
/// (k) TAG ↔ COASTLINE CONSISTENT — per cell, classify-land ⇔ render-land (the curve is
/// identity at sea and monotone above, so it must be — D-046), and every TAGGED cell is land
/// in both fields. This is what lets the tag be carried through pass 2 without recomputation.
/// </summary>
public static Check TagCoastlineConsistent(Pass2Result p2, float sea)
{
var c = new Check { Id = "k", Name = "offshore tag: classify/render coastline consistent, every tagged cell is land" };
long mismatch = 0, tagNotLand = 0, tagged = 0;
for (int x = 0; x < p2.MapSize; x++)
{
for (int y = 0; y < p2.MapSize; y++)
{
bool cl = p2.HeightClassify[x, y] >= sea;
bool rl = p2.Height[x, y] >= sea;
if (cl != rl) mismatch++;
if (p2.IsOffshoreIsland != null && p2.IsOffshoreIsland[x, y])
{
tagged++;
if (!cl || !rl) tagNotLand++;
}
}
}
c.Passed = mismatch == 0 && tagNotLand == 0;
c.Detail = $"{mismatch:N0} classify/render landness mismatches; {tagNotLand:N0} of {tagged:N0} tagged cells not land";
return c;
}
/// <summary>
/// (l) HMaxSeed RECOMPUTED AFTER SHELF + OFFSHORE — reported. Expected unchanged (a ~34 m
/// crest vs a ~290 m peak), but the ORDER is the fix (chat2/00 Drift §2), and the value is
/// measured rather than assumed. Always passes; the detail is the point.
/// </summary>
public static Check HMaxAfterOffshore(Pass1Result p1)
{
bool moved = p1.HMaxSeed != p1.HMaxSeedBeforeOffshore;
return new Check
{
Id = "l", Name = "HMaxSeed recomputed after shelf + offshore",
Passed = true,
Detail = $"before {p1.HMaxSeedBeforeOffshore:F6} → after {p1.HMaxSeed:F6} " +
(moved ? "— ⚠ MOVED (an islet outran the peak?)" : "— unchanged, as expected; the ORDER is now right by construction"),
};
}
/// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary> /// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary>
public static string ToMarkdownTable(IEnumerable<Check> checks) public static string ToMarkdownTable(IEnumerable<Check> checks)
{ {

View file

@ -0,0 +1,96 @@
using System.Collections.Generic;
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// The offshore TAG / HEMISPHERE debug overlay (chat2/05): mainland one tint, offshore-island
/// land tinted by hemisphere, seeded centres ringed, the midline drawn — so the island count,
/// the N/S split and the tag's correctness are all visible at one glance.
///
/// ⚠ A DIAGNOSTIC, NOT A MAP. It draws the tag layer, which is DATA the shape pass set; it is
/// the one artifact in the batch that shows what a downstream consumer of the tag would see.
/// No hypsometry, no hillshade — three flat tints and some rings, on purpose.
///
/// Presentation only: it is handed arrays and returns a PNG. It cannot change them.
/// </summary>
public static class TagOverlayRenderer
{
private static readonly Color Sea = new(0.055f, 0.110f, 0.235f);
private static readonly Color Mainland = new(0.310f, 0.360f, 0.300f);
private static readonly Color IslandN = new(0.250f, 0.850f, 0.950f); // cool — north
private static readonly Color IslandS = new(0.980f, 0.600f, 0.200f); // warm — south
private static readonly Color Untagged = new(0.950f, 0.150f, 0.800f); // ⚠ land that is neither — must never appear
private static readonly Color Midline = new(0.700f, 0.720f, 0.760f);
private static readonly Color Ring = new(1.000f, 1.000f, 1.000f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
/// <param name="isMainlandLand">
/// Per cell, land that is NOT offshore (from the offshore-OFF field, so a tag bug cannot hide
/// by mis-tagging mainland). Null ⇒ derived as "land and not tagged", which is weaker.
/// </param>
public static void SavePng(float[,] height, bool[,] tag, byte[,] hemi, int mapSize, float sea,
IReadOnlyList<(int x, int y, int r)> centres, int countN, int countS, string absolutePath)
{
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
Color c;
bool land = height[x, y] >= sea;
bool tagged = tag != null && tag[x, y];
if (!land) c = Sea;
else if (!tagged) c = Mainland;
else if (hemi == null) c = Untagged;
else c = hemi[x, y] switch
{
OffshoreAnalysis.HemiNorth => IslandN,
OffshoreAnalysis.HemiSouth => IslandS,
_ => Untagged,
};
img.SetPixel(x, y, c);
}
}
// The hemisphere midline — the tag's convention, drawn where it bites.
int mid = mapSize / 2;
for (int x = 0; x < mapSize; x += 3) img.SetPixel(x, mid, Midline);
// Seeded centres: a ring at the stamp radius, so the floor islands can be told from the
// organic ones by eye.
if (centres != null)
foreach (var (cx, cy, r) in centres)
DrawRing(img, cx, cy, r, mapSize);
// A legend that cannot be separated from the picture.
int s = mapSize >= 4096 ? 4 : 3;
int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(img, "OFFSHORE TAG OVERLAY", 12, 12, s, Ink);
TinyFont.Draw(img, "GREY: MAINLAND CYAN: ISLAND N ORANGE: ISLAND S", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"ISLANDS: {countN} NORTH {countS} SOUTH - RINGS: SEEDED FLOOR", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, "N ABOVE THE LINE - S BELOW - Y RUNS SOUTH", 12, 12 + lh * 3, s, Ink);
Error err = img.SavePng(absolutePath);
if (err != Error.Ok) GD.PrintErr($"[TagOverlayRenderer] SavePng failed ({err}) for {absolutePath}");
}
private static void DrawRing(Image img, int cx, int cy, int r, int n)
{
int rr = r + 3; // just outside the rim
int steps = System.Math.Max(64, rr * 4);
for (int i = 0; i < steps; i++)
{
double a = i * 2.0 * System.Math.PI / steps;
for (int t = 0; t < 2; t++) // 2 px thick
{
int px = cx + (int)System.Math.Round((rr + t) * System.Math.Cos(a));
int py = cy + (int)System.Math.Round((rr + t) * System.Math.Sin(a));
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, Ring);
}
}
}
}
}

View file

@ -0,0 +1 @@
uid://dxsgyj0fg77rr

View file

@ -240,6 +240,40 @@ namespace IslaApocalypse.Tools
/// <summary>Crater centre Y, columns. Unused while <see cref="CraterRadius"/> is 0.</summary> /// <summary>Crater centre Y, columns. Unused while <see cref="CraterRadius"/> is 0.</summary>
public float CraterCenterY = 0f; public float CraterCenterY = 0f;
// ---- PASS 1b — the coast shelf + offshore islets (chat2/05) ----------
//
// ⚠⚠ BOTH DEFAULT OFF, DELIBERATELY — and that is a decision to revisit, not an oversight.
//
// Every oracle in this phase holds pass 1 against Phase 1's `.f32` dumps (curve-off ==
// `02_pass1_port`), and the curve tools hold it against task 01/03's. The shelf changes every
// below-sea cell and the islets ADD LAND, so the moment either defaults ON, every one of
// those regression anchors goes stale at once. The batch tools that want them turn them on
// explicitly. FLIPPING THESE DEFAULTS IS THE ACT THAT RETIRES THE PHASE-1 REGRESSION DUMPS —
// do it deliberately, in a task that re-baselines the oracles, not as a side effect here.
/// <summary>
/// The submarine coast shelf (<c>IslandFalloff.CoastShelf</c>). Below-sea only,
/// depth-preserving, held strictly below sea by <c>MathF.BitDecrement</c>. Invisible until
/// water renders; ported faithfully now, judged then.
/// </summary>
public bool CoastShelf = false;
// ⚠ Fully qualified: this class's own `IslandFalloff` ablation toggle shadows the static
// type of the same name inside field initializers.
/// <summary>Shelf strength, 0 = off → 1 = a flat lagoon. Reference 0.775.</summary>
public float ShelfStrength = IslaApocalypse.Tools.IslandFalloff.SHELF_STRENGTH;
/// <summary>Metres of depth over which the shelf relaxes. Reference 100.</summary>
public float ShelfScaleM = IslaApocalypse.Tools.IslandFalloff.SHELF_SCALE_M;
/// <summary>
/// ⭐ The offshore islet system — every dial in one object. <c>Mode = Off</c> by default
/// (see the note above). <see cref="OffshoreSettings.Faithful"/> is the reference verbatim;
/// <see cref="OffshoreSettings.Hybrid"/> is the reshape.
/// </summary>
public OffshoreSettings Offshore = new OffshoreSettings();
/// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary> /// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary>
public string VariantLabel = "full"; public string VariantLabel = "full";
@ -256,6 +290,7 @@ namespace IslaApocalypse.Tools
{ {
var c = (TerrainGenConfig)MemberwiseClone(); var c = (TerrainGenConfig)MemberwiseClone();
c.Anchors = Anchors?.Clone(); c.Anchors = Anchors?.Clone();
c.Offshore = Offshore?.Clone(); // same reason: a mutable dial object, deep-copied
return c; return c;
} }

View file

@ -27,12 +27,13 @@ namespace IslaApocalypse.Tools
/// so it is a raw additive wall the exponent never softens. preTrenchFalloff is captured /// so it is a raw additive wall the exponent never softens. preTrenchFalloff is captured
/// between them. Reordering any of it changes the island. /// between them. Reordering any of it changes the island.
/// ///
/// ═══ ⚠ WHAT IS DELIBERATELY NOT PORTED HERE ═══ /// ═══ 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 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). Both are /// the submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). v2 runs them
/// DEFERRED to Phase 2 by the developer's ruling — they act only on below-sea height and are /// as a second sweep over the finished arrays — <see cref="OffshorePass"/> — with the same
/// judged once water renders. <see cref="Pass1Result.PreTrenchFalloff"/> is exposed for them. /// 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, /// 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. /// no rivers, no water bodies, no crater carve, no biomes.
@ -279,14 +280,46 @@ namespace IslaApocalypse.Tools
if (finalH < hMin) hMin = finalH; if (finalH < hMin) hMin = finalH;
height[x, y] = finalH; height[x, y] = finalH;
// ⚠ THE REFERENCE'S PASS 1 CONTINUES HERE with the coast shelf (~:621-640) and // The reference's pass 1 CONTINUED HERE with the coast shelf (~:621-640) and the
// the offshore islets (~:641-664). Both DEFERRED to Phase 2 — below-sea only, // offshore islets (~:641-664). v2 runs them as PASS 1b, a second sweep over these
// judged once water renders. preTrenchFalloff above is their inlet. // 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, return new Pass1Result(mapSize, cfg.Seed, height, preTrenchFalloff, latitudeField,
hMax, hMin, Time.GetTicksMsec() - t0); 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);
} }
} }
} }