From f810ff44dc695e5a4b3c22aba98f538d6a581782 Mon Sep 17 00:00:00 2001 From: beezm Date: Mon, 10 Aug 2026 05:50:22 -0400 Subject: [PATCH] fix: shrink erosion's crater exclusion to the strike core; add FULL/FEATHER modes (terrain-water task 19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 17's hard 1.2 x CraterRadius cutoff left a visible un-eroded disc with a hard edge. Measured (radius dump, seed 1280587109): the carve writes only inside 0.80 x (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. That annulus is now eroded — 98% of those cells are touched. The protected core is the carve's own extent (CraterErosionCore, 0.80 x). CraterErosionMode selects the transition: "full" applies full strength at the core boundary (older-crater look), "feather" ramps 0->full out to CraterErosionFeather (1.05 x, mirroring the detail pass) for a younger-crater look with no seam. Implemented as a WEIGHT that scales carve and deposit amounts, not a skip, which is what makes feather a one-liner. Default "feather" pending the gate. Core = the carve radius is load-bearing, not tidy: the carve runs AFTER erosion and scales height toward the sea target, so it amplifies any erosion delta inside its radius. Measured at a 0.50 core: 79 cells newly below the rendered sea and 113,310 below-sea cells disturbed — the in-pass flood guard cannot see this because it measures before the carve. At 0.80 the two passes touch disjoint cells and the guarantee is exact again (0/0/0). A narrower core also reclaims nothing extra, since the over-protected annulus lies entirely outside the carve. MapGenerator now owns CRATER_CARVE_FACTOR as the single source of that 0.80 and warns loudly if the configured core is narrower. Verified both modes, seed 1280587109: 1_biomes/0_water md5-identical to erosion-OFF, BIOM/WBID bitwise equal, 0 newly below/above sea, 0 below-sea cells modified, 0 cells modified inside the core, island top 457.65 m unchanged, isotropy 1.005/1.007. Bay-to-ocean connection demonstrated explicitly by flood fill through rendered water to the map's north border (2,122,935 cells), not merely argued from the sea clamp. EROS body version -> 3: craterExclFactor replaced by craterCoreFactor + craterFeatherFactor + craterMode. Round-trip harness PASS with a real v3 payload. Co-Authored-By: Claude Opus 5 (1M context) --- Core/Scripts/BlueprintFormat.cs | 4 +- Core/Scripts/BlueprintWriter.cs | 4 +- Core/Scripts/ConfigManager.cs | 49 ++++++++++++++++++++++++ Core/Scripts/MapDataParser.cs | 6 ++- Tools/Scripts/HydraulicErosion.cs | 63 ++++++++++++++++++++++++------- Tools/Scripts/MapGenerator.cs | 32 ++++++++++++++-- Tools/Scripts/RoundTripHarness.cs | 7 ++-- 7 files changed, 141 insertions(+), 24 deletions(-) diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs index 38e591a..d8af0d8 100644 --- a/Core/Scripts/BlueprintFormat.cs +++ b/Core/Scripts/BlueprintFormat.cs @@ -49,7 +49,9 @@ namespace IslaApocalypse.Core // current) inserts the DEPOSIT CAP governor after the carve cap — deposition is // now brush-spread and per-cell bounded. Same reader rule as TDTL: an unknown // body version is skipped whole rather than misread into plausible nonsense. - public const ushort EROS_VERSION = 2; + // 3 (task 19, current) replaces the single crater exclusion factor with the + // protected-core factor, a feather-band factor, and the crater mode byte. + public const ushort EROS_VERSION = 3; // 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 diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index fa4c43f..9dca0f8 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -176,7 +176,9 @@ namespace IslaApocalypse.Core 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 + writer.Write(e.CraterCoreFactor); // f32 + writer.Write(e.CraterFeatherFactor); // f32 + writer.Write(e.CraterMode); // u8 } private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index c7d696d..a56809f 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -79,6 +79,32 @@ namespace IslaApocalypse.Core // Change this if your namespace is different public static float ErosionEvaporation = 0.004f; public static float ErosionGravity = 4.0f; + // How erosion treats the crater surrounds (task 19). The task-17 hard + // 1.2 × CraterRadius cutoff left a visible un-eroded disc: measured, the carve + // writes only inside 0.80 × and its displacement is exactly 0 beyond that, so + // 620 811 LAND cells of ordinary terrain were being held smooth for no + // geometric reason. Now only the deep strike core is protected. + // "full" — full-strength erosion right up to the core boundary. The + // crater formed after the terrain and has weathered since. + // "feather" — erosion ramps 0→full across CraterErosionCore → + // CraterErosionFeather (the detail pass's shape), so the crater + // reads as younger, less-weathered, and there is no seam at all. + // Radii are FACTORS of CraterRadius. The flooded bay and its sea connection do + // NOT depend on these: below-sea cells are read-only in both directions. + public static string CraterErosionMode = "feather"; + public static float CraterErosionCore = CRATER_EROSION_CORE_DEFAULT; + public static float CraterErosionFeather = CRATER_EROSION_FEATHER_DEFAULT; + // 0.80 = the carve's OWN extent (MapGenerator's physicalCraterRadius). Keeping the + // core at least this wide is what makes erosion and the carve touch DISJOINT cells, + // which is what keeps the post-carve flood guard exactly zero: the carve runs after + // erosion and scales height toward the sea target, so it AMPLIFIES any erosion delta + // inside its radius and can push a hair-above-sea cell across the waterline. Measured + // at a 0.50 core: 79 cells newly below the rendered sea, 113 310 below-sea cells + // disturbed. A smaller core reclaims nothing extra either — the over-protected annulus + // is 0.80x-1.2x, entirely outside the carve. + public const float CRATER_EROSION_CORE_DEFAULT = 0.80f; + public const float CRATER_EROSION_FEATHER_DEFAULT = 1.05f; + // Island falloff shaping (task 11). // // CoastProfile: "wide" adds the submarine shelf — the height curve is identity @@ -246,6 +272,29 @@ namespace IslaApocalypse.Core // Change this if your namespace is different 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."); + // Crater erosion treatment (task 19) + if (data.ContainsKey("CraterErosionMode")) + { + string cm = (string)data["CraterErosionMode"]; + if (cm == "full" || cm == "feather") + CraterErosionMode = cm; + else + GD.PrintErr($"[ConfigManager] Unknown CraterErosionMode '{cm}'. Keeping '{CraterErosionMode}'."); + } + if (data.ContainsKey("CraterErosionCore")) CraterErosionCore = (float)data["CraterErosionCore"]; + if (data.ContainsKey("CraterErosionFeather")) CraterErosionFeather = (float)data["CraterErosionFeather"]; + CraterErosionCore = Mathf.Clamp(CraterErosionCore, 0f, 3f); + CraterErosionFeather = Mathf.Clamp(CraterErosionFeather, 0f, 4f); + // A feather band that does not extend past the core is not a band; say so + // rather than silently behaving like "full". + if (CraterErosionMode == "feather" && CraterErosionFeather <= CraterErosionCore) + { + GD.PrintErr($"[ConfigManager] CraterErosionFeather {CraterErosionFeather:F2} must exceed " + + $"CraterErosionCore {CraterErosionCore:F2} — the ramp would have zero width. " + + $"Restoring {CRATER_EROSION_FEATHER_DEFAULT:F2}."); + CraterErosionFeather = CRATER_EROSION_FEATHER_DEFAULT; + } + // Negative is meaningless; 0 is the documented "unbounded" escape hatch. ErosionDepositCap = Mathf.Clamp(ErosionDepositCap, 0f, 60f); ErosionSeaMargin = Mathf.Clamp(ErosionSeaMargin, 0f, 5f); diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 068028d..d86ed9b 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -118,7 +118,8 @@ namespace IslaApocalypse.Core public float CarveCapM, DepositCapM, SeaMarginM; public float Inertia, CapacityFactor, MinSlopeM; public float ErodeRate, DepositRate, Evaporation, Gravity; - public float CraterExclFactor; + public float CraterCoreFactor, CraterFeatherFactor; + public byte CraterMode; // 0 = full, 1 = feather } public class WorldBlueprint @@ -515,7 +516,8 @@ namespace IslaApocalypse.Core e.MinSlopeM = reader.ReadSingle(); e.ErodeRate = reader.ReadSingle(); e.DepositRate = reader.ReadSingle(); e.Evaporation = reader.ReadSingle(); e.Gravity = reader.ReadSingle(); - e.CraterExclFactor = reader.ReadSingle(); + e.CraterCoreFactor = reader.ReadSingle(); e.CraterFeatherFactor = reader.ReadSingle(); + e.CraterMode = reader.ReadByte(); blueprint.Erosion = e; return true; } diff --git a/Tools/Scripts/HydraulicErosion.cs b/Tools/Scripts/HydraulicErosion.cs index 4ff2877..cfb9479 100644 --- a/Tools/Scripts/HydraulicErosion.cs +++ b/Tools/Scripts/HydraulicErosion.cs @@ -38,9 +38,12 @@ using System; /// 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. +/// 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, @@ -62,9 +65,25 @@ public static class HydraulicErosion 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; + // --- Crater treatment (task 19) --- + // + // 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 + // (on 1280587109 there is no land at all inside 0.50 ×, so the core is + // functionally redundant there — the guard is for the general seed). + public const float CRATER_CORE_FACTOR_DEFAULT = 0.50f; // ×CraterRadius + 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, @@ -91,6 +110,7 @@ public static class HydraulicErosion 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 } @@ -130,7 +150,7 @@ public static class HydraulicErosion /// 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) + float craterCx, float craterCy, float craterCoreRadius, float craterFeatherRadius, Params p) { var stats = new Stats(); var rng = new Pcg32(p.Seed); @@ -173,12 +193,25 @@ public static class HydraulicErosion 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) + + // 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. + 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; - return ddx * ddx + ddy * ddy < exclSq; + 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++) @@ -261,12 +294,13 @@ public static class HydraulicErosion { 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 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]; + 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. @@ -293,11 +327,12 @@ public static class HydraulicErosion { 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 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]; + float want = amountM * brushW[b] * wCrater; float bySea = MathF.Max(0f, (hCell - (sea + p.SeaMarginM / M_PER_UNIT)) * M_PER_UNIT); float byCap = MathF.Max(0f, p.CarveCapM - net[cx, cy]); float take = MathF.Min(want, MathF.Min(bySea, byCap)); diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index bc34835..7daf51c 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -53,6 +53,12 @@ public partial class MapGenerator : TextureRect // 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; + private byte _craterErosionMode; + + // The carve's radius as a fraction of CraterRadius — the ONLY place this number + // lives on the generator side. Erosion's protected core must be at least this + // wide or the two passes overlap; see the guard before the erosion call. + private const float CRATER_CARVE_FACTOR = 0.80f; // 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 @@ -114,6 +120,8 @@ public partial class MapGenerator : TextureRect _curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null; _curveOn = _curveKnots != null; _erosionOn = ConfigManager.Erosion == "v1"; + _craterErosionMode = ConfigManager.CraterErosionMode == "feather" + ? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) // Classify gets its own array whenever ANY render-only pass diverges the two @@ -392,7 +400,9 @@ public partial class MapGenerator : TextureRect DepositRate = ConfigManager.ErosionDepositRate, Evaporation = ConfigManager.ErosionEvaporation, Gravity = ConfigManager.ErosionGravity, - CraterExclFactor = HydraulicErosion.CRATER_EXCL_FACTOR + CraterCoreFactor = ConfigManager.CraterErosionCore, + CraterFeatherFactor = ConfigManager.CraterErosionFeather, + CraterMode = _craterErosionMode } : null, TerrainDetail = _detailOn ? new TerrainDetailInfo { @@ -638,7 +648,7 @@ public partial class MapGenerator : TextureRect // `raw`; neither can move a column below the red ceiling or above the cap, // because K2 and K6 never move and the curve is monotonic between them. float reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f; - float physicalCraterRadius = _impactRadius * 0.80f; + float physicalCraterRadius = _impactRadius * CRATER_CARVE_FACTOR; for (int x = 0; x < MapSize; x++) { for (int y = 0; y < MapSize; y++) @@ -700,6 +710,17 @@ public partial class MapGenerator : TextureRect for (int y = 0; y < MapSize; y++) seaMap[x, y] = GetSeaLevel(_tempMap[x, y]); } + // Erosion's protected core must cover the carve, or the two passes touch the + // same cells and the carve — which runs AFTER erosion and scales height + // toward the sea target — amplifies erosion's delta across the waterline. + // Refusing to fail silently: the in-pass flood guard below cannot see this, + // because it measures before the carve. + if (ConfigManager.CraterErosionCore < CRATER_CARVE_FACTOR) + GD.PrintErr($"[MapGenerator] ⚠ CraterErosionCore {ConfigManager.CraterErosionCore:F2} is " + + $"inside the carve radius ({CRATER_CARVE_FACTOR:F2} × CraterRadius). Erosion will modify " + + "carve-authored terrain, and the crater carve will then amplify those deltas — expect a " + + "handful of rendered-waterline crossings inside the bay that the erosion flood guard cannot see."); + long wetBefore = CountRenderWaterPixels(seaMap, seaFlat); var p = new HydraulicErosion.Params @@ -717,17 +738,22 @@ public partial class MapGenerator : TextureRect DepositRate = ConfigManager.ErosionDepositRate, Evaporation = ConfigManager.ErosionEvaporation, Gravity = ConfigManager.ErosionGravity, + CraterMode = _craterErosionMode, Seed = _noise.Seed + HydraulicErosion.SEED_OFFSET }; var st = HydraulicErosion.Apply(_heightMap, MapSize, seaMap, seaFlat, _impactCenter.X, _impactCenter.Y, - _impactRadius * HydraulicErosion.CRATER_EXCL_FACTOR, p); + _impactRadius * ConfigManager.CraterErosionCore, + _impactRadius * ConfigManager.CraterErosionFeather, 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] crater '{ConfigManager.CraterErosionMode}': core {_impactRadius * ConfigManager.CraterErosionCore:F0} px" + + (ConfigManager.CraterErosionMode == "feather" + ? $" -> feather to {_impactRadius * ConfigManager.CraterErosionFeather:F0} px" : " (hard edge, full strength beyond)") + "."); 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.ModifiedCells} touched cells " + $"(max cell carve {st.MaxCellErosionM:F2} m vs cap {p.CarveCapM:F2} m), deposited {st.DepositedVolumeM3:F0} m³ " + diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index 846c954..087f5ac 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -257,11 +257,12 @@ public partial class RoundTripHarness : Node 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; + && ea.BrushRadius == eb.BrushRadius && ea.SeedOffset == eb.SeedOffset + && ea.CraterMode == eb.CraterMode; float[] fa = { ea.CarveCapM, ea.DepositCapM, ea.SeaMarginM, ea.Inertia, ea.CapacityFactor, ea.MinSlopeM, - ea.ErodeRate, ea.DepositRate, ea.Evaporation, ea.Gravity, ea.CraterExclFactor }; + ea.ErodeRate, ea.DepositRate, ea.Evaporation, ea.Gravity, ea.CraterCoreFactor, ea.CraterFeatherFactor }; float[] fb = { eb.CarveCapM, eb.DepositCapM, eb.SeaMarginM, eb.Inertia, eb.CapacityFactor, eb.MinSlopeM, - eb.ErodeRate, eb.DepositRate, eb.Evaporation, eb.Gravity, eb.CraterExclFactor }; + eb.ErodeRate, eb.DepositRate, eb.Evaporation, eb.Gravity, eb.CraterCoreFactor, eb.CraterFeatherFactor }; 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; }