chat2/11: hydraulic erosion — the faithful droplet pass on the locked shape, render-only, judged off vs on

Step 0: tag terrain-shape-v1 on a59e52f (the frag_4-locked, gallery-confirmed state).

Core/Scripts/HydraulicErosion.cs is the reference's pass ported verbatim - the four governors
(250,000 droplets, lifetime 384, carve cap 15 m, deposit cap 6 m) on one net-displacement
ledger read both ways and PROVEN on exit (the CARVE-CAP / DEPOSIT-CAP violations throw), the
sea clamp (spawn rejected below sea, death at sea, both brushes skip below-sea cells, the 0.5 m
margin floor on the erode brush), the cone-weighted normalized brush shared by erode and
deposit, the droplet physics (inertia 0.35, capacity 4, min slope 0.02, erode 0.12, deposit
0.15, evaporation 0.004, gravity 4, brush 2), PCG32 seeded at seed + 9271, the crater
exclusion whole. Engine-free; every metres<->raw conversion through WorldScale (no literal
251 - the same multiply/divide, so bit-identical arithmetic).

Tools/Scripts/ErosionPass.cs is the caller (pass 2b): render field only (copied if aliased to
classify), governors clamped as the reference ConfigManager clamped them, the crater exclusion
passed through INERT (no crater => radius 0 => weight 1 everywhere; the reference's
"core < carve factor" warning dormant), and the FLOOD GUARD - render water pixels counted
before and after, any change throws. TerrainGenConfig gains Erosion (default OFF, the anchor
rule) and the governors/physics/crater fields. Pass2Result.WithHeight hands back the eroded
render field. ShadeRenderer is a pure-hillshade plate (land only) because the palette relief's
0.30 hillshade hides half-metre drainage. ErosionTool carries TerrainShapeV1 (the locked shape's
values, pinned once) and the batch: 4 gallery seeds x off/on at 8192, grayscale + .f32 +
relief + shade per field, a mid-slope 1024-px crop off/on (relief and shade), the stats.

Faithful first, no tune: at 8192 the pass touches ~88 % of land cells at a mean 0.14 m, carve
15.00 / deposit 6.00 m at the caps, 0.5 m mean on the massif with 18 % of its cells moved more
than a metre. It reads as dissected summits with radial gully fans and carve/deposit bands
along the slope breaks, not dendritic networks: droplets die of lifetime (170k of 224k) before
they converge. A lifetime probe at 4096 (scratch) shows 1536 and 4096 identical - every
droplet is dead of evaporation by ~1,300 steps - so lifetime is not the lever; droplet count
is. The mid-slope green->yellow transition is not softened at the faithful tune.

Oracle, all passing on 4 seeds: erosion OFF bit-identical to terrain-shape-v1 (the task-10
gallery dumps, 67 M cells each); classify bit-identical off vs on and == the pass-1 field;
region labeling + island tag identical; flood guard (27.5 M water pixels unchanged, every
seed); caps proven; tag/coastline; classify == raw; centre is land; eroded field
bit-identical across two runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
This commit is contained in:
Stewart Howe 2026-08-22 12:19:20 -04:00
parent a59e52fcbc
commit ea291eaab5
12 changed files with 1045 additions and 0 deletions

View file

@ -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/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/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/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/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/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. | | `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |

View file

@ -0,0 +1,385 @@
using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐⭐ DROPLET (HYDRAULIC) EROSION — THE FAITHFUL PORT (chat2/11). Ported from the reference's
/// <c>Tools/Scripts/HydraulicErosion.cs</c> at tag <c>pre-rewrite-reference</c> (<c>ab78883</c>),
/// 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: <c>IslaApocalypse.Core</c> — 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 <see cref="WorldScale"/>
/// (<c>MetresFromRaw</c> / <c>RawFromMetres</c>) — 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 <c>* M_PER_UNIT</c> / <c>/ M_PER_UNIT</c>.
/// • <c>VERSION</c> 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 <c>CraterWeight</c> is 1 everywhere (see <c>ErosionPass</c>).
///
/// ═══ 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. &lt;= 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.
/// </summary>
public static class HydraulicErosion
{
/// <summary>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.</summary>
public const ushort VERSION = 1;
/// <summary>Deterministic RNG stream: seeded from resolvedSeed + this offset, decorrelated from every noise field.</summary>
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 640960 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)
}
/// <summary>
/// Runs the pass in place on <paramref name="height"/>. Sea level per cell is
/// <paramref name="seaMap"/>[x,y] when non-null, else the flat scalar
/// <paramref name="seaFlat"/>. Throws (refusing the generation) if a governor
/// bound is violated on exit — the caller treats that as a build failure.
/// </summary>
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;
}
}
}

View file

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

View file

@ -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/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/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/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/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/Pass1Result.cs` | The height field **and the Phase-2 seams** |
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles | | `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |

View file

@ -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")

View file

@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ PASS 2b — HYDRAULIC EROSION, THE CALLER (chat2/11). Runs <see cref="HydraulicErosion"/> 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 (<c>MapGenerator.cs:737-805</c>).
///
/// ═══ 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 &lt; 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.
/// </summary>
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<string> Notes = new();
}
/// <summary>Count render-map water pixels (height below the flat sea) — the flood guard's instrument.</summary>
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;
}
/// <summary>
/// Erode <paramref name="p2"/>'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.
/// </summary>
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;
}
}
}

View file

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

View file

@ -0,0 +1,431 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ 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.
/// </summary>
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;
/// <summary>Apply the locked shape to a config (curve settings are the caller's — they come from the calibration).</summary>
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";
}
/// <summary>
/// ⭐ 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)
/// </summary>
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;
/// <summary>The pure-shade plate's vertical exaggeration — stronger than the relief's 18 so half-metre drainage reads. A look dial.</summary>
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<ShapingOracle.Check>();
var perSeed = new List<ShapingOracle.Check>();
var rows = new List<Row>();
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.Check>
{
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<int, Pass1Result>();
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<Row> rows, TerrainGenConfig tune,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> 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 (30100 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<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

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

View file

@ -166,6 +166,21 @@ namespace IslaApocalypse.Tools
/// </summary> /// </summary>
public bool FieldsAreAliased => ReferenceEquals(Height, HeightClassify); public bool FieldsAreAliased => ReferenceEquals(Height, HeightClassify);
/// <summary>
/// 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.
/// </summary>
public Pass2Result WithHeight(float[,] newHeight, List<string> extraNotes, ulong extraMs)
{
var notes = new List<string>(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);
}
/// <summary>Fraction of the RENDER field at or above the sea threshold.</summary> /// <summary>Fraction of the RENDER field at or above the sea threshold.</summary>
public float LandFraction(float seaLevel) public float LandFraction(float seaLevel)
{ {

View file

@ -0,0 +1,34 @@
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// 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).
/// </summary>
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}");
}
}
}

View file

@ -353,6 +353,48 @@ namespace IslaApocalypse.Tools
/// </summary> /// </summary>
public bool FragmentBitesOnly = CoastalFragment.DefaultBitesOnly; 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).
/// <summary>⭐ Erosion on/off. Render only. Default OFF (see above).</summary>
public bool Erosion = false;
/// <summary>Governor 1 — droplet count. Reference 250000, clamp [0, 50,000,000].</summary>
public int ErosionDropletCount = 250000;
/// <summary>Governor 2 — max steps per droplet. Reference 384, clamp [1, 4096].</summary>
public int ErosionDropletLifetime = 384;
/// <summary>Governor 3 — max carve per cell, metres (net ledger). Reference 15, clamp [0, 60].</summary>
public float ErosionCarveCapM = 15.0f;
/// <summary>Governor 4 — max build-up per cell, metres (the ledger read the other way). Reference 6, clamp [0, 60]; ≤ 0 = unbounded.</summary>
public float ErosionDepositCapM = 6.0f;
/// <summary>The sea clamp's carve floor above sea, metres. Reference 0.5, clamp [0, 5].</summary>
public float ErosionSeaMarginM = 0.5f;
/// <summary>Brush radius, px (the cone brush shared by erode and deposit). Reference 2.</summary>
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;
/// <summary>
/// The crater exclusion (task 19), PORTED BUT INERT: with no crater (<see cref="CraterRadius"/> 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 &lt; carve factor" warning is dormant until then.
/// </summary>
public float CraterErosionCore = 0.80f;
public float CraterErosionFeather = 1.05f;
public bool CraterErosionFeatherMode = true;
/// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary> /// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary>
public string VariantLabel = "full"; public string VariantLabel = "full";