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); } } }