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