mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-23 08:30:34 +00:00
A hydrology tile that fails to plan generates without rivers
Planning failures used to escape into the chunk system and crash the server. The tile cache now catches them, logs the failure in full and publishes an empty tile so terrain keeps generating. A natural height that is not finite is no longer memoized, and its error names the region, biome, fluid height, overlay, every interpolator's bounds and generator heights, and the cached and fresh values for the column.
This commit is contained in:
1 parent
e63fd9c448
commit
88bd8feeeb
10 files changed
+217
-4
No files matched your search
@@ -309,6 +309,7 @@ public class IrisComplex implements DataProvider {
|
||||
data,
|
||||
(x, z, naturalHeight) -> sampleHydrologyNatural(engine, x, z, naturalHeight),
|
||||
(x, z) -> naturalHeightStream.getDouble(x, z),
|
||||
(x, z) -> describeNaturalHeight(engine, x, z),
|
||||
this::sampleNaturalOcean,
|
||||
footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint)
|
||||
));
|
||||
@@ -399,6 +400,35 @@ public class IrisComplex implements DataProvider {
|
||||
return resolveNaturalInferredType(bridgeStream, focusBiome, x, z) == InferredType.SEA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every input to the natural height at one column, recomputed outside the caches: the region
|
||||
* and base biome, the fluid height and overlay, each interpolator's bounds and generator
|
||||
* heights, then the cached and the fresh natural height. Diagnostic only.
|
||||
*/
|
||||
String describeNaturalHeight(Engine engine, int x, int z) {
|
||||
long seed = engine.getSeedManager().getHeight();
|
||||
IrisRegion region = regionStream.get(x, z);
|
||||
IrisBiome biome = baseBiomeStream.get(x, z);
|
||||
StringBuilder out = new StringBuilder(256);
|
||||
out.append("region=").append(region == null ? "null" : region.getLoadKey());
|
||||
out.append(" biome=").append(biome == null ? "null" : biome.getLoadKey());
|
||||
out.append(" fluidHeight=").append(fluidHeight);
|
||||
out.append(" overlay=").append(overlayStream.getDouble(x, z));
|
||||
for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) {
|
||||
IrisGenerator[] generators = frozenGenerators[interpolatorIndex];
|
||||
NoiseBounds bounds = gridSampleBounds(engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, generators, x, z);
|
||||
out.append(" interpolator[").append(interpolatorIndex).append("] bounds=")
|
||||
.append(bounds.min()).append("..").append(bounds.max());
|
||||
for (IrisGenerator generator : generators) {
|
||||
out.append(' ').append(generator.getLoadKey()).append('=')
|
||||
.append(generator.getHeight(x, z, seed + 239945));
|
||||
}
|
||||
}
|
||||
out.append(" cachedNatural=").append(naturalHeightStream.getDouble(x, z));
|
||||
out.append(" freshNatural=").append(sampleNaturalTerrainHeight(engine, x, z));
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private double sampleNaturalTerrainHeight(Engine engine, double x, double z) {
|
||||
double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight());
|
||||
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
|
||||
|
||||
@@ -252,6 +252,27 @@ public final class HydrologyPlanner {
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile with the same identity as a planned tile but no rivers, used when planning the tile
|
||||
* failed and terrain there must still generate.
|
||||
*/
|
||||
public HydrologyTile emptyTile(HydrologyTileKey key) {
|
||||
Objects.requireNonNull(key, "key");
|
||||
return new HydrologyTile(
|
||||
key,
|
||||
worldSeed,
|
||||
settings.fingerprint(),
|
||||
settings.routing().tileSize(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
RiverFootprint.empty()
|
||||
);
|
||||
}
|
||||
|
||||
public HydrologyTile plan(HydrologyTileKey key) {
|
||||
return materializeAcceptedTile(resolveCrossTileOwner(key));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.engine.hydrology;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.util.common.parallel.MultiBurst;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
@@ -49,7 +50,24 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
|
||||
public HydrologyTile get(HydrologyTileKey key) {
|
||||
Objects.requireNonNull(key, "key");
|
||||
return tiles.get(key, planner::plan);
|
||||
return tiles.get(key, this::planOrEmpty);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile whose planning throws is published without rivers instead of failing the chunks that
|
||||
* asked for it: one bad column must not take the chunk system down. The failure is logged in
|
||||
* full so the cause can be traced, and the empty tile is cached like any other so the session
|
||||
* stays consistent.
|
||||
*/
|
||||
private HydrologyTile planOrEmpty(HydrologyTileKey key) {
|
||||
try {
|
||||
return planner.plan(key);
|
||||
} catch (RuntimeException failure) {
|
||||
IrisLogging.error("Hydrology tile " + key.tileX() + "," + key.tileZ()
|
||||
+ " failed to plan and generates without rivers: " + failure.getMessage());
|
||||
IrisLogging.reportError(failure);
|
||||
return planner.emptyTile(key);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package art.arcane.iris.engine.hydrology.runtime;
|
||||
|
||||
/**
|
||||
* Explains how the natural terrain height at a column was produced, for the diagnostic that fires
|
||||
* when that height is not a finite number.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface IrisHydrologyNaturalHeightDescriber {
|
||||
String describe(int x, int z);
|
||||
}
|
||||
+5
@@ -306,6 +306,11 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
}
|
||||
}
|
||||
double sampled = heightProvider.sample(blockX, blockZ);
|
||||
if (!Double.isFinite(sampled)) {
|
||||
// Never memoize a broken sample: the basis provider rejects it with a diagnostic and a
|
||||
// later sample of the same column gets a fresh chance instead of the cached failure.
|
||||
return sampled;
|
||||
}
|
||||
synchronized (lock) {
|
||||
double existing = naturalHeights.getAndMoveToLast(packed);
|
||||
if (!Double.isNaN(existing) || naturalHeights.containsKey(packed)) {
|
||||
|
||||
+29
-1
@@ -412,6 +412,28 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
return routingTerrainSampler.sampleBasis(x, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a column whose natural height is not a finite number. It carries both the
|
||||
* raw stream sample and the natural sample plus the terrain breakdown, so the log of a failed
|
||||
* tile says which generator, overlay or cache produced the value.
|
||||
*/
|
||||
static String nonFiniteHeightMessage(
|
||||
int x,
|
||||
int z,
|
||||
double rawNaturalHeight,
|
||||
double sampledNaturalHeight,
|
||||
IrisHydrologyNaturalHeightDescriber describer
|
||||
) {
|
||||
String breakdown;
|
||||
try {
|
||||
breakdown = describer.describe(x, z);
|
||||
} catch (RuntimeException failure) {
|
||||
breakdown = "breakdown unavailable: " + failure.getClass().getSimpleName() + ": " + failure.getMessage();
|
||||
}
|
||||
return "Hydrology natural height was not finite at " + x + "," + z
|
||||
+ " (raw=" + rawNaturalHeight + ", sampled=" + sampledNaturalHeight + "; " + breakdown + ")";
|
||||
}
|
||||
|
||||
private IrisHydrologyRoutingTerrainSampler.TerrainBasis createTerrainBasis(
|
||||
int x,
|
||||
int z,
|
||||
@@ -426,7 +448,13 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
? sampledNaturalHeight
|
||||
: rawNaturalHeight;
|
||||
if (!Double.isFinite(resolvedNaturalHeight)) {
|
||||
throw new IllegalStateException("Hydrology natural height was not finite at " + x + "," + z);
|
||||
throw new IllegalStateException(nonFiniteHeightMessage(
|
||||
x,
|
||||
z,
|
||||
rawNaturalHeight,
|
||||
sampledNaturalHeight,
|
||||
context.naturalHeightDescriber()
|
||||
));
|
||||
}
|
||||
int naturalHeight = (int) StrictMath.round(resolvedNaturalHeight);
|
||||
boolean ocean = naturalSample.ocean();
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ public record IrisHydrologyRuntimeContext(
|
||||
IrisData data,
|
||||
IrisHydrologyNaturalSampleProvider naturalSampleProvider,
|
||||
IrisHydrologyNaturalHeightProvider naturalHeightProvider,
|
||||
IrisHydrologyNaturalHeightDescriber naturalHeightDescriber,
|
||||
IrisHydrologyNaturalOceanClassifier naturalOceanClassifier,
|
||||
HydrologyCaveVoxelViewFactory caveViewFactory
|
||||
) {
|
||||
@@ -24,6 +25,7 @@ public record IrisHydrologyRuntimeContext(
|
||||
Objects.requireNonNull(data);
|
||||
Objects.requireNonNull(naturalSampleProvider);
|
||||
Objects.requireNonNull(naturalHeightProvider);
|
||||
Objects.requireNonNull(naturalHeightDescriber);
|
||||
Objects.requireNonNull(naturalOceanClassifier);
|
||||
Objects.requireNonNull(caveViewFactory);
|
||||
}
|
||||
|
||||
@@ -341,4 +341,35 @@ public class HydrologyTileCacheTest {
|
||||
List.of("default"), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void planningFailureFallsBackToTheEmptyTileAndIsCached() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(3, -2);
|
||||
HydrologyTile empty = new HydrologyTile(key, 7L, 11L, 1024, List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), RiverFootprint.empty());
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
when(planner.plan(key)).thenThrow(new IllegalStateException("Hydrology natural height was not finite at -66,-641"));
|
||||
when(planner.emptyTile(key)).thenReturn(empty);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
|
||||
assertSame(empty, cache.get(key));
|
||||
assertSame(empty, cache.get(key));
|
||||
|
||||
verify(planner, times(1)).plan(key);
|
||||
assertTrue(cache.get(key).courses().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plannerEmptyTileCarriesTheTileIdentityAndNoContent() {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(811L, featureSettings(), this::featureTerrain);
|
||||
HydrologyTileKey key = new HydrologyTileKey(-4, 9);
|
||||
|
||||
HydrologyTile tile = planner.emptyTile(key);
|
||||
|
||||
assertEquals(key, tile.key());
|
||||
assertEquals(featureSettings().routing().tileSize(), tile.tileSize());
|
||||
assertTrue(tile.courses().isEmpty());
|
||||
assertTrue(tile.outlets().isEmpty());
|
||||
assertTrue(tile.columnAt(0, 0).isEmpty());
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -338,7 +338,7 @@ public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void primitiveHeightCacheRetainsNanValues() {
|
||||
public void primitiveHeightCacheNeverRetainsNanValues() {
|
||||
AtomicInteger heightCalls = new AtomicInteger();
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
@@ -354,7 +354,8 @@ public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
|
||||
assertTrue(Double.isNaN(sampler.localSlope(0, 0, 0D)));
|
||||
assertTrue(Double.isNaN(sampler.localSlope(0, 0, 0D)));
|
||||
assertEquals(2, heightCalls.get());
|
||||
// Two neighbours per slope, sampled again on the second call because a NaN is never memoized.
|
||||
assertEquals(4, heightCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -609,4 +610,30 @@ public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
private long pack(int x, int z) {
|
||||
return ((long) x << 32) ^ (z & 0xffffffffL);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonFiniteNaturalHeightIsNotCachedSoTheNextSampleCanRecover() {
|
||||
AtomicInteger originCalls = new AtomicInteger();
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(int x, int z, double naturalHeight) -> new IrisHydrologyRoutingTerrainSampler.TerrainBasis(
|
||||
naturalHeight,
|
||||
HydrologyTerrainSample.openLand((int) Math.round(naturalHeight), 0D, "parent")
|
||||
),
|
||||
(int x, int z) -> {
|
||||
if (x == 4 && z == 4) {
|
||||
return originCalls.incrementAndGet() == 1 ? Double.NaN : 90D;
|
||||
}
|
||||
return 90D;
|
||||
},
|
||||
(int x, int z) -> false
|
||||
),
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(64)
|
||||
);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> sampler.sampleBasis(4, 4));
|
||||
|
||||
assertEquals(90, sampler.sampleBasis(4, 4).naturalHeight());
|
||||
assertEquals(2, originCalls.get());
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package art.arcane.iris.engine.hydrology.runtime;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class IrisHydrologyRuntimeDiagnosticsTest {
|
||||
@Test
|
||||
public void nonFiniteHeightMessageNamesTheColumnBothSamplesAndTheTerrainBreakdown() {
|
||||
String message = IrisHydrologyRuntime.nonFiniteHeightMessage(
|
||||
-66,
|
||||
-641,
|
||||
Double.NaN,
|
||||
Double.POSITIVE_INFINITY,
|
||||
(int x, int z) -> "region=temperate biome=temperate/plains"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
"Hydrology natural height was not finite at -66,-641 (raw=NaN, sampled=Infinity; region=temperate biome=temperate/plains)",
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonFiniteHeightMessageSurvivesAFailingDescriber() {
|
||||
String message = IrisHydrologyRuntime.nonFiniteHeightMessage(
|
||||
1,
|
||||
2,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
(int x, int z) -> {
|
||||
throw new IllegalStateException("stream closed");
|
||||
}
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
"Hydrology natural height was not finite at 1,2 (raw=NaN, sampled=NaN; breakdown unavailable: IllegalStateException: stream closed)",
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user