feat: droplet hydraulic erosion — carve+deposit, three governors, sea clamp (terrain-water task 17, Phase C0)

Erosion: "off"|"v1" gate (default off, pending the developer's gate). Output-only:
applied to the render map after detail, before the crater carve; the classify path
reads pre-erosion heights, so biomes/water stay byte-identical (verified: OFF run
bit-identical to the task-14 reference; ON run BIOM/WBID identical to OFF).
Governors: droplet count / lifetime / per-cell carve cap (ledger-enforced, thrown
on violation). Sea clamp: below-sea cells read-only, land never carved below
sea+margin — rendered coastline provably fixed (asserted per generation). Crater
excluded at 1.2×R. Deterministic from resolvedSeed+9271 (PCG32). New EROS
blueprint section (writer/parser/harness/format doc). Supersedes the reverted D8
approach with the organic droplet model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-10 02:42:26 -04:00
parent 34b0e1c9e3
commit e1e1fe3227
8 changed files with 611 additions and 22 deletions

View file

@ -57,6 +57,7 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`.
| `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. |
| `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset`. **When `curveVersion ≥ 5`, a 9 B preset extension follows:** `u8 presetId` (1 = compact, 2 = balanced) · `f32 k5` · `f32 k6` — with the four base knot slots this makes the effective curve unambiguous from the record alone (record total 95 B; the length-framed section + version byte keep every layout change safe) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. |
| `TDTL` / `0x4C544454` | 30 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `i32 reliefSeedOffset` · `f32 edgeAmpM` · `f32 edgeFreqIslands` · `i32 edgeSeedOffset` · `f32 edgeMaxShiftM` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve). **`detailVersion` selects the body layout and a reader that does not recognise it SKIPS the section** (leaving detail metadata null) rather than misreading a differently shaped payload — the one section whose body is versioned rather than extended, because v1's layout was retired rather than grown. **v1 (retired, never shipped)** — shelf micro-relief + D8 drainage incision, 38 B; the incision produced grid-aligned artifacts and was reverted whole, so the only v1 payloads that exist are in that batch's own tree. **v2 (current)** — shelf micro-relief (±`reliefAmpM` output metres, shelf-ness weighted) + shelf-edge variation: a per-column shift of the curve's shelf/riser knot block K3/K4/K5, drawn from a Simplex field seeded `resolvedWorldSeed + edgeSeedOffset` at `edgeFreqIslands/MapSize`, amplitude ±`edgeAmpM` **metres of INPUT height** — a displacement of the shelf boundary contour, not an elevation change. `edgeAmpM` is recorded **as applied**, after the clamp to `edgeMaxShiftM` (the preset's band-squeeze bound), so the record always describes the terrain rather than the request. **Metadata only** — heights are already detailed. Machinery: `Tools/Scripts/TerrainDetailPass.cs`. |
| `EROS` / `0x534F5245` | 58 B: `u16 erosionVersion` · `i32 dropletCount` · `i32 lifetime` · `i32 brushRadius` · `i32 seedOffset` · `f32 carveCapM` · `f32 seaMarginM` · `f32 inertia` · `f32 capacityFactor` · `f32 minSlopeM` · `f32 erodeRate` · `f32 depositRate` · `f32 evaporation` · `f32 gravity` · `f32 craterExclFactor` | Optional — present iff the droplet hydraulic-erosion pass shaped this blueprint's `HGTS` (config `Erosion: "v1"`, terrain-water task 17). **`erosionVersion` selects the body layout; an unrecognised version is SKIPPED whole** (erosion metadata left null), same rule as `TDTL`. **v1 (current)** — the carve-and-deposit droplet model: `dropletCount`/`lifetime`/`carveCapM` are the three hard governors (total droplets, max steps each, max cumulative erosion depth per cell in metres); `seaMarginM` is the flood-guard clamp (no cell carved below sea + margin; below-sea cells untouched entirely, so the rendered coastline cannot move); droplets are deterministic from `resolvedWorldSeed + seedOffset` (PCG32); no cell within `craterExclFactor × CraterRadius` of the impact centre is modified. Strength constants (`inertia`, `capacityFactor`, `minSlopeM`, `erodeRate`, `depositRate`, `evaporation`, `gravity`) are the standard droplet-model dials; slopes/amounts in metres (1 raw = 251 m). All values recorded **as applied** (post config clamping). **Metadata only** — heights are already eroded, and the classify-side sections (`BIOM`/`WBID`/…) never saw the pass by design. Machinery: `Tools/Scripts/HydraulicErosion.cs`. |
| `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. |
| `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. |
| `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q − 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. |
@ -121,6 +122,15 @@ body).
construction (it classifies the retained uncurved heights); town positions and everything 3D
follow the curved terrain. When on, `TCRV` records the effective parameters including the
per-seed `spikeMax`.
- **`Erosion`** (`"v1"` | `"off"`, default `"off"` — opt-in until the developer's gate approves
it) — the task-17 droplet hydraulic-erosion pass: carve-and-deposit drainage detailing of
`HGTS` after the detail passes and before the crater carve. Governor dials
(`ErosionDropletCount` / `ErosionDropletLifetime` / `ErosionCarveCap`, defaults 400 000 / 48 /
8 m; bounds clamped loudly at load) plus the sea-clamp margin and strength constants — the
full dial list and semantics live in the `EROS` row above and `ConfigManager.cs`. Output-height
only: the classify path reads pre-erosion heights, so `BIOM` and every water section stay
bit-identical with erosion on or off, and the sea clamp keeps even the RENDERED coastline
fixed. When on, `EROS` records the parameters as applied.
- **`CoastProfile`** (`"wide"` | `"steep"`, default `"wide"`) — the submarine shelf. The height
curve is identity at and below sea level, so it never reshaped the seabed; `"wide"` compresses

View file

@ -34,6 +34,7 @@ namespace IslaApocalypse.Core
public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF"
public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV"
public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL"
public const uint TAG_EROSION = 0x534F5245; // "EROS"
// TDTL body version. 1 = shelf micro-relief + D8 drainage incision (the
// task-10 draft; the incision was reverted, and the only v1 payloads in
@ -42,6 +43,12 @@ namespace IslaApocalypse.Core
// differently shaped payload into plausible-looking nonsense.
public const ushort TDTL_VERSION = 2;
// EROS body version (terrain-water task 17). 1 = the droplet hydraulic-
// erosion params block: governors (count/lifetime/carve cap), sea clamp,
// brush, strength constants, RNG seed offset, crater exclusion factor.
// Same reader rule as TDTL: an unknown body version is skipped whole.
public const ushort EROS_VERSION = 1;
// WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L
// (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine
// level can never encode to 0. Decodes back via (q − 1) / 32768. Covers

View file

@ -35,6 +35,8 @@ namespace IslaApocalypse.Core
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve));
if (bp.TerrainDetail != null)
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail));
if (bp.Erosion != null)
WriteSection(writer, BlueprintFormat.TAG_EROSION, w => WriteErosion(w, bp.Erosion));
WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp));
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp));
@ -163,6 +165,19 @@ namespace IslaApocalypse.Core
writer.Write(d.EdgeMaxShiftM); // f32
}
private static void WriteErosion(BinaryWriter writer, ErosionInfo e)
{
writer.Write(e.Version); // u16
writer.Write(e.DropletCount); writer.Write(e.Lifetime); // 2 × i32
writer.Write(e.BrushRadius); writer.Write(e.SeedOffset); // 2 × i32
writer.Write(e.CarveCapM); writer.Write(e.SeaMarginM); // 2 × f32
writer.Write(e.Inertia); writer.Write(e.CapacityFactor); // 2 × f32
writer.Write(e.MinSlopeM); // f32
writer.Write(e.ErodeRate); writer.Write(e.DepositRate); // 2 × f32
writer.Write(e.Evaporation); writer.Write(e.Gravity); // 2 × f32
writer.Write(e.CraterExclFactor); // f32
}
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)
{
int n = bp.MapSize;

View file

@ -44,6 +44,31 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
public static float ShelfReliefAmp = 3.0f;
public static float ShelfEdgeVariation = 12.0f;
// Hydraulic erosion (task 17, Phase C0): droplet-based carve-and-deposit on
// the RENDER height map only — the classify path (biomes/water) never sees
// it. "off" until the developer's gate approves it; the batch that turns it
// on does so explicitly. The three GOVERNORS hard-bound the pass:
// DropletCount (cost/detail), DropletLifetime (max steps per droplet),
// CarveCap (max erosion depth per cell, metres — the runaway-trench guard
// and what keeps erosion a detailing pass). ErosionSeaMargin is the flood
// guard: no cell is ever carved below sea + margin, and below-sea cells are
// never touched at all, so the rendered coastline cannot move. The remaining
// dials are the standard droplet-model strength constants; slopes/amounts
// are in METRES (1 raw height unit = 251 m).
public static string Erosion = "off";
public static int ErosionDropletCount = 400000;
public static int ErosionDropletLifetime = 48;
public static float ErosionCarveCap = 8.0f; // m per cell
public static float ErosionSeaMargin = 0.5f; // m above sea, carve floor
public static int ErosionBrushRadius = 2; // px
public static float ErosionInertia = 0.05f;
public static float ErosionCapacity = 4.0f;
public static float ErosionMinSlope = 0.01f; // m per px, capacity floor
public static float ErosionErodeRate = 0.3f;
public static float ErosionDepositRate = 0.3f;
public static float ErosionEvaporation = 0.02f;
public static float ErosionGravity = 4.0f;
// Island falloff shaping (task 11).
//
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
@ -178,6 +203,48 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
ShelfEdgeVariation = (float)data["ShelfEdgeVariation"];
}
// Extract the erosion gate + dials (task 17)
if (data.ContainsKey("Erosion"))
{
string erosion = (string)data["Erosion"];
if (erosion == "off" || erosion == "v1")
Erosion = erosion;
else
GD.PrintErr($"[ConfigManager] Unknown Erosion '{erosion}'. Keeping '{Erosion}'.");
}
if (data.ContainsKey("ErosionDropletCount")) ErosionDropletCount = (int)data["ErosionDropletCount"];
if (data.ContainsKey("ErosionDropletLifetime")) ErosionDropletLifetime = (int)data["ErosionDropletLifetime"];
if (data.ContainsKey("ErosionCarveCap")) ErosionCarveCap = (float)data["ErosionCarveCap"];
if (data.ContainsKey("ErosionSeaMargin")) ErosionSeaMargin = (float)data["ErosionSeaMargin"];
if (data.ContainsKey("ErosionBrushRadius")) ErosionBrushRadius = (int)data["ErosionBrushRadius"];
if (data.ContainsKey("ErosionInertia")) ErosionInertia = (float)data["ErosionInertia"];
if (data.ContainsKey("ErosionCapacity")) ErosionCapacity = (float)data["ErosionCapacity"];
if (data.ContainsKey("ErosionMinSlope")) ErosionMinSlope = (float)data["ErosionMinSlope"];
if (data.ContainsKey("ErosionErodeRate")) ErosionErodeRate = (float)data["ErosionErodeRate"];
if (data.ContainsKey("ErosionDepositRate")) ErosionDepositRate = (float)data["ErosionDepositRate"];
if (data.ContainsKey("ErosionEvaporation")) ErosionEvaporation = (float)data["ErosionEvaporation"];
if (data.ContainsKey("ErosionGravity")) ErosionGravity = (float)data["ErosionGravity"];
// Governor bounds are enforced HERE, loudly, so a bad dial is a refused
// dial rather than a silently absurd generation. The clamps are wide —
// they exist to catch typos (an extra zero), not to tune.
int rawCount = ErosionDropletCount; int rawLife = ErosionDropletLifetime;
float rawCap = ErosionCarveCap;
ErosionDropletCount = Mathf.Clamp(ErosionDropletCount, 0, 50_000_000);
ErosionDropletLifetime = Mathf.Clamp(ErosionDropletLifetime, 1, 4096);
ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f);
if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap)
GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m.");
ErosionSeaMargin = Mathf.Clamp(ErosionSeaMargin, 0f, 5f);
ErosionBrushRadius = Mathf.Clamp(ErosionBrushRadius, 0, 8);
ErosionInertia = Mathf.Clamp(ErosionInertia, 0f, 0.99f);
ErosionCapacity = Mathf.Max(ErosionCapacity, 0f);
ErosionMinSlope = Mathf.Max(ErosionMinSlope, 0f);
ErosionErodeRate = Mathf.Clamp(ErosionErodeRate, 0f, 1f);
ErosionDepositRate = Mathf.Clamp(ErosionDepositRate, 0f, 1f);
ErosionEvaporation = Mathf.Clamp(ErosionEvaporation, 0f, 0.5f);
ErosionGravity = Mathf.Max(ErosionGravity, 0f);
// Extract the island-falloff dials (task 11)
if (data.ContainsKey("CoastProfile"))
{

View file

@ -104,6 +104,23 @@ namespace IslaApocalypse.Core
public float EdgeMaxShiftM;
}
/// <summary>
/// The hydraulic-erosion pass that detailed this blueprint's HGTS (v2 EROS
/// section, terrain-water task 17): droplet-model governors and strength
/// constants, as APPLIED (post config clamping). Null when erosion was off.
/// Metadata only — heights are already eroded; the classify-side data (biomes,
/// water) never saw the pass by design.
/// </summary>
public class ErosionInfo
{
public ushort Version;
public int DropletCount, Lifetime, BrushRadius, SeedOffset;
public float CarveCapM, SeaMarginM;
public float Inertia, CapacityFactor, MinSlopeM;
public float ErodeRate, DepositRate, Evaporation, Gravity;
public float CraterExclFactor;
}
public class WorldBlueprint
{
public int MapSize;
@ -134,6 +151,9 @@ namespace IslaApocalypse.Core
// The detail passes that shaped HeightMap (TDTL section); null = no detail.
public TerrainDetailInfo TerrainDetail;
// The erosion pass that detailed HeightMap (EROS section); null = no erosion.
public ErosionInfo Erosion;
}
// 2. The Parser Utility
@ -258,6 +278,7 @@ namespace IslaApocalypse.Core
else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint);
else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint);
else if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(reader, blueprint, payloadLength);
else if (tag == BlueprintFormat.TAG_EROSION) sectionOk = ParseErosion(reader, blueprint, payloadLength);
else
{
// The property the redesign exists to buy: future sections (water,
@ -474,6 +495,30 @@ namespace IslaApocalypse.Core
return true;
}
private static bool ParseErosion(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength)
{
var e = new ErosionInfo();
e.Version = reader.ReadUInt16();
if (e.Version != BlueprintFormat.EROS_VERSION)
{
// Same rule as TDTL: an unrecognised body version is skipped whole
// rather than misread into plausible-looking nonsense.
GD.PrintErr($"[MapDataParser] ⚠ EROS version {e.Version} is not the current {BlueprintFormat.EROS_VERSION} — section skipped, erosion metadata unavailable.");
reader.BaseStream.Seek((long)payloadLength - 2L, SeekOrigin.Current);
return true;
}
e.DropletCount = reader.ReadInt32(); e.Lifetime = reader.ReadInt32();
e.BrushRadius = reader.ReadInt32(); e.SeedOffset = reader.ReadInt32();
e.CarveCapM = reader.ReadSingle(); e.SeaMarginM = reader.ReadSingle();
e.Inertia = reader.ReadSingle(); e.CapacityFactor = reader.ReadSingle();
e.MinSlopeM = reader.ReadSingle();
e.ErodeRate = reader.ReadSingle(); e.DepositRate = reader.ReadSingle();
e.Evaporation = reader.ReadSingle(); e.Gravity = reader.ReadSingle();
e.CraterExclFactor = reader.ReadSingle();
blueprint.Erosion = e;
return true;
}
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
{
int pathCount = reader.ReadInt32();