diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md index 8a55bf7..76ded96 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -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 diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs index 162bdd2..75ca496 100644 --- a/Core/Scripts/BlueprintFormat.cs +++ b/Core/Scripts/BlueprintFormat.cs @@ -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 diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index 25a7854..4a9e339 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -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; diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index cbc0a52..d27bb1e 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -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")) { diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 78243ff..f160a73 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -104,6 +104,23 @@ namespace IslaApocalypse.Core public float EdgeMaxShiftM; } + /// + /// 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. + /// + 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 into) { int pathCount = reader.ReadInt32(); diff --git a/Tools/Scripts/HydraulicErosion.cs b/Tools/Scripts/HydraulicErosion.cs new file mode 100644 index 0000000..9638723 --- /dev/null +++ b/Tools/Scripts/HydraulicErosion.cs @@ -0,0 +1,310 @@ +using System; + +/// +/// 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. 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 MapGenerator). +/// +/// 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 cumulative erosion depth per cell, in metres, +/// enforced against a per-cell ledger. The runaway-trench +/// guard, and what keeps this a DETAILING pass. +/// +/// 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 exclusion: no cell within CraterExclRadius of the impact centre is +/// modified (droplets may traverse). The carve remains the final authority on its +/// own terrain. +/// +/// 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 +{ + public const ushort VERSION = 1; + + // Deterministic RNG stream: seeded from resolvedSeed + this offset, so a seed + // reproduces exactly and the stream is decorrelated from every noise field + // (7409/8117/… are taken; see MakeModulationNoise call sites). + public const int SEED_OFFSET = 9271; + + public const float M_PER_UNIT = 251f; + + // Crater exclusion factor: erosion stays outside 1.2 × CraterRadius — fully + // clear of both the physical carve (0.80×) and the detail feather (1.05×). + public const float CRATER_EXCL_FACTOR = 1.2f; + + // 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 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 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 long ErodedCells; // cells with any net ledger erosion + } + + // 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 craterExclRadius, Params p) + { + var stats = new Stats(); + var rng = new Pcg32(p.Seed); + float capUnits = p.CarveCapM / M_PER_UNIT; + if (p.DropletCount <= 0 || capUnits <= 0f) return stats; + + // Per-cell cumulative-erosion ledger — governor 3's enforcement record. + float[,] eroded = 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 exclSq = craterExclRadius * craterExclRadius; + float SeaAt(int cx, int cy) => seaMap != null ? seaMap[cx, cy] : seaFlat; + bool Excluded(int cx, int cy) + { + float ddx = cx - craterCx, ddy = cy - craterCy; + return ddx * ddx + ddy * ddy < exclSq; + } + + 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 = (hNew - hOld) * M_PER_UNIT; + 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 at + // the OLD position, bilinear over its 4 cells. + float amountM = dhM > 0f ? MathF.Min(dhM, sedimentM) + : (sedimentM - capacityM) * p.DepositRate; + if (amountM > 0f) + { + float w00 = (1f - fx) * (1f - fy), w10 = fx * (1f - fy); + float w01 = (1f - fx) * fy, w11 = fx * fy; + sedimentM -= DepositCell(height, eroded, xi, yi, amountM * w00, stats, SeaAt, Excluded) + + DepositCell(height, eroded, xi + 1, yi, amountM * w10, stats, SeaAt, Excluded) + + DepositCell(height, eroded, xi, yi + 1, amountM * w01, stats, SeaAt, Excluded) + + DepositCell(height, eroded, xi + 1, yi + 1, amountM * w11, stats, SeaAt, Excluded); + } + } + 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; + if (Excluded(cx, cy)) 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]; + float bySea = MathF.Max(0f, (hCell - (sea + p.SeaMarginM / M_PER_UNIT)) * M_PER_UNIT); + float byCap = MathF.Max(0f, p.CarveCapM - eroded[cx, cy]); + float take = MathF.Min(want, MathF.Min(bySea, byCap)); + if (take <= 0f) continue; + height[cx, cy] = hCell - take / M_PER_UNIT; + if (eroded[cx, cy] == 0f) stats.ErodedCells++; + eroded[cx, cy] += take; + if (eroded[cx, cy] > stats.MaxCellErosionM) stats.MaxCellErosionM = eroded[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."); + + return stats; + } + + /// + /// Deposits up to metres on one cell; returns what was + /// actually placed. Below-sea cells and crater-excluded cells take nothing — + /// deposition only ever raises LAND, so the coastline cannot move and the sea + /// cannot shallow. A cell's ledgered erosion is paid back first, so erode-then- + /// deposit at one cell frees cap headroom instead of double-counting. + /// + private static float DepositCell(float[,] height, float[,] eroded, int cx, int cy, + float amountM, Stats stats, Func seaAt, Func excluded) + { + if (amountM <= 0f) return 0f; + float hCell = height[cx, cy]; + if (hCell < seaAt(cx, cy)) return 0f; + if (excluded(cx, cy)) return 0f; + height[cx, cy] = hCell + amountM / M_PER_UNIT; + eroded[cx, cy] = MathF.Max(0f, eroded[cx, cy] - amountM); + stats.DepositedVolumeM3 += amountM; + return amountM; + } +} diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index f44835d..0e2e1d8 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -27,13 +27,14 @@ public partial class MapGenerator : TextureRect private float[,] _heightMap; - // Classification heightmap (task 05): the UNCURVED heights (plus the crater - // carve), i.e. exactly what the curve-off pipeline produces. Biome rules, the - // two flood fills, and the shared water predicates read THIS map, so biome and - // water output is identical with the curve on or off — the bit-identical-biomes - // oracle holds by construction. Towns, roads, diagnostics, and the exported - // heights use the curved _heightMap (they live in the 3D world). When the curve - // is off this is the SAME array as _heightMap (aliased, no copy). + // Classification heightmap (task 05): the UNCURVED, UNERODED heights (plus the + // crater carve), i.e. exactly what the curve-off pipeline produces. Biome rules, + // the two flood fills, and the shared water predicates read THIS map, so biome + // and water output is identical with the curve and erosion on or off — the + // bit-identical-biomes oracle holds by construction. Towns, roads, diagnostics, + // and the exported heights use the curved (and, when on, eroded) _heightMap + // (they live in the 3D world). When every render-only pass is off this is the + // SAME array as _heightMap (aliased, no copy). private float[,] _heightMapClassify; private bool _curveOn; @@ -49,6 +50,10 @@ public partial class MapGenerator : TextureRect // v5: the selected knot preset; null = curve off. private CurveKnots _curveKnots; + // Hydraulic erosion (task 17): output-only droplet pass on the RENDER map, + // after detail, before the crater carve. The classify map never sees it. + private bool _erosionOn; + // Task-10 detail passes (shelf micro-relief + shelf-edge variation): gated by // TerrainDetail, active only with the curve on (both are defined in terms of // the curve's bands). The relief noise seeds from resolvedSeed + 7409, the @@ -108,9 +113,13 @@ public partial class MapGenerator : TextureRect _heightMap = new float[MapSize, MapSize]; _curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null; _curveOn = _curveKnots != null; + _erosionOn = ConfigManager.Erosion == "v1"; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) - _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; + // Classify gets its own array whenever ANY render-only pass diverges the two + // maps — the curve, or erosion (which must not leak into classify even with + // the curve off; aliased arrays would be exactly that leak). + _heightMapClassify = (_curveOn || _erosionOn) ? new float[MapSize, MapSize] : _heightMap; _tempMap = new float[MapSize, MapSize]; _biomeMap = new Biome[MapSize, MapSize]; _isTrueOcean = new bool[MapSize, MapSize]; @@ -364,6 +373,26 @@ public partial class MapGenerator : TextureRect StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET, PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6 } : null, + // EROS: the erosion params AS APPLIED (post config clamping) — a blueprint + // with eroded heights is self-describing without the config that made it. + Erosion = _erosionOn ? new ErosionInfo + { + Version = HydraulicErosion.VERSION, + DropletCount = ConfigManager.ErosionDropletCount, + Lifetime = ConfigManager.ErosionDropletLifetime, + BrushRadius = ConfigManager.ErosionBrushRadius, + SeedOffset = HydraulicErosion.SEED_OFFSET, + CarveCapM = ConfigManager.ErosionCarveCap, + SeaMarginM = ConfigManager.ErosionSeaMargin, + Inertia = ConfigManager.ErosionInertia, + CapacityFactor = ConfigManager.ErosionCapacity, + MinSlopeM = ConfigManager.ErosionMinSlope, + ErodeRate = ConfigManager.ErosionErodeRate, + DepositRate = ConfigManager.ErosionDepositRate, + Evaporation = ConfigManager.ErosionEvaporation, + Gravity = ConfigManager.ErosionGravity, + CraterExclFactor = HydraulicErosion.CRATER_EXCL_FACTOR + } : null, TerrainDetail = _detailOn ? new TerrainDetailInfo { Version = TerrainDetailPass.VERSION, @@ -594,7 +623,8 @@ public partial class MapGenerator : TextureRect // curve — after hMaxSeed is known, before any pixel is curved. if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots, _detailOn ? _edgeAmpRaw : 0f); - // --- PASS 2: curve (task 05/06) + detail (task 10) + crater carve --- + // --- PASS 2: curve (task 05/06) + detail (task 10), then erosion (task 17), + // then the crater carve — three sub-passes (2a/2b/2c) in that order. --- // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so // the carve cuts into curved terrain and the rim/bowl shape is untouched by // the curve. Identity at and below sea + this ordering preserve the @@ -646,23 +676,100 @@ public partial class MapGenerator : TextureRect curvedH = raw; } - // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- - // Both maps are carved from LOCALS and written once, so the curve-off - // aliasing (classify and height are the same array) cannot double-carve. - // We only carve the physical hole at 80% of the radius to guarantee a landbridge! - if (distToCrater < physicalCraterRadius) - { - float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); - // Dialed back to -0.15f as per your excellent instinct! - float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; - classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); - curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); - } - _heightMapClassify[x, y] = classifyH; _heightMap[x, y] = curvedH; } } + + // --- EROSION (task 17): render map ONLY — after detail, BEFORE the crater + // carve. Output-only by construction: _heightMapClassify was finalized above + // (bar the carve) and the pass never sees it, so biomes/water classify + // pre-erosion — the oracle. The pass's sea clamp plus its below-sea + // read-only rule mean the RENDERED coastline cannot move either; that is + // verified here, not assumed, by counting render-map water pixels A/B. + if (_erosionOn) + { + ulong tEro0 = Time.GetTicksMsec(); + float[,] seaMap = null; + float seaFlat = ConfigManager.SeaLevelValue; + if (ConfigManager.SeaLevelModel != "flat") + { + seaMap = new float[MapSize, MapSize]; + for (int x = 0; x < MapSize; x++) + for (int y = 0; y < MapSize; y++) + seaMap[x, y] = GetSeaLevel(_tempMap[x, y]); + } + long wetBefore = CountRenderWaterPixels(seaMap, seaFlat); + + var p = new HydraulicErosion.Params + { + DropletCount = ConfigManager.ErosionDropletCount, + Lifetime = ConfigManager.ErosionDropletLifetime, + CarveCapM = ConfigManager.ErosionCarveCap, + SeaMarginM = ConfigManager.ErosionSeaMargin, + BrushRadius = ConfigManager.ErosionBrushRadius, + Inertia = ConfigManager.ErosionInertia, + CapacityFactor = ConfigManager.ErosionCapacity, + MinSlopeM = ConfigManager.ErosionMinSlope, + ErodeRate = ConfigManager.ErosionErodeRate, + DepositRate = ConfigManager.ErosionDepositRate, + Evaporation = ConfigManager.ErosionEvaporation, + Gravity = ConfigManager.ErosionGravity, + Seed = _noise.Seed + HydraulicErosion.SEED_OFFSET + }; + var st = HydraulicErosion.Apply(_heightMap, MapSize, seaMap, seaFlat, + _impactCenter.X, _impactCenter.Y, + _impactRadius * HydraulicErosion.CRATER_EXCL_FACTOR, p); + + long wetAfter = CountRenderWaterPixels(seaMap, seaFlat); + if (wetAfter != wetBefore) + throw new System.InvalidOperationException( + $"[MapGenerator] EROSION FLOOD-GUARD VIOLATION: render-map water pixels {wetBefore} -> {wetAfter}. Refusing to generate."); + + GD.Print($"{T()} [Erosion] v1: {st.Spawned} droplets ({st.SkippedNoLand} skipped), {st.Steps} steps, " + + $"{(Time.GetTicksMsec() - tEro0) / 1000.0:F1}s wall. Eroded {st.ErodedVolumeM3:F0} m³ over {st.ErodedCells} cells " + + $"(max cell {st.MaxCellErosionM:F2} m vs cap {p.CarveCapM:F2} m), deposited {st.DepositedVolumeM3:F0} m³. " + + $"Deaths: {st.DiedSea} sea / {st.DiedEdge} edge / {st.DiedDry} dry / {st.DiedLifetime} lifetime. " + + $"Water pixels {wetBefore} -> {wetAfter} (flood guard holds)."); + } + + // --- PASS 2c: CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- + // The carve stays LAST — the final authority on its own terrain (erosion is + // also excluded within 1.2× CraterRadius, entirely clear of this 0.8× + // physical carve). Both heights are read into locals BEFORE either write, so + // the all-passes-off aliasing (classify and height are the same array) + // cannot double-carve. We only carve the physical hole at 80% of the radius + // to guarantee a landbridge! + int cx0 = Mathf.Max(0, (int)(_impactCenter.X - physicalCraterRadius) - 1); + int cx1 = Mathf.Min(MapSize - 1, (int)(_impactCenter.X + physicalCraterRadius) + 1); + int cy0 = Mathf.Max(0, (int)(_impactCenter.Y - physicalCraterRadius) - 1); + int cy1 = Mathf.Min(MapSize - 1, (int)(_impactCenter.Y + physicalCraterRadius) + 1); + for (int x = cx0; x <= cx1; x++) + { + for (int y = cy0; y <= cy1; y++) + { + float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); + if (distToCrater >= physicalCraterRadius) continue; + float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); + // Dialed back to -0.15f as per your excellent instinct! + float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; + float classifyH = _heightMapClassify[x, y]; + float curvedH = _heightMap[x, y]; + _heightMapClassify[x, y] = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); + _heightMap[x, y] = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); + } + } + } + + // Render-map water pixel count — the erosion flood-guard's external check. + private long CountRenderWaterPixels(float[,] seaMap, float seaFlat) + { + long wet = 0; + for (int x = 0; x < MapSize; x++) + for (int y = 0; y < MapSize; y++) + if (_heightMap[x, y] < (seaMap != null ? seaMap[x, y] : seaFlat)) + wet++; + return wet; } private void CalculateTrueOcean() diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index 1d57827..078523e 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -122,6 +122,7 @@ public partial class RoundTripHarness : Node ok &= CompareWater(a, b); ok &= CompareTerrainCurve(a, b); ok &= CompareTerrainDetail(a, b); + ok &= CompareErosion(a, b); if (ok) GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + @@ -241,6 +242,33 @@ public partial class RoundTripHarness : Node return true; } + private bool CompareErosion(WorldBlueprint a, WorldBlueprint b) + { + if (a.Erosion == null && b.Erosion == null) + { + GD.Print("[Harness] EROS: absent in source — nothing to compare (and none reappeared)."); + return true; + } + if (a.Erosion == null || b.Erosion == null) + { + GD.PrintErr("[Harness] EROS presence mismatch between source and reread."); + return false; + } + var ea = a.Erosion; var eb = b.Erosion; + bool same = ea.Version == eb.Version + && ea.DropletCount == eb.DropletCount && ea.Lifetime == eb.Lifetime + && ea.BrushRadius == eb.BrushRadius && ea.SeedOffset == eb.SeedOffset; + float[] fa = { ea.CarveCapM, ea.SeaMarginM, ea.Inertia, ea.CapacityFactor, ea.MinSlopeM, + ea.ErodeRate, ea.DepositRate, ea.Evaporation, ea.Gravity, ea.CraterExclFactor }; + float[] fb = { eb.CarveCapM, eb.SeaMarginM, eb.Inertia, eb.CapacityFactor, eb.MinSlopeM, + eb.ErodeRate, eb.DepositRate, eb.Evaporation, eb.Gravity, eb.CraterExclFactor }; + for (int i = 0; i < fa.Length; i++) + if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false; + if (!same) { GD.PrintErr("[Harness] EROS fields differ."); return false; } + GD.Print($"[Harness] EROS equal (erosion v{ea.Version})."); + return true; + } + private bool CompareRoads(string tier, List a, List b) { if (a.Count != b.Count)