diff --git a/Tools/README.md b/Tools/README.md
index c5bafbd..0608121 100644
--- a/Tools/README.md
+++ b/Tools/README.md
@@ -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/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/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
| `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).
> 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**
-(reference ~:621-664). Both act only below sea level and are judged once water renders.
-`Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and
-`Pass1Result.HMaxSeed` is carried for the seed-dependent redistribution curve.
+**Pass 1b — the coast shelf and the offshore islets (chat2/05).** The reference continued its pass-1
+loop with the submarine **coast shelf** and the **offshore islet** layer (~:621-664); v2 runs them as a
+second sweep over the finished arrays — `OffshorePass` — with the same per-pixel arithmetic in the same
+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
-> 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
-> `HMaxSeed` normalizes the curve's summit spike, porting those layers without moving the max
-> computation below them changes the world for a given seed — silently, with no throw and no failed
-> assertion. → chat2/00 report, Drift §2.
+> ### ⚠ Both default OFF — deliberately, and that is a decision to revisit.
+>
+> Every oracle in this phase holds pass 1 against Phase 1's `.f32` dumps. The shelf changes every
+> below-sea cell and the islets ADD LAND, so defaulting either ON stales every regression anchor at
+> once. Batch tools turn them on explicitly. **Flipping the defaults is the act that retires the
+> 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.
diff --git a/Tools/Scenes/OffshoreIslandsTool.tscn b/Tools/Scenes/OffshoreIslandsTool.tscn
new file mode 100644
index 0000000..b8af387
--- /dev/null
+++ b/Tools/Scenes/OffshoreIslandsTool.tscn
@@ -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")
diff --git a/Tools/Scripts/IslandFalloff.cs b/Tools/Scripts/IslandFalloff.cs
index 6afc47c..e195084 100644
--- a/Tools/Scripts/IslandFalloff.cs
+++ b/Tools/Scripts/IslandFalloff.cs
@@ -4,17 +4,27 @@ namespace IslaApocalypse.Tools
{
///
/// Shape helpers for the island mask, ported from the reference's
- /// Tools/Scripts/IslandFalloff.cs.
+ /// Tools/Scripts/IslandFalloff.cs — now the WHOLE file, in three parts:
///
- /// ⚠ ONLY THE PASS-1 PARTS ARE HERE. The reference file also carries the submarine COAST SHELF
- /// (SHELF_STRENGTH / SHELF_SCALE_M / CoastShelf) and the OFFSHORE ISLET layer (OffshoreBlob,
- /// OffshoreZoneWeight, CalibrateThreshold, and their constants). Both are DEFERRED to Phase 2 —
- /// they act on below-sea height and are judged once water renders. They will port into THIS
- /// file, which is why it keeps the reference's name and shape.
+ /// 1. the spine crest () — Phase 1, chat1
+ /// 2. the submarine COAST SHELF () — chat2/05 stage 1
+ /// 3. the OFFSHORE ISLET layer (,
+ /// , ) — chat2/05 stage 1
+ /// + the RESHAPE helpers (, ) — 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
+ /// .
///
/// 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
- /// halves land beside their siblings rather than in a second location.
+ /// port stays auditable as one unit against one reference file.
///
public static class IslandFalloff
{
@@ -47,5 +57,201 @@ namespace IslaApocalypse.Tools
float a = Math.Abs(d);
return a * a / MathF.Sqrt(a * a + epsilon * epsilon);
}
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // 2. THE COAST SHELF — chat2/05 stage 1, faithful (reference ~:48-62)
+ // ═══════════════════════════════════════════════════════════════════════
+ //
+ // depth' = depth · (1 − STRENGTH · exp(−depth / SCALE_M))
+ //
+ // The height curve is identity at and below sea, so it never reached the seabed. Measured
+ // on the reference: land rises from the shoreline at 0.038 m/px while the seabed drops at
+ // 0.258 m/px — a shelf on the land side and a ramp on the sea side. This compresses shallow
+ // depth so the shallows extend much further out, leaving deep water and the Trench alone.
+ // At the shoreline the seabed starts at (1 − STRENGTH) = 22.5 % of its former gradient.
+ //
+ // C^∞ everywhere and STRICTLY POSITIVE for positive depth — in exact arithmetic it cannot
+ // move the waterline by one pixel. ⚠ In float32 it can (see the call site's BitDecrement
+ // clamp), which is why "cannot" is enforced at the call site and not assumed here.
+ //
+ // ⚠ INVISIBLE UNTIL WATER RENDERS. Nothing in Phase 2's hypsometric plates shows it; it is
+ // ported faithfully now, wired in now, and judged when the water pass lands.
+
+ /// Reference: 0 = off, →1 = a flat lagoon.
+ public const float SHELF_STRENGTH = 0.775f;
+
+ /// Reference: metres of depth over which the shelf relaxes back to the raw seabed.
+ public const float SHELF_SCALE_M = 100f;
+
+ /// Remaps a positive depth in metres. Returns the new depth in metres. THE FAITHFUL FORM.
+ public static float CoastShelf(float depthMetres)
+ => CoastShelf(depthMetres, SHELF_STRENGTH, SHELF_SCALE_M);
+
+ /// The parameterized form. With the reference constants it IS the reference — same floats, same order.
+ 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".
+
+ /// Reference: ~585 px blobs at 8K — few and sizeable, not a scatter of 50 px debris.
+ public const float OFFSHORE_FREQ_ISLANDS = 14f;
+
+ /// Reference: the islet noise field's seed offset. A SEED offset, not a coordinate offset.
+ public const int OFFSHORE_SEED_OFFSET = 7607;
+
+ /// Reference: target crest, metres above sea, PRE-CURVE. The curve's toe squashes it lower.
+ public const float OFFSHORE_ISLAND_H_M = 34f;
+
+ /// Reference: fraction of a blob's excess over threshold that saturates to full weight.
+ public const float OFFSHORE_CORE = 0.45f;
+
+ ///
+ /// ⭐ 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.
+ ///
+ public const float OFFSHORE_MIN_DEPTH_M = 14f;
+
+ /// Reference: the moat's feather width, metres.
+ public const float OFFSHORE_DEPTH_FEATHER_M = 10f;
+
+ /// Reference: the Trench mask — zone fades from INNER to zero at OUTER (the Trench ramp starts at 0.90).
+ public const float OFFSHORE_TRENCH_INNER = 0.78f;
+ public const float OFFSHORE_TRENCH_OUTER = 0.86f;
+
+ ///
+ /// ⭐ 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 Pass1Result.PreTrenchFalloff exists.
+ ///
+ public const float OFFSHORE_MIN_FALLOFF = 0.72f;
+
+ /// Reference: the falloff test's feather width.
+ public const float OFFSHORE_FALLOFF_FEATHER = 0.06f;
+
+ ///
+ /// Blob weight in [0,1] for one ocean column. THE FAITHFUL FORM.
+ ///
+ /// ⚠ comes from , 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."
+ ///
+ public static float OffshoreBlob(float noise01, float threshold)
+ => OffshoreBlob(noise01, threshold, OFFSHORE_CORE);
+
+ ///
+ /// The parameterized form. 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.)
+ ///
+ 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);
+ }
+
+ ///
+ /// The noise value that of exceed.
+ /// Sorts a COPY, so the caller's array is left alone. Verbatim.
+ ///
+ 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)];
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+
+ ///
+ /// The parameterized form. ⚠ / 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.
+ ///
+ 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.
+ // ═══════════════════════════════════════════════════════════════════════
+
+ ///
+ /// The reshaped organic blob: the faithful smoothstep, then its weight pushed toward
+ /// saturation by — 1 − (1 − w)^sharpness. At
+ /// sharpness 1 this IS . 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// The seeded-floor stamp: a flat disc of radius radius · coreFraction at full weight,
+ /// then a smoothstep rim down to zero at . Higher
+ /// ⇒ flatter top, crisper shore. This is the "rigid, defined
+ /// coastline" the reshape asks for, in its simplest honest form.
+ ///
+ 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);
+ }
}
}
diff --git a/Tools/Scripts/OffshoreAnalysis.cs b/Tools/Scripts/OffshoreAnalysis.cs
new file mode 100644
index 0000000..07be0e7
--- /dev/null
+++ b/Tools/Scripts/OffshoreAnalysis.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+
+namespace IslaApocalypse.Tools
+{
+ /// One connected island of tagged offshore land, as the analysis sees it.
+ public sealed class IslandComponent
+ {
+ public int Id;
+ public long Cells;
+ public double CentroidX, CentroidY;
+ public int MinX, MinY, MaxX, MaxY;
+
+ /// Hemisphere by CENTROID (an island straddling the midline is counted once, where its mass is).
+ public byte Hemisphere;
+
+ ///
+ /// ⚠ 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.
+ ///
+ public bool BridgedToMainland;
+ }
+
+ ///
+ /// ⭐ 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 y / MapSize (+ 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): y increases SOUTHWARD. North is the top half of the image.
+ ///
+ /// 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.
+ ///
+ public static class OffshoreAnalysis
+ {
+ public const byte HemiNone = 0;
+ public const byte HemiNorth = 1;
+ public const byte HemiSouth = 2;
+
+ /// The convention, in one place. Every consumer of the tag reads hemisphere through this.
+ 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 };
+
+ ///
+ /// Label the 8-connected components of tagged offshore land, and for each, whether it
+ /// touches untagged land (a bridge). +
+ /// define "land"; defines "offshore". Both are needed: the bridge test
+ /// is "tagged cell next to a land cell that is not tagged".
+ ///
+ public static List Components(bool[,] tag, float[,] height, float sea, int mapSize)
+ => Components(tag, height, sea, mapSize, out _);
+
+ ///
+ /// As above, also returning the per-cell component id map (x * mapSize + y; 0 = not
+ /// tagged) — the debris guard needs membership, not just the list.
+ ///
+ public static List Components(bool[,] tag, float[,] height, float sea, int mapSize,
+ out int[] idMap)
+ {
+ var comps = new List();
+ 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 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;
+ }
+
+ /// Island counts per hemisphere, by component centroid.
+ public static (int north, int south) CountByHemisphere(List 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);
+ }
+
+ ///
+ /// 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).
+ ///
+ public static (long min, long median, double mean, long max, int belowThreshold)
+ SizeSummary(List comps, long threshold)
+ {
+ if (comps.Count == 0) return (0, 0, 0.0, 0, 0);
+ var sizes = new List(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);
+ }
+
+ /// How many components touch the mainland. Zero is the only acceptable answer.
+ public static int BridgedCount(List comps)
+ {
+ int b = 0;
+ foreach (var c in comps) if (c.BridgedToMainland) b++;
+ return b;
+ }
+ }
+}
diff --git a/Tools/Scripts/OffshoreAnalysis.cs.uid b/Tools/Scripts/OffshoreAnalysis.cs.uid
new file mode 100644
index 0000000..066df3e
--- /dev/null
+++ b/Tools/Scripts/OffshoreAnalysis.cs.uid
@@ -0,0 +1 @@
+uid://bw21tp5wa5vxt
diff --git a/Tools/Scripts/OffshoreIslandsTool.cs b/Tools/Scripts/OffshoreIslandsTool.cs
new file mode 100644
index 0000000..dac74fb
--- /dev/null
+++ b/Tools/Scripts/OffshoreIslandsTool.cs
@@ -0,0 +1,424 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ 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 (24–34 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")
+ ///
+ public partial class OffshoreIslandsTool : Node
+ {
+ private static readonly int[] DefaultHybridSeeds = { 1063685222, 20260821, 8675309, 123456789, 271828182, 999999937 };
+ private static readonly int[] DefaultSmallSeeds = { 1063685222, 8675309, 999999937 };
+
+ /// ⚠ Task 01's pool, verbatim — the curve's identity.
+ 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();
+ {
+ 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 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(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(); // per seed, shelf off + offshore off
+ var rows = new List();
+ var perVariantChecks = new List();
+ var knobs = new Dictionary();
+ 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();
+ 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();
+ 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();
+ 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 rows, Dictionary knobs,
+ List hard, List 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();
+ 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;
+ }
+ }
+}
diff --git a/Tools/Scripts/OffshoreIslandsTool.cs.uid b/Tools/Scripts/OffshoreIslandsTool.cs.uid
new file mode 100644
index 0000000..b6f514b
--- /dev/null
+++ b/Tools/Scripts/OffshoreIslandsTool.cs.uid
@@ -0,0 +1 @@
+uid://bvrfybmk7pvyn
diff --git a/Tools/Scripts/OffshorePass.cs b/Tools/Scripts/OffshorePass.cs
new file mode 100644
index 0000000..7d95c0b
--- /dev/null
+++ b/Tools/Scripts/OffshorePass.cs
@@ -0,0 +1,454 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐⭐ PASS 1b — THE COAST SHELF AND THE OFFSHORE ISLETS (chat2/05). Runs over the finished
+ /// pass-1 arrays, IN PLACE, before HMaxSeed 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 (MapGenerator.cs:621-664):
+ /// combine → shelf → islets → _hMaxSeed → 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 HMaxSeed 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 (Result.Tag) with its
+ /// hemisphere (Result.Hemi). That tag is data the shape pass sets and carries; nothing in
+ /// this phase reads it. → for the hemisphere convention.
+ ///
+ 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 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)
+ }
+
+ ///
+ /// Apply the shelf and/or islets to IN PLACE. Returns null when
+ /// both are off (nothing touched, nothing allocated).
+ ///
+ 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();
+ 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;
+ }
+
+ /// One seeded stamp: a flat-topped disc lerped toward the crest, zone-masked per pixel.
+ 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;
+ }
+
+ /// Any land cell (at/above sea) within a Chebyshev box of ? Conservative by design.
+ 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; }
+
+ ///
+ /// 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++.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/Tools/Scripts/OffshorePass.cs.uid b/Tools/Scripts/OffshorePass.cs.uid
new file mode 100644
index 0000000..32e82fb
--- /dev/null
+++ b/Tools/Scripts/OffshorePass.cs.uid
@@ -0,0 +1 @@
+uid://cseifxt2bd820
diff --git a/Tools/Scripts/OffshoreSettings.cs b/Tools/Scripts/OffshoreSettings.cs
new file mode 100644
index 0000000..8129e43
--- /dev/null
+++ b/Tools/Scripts/OffshoreSettings.cs
@@ -0,0 +1,210 @@
+using System.Text;
+
+namespace IslaApocalypse.Tools
+{
+ /// Which offshore-islet system pass 1 runs. → .
+ public enum OffshoreMode
+ {
+ /// No islets. The shelf is gated separately (TerrainGenConfig.CoastShelf).
+ Off,
+
+ ///
+ /// ⭐ 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.
+ ///
+ Faithful,
+
+ ///
+ /// ⭐ 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.
+ ///
+ Hybrid,
+ }
+
+ ///
+ /// ⭐ EVERY OFFSHORE-ISLET DIAL, IN ONE OBJECT — config-gated and isolated, per the developer's
+ /// standing modularity concern. Nothing above hardcodes an island
+ /// specific; the whole system is this object + IslandFalloff + OffshorePass.
+ ///
+ /// ═══ TWO PRESETS, AND WHY BOTH EXIST ═══
+ ///
+ /// the reference's constants, verbatim. The control in every batch.
+ /// the reshape — the deliverable.
+ ///
+ /// The reshape does not EDIT the faithful constants; it sets different values on the same
+ /// dials. So Faithful() stays bit-reproducible however far the hybrid is tuned.
+ ///
+ /// ═══ THE TWO PROTECTIONS THAT ARE NOT DIALS ═══
+ ///
+ /// (the moat) and (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 OffshorePass applies
+ /// them to the SEEDED stamps as well, so a guaranteed island cannot be guaranteed into the wrong
+ /// place.
+ ///
+ public sealed class OffshoreSettings
+ {
+ public OffshoreMode Mode = OffshoreMode.Off;
+
+ // ---- the organic (noise) layer ----------------------------------------
+
+ /// Run the noise layer at all. Off ⇒ only the seeded floor (the `floor_only` contrast).
+ public bool Organic = true;
+
+ /// Islet noise frequency, periods per map width. Higher ⇒ SMALLER blobs. Reference 14.
+ public float FreqPerMapWidth = IslandFalloff.OFFSHORE_FREQ_ISLANDS;
+
+ /// Islet noise seed offset. Reference 7607.
+ public int SeedOffset = IslandFalloff.OFFSHORE_SEED_OFFSET;
+
+ ///
+ /// 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 >
+ /// north ⇒ the developer's "weighted south". Reference 0.02 / 0.02.
+ ///
+ public float DensityNorth = 0.02f;
+ public float DensitySouth = 0.02f;
+
+ ///
+ /// 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).
+ ///
+ public float HemisphereBlendHalfWidth = 0.05f;
+
+ /// Islet crest, metres above sea, PRE-CURVE. Reference 34. The curve's toe squashes it lower.
+ public float CrestM = IslandFalloff.OFFSHORE_ISLAND_H_M;
+
+ /// Fraction of a blob's excess over threshold that saturates. LOWER ⇒ flatter. Reference 0.45.
+ public float CoreFraction = IslandFalloff.OFFSHORE_CORE;
+
+ /// Crest-to-sea edge sharpening exponent. 1 ⇒ the faithful smoothstep. Higher ⇒ crisper shore.
+ public float EdgeSharpness = 1f;
+
+ ///
+ /// ⚠ 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.
+ ///
+ public float MinIslandAreaFrac = 0f;
+
+ // ---- the zone mask: protections + the Trench bound --------------------
+
+ /// ⭐ THE MOAT. Reference 14 m. The presets do not move it.
+ public float MinDepthM = IslandFalloff.OFFSHORE_MIN_DEPTH_M;
+ public float DepthFeatherM = IslandFalloff.OFFSHORE_DEPTH_FEATHER_M;
+
+ /// ⭐ THE "ACTUALLY OFFSHORE" TEST on the pre-Trench falloff. Reference 0.72. The presets do not move it.
+ public float MinFalloff = IslandFalloff.OFFSHORE_MIN_FALLOFF;
+ public float FalloffFeather = IslandFalloff.OFFSHORE_FALLOFF_FEATHER;
+
+ ///
+ /// 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 < 1 keeps every island's centre on the playable
+ /// map.
+ ///
+ public float TrenchInner = IslandFalloff.OFFSHORE_TRENCH_INNER;
+ public float TrenchOuter = IslandFalloff.OFFSHORE_TRENCH_OUTER;
+
+ // ---- the seeded floor (Hybrid only) -----------------------------------
+
+ /// ⭐ The guaranteed minimum island count per hemisphere. 0 / 0 ⇒ no floor (Faithful).
+ public int FloorNorth = 0;
+ public int FloorSouth = 0;
+
+ /// Stamp radius, fraction of the map. Small and low is the target.
+ public float StampRadiusFrac = 0.012f;
+
+ /// Fraction of the stamp radius that is flat top. Higher ⇒ flatter, crisper shore.
+ public float StampCoreFrac = 0.60f;
+
+ ///
+ /// 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.
+ ///
+ public float StampEdgeJitter = 0f;
+
+ /// Minimum centre-to-centre separation between seeded islands, fraction of the map.
+ public float SeparationFrac = 0.08f;
+
+ ///
+ /// 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.
+ ///
+ public float LandGapFrac = 0.010f;
+
+ /// The placement RNG's seed offset. A SEED offset; positions vary per world seed.
+ public int PlacementSeedOffset = 7703;
+
+ /// Rejection-sampling budget per hemisphere before the pass REFUSES (loudly) rather than under-delivers.
+ public int MaxPlacementAttempts = 40000;
+
+ // ---- presets ----------------------------------------------------------
+
+ /// The reference, verbatim. No floor, single density, original footprint/crest/mask.
+ 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,
+ };
+
+ ///
+ /// ⭐ The reshape: small / low / flatter / more rigid, loose-guaranteed (≥2 N, ≥4 S), organic
+ /// extras weighted south, corners and the outer edge allowed.
+ ///
+ 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 ~+1–2 N / +6–8 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();
+ }
+ }
+}
diff --git a/Tools/Scripts/OffshoreSettings.cs.uid b/Tools/Scripts/OffshoreSettings.cs.uid
new file mode 100644
index 0000000..e0194ab
--- /dev/null
+++ b/Tools/Scripts/OffshoreSettings.cs.uid
@@ -0,0 +1 @@
+uid://cv2f5uho1c3km
diff --git a/Tools/Scripts/Pass1Result.cs b/Tools/Scripts/Pass1Result.cs
index 941114b..03172ba 100644
--- a/Tools/Scripts/Pass1Result.cs
+++ b/Tools/Scripts/Pass1Result.cs
@@ -1,3 +1,5 @@
+using System.Collections.Generic;
+
namespace IslaApocalypse.Tools
{
///
@@ -59,11 +61,54 @@ namespace IslaApocalypse.Tools
/// The minimum height. Not a reference field — carried for the renderer's ramp and the report.
public readonly float HMinSeed;
+ ///
+ /// ⚠ The map-wide max BEFORE the coast shelf and offshore islets ran (chat2/05). The
+ /// reference takes _hMaxSeed AFTER both, inside the same loop; v2 now does too —
+ /// 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).
+ ///
+ 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.
+
+ ///
+ /// 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.
+ ///
+ public readonly bool[,] IsOffshoreIsland;
+
+ ///
+ /// Per column: /
+ /// for tagged cells, otherwise. The convention is the
+ /// row midline — see . Null when offshore is off.
+ ///
+ public readonly byte[,] IslandHemisphere;
+
+ /// The seeded-floor stamp centres and radii (Hybrid only; empty otherwise). For the overlay and the report.
+ public readonly IReadOnlyList<(int x, int y, int r)> OffshoreCentres;
+
+ /// Cells the islet layer lifted above sea. The reference printed this too.
+ public readonly long OffshoreLiftedCells;
+
+ /// Lines worth printing from the shelf/offshore pass: thresholds, floor placement, lifted counts.
+ public readonly IReadOnlyList Notes;
+
/// Wall-clock milliseconds the pass took.
public readonly ulong ElapsedMs;
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 notes = null)
{
MapSize = mapSize;
Seed = seed;
@@ -73,8 +118,17 @@ namespace IslaApocalypse.Tools
HMaxSeed = hMaxSeed;
HMinSeed = hMinSeed;
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();
}
+ /// Whether the offshore layer ran on this field (the tag arrays are present).
+ public bool HasOffshoreTag => IsOffshoreIsland != null;
+
/// Fraction of the map at or above the sea threshold. A cheap shape sanity number.
public float LandFraction(float seaLevel)
{
diff --git a/Tools/Scripts/Pass2Result.cs b/Tools/Scripts/Pass2Result.cs
index d0c7a20..ecc6b4a 100644
--- a/Tools/Scripts/Pass2Result.cs
+++ b/Tools/Scripts/Pass2Result.cs
@@ -83,6 +83,24 @@ namespace IslaApocalypse.Tools
///
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.
+
+ /// → , the same array. Null when offshore is off.
+ public readonly bool[,] IsOffshoreIsland;
+
+ /// → , the same array. Null when offshore is off.
+ public readonly byte[,] IslandHemisphere;
+
/// Was shelf detail applied? Requires — it warps the curve's knots.
public readonly bool DetailOn;
@@ -118,8 +136,10 @@ namespace IslaApocalypse.Tools
bool curveOn, bool detailOn, string curveModeLabel, string variantLabel,
ContinuousCurve continuous, CurveKnots knots, CurveAnchors anchors, float hMaxSeed,
float edgeAmpRaw, float maxEdgeShiftRaw, float hMin, float hMax, ulong elapsedMs,
- List notes)
+ List notes, bool[,] isOffshoreIsland = null, byte[,] islandHemisphere = null)
{
+ IsOffshoreIsland = isOffshoreIsland;
+ IslandHemisphere = islandHemisphere;
MapSize = mapSize;
Seed = seed;
Height = height;
diff --git a/Tools/Scripts/Shaping.cs b/Tools/Scripts/Shaping.cs
index c53add1..8dbbc91 100644
--- a/Tools/Scripts/Shaping.cs
+++ b/Tools/Scripts/Shaping.cs
@@ -74,7 +74,8 @@ namespace IslaApocalypse.Tools
curveOn: false, detailOn: false, curveModeLabel: "off", variantLabel: cfg.VariantLabel, continuous: null,
knots: null, anchors: null, hMaxSeed: 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) ═══
@@ -210,7 +211,8 @@ namespace IslaApocalypse.Tools
curveOn: true, detailOn: detailOn, curveModeLabel: "staircase", variantLabel: cfg.VariantLabel, continuous: null,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
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);
}
///
@@ -280,7 +282,8 @@ namespace IslaApocalypse.Tools
curveOn: true, detailOn: false, curveModeLabel: "continuous", variantLabel: cfg.VariantLabel, continuous: curve,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
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);
}
///
@@ -333,7 +336,8 @@ namespace IslaApocalypse.Tools
curveOn: true, detailOn: false, curveModeLabel: "lifted_WRONG", variantLabel: cfg.VariantLabel, continuous: null,
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
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);
}
}
}
diff --git a/Tools/Scripts/ShapingOracle.cs b/Tools/Scripts/ShapingOracle.cs
index 4a7ee73..9ea805c 100644
--- a/Tools/Scripts/ShapingOracle.cs
+++ b/Tools/Scripts/ShapingOracle.cs
@@ -400,6 +400,130 @@ namespace IslaApocalypse.Tools
return land == 0 ? (0.0, 0.0) : (100.0 * a100 / land, 100.0 * a220 / land);
}
+ // ═══ chat2/05 — the offshore checks ═══
+
+ ///
+ /// (h) ⭐ FLOOR HOLDS — at least /
+ /// 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.
+ ///
+ public static Check OffshoreFloor(Pass1Result p1, int needNorth, int needSouth, float sea,
+ List 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;
+ }
+
+ ///
+ /// (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.
+ ///
+ public static Check MoatIntact(Pass1Result p1, List 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;
+ }
+
+ ///
+ /// (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.
+ ///
+ 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;
+ }
+
+ ///
+ /// (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.
+ ///
+ 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;
+ }
+
+ ///
+ /// (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.
+ ///
+ 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"),
+ };
+ }
+
/// Render the whole oracle as a markdown table for the INDEX and the report.
public static string ToMarkdownTable(IEnumerable checks)
{
diff --git a/Tools/Scripts/TagOverlayRenderer.cs b/Tools/Scripts/TagOverlayRenderer.cs
new file mode 100644
index 0000000..4b3d273
--- /dev/null
+++ b/Tools/Scripts/TagOverlayRenderer.cs
@@ -0,0 +1,96 @@
+using System.Collections.Generic;
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// 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.
+ ///
+ 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);
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+ }
+ }
+}
diff --git a/Tools/Scripts/TagOverlayRenderer.cs.uid b/Tools/Scripts/TagOverlayRenderer.cs.uid
new file mode 100644
index 0000000..d50a1fe
--- /dev/null
+++ b/Tools/Scripts/TagOverlayRenderer.cs.uid
@@ -0,0 +1 @@
+uid://dxsgyj0fg77rr
diff --git a/Tools/Scripts/TerrainGenConfig.cs b/Tools/Scripts/TerrainGenConfig.cs
index ad2d14f..f5ca742 100644
--- a/Tools/Scripts/TerrainGenConfig.cs
+++ b/Tools/Scripts/TerrainGenConfig.cs
@@ -240,6 +240,40 @@ namespace IslaApocalypse.Tools
/// Crater centre Y, columns. Unused while is 0.
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.
+
+ ///
+ /// The submarine coast shelf (IslandFalloff.CoastShelf). Below-sea only,
+ /// depth-preserving, held strictly below sea by MathF.BitDecrement. Invisible until
+ /// water renders; ported faithfully now, judged then.
+ ///
+ public bool CoastShelf = false;
+
+ // ⚠ Fully qualified: this class's own `IslandFalloff` ablation toggle shadows the static
+ // type of the same name inside field initializers.
+
+ /// Shelf strength, 0 = off → 1 = a flat lagoon. Reference 0.775.
+ public float ShelfStrength = IslaApocalypse.Tools.IslandFalloff.SHELF_STRENGTH;
+
+ /// Metres of depth over which the shelf relaxes. Reference 100.
+ public float ShelfScaleM = IslaApocalypse.Tools.IslandFalloff.SHELF_SCALE_M;
+
+ ///
+ /// ⭐ The offshore islet system — every dial in one object. Mode = Off by default
+ /// (see the note above). is the reference verbatim;
+ /// is the reshape.
+ ///
+ public OffshoreSettings Offshore = new OffshoreSettings();
+
/// A short label for this variant, used in output filenames. E.g. "full", "base_only".
public string VariantLabel = "full";
@@ -256,6 +290,7 @@ namespace IslaApocalypse.Tools
{
var c = (TerrainGenConfig)MemberwiseClone();
c.Anchors = Anchors?.Clone();
+ c.Offshore = Offshore?.Clone(); // same reason: a mutable dial object, deep-copied
return c;
}
diff --git a/Tools/Scripts/Topography.cs b/Tools/Scripts/Topography.cs
index 19c58c9..0adc131 100644
--- a/Tools/Scripts/Topography.cs
+++ b/Tools/Scripts/Topography.cs
@@ -27,12 +27,13 @@ namespace IslaApocalypse.Tools
/// so it is a raw additive wall the exponent never softens. preTrenchFalloff is captured
/// 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 submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). Both are
- /// DEFERRED to Phase 2 by the developer's ruling — they act only on below-sea height and are
- /// judged once water renders. is exposed for them.
+ /// the submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). v2 runs them
+ /// as a second sweep over the finished arrays — — with the same
+ /// per-pixel arithmetic in the same order, and then recomputes HMaxSeed AFTER them, as
+ /// the reference did. Config-gated; both default off (see TerrainGenConfig for why).
///
/// Nothing from pass 2 is here at all: no redistribution curve, no shelf detail, no erosion,
/// no rivers, no water bodies, no crater carve, no biomes.
@@ -279,14 +280,46 @@ namespace IslaApocalypse.Tools
if (finalH < hMin) hMin = finalH;
height[x, y] = finalH;
- // ⚠ THE REFERENCE'S PASS 1 CONTINUES HERE with the coast shelf (~:621-640) and
- // the offshore islets (~:641-664). Both DEFERRED to Phase 2 — below-sea only,
- // judged once water renders. preTrenchFalloff above is their inlet.
+ // The reference's pass 1 CONTINUED HERE with the coast shelf (~:621-640) and the
+ // offshore islets (~:641-664). v2 runs them as PASS 1b, a second sweep over these
+ // arrays, immediately below — same per-pixel arithmetic, same order, and the
+ // seeded floor needs the whole depth/falloff field to exist first. → OffshorePass.
}
}
+ // ═══ PASS 1b — THE COAST SHELF + OFFSHORE ISLETS (chat2/05) ═══
+ //
+ // In place, on `height`. Config-gated; returns null when both are off, in which case
+ // nothing above is touched and this pass-1 output is bit-identical to Phase 1's.
+ //
+ // ⚠⚠ HMaxSeed IS RECOMPUTED AFTER THIS — closing chat2/00 Drift §2. The reference took
+ // `_hMaxSeed` after the shelf and islets inside the same loop; v2 used to take it before
+ // they existed. The curve normalizes its summit spike against this value, so the order is
+ // load-bearing even when the number does not move (an islet crest is ~34 m; a peak is
+ // ~290 m). Both values are carried so the report states whether it moved, not guesses.
+ float hMaxBeforeOffshore = hMax;
+ OffshorePass.Result offshore = OffshorePass.Apply(height, preTrenchFalloff, mapSize, cfg.Seed, cfg.SeaLevel, cfg);
+ if (offshore != null)
+ {
+ hMax = float.MinValue;
+ hMin = float.MaxValue;
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ {
+ float h = height[x, y];
+ if (h > hMax) hMax = h;
+ if (h < hMin) hMin = h;
+ }
+ }
+
return new Pass1Result(mapSize, cfg.Seed, height, preTrenchFalloff, latitudeField,
- hMax, hMin, Time.GetTicksMsec() - t0);
+ 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);
}
}
}