diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java index f6fda8937..47ad5e198 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java @@ -42,6 +42,7 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KList; +import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.WorldBorder; @@ -112,6 +113,11 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { } } + @Override + public boolean isMainThread() { + return Bukkit.isPrimaryThread(); + } + @Override public boolean isPregeneratorActive(Engine engine) { IrisWorld world = engine.getWorld(); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index d4e92e1a5..bf9f546a1 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -692,6 +692,26 @@ public class IrisComplex implements DataProvider { return hydrologyRuntime == null ? null : hydrologyRuntime.sample(x, z).orElse(null); } + /** Whether the column's hydrology can be sampled without waiting for a plan (see Engine.answersFromNaturalTerrain). */ + public boolean isHydrologyPlanned(int x, int z) { + return hydrologyRuntime == null || hydrologyRuntime.isPlanned(x, z); + } + + /** The natural terrain height of a column, from the natural height stream only: never touches hydrology or its caches. */ + public int naturalTrueHeight(int x, int z) { + return (int) Math.round(naturalHeightStream.getDouble(x, z)); + } + + /** The surface biome a column would have without any river content, from natural streams only. */ + public IrisBiome naturalSurfaceBiome(int x, int z) { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + if (mapped != null) { + return mapped; + } + double terrainHeight = naturalHeightStream.getDouble(x, z); + return fixBiomeType(terrainHeight, baseBiomeStream.get(x, z), regionStream.get(x, z), (double) x, (double) z, fluidHeight); + } + private HydrologyColumnLayer surfaceLayer(double x, double z) { HydrologyColumnSample sample = hydrologySample(x, z); return sample == null ? null : sample.primarySurfaceLayer().orElse(null); @@ -928,15 +948,14 @@ public class IrisComplex implements DataProvider { // catches the same corner reached by another thread or by hydrology planning earlier. SharedCornerBounds shared = sharedCornerBounds(); long sharedKey = SharedCornerBounds.key(gx, gz, interpolatorIndex); - long packed; - if (shared.contains(sharedKey)) { - packed = shared.get(sharedKey); - if (packed == Long.MIN_VALUE) { - packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz); - shared.put(sharedKey, packed); - } - } else { + long packed = shared.contains(sharedKey) ? shared.get(sharedKey) : Long.MIN_VALUE; + if (packed == Long.MIN_VALUE) { packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz); + if (!Double.isFinite(boundsLow(packed)) || !Double.isFinite(boundsHigh(packed))) { + // A non-finite corner is a transient sampling fault, not a fact about the terrain: + // hand it back uncached so the next sample recomputes it instead of pinning it. + return packed; + } shared.put(sharedKey, packed); } cache.gx[slot] = gx; diff --git a/core/src/main/java/art/arcane/iris/engine/SharedCornerBounds.java b/core/src/main/java/art/arcane/iris/engine/SharedCornerBounds.java index 3241a9e12..43d15d219 100644 --- a/core/src/main/java/art/arcane/iris/engine/SharedCornerBounds.java +++ b/core/src/main/java/art/arcane/iris/engine/SharedCornerBounds.java @@ -52,6 +52,7 @@ final class SharedCornerBounds { return false; } long storedKey = (long) LONGS.getAcquire(keys, slot); + VarHandle.loadLoadFence(); int after = (int) STAMPS.getAcquire(stamps, slot); return before == after && storedKey == key; } @@ -69,6 +70,8 @@ final class SharedCornerBounds { } long storedKey = (long) LONGS.getAcquire(keys, slot); long value = (long) LONGS.getAcquire(values, slot); + // The data loads above must not drift past the second stamp read, or a torn slot could pass. + VarHandle.loadLoadFence(); int after = (int) STAMPS.getAcquire(stamps, slot); if (before != after || storedKey != key) { return Long.MIN_VALUE; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index b7c508ca7..17ea4b3e5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -333,9 +333,21 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisBiome getSurfaceBiome(int x, int z) { + if (answersFromNaturalTerrain(x, z)) { + return getComplex().naturalSurfaceBiome(x, z); + } return getComplex().getTrueBiomeStream().get(x, z); } + /** + * The server thread must never wait for a cold hydrology plan, so a height or biome query from it + * on a column whose tiles are not planned yet is answered from natural terrain (the tiles are + * requested from the planning pool meanwhile). Generation threads always wait for the real plan. + */ + default boolean answersFromNaturalTerrain(int x, int z) { + return getPlatformHooks().isMainThread() && !getComplex().isHydrologyPlanned(x, z); + } + @BlockCoordinates default int getHeight(int x, int z) { return getHeight(x, z, true); @@ -343,6 +355,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default int getHeight(int x, int z, boolean ignoreFluid) { + if (answersFromNaturalTerrain(x, z)) { + int natural = getComplex().naturalTrueHeight(x, z); + return ignoreFluid ? natural : Math.max(natural, getMantle().getFluidHeight()); + } return getMantle().getHighest(x, z, getData(), ignoreFluid); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java index 4170c5ed4..4c6028526 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java @@ -53,6 +53,14 @@ public interface EnginePlatformHooks { return false; } + /** + * Whether the calling thread is the server's tick thread. Queries from there must never wait + * for hydrology planning, so they answer from natural terrain until the tiles are planned. + */ + default boolean isMainThread() { + return false; + } + default void shutdownPregenerator(Engine engine) { } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 74f693c01..2b6f39d5e 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -194,6 +194,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun INMS.get().inject(world.getSeed(), engine, world); engine.getPlatformHooks().applyWorldBoundary(engine); IrisLogging.debug("Injected Iris Biome Source into " + world.getName()); + prefetchSpawnHydrology(engine, world); if (!studio) { J.sfut(() -> updateSpawnLocation(world), 1) .whenComplete((ignored, failure) -> { @@ -239,6 +240,44 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return new Location(world, 0.5D, y, 0.5D); } + private int hydrologyTileSize() { + Engine activeEngine = engine; + if (activeEngine == null || activeEngine.getComplex() == null || activeEngine.getComplex().getHydrologyRuntime() == null) { + return 0; + } + return activeEngine.getComplex().getHydrologyRuntime().settings().routing().tileSize(); + } + + /** + * Starts planning the hydrology tiles around the initial spawn as soon as the generator is injected + * into a world whose spawn chunk does not exist yet. The first teleport there makes the chunk system + * generate chunks a few hundred blocks around the spawn, and each of those chunks' mantle windows + * reaches further still, so the area planned ahead is half a tile in every direction: every tile a + * cold entry can touch starts planning at once instead of one generation thread at a time finding + * it cold. A world that already generated its spawn (every existing world at boot, a hotloaded live + * world) skips it. + */ + private void prefetchSpawnHydrology(Engine engine, World world) { + if (engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { + return; + } + Location spawn = getInitialSpawnLocation(world); + int spawnX = spawn.getBlockX(); + int spawnZ = spawn.getBlockZ(); + if (world.isChunkGenerated(spawnX >> 4, spawnZ >> 4)) { + return; + } + int reach = Math.max(16, hydrologyTileSize() / 2); + engine.getComplex().getHydrologyRuntime().prefetchArea( + spawnX - reach, + spawnZ - reach, + spawnX + reach - 1, + spawnZ + reach - 1, + spawnX, + spawnZ + ); + } + private void updateSpawnLocation(World world) { try { Location initialSpawn = getInitialSpawnLocation(world); diff --git a/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java b/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java index c098009da..88d6c48d6 100644 --- a/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java +++ b/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java @@ -20,7 +20,7 @@ public class MultiBurst extends MultiBurstSupport { * Long-running hydrology tile planning. Kept off {@link #burst} so a handful of twenty-second * plans never pin the workers that chunk generation's short stage and mantle tasks need. */ - public static final MultiBurst hydrology = new MultiBurst("Iris Hydrology", () -> Math.max(2, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) / 3)); + public static final MultiBurst hydrology = new MultiBurst("Iris Hydrology", () -> Math.max(2, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()))); public MultiBurst() { this("Iris"); diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java index 46f6cd2de..00dfa3d9a 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java @@ -14,6 +14,7 @@ import java.util.HashMap; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertNotEquals; import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; @@ -48,6 +49,20 @@ public class IrisComplexGridBoundsCacheTest { assertEquals(1, interpolator.getInvocations()); } + @Test + public void nonFiniteCornerBoundsAreNeverCached() throws Exception { + IrisComplex complex = createComplex(); + Method cornerBounds = cornerBoundsMethod(); + CountingInterpolator interpolator = new CountingInterpolator(Double.NaN, 7.25D); + + long firstPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96); + long secondPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96); + + assertTrue(Double.isNaN(unpackLow(firstPacked))); + assertTrue(Double.isNaN(unpackLow(secondPacked))); + assertEquals(2, interpolator.getInvocations()); + } + @Test public void alignedGridSampleUsesOnlyTheContributingCorner() throws Exception { IrisComplex complex = createComplex();