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";