diff --git a/Core/README.md b/Core/README.md
index 8e656df..aaa72c4 100644
--- a/Core/README.md
+++ b/Core/README.md
@@ -37,6 +37,7 @@ resolution and the file-safety rails. Constants and contracts.
| `Scripts/CurveKnots.cs` | The six INPUT knots — percentiles of the measured land CDF, plus the reference's for comparison. |
| `Scripts/CurveAnchors.cs` | The OUTPUT anchors — the storm-ladder elevations each band lands at. |
| `Scripts/TerrainDetailPass.cs` | Shelf micro-relief + the shelf-edge **knot warp**. Output-height only. |
+| `Scripts/HydraulicErosion.cs` | ⭐⭐ **Droplet (hydraulic) erosion** (chat2/11) — the reference's pass ported VERBATIM: four governors (count, lifetime, carve cap, deposit cap) on a net-displacement ledger proven on exit, the sea clamp (below-sea read-only both ways), the cone brush shared by erode and deposit, the crater exclusion (inert until the carve exists). Engine-free, own PCG32, `WorldScale`-denominated (no literal 251). Render-map only — the caller (`Tools/ErosionPass`) owns the split and the flood guard. |
| `Scripts/RegionLabeling.cs` | ⭐⭐ **The region-labeling layer** (chat2/07) — shared infrastructure. 8-connected land components on the CLASSIFY field; mainland = the centre component; per component id / size / centroid / hemisphere (by centroid) / isMainland. Pure, engine-free, C++-candidate; a **contract** downstream phases consume (islands first; biomes, placement, rivers, the crater later). The hemisphere convention lives here. |
| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |
diff --git a/Core/Scripts/HydraulicErosion.cs b/Core/Scripts/HydraulicErosion.cs
new file mode 100644
index 0000000..b2021ee
--- /dev/null
+++ b/Core/Scripts/HydraulicErosion.cs
@@ -0,0 +1,385 @@
+using System;
+
+namespace IslaApocalypse.Core
+{
+ ///
+ /// ⭐⭐ DROPLET (HYDRAULIC) EROSION — THE FAITHFUL PORT (chat2/11). Ported from the reference's
+ /// Tools/Scripts/HydraulicErosion.cs at tag pre-rewrite-reference (ab78883),
+ /// VERBATIM in arithmetic and order (D-050): the reference's class doc is kept below because it is
+ /// the design record; what this port changes is listed first.
+ ///
+ /// ═══ WHAT THE PORT CHANGES (and nothing else) ═══
+ ///
+ /// • Namespace + location: IslaApocalypse.Core — engine-free (System.MathF, own PCG32), a
+ /// named C++ candidate, exactly as the reference said it was.
+ /// • The yardstick: every metres↔raw conversion goes through
+ /// (MetresFromRaw / RawFromMetres) — there is no literal 251 in this file. The
+ /// arithmetic is the same float multiply / divide by the same constant, so results are
+ /// bit-identical to the reference's * M_PER_UNIT / / M_PER_UNIT.
+ /// • VERSION is a local constant (1): the blueprint format the reference read it from is
+ /// not ported yet; when it is, this becomes a read of the format's constant, as the reference
+ /// warned (task 18).
+ /// • The crater exclusion is ported whole and is INERT in v2 until the crater carve exists: the
+ /// caller passes radius 0, so CraterWeight is 1 everywhere (see ErosionPass).
+ ///
+ /// ═══ THE REFERENCE'S CLASS DOC (verbatim) ═══
+ ///
+ /// Droplet-based hydraulic erosion (terrain-water task 17, Phase C0) — the organic
+ /// carve-AND-deposit pass, Lague/Beyer lineage. Pure numeric over the height array
+ /// (D-035; a named future C++ candidate, kept standalone — no Godot types at all,
+ /// System.MathF only, own deterministic PCG32 RNG).
+ ///
+ /// Each droplet spawns on land (spawn probability weighted toward high ground),
+ /// then walks downhill with inertia, carrying water and sediment. Where the ground
+ /// is steep and it moves fast it ERODES (up to capacity, spread over a small brush
+ /// so no single-cell spikes — the anti-artifact that killed the D8 predecessor);
+ /// where it flattens out it DEPOSITS, building valley floors and fans, over the
+ /// SAME brush (task 18 — bilinear 4-cell deposition built isolated cones at gully
+ /// mouths; carving and dumping are now symmetric). Water
+ /// evaporates each step; the droplet dies at its lifetime, at the map edge, or on
+ /// reaching the sea (its remaining sediment is lost to the ocean).
+ ///
+ /// OUTPUT-ONLY: this pass is applied to the RENDER height map only; the classify
+ /// map never sees it (the caller owns that split — see ErosionPass).
+ ///
+ /// The three hard governors (the pass provably cannot run away):
+ /// 1. DropletCount — total droplets (the main detail/cost dial).
+ /// 2. Lifetime — max steps per droplet; no infinite wandering.
+ /// 3. CarveCapM — max erosion depth per cell, in metres, measured from the
+ /// height the pass found and enforced against a per-cell NET
+ /// displacement ledger. The runaway-trench guard, and the
+ /// dial that decides how deep trunk channels may cut.
+ /// 4. DepositCapM — max build-up per cell, the same ledger read the other way
+ /// (task 18). Brush-spreading alone does not bound a spike:
+ /// droplets on long paths carry far more sediment, and a
+ /// loaded droplet meeting a rise dumps min(rise, load) at
+ /// once. This makes "no deposit cones" a governor rather
+ /// than a hope. <= 0 disables it (the reference model).
+ ///
+ /// The sea clamp (the "don't over-flood" guard): erosion never lowers any cell
+ /// below its local sea level + SeaMarginM, and cells already below sea are
+ /// read-only — never eroded, never deposited on. Land stays land, sea stays sea;
+ /// the rendered coastline cannot move. Deposition only raises land cells.
+ ///
+ /// The crater treatment (task 19): no cell within the protected strike CORE is
+ /// modified (droplets may traverse), and outside it either FULL strength applies
+ /// immediately or FEATHER ramps in across a band. The carve remains the final
+ /// authority on the deep bowl; the bay's sea connection is guaranteed by the sea
+ /// clamp rather than by the exclusion, since below-sea cells are read-only in
+ /// both directions.
+ ///
+ /// Heights in the array are raw blueprint units (1 unit = 251 m). All sediment
+ /// accounting below is done in METRES and converted only when a delta is applied,
+ /// so untouched cells keep their exact bit pattern — the invariants above are
+ /// exact, not statistical.
+ ///
+ public static class HydraulicErosion
+ {
+ /// The EROS body version. ⚠ The reference read this from its blueprint format (the version byte IS the payload layout's identity); v2 has no format yet, so it is a local 1 until then.
+ public const ushort VERSION = 1;
+
+ /// Deterministic RNG stream: seeded from resolvedSeed + this offset, decorrelated from every noise field.
+ public const int SEED_OFFSET = 9271;
+
+ // --- Crater treatment (task 19) — ported whole, INERT in v2 until the crater carve lands ---
+ //
+ // Task 17 used a hard 1.2 × CraterRadius cutoff. Measured on seed 1280587109
+ // (task-19 radius dump): the carve writes only inside 0.80 × (640 px) and its
+ // displacement is EXACTLY 0 beyond that, so the 640–960 px annulus was 620,811
+ // land cells of ordinary terrain held smooth for no geometric reason — a
+ // visible un-eroded disc against dissected ground, with a hard edge.
+ //
+ // The protected core is now the deep strike zone only. The bay itself needs no
+ // exclusion: below-sea cells are read-only in both directions (the sea clamp),
+ // so erosion can neither carve the bay's sea connection open nor silt it shut.
+ // The core exists to stop the BOWL being dissected on seeds where it holds land.
+ public const float CRATER_CORE_FACTOR_DEFAULT = 0.50f; // ×CraterRadius (the pass's own default; the reference's ConfigManager shipped 0.80 — see ErosionPass)
+ public const float CRATER_FEATHER_FACTOR_DEFAULT = 1.05f; // ×CraterRadius, FEATHER only
+
+ public const byte CRATER_MODE_FULL = 0;
+ public const byte CRATER_MODE_FEATHER = 1;
+
+ // Spawn: droplets source in the mountains, never the ocean. A land point is
+ // accepted with probability SPAWN_FLOOR + (1-SPAWN_FLOOR) · relative elevation,
+ // after at most SPAWN_TRIES rejection-sampling attempts (then the droplet is
+ // skipped and counted — on any real island this is vanishingly rare).
+ private const int SPAWN_TRIES = 16;
+ private const float SPAWN_FLOOR = 0.15f;
+
+ private const float MIN_WATER = 0.005f; // droplet dies when effectively dry
+ private const float MIN_DIR = 1e-10f; // below this, direction is re-drawn at random
+
+ public struct Params
+ {
+ public int DropletCount; // governor 1
+ public int Lifetime; // governor 2
+ public float CarveCapM; // governor 3 (metres)
+ public float DepositCapM; // governor 4 (metres); <= 0 = unbounded
+ public float SeaMarginM; // sea clamp margin (metres)
+ public int BrushRadius; // erosion brush radius, px
+ public float Inertia; // 0 = pure gradient descent, 1 = never turns
+ public float CapacityFactor; // sediment capacity multiplier
+ public float MinSlopeM; // capacity slope floor, metres per px
+ public float ErodeRate; // fraction of remaining capacity eroded per step
+ public float DepositRate; // fraction of surplus sediment dropped per step
+ public float Evaporation; // water lost per step (fraction)
+ public float Gravity; // speed gain per metre of drop
+ public byte CraterMode; // CRATER_MODE_FULL | CRATER_MODE_FEATHER (task 19)
+ public int Seed; // resolvedSeed + SEED_OFFSET
+ }
+
+ public class Stats
+ {
+ public int Spawned;
+ public int SkippedNoLand;
+ public long Steps;
+ public int DiedLifetime, DiedEdge, DiedSea, DiedDry;
+ public double ErodedVolumeM3; // 1 px = 1 m², so metres of depth sum to m³
+ public double DepositedVolumeM3;
+ public float MaxCellErosionM; // must end ≤ CarveCapM
+ public float MaxCellDepositM; // the deposit-spike metric (task 18)
+ public long ModifiedCells; // cells the pass touched at all
+ }
+
+ // PCG32 (O'Neill) — tiny, deterministic, 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 float NextF() => (NextU() >> 8) * (1f / 16777216f); // [0,1)
+ }
+
+ ///
+ /// Runs the pass in place on . Sea level per cell is
+ /// [x,y] when non-null, else the flat scalar
+ /// . Throws (refusing the generation) if a governor
+ /// bound is violated on exit — the caller treats that as a build failure.
+ ///
+ public static Stats Apply(float[,] height, int mapSize, float[,] seaMap, float seaFlat,
+ float craterCx, float craterCy, float craterCoreRadius, float craterFeatherRadius, Params p)
+ {
+ var stats = new Stats();
+ var rng = new Pcg32(p.Seed);
+ float capUnits = WorldScale.RawFromMetres(p.CarveCapM);
+ if (p.DropletCount <= 0 || capUnits <= 0f) return stats;
+
+ // Per-cell NET displacement ledger, metres, positive = carved below where the
+ // pass found this cell, negative = built up above it. Governor 3's enforcement
+ // record: the cap bounds `net`, so it bounds erosion depth measured from the
+ // ORIGINAL height — deposit-then-carve at one cell cannot smuggle in extra
+ // depth, and carve-then-deposit correctly frees the headroom back up.
+ float[,] net = new float[mapSize, mapSize];
+
+ // Spawn weighting needs the seed's top height.
+ float hTop = float.MinValue;
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ if (height[x, y] > hTop) hTop = height[x, y];
+
+ // Erosion brush: all offsets within BrushRadius, cone-weighted (1 - d/r),
+ // normalized. Radius 0 degrades to the single cell.
+ int r = Math.Max(p.BrushRadius, 0);
+ int brushN = 0;
+ for (int dx = -r; dx <= r; dx++)
+ for (int dy = -r; dy <= r; dy++)
+ if (MathF.Sqrt(dx * dx + dy * dy) <= r + 1e-4f) brushN++;
+ int[] brushDx = new int[brushN], brushDy = new int[brushN];
+ float[] brushW = new float[brushN];
+ {
+ int i = 0; float wSum = 0f;
+ for (int dx = -r; dx <= r; dx++)
+ for (int dy = -r; dy <= r; dy++)
+ {
+ float d = MathF.Sqrt(dx * dx + dy * dy);
+ if (d > r + 1e-4f) continue;
+ brushDx[i] = dx; brushDy[i] = dy;
+ brushW[i] = r > 0 ? 1f - d / (r + 1f) : 1f;
+ wSum += brushW[i]; i++;
+ }
+ for (int j = 0; j < brushN; j++) brushW[j] /= wSum;
+ }
+
+ float SeaAt(int cx, int cy) => seaMap != null ? seaMap[cx, cy] : seaFlat;
+
+ // Crater weight (task 19): 0 inside the protected strike core, 1 where erosion
+ // runs at full strength. FULL steps straight to 1 at the core boundary; FEATHER
+ // ramps linearly out to craterFeatherRadius, mirroring the detail pass's shape,
+ // so the crater reads as younger/less-weathered with no seam. Amounts are SCALED
+ // by this rather than skipped, which is what makes FEATHER a one-liner.
+ // ⚠ v2: with no crater (radius 0) this is 1 everywhere — INERT.
+ float coreSq = craterCoreRadius * craterCoreRadius;
+ bool feather = p.CraterMode == CRATER_MODE_FEATHER
+ && craterFeatherRadius > craterCoreRadius;
+ float CraterWeight(int cx, int cy)
+ {
+ float ddx = cx - craterCx, ddy = cy - craterCy;
+ float d2 = ddx * ddx + ddy * ddy;
+ if (d2 < coreSq) return 0f;
+ if (!feather) return 1f;
+ float d = MathF.Sqrt(d2);
+ if (d >= craterFeatherRadius) return 1f;
+ return (d - craterCoreRadius) / (craterFeatherRadius - craterCoreRadius);
+ }
+
+ for (int drop = 0; drop < p.DropletCount; drop++)
+ {
+ // --- spawn (land only, elevation-weighted) ---
+ float px = -1f, py = -1f;
+ for (int attempt = 0; attempt < SPAWN_TRIES; attempt++)
+ {
+ float sx = 1f + rng.NextF() * (mapSize - 3);
+ float sy = 1f + rng.NextF() * (mapSize - 3);
+ int cx = (int)sx, cy = (int)sy;
+ float h = height[cx, cy];
+ float sea = SeaAt(cx, cy);
+ if (h < sea) { continue; }
+ float rel = hTop > sea ? Math.Clamp((h - sea) / (hTop - sea), 0f, 1f) : 0f;
+ if (rng.NextF() < SPAWN_FLOOR + (1f - SPAWN_FLOOR) * rel) { px = sx; py = sy; break; }
+ }
+ if (px < 0f) { stats.SkippedNoLand++; continue; }
+ stats.Spawned++;
+
+ float dirX = 0f, dirY = 0f, speed = 1f, water = 1f, sedimentM = 0f;
+
+ for (int step = 0; step < p.Lifetime; step++)
+ {
+ stats.Steps++;
+ int xi = (int)px, yi = (int)py;
+ float fx = px - xi, fy = py - yi;
+
+ // Bilinear height + gradient at the current position.
+ float h00 = height[xi, yi], h10 = height[xi + 1, yi];
+ float h01 = height[xi, yi + 1], h11 = height[xi + 1, yi + 1];
+ float gradX = (h10 - h00) * (1f - fy) + (h11 - h01) * fy;
+ float gradY = (h01 - h00) * (1f - fx) + (h11 - h10) * fx;
+ float hOld = h00 * (1f - fx) * (1f - fy) + h10 * fx * (1f - fy)
+ + h01 * (1f - fx) * fy + h11 * fx * fy;
+
+ // Inertia blend, then one unit step.
+ dirX = dirX * p.Inertia - gradX * (1f - p.Inertia);
+ dirY = dirY * p.Inertia - gradY * (1f - p.Inertia);
+ float len = MathF.Sqrt(dirX * dirX + dirY * dirY);
+ if (len < MIN_DIR)
+ {
+ float ang = rng.NextF() * 2f * MathF.PI;
+ dirX = MathF.Cos(ang); dirY = MathF.Sin(ang); len = 1f;
+ }
+ dirX /= len; dirY /= len;
+ px += dirX; py += dirY;
+
+ if (px < 1f || px >= mapSize - 2 || py < 1f || py >= mapSize - 2)
+ { stats.DiedEdge++; break; }
+
+ int nxi = (int)px, nyi = (int)py;
+ float nfx = px - nxi, nfy = py - nyi;
+ float n00 = height[nxi, nyi], n10 = height[nxi + 1, nyi];
+ float n01 = height[nxi, nyi + 1], n11 = height[nxi + 1, nyi + 1];
+ float hNew = n00 * (1f - nfx) * (1f - nfy) + n10 * nfx * (1f - nfy)
+ + n01 * (1f - nfx) * nfy + n11 * nfx * nfy;
+
+ // Reached the sea: die; the sediment is the ocean's now.
+ if (hNew < SeaAt(nxi, nyi)) { stats.DiedSea++; break; }
+
+ float dhM = WorldScale.MetresFromRaw(hNew - hOld);
+ float capacityM = MathF.Max(-dhM, p.MinSlopeM) * speed * water * p.CapacityFactor;
+
+ if (dhM > 0f || sedimentM > capacityM)
+ {
+ // Moving uphill (fill the pit behind us, at most the rise) or
+ // over capacity (drop a fraction of the surplus): DEPOSIT over
+ // the SAME cone brush erosion uses (task 18). Bilinear 4-cell
+ // deposition — the reference model's — concentrated a whole
+ // droplet's load into one cell at gully mouths and built
+ // isolated cones (measured 15.5 m on seed 1280587109, task 17
+ // §6.1). Spreading it makes deposition the symmetric mirror of
+ // carving; total mass is unchanged, only its footprint.
+ float amountM = dhM > 0f ? MathF.Min(dhM, sedimentM)
+ : (sedimentM - capacityM) * p.DepositRate;
+ if (amountM > 0f)
+ {
+ for (int b = 0; b < brushN; b++)
+ {
+ int cx = xi + brushDx[b], cy = yi + brushDy[b];
+ if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
+ float wCrater = CraterWeight(cx, cy);
+ if (wCrater <= 0f) continue;
+ float hCell = height[cx, cy];
+ // Below-sea cells are read-only in BOTH directions: no
+ // submarine deltas, so the rendered coastline cannot move.
+ if (hCell < SeaAt(cx, cy)) continue;
+ float give = amountM * brushW[b] * wCrater;
+ // Governor 4: the ledger read the other way. net is negative
+ // where the cell has already been built up, so the headroom
+ // is cap + net.
+ if (p.DepositCapM > 0f)
+ give = MathF.Min(give, MathF.Max(0f, p.DepositCapM + net[cx, cy]));
+ if (give <= 0f) continue;
+ height[cx, cy] = hCell + WorldScale.RawFromMetres(give);
+ if (net[cx, cy] == 0f) stats.ModifiedCells++;
+ net[cx, cy] -= give;
+ if (-net[cx, cy] > stats.MaxCellDepositM) stats.MaxCellDepositM = -net[cx, cy];
+ sedimentM -= give;
+ stats.DepositedVolumeM3 += give;
+ }
+ }
+ }
+ else
+ {
+ // Under capacity on a downhill move: ERODE, spread over the
+ // brush, never more than the drop itself (no digging pits).
+ float amountM = MathF.Min((capacityM - sedimentM) * p.ErodeRate, -dhM);
+ if (amountM > 0f)
+ {
+ for (int b = 0; b < brushN; b++)
+ {
+ int cx = xi + brushDx[b], cy = yi + brushDy[b];
+ if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
+ float wCrater = CraterWeight(cx, cy);
+ if (wCrater <= 0f) continue;
+ float sea = SeaAt(cx, cy);
+ float hCell = height[cx, cy];
+ if (hCell < sea) continue; // below-sea cells are read-only
+ float want = amountM * brushW[b] * wCrater;
+ float bySea = MathF.Max(0f, WorldScale.MetresFromRaw(hCell - (sea + WorldScale.RawFromMetres(p.SeaMarginM))));
+ float byCap = MathF.Max(0f, p.CarveCapM - net[cx, cy]);
+ float take = MathF.Min(want, MathF.Min(bySea, byCap));
+ if (take <= 0f) continue;
+ height[cx, cy] = hCell - WorldScale.RawFromMetres(take);
+ if (net[cx, cy] == 0f) stats.ModifiedCells++;
+ net[cx, cy] += take;
+ if (net[cx, cy] > stats.MaxCellErosionM) stats.MaxCellErosionM = net[cx, cy];
+ sedimentM += take;
+ stats.ErodedVolumeM3 += take;
+ }
+ }
+ }
+
+ speed = MathF.Sqrt(MathF.Max(0f, speed * speed - dhM * p.Gravity));
+ water *= 1f - p.Evaporation;
+ if (water < MIN_WATER) { stats.DiedDry++; break; }
+ if (step == p.Lifetime - 1) stats.DiedLifetime++;
+ }
+ }
+
+ // Governor 3, proven on exit rather than assumed: the ledger's maximum must
+ // respect the cap (float addition of clamped takes cannot exceed it by more
+ // than rounding; allow one ulp-scale epsilon).
+ if (stats.MaxCellErosionM > p.CarveCapM * (1f + 1e-5f))
+ throw new InvalidOperationException(
+ $"[HydraulicErosion] CARVE-CAP VIOLATION: a cell accumulated {stats.MaxCellErosionM} m against cap {p.CarveCapM} m. Refusing to generate.");
+ if (p.DepositCapM > 0f && stats.MaxCellDepositM > p.DepositCapM * (1f + 1e-5f))
+ throw new InvalidOperationException(
+ $"[HydraulicErosion] DEPOSIT-CAP VIOLATION: a cell built up {stats.MaxCellDepositM} m against cap {p.DepositCapM} m. Refusing to generate.");
+
+ return stats;
+ }
+ }
+}
diff --git a/Core/Scripts/HydraulicErosion.cs.uid b/Core/Scripts/HydraulicErosion.cs.uid
new file mode 100644
index 0000000..5907f42
--- /dev/null
+++ b/Core/Scripts/HydraulicErosion.cs.uid
@@ -0,0 +1 @@
+uid://bb4v6quey3xwl
diff --git a/Tools/README.md b/Tools/README.md
index 9de7678..fc8c5a6 100644
--- a/Tools/README.md
+++ b/Tools/README.md
@@ -44,6 +44,8 @@ constants, carried over verbatim — not re-derived from a design summary** (→
| `Scripts/CoastalFragment.cs` | ⭐ **Coastal fragmentation** (chat2/09, exploration) — a perimeter-wide, band-limited, zero-mean noise on the pre-power falloff inside the coastal window (≈ 0.66 ± 0.18); thin necks flip first; interior bit-identical by construction |
| `Scripts/CoastalFragmentTool.cs` + `Scenes/CoastalFragmentTool.tscn` | The chat2/09 batch — the frequency × amplitude probe (`ISLA_PROBE`) and the 4-amplitude × 2-seed ladder at a fixed stretch |
| `Scripts/FragGalleryTool.cs` + `Scenes/FragGalleryTool.tscn` | The chat2/10 gallery — render-only: 09's `frag_4` frozen across 2 anchors + 6 fresh seeds at 8192, with the count/size table |
+| `Scripts/ErosionPass.cs` | ⭐ **Pass 2b** (chat2/11) — the erosion caller: render field only (copied if aliased), governors clamped as the reference's ConfigManager did, the crater exclusion passed through INERT, and the **flood guard** (render water pixels before/after; any change throws) |
+| `Scripts/ErosionTool.cs` + `Scenes/ErosionTool.tscn` | The chat2/11 batch — 4 gallery seeds × erosion off/on at 8192, the mid-slope crop, the erosion stats table; also `TerrainShapeV1` — the locked shape's values pinned once |
| `Scripts/OffshoreIslandsTool.cs` + `Scenes/OffshoreIslandsTool.tscn` | The offshore batch — chat2/06: 4 plates + the count table + the diagnosis (the chat2/05 version is at `3b96e06`) |
| `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** |
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
diff --git a/Tools/Scenes/ErosionTool.tscn b/Tools/Scenes/ErosionTool.tscn
new file mode 100644
index 0000000..9237b83
--- /dev/null
+++ b/Tools/Scenes/ErosionTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://cerosion11isla"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/ErosionTool.cs" id="1_ert"]
+
+[node name="ErosionTool" type="Node"]
+script = ExtResource("1_ert")
diff --git a/Tools/Scripts/ErosionPass.cs b/Tools/Scripts/ErosionPass.cs
new file mode 100644
index 0000000..4a7d2a8
--- /dev/null
+++ b/Tools/Scripts/ErosionPass.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ PASS 2b — HYDRAULIC EROSION, THE CALLER (chat2/11). Runs on
+ /// the RENDER field of a shaped result — after the curve + detail (pass 2a), before the crater
+ /// carve (which does not exist yet) — exactly the reference's slot (MapGenerator.cs:737-805).
+ ///
+ /// ═══ THE THREE THINGS THE CALLER OWNS (ported from the reference caller) ═══
+ ///
+ /// 1. THE SPLIT. Only the render field is eroded; the classify field is finalized before the pass
+ /// and never sees it — biomes / water / region labeling classify pre-erosion (the oracle).
+ /// If the two fields are aliased (curve off), the render field is COPIED first, as the
+ /// reference allocated a separate classify array when erosion was on.
+ /// 2. THE FLOOD GUARD. Render-map water pixels are counted BEFORE and AFTER; any change throws
+ /// "EROSION FLOOD-GUARD VIOLATION". With the pass's sea clamp (below-sea cells read-only in
+ /// both directions; carve floor at sea + margin) this is the active proof that no coastline
+ /// moved — mainland and every island alike.
+ /// 3. THE CRATER EXCLUSION, INERT. The weight is passed whole (core / feather radii, mode) but
+ /// with no crater in v2 the radius is 0 ⇒ weight 1 everywhere. It activates when the crater
+ /// carve lands; the reference's "CraterErosionCore < carve factor" warning is DORMANT until
+ /// then (it concerns the erosion↔carve interaction, which does not exist yet).
+ ///
+ /// The governors are clamped as the reference's ConfigManager clamped them (count [0, 50 M],
+ /// lifetime [1, 4096], carve cap [0, 60], deposit cap [0, 60], sea margin [0, 5], inertia
+ /// [0, 0.99], evaporation [0, 0.5]) and a clamp is reported, not silent.
+ ///
+ public static class ErosionPass
+ {
+ public sealed class Result
+ {
+ public Pass2Result Shaped; // the result with the eroded RENDER field (classify untouched)
+ public HydraulicErosion.Stats Stats;
+ public HydraulicErosion.Params Params;
+ public long WetBefore, WetAfter;
+ public ulong Ms;
+ public List Notes = new();
+ }
+
+ /// Count render-map water pixels (height below the flat sea) — the flood guard's instrument.
+ public static long CountWaterPixels(float[,] height, int mapSize, float sea)
+ {
+ long wet = 0;
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ if (height[x, y] < sea) wet++;
+ return wet;
+ }
+
+ ///
+ /// Erode 's render field (a copy if aliased to classify) and return the
+ /// result. Throws on a governor-cap violation (from the pass) or a flood-guard violation.
+ ///
+ public static Result Apply(Pass2Result p2, TerrainGenConfig cfg)
+ {
+ ulong t0 = Time.GetTicksMsec();
+ var r = new Result();
+ int n = p2.MapSize;
+ float sea = cfg.SeaLevel;
+
+ // 1. THE SPLIT — never erode an array the classify field shares.
+ float[,] render = p2.Height;
+ if (p2.FieldsAreAliased)
+ {
+ render = (float[,])p2.Height.Clone();
+ r.Notes.Add("[Erosion] render and classify were aliased (curve off) — the render field was copied before eroding, as the reference allocated a separate classify array when erosion was on.");
+ }
+
+ // The governors, clamped as the reference's ConfigManager clamped them — reported, not silent.
+ int count = Math.Clamp(cfg.ErosionDropletCount, 0, 50_000_000);
+ int life = Math.Clamp(cfg.ErosionDropletLifetime, 1, 4096);
+ float carve = Math.Clamp(cfg.ErosionCarveCapM, 0f, 60f);
+ float deposit = Math.Clamp(cfg.ErosionDepositCapM, 0f, 60f);
+ float margin = Math.Clamp(cfg.ErosionSeaMarginM, 0f, 5f);
+ float inertia = Math.Clamp(cfg.ErosionInertia, 0f, 0.99f);
+ float evap = Math.Clamp(cfg.ErosionEvaporation, 0f, 0.5f);
+ if (count != cfg.ErosionDropletCount || life != cfg.ErosionDropletLifetime || carve != cfg.ErosionCarveCapM || deposit != cfg.ErosionDepositCapM
+ || margin != cfg.ErosionSeaMarginM || inertia != cfg.ErosionInertia || evap != cfg.ErosionEvaporation)
+ r.Notes.Add($"[Erosion] ⚠ governor out of bounds — clamped: count {cfg.ErosionDropletCount}→{count}, lifetime {cfg.ErosionDropletLifetime}→{life}, carve {cfg.ErosionCarveCapM}→{carve} m, deposit {cfg.ErosionDepositCapM}→{deposit} m, margin {cfg.ErosionSeaMarginM}→{margin} m, inertia {cfg.ErosionInertia}→{inertia}, evaporation {cfg.ErosionEvaporation}→{evap}.");
+
+ // 3. THE CRATER EXCLUSION — inert: no crater ⇒ radius 0 ⇒ weight 1 everywhere.
+ float craterRadius = cfg.CraterRadius; // 0 in v2 until the crater task
+ float coreR = craterRadius * cfg.CraterErosionCore;
+ float featherR = craterRadius * cfg.CraterErosionFeather;
+ const float CRATER_CARVE_FACTOR = 0.80f; // the reference's carve radius factor
+ if (craterRadius > 0f && cfg.CraterErosionCore < CRATER_CARVE_FACTOR)
+ r.Notes.Add($"[Erosion] ⚠ CraterErosionCore {cfg.CraterErosionCore:F2} is inside the carve radius ({CRATER_CARVE_FACTOR:F2} × CraterRadius) — erosion will modify carve-authored terrain and the carve will amplify those deltas across the waterline (the reference's warning; live only once a crater exists).");
+
+ // 2. THE FLOOD GUARD — before.
+ r.WetBefore = CountWaterPixels(render, n, sea);
+
+ r.Params = new HydraulicErosion.Params
+ {
+ DropletCount = count, Lifetime = life, CarveCapM = carve, DepositCapM = deposit, SeaMarginM = margin,
+ BrushRadius = Math.Max(0, cfg.ErosionBrushRadius), Inertia = inertia, CapacityFactor = cfg.ErosionCapacity,
+ MinSlopeM = cfg.ErosionMinSlopeM, ErodeRate = cfg.ErosionErodeRate, DepositRate = cfg.ErosionDepositRate,
+ Evaporation = evap, Gravity = cfg.ErosionGravity,
+ CraterMode = cfg.CraterErosionFeatherMode ? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL,
+ Seed = cfg.Seed + HydraulicErosion.SEED_OFFSET,
+ };
+ r.Stats = HydraulicErosion.Apply(render, n, null, sea, cfg.CraterCenterX, cfg.CraterCenterY, coreR, featherR, r.Params);
+
+ // 2. THE FLOOD GUARD — after. Any change refuses the generation.
+ r.WetAfter = CountWaterPixels(render, n, sea);
+ if (r.WetAfter != r.WetBefore)
+ throw new InvalidOperationException(
+ $"[ErosionPass] EROSION FLOOD-GUARD VIOLATION: render-map water pixels {r.WetBefore} -> {r.WetAfter}. Refusing to generate.");
+
+ r.Ms = Time.GetTicksMsec() - t0;
+ var st = r.Stats;
+ r.Notes.Add($"[Erosion] crater exclusion: {(craterRadius > 0f ? $"core {coreR:F0} px → feather {featherR:F0} px" : "INERT (no crater; weight 1 everywhere)")}.");
+ r.Notes.Add($"[Erosion] v1: {st.Spawned} droplets ({st.SkippedNoLand} skipped), {st.Steps:N0} steps, {r.Ms / 1000.0:F1}s wall. " +
+ $"Eroded {st.ErodedVolumeM3:F0} m³ over {st.ModifiedCells:N0} touched cells (max cell carve {st.MaxCellErosionM:F2} m vs cap {carve:F2} m), " +
+ $"deposited {st.DepositedVolumeM3:F0} m³ (max cell deposit {st.MaxCellDepositM:F2} m vs cap {deposit:F2} m). " +
+ $"Deaths: {st.DiedSea} sea / {st.DiedEdge} edge / {st.DiedDry} dry / {st.DiedLifetime} lifetime. " +
+ $"Water pixels {r.WetBefore:N0} -> {r.WetAfter:N0} (flood guard holds).");
+
+ r.Shaped = p2.WithHeight(render, r.Notes, r.Ms);
+ return r;
+ }
+ }
+}
diff --git a/Tools/Scripts/ErosionPass.cs.uid b/Tools/Scripts/ErosionPass.cs.uid
new file mode 100644
index 0000000..cc0c899
--- /dev/null
+++ b/Tools/Scripts/ErosionPass.cs.uid
@@ -0,0 +1 @@
+uid://dlpkt4kkcqvg8
diff --git a/Tools/Scripts/ErosionTool.cs b/Tools/Scripts/ErosionTool.cs
new file mode 100644
index 0000000..88507f2
--- /dev/null
+++ b/Tools/Scripts/ErosionTool.cs
@@ -0,0 +1,431 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ THE LOCKED SHAPE — `terrain-shape-v1` (chat2/10 gallery-confirmed): the continuous curve + the
+ /// frag_4 organic islands. Every later pass (erosion, rivers, …) starts from exactly these values,
+ /// pinned here once so no tool re-types them.
+ ///
+ public static class TerrainShapeV1
+ {
+ public const float FragmentAmp = 0.5f, FragmentFreq = 12f, BandCentre = 0.66f, BandHalfWidth = 0.18f;
+ public const bool BitesOnly = false;
+ public const float Stretch = 2f, BandStart = 0.70f, BandFeather = 0.05f;
+ public const bool StretchSinker = true;
+ public const float SpeckFrac = 2.5e-7f;
+
+ /// Apply the locked shape to a config (curve settings are the caller's — they come from the calibration).
+ public static void Apply(TerrainGenConfig c)
+ {
+ c.CoastShelf = false; c.Offshore = new OffshoreSettings();
+ c.RegionLabeling = true; c.SpeckRevert = true; c.MinLandComponentFrac = SpeckFrac;
+ c.SouthStretch = Stretch; c.SouthBandStartFrac = BandStart; c.SouthBandFeatherFrac = BandFeather; c.StretchSinker = StretchSinker;
+ c.FragmentAmp = FragmentAmp; c.FragmentFreqPerMapWidth = FragmentFreq;
+ c.FragmentBandCentre = BandCentre; c.FragmentBandHalfWidth = BandHalfWidth; c.FragmentBitesOnly = BitesOnly;
+ }
+
+ public static string Describe() =>
+ $"terrain-shape-v1: frag amp {FragmentAmp} freq {FragmentFreq} window {BandCentre}±{BandHalfWidth} · stretch {Stretch} (band {BandStart}/{BandFeather}, sinker stretched) · speck revert {SpeckFrac:G2} · offshore OFF · shelf OFF · labeling ON";
+ }
+
+ ///
+ /// ⭐ THE EROSION BATCH (chat2/11) — the faithful droplet erosion on the locked shape, judged across
+ /// seeds, erosion OFF vs ON. 4 seeds from the task-10 gallery × {off, on} = 8 fields at showpiece
+ /// size; per field grayscale + .f32 + hillshaded relief; a mid-slope close-up off/on on the first
+ /// seed (the green→yellow feather gate); the erosion stats table; the asymmetric oracle.
+ ///
+ /// ═══ RUNNING IT ═══
+ ///
+ /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
+ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ErosionTool.tscn
+ ///
+ /// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
+ /// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
+ /// ISLA_SEEDS (default 4 gallery seeds)
+ /// ISLA_ERO_COUNT / _LIFETIME / _CARVE / _DEPOSIT governor overrides (the faithful tune is the default)
+ /// ISLA_SKIP_TAG_CHECK=1 skip the bit-identity against the 10 gallery dumps (terrain-shape-v1)
+ ///
+ public partial class ErosionTool : Node
+ {
+ private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 17320508 };
+ private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
+ private const int DefaultMapSize = 8192;
+ private const int DefaultCalibSize = 2048;
+
+ /// The pure-shade plate's vertical exaggeration — stronger than the relief's 18 so half-metre drainage reads. A look dial.
+ private const float ShadeZ = 60f;
+
+ public override void _Ready()
+ {
+ try { Run(); }
+ catch (Exception e)
+ {
+ GD.PrintErr("==================================================================");
+ GD.PrintErr($" REFUSED: {e.Message}");
+ GD.PrintErr(e.StackTrace);
+ GD.PrintErr("==================================================================");
+ GetTree().Quit(2);
+ }
+ }
+
+ private sealed class Row
+ {
+ public int Seed; public HydraulicErosion.Stats St; public HydraulicErosion.Params P;
+ public long WetBefore, WetAfter; public ulong MsErosion, MsGen;
+ public double ErodedMeanM, DepositedMeanM; public long LandCells;
+ public bool Ok;
+ }
+
+ private void Run()
+ {
+ ToolingPaths.Configure(OS.GetUserDataDir());
+
+ int task = EnvInt("ISLA_TASK", 11);
+ string descr = EnvStr("ISLA_BATCH", "erosion");
+ int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
+ int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
+ int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
+ bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
+ bool skipTag = EnvStr("ISLA_SKIP_TAG_CHECK", "0") == "1";
+ string t10Source = EnvStr("ISLA_T10_SOURCE", "10_frag4_seed_gallery");
+
+ string batchRoot = ToolingPaths.BatchRoot(task, descr);
+ DirAccess.MakeDirRecursiveAbsolute(batchRoot);
+ DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
+
+ var anchors = CurveAnchors.Default;
+ float sea = 0.15f;
+
+ GD.Print("==================================================================");
+ GD.Print(" HYDRAULIC EROSION (chat2/11) — the faithful port on the locked shape, off vs on");
+ GD.Print("==================================================================");
+ GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
+ GD.Print($"seeds : {string.Join(", ", seeds)}");
+ GD.Print($"shape : {TerrainShapeV1.Describe()}");
+ GD.Print($"batch : {batchRoot}");
+ GD.Print("==================================================================");
+
+ GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
+ var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
+ GD.Print($" {knots}");
+
+ TerrainGenConfig Cfg(int size, int seed, string label, bool erosion)
+ {
+ var c = new TerrainGenConfig
+ {
+ MapSize = size, Seed = seed, VariantLabel = label,
+ Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
+ Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
+ };
+ TerrainShapeV1.Apply(c);
+ c.Erosion = erosion;
+ c.ErosionDropletCount = EnvInt("ISLA_ERO_COUNT", c.ErosionDropletCount);
+ c.ErosionDropletLifetime = EnvInt("ISLA_ERO_LIFETIME", c.ErosionDropletLifetime);
+ c.ErosionCarveCapM = EnvFloat("ISLA_ERO_CARVE", c.ErosionCarveCapM);
+ c.ErosionDepositCapM = EnvFloat("ISLA_ERO_DEPOSIT", c.ErosionDepositCapM);
+ return c;
+ }
+ var tuneCfg = Cfg(mapSize, seeds[0], "tune", true);
+ GD.Print($" tune : droplets {tuneCfg.ErosionDropletCount:N0} · lifetime {tuneCfg.ErosionDropletLifetime} · carve cap {tuneCfg.ErosionCarveCapM} m · deposit cap {tuneCfg.ErosionDepositCapM} m · sea margin {tuneCfg.ErosionSeaMarginM} m · brush {tuneCfg.ErosionBrushRadius} px · " +
+ $"inertia {tuneCfg.ErosionInertia} · capacity {tuneCfg.ErosionCapacity} · min slope {tuneCfg.ErosionMinSlopeM} m/px · erode {tuneCfg.ErosionErodeRate} · deposit {tuneCfg.ErosionDepositRate} · evaporation {tuneCfg.ErosionEvaporation} · gravity {tuneCfg.ErosionGravity} · crater exclusion INERT");
+
+ // ═══ THE FIELDS ═══
+ var hard = new List();
+ var perSeed = new List();
+ var rows = new List();
+ var look = new LookConfig
+ {
+ Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
+ ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
+ };
+ float bandLo = sea + WorldScale.RawFromMetres(30f), bandHi = sea + WorldScale.RawFromMetres(100f); // the green→yellow mid-slope band
+ bool cropDone = false;
+
+ for (int si = 0; si < seeds.Length; si++)
+ {
+ int seed = seeds[si];
+ GD.Print($"\n--- seed {seed} ---");
+
+ // OFF — the locked shape, the reference half.
+ var cOff = Cfg(mapSize, seed, "erosion_off", false);
+ Pass1Result p1Off = Topography.Generate(cOff);
+ Pass2Result p2Off = Shaping.Shape(p1Off, cOff);
+ if (!skipTag)
+ {
+ string dump = Path.Combine(ToolingPaths.BatchesRoot, t10Source, $"{seed}", "height.f32");
+ if (File.Exists(dump) && mapSize == 8192)
+ hard.Add(ShapingOracle.DumpRegression("a10", $"erosion OFF == terrain-shape-v1 (the task-10 gallery dump) [{seed}]", p2Off.Height, HeightField.Load(dump, mapSize), mapSize, dump));
+ else GD.Print($" a10 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the gallery's 8192" : $"no gallery dump at {dump}")}");
+ }
+ Image reliefOff = ReliefRenderer.Render(p2Off.Height, mapSize, look);
+ Image shadeOff = ShadeRenderer.Render(p2Off.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
+ WriteField(batchRoot, p2Off, sea, anchors, skipRaw, reliefOff, shadeOff, "OFF");
+
+ // ON — an independent generation, then the pass.
+ var cOn = Cfg(mapSize, seed, "erosion_on", true);
+ Pass1Result p1On = Topography.Generate(cOn);
+ Pass2Result p2Shaped = Shaping.Shape(p1On, cOn);
+ ulong tE = Time.GetTicksMsec();
+ var ero = ErosionPass.Apply(p2Shaped, cOn);
+ Pass2Result p2On = ero.Shaped;
+ foreach (string nline in ero.Notes) GD.Print(" " + nline);
+ Image reliefOn = ReliefRenderer.Render(p2On.Height, mapSize, look);
+ Image shadeOn = ShadeRenderer.Render(p2On.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
+ WriteField(batchRoot, p2On, sea, anchors, skipRaw, reliefOn, shadeOn, "ON");
+
+ // The oracle, per seed.
+ var checks = new List
+ {
+ ShapingOracle.NorthLocked("c", "classify field bit-identical, erosion OFF vs ON (erosion is render-only)", p1Off.Height, p1On.Height, mapSize, mapSize),
+ ShapingOracle.NorthLocked("c2", "the ON result's classify field IS the pass-1 field (untouched by the pass)", p2On.HeightClassify, p1On.Height, mapSize, mapSize),
+ ShapingOracle.LabelsDeterministic(p1Off, p1On),
+ FloodGuard(ero, p2Off.Height, mapSize, sea),
+ Caps(ero),
+ ShapingOracle.TagCoastlineConsistent(p2On, sea),
+ ShapingOracle.ClassifyFidelity(p1On, p2On),
+ ShapingOracle.CentreIsLand(p1On),
+ };
+ checks[2].Name = "region labeling + island tag identical, erosion OFF vs ON";
+ foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); GD.Print(" " + c); }
+ bool ok = checks.TrueForAll(c => c.Passed);
+
+ // Determinism — the first seed: shape again from the same pass 1, erode again, compare bit for bit.
+ if (si == 0)
+ {
+ var again = ErosionPass.Apply(Shaping.Shape(p1On, cOn), cOn);
+ var det = ShapingOracle.NorthLocked("o", "eroded render field bit-identical across two runs (determinism)", p2On.Height, again.Shaped.Height, mapSize, mapSize);
+ det.Name += $" [{seed}]"; perSeed.Add(det); GD.Print(" " + det);
+ ok &= det.Passed;
+ }
+
+ // The mid-slope crop — the first seed: the 1024² window with the most green→yellow band cells.
+ if (!cropDone)
+ {
+ var (cx, cy, cw) = FindMidSlopeWindow(p2Off.Height, mapSize, bandLo, bandHi, Math.Min(1024, mapSize / 4));
+ WriteCrop(batchRoot, reliefOff, reliefOn, cx, cy, cw, seed, "midslope");
+ WriteCrop(batchRoot, shadeOff, shadeOn, cx, cy, cw, seed, "midslope_shade");
+ GD.Print($" mid-slope crop: window ({cx},{cy}) {cw}² — {Path.Combine(batchRoot, "midslope_pair.png")}");
+ cropDone = true;
+ }
+
+ long land = 0; for (int x = 0; x < mapSize; x++) for (int y = 0; y < mapSize; y++) if (p1Off.Height[x, y] >= sea) land++;
+ rows.Add(new Row
+ {
+ Seed = seed, St = ero.Stats, P = ero.Params, WetBefore = ero.WetBefore, WetAfter = ero.WetAfter, MsErosion = ero.Ms, MsGen = p1On.ElapsedMs,
+ LandCells = land, ErodedMeanM = ero.Stats.ModifiedCells == 0 ? 0 : ero.Stats.ErodedVolumeM3 / ero.Stats.ModifiedCells,
+ DepositedMeanM = ero.Stats.ModifiedCells == 0 ? 0 : ero.Stats.DepositedVolumeM3 / ero.Stats.ModifiedCells, Ok = ok,
+ });
+ GD.Print($" seed {seed}: {(ok ? "ok" : "⚠ CHECK FAILED")} erosion {ero.Ms / 1000.0:F1}s");
+ }
+
+ bool allOk = hard.TrueForAll(c => c.Passed) && perSeed.TrueForAll(c => c.Passed);
+ GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
+ foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
+
+ WriteIndex(batchRoot, mapSize, calibSize, seeds, rows, tuneCfg, hard, perSeed, 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 checks only this batch needs -----------------------------------
+
+ private static ShapingOracle.Check FloodGuard(ErosionPass.Result ero, float[,] offRender, int n, float sea)
+ {
+ long wetOff = ErosionPass.CountWaterPixels(offRender, n, sea);
+ var c = new ShapingOracle.Check { Id = "f", Name = "flood guard — render water pixels unchanged (OFF field, before, after)" };
+ c.Passed = wetOff == ero.WetBefore && ero.WetBefore == ero.WetAfter;
+ c.Detail = $"OFF {wetOff:N0} · before {ero.WetBefore:N0} · after {ero.WetAfter:N0}" + (c.Passed ? " — no coastline moved" : " — MOVED");
+ return c;
+ }
+
+ private static ShapingOracle.Check Caps(ErosionPass.Result ero)
+ {
+ var st = ero.Stats; var p = ero.Params;
+ var c = new ShapingOracle.Check { Id = "g", Name = "governor caps proven on exit (the pass re-checked here)" };
+ bool carveOk = st.MaxCellErosionM <= p.CarveCapM * (1f + 1e-5f);
+ bool depOk = p.DepositCapM <= 0f || st.MaxCellDepositM <= p.DepositCapM * (1f + 1e-5f);
+ c.Passed = carveOk && depOk;
+ c.Detail = $"max cell carve {st.MaxCellErosionM:F3} m ≤ {p.CarveCapM} m; max cell deposit {st.MaxCellDepositM:F3} m ≤ {p.DepositCapM} m; {st.ModifiedCells:N0} cells touched";
+ return c;
+ }
+
+ private static (int x, int y, int w) FindMidSlopeWindow(float[,] h, int n, float lo, float hi, int w)
+ {
+ int best = -1, bx = 0, by = 0; int step = Math.Max(64, w / 4);
+ for (int x0 = 0; x0 + w <= n; x0 += step)
+ for (int y0 = 0; y0 + w <= n; y0 += step)
+ {
+ int cnt = 0;
+ for (int x = x0; x < x0 + w; x += 4)
+ for (int y = y0; y < y0 + w; y += 4)
+ { float v = h[x, y]; if (v >= lo && v <= hi) cnt++; }
+ if (cnt > best) { best = cnt; bx = x0; by = y0; }
+ }
+ return (bx, by, w);
+ }
+
+ // ---- the curve --------------------------------------------------------
+
+ 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 });
+ 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);
+ }
+
+ // ---- output -----------------------------------------------------------
+
+ private static void WriteField(string batchRoot, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, Image relief, Image shade, string tag)
+ {
+ string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
+ DirAccess.MakeDirRecursiveAbsolute(dir);
+ shade.SavePng(Path.Combine(dir, "shade.png"));
+ GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
+ if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
+ LegendRenderer.WithLegend((Image)relief.Duplicate(), ReliefPalette.Kind.ProvisionalEven, sea, anchors.PeakCap, $"EROSION {tag} {p2.Seed}")
+ .SavePng(Path.Combine(dir, "relief.png"));
+ }
+
+ private static void WriteCrop(string batchRoot, Image off, Image on, int x, int y, int w, int seed, string name)
+ {
+ var rect = new Rect2I(x, y, w, w);
+ Image a = off.GetRegion(rect), b = on.GetRegion(rect);
+ a.SavePng(Path.Combine(batchRoot, $"{name}_off.png"));
+ b.SavePng(Path.Combine(batchRoot, $"{name}_on.png"));
+ var pair = Image.CreateEmpty(w * 2 + 16, w, false, Image.Format.Rgb8);
+ pair.Fill(new Color(0, 0, 0));
+ pair.BlitRect(a, new Rect2I(0, 0, w, w), new Vector2I(0, 0));
+ pair.BlitRect(b, new Rect2I(0, 0, w, w), new Vector2I(w + 16, 0));
+ int s = 3; int lh = TinyFont.Height(s) + 6;
+ TinyFont.Draw(pair, $"MID-SLOPE (30-100 M BAND) SEED {seed} WINDOW ({x},{y}) {w}PX", 12, 12, s, new Color(0.94f, 0.95f, 0.96f));
+ TinyFont.Draw(pair, "LEFT: EROSION OFF", 12, 12 + lh, s, new Color(0.94f, 0.95f, 0.96f));
+ TinyFont.Draw(pair, "RIGHT: EROSION ON", w + 16 + 12, 12 + lh, s, new Color(0.94f, 0.95f, 0.96f));
+ pair.SavePng(Path.Combine(batchRoot, $"{name}_pair.png"));
+ }
+
+ private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List rows, TerrainGenConfig tune,
+ List hard, List perSeed, bool allOk)
+ {
+ int best = seeds[0];
+ var sb = new StringBuilder();
+ sb.AppendLine($"# Batch 11 — hydraulic erosion on the locked shape: off vs on, {seeds.Length} seeds at {mapSize}");
+ sb.AppendLine();
+ sb.AppendLine("**The faithful droplet erosion** (the reference's `HydraulicErosion`, ported verbatim into `Core/`, `WorldScale`-denominated),");
+ sb.AppendLine("run on the RENDER map only after shaping. OFF is the locked shape `terrain-shape-v1` (bit-identical to the task-10 gallery);");
+ sb.AppendLine("ON is the star; the pair is the before/after. Hillshade is what makes the dendritic drainage read.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ Open this first");
+ sb.AppendLine();
+ sb.AppendLine($"1. **`{best}_erosion_on/relief.png`** — then `{best}_erosion_off/relief.png` beside it.");
+ sb.AppendLine("2. **`midslope_pair.png`** — the mid-slope (30–100 m, green→yellow) close-up, OFF left / ON right: the feather gate; **`midslope_shade_pair.png`** is the same window in pure hillshade (z×60), where half-metre drainage reads.");
+ sb.AppendLine(" Every field also has **`shade.png`** — pure hillshade, land only — beside its `relief.png`.");
+ sb.AppendLine("3. The other three pairs below.");
+ sb.AppendLine();
+ sb.AppendLine("## The contact sheet — OFF / ON side by side");
+ sb.AppendLine();
+ sb.AppendLine("| Seed | erosion OFF (relief · shade) | erosion ON (relief · shade) | droplets · steps | eroded m³ / deposited m³ | max cell carve / deposit (m) | cells touched | water px before → after | erosion wall | oracle |");
+ sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
+ foreach (var r in rows)
+ sb.AppendLine($"| `{r.Seed}` | [`relief`]({r.Seed}_erosion_off/relief.png) · [`shade`]({r.Seed}_erosion_off/shade.png) | [`relief`]({r.Seed}_erosion_on/relief.png) · [`shade`]({r.Seed}_erosion_on/shade.png) | " +
+ $"{r.St.Spawned:N0} · {r.St.Steps:N0} | {r.St.ErodedVolumeM3:N0} / {r.St.DepositedVolumeM3:N0} | {r.St.MaxCellErosionM:F2} / {r.St.MaxCellDepositM:F2} | {r.St.ModifiedCells:N0} ({100.0 * r.St.ModifiedCells / Math.Max(1, r.LandCells):F1} % of land) | {r.WetBefore:N0} → {r.WetAfter:N0} | {r.MsErosion / 1000.0:F0} s | {(r.Ok ? "pass" : "**FAIL**")} |");
+ sb.AppendLine();
+ sb.AppendLine("Deaths per seed (sea / edge / dry / lifetime): " + string.Join(" · ", rows.ConvertAll(r => $"`{r.Seed}` {r.St.DiedSea} / {r.St.DiedEdge} / {r.St.DiedDry} / {r.St.DiedLifetime}")));
+ sb.AppendLine();
+ sb.AppendLine("## The tune (faithful — the reference's declared defaults, unchanged unless stated)");
+ sb.AppendLine();
+ sb.AppendLine($"droplets `{tune.ErosionDropletCount:N0}` · lifetime `{tune.ErosionDropletLifetime}` · carve cap `{tune.ErosionCarveCapM} m` · deposit cap `{tune.ErosionDepositCapM} m` · sea margin `{tune.ErosionSeaMarginM} m` · brush `{tune.ErosionBrushRadius} px` · " +
+ $"inertia `{tune.ErosionInertia}` · capacity `{tune.ErosionCapacity}` · min slope `{tune.ErosionMinSlopeM} m/px` · erode `{tune.ErosionErodeRate}` · deposit `{tune.ErosionDepositRate}` · evaporation `{tune.ErosionEvaporation}` · gravity `{tune.ErosionGravity}` · " +
+ $"seed offset `{HydraulicErosion.SEED_OFFSET}` · crater exclusion **INERT** (no crater; core ×{tune.CraterErosionCore}, feather ×{tune.CraterErosionFeather}, feather mode — activates with the crater task).");
+ sb.AppendLine();
+ sb.AppendLine($"Shape: {TerrainShapeV1.Describe()}. Curve calibrated at {calibSize}.");
+ sb.AppendLine();
+ sb.AppendLine("## ⚠ The palette is PROVISIONAL");
+ sb.AppendLine();
+ sb.AppendLine("`ProvisionalEven` + hillshade (z-exaggeration 18, strength 0.30), flagged. The grayscale is the honest instrument.");
+ sb.AppendLine();
+ sb.AppendLine("## The oracle (render-only: classify, labels and every island untouched; no coastline moved)");
+ sb.AppendLine();
+ sb.AppendLine(hard.Count == 0 ? "*(the terrain-shape-v1 check was skipped)*\n" : ShapingOracle.ToMarkdownTable(hard));
+ sb.AppendLine("Per seed (classify bit-identical c / c2 · labels identical · flood guard f · caps g · tag/coastline k · classify b · centre m · determinism o):");
+ sb.AppendLine();
+ sb.AppendLine(ShapingOracle.ToMarkdownTable(perSeed));
+ 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("| `relief.png`, `shade.png` (both), `midslope_*.png`, `INDEX.md` | **keep** |");
+ sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
+ sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the locked shape (+ the tune) — 256 MB each, clear freely |");
+ sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
+ sb.AppendLine();
+ sb.AppendLine($"{WorldScale.Describe()}.");
+ WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
+ }
+
+ private static void WriteText(string path, string text)
+ {
+ using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
+ if (f == null) { GD.PrintErr($"could not write {path}"); return; }
+ f.StoreString(text);
+ }
+
+ 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/ErosionTool.cs.uid b/Tools/Scripts/ErosionTool.cs.uid
new file mode 100644
index 0000000..a987682
--- /dev/null
+++ b/Tools/Scripts/ErosionTool.cs.uid
@@ -0,0 +1 @@
+uid://df3p1g6ktwij6
diff --git a/Tools/Scripts/Pass2Result.cs b/Tools/Scripts/Pass2Result.cs
index 6b8db64..0d29d0d 100644
--- a/Tools/Scripts/Pass2Result.cs
+++ b/Tools/Scripts/Pass2Result.cs
@@ -166,6 +166,21 @@ namespace IslaApocalypse.Tools
///
public bool FieldsAreAliased => ReferenceEquals(Height, HeightClassify);
+ ///
+ /// The same result with a different RENDER field — how a render-only pass (erosion, chat2/11)
+ /// hands back its output without touching the classify field or anything else carried here.
+ ///
+ public Pass2Result WithHeight(float[,] newHeight, List extraNotes, ulong extraMs)
+ {
+ var notes = new List(Notes); if (extraNotes != null) notes.AddRange(extraNotes);
+ float hMin = float.MaxValue, hMax = float.MinValue;
+ for (int x = 0; x < MapSize; x++)
+ for (int y = 0; y < MapSize; y++) { float h = newHeight[x, y]; if (h < hMin) hMin = h; if (h > hMax) hMax = h; }
+ return new Pass2Result(MapSize, Seed, newHeight, HeightClassify, CurveOn, DetailOn, CurveModeLabel, VariantLabel,
+ Continuous, Knots, Anchors, HMaxSeed, EdgeAmpRaw, MaxEdgeShiftRaw, hMin, hMax, ElapsedMs + extraMs, notes,
+ IsIsland, IslandHemisphere);
+ }
+
/// Fraction of the RENDER field at or above the sea threshold.
public float LandFraction(float seaLevel)
{
diff --git a/Tools/Scripts/ShadeRenderer.cs b/Tools/Scripts/ShadeRenderer.cs
new file mode 100644
index 0000000..61e8c39
--- /dev/null
+++ b/Tools/Scripts/ShadeRenderer.cs
@@ -0,0 +1,34 @@
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// PURE HILLSHADE (chat2/11) — a grayscale slope/aspect plate with no hypsometric tint, for
+ /// reading low-amplitude surface detail (erosion's drainage is half a metre on average) that the
+ /// palette-blended relief hides. Land shaded; sea a flat dark. ⚠ PRESENTATION ONLY — a look dial,
+ /// never a claim about the world (→ Hillshade).
+ ///
+ public static class ShadeRenderer
+ {
+ public static Image Render(float[,] height, int mapSize, float sea, float zExaggeration, float azimuth, float altitude)
+ {
+ var (lx, ly, lz) = Hillshade.LightVector(azimuth, altitude);
+ var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
+ var seaColor = new Color(0.10f, 0.14f, 0.22f);
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ {
+ if (height[x, y] < sea) { img.SetPixel(x, y, seaColor); continue; }
+ float s = Hillshade.At(height, mapSize, x, y, zExaggeration, lx, ly, lz);
+ img.SetPixel(x, y, new Color(s, s, s));
+ }
+ return img;
+ }
+
+ public static void SavePng(float[,] height, int mapSize, float sea, float zExaggeration, float azimuth, float altitude, string absolutePath)
+ {
+ Error err = Render(height, mapSize, sea, zExaggeration, azimuth, altitude).SavePng(absolutePath);
+ if (err != Error.Ok) GD.PrintErr($"[ShadeRenderer] SavePng failed ({err}) for {absolutePath}");
+ }
+ }
+}
diff --git a/Tools/Scripts/TerrainGenConfig.cs b/Tools/Scripts/TerrainGenConfig.cs
index d78585f..c6dfe99 100644
--- a/Tools/Scripts/TerrainGenConfig.cs
+++ b/Tools/Scripts/TerrainGenConfig.cs
@@ -353,6 +353,48 @@ namespace IslaApocalypse.Tools
///
public bool FragmentBitesOnly = CoastalFragment.DefaultBitesOnly;
+ // ---- PASS 2b — HYDRAULIC EROSION (chat2/11) — RENDER MAP ONLY ------------------
+ //
+ // The reference's droplet erosion, ported verbatim (Core.HydraulicErosion), run on the render
+ // field AFTER shaping (after detail, before the crater carve — which does not exist yet). The
+ // classify field never sees it (D-046); the caller's flood guard proves no waterline moved.
+ // ⚠ DEFAULT OFF in the bare config for the usual reason (regression anchors); the batch turns
+ // it on. The governors + physics are the reference ConfigManager's declared defaults, clamped
+ // as it clamped them (→ ErosionPass).
+
+ /// ⭐ Erosion on/off. Render only. Default OFF (see above).
+ public bool Erosion = false;
+
+ /// Governor 1 — droplet count. Reference 250000, clamp [0, 50,000,000].
+ public int ErosionDropletCount = 250000;
+ /// Governor 2 — max steps per droplet. Reference 384, clamp [1, 4096].
+ public int ErosionDropletLifetime = 384;
+ /// Governor 3 — max carve per cell, metres (net ledger). Reference 15, clamp [0, 60].
+ public float ErosionCarveCapM = 15.0f;
+ /// Governor 4 — max build-up per cell, metres (the ledger read the other way). Reference 6, clamp [0, 60]; ≤ 0 = unbounded.
+ public float ErosionDepositCapM = 6.0f;
+ /// The sea clamp's carve floor above sea, metres. Reference 0.5, clamp [0, 5].
+ public float ErosionSeaMarginM = 0.5f;
+ /// Brush radius, px (the cone brush shared by erode and deposit). Reference 2.
+ public int ErosionBrushRadius = 2;
+ public float ErosionInertia = 0.35f; // clamp [0, 0.99]
+ public float ErosionCapacity = 4.0f;
+ public float ErosionMinSlopeM = 0.02f; // metres per px
+ public float ErosionErodeRate = 0.12f;
+ public float ErosionDepositRate = 0.15f;
+ public float ErosionEvaporation = 0.004f; // clamp [0, 0.5]
+ public float ErosionGravity = 4.0f;
+
+ ///
+ /// The crater exclusion (task 19), PORTED BUT INERT: with no crater ( 0)
+ /// the weight is 1 everywhere. Core ×radius — the reference's ConfigManager shipped 0.80 (the
+ /// pass's own default constant is 0.50); feather ×radius 1.05; mode feather. Activates when
+ /// the crater carve lands; the reference's "core < carve factor" warning is dormant until then.
+ ///
+ public float CraterErosionCore = 0.80f;
+ public float CraterErosionFeather = 1.05f;
+ public bool CraterErosionFeatherMode = true;
+
/// A short label for this variant, used in output filenames. E.g. "full", "base_only".
public string VariantLabel = "full";