fix: shrink erosion's crater exclusion to the strike core; add FULL/FEATHER modes (terrain-water task 19)

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) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-10 05:50:22 -04:00
parent 85e7bceb72
commit f810ff44dc
7 changed files with 141 additions and 24 deletions

View file

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

View file

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

View file

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

View file

@ -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;
}

View file

@ -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 640960 px annulus was 620,811
// land cells of ordinary terrain held smooth for no geometric reason — a
// visible un-eroded disc against dissected ground, with a hard edge.
//
// The protected core is now the deep strike zone only. The bay itself needs no
// exclusion: below-sea cells are read-only in both directions (the sea clamp),
// so erosion can neither carve the bay's sea connection open nor silt it shut.
// The core exists to stop the BOWL being dissected on seeds where it holds land
// (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.
/// </summary>
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));

View file

@ -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³ " +

View file

@ -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; }