diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index f055c2a72..24b6b7a28 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -201,6 +201,7 @@ dependencies { libs.asm, libs.lru, libs.caffeine, + 'com.fulcrumgenomics:jlibdeflate:0.2.0', libs.dom4j, libs.jaxen ] diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index a10f2349f..1dca2a8d7 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -200,6 +200,7 @@ dependencies { libs.asm, libs.lru, libs.caffeine, + 'com.fulcrumgenomics:jlibdeflate:0.2.0', libs.dom4j, libs.jaxen ] diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 517c8da0a..e972e70eb 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -391,10 +391,7 @@ public final class ModdedEngineBootstrap { BlockDataMergeSupport.StateMerger previousMerger = BlockDataMergeSupport.bindPlatformMerger(new NativeStateMerger(ModdedBlockResolution.BLOCKS)::merge); rollback.add(() -> BlockDataMergeSupport.restorePlatformMerger(previousMerger)); - NativeTileReader nativeTileReader = new NativeTileReader(() -> { - NativeModdedServer server = boundLoader.currentServer(); - return server == null ? null : server.registryAccess(); - }); + NativeTileReader nativeTileReader = NativeTileReader.forServer(boundLoader::currentServer); TileData.TileReader previousTileReader = TileData.bindPlatformReader(in -> ModdedTileData.wrap(nativeTileReader.read(in))); rollback.add(() -> TileData.restorePlatformReader(previousTileReader)); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index ba3f55d9d..2a488b3d5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -38,8 +38,6 @@ import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.volmlib.nativelib.terrain.NativeWorld; -import net.minecraft.core.HolderLookup; - import java.io.File; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -64,7 +62,7 @@ public final class ModdedPlatform implements IrisPlatform { this.loader = loader; this.registries = new ModdedRegistries( new NativeRegistryAccess(new NativeRegistryAccess.Configuration( - ModdedPlatform::nativeRegistries, ModdedPlatform::reloadableRegistries, + ModdedEngineBootstrap::currentServer, ModdedRegistries::warnNotReady)), ModdedPlatform::generationRegistry); this.scheduler = new ModdedScheduler(); @@ -72,16 +70,6 @@ public final class ModdedPlatform implements IrisPlatform { this.biomeWriter = new ModdedBiomeWriter(new NativeBiomeRegistry(ModdedEngineBootstrap::currentServer, "minecraft:plains")); } - private static HolderLookup.Provider nativeRegistries() { - NativeModdedServer server = ModdedEngineBootstrap.currentServer(); - return server == null ? null : server.registryAccess(); - } - - private static HolderLookup.Provider reloadableRegistries() { - NativeModdedServer server = ModdedEngineBootstrap.currentServer(); - return server == null ? null : server.reloadableRegistries(); - } - public static void errorSink(Consumer sink) { ERROR_SINK = sink; } diff --git a/adapters/modded-common/src/main/resources/pack.mcmeta b/adapters/modded-common/src/main/resources/pack.mcmeta new file mode 100644 index 000000000..1189047ea --- /dev/null +++ b/adapters/modded-common/src/main/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "Iris client resources and mod data", + "min_format": [88, 0], + "max_format": [107, 1] + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModLanguageAssetsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModLanguageAssetsTest.java index 12d0eea95..68c402121 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModLanguageAssetsTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModLanguageAssetsTest.java @@ -72,10 +72,10 @@ public class IrisModLanguageAssetsTest { } private Set resourceFiles() throws Exception { - URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT); - assertNotNull("Missing mod language resource directory: " + ROOT, resource); + URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT + "en_us.json"); + assertNotNull("Missing English mod language resource", resource); assertEquals("Mod language resources must resolve to a directory on disk", "file", resource.getProtocol()); - Path directory = Path.of(resource.toURI()); + Path directory = Path.of(resource.toURI()).getParent(); try (Stream paths = Files.list(directory)) { return paths .filter(Files::isRegularFile) diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackMetadataTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackMetadataTest.java new file mode 100644 index 000000000..48d82911c --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackMetadataTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.modded; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.metadata.pack.PackMetadataSection; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class ModdedPackMetadataTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void metadataLoadsClientResourcesAndServerDataForCurrentMinecraft() throws IOException { + try (InputStream resource = ModdedPackMetadataTest.class.getResourceAsStream("/pack.mcmeta")) { + assertNotNull(resource); + try (InputStreamReader reader = new InputStreamReader(resource, StandardCharsets.UTF_8)) { + JsonObject metadata = JsonParser.parseReader(reader).getAsJsonObject(); + for (PackType type : PackType.values()) { + PackMetadataSection section = PackMetadataSection.forPackType(type).codec() + .parse(JsonOps.INSTANCE, metadata.get("pack")).getOrThrow(); + assertEquals("Iris client resources and mod data", section.description().getString()); + assertTrue(type.toString(), section.supportedFormats().isValueInRange( + SharedConstants.getCurrentVersion().packVersion(type))); + } + } + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureTemplateCacheTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureTemplateCacheTest.java index dc856bb18..9b6b14d4d 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureTemplateCacheTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureTemplateCacheTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.modded; import org.junit.Test; +import org.objectweb.asm.ClassReader; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -62,9 +63,11 @@ public class ModdedStructureTemplateCacheTest { config = new String(input.readAllBytes(), StandardCharsets.UTF_8); } assertTrue(config.contains("\"StructureTemplatePaletteConcurrencyMixin\"")); - assertNotNull(Class.forName( - "art.arcane.volmlib.nativelib.minecraft26_2.modded.mixin.StructureTemplatePaletteConcurrencyMixin", - false, - ModdedStructureTemplateCacheTest.class.getClassLoader())); + String mixin = "art/arcane/volmlib/nativelib/minecraft26_2/modded/mixin/StructureTemplatePaletteConcurrencyMixin"; + try (InputStream input = ModdedStructureTemplateCacheTest.class.getClassLoader() + .getResourceAsStream(mixin + ".class")) { + assertNotNull(input); + assertEquals(mixin, new ClassReader(input).getClassName()); + } } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java index 3a948b43a..7fbb2b7ee 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java @@ -41,7 +41,7 @@ public class ModdedTileParityTest { SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); - NativeTileReader nativeTileReader = new NativeTileReader(() -> null); + NativeTileReader nativeTileReader = NativeTileReader.forServer(() -> null); TileData.bindPlatformReader(in -> ModdedTileData.wrap(nativeTileReader.read(in))); TileData.bindPlatformFactory(ModdedTileData::fromProperties); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java index 5f4b286de..52ca89a7a 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java @@ -11,9 +11,12 @@ import art.arcane.iris.generation.decoration.IrisSurface; import art.arcane.volmlib.util.math.RNG; import net.minecraft.SharedConstants; import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.server.Bootstrap; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import org.junit.BeforeClass; import org.junit.Test; @@ -34,6 +37,11 @@ public class ModdedWaterEntitySpawnTest { public static void bootstrapMinecraftRegistries() { SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); + for (Block block : BuiltInRegistries.BLOCK) { + for (BlockState state : block.getStateDefinition().getPossibleStates()) { + state.initCache(); + } + } } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeRegistryAccessTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeRegistryAccessTest.java index 5c9ebd510..af9790899 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeRegistryAccessTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeRegistryAccessTest.java @@ -1,20 +1,31 @@ package art.arcane.iris.modded; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty; import net.minecraft.SharedConstants; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; import org.junit.BeforeClass; import org.junit.Test; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class NativeRegistryAccessTest { @BeforeClass @@ -25,7 +36,7 @@ public class NativeRegistryAccessTest { @Test public void statePropertiesPreserveTypesDefaultsAndSharedGroups() { - NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, () -> null, message -> {})); + NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, message -> {})); Map> properties = access.blockStateProperties(); assertSame(properties.get("minecraft:stone"), properties.get("minecraft:dirt")); @@ -41,7 +52,7 @@ public class NativeRegistryAccessTest { @Test public void staticRegistriesResolveWithoutAnActiveServerAndDynamicReadsStayEmpty() { List warnings = new ArrayList<>(); - NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, () -> null, warnings::add)); + NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, warnings::add)); assertEquals("minecraft:diamond_sword", access.item("DIAMOND SWORD").key()); assertEquals("creature", access.entity("minecraft:cow").spawnCategory()); @@ -49,6 +60,34 @@ public class NativeRegistryAccessTest { assertTrue(access.biomeKeys().isEmpty()); assertTrue(access.structureKeys().isEmpty()); assertTrue(access.enchantmentKeys().isEmpty()); - assertEquals(List.of("biome", "structure", "enchantment"), warnings); + assertTrue(access.lootTableKeys().isEmpty()); + assertEquals(List.of("biome", "structure", "enchantment", "loot table"), warnings); + } + + @Test + public void dynamicRegistriesFollowServerStartupReloadAndShutdown() { + AtomicReference activeServer = new AtomicReference<>(); + List warnings = new ArrayList<>(); + NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(activeServer::get, warnings::add)); + assertTrue(access.lootTableKeys().isEmpty()); + + MinecraftServer server = mock(MinecraftServer.class, RETURNS_DEEP_STUBS); + when(server.registryAccess().lookupOrThrow(Registries.BIOME).listElementIds()) + .thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.BIOME, Identifier.parse("example:biome")))); + when(server.reloadableRegistries().lookup().lookupOrThrow(Registries.LOOT_TABLE).listElementIds()) + .thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.LOOT_TABLE, Identifier.parse("example:initial")))); + activeServer.set(NativeModdedServer.fromHandle(server)); + + assertEquals(List.of("example:biome"), access.biomeKeys()); + assertEquals(List.of("example:initial"), access.lootTableKeys()); + HolderLookup.Provider reloadedRegistries = mock(HolderLookup.Provider.class, RETURNS_DEEP_STUBS); + when(reloadedRegistries.lookupOrThrow(Registries.LOOT_TABLE).listElementIds()) + .thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.LOOT_TABLE, Identifier.parse("example:reloaded")))); + when(server.reloadableRegistries().lookup()).thenReturn(reloadedRegistries); + assertEquals(List.of("example:reloaded"), access.lootTableKeys()); + activeServer.set(null); + assertTrue(access.biomeKeys().isEmpty()); + assertTrue(access.lootTableKeys().isEmpty()); + assertEquals(List.of("loot table", "biome", "loot table"), warnings); } } diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index b995a63d0..fe8632d69 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -173,6 +173,7 @@ dependencies { libs.asm, libs.lru, libs.caffeine, + 'com.fulcrumgenomics:jlibdeflate:0.2.0', libs.dom4j, libs.jaxen ] diff --git a/build.gradle b/build.gradle index 345871505..ccd014dad 100644 --- a/build.gradle +++ b/build.gradle @@ -576,6 +576,14 @@ tasks.named('check').configure { } List commonModdedArtifactEntries = [ + 'pack.mcmeta', + 'com/fulcrumgenomics/jlibdeflate/LibdeflateDecompressor.class', + 'com/fulcrumgenomics/jlibdeflate/LibdeflateException.class', + 'native/linux-aarch64/libjlibdeflate.so', + 'native/linux-x86_64/libjlibdeflate.so', + 'native/osx-aarch64/libjlibdeflate.dylib', + 'native/osx-x86_64/libjlibdeflate.dylib', + 'native/windows-x86_64/jlibdeflate.dll', 'META-INF/iris/generation-kernels/catalog.tsv', 'META-INF/iris/generation-kernels/abi-1.revision', 'META-INF/services/art.arcane.iris.localization.IrisMessageContributor', diff --git a/buildSrc/src/main/java/ModdedArtifactVerifier.java b/buildSrc/src/main/java/ModdedArtifactVerifier.java index a77cd67fd..f2a10102b 100644 --- a/buildSrc/src/main/java/ModdedArtifactVerifier.java +++ b/buildSrc/src/main/java/ModdedArtifactVerifier.java @@ -85,6 +85,11 @@ public final class ModdedArtifactVerifier { throw new GradleException(artifact.getName() + " is missing " + requiredEntry); } } + if (jar.getJarEntry("assets/irisworldgen/lang/en_us.json") != null + && jar.getJarEntry("pack.mcmeta") == null) { + throw new GradleException(artifact.getName() + + " ships Iris client language resources without pack.mcmeta"); + } Set bundledRuntimeEntries = new LinkedHashSet<>(); Set privateReferenceClasses = new LinkedHashSet<>(); diff --git a/buildSrc/src/test/java/ModdedArtifactVerifierTest.java b/buildSrc/src/test/java/ModdedArtifactVerifierTest.java index 3517999ee..efb9303d2 100644 --- a/buildSrc/src/test/java/ModdedArtifactVerifierTest.java +++ b/buildSrc/src/test/java/ModdedArtifactVerifierTest.java @@ -44,6 +44,28 @@ public class ModdedArtifactVerifierTest { ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES); } + @Test + public void rejectsClientLanguagesWithoutPackMetadata() throws Exception { + Map entries = validEntries(); + entries.put("assets/irisworldgen/lang/en_us.json", "{}".getBytes(StandardCharsets.UTF_8)); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("client language resources without pack.mcmeta")); + } + + @Test + public void acceptsClientLanguagesWithPackMetadata() throws Exception { + Map entries = validEntries(); + entries.put("assets/irisworldgen/lang/en_us.json", "{}".getBytes(StandardCharsets.UTF_8)); + entries.put("pack.mcmeta", ("{\"pack\":{\"description\":\"Iris\"," + + "\"min_format\":[88,0],\"max_format\":[107,1]}}") + .getBytes(StandardCharsets.UTF_8)); + + ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES); + } + @Test public void rejectsRelocatedRuntimeReference() throws Exception { Map entries = validEntries(); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index 4c04aa714..f9f4b1b31 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -1089,16 +1089,18 @@ public class IrisComplex implements DataProvider { continue; } HydrologyColumnLayer original = sample.layers().get(index); - int head = (int) Math.round(transitionDisplacement.fluidHeight(sample.x(), sample.z(), layer.fluidHeadY())); + int displacedHead = (int) Math.round( + transitionDisplacement.fluidHeight(sample.x(), sample.z(), original.fluidHeadY())); + boolean channel = original.channel() && layer.bedY() <= displacedHead; + int head = channel ? displacedHead : layer.bedY(); if ((tapered.naturalHeight() < seaLevel || tapered.naturalHeight() == seaLevel && head > seaLevel) && (original.terrainOwned() || original.fluidOwned() || original.grading() || original.shore())) { continue; } - boolean dryChannel = original.channel() && layer.bedY() > head; layers.add(new HydrologyColumnLayer(layer.feature(), layer.bedY(), - head, head, original.channel() && !dryChannel, layer.shore(), layer.grading(), original.connectedFluid() && !dryChannel, - original.fallingFluid() && !dryChannel, original.receivingPool() && !dryChannel, - layer.terrainOwned(), original.fluidOwned() && !dryChannel, layer.oceanApron(), + head, head, channel, layer.shore(), layer.grading(), original.connectedFluid() && channel, + original.fallingFluid() && channel, original.receivingPool() && channel, + layer.terrainOwned(), original.fluidOwned() && channel, layer.oceanApron(), layer.profileKey(), layer.surfaceBiomeKey(), layer.mouthBiomeKey(), layer.shoreBiomeKey(), layer.bankBiomeKey(), layer.floodedCaveBiomeKey())); } @@ -1140,20 +1142,21 @@ public class IrisComplex implements DataProvider { return layer; } int bed = GenerationBlend.interpolateHeight(naturalHeight, layer.bedY(), hydrologyWeight); - boolean dryChannel = layer.channel() && bed > layer.fluidHeadY(); + boolean channel = layer.channel() && bed <= layer.fluidHeadY(); + int head = channel ? layer.fluidHeadY() : bed; return new HydrologyColumnLayer( layer.feature(), bed, - layer.fluidHeadY(), - layer.ceilingY(), - layer.channel() && !dryChannel, + head, + head, + channel, layer.shore(), layer.grading(), - layer.connectedFluid() && !dryChannel, - layer.fallingFluid() && !dryChannel, - layer.receivingPool() && !dryChannel, + layer.connectedFluid() && channel, + layer.fallingFluid() && channel, + layer.receivingPool() && channel, layer.terrainOwned(), - layer.fluidOwned() && !dryChannel, + layer.fluidOwned() && channel, layer.oceanApron(), layer.profileKey(), layer.surfaceBiomeKey(), diff --git a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java index 7c04a6142..702792457 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java @@ -30,6 +30,7 @@ import java.util.concurrent.Semaphore; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.studio.view.PregeneratorJob; import art.arcane.iris.world.IrisWorlds; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.platform.bukkit.nms.INMS; @@ -1290,6 +1291,15 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public void generateNoise(@NotNull WorldInfo world, @NotNull Random random, int x, int z, @NotNull ChunkGenerator.ChunkData d) { + try { + generateNoiseChunk(world, random, x, z, d); + } catch (Throwable e) { + PregeneratorJob.reportChunkGenerationFailure(this.world.identity(), x, z, e); + throw e; + } + } + + private void generateNoiseChunk(WorldInfo world, Random random, int x, int z, ChunkGenerator.ChunkData d) { if (closing) { throw new IllegalStateException("Iris chunk generation was rejected while the generator is closing."); } diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/studio/view/PregeneratorJob.java index f767a73e6..afe412d8f 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/studio/view/PregeneratorJob.java @@ -254,6 +254,14 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return instance.get(); } + public static void reportChunkGenerationFailure(String worldIdentity, int chunkX, int chunkZ, Throwable failure) { + PregeneratorJob inst = instance.get(); + if (inst == null || worldIdentity == null || !worldIdentity.equals(inst.worldIdentity())) { + return; + } + inst.pregenerator.onChunkGenerationFailed(chunkX, chunkZ, failure); + } + public static boolean pauseResume() { PregeneratorJob inst = instance.get(); return inst != null && inst.togglePause(); diff --git a/core/src/main/java/art/arcane/iris/world/pregen/AsyncOrMedievalPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/AsyncOrMedievalPregenMethod.java index 0a729c1fc..e4f50bd83 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/AsyncOrMedievalPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/AsyncOrMedievalPregenMethod.java @@ -85,6 +85,11 @@ public class AsyncOrMedievalPregenMethod implements PregeneratorMethod { method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); } + @Override + public void onChunkGenerationFailed(int chunkX, int chunkZ, Throwable failure) { + method.onChunkGenerationFailed(chunkX, chunkZ, failure); + } + @Override public void onPregenStart(int centerBlockX, int centerBlockZ) { method.onPregenStart(centerBlockX, centerBlockZ); diff --git a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java index 29dc7906f..09d5e0954 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java @@ -21,6 +21,7 @@ package art.arcane.iris.world.pregen; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.world.lifecycle.WorldLifecycleService; import art.arcane.volmlib.nativelib.terrain.NativeWorkerPool; +import java.util.Map; import java.util.Objects; import art.arcane.iris.world.IrisPaperLikeBackendMode; import art.arcane.iris.world.IrisRuntimeSchedulerMode; @@ -55,6 +56,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; public class AsyncPregenMethod implements PregeneratorMethod { // THREAD_COUNT records the pool's original size while boosted; BOOST_HOLDERS counts live @@ -64,7 +66,8 @@ public class AsyncPregenMethod implements PregeneratorMethod { private static final AtomicInteger BOOST_HOLDERS = new AtomicInteger(); private static final int ADAPTIVE_SLOW_REQUEST_STEP = 3; private static final int ADAPTIVE_RECOVERY_INTERVAL = 8; - private static final long CLOSE_DRAIN_WARNING_SECONDS = 60L; + private static final long CLOSE_DRAIN_WARNING_SECONDS = 20L; + private static final long CLOSE_DRAIN_TIMEOUT_SECONDS = 60L; private static final long FLUSH_TIMEOUT_SECONDS = 120L; private static final long MANTLE_CLEANUP_DRAIN_SECONDS = 60L; private static final long CHUNK_FLUSH_DRAIN_SECONDS = 30L; @@ -113,6 +116,8 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final AtomicLong failed = new AtomicLong(); private final AtomicLong lastProgressAt = new AtomicLong(M.ms()); private final AtomicBoolean closing = new AtomicBoolean(); + private final PregenInFlightRequests inFlightRequests = new PregenInFlightRequests(); + private final AtomicReference generationFailure = new AtomicReference<>(); private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean(); private volatile Engine metricsEngine; private volatile Mantle cachedMantle; @@ -515,10 +520,20 @@ public class AsyncPregenMethod implements PregeneratorMethod { } private void completeChunk(int x, int z, PregenListener listener, Chunk chunk, Throwable throwable) { + PregenListener owner = inFlightRequests.settle(PregenInFlightRequests.key(x, z)); + if (owner == null) { + return; + } + settleChunk(x, z, owner, chunk, throwable, true); + } + + private void settleChunk(int x, int z, PregenListener listener, Chunk chunk, Throwable throwable, boolean reportFailure) { boolean success = false; try { if (throwable != null) { - onChunkFutureFailure(x, z, throwable); + if (reportFailure) { + onChunkFutureFailure(x, z, throwable); + } onChunkFailedToLoad(x, z); listener.onChunkFailed(x, z); } else if (chunk == null) { @@ -718,11 +733,57 @@ public class AsyncPregenMethod implements PregeneratorMethod { + " stalledForMs=" + stalledFor; } - private void markSubmitted() { + private void markSubmitted(int x, int z, PregenListener listener) { + inFlightRequests.add(PregenInFlightRequests.key(x, z), listener); submitted.incrementAndGet(); inFlight.incrementAndGet(); } + @Override + public void onChunkGenerationFailed(int chunkX, int chunkZ, Throwable failure) { + GenerationFailure recorded = new GenerationFailure(chunkX, chunkZ, failure); + if (generationFailure.compareAndSet(null, recorded)) { + IrisLogging.error("Async pregen aborting: Iris failed to generate chunk " + chunkX + "," + chunkZ + + " and the server's chunk system will not complete the " + inFlightRequests.size() + + " outstanding chunk request(s). " + metricsSnapshot()); + } + failOutstandingRequests(failure); + } + + private void failOutstandingRequests(Throwable failure) { + Map outstanding = inFlightRequests.drain(); + for (Map.Entry entry : outstanding.entrySet()) { + long key = entry.getKey(); + settleChunk(PregenInFlightRequests.chunkX(key), PregenInFlightRequests.chunkZ(key), entry.getValue(), + null, failure, false); + } + admission.wake(); + backpressure.signalProgress(); + } + + private void throwIfGenerationFailed() { + GenerationFailure failure = generationFailure.get(); + if (failure != null) { + throw new IllegalStateException("Pregen aborted: Iris failed to generate chunk " + failure.chunkX() + "," + + failure.chunkZ() + " in world " + world.getName() + + " and the server's chunk system stopped completing requests.", failure.cause()); + } + } + + private void abandonOutstandingRequests(PregenAdmissionGate.Drain drain) { + Map abandoned = inFlightRequests.drain(); + IrisLogging.error("Async pregen abandoned " + abandoned.size() + " chunk request(s) that did not complete within " + + CLOSE_DRAIN_TIMEOUT_SECONDS + "s of closing (" + drain.outstanding() + + " permit(s) still held). They are not cached and regenerate on the next run. " + metricsSnapshot()); + IllegalStateException cause = new IllegalStateException( + "Async chunk request never completed before the pregen closed in world " + world.getName() + "."); + for (Map.Entry entry : abandoned.entrySet()) { + long key = entry.getKey(); + settleChunk(PregenInFlightRequests.chunkX(key), PregenInFlightRequests.chunkZ(key), entry.getValue(), + null, cause, false); + } + } + private void markFinished(boolean success) { if (success) { completed.incrementAndGet(); @@ -840,11 +901,17 @@ public class AsyncPregenMethod implements PregeneratorMethod { // A stop request interrupts the pregen worker; shield the drain and flush so chunks still hit disk. boolean interrupted = Thread.interrupted(); try { - interrupted |= admission.awaitDrain( + PregenAdmissionGate.Drain drain = admission.awaitDrain( CLOSE_DRAIN_WARNING_SECONDS, TimeUnit.SECONDS, + CLOSE_DRAIN_TIMEOUT_SECONDS, + TimeUnit.SECONDS, () -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot()) ); + interrupted |= drain.interrupted(); + if (!drain.drained()) { + abandonOutstandingRequests(drain); + } syncGenerationHistory(); mantleCleanup.close(MANTLE_CLEANUP_DRAIN_SECONDS, TimeUnit.SECONDS); @@ -889,7 +956,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { } private boolean isCancelled() { - return closing.get() || Thread.currentThread().isInterrupted(); + return closing.get() || generationFailure.get() != null || Thread.currentThread().isInterrupted(); } @Override @@ -917,6 +984,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { @Override public void generateChunk(int x, int z, PregenListener listener) { + throwIfGenerationFailed(); listener.onChunkGenerating(x, z); if (MantleHeapPressure.overHighWater()) { reclaimMemory(); @@ -947,10 +1015,15 @@ public class AsyncPregenMethod implements PregeneratorMethod { recordPermitWait(waited.permitMs()); } + throwIfGenerationFailed(); regionPending.computeIfAbsent(rkey(x >> 5, z >> 5), k -> new AtomicInteger(1)).incrementAndGet(); retainAround(x, z); - markSubmitted(); + markSubmitted(x, z, listener); executor.generate(x, z, listener); + GenerationFailure failure = generationFailure.get(); + if (failure != null) { + failOutstandingRequests(failure.cause()); + } } private void reclaimHeapPressure() { @@ -1187,6 +1260,9 @@ public class AsyncPregenMethod implements PregeneratorMethod { } } + private record GenerationFailure(int chunkX, int chunkZ, Throwable cause) { + } + private record ChunkAsyncMethodSelection(Method urgentMethod, Method standardMethod, String mode) { } } diff --git a/core/src/main/java/art/arcane/iris/world/pregen/CachedPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/CachedPregenMethod.java index 783906055..ecd559cd7 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/CachedPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/CachedPregenMethod.java @@ -189,6 +189,11 @@ public class CachedPregenMethod implements PregeneratorMethod { method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); } + @Override + public void onChunkGenerationFailed(int chunkX, int chunkZ, Throwable failure) { + method.onChunkGenerationFailed(chunkX, chunkZ, failure); + } + @Override public void onPregenStart(int centerBlockX, int centerBlockZ) { method.onPregenStart(centerBlockX, centerBlockZ); diff --git a/core/src/main/java/art/arcane/iris/world/pregen/HybridPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/HybridPregenMethod.java index 7e7498bf8..e1855d7f9 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/HybridPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/HybridPregenMethod.java @@ -78,6 +78,11 @@ public class HybridPregenMethod implements PregeneratorMethod { inWorld.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); } + @Override + public void onChunkGenerationFailed(int chunkX, int chunkZ, Throwable failure) { + inWorld.onChunkGenerationFailed(chunkX, chunkZ, failure); + } + @Override public void onPregenStart(int centerBlockX, int centerBlockZ) { inWorld.onPregenStart(centerBlockX, centerBlockZ); diff --git a/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java index 61f45c3e4..1b56eaf85 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java @@ -215,6 +215,10 @@ public class IrisPregenerator { signalState(); } + public void onChunkGenerationFailed(int chunkX, int chunkZ, Throwable failure) { + generator.onChunkGenerationFailed(chunkX, chunkZ, failure); + } + public void start() { PrecisionStopwatch p = PrecisionStopwatch.start(); boolean completed = false; diff --git a/core/src/main/java/art/arcane/iris/world/pregen/PregenAdmissionGate.java b/core/src/main/java/art/arcane/iris/world/pregen/PregenAdmissionGate.java index a4533d99d..0b4be13d2 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/PregenAdmissionGate.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/PregenAdmissionGate.java @@ -111,14 +111,20 @@ public final class PregenAdmissionGate { } } - public boolean awaitDrain(long warningInterval, TimeUnit timeUnit, Runnable onWait) { + public Drain awaitDrain(long warningInterval, TimeUnit warningUnit, long deadline, TimeUnit deadlineUnit, Runnable onWait) { + long warningNanos = warningUnit.toNanos(warningInterval); + long deadlineAt = System.nanoTime() + deadlineUnit.toNanos(deadline); boolean interrupted = false; lock.lock(); try { while (free < permits) { + long remaining = deadlineAt - System.nanoTime(); + if (remaining <= 0L) { + return new Drain(false, interrupted, permits - free); + } boolean signalled = false; try { - signalled = changed.await(warningInterval, timeUnit); + signalled = changed.awaitNanos(Math.min(warningNanos, remaining)) > 0L; } catch (InterruptedException e) { interrupted = true; continue; @@ -127,10 +133,12 @@ public final class PregenAdmissionGate { onWait.run(); } } + return new Drain(true, interrupted, 0); } finally { lock.unlock(); } + } - return interrupted; + public record Drain(boolean drained, boolean interrupted, int outstanding) { } } diff --git a/core/src/main/java/art/arcane/iris/world/pregen/PregenInFlightRequests.java b/core/src/main/java/art/arcane/iris/world/pregen/PregenInFlightRequests.java new file mode 100644 index 000000000..d184cd1a4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/pregen/PregenInFlightRequests.java @@ -0,0 +1,47 @@ +package art.arcane.iris.world.pregen; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +public final class PregenInFlightRequests { + private final ConcurrentHashMap requests = new ConcurrentHashMap<>(); + + public static long key(int chunkX, int chunkZ) { + return ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL); + } + + public static int chunkX(long key) { + return (int) (key >> 32); + } + + public static int chunkZ(long key) { + return (int) key; + } + + public boolean add(long key, PregenListener listener) { + return requests.putIfAbsent(key, Objects.requireNonNull(listener, "listener")) == null; + } + + public PregenListener settle(long key) { + return requests.remove(key); + } + + public Map drain() { + HashMap drained = new HashMap<>(); + Iterator> iterator = requests.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (requests.remove(entry.getKey(), entry.getValue())) { + drained.put(entry.getKey(), entry.getValue()); + } + } + return drained; + } + + public int size() { + return requests.size(); + } +} diff --git a/core/src/main/java/art/arcane/iris/world/pregen/PregeneratorMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/PregeneratorMethod.java index 151ef6a15..ee0749620 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/PregeneratorMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/PregeneratorMethod.java @@ -83,6 +83,9 @@ public interface PregeneratorMethod { default void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { } + default void onChunkGenerationFailed(int chunkX, int chunkZ, Throwable failure) { + } + default void onPregenStart(int centerBlockX, int centerBlockZ) { } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java index 86da599d0..fc6cb8f3f 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java @@ -31,13 +31,14 @@ public class IrisComplexTransitionTest { @Test public void tapersRiverDepthWithoutRaisingItsFluidHead() { - HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(hydrologySample(), 0.5D); + HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(hydrologySample(), 0.8D); HydrologyColumnLayer layer = tapered.layers().getFirst(); - assertEquals(73, layer.bedY()); + assertEquals(69, layer.bedY()); assertEquals(70, layer.fluidHeadY()); assertEquals(70, layer.ceilingY()); - assertEquals(73, tapered.terrainHeight()); + assertTrue(layer.channel()); + assertEquals(69, tapered.terrainHeight()); assertEquals("river", layer.profileKey()); } @@ -61,10 +62,10 @@ public class IrisComplexTransitionTest { HydrologyColumnLayer surface = hydrologySample().layers().getFirst(); HydrologyColumnSample sample = new HydrologyColumnSample(4, 6, 80, 63, false, "parent", List.of(surface, cave)); - HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(sample, 0.5D); + HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(sample, 0.8D); assertSame(cave, tapered.layers().getFirst()); - assertEquals(73, tapered.layers().getLast().bedY()); + assertEquals(69, tapered.layers().getLast().bedY()); assertEquals(70, tapered.layers().getLast().fluidHeadY()); assertEquals(70, tapered.layers().getLast().ceilingY()); } @@ -85,6 +86,62 @@ public class IrisComplexTransitionTest { assertSame(sample, IrisComplex.transitionHydrologySample(sample, 1D, null)); } + @Test + public void taperedDryBankKeepsItsHeadOnItsBed() { + HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(bankSample(364, 366), 0.5D); + HydrologyColumnLayer bank = tapered.layers().getFirst(); + + assertEquals(365, bank.bedY()); + assertEquals(365, bank.fluidHeadY()); + assertEquals(365, bank.ceilingY()); + assertFalse(bank.channel()); + assertTrue(bank.grading()); + assertTrue(bank.terrainOwned()); + assertEquals(365, tapered.terrainHeight()); + } + + @Test + public void transitionedDryBankKeepsItsHeadOnItsBed() { + HydrologyColumnSample sample = bankSample(365, 380); + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); + when(displacement.sample(4, 6)).thenReturn( + new TransitionDisplacementField.Sample(0D, 8D, 0.25D, 0D, 0D, 300D, "minecraft:water")); + when(displacement.fluidHeight(4, 6, 63D)).thenReturn(63D); + when(displacement.fluidHeight(4, 6, 365D)).thenReturn(364D); + + HydrologyColumnSample transitioned = IrisComplex.transitionHydrologySample(sample, 1D, displacement); + HydrologyColumnLayer bank = transitioned.layers().getFirst(); + + assertEquals(365, bank.bedY()); + assertEquals(365, bank.fluidHeadY()); + assertEquals(365, bank.ceilingY()); + assertFalse(bank.channel()); + assertTrue(bank.terrainOwned()); + assertEquals(365, transitioned.terrainHeight()); + } + + @Test + public void driedChannelHeadFollowsItsTaperedBed() { + HydrologyColumnSample sample = hydrologySample(); + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); + when(displacement.sample(4, 6)).thenReturn( + new TransitionDisplacementField.Sample(0D, 8D, 0.25D, 1D, 80D, 80D, "minecraft:water")); + when(displacement.fluidHeight(4, 6, 63D)).thenReturn(63D); + when(displacement.fluidHeight(4, 6, 70D)).thenReturn(70D); + + HydrologyColumnSample transitioned = IrisComplex.transitionHydrologySample(sample, 0.35D, displacement); + HydrologyColumnLayer river = transitioned.layers().getFirst(); + + assertEquals(75, river.bedY()); + assertEquals(75, river.fluidHeadY()); + assertEquals(75, river.ceilingY()); + assertFalse(river.channel()); + assertFalse(river.fluidOwned()); + assertFalse(river.connectedFluid()); + assertTrue(river.terrainOwned()); + assertEquals(75, transitioned.terrainHeight()); + } + @Test public void tinyRiverWeightsExcavateGraduallyUntilReachingUnraisedWater() { HydrologyColumnSample sample = hydrologySample(); @@ -93,13 +150,14 @@ public class IrisComplexTransitionTest { double weight = step / 100D; HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(sample, weight); HydrologyColumnLayer layer = tapered.layers().getFirst(); - assertEquals(70, layer.fluidHeadY()); + assertEquals(layer.bedY() <= 70, layer.channel()); + assertEquals(layer.channel() ? 70 : layer.bedY(), layer.fluidHeadY()); + assertEquals(layer.fluidHeadY(), layer.ceilingY()); assertTrue(layer.bedY() <= previousBed); assertTrue(previousBed - layer.bedY() <= 1); assertEquals(layer.bedY(), tapered.terrainHeight()); - assertEquals(layer.bedY() <= layer.fluidHeadY(), layer.channel()); assertEquals(layer.channel(), layer.fluidOwned()); - if (layer.bedY() > layer.fluidHeadY()) { + if (!layer.channel()) { assertFalse(layer.connectedFluid()); assertFalse(layer.fallingFluid()); assertFalse(layer.receivingPool()); @@ -209,6 +267,15 @@ public class IrisComplexTransitionTest { return hydrologySample(HydrologyFeatureType.SURFACE_POOL); } + private static HydrologyColumnSample bankSample(int bankHeight, int naturalHeight) { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 1L, HydrologyFeatureType.SURFACE_POOL, 2L, 3L, 1080, 346, 204, 0, -1, false); + HydrologyColumnLayer bank = new HydrologyColumnLayer(feature, bankHeight, bankHeight, bankHeight, + false, false, true, false, false, false, true, false, false, + "water", "surface", "mouth", "shore", "bank", "cave"); + return new HydrologyColumnSample(4, 6, naturalHeight, 63, false, "parent", List.of(bank)); + } + private static HydrologyColumnSample hydrologySample(HydrologyFeatureType type) { HydrologyFeatureRef feature = new HydrologyFeatureRef( 1L, diff --git a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenGenerationFailureTest.java b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenGenerationFailureTest.java new file mode 100644 index 000000000..3d3606a4f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenGenerationFailureTest.java @@ -0,0 +1,147 @@ +package art.arcane.iris.world.pregen; + +import org.bukkit.Chunk; +import org.bukkit.World; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class AsyncPregenGenerationFailureTest { + @Test + public void aGenerationFailureFailsEveryOutstandingRequestAndReleasesTheirPermits() throws Exception { + Harness harness = Harness.withOutstanding(2); + IllegalStateException cause = new IllegalStateException("Accepted hydrology layer exceeds the mantle bounds"); + + harness.method.onChunkGenerationFailed(48, -4, cause); + + assertEquals(2, harness.admission.availablePermits()); + assertEquals(2, harness.failed.get()); + assertEquals(0, harness.inFlight.get()); + assertEquals(0, harness.requests.size()); + verify(harness.listener).onChunkFailed(10, 0); + verify(harness.listener).onChunkFailed(11, 0); + + IllegalStateException abort = assertThrows(IllegalStateException.class, + () -> harness.method.generateChunk(12, 0, harness.listener)); + assertSame(cause, abort.getCause()); + verify(harness.listener, never()).onChunkGenerating(12, 0); + } + + @Test + public void aLateCompletionForASettledRequestIsIgnored() throws Exception { + Harness harness = Harness.withOutstanding(2); + harness.method.onChunkGenerationFailed(48, -4, new IllegalStateException("boom")); + + harness.completeChunk(10, 0, null, null); + harness.completeChunk(11, 0, null, new IllegalStateException("late failure")); + + assertEquals(2, harness.failed.get()); + assertEquals(0, harness.completed.get()); + assertEquals(0, harness.inFlight.get()); + assertEquals(2, harness.admission.availablePermits()); + } + + @Test + public void closingAbandonsRequestsThatNeverCompleteAndReturnsTheirPermits() throws Exception { + Harness harness = Harness.withOutstanding(2); + + harness.abandon(new PregenAdmissionGate.Drain(false, false, 2)); + + assertEquals(2, harness.admission.availablePermits()); + assertEquals(2, harness.failed.get()); + assertEquals(0, harness.requests.size()); + verify(harness.listener).onChunkFailed(10, 0); + verify(harness.listener).onChunkFailed(11, 0); + } + + private static final class Harness { + private final AsyncPregenMethod method; + private final PregenAdmissionGate admission; + private final PregenInFlightRequests requests; + private final PregenListener listener; + private final AtomicInteger inFlight; + private final AtomicLong failed; + private final AtomicLong completed; + + private Harness(AsyncPregenMethod method, PregenAdmissionGate admission, PregenInFlightRequests requests, + PregenListener listener, AtomicInteger inFlight, AtomicLong failed, AtomicLong completed) { + this.method = method; + this.admission = admission; + this.requests = requests; + this.listener = listener; + this.inFlight = inFlight; + this.failed = failed; + this.completed = completed; + } + + static Harness withOutstanding(int outstanding) throws Exception { + AsyncPregenMethod method = mock(AsyncPregenMethod.class, CALLS_REAL_METHODS); + World world = mock(World.class); + when(world.getName()).thenReturn("qa"); + PregenAdmissionGate admission = new PregenAdmissionGate(outstanding, 50L, System::currentTimeMillis); + PregenInFlightRequests requests = new PregenInFlightRequests(); + PregenListener listener = mock(PregenListener.class); + AtomicInteger inFlight = new AtomicInteger(outstanding); + AtomicLong failed = new AtomicLong(); + AtomicLong completed = new AtomicLong(); + for (int index = 0; index < outstanding; index++) { + assertNotNull(admission.admit(() -> false, () -> false)); + requests.add(PregenInFlightRequests.key(10 + index, 0), listener); + } + set(method, "world", world); + set(method, "admission", admission); + set(method, "backpressure", mock(PregenMantleBackpressure.class)); + set(method, "mantleCleanup", mock(PregenSerialWorker.class)); + set(method, "inFlightRequests", requests); + set(method, "generationFailure", new AtomicReference<>()); + set(method, "regionPending", new ConcurrentHashMap<>()); + set(method, "lastFailedReleaseLogAt", new AtomicLong()); + set(method, "slowRequestWarnIntervalMs", 15_000); + set(method, "inFlight", inFlight); + set(method, "submitted", new AtomicLong(outstanding)); + set(method, "completed", completed); + set(method, "failed", failed); + set(method, "lastProgressAt", new AtomicLong()); + set(method, "adaptiveInFlightLimit", new AtomicInteger(outstanding)); + set(method, "threads", outstanding); + set(method, "closing", new AtomicBoolean()); + return new Harness(method, admission, requests, listener, inFlight, failed, completed); + } + + void completeChunk(int x, int z, Chunk chunk, Throwable throwable) throws Exception { + Method complete = AsyncPregenMethod.class.getDeclaredMethod( + "completeChunk", int.class, int.class, PregenListener.class, Chunk.class, Throwable.class); + complete.setAccessible(true); + complete.invoke(method, x, z, listener, chunk, throwable); + } + + void abandon(PregenAdmissionGate.Drain drain) throws Exception { + Method abandon = AsyncPregenMethod.class.getDeclaredMethod( + "abandonOutstandingRequests", PregenAdmissionGate.Drain.class); + abandon.setAccessible(true); + abandon.invoke(method, drain); + } + + private static void set(AsyncPregenMethod method, String name, Object value) throws Exception { + Field field = AsyncPregenMethod.class.getDeclaredField(name); + field.setAccessible(true); + field.set(method, value); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenMethodConcurrencyCapTest.java b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenMethodConcurrencyCapTest.java index 6b59263ec..0f26db83c 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenMethodConcurrencyCapTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenMethodConcurrencyCapTest.java @@ -148,9 +148,11 @@ public class AsyncPregenMethodConcurrencyCapTest { AtomicInteger warnings = new AtomicInteger(); ExecutorService executor = Executors.newSingleThreadExecutor(); try { - Future drain = executor.submit(() -> gate.awaitDrain( + Future drain = executor.submit(() -> gate.awaitDrain( 10L, TimeUnit.MILLISECONDS, + 5L, + TimeUnit.SECONDS, warnings::incrementAndGet )); @@ -160,7 +162,9 @@ public class AsyncPregenMethodConcurrencyCapTest { gate.release(); gate.release(); - assertFalse(drain.get(1L, TimeUnit.SECONDS)); + PregenAdmissionGate.Drain result = drain.get(1L, TimeUnit.SECONDS); + assertTrue(result.drained()); + assertFalse(result.interrupted()); assertTrue(warnings.get() > 0); assertEquals(2, gate.availablePermits()); } finally { diff --git a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenServerSaveOwnershipTest.java b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenServerSaveOwnershipTest.java index 52b49754b..446ec3bd3 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenServerSaveOwnershipTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenServerSaveOwnershipTest.java @@ -514,6 +514,8 @@ public class AsyncPregenServerSaveOwnershipTest { set("regionPending", pendingRegions); set("pendingEvictions", evictions); set("chunkIoExecutor", (Executor) Runnable::run); + set("generationFailure", new AtomicReference<>()); + set("inFlightRequests", new PregenInFlightRequests()); when(chunk.getX()).thenReturn(2); when(chunk.getZ()).thenReturn(3); when(binding.saveAndUnloadChunk(world, 2, 3)).thenReturn(true); diff --git a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenWorkerBoostTest.java b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenWorkerBoostTest.java index 4f1f36985..1e704ab49 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenWorkerBoostTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenWorkerBoostTest.java @@ -113,7 +113,8 @@ public class AsyncPregenWorkerBoostTest { AsyncPregenMethod method = mock(AsyncPregenMethod.class, CALLS_REAL_METHODS); PregenAdmissionGate admission = mock(PregenAdmissionGate.class); IllegalStateException failure = new IllegalStateException("drain failed"); - when(admission.awaitDrain(anyLong(), any(TimeUnit.class), any(Runnable.class))).thenThrow(failure); + when(admission.awaitDrain(anyLong(), any(TimeUnit.class), anyLong(), any(TimeUnit.class), any(Runnable.class))) + .thenThrow(failure); set(method, "closing", new AtomicBoolean()); set(method, "holdsWorkerBoost", new AtomicBoolean(true)); set(method, "admission", admission); diff --git a/core/src/test/java/art/arcane/iris/world/pregen/PregenAdmissionGateTest.java b/core/src/test/java/art/arcane/iris/world/pregen/PregenAdmissionGateTest.java index 0bbb62400..69ac7e29b 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/PregenAdmissionGateTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/PregenAdmissionGateTest.java @@ -12,6 +12,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -162,7 +163,8 @@ public class PregenAdmissionGateTest { AtomicInteger warnings = new AtomicInteger(); ExecutorService executor = Executors.newSingleThreadExecutor(); try { - Future drain = executor.submit(() -> gate.awaitDrain(5L, TimeUnit.MILLISECONDS, warnings::incrementAndGet)); + Future drain = executor.submit(() -> gate.awaitDrain( + 5L, TimeUnit.MILLISECONDS, 5L, TimeUnit.SECONDS, warnings::incrementAndGet)); while (warnings.get() < 2) { Thread.onSpinWait(); @@ -170,13 +172,46 @@ public class PregenAdmissionGateTest { gate.release(); gate.release(); - assertEquals(Boolean.FALSE, drain.get(5, TimeUnit.SECONDS)); + PregenAdmissionGate.Drain result = drain.get(5, TimeUnit.SECONDS); + assertTrue(result.drained()); + assertFalse(result.interrupted()); + assertEquals(0, result.outstanding()); assertEquals(2, gate.availablePermits()); } finally { executor.shutdownNow(); } } + @Test + public void drainGivesUpAtItsDeadlineWhilePermitsStayHeld() throws Exception { + PregenAdmissionGate gate = new PregenAdmissionGate(2, GENEROUS_BOUND_MS, System::currentTimeMillis); + assertNotNull(gate.admit(() -> false, () -> false)); + assertNotNull(gate.admit(() -> false, () -> false)); + AtomicInteger warnings = new AtomicInteger(); + + PregenAdmissionGate.Drain result = gate.awaitDrain( + 5L, TimeUnit.MILLISECONDS, 80L, TimeUnit.MILLISECONDS, warnings::incrementAndGet); + + assertFalse(result.drained()); + assertFalse(result.interrupted()); + assertEquals(2, result.outstanding()); + assertTrue(warnings.get() > 0); + assertEquals(0, gate.availablePermits()); + } + + @Test + public void drainReturnsAtOnceWhenNothingIsHeld() { + PregenAdmissionGate gate = new PregenAdmissionGate(2, GENEROUS_BOUND_MS, System::currentTimeMillis); + AtomicInteger warnings = new AtomicInteger(); + + PregenAdmissionGate.Drain result = gate.awaitDrain( + 5L, TimeUnit.MILLISECONDS, 5L, TimeUnit.SECONDS, warnings::incrementAndGet); + + assertTrue(result.drained()); + assertEquals(0, result.outstanding()); + assertEquals(0, warnings.get()); + } + @Test(expected = IllegalArgumentException.class) public void aGateWithoutPermitsIsRejected() { new PregenAdmissionGate(0, GENEROUS_BOUND_MS, System::currentTimeMillis); diff --git a/core/src/test/java/art/arcane/iris/world/pregen/PregenInFlightRequestsTest.java b/core/src/test/java/art/arcane/iris/world/pregen/PregenInFlightRequestsTest.java new file mode 100644 index 000000000..bd83f119c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/pregen/PregenInFlightRequestsTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.world.pregen; + +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class PregenInFlightRequestsTest { + @Test + public void settlingARequestReturnsItsListenerOnlyOnce() { + PregenInFlightRequests requests = new PregenInFlightRequests(); + PregenListener listener = mock(PregenListener.class); + long key = PregenInFlightRequests.key(48, -4); + + assertTrue(requests.add(key, listener)); + assertEquals(1, requests.size()); + assertSame(listener, requests.settle(key)); + assertNull(requests.settle(key)); + assertEquals(0, requests.size()); + } + + @Test + public void aDuplicateSubmissionKeepsTheOriginalRequest() { + PregenInFlightRequests requests = new PregenInFlightRequests(); + PregenListener first = mock(PregenListener.class); + PregenListener second = mock(PregenListener.class); + long key = PregenInFlightRequests.key(1, 2); + + assertTrue(requests.add(key, first)); + assertFalse(requests.add(key, second)); + assertSame(first, requests.settle(key)); + } + + @Test + public void drainingHandsBackEveryOutstandingRequestExactlyOnce() { + PregenInFlightRequests requests = new PregenInFlightRequests(); + PregenListener listener = mock(PregenListener.class); + requests.add(PregenInFlightRequests.key(0, 0), listener); + requests.add(PregenInFlightRequests.key(1, 0), listener); + requests.add(PregenInFlightRequests.key(-7, 9), listener); + + Map drained = requests.drain(); + + assertEquals(3, drained.size()); + assertEquals(0, requests.size()); + assertTrue(requests.drain().isEmpty()); + assertNull(requests.settle(PregenInFlightRequests.key(1, 0))); + } + + @Test + public void keysDistinguishNegativeCoordinates() { + assertTrue(PregenInFlightRequests.key(1, -1) != PregenInFlightRequests.key(-1, 1)); + assertTrue(PregenInFlightRequests.key(0, -1) != PregenInFlightRequests.key(-1, 0)); + assertEquals(PregenInFlightRequests.key(48, -4), PregenInFlightRequests.key(48, -4)); + } +} diff --git a/gradle/native-minecraft-sources.gradle b/gradle/native-minecraft-sources.gradle index ad4e85dd6..4f21047ea 100644 --- a/gradle/native-minecraft-sources.gradle +++ b/gradle/native-minecraft-sources.gradle @@ -37,6 +37,14 @@ tasks.named('processResources').configure { dependsOn(unpackNativeMinecraftSources) } +tasks.named('verifyLoggingPolicy').configure { + dependsOn(unpackNativeMinecraftSources) +} + +tasks.named('verifyNativeClassBoundary').configure { + dependsOn(tasks.named('classes')) +} + tasks.matching { Task task -> task.name == 'createMinecraftArtifacts' }.configureEach { dependsOn(unpackNativeMinecraftSources) }