diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 2a49e1344..1fbe4a574 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -5,9 +5,12 @@ import com.mojang.serialization.MapCodec; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; import net.minecraft.core.Holder; @@ -148,17 +151,26 @@ public class CustomBiomeSource extends BiomeSource { } Set> possibleStructureBiomes() { - ensureCachesCurrent(); - World world = BukkitWorldBinding.world(engine.getWorld()); - if (world == null) { - throw new IllegalStateException("Iris biome source has no bound Bukkit world"); + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_possible_biomes"); + if (lease == null) { + throw new IllegalStateException("Iris possible biome lookup was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isRuntimeAvailable()) { + throw new IllegalStateException("Iris possible biome lookup has no active engine runtime"); + } + ensureCachesCurrent(); + World world = BukkitWorldBinding.world(engine.getWorld()); + if (world == null) { + throw new IllegalStateException("Iris biome source has no bound Bukkit world"); + } + Registry customRegistry = ((RegistryAccess) getFor( + RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())) + .lookup(Registries.BIOME).orElse(null); + Registry worldRegistry = ((CraftWorld) world).getHandle().registryAccess() + .lookup(Registries.BIOME).orElse(null); + return Set.copyOf(getAllBiomes(customRegistry, worldRegistry, engine)); } - Registry customRegistry = ((RegistryAccess) getFor( - RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())) - .lookup(Registries.BIOME).orElse(null); - Registry worldRegistry = ((CraftWorld) world).getHandle().registryAccess() - .lookup(Registries.BIOME).orElse(null); - return Set.copyOf(getAllBiomes(customRegistry, worldRegistry, engine)); } private KMap> fillCustomBiomes(Registry customRegistry, Engine engine, Holder fallback) { @@ -212,11 +224,20 @@ public class CustomBiomeSource extends BiomeSource { } Holder getVanillaSpawnBiome(Holder biome) { - ensureCachesCurrent(); if (biome == null) { return null; } - return vanillaSpawnBiomes.get(biome.value()); + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_spawn_biome"); + if (lease == null) { + throw new IllegalStateException("Iris spawn biome lookup was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isRuntimeAvailable()) { + throw new IllegalStateException("Iris spawn biome lookup has no active engine runtime"); + } + ensureCachesCurrent(); + return vanillaSpawnBiomes.get(biome.value()); + } } private RegistryAccess registry() { @@ -230,51 +251,69 @@ public class CustomBiomeSource extends BiomeSource { @Override public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { - ensureCachesCurrent(); - if (isGuaranteedSurfaceBiome(y)) { - return getSurfaceStructureBiomeHolder(x, z); + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_structure_biome"); + if (lease == null) { + throw new IllegalStateException("Iris structure biome lookup was rejected during an engine transition"); } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isRuntimeAvailable()) { + throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); + } + ensureCachesCurrent(); + if (isGuaranteedSurfaceBiome(y)) { + return getSurfaceStructureBiomeHolder(x, z); + } - long cacheKey = packNoiseKey(x, y, z); - Holder cachedHolder = structureBiomeCache.get(cacheKey); - if (cachedHolder != null) { - return cachedHolder; + long cacheKey = packNoiseKey(x, y, z); + Holder cachedHolder = structureBiomeCache.get(cacheKey); + if (cachedHolder != null) { + return cachedHolder; + } + + Holder resolvedHolder = resolveStructureBiomeHolder(x, y, z); + Holder existingHolder = structureBiomeCache.putIfAbsent(cacheKey, resolvedHolder); + if (existingHolder != null) { + return existingHolder; + } + + if (structureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + structureBiomeCache.clear(); + } + + return resolvedHolder; } - - Holder resolvedHolder = resolveStructureBiomeHolder(x, y, z); - Holder existingHolder = structureBiomeCache.putIfAbsent(cacheKey, resolvedHolder); - if (existingHolder != null) { - return existingHolder; - } - - if (structureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { - structureBiomeCache.clear(); - } - - return resolvedHolder; } @Override public Set> getBiomesWithin(int x, int y, int z, int radius, Climate.Sampler sampler) { - ensureCachesCurrent(); - int minQuartY = QuartPos.fromBlock(y - radius); - boolean monumentQuery = radius == 29 - && y == engine.getMinHeight() + engine.getDimension().getFluidHeight(); - if (!monumentQuery && !isGuaranteedSurfaceBiome(minQuartY)) { - return super.getBiomesWithin(x, y, z, radius, sampler); + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_biomes_within"); + if (lease == null) { + throw new IllegalStateException("Iris biome radius lookup was rejected during an engine transition"); } - int minQuartX = QuartPos.fromBlock(x - radius); - int maxQuartX = QuartPos.fromBlock(x + radius); - int minQuartZ = QuartPos.fromBlock(z - radius); - int maxQuartZ = QuartPos.fromBlock(z + radius); - int columns = (maxQuartX - minQuartX + 1) * (maxQuartZ - minQuartZ + 1); - Set> biomes = new HashSet<>(columns); - for (int quartZ = minQuartZ; quartZ <= maxQuartZ; quartZ++) { - for (int quartX = minQuartX; quartX <= maxQuartX; quartX++) { - biomes.add(getSurfaceStructureBiomeHolder(quartX, quartZ)); + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isRuntimeAvailable()) { + throw new IllegalStateException("Iris biome radius lookup has no active engine runtime"); } + ensureCachesCurrent(); + int minQuartY = QuartPos.fromBlock(y - radius); + boolean monumentQuery = radius == 29 + && y == engine.getMinHeight() + engine.getDimension().getFluidHeight(); + if (!monumentQuery && !isGuaranteedSurfaceBiome(minQuartY)) { + return super.getBiomesWithin(x, y, z, radius, sampler); + } + int minQuartX = QuartPos.fromBlock(x - radius); + int maxQuartX = QuartPos.fromBlock(x + radius); + int minQuartZ = QuartPos.fromBlock(z - radius); + int maxQuartZ = QuartPos.fromBlock(z + radius); + int columns = (maxQuartX - minQuartX + 1) * (maxQuartZ - minQuartZ + 1); + Set> biomes = new HashSet<>(columns); + for (int quartZ = minQuartZ; quartZ <= maxQuartZ; quartZ++) { + for (int quartX = minQuartX; quartX <= maxQuartX; quartX++) { + biomes.add(getSurfaceStructureBiomeHolder(quartX, quartZ)); + } + } + return biomes; } - return biomes; } private Holder getSurfaceStructureBiomeHolder(int x, int z) { @@ -322,24 +361,52 @@ public class CustomBiomeSource extends BiomeSource { } public Holder getVisibleNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { - ensureCachesCurrent(); - long cacheKey = packNoiseKey(x, y, z); - Holder cachedHolder = noiseBiomeCache.get(cacheKey); - if (cachedHolder != null) { - return cachedHolder; + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_visible_biome"); + if (lease == null) { + throw new IllegalStateException("Iris visible biome lookup was rejected during an engine transition"); } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isRuntimeAvailable()) { + throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); + } + ensureCachesCurrent(); + long cacheKey = packNoiseKey(x, y, z); + Holder cachedHolder = noiseBiomeCache.get(cacheKey); + if (cachedHolder != null) { + return cachedHolder; + } - Holder resolvedHolder = resolveVisibleBiomeHolder(x, y, z); - Holder existingHolder = noiseBiomeCache.putIfAbsent(cacheKey, resolvedHolder); - if (existingHolder != null) { - return existingHolder; + Holder resolvedHolder = resolveVisibleBiomeHolder(x, y, z); + Holder existingHolder = noiseBiomeCache.putIfAbsent(cacheKey, resolvedHolder); + if (existingHolder != null) { + return existingHolder; + } + + if (noiseBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + noiseBiomeCache.clear(); + } + + return resolvedHolder; } + } - if (noiseBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { - noiseBiomeCache.clear(); + private GenerationSessionLease tryAcquireGenerationLease(String operation) { + if (engine.isClosed()) { + return null; } + try { + return engine.acquireGenerationLease(operation); + } catch (GenerationSessionException e) { + if (engine.isClosing() || e.isExpectedTeardown()) { + return null; + } + throw new IllegalStateException("Iris biome source could not acquire generation session for " + + operation + ".", e); + } + } - return resolvedHolder; + private boolean isRuntimeAvailable() { + return !engine.isClosed() && engine.getComplex() != null; } private void ensureCachesCurrent() { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 7ff5cfde2..cd2bfddd8 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -1,6 +1,8 @@ package art.arcane.iris.core.nms.v26_2_R1; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.object.IrisDimension; @@ -14,6 +16,7 @@ import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.reflect.WrappedField; import art.arcane.iris.util.common.reflect.WrappedReturningMethod; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.math.RNG; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.MapCodec; @@ -90,6 +93,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private final CustomBiomeSource customBiomeSource; private final int runtimeMinY; private final int runtimeHeight; + private final int runtimeSeaLevel; private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); private volatile ReachableStructureCache reachableStructureCache; private volatile StructureStepCache structureStepCache; @@ -106,17 +110,21 @@ public class IrisChunkGenerator extends CustomChunkGenerator { ServerLevel level = ((CraftWorld) world).getHandle(); this.runtimeMinY = level.getMinY(); this.runtimeHeight = level.getHeight(); + this.runtimeSeaLevel = runtimeMinY + engine.getDimension().getFluidHeight(); } @Override public @Nullable Pair> findNearestMapStructure(ServerLevel level, HolderSet holders, BlockPos pos, int radius, boolean findUnexplored) { - Pair> irisPlaced = findNearestIrisStructure( - level, holders, pos, Math.max(1, radius), findUnexplored); - HolderSet reachable = filterReachableStructures(level, holders); - Pair> nativeLocated = reachable == null || reachable.size() == 0 - ? null - : delegate.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); - return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); + try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + Pair> irisPlaced = findNearestIrisStructure( + level, holders, pos, Math.max(1, radius), findUnexplored); + HolderSet reachable = filterReachableStructures(level, holders); + Pair> nativeLocated = reachable == null || reachable.size() == 0 + ? null + : delegate.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); + return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); + } } private Pair> findNearestIrisStructure(ServerLevel level, @@ -231,14 +239,17 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public int getSeaLevel() { - return runtimeMinY + engine.getDimension().getFluidHeight(); + return runtimeSeaLevel; } @Override public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess access, StructureTemplateManager templateManager, ResourceKey levelKey) { - Map previousStarts = new HashMap<>(access.getAllStarts()); - super.createStructures(registryAccess, structureState, structureManager, access, templateManager, levelKey); - adjustGeneratedStructures(registryAccess, access, previousStarts); + try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_structures"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + Map previousStarts = new HashMap<>(access.getAllStarts()); + super.createStructures(registryAccess, structureState, structureManager, access, templateManager, levelKey); + adjustGeneratedStructures(registryAccess, access, previousStarts); + } } private void adjustGeneratedStructures(RegistryAccess registryAccess, ChunkAccess access, Map previousStarts) { @@ -298,8 +309,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public CompletableFuture createBiomes(RandomState randomstate, Blender blender, StructureManager structuremanager, ChunkAccess ichunkaccess) { - ichunkaccess.fillBiomesFromNoise(customBiomeSource::getVisibleNoiseBiome, randomstate.sampler()); - return CompletableFuture.completedFuture(ichunkaccess); + try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_biomes"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + ichunkaccess.fillBiomesFromNoise(customBiomeSource::getVisibleNoiseBiome, randomstate.sampler()); + return CompletableFuture.completedFuture(ichunkaccess); + } } @Override @@ -355,9 +369,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla) { - addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager); - placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); - delegate.applyBiomeDecoration(generatoraccessseed, ichunkaccess, structuremanager, false); + try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager); + placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); + delegate.applyBiomeDecoration(generatoraccessseed, ichunkaccess, structuremanager, false); + } } private void placeVanillaStructures(WorldGenLevel world, ChunkAccess chunk, StructureManager structureManager) { @@ -525,29 +542,35 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void addVanillaDecorations(WorldGenLevel level, ChunkAccess chunkAccess, StructureManager structureManager) { - SectionPos sectionPos = SectionPos.of(chunkAccess.getPos(), level.getMinSectionY()); - BlockPos blockPos = sectionPos.origin(); + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_heightmaps"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + SectionPos sectionPos = SectionPos.of(chunkAccess.getPos(), level.getMinSectionY()); + BlockPos blockPos = sectionPos.origin(); - Heightmap surface = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); - Heightmap ocean = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); - Heightmap motion = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.MOTION_BLOCKING); - Heightmap motionNoLeaves = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES); + Heightmap surface = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); + Heightmap ocean = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); + Heightmap motion = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.MOTION_BLOCKING); + Heightmap motionNoLeaves = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES); + int minHeight = engine.getMinHeight(); - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - int wX = x + blockPos.getX(); - int wZ = z + blockPos.getZ(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int wX = x + blockPos.getX(); + int wZ = z + blockPos.getZ(); - int terrainTop = engine.getHeight(wX, wZ, false) + engine.getMinHeight() + 1; - int terrainNoFluid = engine.getHeight(wX, wZ, true) + engine.getMinHeight() + 1; - SET_HEIGHT.invoke(ocean, x, z, terrainNoFluid); - SET_HEIGHT.invoke(surface, x, z, terrainTop); - SET_HEIGHT.invoke(motion, x, z, terrainTop); - SET_HEIGHT.invoke(motionNoLeaves, x, z, terrainTop); + int terrainTop = engine.getHeight(wX, wZ, false) + minHeight + 1; + int terrainNoFluid = engine.getHeight(wX, wZ, true) + minHeight + 1; + SET_HEIGHT.invoke(ocean, x, z, terrainNoFluid); + SET_HEIGHT.invoke(surface, x, z, terrainTop); + SET_HEIGHT.invoke(motion, x, z, terrainTop); + SET_HEIGHT.invoke(motionNoLeaves, x, z, terrainTop); + } } - } - Heightmap.primeHeightmaps(chunkAccess, ChunkStatus.FINAL_HEIGHTMAPS); + Heightmap.primeHeightmaps(chunkAccess, ChunkStatus.FINAL_HEIGHTMAPS); + } catch (GenerationSessionException e) { + throw new IllegalStateException("Iris heightmap generation could not acquire its engine runtime.", e); + } } @Override @@ -585,20 +608,44 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public int getBaseHeight(int i, int j, Heightmap.Types heightmap_type, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { - return levelheightaccessor.getMinY() + engine.getHeight(i, j, !heightmap_type.isOpaque().test(Blocks.WATER.defaultBlockState())) + 1; + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_height"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + return levelheightaccessor.getMinY() + + engine.getHeight(i, j, !heightmap_type.isOpaque().test(Blocks.WATER.defaultBlockState())) + + 1; + } catch (GenerationSessionException e) { + throw new IllegalStateException("Iris base height query could not acquire its engine runtime.", e); + } } @Override public NoiseColumn getBaseColumn(int i, int j, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { - int block = engine.getHeight(i, j, true); - int water = engine.getHeight(i, j, false); - BlockState[] column = new BlockState[levelheightaccessor.getHeight()]; - for (int k = 0; k < column.length; k++) { - if (k <= block) column[k] = Blocks.STONE.defaultBlockState(); - else if (k <= water) column[k] = Blocks.WATER.defaultBlockState(); - else column[k] = Blocks.AIR.defaultBlockState(); + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_column"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + int block = engine.getHeight(i, j, true); + int water = engine.getHeight(i, j, false); + BlockState[] column = new BlockState[levelheightaccessor.getHeight()]; + for (int k = 0; k < column.length; k++) { + if (k <= block) { + column[k] = Blocks.STONE.defaultBlockState(); + } else if (k <= water) { + column[k] = Blocks.WATER.defaultBlockState(); + } else { + column[k] = Blocks.AIR.defaultBlockState(); + } + } + return new NoiseColumn(levelheightaccessor.getMinY(), column); + } catch (GenerationSessionException e) { + throw new IllegalStateException("Iris base column query could not acquire its engine runtime.", e); + } + } + + private GenerationSessionLease requireGenerationLease(String operation) { + try { + return engine.acquireGenerationLease(operation); + } catch (GenerationSessionException exception) { + throw new IllegalStateException("Iris " + operation + " could not acquire its engine runtime.", exception); } - return new NoiseColumn(levelheightaccessor.getMinY(), column); } @Override diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java index dccc475e7..7f34dd8c6 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java @@ -19,4 +19,21 @@ public class CustomBiomeSourceStructureContractTest { assertTrue(source.contains("resolution.irisBiome.getStructureDerivativeKey()")); assertFalse(source.contains("resolution.irisBiome.getVanillaDerivative()")); } + + @Test + public void biomeRuntimeReadsAreGenerationLeased() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + + assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_possible_biomes\")")); + assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_spawn_biome\")")); + assertTrue(source.contains("Iris spawn biome lookup was rejected during an engine transition")); + assertTrue(source.contains("Iris spawn biome lookup has no active engine runtime")); + assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_structure_biome\")")); + assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_biomes_within\")")); + assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_visible_biome\")")); + assertTrue(source.contains("if (engine.isClosed())")); + assertTrue(source.contains("engine.isClosing() || e.isExpectedTeardown()")); + assertTrue(source.contains("catch (GenerationSessionException e)")); + assertTrue(source.contains("e.isExpectedTeardown()")); + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java index 27d7d8b88..24888a18c 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -54,4 +54,14 @@ public class IrisChunkGeneratorFailureContractTest { < placement.indexOf("for (NativePlacementGroup group")); assertFalse(placement.contains("IrisLogging.reportError")); } + + @Test + public void heightmapRuntimeReadsAreGenerationLeased() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + + assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_heightmaps\")")); + assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_base_height\")")); + assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_base_column\")")); + assertTrue(source.contains("catch (GenerationSessionException e)")); + } } diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index 253a74cfa..9f327c664 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -46,4 +46,8 @@ tasks.named('jar', Jar).configure { tasks.named('test').configure { systemProperty('iris.commandFindSource', file('src/main/java/art/arcane/iris/core/commands/CommandFind.java').absolutePath) systemProperty('iris.commandStructureSource', file('src/main/java/art/arcane/iris/core/commands/CommandStructure.java').absolutePath) + systemProperty('iris.bukkitChunkGeneratorSource', rootProject.file('core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java').absolutePath) + systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath) + systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath) + systemProperty('iris.engineSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java').absolutePath) } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 5c78a40fe..a59240a61 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -55,6 +55,7 @@ import art.arcane.iris.engine.EnginePanic; import art.arcane.iris.engine.framework.BlockEditAccess; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.object.IrisCompat; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; @@ -634,11 +635,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisServices.register(EngineComponentCleanup.class, (EngineComponentCleanup) BukkitPlatform::unregisterListener); IrisServices.register(EngineEffectsProvider.class, (EngineEffectsProvider) IrisEngineEffects::new); IrisServices.register(EnginePlatformHooks.class, new BukkitEnginePlatformHooks()); - IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> { - IrisWorldManager manager = new IrisWorldManager(engine); - manager.startManager(); - return manager; - }); + IrisServices.register(EngineWorldManagerProvider.class, + (EngineWorldManagerProvider) IrisWorldManager::new); IrisServices.register(art.arcane.iris.core.runtime.WorldDeletionQueue.class, (art.arcane.iris.core.runtime.WorldDeletionQueue) Iris::queueWorldDeletionOnStartup); settingsFile = getDataFile("settings.json"); configHotloadEngine = new ConfigHotloadEngine( @@ -673,6 +671,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } IrisToolbelt.retainMantleDataForSlice(String.class.getCanonicalName()); IrisToolbelt.retainMantleDataForSlice(BlockData.class.getCanonicalName()); + IrisToolbelt.retainMantleDataForSlice(TreeBlockMaterial.class.getCanonicalName()); }); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/IrisTreeFellerService.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/IrisTreeFellerService.java new file mode 100644 index 000000000..944062bf1 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/IrisTreeFellerService.java @@ -0,0 +1,12 @@ +package art.arcane.iris.api.tree; + +import org.bukkit.block.Block; +import org.bukkit.event.block.BlockBreakEvent; + +public interface IrisTreeFellerService { + boolean tryFell(BlockBreakEvent event, TreeFellerOptions options); + + boolean isManagedBreak(BlockBreakEvent event); + + boolean isTreeBlock(Block block); +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerAccess.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerAccess.java new file mode 100644 index 000000000..17631b89d --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerAccess.java @@ -0,0 +1,6 @@ +package art.arcane.iris.api.tree; + +public enum TreeFellerAccess { + STANDALONE, + INTEGRATION_OVERRIDE +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerOptions.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerOptions.java new file mode 100644 index 000000000..4a7191764 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerOptions.java @@ -0,0 +1,32 @@ +package art.arcane.iris.api.tree; + +import java.util.Objects; + +public record TreeFellerOptions( + TreeFellerAccess access, + int durabilityPreservationChance, + TreeFellerRunHooks runHooks +) { + public TreeFellerOptions { + Objects.requireNonNull(access, "access"); + Objects.requireNonNull(runHooks, "runHooks"); + if (durabilityPreservationChance < 0 || durabilityPreservationChance > 100) { + throw new IllegalArgumentException("durabilityPreservationChance must be between 0 and 100"); + } + } + + public static TreeFellerOptions standalone() { + return new TreeFellerOptions(TreeFellerAccess.STANDALONE, 0, TreeFellerRunHooks.NONE); + } + + public static TreeFellerOptions integrationOverride( + int durabilityPreservationChance, + TreeFellerRunHooks runHooks + ) { + return new TreeFellerOptions( + TreeFellerAccess.INTEGRATION_OVERRIDE, + durabilityPreservationChance, + runHooks + ); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerRunHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerRunHooks.java new file mode 100644 index 000000000..ba4ee17ee --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerRunHooks.java @@ -0,0 +1,30 @@ +package art.arcane.iris.api.tree; + +public interface TreeFellerRunHooks { + TreeFellerRunHooks NONE = new TreeFellerRunHooks() { + @Override + public void onActivationAccepted() { + } + + @Override + public boolean reserveLogCost() { + return true; + } + + @Override + public void commitLogCost() { + } + + @Override + public void refundLogCost() { + } + }; + + void onActivationAccepted(); + + boolean reserveLogCost(); + + void commitLogCost(); + + void refundLogCost(); +} 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 335da8336..9f5b2de2e 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 @@ -80,7 +80,10 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { @Override public void shutdownPregenerator(Engine engine) { - PregeneratorJob.shutdownInstance(); + IrisWorld world = engine.getWorld(); + if (world != null) { + PregeneratorJob.shutdownInstanceForWorld(world.identity()); + } } @Override diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java index a9ed4b7e5..42cae3ca6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java @@ -1,184 +1,158 @@ package art.arcane.iris.core.service; -import art.arcane.iris.platform.bukkit.BukkitWorldBinding; -import com.google.common.util.concurrent.AtomicDouble; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.ResourceLoader; -import art.arcane.iris.core.runtime.GoldenHashScanner; +import art.arcane.iris.core.pregenerator.MantleHeapPressure; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTelemetrySnapshot; +import art.arcane.iris.engine.framework.MeteredCache; import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import art.arcane.volmlib.util.bukkit.WorldIdentity; -import art.arcane.volmlib.util.collection.KMap; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.common.format.C; -import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.iris.util.common.plugin.VolmitSender; -import art.arcane.volmlib.util.scheduling.Looper; +import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D; import art.arcane.iris.util.project.stream.utility.CachedStream2D; import art.arcane.iris.util.project.stream.utility.CachedStream3D; -import art.arcane.iris.core.gui.PregeneratorJob; -import art.arcane.iris.core.pregenerator.MantleHeapPressure; -import lombok.Synchronized; +import art.arcane.volmlib.util.bukkit.WorldIdentity; +import art.arcane.volmlib.util.format.Form; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.WorldUnloadEvent; -import org.jetbrains.annotations.Nullable; -import java.util.Locale; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; -public class IrisEngineSVC implements IrisService { - private static final int TRIM_PERIOD = 2_000; - private final AtomicInteger tectonicLimit = new AtomicInteger(30); - private final AtomicInteger tectonicPlates = new AtomicInteger(); - private final AtomicInteger queuedTectonicPlates = new AtomicInteger(); - private final AtomicInteger trimmerAlive = new AtomicInteger(); - private final AtomicInteger unloaderAlive = new AtomicInteger(); - private final AtomicInteger totalWorlds = new AtomicInteger(); - private final AtomicDouble maxIdleDuration = new AtomicDouble(); - private final AtomicDouble minIdleDuration = new AtomicDouble(); - private final AtomicLong loadedChunks = new AtomicLong(); - private final KMap worlds = new KMap<>(); - private ScheduledExecutorService service; - private Looper updateTicker; +public final class IrisEngineSVC implements IrisService { + private static final long MAINTENANCE_PERIOD_MILLIS = 2_000L; + private static final long METRICS_PERIOD_MILLIS = 1_000L; + private static final long SHUTDOWN_TIMEOUT_SECONDS = 30L; + + private final Object registrationLock = new Object(); + private final Set closingGenerators = new HashSet<>(); + private final AtomicReference metrics = + new AtomicReference<>(IrisEngineStatus.MaintenanceMetrics.EMPTY); + private final AtomicReference telemetry = + new AtomicReference<>(IrisTelemetrySnapshot.EMPTY); + private final Map> pendingRegistrations = new HashMap<>(); + private final Map worlds = new ConcurrentHashMap<>(); + private volatile ScheduledThreadPoolExecutor service; + private volatile ScheduledFuture metricsTask; @Override public void onEnable() { - var settings = IrisSettings.get().getPerformance(); - var engine = settings.getEngineSVC(); - service = Executors.newScheduledThreadPool(0, - (engine.isUseVirtualThreads() - ? Thread.ofVirtual() - : Thread.ofPlatform().priority(engine.getPriority())) - .name("Iris EngineSVC-", 0) - .factory()); - tectonicLimit.set(settings.getTectonicPlateSize()); - Bukkit.getWorlds().forEach(this::add); - setup(); + ScheduledThreadPoolExecutor current = service; + if (current != null && !current.isShutdown()) { + return; + } + + IrisSettings.IrisSettingsEngineSVC settings = IrisSettings.get().getPerformance().getEngineSVC(); + ThreadFactory threadFactory = (settings.isUseVirtualThreads() + ? Thread.ofVirtual() + : Thread.ofPlatform().priority(settings.getPriority())) + .name("Iris EngineSVC-", 0) + .factory(); + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor( + EngineMaintenance.workerParallelism(), threadFactory); + executor.setRemoveOnCancelPolicy(true); + executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); + executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + service = executor; + + for (World world : Bukkit.getWorlds()) { + add(world); + } + metricsTask = executor.scheduleWithFixedDelay( + this::updateMetricsSafely, + 0L, + METRICS_PERIOD_MILLIS, + TimeUnit.MILLISECONDS); } @Override public void onDisable() { - for (World world : worlds.keySet()) { - PlatformChunkGenerator gen = IrisToolbelt.access(world); - if (gen == null) continue; - try { - gen.close(); - } catch (Throwable t) { - IrisLogging.reportError(t); + ScheduledThreadPoolExecutor activeService = service; + service = null; + + ScheduledFuture activeMetricsTask = metricsTask; + metricsTask = null; + if (activeMetricsTask != null) { + activeMetricsTask.cancel(false); + } + + List registeredWorlds; + List reservedCloses = new ArrayList<>(); + List> generatorCloses; + synchronized (registrationLock) { + registeredWorlds = List.copyOf(worlds.values()); + worlds.clear(); + pendingRegistrations.clear(); + for (Registered registered : registeredWorlds) { + registered.close(); + reservedCloses.add(reserveClose(registered)); + } + generatorCloses = new ArrayList<>(closingGenerators.size()); + for (ClosingGenerator closing : closingGenerators) { + generatorCloses.add(closing.completion()); } } - if (service != null) { - service.shutdown(); + + shutdownAndDrain(activeService); + for (int index = 0; index < registeredWorlds.size(); index++) { + startClose(registeredWorlds.get(index), reservedCloses.get(index)); } - if (updateTicker != null) { - updateTicker.interrupt(); - } - worlds.keySet().forEach(this::remove); - worlds.clear(); - } - - public int getQueuedTectonicPlateCount() { - return queuedTectonicPlates.get(); - } - - public double getAverageIdleDuration() { - double min = minIdleDuration.get(); - double max = maxIdleDuration.get(); - if (!Double.isFinite(min) || !Double.isFinite(max) || min < 0D || max < 0D) { - return 0D; - } - - if (max < min) { - return max; - } - - return (min + max) / 2D; - } - - public double getBiomeCacheUsageRatio() { - PreservationSVC preservation = IrisServices.get(PreservationSVC.class); - if (preservation == null) { - return 0D; - } - - double total = 0D; - int count = 0; - for (var cache : preservation.getCaches()) { - if (!(cache instanceof CachedStream2D) && !(cache instanceof CachedDoubleStream2D)) { - continue; - } - - double usage = cache.getUsage(); - if (!Double.isFinite(usage)) { - continue; - } - - total += Math.max(0D, Math.min(1D, usage)); - count++; - } - - if (count <= 0) { - return 0D; - } - - return total / count; + awaitGeneratorShutdown(generatorCloses); + resetMetrics(); } public void engineStatus(VolmitSender sender) { - long[] sizes = new long[4]; - long[] count = new long[4]; + ScheduledThreadPoolExecutor activeService = service; + ScheduledFuture activeMetricsTask = metricsTask; + boolean serviceRunning = activeService != null && !activeService.isShutdown(); + boolean metricsRunning = activeMetricsTask != null + && !activeMetricsTask.isDone() + && !activeMetricsTask.isCancelled(); - for (var cache : IrisServices.get(PreservationSVC.class).getCaches()) { - var type = switch (cache) { - case ResourceLoader ignored -> 0; - case CachedStream2D ignored -> 1; - case CachedDoubleStream2D ignored -> 1; - case CachedStream3D ignored -> 2; - default -> 3; - }; - - sizes[type] += cache.getSize(); - count[type]++; - } - - sender.sendMessage(C.DARK_PURPLE + "-------------------------"); - sender.sendMessage(C.DARK_PURPLE + "Status:"); - sender.sendMessage(C.DARK_PURPLE + "- Service: " + C.LIGHT_PURPLE + (service.isShutdown() ? "Shutdown" : "Running")); - sender.sendMessage(C.DARK_PURPLE + "- Updater: " + C.LIGHT_PURPLE + (updateTicker.isAlive() ? "Running" : "Stopped")); - sender.sendMessage(C.DARK_PURPLE + "- Period: " + C.LIGHT_PURPLE + Form.duration(TRIM_PERIOD)); - sender.sendMessage(C.DARK_PURPLE + "- Trimmers: " + C.LIGHT_PURPLE + trimmerAlive.get()); - sender.sendMessage(C.DARK_PURPLE + "- Unloaders: " + C.LIGHT_PURPLE + unloaderAlive.get()); - sender.sendMessage(C.DARK_PURPLE + "Tectonic Plates:"); - sender.sendMessage(C.DARK_PURPLE + "- Limit: " + C.LIGHT_PURPLE + tectonicLimit.get()); - sender.sendMessage(C.DARK_PURPLE + "- Total: " + C.LIGHT_PURPLE + tectonicPlates.get()); - sender.sendMessage(C.DARK_PURPLE + "- Queued: " + C.LIGHT_PURPLE + queuedTectonicPlates.get()); - sender.sendMessage(C.DARK_PURPLE + "- Max Idle Duration: " + C.LIGHT_PURPLE + Form.duration(maxIdleDuration.get(), 2)); - sender.sendMessage(C.DARK_PURPLE + "- Min Idle Duration: " + C.LIGHT_PURPLE + Form.duration(minIdleDuration.get(), 2)); - sender.sendMessage(C.DARK_PURPLE + "Caches:"); - sender.sendMessage(C.DARK_PURPLE + "- Resource: " + C.LIGHT_PURPLE + sizes[0] + " (" + count[0] + ")"); - sender.sendMessage(C.DARK_PURPLE + "- 2D Stream: " + C.LIGHT_PURPLE + sizes[1] + " (" + count[1] + ")"); - sender.sendMessage(C.DARK_PURPLE + "- 3D Stream: " + C.LIGHT_PURPLE + sizes[2] + " (" + count[2] + ")"); - sender.sendMessage(C.DARK_PURPLE + "- Other: " + C.LIGHT_PURPLE + sizes[3] + " (" + count[3] + ")"); - sender.sendMessage(C.DARK_PURPLE + "Other:"); - sender.sendMessage(C.DARK_PURPLE + "- Iris Worlds: " + C.LIGHT_PURPLE + totalWorlds.get()); - sender.sendMessage(C.DARK_PURPLE + "- Loaded Chunks: " + C.LIGHT_PURPLE + loadedChunks.get()); - sender.sendMessage(C.DARK_PURPLE + "-------------------------"); + IrisEngineStatus.send(sender, new IrisEngineStatus.Snapshot( + serviceRunning, + metricsRunning, + MAINTENANCE_PERIOD_MILLIS, + serviceRunning ? activeService.getCorePoolSize() : 0, + TimeUnit.SECONDS.toMillis(IrisSettings.get().getPerformance().getMantleKeepAlive()), + MantleHeapPressure.usedFraction(), + metrics.get())); } - @EventHandler + IrisTelemetrySnapshot telemetrySnapshot() { + return telemetry.get(); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onWorldUnload(WorldUnloadEvent event) { remove(event.getWorld()); } @@ -188,268 +162,539 @@ public class IrisEngineSVC implements IrisService { add(event.getWorld()); } - private void remove(World world) { - var entry = worlds.remove(world); - if (entry == null) return; - entry.close(); - } - private void add(World world) { - if (world == null || worlds.containsKey(world)) return; - PlatformChunkGenerator access = IrisToolbelt.access(world); - if (access == null) return; - worlds.put(world, new Registered(world.getName(), access)); - } - - private synchronized void setup() { - if (updateTicker != null && updateTicker.isAlive()) + ScheduledThreadPoolExecutor activeService = service; + if (world == null || activeService == null || activeService.isShutdown() || !isCurrentWorld(world)) { return; - - updateTicker = new Looper() { - @Override - protected long loop() { - try { - for (World world : Bukkit.getWorlds()) { - add(world); - } - - int queuedPlates = 0; - int totalPlates = 0; - long chunks = 0; - int unloaders = 0; - int trimmers = 0; - int iris = 0; - - double maxDuration = Long.MIN_VALUE; - double minDuration = Long.MAX_VALUE; - for (var entry : worlds.entrySet()) { - var registered = entry.getValue(); - if (registered.closed) continue; - - iris++; - if (registered.unloaderAlive()) unloaders++; - if (registered.trimmerAlive()) trimmers++; - - var engine = registered.getEngine(); - if (engine == null) continue; - - queuedPlates += engine.getMantle().getUnloadRegionCount(); - totalPlates += engine.getMantle().getLoadedRegionCount(); - chunks += entry.getKey().getLoadedChunks().length; - - double duration = engine.getMantle().getAdjustedIdleDuration(); - if (duration > maxDuration) maxDuration = duration; - if (duration < minDuration) minDuration = duration; - } - - trimmerAlive.set(trimmers); - unloaderAlive.set(unloaders); - tectonicPlates.set(totalPlates); - queuedTectonicPlates.set(queuedPlates); - maxIdleDuration.set(maxDuration); - minIdleDuration.set(minDuration); - loadedChunks.set(chunks); - totalWorlds.set(iris); - - worlds.values().forEach(Registered::update); - } catch (Throwable e) { - e.printStackTrace(); - } - return 1000; - } - }; - updateTicker.start(); - } - - private static boolean isMantleClosed(Throwable throwable) { - Throwable current = throwable; - while (current != null) { - String message = current.getMessage(); - if (message != null && message.toLowerCase(Locale.ROOT).contains("mantle is closed")) { - return true; - } - - current = current.getCause(); } + PlatformChunkGenerator access = IrisToolbelt.access(world); + if (access == null) { + return; + } + + RegistrationIdentity registrationIdentity = RegistrationIdentity.from(world); + Registered replaced = null; + ClosingGenerator replacementClose = null; + CompletableFuture retryAfter = null; + try { + synchronized (registrationLock) { + if (service != activeService || activeService.isShutdown() || !isCurrentWorld(world)) { + return; + } + + Registered current = worlds.get(world); + if (current != null && !current.isClosed() && current.uses(access)) { + pendingRegistrations.remove(world); + return; + } + if (current != null) { + worlds.remove(world, current); + current.close(); + replaced = current; + replacementClose = reserveClose(current); + retryAfter = replacementClose.completion(); + } else { + ClosingGenerator closing = findClosingGenerator(registrationIdentity); + if (closing != null) { + retryAfter = closing.completion(); + } else if (!access.isClosing() && !hasRegisteredConflict(registrationIdentity)) { + Registration registration = new Registration(world.getName(), access, registrationIdentity); + worlds.put(world, new Registered(registration, activeService)); + pendingRegistrations.remove(world); + } + } + } + } catch (RejectedExecutionException exception) { + if (!activeService.isShutdown()) { + reportFailure("Failed to register engine maintenance for " + world.getName(), exception); + } + } + + if (replacementClose != null) { + retryRegistrationAfterClose(world, retryAfter); + startClose(replaced, replacementClose); + return; + } + if (retryAfter != null) { + retryRegistrationAfterClose(world, retryAfter); + } + } + + private void remove(World world) { + if (world == null) { + return; + } + + Registered registered; + ClosingGenerator closing = null; + synchronized (registrationLock) { + pendingRegistrations.remove(world); + registered = worlds.remove(world); + if (registered != null) { + registered.close(); + closing = reserveClose(registered); + } + } + if (closing != null) { + startClose(registered, closing); + } + } + + private ClosingGenerator reserveClose(Registered registered) { + ClosingGenerator closing = new ClosingGenerator( + registered.registrationIdentity(), + new CompletableFuture<>()); + closingGenerators.add(closing); + return closing; + } + + private void startClose(Registered registered, ClosingGenerator closing) { + try { + CompletableFuture close = registered.closeGenerator(); + if (close == null) { + throw new IllegalStateException("Generator close returned no completion future for " + registered.name()); + } + close.whenComplete((ignored, failure) -> completeClose(closing, failure)); + } catch (Throwable exception) { + reportFailure("Failed to start generator close for " + registered.name(), exception); + completeClose(closing, exception); + } + } + + private void completeClose(ClosingGenerator closing, Throwable failure) { + if (failure == null) { + synchronized (registrationLock) { + closingGenerators.remove(closing); + } + closing.completion().complete(null); + } else { + closing.completion().completeExceptionally(failure); + } + } + + private ClosingGenerator findClosingGenerator(RegistrationIdentity registrationIdentity) { + for (ClosingGenerator closing : closingGenerators) { + if (closing.registrationIdentity().conflictsWith(registrationIdentity)) { + return closing; + } + } + return null; + } + + private boolean hasRegisteredConflict(RegistrationIdentity registrationIdentity) { + for (Registered registered : worlds.values()) { + if (!registered.isClosed() && registered.registrationIdentity().conflictsWith(registrationIdentity)) { + return true; + } + } return false; } - static boolean shouldSkipMantleReductionForMaintenance(boolean maintenanceActive, boolean pregeneratorTargetsWorld) { - return maintenanceActive && !pregeneratorTargetsWorld; + private void retryRegistrationAfterClose(World world, CompletableFuture completion) { + synchronized (registrationLock) { + if (service == null || pendingRegistrations.get(world) == completion) { + return; + } + pendingRegistrations.put(world, completion); + } + + completion.whenComplete((ignored, failure) -> { + synchronized (registrationLock) { + if (pendingRegistrations.get(world) != completion) { + return; + } + pendingRegistrations.remove(world); + } + if (failure != null) { + return; + } + + ScheduledThreadPoolExecutor activeService = service; + if (activeService == null || activeService.isShutdown()) { + return; + } + try { + J.s(() -> add(world), 1); + } catch (Throwable exception) { + if (service != null) { + reportFailure("Failed to retry engine maintenance registration for " + world.getName(), exception); + } + } + }); + } + + private static boolean isCurrentWorld(World world) { + return Bukkit.getWorld(world.getUID()) == world; + } + + private void updateMetricsSafely() { + try { + updateMetrics(); + } catch (Throwable exception) { + reportFailure("Failed to update engine maintenance metrics", exception); + } + } + + private void updateMetrics() { + long now = System.currentTimeMillis(); + List worldSnapshots = new ArrayList<>(worlds.size()); + int maintenanceTasks = 0; + int irisWorlds = 0; + + for (Registered registered : worlds.values()) { + if (registered.isClosed()) { + continue; + } + + irisWorlds++; + if (registered.maintenanceInFlight()) { + maintenanceTasks++; + } + + try { + Engine engine = registered.getEngine(); + if (engine == null) { + continue; + } + if (engine.isClosing() && !engine.isClosed()) { + continue; + } + if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) { + registered.close(); + continue; + } + + double chunksPerSecond = registered.sampleChunksPerSecond(engine.getGenerated(), now); + worldSnapshots.add(EngineTelemetrySnapshot.capture(engine, chunksPerSecond, now)); + registered.clearMetricsFailure(); + } catch (Throwable exception) { + if (EngineMaintenance.isMantleClosed(exception)) { + if (registered.generatorClosing()) { + registered.close(); + } + } else { + registered.reportMetricsFailure(exception); + } + } + } + + EngineTelemetrySnapshot.Aggregate aggregate = EngineTelemetrySnapshot.aggregate(worldSnapshots); + metrics.set(new IrisEngineStatus.MaintenanceMetrics( + aggregate.mantleResidentPlates(), + aggregate.mantleQueuedPlates(), + aggregate.loadedChunks(), + irisWorlds, + maintenanceTasks, + aggregate.mantleIdleAverageMs(), + aggregate.mantleIdleMaxMs(), + aggregate.mantleIdleMinMs())); + + ScheduledThreadPoolExecutor activeService = service; + int workers = activeService == null || activeService.isShutdown() + ? 0 + : activeService.getCorePoolSize(); + int closingCount; + int pendingCount; + synchronized (registrationLock) { + closingCount = closingGenerators.size(); + pendingCount = pendingRegistrations.size(); + } + double heapUsage = MantleHeapPressure.usedFraction(); + telemetry.set(new IrisTelemetrySnapshot( + now, + worldSnapshots, + aggregate, + maintenanceTasks, + workers, + closingCount, + pendingCount, + heapUsage, + MantleHeapPressure.reclaimUrgency(heapUsage), + summarizeCaches(), + pregeneratorSnapshot())); + } + + private IrisTelemetrySnapshot.CacheSnapshot summarizeCaches() { + IrisTelemetrySnapshot.CacheBucket resource = IrisTelemetrySnapshot.CacheBucket.EMPTY; + IrisTelemetrySnapshot.CacheBucket stream2d = IrisTelemetrySnapshot.CacheBucket.EMPTY; + IrisTelemetrySnapshot.CacheBucket stream3d = IrisTelemetrySnapshot.CacheBucket.EMPTY; + IrisTelemetrySnapshot.CacheBucket other = IrisTelemetrySnapshot.CacheBucket.EMPTY; + PreservationSVC preservation = IrisServices.get(PreservationSVC.class); + List caches = preservation == null ? List.of() : preservation.getCaches(); + + for (MeteredCache cache : caches) { + IrisTelemetrySnapshot.CacheBucket sample = new IrisTelemetrySnapshot.CacheBucket( + 1, + cache.getSize(), + cache.getMaxSize()); + if (cache instanceof ResourceLoader) { + resource = resource.plus(sample); + } else if (cache instanceof CachedStream2D || cache instanceof CachedDoubleStream2D) { + stream2d = stream2d.plus(sample); + } else if (cache instanceof CachedStream3D) { + stream3d = stream3d.plus(sample); + } else { + other = other.plus(sample); + } + } + + IrisTelemetrySnapshot.CacheBucket total = resource.plus(stream2d).plus(stream3d).plus(other); + return new IrisTelemetrySnapshot.CacheSnapshot(total, resource, stream2d, stream3d, other); + } + + private IrisTelemetrySnapshot.PregenSnapshot pregeneratorSnapshot() { + PregeneratorJob.PregenProgress progress = PregeneratorJob.progressSnapshot(); + if (progress == null) { + return IrisTelemetrySnapshot.PregenSnapshot.INACTIVE; + } + return new IrisTelemetrySnapshot.PregenSnapshot( + true, + progress.worldIdentity(), + progress.worldName(), + progress.paused(), + progress.percent(), + progress.generated(), + progress.totalChunks(), + progress.chunksRemaining(), + progress.chunksPerSecond(), + progress.eta(), + progress.elapsed(), + progress.failed()); + } + + private void shutdownAndDrain(ScheduledThreadPoolExecutor activeService) { + if (activeService == null) { + return; + } + + activeService.shutdown(); + try { + if (!activeService.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + activeService.shutdownNow(); + } + } catch (InterruptedException exception) { + activeService.shutdownNow(); + Thread.currentThread().interrupt(); + reportFailure("Interrupted while draining engine maintenance", exception); + } + } + + private void awaitGeneratorShutdown(List> closes) { + if (closes.isEmpty()) { + return; + } + + try { + CompletableFuture.allOf(closes.toArray(new CompletableFuture[0])) + .get(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + reportFailure("Interrupted while closing Iris generators", exception); + } catch (ExecutionException exception) { + Throwable cause = exception.getCause() == null ? exception : exception.getCause(); + reportFailure("Unexpected generator close aggregation failure", cause); + } catch (TimeoutException exception) { + reportFailure("Timed out while closing Iris generators", exception); + } + } + + private void resetMetrics() { + metrics.set(IrisEngineStatus.MaintenanceMetrics.EMPTY); + telemetry.set(IrisTelemetrySnapshot.EMPTY); + } + + private static void reportFailure(String message, Throwable exception) { + IrisLogging.reportError(exception); + IrisLogging.error("EngineSVC: " + message); + exception.printStackTrace(); } private final class Registered { private final String name; private final PlatformChunkGenerator access; - private final int offset = RNG.r.nextInt(TRIM_PERIOD); - private transient ScheduledFuture trimmer; - private transient ScheduledFuture unloader; - private transient boolean closed; + private final RegistrationIdentity registrationIdentity; + private final AtomicBoolean metricsFailureReported = new AtomicBoolean(); + private final AtomicReference> activeMaintenance = new AtomicReference<>(); + private volatile ScheduledFuture maintenance; + private volatile boolean closed; + private long lastGeneratedSampleAtMs; + private int lastGeneratedCount; - private Registered(String name, PlatformChunkGenerator access) { - this.name = name; - this.access = access; - update(); + private Registered(Registration registration, ScheduledThreadPoolExecutor executor) { + name = registration.name(); + access = registration.access(); + registrationIdentity = registration.registrationIdentity(); + long offset = ThreadLocalRandom.current().nextLong(MAINTENANCE_PERIOD_MILLIS); + maintenance = executor.scheduleWithFixedDelay( + this::maintain, + offset, + MAINTENANCE_PERIOD_MILLIS, + TimeUnit.MILLISECONDS); } - private boolean unloaderAlive() { - return unloader != null && !unloader.isDone() && !unloader.isCancelled(); - } - - private boolean trimmerAlive() { - return trimmer != null && !trimmer.isDone() && !trimmer.isCancelled(); - } - - @Synchronized - private void update() { - if (closed || service == null || service.isShutdown()) + private void maintain() { + if (closed) { return; - - if (trimmer == null || trimmer.isDone() || trimmer.isCancelled()) { - trimmer = service.scheduleAtFixedRate(() -> { - Engine engine = getEngine(); - if (engine == null - || engine.isClosed() - || engine.getMantle().getMantle().isClosed() - || !shouldReduce(engine)) - return; - World engineWorld = BukkitWorldBinding.world(engine.getWorld()); - if (shouldSkipForMaintenance(engineWorld)) { - return; - } - if (GoldenHashScanner.isScanActive()) { - return; - } - - try { - if (pregenTargets(engine) && MantleHeapPressure.overHighWater()) { - engine.getMantle().trim(0L, 0); - } else { - engine.getMantle().trim(activeIdleDuration(engine), activeTectonicLimit(engine)); - } - } catch (Throwable e) { - if (isMantleClosed(e)) { - close(); - return; - } - IrisLogging.reportError(e); - IrisLogging.error("EngineSVC: Failed to trim for " + name); - e.printStackTrace(); - } - }, offset, TRIM_PERIOD, TimeUnit.MILLISECONDS); } + CompletableFuture completion = new CompletableFuture<>(); + if (!activeMaintenance.compareAndSet(null, completion)) { + return; + } + try { + if (closed) { + return; + } + Engine engine = getEngine(); + if (engine == null) { + return; + } + if (!EngineMaintenance.isAvailable(engine)) { + return; + } - if (unloader == null || unloader.isDone() || unloader.isCancelled()) { - unloader = service.scheduleAtFixedRate(() -> { - Engine engine = getEngine(); - if (engine == null - || engine.isClosed() - || engine.getMantle().getMantle().isClosed() - || !shouldReduce(engine)) - return; - World engineWorld = BukkitWorldBinding.world(engine.getWorld()); - if (shouldSkipForMaintenance(engineWorld)) { - return; - } + boolean pregeneratorTargetsWorld = EngineMaintenance.pregeneratorTargets(engine); + if (pregeneratorTargetsWorld || !EngineMaintenance.shouldRun(engine)) { + return; + } - if (GoldenHashScanner.isScanActive()) { - return; + EngineMaintenance.Outcome outcome = EngineMaintenance.run(engine); + if (outcome.unloadedTectonicPlates() > 0) { + IrisLogging.debug(C.GOLD + "Unloaded " + C.YELLOW + + outcome.unloadedTectonicPlates() + " TectonicPlates in " + C.RED + + Form.duration(outcome.unloadDurationMillis(), 2) + " for " + name); + } + } catch (Throwable exception) { + if (EngineMaintenance.isMantleClosed(exception)) { + if (generatorClosing()) { + close(); } - - try { - long unloadStart = System.currentTimeMillis(); - boolean heapPressure = pregenTargets(engine) && MantleHeapPressure.overHighWater(); - int unloadLimit = (heapPressure || IrisSettings.get().getPerformance().getEngineSVC().forceMulticoreWrite) ? 0 : activeTectonicLimit(engine); - int count = engine.getMantle().unloadTectonicPlate(unloadLimit); - if (heapPressure && MantleHeapPressure.overPanicWater()) { - MantleHeapPressure.requestPanicReclaim(); - } - if (count > 0) { - IrisLogging.debug(C.GOLD + "Unloaded " + C.YELLOW + count + " TectonicPlates in " + C.RED + Form.duration(System.currentTimeMillis() - unloadStart, 2)); - } - } catch (Throwable e) { - if (isMantleClosed(e)) { - close(); - return; - } - IrisLogging.reportError(e); - IrisLogging.error("EngineSVC: Failed to unload for " + name); - e.printStackTrace(); - } - }, offset + TRIM_PERIOD / 2, TRIM_PERIOD, TimeUnit.MILLISECONDS); + return; + } + reportFailure("Failed maintenance for " + name, exception); + } finally { + completion.complete(null); + activeMaintenance.compareAndSet(completion, null); } } - private int tectonicLimit() { - return tectonicLimit.get() / Math.max(worlds.size(), 1); + private boolean uses(PlatformChunkGenerator candidate) { + return access == candidate; } - private boolean pregenTargets(Engine engine) { - if (engine == null || engine.getWorld() == null) { - return false; + private String name() { + return name; + } + + private RegistrationIdentity registrationIdentity() { + return registrationIdentity; + } + + private boolean generatorClosing() { + return access.isClosing(); + } + + private boolean isClosed() { + return closed; + } + + private boolean maintenanceInFlight() { + return activeMaintenance.get() != null; + } + + private double sampleChunksPerSecond(int generatedCount, long sampledAtMs) { + int safeGeneratedCount = Math.max(0, generatedCount); + long previousAt = lastGeneratedSampleAtMs; + int previousCount = lastGeneratedCount; + lastGeneratedSampleAtMs = sampledAtMs; + lastGeneratedCount = safeGeneratedCount; + if (previousAt <= 0L || sampledAtMs <= previousAt) { + return 0D; } - - PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - return pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(engine.getWorld().identity()); + int generatedDelta = Math.max(0, safeGeneratedCount - previousCount); + return generatedDelta * 1000D / (sampledAtMs - previousAt); } - private int activeTectonicLimit(Engine engine) { - int limit = tectonicLimit(); - if (!pregenTargets(engine)) { - return limit; - } - - return Math.max(limit, IrisSettings.get().getPregen().getEffectiveResidentTectonicPlates(engine.getHeight())); - } - - private long activeIdleDuration(Engine engine) { - return TimeUnit.SECONDS.toMillis(IrisSettings.get().getPerformance().getMantleKeepAlive()); - } - - @Synchronized private void close() { - if (closed) return; + if (closed) { + return; + } closed = true; - if (trimmer != null) { - trimmer.cancel(false); - trimmer = null; - } - - if (unloader != null) { - unloader.cancel(false); - unloader = null; + ScheduledFuture activeMaintenance = maintenance; + maintenance = null; + if (activeMaintenance != null) { + activeMaintenance.cancel(false); + } + } + + private CompletableFuture closeGenerator() { + CompletableFuture runningMaintenance = activeMaintenance.get(); + if (runningMaintenance == null) { + return invokeGeneratorClose(); + } + return runningMaintenance.thenCompose(ignored -> invokeGeneratorClose()); + } + + private CompletableFuture invokeGeneratorClose() { + try { + CompletableFuture future = access.closeAsync(); + if (future == null) { + IllegalStateException exception = new IllegalStateException( + "Generator close returned no completion future for " + name); + reportFailure("Failed to close generator for " + name, exception); + return CompletableFuture.failedFuture(exception); + } + return future.whenComplete((ignored, failure) -> { + if (failure != null) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + reportFailure("Failed to close generator for " + name, cause); + } + }); + } catch (Throwable exception) { + reportFailure("Failed to close generator for " + name, exception); + return CompletableFuture.failedFuture(exception); } } - @Nullable private Engine getEngine() { - if (closed) return null; - return access.getEngine(); + return closed ? null : access.getEngine(); } - private boolean shouldReduce(Engine engine) { - if (!engine.isStudio() || IrisSettings.get().getPerformance().isTrimMantleInStudio()) { - return true; - } - - return pregenTargets(engine); + private void clearMetricsFailure() { + metricsFailureReported.set(false); } - private boolean shouldSkipForMaintenance(@Nullable World world) { - if (world == null) { - return false; + private void reportMetricsFailure(Throwable exception) { + if (metricsFailureReported.compareAndSet(false, true)) { + reportFailure("Failed to sample metrics for " + name, exception); } - - boolean maintenanceActive = IrisToolbelt.isWorldMaintenanceActive(world); - if (!maintenanceActive) { - return false; - } - - PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - boolean pregeneratorTargetsWorld = pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(WorldIdentity.serialize(world)); - return shouldSkipMantleReductionForMaintenance(maintenanceActive, pregeneratorTargetsWorld); } } + + private record Registration(String name, PlatformChunkGenerator access, + RegistrationIdentity registrationIdentity) { + } + + record RegistrationIdentity(String worldIdentity, Path worldFolder) { + RegistrationIdentity { + worldIdentity = Objects.requireNonNull(worldIdentity); + worldFolder = Objects.requireNonNull(worldFolder).toAbsolutePath().normalize(); + } + + private static RegistrationIdentity from(World world) { + return new RegistrationIdentity( + WorldIdentity.serialize(world), + world.getWorldFolder().toPath()); + } + + boolean conflictsWith(RegistrationIdentity candidate) { + return worldIdentity.equals(candidate.worldIdentity) + || worldFolder.equals(candidate.worldFolder); + } + } + + private record ClosingGenerator(RegistrationIdentity registrationIdentity, + CompletableFuture completion) { + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineStatus.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineStatus.java new file mode 100644 index 000000000..51f288ef9 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineStatus.java @@ -0,0 +1,91 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D; +import art.arcane.iris.util.project.stream.utility.CachedStream2D; +import art.arcane.iris.util.project.stream.utility.CachedStream3D; +import art.arcane.volmlib.util.format.Form; + +import java.util.List; + +final class IrisEngineStatus { + private IrisEngineStatus() { + } + + static void send(VolmitSender sender, Snapshot snapshot) { + CacheSummary caches = summarizeCaches(); + MaintenanceMetrics metrics = snapshot.metrics(); + + sender.sendMessage(C.DARK_PURPLE + "-------------------------"); + sender.sendMessage(C.DARK_PURPLE + "Status:"); + sender.sendMessage(C.DARK_PURPLE + "- Service: " + C.LIGHT_PURPLE + (snapshot.serviceRunning() ? "Running" : "Stopped")); + sender.sendMessage(C.DARK_PURPLE + "- Metrics: " + C.LIGHT_PURPLE + (snapshot.metricsRunning() ? "Running" : "Stopped")); + sender.sendMessage(C.DARK_PURPLE + "- Maintenance Period: " + C.LIGHT_PURPLE + Form.duration(snapshot.maintenancePeriodMillis())); + sender.sendMessage(C.DARK_PURPLE + "- Worker Parallelism: " + C.LIGHT_PURPLE + snapshot.workerParallelism()); + sender.sendMessage(C.DARK_PURPLE + "- Active World Tasks: " + C.LIGHT_PURPLE + metrics.activeTasks()); + sender.sendMessage(C.DARK_PURPLE + "Tectonic Plates:"); + sender.sendMessage(C.DARK_PURPLE + "- Configured Retention: " + C.LIGHT_PURPLE + Form.duration(snapshot.retentionMillis())); + sender.sendMessage(C.DARK_PURPLE + "- Heap Usage: " + C.LIGHT_PURPLE + Form.pc(snapshot.heapUsage())); + sender.sendMessage(C.DARK_PURPLE + "- Resident: " + C.LIGHT_PURPLE + metrics.residentTectonicPlates()); + sender.sendMessage(C.DARK_PURPLE + "- Queued: " + C.LIGHT_PURPLE + metrics.queuedTectonicPlates()); + sender.sendMessage(C.DARK_PURPLE + "- Average Idle Duration: " + C.LIGHT_PURPLE + Form.duration(metrics.averageIdleDuration(), 2)); + sender.sendMessage(C.DARK_PURPLE + "- Max Idle Duration: " + C.LIGHT_PURPLE + Form.duration(metrics.maxIdleDuration(), 2)); + sender.sendMessage(C.DARK_PURPLE + "- Min Idle Duration: " + C.LIGHT_PURPLE + Form.duration(metrics.minIdleDuration(), 2)); + sender.sendMessage(C.DARK_PURPLE + "Caches:"); + sender.sendMessage(C.DARK_PURPLE + "- Resource: " + C.LIGHT_PURPLE + caches.sizes()[0] + " (" + caches.counts()[0] + ")"); + sender.sendMessage(C.DARK_PURPLE + "- 2D Stream: " + C.LIGHT_PURPLE + caches.sizes()[1] + " (" + caches.counts()[1] + ")"); + sender.sendMessage(C.DARK_PURPLE + "- 3D Stream: " + C.LIGHT_PURPLE + caches.sizes()[2] + " (" + caches.counts()[2] + ")"); + sender.sendMessage(C.DARK_PURPLE + "- Other: " + C.LIGHT_PURPLE + caches.sizes()[3] + " (" + caches.counts()[3] + ")"); + sender.sendMessage(C.DARK_PURPLE + "Other:"); + sender.sendMessage(C.DARK_PURPLE + "- Iris Worlds: " + C.LIGHT_PURPLE + metrics.worlds()); + sender.sendMessage(C.DARK_PURPLE + "- Loaded Chunks: " + C.LIGHT_PURPLE + metrics.loadedChunks()); + sender.sendMessage(C.DARK_PURPLE + "-------------------------"); + } + + private static CacheSummary summarizeCaches() { + long[] sizes = new long[4]; + long[] counts = new long[4]; + PreservationSVC preservation = IrisServices.get(PreservationSVC.class); + List caches = preservation == null ? List.of() : preservation.getCaches(); + + for (MeteredCache cache : caches) { + int type = switch (cache) { + case ResourceLoader ignored -> 0; + case CachedStream2D ignored -> 1; + case CachedDoubleStream2D ignored -> 1; + case CachedStream3D ignored -> 2; + default -> 3; + }; + sizes[type] += cache.getSize(); + counts[type]++; + } + return new CacheSummary(sizes, counts); + } + + record Snapshot(boolean serviceRunning, + boolean metricsRunning, + long maintenancePeriodMillis, + int workerParallelism, + long retentionMillis, + double heapUsage, + MaintenanceMetrics metrics) { + } + + record MaintenanceMetrics(long residentTectonicPlates, + long queuedTectonicPlates, + long loadedChunks, + int worlds, + int activeTasks, + double averageIdleDuration, + double maxIdleDuration, + double minIdleDuration) { + static final MaintenanceMetrics EMPTY = new MaintenanceMetrics(0L, 0L, 0L, 0, 0, 0D, 0D, 0D); + } + + private record CacheSummary(long[] sizes, long[] counts) { + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisIntegrationService.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisIntegrationService.java index 13cbd478c..c8804e983 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisIntegrationService.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisIntegrationService.java @@ -1,12 +1,14 @@ package art.arcane.iris.core.service; import art.arcane.iris.Iris; -import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.engine.framework.EngineTelemetrySnapshot; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.volmlib.integration.IntegrationHandshakeRequest; import art.arcane.volmlib.integration.IntegrationHandshakeResponse; import art.arcane.volmlib.integration.IntegrationHeartbeat; import art.arcane.volmlib.integration.IntegrationMetricDescriptor; +import art.arcane.volmlib.integration.IntegrationMetricGroup; import art.arcane.volmlib.integration.IntegrationMetricSample; import art.arcane.volmlib.integration.IntegrationMetricSchema; import art.arcane.volmlib.integration.IntegrationProtocolNegotiator; @@ -15,25 +17,59 @@ import art.arcane.volmlib.integration.IntegrationServiceContract; import org.bukkit.Bukkit; import org.bukkit.plugin.ServicePriority; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Supplier; public class IrisIntegrationService implements IrisService, IntegrationServiceContract { + private static final IntegrationProtocolVersion CURRENT_PROTOCOL = new IntegrationProtocolVersion(1, 2); private static final Set SUPPORTED_PROTOCOLS = Set.of( new IntegrationProtocolVersion(1, 0), - new IntegrationProtocolVersion(1, 1) + new IntegrationProtocolVersion(1, 1), + CURRENT_PROTOCOL ); - private static final Set CAPABILITIES = Set.of( "handshake", "heartbeat", "metrics", - "iris-engine-metrics" + "metric-groups", + "iris-engine-metrics", + "iris-world-metrics" + ); + private static final Map TIMING_KEYS = Map.ofEntries( + Map.entry("total", IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS), + Map.entry("updates", IntegrationMetricSchema.IRIS_GENERATION_UPDATES_MS), + Map.entry("terrain", IntegrationMetricSchema.IRIS_GENERATION_TERRAIN_MS), + Map.entry("biome", IntegrationMetricSchema.IRIS_GENERATION_BIOME_MS), + Map.entry("post", IntegrationMetricSchema.IRIS_GENERATION_POST_MS), + Map.entry("perfection", IntegrationMetricSchema.IRIS_GENERATION_PERFECTION_MS), + Map.entry("decoration", IntegrationMetricSchema.IRIS_GENERATION_DECORATION_MS), + Map.entry("cave", IntegrationMetricSchema.IRIS_GENERATION_CAVE_MS), + Map.entry("deposit", IntegrationMetricSchema.IRIS_GENERATION_DEPOSIT_MS), + Map.entry("carve.resolve", IntegrationMetricSchema.IRIS_GENERATION_CARVE_RESOLVE_MS), + Map.entry("carve.apply", IntegrationMetricSchema.IRIS_GENERATION_CARVE_APPLY_MS), + Map.entry("context.prefill", IntegrationMetricSchema.IRIS_GENERATION_CONTEXT_PREFILL_MS), + Map.entry("pregen.wait.permit", IntegrationMetricSchema.IRIS_PREGEN_WAIT_PERMIT_MS), + Map.entry("pregen.wait.adaptive", IntegrationMetricSchema.IRIS_PREGEN_WAIT_ADAPTIVE_MS) ); - private volatile IntegrationProtocolVersion negotiatedProtocol = new IntegrationProtocolVersion(1, 1); + private final Supplier telemetrySupplier; + + public IrisIntegrationService() { + this(IrisIntegrationService::currentTelemetry); + } + + IrisIntegrationService(Supplier telemetrySupplier) { + this.telemetrySupplier = telemetrySupplier == null + ? () -> IrisTelemetrySnapshot.EMPTY + : telemetrySupplier; + } @Override public void onEnable() { @@ -68,9 +104,11 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo @Override public Set metricDescriptors() { - return IntegrationMetricSchema.descriptors().stream() - .filter(descriptor -> descriptor.key().startsWith("iris.")) - .collect(java.util.stream.Collectors.toSet()); + Set descriptors = new LinkedHashSet<>(); + for (String key : IntegrationMetricSchema.irisKeys()) { + descriptors.add(IntegrationMetricSchema.descriptor(key)); + } + return Set.copyOf(descriptors); } @Override @@ -106,12 +144,11 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo ); } - negotiatedProtocol = negotiated.get(); return new IntegrationHandshakeResponse( pluginId(), pluginVersion(), true, - negotiatedProtocol, + negotiated.get(), SUPPORTED_PROTOCOLS, CAPABILITIES, "ok", @@ -121,90 +158,228 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo @Override public IntegrationHeartbeat heartbeat() { - long now = System.currentTimeMillis(); - return new IntegrationHeartbeat(negotiatedProtocol, true, now, "ok"); + return new IntegrationHeartbeat(CURRENT_PROTOCOL, true, System.currentTimeMillis(), "ok"); } @Override public Map sampleMetrics(Set metricKeys) { Set requested = metricKeys == null || metricKeys.isEmpty() ? IntegrationMetricSchema.irisKeys() - : metricKeys; - long now = System.currentTimeMillis(); - Map out = new HashMap<>(); + : Set.copyOf(metricKeys); + IrisTelemetrySnapshot snapshot = safeSnapshot(); + long sampledAtMs = snapshot.sampledAtMs() > 0L + ? snapshot.sampledAtMs() + : System.currentTimeMillis(); + if (snapshot.sampledAtMs() <= 0L) { + return unavailableMetrics(requested, "telemetry-not-ready", sampledAtMs); + } + Map available = globalSamples(snapshot); + Map selected = new LinkedHashMap<>(requested.size()); for (String key : requested) { - switch (key) { - case IntegrationMetricSchema.IRIS_CHUNK_STREAM_MS -> out.put(key, sampleChunkStreamMetric(now)); - case IntegrationMetricSchema.IRIS_PREGEN_QUEUE -> out.put(key, samplePregenQueueMetric(now)); - case IntegrationMetricSchema.IRIS_BIOME_CACHE_HIT_RATE -> out.put(key, sampleBiomeCacheHitRateMetric(now)); - default -> out.put(key, IntegrationMetricSample.unavailable( - IntegrationMetricSchema.descriptor(key), - "unsupported-key", - now - )); + IntegrationMetricSample sample = available.get(key); + selected.put(key, sample == null + ? unavailable(key, "unsupported-key", sampledAtMs) + : sample); + } + return Map.copyOf(selected); + } + + @Override + public List metricGroups() { + IrisTelemetrySnapshot snapshot = safeSnapshot(); + if (snapshot.sampledAtMs() <= 0L || snapshot.worlds().isEmpty()) { + return List.of(); + } + + List worlds = new ArrayList<>(snapshot.worlds()); + worlds.sort(Comparator.comparing(EngineTelemetrySnapshot::worldIdentity)); + List groups = new ArrayList<>(worlds.size()); + for (EngineTelemetrySnapshot world : worlds) { + groups.add(new IntegrationMetricGroup( + "world", + world.worldIdentity(), + world.worldName(), + Map.of( + "plugin", "iris", + "dimension", world.dimensionKey() + ), + worldSamples(world, snapshot.pregenerator()) + )); + } + return List.copyOf(groups); + } + + private Map globalSamples(IrisTelemetrySnapshot snapshot) { + long sampledAtMs = snapshot.sampledAtMs(); + EngineTelemetrySnapshot.Aggregate aggregate = snapshot.aggregate(); + Map samples = new LinkedHashMap<>(); + put(samples, IntegrationMetricSchema.IRIS_WORLD_COUNT, aggregate.worldCount(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_ACTIVE, aggregate.active(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_CLOSING, aggregate.closing() + snapshot.closingEngines(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_FAILED, aggregate.failed(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_STUDIO, aggregate.studio(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_PENDING_REGISTRATIONS, snapshot.pendingRegistrations(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_LOADED_CHUNKS, aggregate.loadedChunks(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_LOADED_ENTITIES, aggregate.loadedEntities(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENTITY_SATURATION, aggregate.entitySaturationMax(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_SESSION, aggregate.generatedSession(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL, aggregate.generatedTotal(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_CHUNKS_PER_SECOND, aggregate.chunksPerSecond(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_BLOCK_UPDATES_PER_SECOND, aggregate.blockUpdatesPerSecond(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_PARALLELISM, aggregate.parallelism(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_GENERATION_ACTIVE_LEASES, aggregate.activeGenerationLeases(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_HOTLOADS_TOTAL, aggregate.hotloadsTotal(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MAINTENANCE_ACTIVE_TASKS, snapshot.maintenanceActiveTasks(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MAINTENANCE_WORKERS, snapshot.maintenanceWorkers(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_RESIDENT_PLATES, aggregate.mantleResidentPlates(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_QUEUED_PLATES, aggregate.mantleQueuedPlates(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_IDLE_AVERAGE_MS, aggregate.mantleIdleAverageMs(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_IDLE_MAX_MS, aggregate.mantleIdleMaxMs(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_IDLE_MIN_MS, aggregate.mantleIdleMinMs(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_HEAP_USAGE, snapshot.heapUsage(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_RECLAIM_URGENCY, snapshot.reclaimUrgency(), sampledAtMs); + putCacheSamples(samples, snapshot.caches(), sampledAtMs); + putPregenSamples(samples, snapshot.pregenerator(), sampledAtMs); + putTimingSamples(samples, aggregate.generationTimingMaximaMs(), sampledAtMs); + return Map.copyOf(samples); + } + + private Map worldSamples( + EngineTelemetrySnapshot world, + IrisTelemetrySnapshot.PregenSnapshot pregenerator + ) { + long sampledAtMs = world.sampledAtMs(); + Map samples = new LinkedHashMap<>(); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_ACTIVE, world.active() ? 1 : 0, sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_CLOSING, world.closing() ? 1 : 0, sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_FAILED, world.failed() ? 1 : 0, sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_STUDIO, world.studio() ? 1 : 0, sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_LOADED_CHUNKS, world.loadedChunks(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_LOADED_ENTITIES, world.loadedEntities(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENTITY_SATURATION, world.entitySaturation(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_SESSION, world.generatedSession(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL, world.generatedTotal(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_CHUNKS_PER_SECOND, world.chunksPerSecond(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_BLOCK_UPDATES_PER_SECOND, world.blockUpdatesPerSecond(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_ENGINE_PARALLELISM, world.parallelism(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_GENERATION_ACTIVE_LEASES, world.activeGenerationLeases(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_HOTLOADS_TOTAL, world.hotloadsTotal(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_RESIDENT_PLATES, world.mantleResidentPlates(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_QUEUED_PLATES, world.mantleQueuedPlates(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_MANTLE_IDLE_AVERAGE_MS, world.mantleIdleMs(), sampledAtMs); + boolean targetsWorld = pregenerator.active() + && world.worldIdentity().equals(pregenerator.worldIdentity()); + putPregenSamples(samples, targetsWorld ? pregenerator : IrisTelemetrySnapshot.PregenSnapshot.INACTIVE, sampledAtMs); + putTimingSamples(samples, world.generationTimingsMs(), sampledAtMs); + samples.keySet().retainAll(IntegrationMetricSchema.irisWorldKeys()); + return Map.copyOf(samples); + } + + private void putCacheSamples( + Map samples, + IrisTelemetrySnapshot.CacheSnapshot caches, + long sampledAtMs + ) { + putCacheBucket(samples, caches.total(), IntegrationMetricSchema.IRIS_CACHE_COUNT, IntegrationMetricSchema.IRIS_CACHE_ENTRIES, IntegrationMetricSchema.IRIS_CACHE_CAPACITY, IntegrationMetricSchema.IRIS_CACHE_USAGE, sampledAtMs); + putCacheBucket(samples, caches.resource(), IntegrationMetricSchema.IRIS_CACHE_RESOURCE_COUNT, IntegrationMetricSchema.IRIS_CACHE_RESOURCE_ENTRIES, IntegrationMetricSchema.IRIS_CACHE_RESOURCE_CAPACITY, IntegrationMetricSchema.IRIS_CACHE_RESOURCE_USAGE, sampledAtMs); + putCacheBucket(samples, caches.stream2d(), IntegrationMetricSchema.IRIS_CACHE_STREAM_2D_COUNT, IntegrationMetricSchema.IRIS_CACHE_STREAM_2D_ENTRIES, IntegrationMetricSchema.IRIS_CACHE_STREAM_2D_CAPACITY, IntegrationMetricSchema.IRIS_CACHE_STREAM_2D_USAGE, sampledAtMs); + putCacheBucket(samples, caches.stream3d(), IntegrationMetricSchema.IRIS_CACHE_STREAM_3D_COUNT, IntegrationMetricSchema.IRIS_CACHE_STREAM_3D_ENTRIES, IntegrationMetricSchema.IRIS_CACHE_STREAM_3D_CAPACITY, IntegrationMetricSchema.IRIS_CACHE_STREAM_3D_USAGE, sampledAtMs); + putCacheBucket(samples, caches.other(), IntegrationMetricSchema.IRIS_CACHE_OTHER_COUNT, IntegrationMetricSchema.IRIS_CACHE_OTHER_ENTRIES, IntegrationMetricSchema.IRIS_CACHE_OTHER_CAPACITY, IntegrationMetricSchema.IRIS_CACHE_OTHER_USAGE, sampledAtMs); + } + + private void putCacheBucket( + Map samples, + IrisTelemetrySnapshot.CacheBucket bucket, + String countKey, + String entriesKey, + String capacityKey, + String usageKey, + long sampledAtMs + ) { + put(samples, countKey, bucket.count(), sampledAtMs); + put(samples, entriesKey, bucket.entries(), sampledAtMs); + put(samples, capacityKey, bucket.capacity(), sampledAtMs); + put(samples, usageKey, bucket.usage(), sampledAtMs); + } + + private void putPregenSamples( + Map samples, + IrisTelemetrySnapshot.PregenSnapshot pregenerator, + long sampledAtMs + ) { + put(samples, IntegrationMetricSchema.IRIS_PREGEN_ACTIVE, pregenerator.active() ? 1 : 0, sampledAtMs); + if (!pregenerator.active()) { + for (String key : Set.of( + IntegrationMetricSchema.IRIS_PREGEN_PAUSED, + IntegrationMetricSchema.IRIS_PREGEN_PROGRESS, + IntegrationMetricSchema.IRIS_PREGEN_GENERATED, + IntegrationMetricSchema.IRIS_PREGEN_TOTAL, + IntegrationMetricSchema.IRIS_PREGEN_QUEUE, + IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, + IntegrationMetricSchema.IRIS_PREGEN_ETA_MS, + IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, + IntegrationMetricSchema.IRIS_PREGEN_FAILED)) { + samples.put(key, unavailable(key, "pregen-inactive", sampledAtMs)); } + return; } - return out; + put(samples, IntegrationMetricSchema.IRIS_PREGEN_PAUSED, pregenerator.paused() ? 1 : 0, sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_PREGEN_PROGRESS, pregenerator.progressPercent(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_PREGEN_GENERATED, pregenerator.generated(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_PREGEN_TOTAL, pregenerator.total(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_PREGEN_QUEUE, pregenerator.remaining(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, pregenerator.chunksPerSecond(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_PREGEN_ETA_MS, pregenerator.etaMs(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, pregenerator.elapsedMs(), sampledAtMs); + put(samples, IntegrationMetricSchema.IRIS_PREGEN_FAILED, pregenerator.failed(), sampledAtMs); } - private IntegrationMetricSample sampleChunkStreamMetric(long now) { - IntegrationMetricDescriptor descriptor = IntegrationMetricSchema.descriptor(IntegrationMetricSchema.IRIS_CHUNK_STREAM_MS); - - double chunksPerSecond = PregeneratorJob.chunksPerSecond(); - - if (chunksPerSecond > 0D) { - return IntegrationMetricSample.available(descriptor, 1000D / chunksPerSecond, now); + private void putTimingSamples( + Map samples, + Map timings, + long sampledAtMs + ) { + for (Map.Entry entry : TIMING_KEYS.entrySet()) { + Double value = timings.get(entry.getKey()); + samples.put(entry.getValue(), value == null + ? unavailable(entry.getValue(), "timing-not-available", sampledAtMs) + : available(entry.getValue(), value, sampledAtMs)); } - - IrisEngineSVC engineService = Iris.service(IrisEngineSVC.class); - if (engineService != null) { - double idle = engineService.getAverageIdleDuration(); - if (idle > 0D && Double.isFinite(idle)) { - return IntegrationMetricSample.available(descriptor, idle, now); - } - } - - return IntegrationMetricSample.available(descriptor, 0D, now); } - private IntegrationMetricSample samplePregenQueueMetric(long now) { - IntegrationMetricDescriptor descriptor = IntegrationMetricSchema.descriptor(IntegrationMetricSchema.IRIS_PREGEN_QUEUE); - long totalQueue = 0L; - boolean hasAnySource = false; - - long pregenRemaining = PregeneratorJob.chunksRemaining(); - if (pregenRemaining >= 0L) { - totalQueue += pregenRemaining; - hasAnySource = true; - } - - IrisEngineSVC engineService = Iris.service(IrisEngineSVC.class); - if (engineService != null) { - totalQueue += Math.max(0, engineService.getQueuedTectonicPlateCount()); - hasAnySource = true; - } - - if (!hasAnySource) { - return IntegrationMetricSample.unavailable(descriptor, "queue-not-available", now); - } - - return IntegrationMetricSample.available(descriptor, totalQueue, now); + private void put(Map samples, String key, double value, long sampledAtMs) { + samples.put(key, available(key, value, sampledAtMs)); } - private IntegrationMetricSample sampleBiomeCacheHitRateMetric(long now) { - IntegrationMetricDescriptor descriptor = IntegrationMetricSchema.descriptor(IntegrationMetricSchema.IRIS_BIOME_CACHE_HIT_RATE); - IrisEngineSVC engineService = Iris.service(IrisEngineSVC.class); - if (engineService == null) { - return IntegrationMetricSample.unavailable(descriptor, "engine-service-unavailable", now); - } + private IntegrationMetricSample available(String key, double value, long sampledAtMs) { + return IntegrationMetricSample.available(IntegrationMetricSchema.descriptor(key), value, sampledAtMs); + } - double ratio = engineService.getBiomeCacheUsageRatio(); - if (!Double.isFinite(ratio)) { - return IntegrationMetricSample.unavailable(descriptor, "biome-cache-ratio-invalid", now); - } + private IntegrationMetricSample unavailable(String key, String reason, long sampledAtMs) { + return IntegrationMetricSample.unavailable(IntegrationMetricSchema.descriptor(key), reason, sampledAtMs); + } - return IntegrationMetricSample.available(descriptor, Math.max(0D, Math.min(1D, ratio)), now); + private Map unavailableMetrics( + Set keys, + String reason, + long sampledAtMs + ) { + Map samples = new LinkedHashMap<>(keys.size()); + for (String key : keys) { + samples.put(key, unavailable(key, reason, sampledAtMs)); + } + return Map.copyOf(samples); + } + + private IrisTelemetrySnapshot safeSnapshot() { + IrisTelemetrySnapshot snapshot = telemetrySupplier.get(); + return snapshot == null ? IrisTelemetrySnapshot.EMPTY : snapshot; + } + + private static IrisTelemetrySnapshot currentTelemetry() { + IrisEngineSVC service = IrisServices.get(IrisEngineSVC.class); + return service == null ? IrisTelemetrySnapshot.EMPTY : service.telemetrySnapshot(); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTelemetrySnapshot.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTelemetrySnapshot.java new file mode 100644 index 000000000..25cfea29b --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTelemetrySnapshot.java @@ -0,0 +1,140 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.framework.EngineTelemetrySnapshot; + +import java.util.List; + +record IrisTelemetrySnapshot( + long sampledAtMs, + List worlds, + EngineTelemetrySnapshot.Aggregate aggregate, + int maintenanceActiveTasks, + int maintenanceWorkers, + int closingEngines, + int pendingRegistrations, + double heapUsage, + double reclaimUrgency, + CacheSnapshot caches, + PregenSnapshot pregenerator +) { + static final IrisTelemetrySnapshot EMPTY = new IrisTelemetrySnapshot( + 0L, + List.of(), + EngineTelemetrySnapshot.aggregate(List.of()), + 0, + 0, + 0, + 0, + 0D, + 0D, + CacheSnapshot.EMPTY, + PregenSnapshot.INACTIVE + ); + + IrisTelemetrySnapshot { + worlds = worlds == null ? List.of() : List.copyOf(worlds); + aggregate = aggregate == null ? EngineTelemetrySnapshot.aggregate(worlds) : aggregate; + maintenanceActiveTasks = Math.max(0, maintenanceActiveTasks); + maintenanceWorkers = Math.max(0, maintenanceWorkers); + closingEngines = Math.max(0, closingEngines); + pendingRegistrations = Math.max(0, pendingRegistrations); + heapUsage = finiteRatio(heapUsage); + reclaimUrgency = finiteRatio(reclaimUrgency); + caches = caches == null ? CacheSnapshot.EMPTY : caches; + pregenerator = pregenerator == null ? PregenSnapshot.INACTIVE : pregenerator; + } + + private static double finiteRatio(double value) { + return Double.isFinite(value) ? Math.max(0D, Math.min(1D, value)) : 0D; + } + + record CacheSnapshot( + CacheBucket total, + CacheBucket resource, + CacheBucket stream2d, + CacheBucket stream3d, + CacheBucket other + ) { + static final CacheSnapshot EMPTY = new CacheSnapshot( + CacheBucket.EMPTY, + CacheBucket.EMPTY, + CacheBucket.EMPTY, + CacheBucket.EMPTY, + CacheBucket.EMPTY + ); + + CacheSnapshot { + total = total == null ? CacheBucket.EMPTY : total; + resource = resource == null ? CacheBucket.EMPTY : resource; + stream2d = stream2d == null ? CacheBucket.EMPTY : stream2d; + stream3d = stream3d == null ? CacheBucket.EMPTY : stream3d; + other = other == null ? CacheBucket.EMPTY : other; + } + } + + record CacheBucket(int count, long entries, long capacity) { + static final CacheBucket EMPTY = new CacheBucket(0, 0L, 0L); + + CacheBucket { + count = Math.max(0, count); + entries = Math.max(0L, entries); + capacity = Math.max(0L, capacity); + } + + double usage() { + return capacity <= 0L ? 0D : Math.min(1D, entries / (double) capacity); + } + + CacheBucket plus(CacheBucket other) { + if (other == null) { + return this; + } + return new CacheBucket(count + other.count, entries + other.entries, capacity + other.capacity); + } + } + + record PregenSnapshot( + boolean active, + String worldIdentity, + String worldName, + boolean paused, + double progressPercent, + long generated, + long total, + long remaining, + double chunksPerSecond, + long etaMs, + long elapsedMs, + long failed + ) { + static final PregenSnapshot INACTIVE = new PregenSnapshot( + false, + "", + "", + false, + 0D, + 0L, + 0L, + 0L, + 0D, + 0L, + 0L, + 0L + ); + + PregenSnapshot { + worldIdentity = worldIdentity == null ? "" : worldIdentity.trim(); + worldName = worldName == null ? "" : worldName.trim(); + progressPercent = Double.isFinite(progressPercent) + ? Math.max(0D, Math.min(100D, progressPercent)) + : 0D; + generated = Math.max(0L, generated); + total = Math.max(0L, total); + remaining = Math.max(0L, remaining); + chunksPerSecond = Double.isFinite(chunksPerSecond) ? Math.max(0D, chunksPerSecond) : 0D; + etaMs = Math.max(0L, etaMs); + elapsedMs = Math.max(0L, elapsedMs); + failed = Math.max(0L, failed); + } + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerPresentation.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerPresentation.java new file mode 100644 index 000000000..9b64a287d --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerPresentation.java @@ -0,0 +1,308 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.ExperienceOrb; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Vector; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +final class TreeFellerPresentation { + private static final int MIN_BLOCKS_PER_PULSE = 4; + private static final int MAX_BLOCKS_PER_PULSE = 64; + private static final int TARGET_EROSION_PULSES = 60; + private static final int MAX_EFFECT_ORIGINS_PER_PULSE = 16; + + private final Player player; + private final World sourceWorld; + private final Queue pendingDrops = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean flushScheduled = new AtomicBoolean(); + private final AtomicBoolean fallbackScheduled = new AtomicBoolean(); + private final AtomicBoolean completed = new AtomicBoolean(); + private final AtomicBoolean effectFailureReported = new AtomicBoolean(); + private final AtomicBoolean deliveryFailureReported = new AtomicBoolean(); + private final AtomicInteger deliveryPulses = new AtomicInteger(); + private volatile Location fallbackLocation; + + TreeFellerPresentation(Player player, World sourceWorld, Location fallbackLocation) { + this.player = player; + this.sourceWorld = sourceWorld; + this.fallbackLocation = fallbackLocation.clone(); + } + + static int blocksPerPulse(int blockCount) { + int requested = Math.max(1, (blockCount + TARGET_EROSION_PULSES - 1) / TARGET_EROSION_PULSES); + return Math.max(MIN_BLOCKS_PER_PULSE, Math.min(requested, MAX_BLOCKS_PER_PULSE)); + } + + static int effectStride(int blocksPerPulse) { + return Math.max( + 1, + (blocksPerPulse + MAX_EFFECT_ORIGINS_PER_PULSE - 1) / MAX_EFFECT_ORIGINS_PER_PULSE + ); + } + + static List consolidateDrops(Collection drops) { + List consolidated = new ArrayList<>(); + for (ItemStack drop : drops) { + mergeDrop(consolidated, drop); + } + return List.copyOf(consolidated); + } + + void activate(Block block) { + try { + Location center = block.getLocation().clone().add(0.5D, 0.5D, 0.5D); + World world = block.getWorld(); + world.spawnParticle(Particle.ENCHANT, center, 24, 0.45D, 0.45D, 0.45D, 0.18D); + world.spawnParticle(Particle.END_ROD, center, 8, 0.25D, 0.25D, 0.25D, 0.035D); + world.playSound(center, Sound.BLOCK_ENCHANTMENT_TABLE_USE, 0.55F, 1.35F); + world.playSound(center, Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.4F, 0.8F); + } catch (Throwable error) { + reportEffectFailure(error); + } + } + + void erode( + Location source, + BlockData visualData, + int erosionOrder, + int processed, + int blocksPerPulse, + int effectStride, + int totalBlocks + ) { + if (processed % effectStride != 0 || source.getWorld() == null) { + return; + } + try { + World world = source.getWorld(); + world.spawnParticle(Particle.BLOCK, source, 5, 0.3D, 0.3D, 0.3D, 0.04D, visualData); + world.spawnParticle(Particle.ENCHANT, source, 3, 0.28D, 0.28D, 0.28D, 0.12D); + if (processed % blocksPerPulse == 0) { + double progress = totalBlocks <= 1 ? 1D : (double) erosionOrder / (double) (totalBlocks - 1); + float pitch = (float) Math.min(1.95D, 0.65D + (progress * 1.25D)); + world.playSound(source, Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.22F, pitch); + } + } catch (Throwable error) { + reportEffectFailure(error); + } + } + + boolean routeDrop(Object drop) { + if (!(drop instanceof ItemStack item)) { + return false; + } + if (item.getType() == Material.AIR || item.getAmount() <= 0) { + return true; + } + return enqueue(new PendingDrop(item.clone(), 0)); + } + + boolean routeExperience(int experience) { + return experience <= 0 || enqueue(new PendingDrop(null, experience)); + } + + void finish() { + completed.set(true); + if (!pendingDrops.isEmpty()) { + scheduleFlush(); + } + } + + private boolean enqueue(PendingDrop drop) { + pendingDrops.add(drop); + if (scheduleFlush()) { + return true; + } + return !pendingDrops.remove(drop); + } + + private boolean scheduleFlush() { + if (pendingDrops.isEmpty() || !flushScheduled.compareAndSet(false, true)) { + return true; + } + fallbackScheduled.set(false); + Runnable retired = () -> { + if (!scheduleFallbackFlush()) { + flushScheduled.set(false); + IrisLogging.error("Unable to deliver queued Iris tree-feller drops after the player retired."); + } + }; + if (J.runEntity(player, this::flushAtPlayer, 1, retired)) { + return true; + } + return scheduleFallbackFlush(); + } + + private boolean scheduleFallbackFlush() { + if (!fallbackScheduled.compareAndSet(false, true)) { + return true; + } + Location fallback = fallbackLocation.clone(); + if (J.runAt(fallback, () -> flushAtFallback(fallback), 1)) { + return true; + } + fallbackScheduled.set(false); + flushScheduled.set(false); + return false; + } + + private void flushAtPlayer() { + if (!player.isOnline() || !player.getWorld().equals(sourceWorld)) { + if (!scheduleFallbackFlush()) { + IrisLogging.error("Unable to deliver queued Iris tree-feller drops at their fallback location."); + } + return; + } + Location target = player.getLocation().clone().add(0D, 0.15D, 0D); + fallbackLocation = target.clone(); + deliverBatch(target, drainPendingDrops(), true); + completeFlush(); + } + + private void flushAtFallback(Location fallback) { + deliverBatch(fallback, drainPendingDrops(), false); + completeFlush(); + } + + private DropBatch drainPendingDrops() { + List items = new ArrayList<>(); + long experience = 0L; + PendingDrop pending; + while ((pending = pendingDrops.poll()) != null) { + if (pending.item() != null) { + items.add(pending.item()); + } + experience = Math.min(Integer.MAX_VALUE, experience + pending.experience()); + } + return new DropBatch(consolidateDrops(items), (int) experience); + } + + private void deliverBatch(Location target, DropBatch batch, boolean atPlayer) { + World world = target.getWorld(); + if (world == null) { + requeueBatch(batch); + return; + } + int deliveredItems = 0; + for (int index = 0; index < batch.items().size(); index++) { + ItemStack item = batch.items().get(index); + Item dropped; + try { + dropped = world.dropItem(target, item); + } catch (Throwable error) { + requeueUndelivered(batch, index); + reportDeliveryFailure(error); + return; + } + deliveredItems++; + try { + dropped.setVelocity(new Vector(0D, 0.08D, 0D)); + } catch (Throwable error) { + reportEffectFailure(error); + } + } + if (batch.experience() > 0) { + try { + ExperienceOrb orb = world.spawn(target.clone().add(0D, 0.35D, 0D), ExperienceOrb.class); + orb.setExperience(batch.experience()); + } catch (Throwable error) { + pendingDrops.add(new PendingDrop(null, batch.experience())); + reportDeliveryFailure(error); + } + } + try { + int enchantParticles = Math.min(32, 6 + (deliveredItems * 2)); + Location effectLocation = target.clone().add(0D, 0.35D, 0D); + world.spawnParticle(Particle.ENCHANT, effectLocation, enchantParticles, 0.35D, 0.25D, 0.35D, 0.15D); + world.spawnParticle(Particle.END_ROD, effectLocation, Math.min(8, 2 + deliveredItems), 0.2D, 0.2D, 0.2D, 0.025D); + int deliveryPulse = deliveryPulses.incrementAndGet(); + if (atPlayer && (deliveryPulse == 1 || deliveryPulse % 4 == 0 || completed.get())) { + world.playSound(target, Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.2F, 1.65F); + } + } catch (Throwable error) { + reportEffectFailure(error); + } + } + + private void completeFlush() { + fallbackScheduled.set(false); + flushScheduled.set(false); + if (!pendingDrops.isEmpty() && !scheduleFlush()) { + IrisLogging.error("Unable to schedule the remaining Iris tree-feller drops."); + } + } + + private void requeueBatch(DropBatch batch) { + requeueUndelivered(batch, 0); + } + + private void requeueUndelivered(DropBatch batch, int firstUndeliveredItem) { + for (int index = firstUndeliveredItem; index < batch.items().size(); index++) { + pendingDrops.add(new PendingDrop(batch.items().get(index), 0)); + } + if (batch.experience() > 0) { + pendingDrops.add(new PendingDrop(null, batch.experience())); + } + } + + private void reportEffectFailure(Throwable error) { + if (effectFailureReported.compareAndSet(false, true)) { + IrisLogging.reportError("Failed to render an Iris tree-feller effect.", error); + } + } + + private void reportDeliveryFailure(Throwable error) { + if (deliveryFailureReported.compareAndSet(false, true)) { + IrisLogging.reportError("Failed to deliver an Iris tree-feller drop; it remains queued for retry.", error); + } + } + + private static void mergeDrop(List consolidated, ItemStack source) { + if (source == null || source.getType() == Material.AIR || source.getAmount() <= 0) { + return; + } + int remaining = source.getAmount(); + for (ItemStack existing : consolidated) { + if (!existing.isSimilar(source) || existing.getAmount() >= existing.getMaxStackSize()) { + continue; + } + int transferable = Math.min(remaining, existing.getMaxStackSize() - existing.getAmount()); + existing.setAmount(existing.getAmount() + transferable); + remaining -= transferable; + if (remaining == 0) { + return; + } + } + int maximumStackSize = Math.max(1, source.getMaxStackSize()); + while (remaining > 0) { + ItemStack stack = source.clone(); + stack.setAmount(Math.min(remaining, maximumStackSize)); + consolidated.add(stack); + remaining -= stack.getAmount(); + } + } + + private record PendingDrop(ItemStack item, int experience) { + } + + private record DropBatch(List items, int experience) { + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerSVC.java new file mode 100644 index 000000000..1051aac7a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerSVC.java @@ -0,0 +1,1110 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.api.tree.IrisTreeFellerService; +import art.arcane.iris.api.tree.TreeFellerAccess; +import art.arcane.iris.api.tree.TreeFellerOptions; +import art.arcane.iris.api.tree.TreeFellerRunHooks; +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.service.tree.BlockDropRouter; +import art.arcane.iris.core.service.tree.TreeDefinitionIndex; +import art.arcane.iris.core.service.tree.TreeMarkerTraversal; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.StructurePlacementMarker; +import art.arcane.iris.engine.framework.TreeBlockMaterial; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.plugin.IrisService; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.ExperienceOrb; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerItemHeldEvent; +import org.bukkit.event.player.PlayerSwapHandItemsEvent; +import org.bukkit.event.player.PlayerToggleSneakEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.plugin.ServicePriority; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public class TreeFellerSVC implements IrisService, IrisTreeFellerService { + private static final String PERMISSION = "iris.treefeller"; + + private final AtomicBoolean serviceEnabled = new AtomicBoolean(); + private final Map pending = Collections.synchronizedMap(new IdentityHashMap<>()); + private final Set managedEvents = Collections.synchronizedSet( + Collections.newSetFromMap(new IdentityHashMap<>()) + ); + private final Set activeClaims = ConcurrentHashMap.newKeySet(); + private final Map> activeRuns = new ConcurrentHashMap<>(); + private final Map definitions = Collections.synchronizedMap(new WeakHashMap<>()); + + @Override + public void onEnable() { + serviceEnabled.set(true); + Bukkit.getServicesManager().register( + IrisTreeFellerService.class, + this, + BukkitPlatform.plugin(), + ServicePriority.Normal + ); + IrisServices.register(IrisTreeFellerService.class, this); + } + + @Override + public void onDisable() { + serviceEnabled.set(false); + Bukkit.getServicesManager().unregister(IrisTreeFellerService.class, this); + IrisServices.remove(IrisTreeFellerService.class); + pending.clear(); + managedEvents.clear(); + for (Set runs : activeRuns.values()) { + for (FellingRun run : runs) { + finish(run); + } + } + activeRuns.clear(); + activeClaims.clear(); + definitions.clear(); + } + + @Override + public boolean tryFell(BlockBreakEvent event, TreeFellerOptions options) { + if (!serviceEnabled.get() + || event == null + || options == null + || event.isCancelled() + || isManagedBreak(event)) { + return false; + } + if (!canUse(event.getPlayer(), options.access())) { + return false; + } + + TreeCandidate candidate; + try { + candidate = resolveCandidate(event.getBlock(), event.getPlayer()); + } catch (Throwable error) { + IrisLogging.reportError("Failed to resolve an Iris tree-feller request.", error); + return false; + } + if (candidate == null) { + return false; + } + + PendingFell requested = new PendingFell( + candidate, + resolvePreservationChance(options), + options.runHooks() + ); + synchronized (pending) { + PendingFell existing = pending.get(event); + if (existing != null && existing.candidate().access() == TreeFellerAccess.INTEGRATION_OVERRIDE) { + return options.access() == TreeFellerAccess.INTEGRATION_OVERRIDE; + } + if (existing == null || options.access() == TreeFellerAccess.INTEGRATION_OVERRIDE) { + pending.put(event, requested.withAccess(options.access())); + } + } + managedEvents.add(event); + return true; + } + + @Override + public boolean isManagedBreak(BlockBreakEvent event) { + return event != null && managedEvents.contains(event); + } + + @Override + public boolean isTreeBlock(Block block) { + if (block == null) { + return false; + } + try { + return resolveTreeContext(block) != null; + } catch (Throwable error) { + IrisLogging.reportError("Failed to inspect Iris tree provenance.", error); + return false; + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void requestStandalone(BlockBreakEvent event) { + tryFell(event, TreeFellerOptions.standalone()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void finalizeBreak(BlockBreakEvent event) { + PendingFell requested; + synchronized (pending) { + requested = pending.remove(event); + } + + if (requested == null) { + if (!event.isCancelled() && !isManagedBreak(event)) { + deferSuccessfulBreakCleanup(event); + } + return; + } + + if (event.isCancelled()) { + releaseManagedLater(event); + return; + } + + TreeCandidate current; + try { + current = resolveCandidate(event.getBlock(), event.getPlayer()); + } catch (Throwable error) { + IrisLogging.reportError("Failed to finalize an Iris tree-feller request.", error); + deferSuccessfulBreakCleanup(event); + releaseManagedLater(event); + return; + } + if (current == null || !current.context().marker().equals(requested.candidate().context().marker())) { + deferSuccessfulBreakCleanup(event); + releaseManagedLater(event); + return; + } + + TreeClaim claim = new TreeClaim(current.world().getUID(), current.context().marker()); + if (!activeClaims.add(claim)) { + event.setCancelled(true); + event.setDropItems(false); + event.setExpToDrop(0); + releaseManagedLater(event); + return; + } + + event.setCancelled(true); + event.setDropItems(false); + event.setExpToDrop(0); + releaseManagedLater(event); + + FellingRun run = new FellingRun( + claim, + current, + requested.preservationChance(), + requested.runHooks(), + event.getPlayer().getInventory().getHeldItemSlot(), + event.getPlayer().getLocation().clone().add(0D, 0.15D, 0D) + ); + activeRuns.computeIfAbsent(event.getPlayer().getUniqueId(), ignored -> ConcurrentHashMap.newKeySet()).add(run); + notifyActivationAccepted(run.runHooks); + run.presentation.activate(event.getBlock()); + discover(run); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void haltWhenSneakingStops(PlayerToggleSneakEvent event) { + if (!event.isSneaking()) { + finishRuns(event.getPlayer().getUniqueId()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void haltWhenHeldSlotChanges(PlayerItemHeldEvent event) { + if (event.getNewSlot() != event.getPreviousSlot()) { + finishRuns(event.getPlayer().getUniqueId()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void haltWhenHandsSwap(PlayerSwapHandItemsEvent event) { + finishRuns(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void clearPlacedProvenance(BlockPlaceEvent event) { + ProvenanceSnapshot snapshot = captureProvenance(event.getBlockPlaced()); + if (snapshot == null) { + return; + } + Location location = event.getBlockPlaced().getLocation(); + Runnable cleanup = () -> { + if (!event.isCancelled()) { + clearProvenanceIfMatching(snapshot); + } + }; + if (!J.runAt(location, cleanup, 1) && !J.isFolia()) { + J.s(cleanup, 1); + } + } + + private boolean canUse(Player player, TreeFellerAccess access) { + if (access == TreeFellerAccess.INTEGRATION_OVERRIDE) { + return true; + } + IrisSettings.IrisSettingsTreeFeller settings = IrisSettings.get().getTreeFeller(); + return settings != null && settings.isEnabled() && player.hasPermission(PERMISSION); + } + + private int resolvePreservationChance(TreeFellerOptions options) { + if (options.access() == TreeFellerAccess.INTEGRATION_OVERRIDE) { + return options.durabilityPreservationChance(); + } + IrisSettings.IrisSettingsTreeFeller settings = IrisSettings.get().getTreeFeller(); + return settings == null ? 0 : settings.getDurabilityPreservationChance(); + } + + private void notifyActivationAccepted(TreeFellerRunHooks runHooks) { + try { + runHooks.onActivationAccepted(); + } catch (Throwable error) { + IrisLogging.reportError("An Iris tree-feller integration activation callback failed.", error); + } + } + + private TreeCandidate resolveCandidate(Block block, Player player) { + if (player.getGameMode() != GameMode.SURVIVAL || !player.isSneaking()) { + return null; + } + if (!Tag.LOGS.isTagged(block.getType())) { + return null; + } + ItemStack tool = player.getInventory().getItemInMainHand(); + if (!isAxe(tool)) { + return null; + } + TreeContext context = resolveTreeContext(block); + if (context == null) { + return null; + } + return new TreeCandidate( + context, + block.getWorld(), + player, + tool.clone(), + TreeFellerAccess.STANDALONE, + new TreeMarkerTraversal.Position(block.getX(), block.getY(), block.getZ()) + ); + } + + private TreeContext resolveTreeContext(Block block) { + if (block.getType().isAir()) { + return null; + } + PlatformChunkGenerator access = IrisToolbelt.access(block.getWorld()); + if (access == null || access.getEngine() == null) { + return null; + } + Engine engine = access.getEngine(); + World world = block.getWorld(); + int minimumY = world.getMinHeight(); + int maximumY = world.getMaxHeight(); + int relativeY = block.getY() - minimumY; + String marker = markerAt(engine, minimumY, block.getX(), block.getY(), block.getZ()); + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker); + if (decoded == null || decoded.structureAware()) { + return null; + } + TreeBlockMaterial expected = engine.getMantle().getMantle().get( + block.getX(), + relativeY, + block.getZ(), + TreeBlockMaterial.class + ); + if (expected != null && !matchesExpectedMaterial(block, expected)) { + return null; + } + if (expected == null + && !decoded.objectKey().startsWith("trees/") + && !definitionIndex(engine).isTreeMarker(marker)) { + return null; + } + return new TreeContext(engine, marker, expected, minimumY, maximumY); + } + + private TreeDefinitionIndex definitionIndex(Engine engine) { + synchronized (definitions) { + return definitions.computeIfAbsent(engine, TreeDefinitionIndex::build); + } + } + + private void discover(FellingRun run) { + J.a(() -> { + if (run.candidate.context().engine().isClosed()) { + finish(run); + return; + } + try { + TreeMarkerTraversal.Discovery discovery = TreeMarkerTraversal.discover( + run.candidate.trigger(), + run.candidate.context().marker(), + run.candidate.context().minimumY(), + run.candidate.context().maximumY(), + (x, y, z) -> markerAt( + run.candidate.context().engine(), + run.candidate.context().minimumY(), + x, + y, + z + ) + ); + List positions = positionsForFelling(discovery, run.candidate.trigger()); + preflight(run, positions, discovery.complete()); + } catch (Throwable error) { + IrisLogging.reportError("Failed to discover an Iris tree for felling.", error); + preflight(run, List.of(run.candidate.trigger()), false); + } + }); + } + + private void preflight(FellingRun run, List positions, boolean allowFallback) { + Map> grouped = groupByChunk(positions); + if (grouped.isEmpty()) { + finish(run); + return; + } + Map erosionOrder = new HashMap<>(positions.size()); + for (int index = 0; index < positions.size(); index++) { + erosionOrder.put(positions.get(index), index); + } + + List members = Collections.synchronizedList(new ArrayList<>()); + AtomicBoolean failed = new AtomicBoolean(); + AtomicInteger remaining = new AtomicInteger(grouped.size()); + AtomicBoolean completed = new AtomicBoolean(); + + for (Map.Entry> entry : grouped.entrySet()) { + ChunkPosition chunk = entry.getKey(); + Runnable task = () -> { + try { + if (!run.candidate.world().isChunkLoaded(chunk.x(), chunk.z())) { + failed.set(true); + return; + } + for (TreeMarkerTraversal.Position position : entry.getValue()) { + TreeMember member = inspectMember(run, position, erosionOrder.getOrDefault(position, Integer.MAX_VALUE)); + if (member != null) { + members.add(member); + } + } + } catch (Throwable error) { + failed.set(true); + IrisLogging.reportError( + "Failed to preflight an Iris tree-feller chunk at " + chunk.x() + "," + chunk.z() + ".", + error + ); + } finally { + completePreflightGroup(run, members, failed, remaining, completed, allowFallback); + } + }; + if (!J.runRegion(run.candidate.world(), chunk.x(), chunk.z(), task)) { + failed.set(true); + completePreflightGroup(run, members, failed, remaining, completed, allowFallback); + } + } + } + + static List positionsForFelling( + TreeMarkerTraversal.Discovery discovery, + TreeMarkerTraversal.Position trigger + ) { + return discovery.complete() ? discovery.members() : List.of(trigger); + } + + private void completePreflightGroup( + FellingRun run, + List members, + AtomicBoolean failed, + AtomicInteger remaining, + AtomicBoolean completed, + boolean allowFallback + ) { + if (remaining.decrementAndGet() != 0 || !completed.compareAndSet(false, true)) { + return; + } + if (failed.get() && allowFallback) { + preflight(run, List.of(run.candidate.trigger()), false); + return; + } + if (failed.get()) { + finish(run); + return; + } + + List ordered = orderMembers(run.candidate.trigger(), members); + if (ordered.isEmpty() || !ordered.getFirst().position().equals(run.candidate.trigger())) { + finish(run); + return; + } + run.work = ordered; + run.blocksPerPulse = TreeFellerPresentation.blocksPerPulse(ordered.size()); + run.effectStride = TreeFellerPresentation.effectStride(run.blocksPerPulse); + processNext(run); + } + + private TreeMember inspectMember(FellingRun run, TreeMarkerTraversal.Position position, int erosionOrder) { + World world = run.candidate.world(); + Block block = world.getBlockAt(position.x(), position.y(), position.z()); + TreeContext context = run.candidate.context(); + if (!context.marker().equals(markerAt(context.engine(), context.minimumY(), position))) { + return null; + } + TreeBlockMaterial expected = materialAt(context.engine(), context.minimumY(), position); + if (expected != null && !matchesExpectedMaterial(block, expected)) { + clearProvenance(context.engine(), context.minimumY(), position); + return null; + } + if (block.getType().isAir()) { + clearProvenance(context.engine(), context.minimumY(), position); + return null; + } + return new TreeMember(position, Tag.LOGS.isTagged(block.getType()), expected, erosionOrder); + } + + private List orderMembers( + TreeMarkerTraversal.Position trigger, + Collection discovered + ) { + Comparator erosionOrder = Comparator + .comparingInt(TreeMember::erosionOrder) + .thenComparingInt(member -> member.position().y()) + .thenComparingInt(member -> member.position().x()) + .thenComparingInt(member -> member.position().z()); + List ordered = discovered.stream().sorted(erosionOrder).toList(); + if (ordered.isEmpty() || !ordered.getFirst().position().equals(trigger)) { + return List.of(); + } + return ordered; + } + + private void processNext(FellingRun run) { + if (run.finished.get()) { + return; + } + int index = run.cursor.getAndIncrement(); + if (index >= run.work.size()) { + finish(run); + return; + } + + TreeMember member = run.work.get(index); + ChunkPosition chunk = new ChunkPosition(member.position().x() >> 4, member.position().z() >> 4); + Runnable task = () -> runTask( + run, + "Failed to prepare an Iris tree-feller block.", + () -> prepareBreak(run, member) + ); + if (!J.runRegion(run.candidate.world(), chunk.x(), chunk.z(), task)) { + if (member.log()) { + finish(run); + } else { + continueRun(run); + } + } + } + + private void prepareBreak(FellingRun run, TreeMember member) { + Block block = liveMemberBlock(run, member); + if (block == null) { + if (member.log()) { + finish(run); + } else { + continueRun(run); + } + return; + } + + if (!member.log()) { + runMutationTask( + run, + member, + new DamageReservation(run.expectedTool.clone(), false, false, false), + new AtomicBoolean() + ); + return; + } + + Runnable task = () -> runTask( + run, + "Failed to reserve Iris tree-feller tool durability.", + () -> reserveDamage(run, member) + ); + if (!J.runEntity(run.candidate.player(), task)) { + finish(run); + } + } + + private void reserveDamage(FellingRun run, TreeMember member) { + Player player = run.candidate.player(); + if (!isRunControlActive(run, player)) { + finish(run); + return; + } + PlayerInventory inventory = player.getInventory(); + ItemStack current = inventory.getItem(run.heldSlot); + if (current == null + || inventory.getHeldItemSlot() != run.heldSlot + || !current.isSimilar(run.expectedTool) + || !isAxe(current)) { + finish(run); + return; + } + + if (!reserveLogCost(run)) { + finish(run); + return; + } + + ItemStack before = current.clone(); + ItemMeta meta = current.getItemMeta(); + if (meta.isUnbreakable() || ThreadLocalRandom.current().nextInt(100) < run.preservationChance) { + scheduleMutation(run, member, new DamageReservation(before, false, false, true)); + return; + } + if (!(meta instanceof Damageable damageable) || current.getType().getMaxDurability() <= 0) { + refundAndFinish(run, new DamageReservation(before, false, false, true)); + return; + } + + int nextDamage = damageable.getDamage() + 1; + boolean broke = nextDamage >= current.getType().getMaxDurability(); + if (broke) { + inventory.setItem(run.heldSlot, new ItemStack(Material.AIR)); + run.expectedTool = new ItemStack(Material.AIR); + } else { + damageable.setDamage(nextDamage); + current.setItemMeta(meta); + inventory.setItem(run.heldSlot, current); + run.expectedTool = current.clone(); + } + scheduleMutation(run, member, new DamageReservation(before, true, broke, true)); + } + + private void scheduleMutation(FellingRun run, TreeMember member, DamageReservation reservation) { + TreeMarkerTraversal.Position position = member.position(); + AtomicBoolean mutationSucceeded = new AtomicBoolean(); + Runnable task = () -> runMutationTask(run, member, reservation, mutationSucceeded); + boolean scheduled; + try { + scheduled = J.runRegion( + run.candidate.world(), + position.x() >> 4, + position.z() >> 4, + task + ); + } catch (Throwable error) { + IrisLogging.reportError("Failed to schedule an Iris tree-feller block removal.", error); + refundAndFinish(run, reservation); + return; + } + if (!scheduled) { + refundAndFinish(run, reservation); + } + } + + private void runMutationTask( + FellingRun run, + TreeMember member, + DamageReservation reservation, + AtomicBoolean mutationSucceeded + ) { + if (run.finished.get()) { + refundAndFinish(run, reservation); + return; + } + try { + probeAndMutate(run, member, reservation, mutationSucceeded); + } catch (Throwable error) { + IrisLogging.reportError("Failed to remove an Iris tree-feller block.", error); + if (mutationSucceeded.get()) { + finish(run); + } else { + refundAndFinish(run, reservation); + } + } + } + + private void probeAndMutate( + FellingRun run, + TreeMember member, + DamageReservation reservation, + AtomicBoolean mutationSucceeded + ) { + Block block = liveMemberBlock(run, member); + if (block == null) { + refundAndFinish(run, reservation); + return; + } + + BlockBreakEvent probe = new RoutedBlockBreakEvent(block, run.candidate.player(), run); + managedEvents.add(probe); + try { + Bukkit.getPluginManager().callEvent(probe); + } catch (Throwable error) { + probe.setCancelled(true); + IrisLogging.reportError("Failed to dispatch an Iris tree-feller block probe.", error); + } finally { + managedEvents.remove(probe); + } + + try { + if (probe.isCancelled()) { + if (reservation.charged() || reservation.logCostReserved()) { + refundAndFinish(run, reservation); + } else if (member.log()) { + finish(run); + } else { + continueRun(run); + } + return; + } + + block = liveMemberBlock(run, member); + if (run.finished.get() || block == null) { + probe.setCancelled(true); + refundAndFinish(run, reservation); + return; + } + + Location source = block.getLocation().clone().add(0.5D, 0.5D, 0.5D); + BlockData visualData = block.getBlockData().clone(); + List vanillaDrops = probe.isDropItems() + ? block.getDrops(reservation.toolForDrops()).stream() + .map(ItemStack::clone) + .toList() + : List.of(); + block.setType(Material.AIR, false); + if (!block.getType().isAir()) { + probe.setCancelled(true); + refundAndFinish(run, reservation); + return; + } + mutationSucceeded.set(true); + run.presentation.erode( + source, + visualData, + member.erosionOrder(), + run.processed.get(), + run.blocksPerPulse, + run.effectStride, + run.work.size() + ); + + clearProvenance( + run.candidate.context().engine(), + run.candidate.context().minimumY(), + member.position() + ); + routeDrops(run, vanillaDrops, source); + if (!run.presentation.routeExperience(probe.getExpToDrop())) { + dropExperience(source, probe.getExpToDrop()); + } + if (reservation.logCostReserved()) { + completeLogCost(run, reservation); + return; + } + completeSuccessfulMutation(run, reservation); + } catch (RuntimeException | Error error) { + if (!mutationSucceeded.get()) { + probe.setCancelled(true); + } + throw error; + } + } + + private Block liveMemberBlock(FellingRun run, TreeMember member) { + World world = run.candidate.world(); + TreeMarkerTraversal.Position position = member.position(); + if (!world.isChunkLoaded(position.x() >> 4, position.z() >> 4)) { + return null; + } + Block block = world.getBlockAt(position.x(), position.y(), position.z()); + TreeContext context = run.candidate.context(); + if (!context.marker().equals(markerAt(context.engine(), context.minimumY(), position))) { + return null; + } + if (block.getType().isAir() || member.log() != Tag.LOGS.isTagged(block.getType())) { + clearProvenance(context.engine(), context.minimumY(), position); + return null; + } + TreeBlockMaterial expected = materialAt(context.engine(), context.minimumY(), position); + if (member.expectedMaterial() != null && !member.expectedMaterial().equals(expected)) { + clearProvenance(context.engine(), context.minimumY(), position); + return null; + } + if (expected != null && !matchesExpectedMaterial(block, expected)) { + clearProvenance(context.engine(), context.minimumY(), position); + return null; + } + return block; + } + + private void refundAndFinish(FellingRun run, DamageReservation reservation) { + if (!reservation.charged() && !reservation.logCostReserved()) { + finish(run); + return; + } + if (!J.runEntity(run.candidate.player(), () -> { + if (reservation.charged()) { + PlayerInventory inventory = run.candidate.player().getInventory(); + ItemStack current = inventory.getItem(run.heldSlot); + boolean expectedAir = run.expectedTool.getType() == Material.AIR; + boolean currentMatches = expectedAir + ? current == null || current.getType() == Material.AIR + : current != null && current.isSimilar(run.expectedTool); + if (currentMatches) { + inventory.setItem(run.heldSlot, reservation.toolForDrops().clone()); + run.expectedTool = reservation.toolForDrops().clone(); + } + } + if (reservation.logCostReserved()) { + refundLogCost(run); + } + finish(run); + })) { + finish(run); + } + } + + private boolean isRunControlActive(FellingRun run, Player player) { + return player.isOnline() + && player.getGameMode() == GameMode.SURVIVAL + && player.isSneaking() + && player.getWorld().equals(run.candidate.world()); + } + + private boolean reserveLogCost(FellingRun run) { + try { + return run.runHooks.reserveLogCost(); + } catch (Throwable error) { + IrisLogging.reportError("An Iris tree-feller integration log-cost reservation failed.", error); + return false; + } + } + + private void completeLogCost(FellingRun run, DamageReservation reservation) { + if (!J.runEntity(run.candidate.player(), () -> { + try { + run.runHooks.commitLogCost(); + } catch (Throwable error) { + IrisLogging.reportError("An Iris tree-feller integration log-cost commit failed.", error); + finish(run); + return; + } + completeSuccessfulMutation(run, reservation); + })) { + finish(run); + } + } + + private void refundLogCost(FellingRun run) { + try { + run.runHooks.refundLogCost(); + } catch (Throwable error) { + IrisLogging.reportError("An Iris tree-feller integration log-cost refund failed.", error); + } + } + + private void completeSuccessfulMutation(FellingRun run, DamageReservation reservation) { + if (reservation.broke()) { + finish(run); + return; + } + continueRun(run); + } + + private void continueRun(FellingRun run) { + int processed = run.processed.incrementAndGet(); + if (processed % run.blocksPerPulse == 0) { + J.s(() -> runTask(run, "Failed to continue an Iris tree-feller run.", () -> processNext(run)), 1); + } else { + processNext(run); + } + } + + private void runTask(FellingRun run, String context, Runnable task) { + if (run.finished.get() || !serviceEnabled.get()) { + finish(run); + return; + } + try { + task.run(); + } catch (Throwable error) { + IrisLogging.reportError(context, error); + finish(run); + } + } + + private void finish(FellingRun run) { + if (run.finished.compareAndSet(false, true)) { + activeClaims.remove(run.claim); + Set runs = activeRuns.get(run.candidate.player().getUniqueId()); + if (runs != null) { + runs.remove(run); + if (runs.isEmpty()) { + activeRuns.remove(run.candidate.player().getUniqueId(), runs); + } + } + run.presentation.finish(); + } + } + + private void finishRuns(UUID playerId) { + Set runs = activeRuns.get(playerId); + if (runs == null) { + return; + } + for (FellingRun run : List.copyOf(runs)) { + finish(run); + } + } + + private void releaseManagedLater(BlockBreakEvent event) { + J.s(() -> managedEvents.remove(event), 1); + } + + private void deferSuccessfulBreakCleanup(BlockBreakEvent event) { + ProvenanceSnapshot snapshot = captureProvenance(event.getBlock()); + if (snapshot == null) { + return; + } + Location location = event.getBlock().getLocation(); + Runnable cleanup = () -> { + if (!event.isCancelled()) { + clearProvenanceIfMatching(snapshot); + } + }; + if (!J.runAt(location, cleanup, 1) && !J.isFolia()) { + J.s(cleanup, 1); + } + } + + private Map> groupByChunk( + List positions + ) { + Map> grouped = new LinkedHashMap<>(); + for (TreeMarkerTraversal.Position position : positions) { + ChunkPosition chunk = new ChunkPosition(position.x() >> 4, position.z() >> 4); + grouped.computeIfAbsent(chunk, ignored -> new ArrayList<>()).add(position); + } + return grouped; + } + + private String markerAt(Engine engine, int minimumY, TreeMarkerTraversal.Position position) { + return markerAt(engine, minimumY, position.x(), position.y(), position.z()); + } + + private String markerAt(Engine engine, int minimumY, int x, int y, int z) { + return engine.getMantle().getMantle().get(x, y - minimumY, z, String.class); + } + + private TreeBlockMaterial materialAt(Engine engine, int minimumY, TreeMarkerTraversal.Position position) { + return engine.getMantle().getMantle().get( + position.x(), + position.y() - minimumY, + position.z(), + TreeBlockMaterial.class + ); + } + + private ProvenanceSnapshot captureProvenance(Block block) { + PlatformChunkGenerator access = IrisToolbelt.access(block.getWorld()); + if (access == null || access.getEngine() == null) { + return null; + } + Engine engine = access.getEngine(); + TreeMarkerTraversal.Position position = new TreeMarkerTraversal.Position( + block.getX(), + block.getY(), + block.getZ() + ); + int minimumY = block.getWorld().getMinHeight(); + String marker = markerAt(engine, minimumY, position); + TreeBlockMaterial material = materialAt(engine, minimumY, position); + if (marker == null && material == null) { + return null; + } + return new ProvenanceSnapshot(engine, block.getWorld(), minimumY, position, marker, material); + } + + private void clearProvenanceIfMatching(ProvenanceSnapshot snapshot) { + String marker = markerAt(snapshot.engine(), snapshot.minimumY(), snapshot.position()); + TreeBlockMaterial material = materialAt(snapshot.engine(), snapshot.minimumY(), snapshot.position()); + if (Objects.equals(snapshot.marker(), marker) && Objects.equals(snapshot.material(), material)) { + clearProvenance(snapshot.engine(), snapshot.minimumY(), snapshot.position()); + } + } + + private void clearProvenance(Engine engine, int minimumY, TreeMarkerTraversal.Position position) { + int relativeY = position.y() - minimumY; + engine.getMantle().getMantle().remove(position.x(), relativeY, position.z(), String.class); + engine.getMantle().getMantle().remove(position.x(), relativeY, position.z(), TreeBlockMaterial.class); + } + + private boolean matchesExpectedMaterial(Block block, TreeBlockMaterial expected) { + return expected.matches(block.getBlockData().getAsString()); + } + + private boolean isAxe(ItemStack item) { + return item != null && item.getType() != Material.AIR && item.getType().name().endsWith("_AXE"); + } + + private void routeDrops(FellingRun run, List drops, Location source) { + World world = source.getWorld(); + if (world == null) { + return; + } + for (ItemStack drop : drops) { + if (!run.presentation.routeDrop(drop)) { + world.dropItem(source, drop); + } + } + } + + private void dropExperience(Location location, int experience) { + if (experience <= 0 || location.getWorld() == null) { + return; + } + ExperienceOrb orb = location.getWorld().spawn( + location, + ExperienceOrb.class + ); + orb.setExperience(experience); + } + + private record PendingFell( + TreeCandidate candidate, + int preservationChance, + TreeFellerRunHooks runHooks + ) { + private PendingFell withAccess(TreeFellerAccess access) { + return new PendingFell(candidate.withAccess(access), preservationChance, runHooks); + } + } + + private record TreeCandidate( + TreeContext context, + World world, + Player player, + ItemStack tool, + TreeFellerAccess access, + TreeMarkerTraversal.Position trigger + ) { + private TreeCandidate withAccess(TreeFellerAccess access) { + return new TreeCandidate(context, world, player, tool, access, trigger); + } + } + + private record TreeContext( + Engine engine, + String marker, + TreeBlockMaterial expectedMaterial, + int minimumY, + int maximumY + ) { + } + + private record TreeClaim(UUID worldId, String marker) { + } + + private record ProvenanceSnapshot( + Engine engine, + World world, + int minimumY, + TreeMarkerTraversal.Position position, + String marker, + TreeBlockMaterial material + ) { + } + + private record ChunkPosition(int x, int z) { + } + + private record TreeMember( + TreeMarkerTraversal.Position position, + boolean log, + TreeBlockMaterial expectedMaterial, + int erosionOrder + ) { + } + + private record DamageReservation( + ItemStack toolForDrops, + boolean charged, + boolean broke, + boolean logCostReserved + ) { + } + + private final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter { + private final FellingRun run; + + private RoutedBlockBreakEvent(Block block, Player player, FellingRun run) { + super(block, player); + this.run = run; + } + + @Override + public boolean routeDrop(Object drop) { + return serviceEnabled.get() && run.presentation.routeDrop(drop); + } + } + + private static final class FellingRun { + private final TreeClaim claim; + private final TreeCandidate candidate; + private final int preservationChance; + private final TreeFellerRunHooks runHooks; + private final int heldSlot; + private final TreeFellerPresentation presentation; + private final AtomicBoolean finished = new AtomicBoolean(); + private final AtomicInteger cursor = new AtomicInteger(); + private final AtomicInteger processed = new AtomicInteger(); + private volatile int blocksPerPulse = 1; + private volatile int effectStride = 1; + private volatile ItemStack expectedTool; + private volatile List work = List.of(); + + private FellingRun( + TreeClaim claim, + TreeCandidate candidate, + int preservationChance, + TreeFellerRunHooks runHooks, + int heldSlot, + Location fallbackLocation + ) { + this.claim = claim; + this.candidate = candidate; + this.preservationChance = preservationChance; + this.runHooks = runHooks; + this.heldSlot = heldSlot; + this.presentation = new TreeFellerPresentation(candidate.player(), candidate.world(), fallbackLocation); + this.expectedTool = candidate.tool().clone(); + } + } +} diff --git a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml index 59bbaca95..eebf7f462 100644 --- a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml @@ -8,6 +8,10 @@ load: STARTUP authors: [ cyberpwn, NextdoorPsycho, Vatuu ] website: volmit.com description: More than a Dimension! +permissions: + iris.treefeller: + description: Allows survival players to fell Iris-managed trees with an axe. + default: op dependencies: server: PlaceholderAPI: diff --git a/adapters/bukkit/plugin/src/main/resources/plugin.yml b/adapters/bukkit/plugin/src/main/resources/plugin.yml index bebe26b6e..f07654159 100644 --- a/adapters/bukkit/plugin/src/main/resources/plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/plugin.yml @@ -22,7 +22,11 @@ loadbefore: [ Multiverse-Core ] authors: [ cyberpwn, NextdoorPsycho, Vatuu ] website: volmit.com description: More than a Dimension! -commands: - iris: - aliases: [ ir, irs ] +commands: + iris: + aliases: [ ir, irs ] +permissions: + iris.treefeller: + description: Allows survival players to fell Iris-managed trees with an axe. + default: op api-version: '${apiVersion}' diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java index e38a6a973..02e6a7b56 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java @@ -2,6 +2,8 @@ package art.arcane.iris; import org.bukkit.plugin.PluginDescriptionFile; import org.bukkit.plugin.PluginLoadOrder; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; import org.junit.Test; import java.io.InputStream; @@ -54,6 +56,9 @@ public class PaperPluginMetadataTest { assertTrue(metadata.contains("folia-supported: true")); assertTrue(metadata.contains("load: STARTUP")); assertFalse(metadata.contains("commands:")); + assertTrue(metadata.contains("permissions:\n iris.treefeller:\n" + + " description: Allows survival players to fell Iris-managed trees with an axe.\n" + + " default: op")); for (String pluginId : JOINED_PLUGIN_IDS) { assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "BEFORE", true))); } @@ -76,6 +81,13 @@ public class PaperPluginMetadataTest { assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases")); assertEquals(BUKKIT_SOFT_DEPEND_IDS, metadata.getSoftDepend()); assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins()); + Permission treeFeller = metadata.getPermissions().stream() + .filter(permission -> "iris.treefeller".equals(permission.getName())) + .findFirst() + .orElse(null); + assertNotNull(treeFeller); + assertEquals("Allows survival players to fell Iris-managed trees with an axe.", treeFeller.getDescription()); + assertEquals(PermissionDefault.OP, treeFeller.getDefault()); } @Test diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/tree/TreeFellerOptionsTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/tree/TreeFellerOptionsTest.java new file mode 100644 index 000000000..210266e7c --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/tree/TreeFellerOptionsTest.java @@ -0,0 +1,28 @@ +package art.arcane.iris.api.tree; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class TreeFellerOptionsTest { + @Test + public void factoriesExposeStableAccessModes() { + TreeFellerOptions standalone = TreeFellerOptions.standalone(); + TreeFellerOptions override = TreeFellerOptions.integrationOverride(75, TreeFellerRunHooks.NONE); + + assertEquals(TreeFellerAccess.STANDALONE, standalone.access()); + assertEquals(0, standalone.durabilityPreservationChance()); + assertEquals(TreeFellerAccess.INTEGRATION_OVERRIDE, override.access()); + assertEquals(75, override.durabilityPreservationChance()); + } + + @Test(expected = IllegalArgumentException.class) + public void chanceBelowRangeIsRejected() { + TreeFellerOptions.integrationOverride(-1, TreeFellerRunHooks.NONE); + } + + @Test(expected = IllegalArgumentException.class) + public void chanceAboveRangeIsRejected() { + TreeFellerOptions.integrationOverride(101, TreeFellerRunHooks.NONE); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java new file mode 100644 index 000000000..7968db370 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java @@ -0,0 +1,83 @@ +package art.arcane.iris.core.runtime; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class BukkitEngineLifecycleContractTest { + @Test + public void closeFutureIsPublishedBeforeCloseWorkIsScheduled() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource"))); + String closeAsync = method(source, "public CompletableFuture closeAsync()"); + + assertBefore(closeAsync, "closeFuture.compareAndSet(null, future)", "withExclusiveControlFuture("); + assertTrue(closeAsync.contains("while (!closeFuture.compareAndSet(null, future))")); + assertTrue(closeAsync.contains("operation.whenComplete(")); + assertFalse(closeAsync.contains("!existing.isDone()")); + + String baseHeight = method(source, "public int getBaseHeight("); + assertTrue(baseHeight.contains("currentEngine.acquireGenerationLease(\"bukkit_base_height\")")); + assertTrue(baseHeight.contains("IrisContext.open(currentEngine, lease.sessionId(), null)")); + assertTrue(baseHeight.contains("catch (GenerationSessionException e)")); + + String generation = method(source, "public void generateNoise("); + assertTrue(generation.contains("throw new IllegalStateException")); + assertTrue(generation.contains("engine.acquireGenerationLease(\"bukkit_terrain_stage\")")); + assertTrue(generation.contains("IrisContext.open(engine, lease.sessionId(), null)")); + assertBefore(generation, "engine.acquireGenerationLease(\"bukkit_terrain_stage\")", "blocks.apply()"); + assertFalse(generation.contains("RED_GLAZED_TERRACOTTA")); + } + + @Test + public void targetedPregeneratorShutdownChecksWorldIdentity() throws IOException { + String jobSource = Files.readString(Path.of(System.getProperty("iris.pregeneratorJobSource"))); + String targetedShutdown = method(jobSource, + "public static boolean shutdownInstanceForWorld(String worldIdentity)"); + + assertBefore(targetedShutdown, "inst.targetsWorldIdentity(worldIdentity)", + "shutdownAndWait(inst, WORLD_SHUTDOWN_TIMEOUT_MILLIS)"); + + String waitedShutdown = method(jobSource, + "private static boolean shutdownAndWait(PregeneratorJob inst, long timeoutMs)"); + assertBefore(waitedShutdown, "inst.worker.interrupt()", "inst.worker.join("); + assertTrue(waitedShutdown.contains("inst.worker.isAlive()")); + + String hooksSource = Files.readString(Path.of(System.getProperty("iris.bukkitEnginePlatformHooksSource"))); + String hookShutdown = method(hooksSource, "public void shutdownPregenerator(Engine engine)"); + assertTrue(hookShutdown.contains("PregeneratorJob.shutdownInstanceForWorld(world.identity());")); + assertFalse(hookShutdown.contains("PregeneratorJob.shutdownInstance();")); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract signature: " + signature, start >= 0); + int openBrace = source.indexOf('{', start); + assertTrue("Missing source contract method body: " + signature, openBrace >= 0); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java new file mode 100644 index 000000000..badf81957 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java @@ -0,0 +1,105 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.framework.EngineAssignedWorldManager; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.world.WorldUnloadEvent; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class IrisEngineLifecycleContractTest { + @Test + public void engineServiceExclusivelyOwnsWorldUnload() throws NoSuchMethodException { + assertMonitorUnloadHandler(IrisEngineSVC.class.getMethod("onWorldUnload", WorldUnloadEvent.class)); + for (Method method : EngineAssignedWorldManager.class.getDeclaredMethods()) { + boolean handlesWorldUnload = method.getParameterCount() == 1 + && method.getParameterTypes()[0] == WorldUnloadEvent.class + && method.isAnnotationPresent(EventHandler.class); + assertFalse("EngineAssignedWorldManager must defer world unload ownership to IrisEngineSVC.", handlesWorldUnload); + } + } + + @Test + public void registrationIdentityConflictsByWorldIdentityOrFolder() { + Path sharedFolder = Path.of("build", "worlds", "shared"); + IrisEngineSVC.RegistrationIdentity first = + new IrisEngineSVC.RegistrationIdentity("minecraft:first", sharedFolder); + IrisEngineSVC.RegistrationIdentity sameIdentity = + new IrisEngineSVC.RegistrationIdentity("minecraft:first", Path.of("build", "worlds", "other")); + IrisEngineSVC.RegistrationIdentity sameFolder = + new IrisEngineSVC.RegistrationIdentity("minecraft:other", sharedFolder); + IrisEngineSVC.RegistrationIdentity distinct = + new IrisEngineSVC.RegistrationIdentity("minecraft:distinct", Path.of("build", "worlds", "distinct")); + + assertTrue(first.conflictsWith(sameIdentity)); + assertTrue(first.conflictsWith(sameFolder)); + assertFalse(first.conflictsWith(distinct)); + } + + @Test + public void registrationRetryWaitsAsynchronouslyForClose() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource"))); + String add = method(source, "private void add(World world)"); + String remove = method(source, "private void remove(World world)"); + String completeClose = method(source, "private void completeClose("); + String retry = method(source, "private void retryRegistrationAfterClose("); + String invokeClose = method(source, "private CompletableFuture invokeGeneratorClose()"); + + assertBefore(add, "findClosingGenerator(registrationIdentity)", "new Registered("); + assertBefore(remove, "reserveClose(registered)", "startClose(registered, closing)"); + assertBefore(completeClose, "if (failure == null)", "closingGenerators.remove(closing)"); + assertBefore(completeClose, "closingGenerators.remove(closing)", "closing.completion().complete(null)"); + assertBefore(completeClose, "} else {", "closing.completion().completeExceptionally(failure)"); + assertTrue(retry.contains("completion.whenComplete(")); + assertTrue(retry.contains("J.s(() -> add(world), 1);")); + assertBefore(retry, "if (failure != null)", "J.s(() -> add(world), 1);"); + assertFalse(retry.contains("completion.get(")); + assertFalse(retry.contains("completion.join(")); + assertTrue(invokeClose.contains("CompletableFuture.failedFuture(")); + assertTrue(invokeClose.contains("future.whenComplete(")); + } + + private static void assertMonitorUnloadHandler(Method method) { + EventHandler eventHandler = method.getAnnotation(EventHandler.class); + assertNotNull(eventHandler); + assertEquals(EventPriority.MONITOR, eventHandler.priority()); + assertTrue(eventHandler.ignoreCancelled()); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract signature: " + signature, start >= 0); + int openBrace = source.indexOf('{', start); + assertTrue("Missing source contract method body: " + signature, openBrace >= 0); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCTest.java deleted file mode 100644 index 30ebb7c12..000000000 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package art.arcane.iris.core.service; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class IrisEngineSVCTest { - @Test - public void maintenanceSkipsReductionWhenPregenDoesNotTargetWorld() { - assertTrue(IrisEngineSVC.shouldSkipMantleReductionForMaintenance(true, false)); - } - - @Test - public void maintenanceDoesNotSkipReductionForActivePregenWorld() { - assertFalse(IrisEngineSVC.shouldSkipMantleReductionForMaintenance(true, true)); - } - - @Test - public void noMaintenanceNeverSkipsReduction() { - assertFalse(IrisEngineSVC.shouldSkipMantleReductionForMaintenance(false, false)); - assertFalse(IrisEngineSVC.shouldSkipMantleReductionForMaintenance(false, true)); - } -} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisIntegrationServiceTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisIntegrationServiceTest.java new file mode 100644 index 000000000..3596783f2 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisIntegrationServiceTest.java @@ -0,0 +1,174 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.framework.EngineTelemetrySnapshot; +import art.arcane.volmlib.integration.IntegrationMetricGroup; +import art.arcane.volmlib.integration.IntegrationMetricSample; +import art.arcane.volmlib.integration.IntegrationMetricSchema; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisIntegrationServiceTest { + @Test + public void unavailableTelemetryDoesNotPublishFalseZeroes() { + IrisIntegrationService service = new IrisIntegrationService(() -> IrisTelemetrySnapshot.EMPTY); + Map samples = service.sampleMetrics(Set.of( + IntegrationMetricSchema.IRIS_WORLD_COUNT, + IntegrationMetricSchema.IRIS_PREGEN_QUEUE, + IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS)); + + for (IntegrationMetricSample sample : samples.values()) { + assertFalse(sample.available()); + assertEquals("telemetry-not-ready", sample.message()); + } + assertTrue(service.metricGroups().isEmpty()); + } + + @Test + public void publishesAggregateAndPerWorldMetricsFromOneImmutableSnapshot() { + long now = System.currentTimeMillis(); + EngineTelemetrySnapshot first = world("minecraft:overworld", "world", 12L, 20L, 4D, now); + EngineTelemetrySnapshot second = world("minecraft:the_nether", "world_nether", 8L, 30L, 2D, now); + IrisTelemetrySnapshot snapshot = telemetry( + List.of(first, second), + new IrisTelemetrySnapshot.PregenSnapshot( + true, + first.worldIdentity(), + first.worldName(), + false, + 50D, + 100L, + 200L, + 100L, + 8D, + 2_000L, + 5_000L, + 1L + ), + now + ); + IrisIntegrationService service = new IrisIntegrationService(() -> snapshot); + + Map samples = service.sampleMetrics(Set.of( + IntegrationMetricSchema.IRIS_WORLD_COUNT, + IntegrationMetricSchema.IRIS_ENGINE_PENDING_REGISTRATIONS, + IntegrationMetricSchema.IRIS_LOADED_CHUNKS, + IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL, + IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, + IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS, + "iris.unsupported" + )); + + assertEquals(2D, value(samples, IntegrationMetricSchema.IRIS_WORLD_COUNT), 0D); + assertEquals(3D, value(samples, IntegrationMetricSchema.IRIS_ENGINE_PENDING_REGISTRATIONS), 0D); + assertEquals(20D, value(samples, IntegrationMetricSchema.IRIS_LOADED_CHUNKS), 0D); + assertEquals(50D, value(samples, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL), 0D); + assertEquals(8D, value(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT), 0D); + assertEquals(4D, value(samples, IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS), 0D); + assertFalse(samples.get("iris.unsupported").available()); + assertEquals("unsupported-key", samples.get("iris.unsupported").message()); + + List groups = service.metricGroups(); + assertEquals(2, groups.size()); + IntegrationMetricGroup overworld = groups.get(0); + IntegrationMetricGroup nether = groups.get(1); + assertEquals("minecraft:overworld", overworld.scopeId()); + assertEquals(1D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_ACTIVE), 0D); + assertEquals(8D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT), 0D); + assertEquals("minecraft:the_nether", nether.scopeId()); + assertEquals(0D, value(nether.samples(), IntegrationMetricSchema.IRIS_PREGEN_ACTIVE), 0D); + assertFalse(nether.samples().get(IntegrationMetricSchema.IRIS_PREGEN_QUEUE).available()); + } + + @Test + public void publishesEveryManagedWorldWithoutAWorldCountCap() { + long now = System.currentTimeMillis(); + List worlds = new ArrayList<>(); + for (int index = 0; index < 48; index++) { + worlds.add(world("iris:world_" + index, "world_" + index, index, index, index, now)); + } + IrisIntegrationService service = new IrisIntegrationService( + () -> telemetry(worlds, IrisTelemetrySnapshot.PregenSnapshot.INACTIVE, now) + ); + + List groups = service.metricGroups(); + + assertEquals(48, groups.size()); + assertEquals(48, groups.stream().map(IntegrationMetricGroup::scopeId).distinct().count()); + } + + private static IrisTelemetrySnapshot telemetry( + List worlds, + IrisTelemetrySnapshot.PregenSnapshot pregenerator, + long now + ) { + IrisTelemetrySnapshot.CacheBucket resource = new IrisTelemetrySnapshot.CacheBucket(2, 10L, 100L); + IrisTelemetrySnapshot.CacheSnapshot caches = new IrisTelemetrySnapshot.CacheSnapshot( + resource, + resource, + IrisTelemetrySnapshot.CacheBucket.EMPTY, + IrisTelemetrySnapshot.CacheBucket.EMPTY, + IrisTelemetrySnapshot.CacheBucket.EMPTY + ); + return new IrisTelemetrySnapshot( + now, + worlds, + EngineTelemetrySnapshot.aggregate(worlds), + 1, + 4, + 0, + 3, + 0.5D, + 0.1D, + caches, + pregenerator + ); + } + + private static EngineTelemetrySnapshot world( + String identity, + String name, + long loadedChunks, + long generatedTotal, + double generationMs, + long now + ) { + return new EngineTelemetrySnapshot( + now, + identity, + name, + "dimension", + true, + false, + false, + false, + loadedChunks, + 3L, + 0.25D, + generatedTotal, + generatedTotal, + 2D, + 4L, + 2, + 1, + 1L, + 4L, + 1L, + 10D, + Map.of("total", generationMs) + ); + } + + private static double value(Map samples, String key) { + IntegrationMetricSample sample = samples.get(key); + assertTrue(sample.available()); + return sample.valueOr(-1D); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/TreeFellerEventOrderTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/TreeFellerEventOrderTest.java new file mode 100644 index 000000000..e3096e9eb --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/TreeFellerEventOrderTest.java @@ -0,0 +1,60 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.service.tree.TreeMarkerTraversal; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerItemHeldEvent; +import org.bukkit.event.player.PlayerSwapHandItemsEvent; +import org.bukkit.event.player.PlayerToggleSneakEvent; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TreeFellerEventOrderTest { + @Test + public void standaloneIntentPrecedesMonitorFinalization() throws Exception { + Method request = TreeFellerSVC.class.getMethod("requestStandalone", org.bukkit.event.block.BlockBreakEvent.class); + Method finalize = TreeFellerSVC.class.getMethod("finalizeBreak", org.bukkit.event.block.BlockBreakEvent.class); + EventHandler requestHandler = request.getAnnotation(EventHandler.class); + EventHandler finalizeHandler = finalize.getAnnotation(EventHandler.class); + + assertEquals(EventPriority.HIGHEST, requestHandler.priority()); + assertTrue(requestHandler.ignoreCancelled()); + assertEquals(EventPriority.MONITOR, finalizeHandler.priority()); + assertFalse(finalizeHandler.ignoreCancelled()); + } + + @Test + public void incompleteDiscoveryFallsOnlyTheTrigger() { + TreeMarkerTraversal.Position trigger = new TreeMarkerTraversal.Position(4, 80, -2); + TreeMarkerTraversal.Discovery incomplete = new TreeMarkerTraversal.Discovery( + List.of(trigger, new TreeMarkerTraversal.Position(4, 81, -2)), + false + ); + + assertEquals(List.of(trigger), TreeFellerSVC.positionsForFelling(incomplete, trigger)); + } + + @Test + public void playerControlChangesHaltRunsAtMonitor() throws Exception { + Method sneak = TreeFellerSVC.class.getMethod("haltWhenSneakingStops", PlayerToggleSneakEvent.class); + Method held = TreeFellerSVC.class.getMethod("haltWhenHeldSlotChanges", PlayerItemHeldEvent.class); + Method hands = TreeFellerSVC.class.getMethod("haltWhenHandsSwap", PlayerSwapHandItemsEvent.class); + + assertMonitorCancellationHandler(sneak); + assertMonitorCancellationHandler(held); + assertMonitorCancellationHandler(hands); + } + + private void assertMonitorCancellationHandler(Method method) { + EventHandler handler = method.getAnnotation(EventHandler.class); + + assertEquals(EventPriority.MONITOR, handler.priority()); + assertTrue(handler.ignoreCancelled()); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/TreeFellerPresentationTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/TreeFellerPresentationTest.java new file mode 100644 index 000000000..3126da560 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/TreeFellerPresentationTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.core.service; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class TreeFellerPresentationTest { + @Test + public void pulseSizeKeepsSmallTreesReadableAndLargeTreesBounded() { + assertEquals(4, TreeFellerPresentation.blocksPerPulse(1)); + assertEquals(4, TreeFellerPresentation.blocksPerPulse(240)); + assertEquals(10, TreeFellerPresentation.blocksPerPulse(600)); + assertEquals(64, TreeFellerPresentation.blocksPerPulse(100_000)); + } + + @Test + public void effectSamplingStaysBoundedPerPulse() { + assertEquals(1, TreeFellerPresentation.effectStride(4)); + assertEquals(1, TreeFellerPresentation.effectStride(16)); + assertEquals(2, TreeFellerPresentation.effectStride(17)); + assertEquals(2, TreeFellerPresentation.effectStride(31)); + assertEquals(2, TreeFellerPresentation.effectStride(32)); + assertEquals(3, TreeFellerPresentation.effectStride(33)); + assertEquals(4, TreeFellerPresentation.effectStride(64)); + } + +} diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index da00ef765..5c4b71332 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -120,7 +120,8 @@ dependencies { libs.fabricApi.eventsInteraction, libs.fabricApi.networking, libs.fabricApi.rendering, - libs.fabricApi.keyMapping + libs.fabricApi.keyMapping, + libs.fabricApi.permission ] fabricApi.each { Object notation -> add('jij', notation) diff --git a/adapters/fabric/logs/2026-07-20-1.log.gz b/adapters/fabric/logs/2026-07-20-1.log.gz new file mode 100644 index 000000000..275013d32 Binary files /dev/null and b/adapters/fabric/logs/2026-07-20-1.log.gz differ diff --git a/adapters/fabric/logs/2026-07-20-2.log.gz b/adapters/fabric/logs/2026-07-20-2.log.gz new file mode 100644 index 000000000..1b93056aa Binary files /dev/null and b/adapters/fabric/logs/2026-07-20-2.log.gz differ diff --git a/adapters/fabric/logs/2026-07-20-3.log.gz b/adapters/fabric/logs/2026-07-20-3.log.gz new file mode 100644 index 000000000..92c5eab29 Binary files /dev/null and b/adapters/fabric/logs/2026-07-20-3.log.gz differ diff --git a/adapters/fabric/logs/latest.log b/adapters/fabric/logs/latest.log index cf47ee89a..2789a5b18 100644 --- a/adapters/fabric/logs/latest.log +++ b/adapters/fabric/logs/latest.log @@ -1,5 +1,95 @@ -[17:42:11] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test' -[17:42:11] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[19:42:28] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test' +[19:42:28] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: second disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:55) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[19:42:28] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +java.lang.RuntimeException: first disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:54) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[19:42:28] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) @@ -44,7 +134,7 @@ java.lang.RuntimeException: cleanup failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[17:42:11] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[19:42:28] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: enable failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) @@ -92,7 +182,7 @@ java.lang.RuntimeException: enable failed Suppressed: java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ... 42 more -[17:42:11] [Test worker/ERROR]: [worldcheck] server stop request failed +[19:42:28] [Test worker/ERROR]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179) @@ -139,7 +229,7 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[17:42:11] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed +[19:42:28] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194) @@ -186,7 +276,7 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[17:42:11] [Test worker/ERROR]: [worldcheck] check failed +[19:42:28] [Test worker/ERROR]: [worldcheck] check failed java.lang.IllegalStateException: check failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174) @@ -233,8 +323,8 @@ java.lang.IllegalStateException: check failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[17:42:11] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success' -[17:42:11] [Test worker/ERROR]: Iris custom content provider discovery failed +[19:42:28] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success' +[19:42:28] [Test worker/ERROR]: Iris custom content provider discovery failed java.lang.RuntimeException: provider init failed at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java index fbde3b847..b2855b32c 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java @@ -19,15 +19,27 @@ package art.arcane.iris.fabric; import art.arcane.iris.modded.ModdedLoader; +import art.arcane.iris.modded.service.ModdedTreeFellerService; import net.fabricmc.api.EnvType; +import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; +import net.fabricmc.fabric.api.permission.v1.PermissionContextOwner; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.PermissionLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; import java.io.File; import java.nio.file.Path; public final class FabricModdedLoader implements ModdedLoader { + private static final Identifier TREE_FELLER_PERMISSION = Identifier.fromNamespaceAndPath("iris", "treefeller"); + @Override public String platformName() { return "fabric"; @@ -74,4 +86,24 @@ public final class FabricModdedLoader implements ModdedLoader { .map((Path p) -> p.toFile()) .orElse(null); } + + @Override + public boolean hasTreeFellerPermission(ServerPlayer player) { + return ((PermissionContextOwner) player).checkPermission( + TREE_FELLER_PERMISSION, + PermissionLevel.GAMEMASTERS + ); + } + + @Override + public boolean canTreeFellerBreak( + ServerLevel level, + ServerPlayer player, + BlockPos position, + BlockState state + ) { + BlockEntity blockEntity = state.hasBlockEntity() ? level.getBlockEntity(position) : null; + return ModdedTreeFellerService.runBreakProbe(() -> PlayerBlockBreakEvents.BEFORE.invoker() + .beforeBlockBreak(level, player, position, state, blockEntity)); + } } diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java index 29b8252ec..357e26a91 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java @@ -42,6 +42,7 @@ import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.player.Player; @@ -60,13 +61,19 @@ public final class IrisFabricBootstrap implements ModInitializer { ServerLifecycleEvents.SERVER_STARTED.register((MinecraftServer server) -> ModdedEngineBootstrap.serverStarted(server)); ServerLifecycleEvents.SERVER_STOPPING.register((MinecraftServer server) -> ModdedEngineBootstrap.stop()); ServerLevelEvents.LOAD.register((MinecraftServer server, ServerLevel level) -> ModdedEngineBootstrap.levelLoaded(level)); + ServerLevelEvents.UNLOAD.register((MinecraftServer server, ServerLevel level) -> ModdedEngineBootstrap.levelUnloaded(level)); CommandRegistrationCallback.EVENT.register((CommandDispatcher dispatcher, CommandBuildContext buildContext, Commands.CommandSelection selection) -> IrisModdedCommands.register(dispatcher)); PlayerBlockBreakEvents.BEFORE.register((Level level, Player player, BlockPos pos, BlockState state, BlockEntity blockEntity) -> { - if (level instanceof ServerLevel serverLevel) { - ModdedBlockBreakHandler.prepare(serverLevel, pos, state); + if (level instanceof ServerLevel serverLevel && player instanceof ServerPlayer serverPlayer) { + ModdedBlockBreakHandler.prepare(serverLevel, serverPlayer, pos, state); } return true; }); + PlayerBlockBreakEvents.CANCELED.register((Level level, Player player, BlockPos pos, BlockState state, BlockEntity blockEntity) -> { + if (level instanceof ServerLevel serverLevel) { + ModdedBlockBreakHandler.cancel(serverLevel, pos); + } + }); AttackBlockCallback.EVENT.register((Player player, Level level, InteractionHand hand, BlockPos pos, Direction direction) -> ModdedWandService.attackBlock(player, level, hand, pos) ? InteractionResult.SUCCESS : InteractionResult.PASS); UseBlockCallback.EVENT.register((Player player, Level level, InteractionHand hand, BlockHitResult hit) -> diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockItemMixin.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockItemMixin.java new file mode 100644 index 000000000..723c4696b --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockItemMixin.java @@ -0,0 +1,25 @@ +package art.arcane.iris.fabric.mixin; + +import art.arcane.iris.modded.ModdedBlockBreakHandler; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.block.state.BlockState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(BlockItem.class) +public final class BlockItemMixin { + @Inject(method = "placeBlock", at = @At("RETURN")) + private void iris$clearTreeProvenance( + BlockPlaceContext context, + BlockState state, + CallbackInfoReturnable info + ) { + if (info.getReturnValue() && context.getLevel() instanceof ServerLevel level) { + ModdedBlockBreakHandler.clearPlacedProvenance(level, context.getClickedPos()); + } + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockMixin.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockMixin.java index df0c3abf0..76fd7366d 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockMixin.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockMixin.java @@ -48,6 +48,10 @@ public class BlockMixin { if (result == null) { return; } + if (result.routeCombinedDrops(info.getReturnValue())) { + info.setReturnValue(List.of()); + return; + } List drops = result.replaceVanillaDrops() ? new ArrayList<>() : new ArrayList<>(info.getReturnValue()); diff --git a/adapters/fabric/src/main/resources/irisworldgen.mixins.json b/adapters/fabric/src/main/resources/irisworldgen.mixins.json index c2f379c1c..5cd269f68 100644 --- a/adapters/fabric/src/main/resources/irisworldgen.mixins.json +++ b/adapters/fabric/src/main/resources/irisworldgen.mixins.json @@ -4,6 +4,7 @@ "package": "art.arcane.iris.fabric.mixin", "compatibilityLevel": "JAVA_25", "mixins": [ + "BlockItemMixin", "BlockMixin", "ServerPacksSourceMixin" ], diff --git a/adapters/forge/logs/2026-07-20-1.log.gz b/adapters/forge/logs/2026-07-20-1.log.gz new file mode 100644 index 000000000..0f21c97e1 Binary files /dev/null and b/adapters/forge/logs/2026-07-20-1.log.gz differ diff --git a/adapters/forge/logs/debug-1.log.gz b/adapters/forge/logs/debug-1.log.gz index 311ea1b5d..3c86ece52 100644 Binary files a/adapters/forge/logs/debug-1.log.gz and b/adapters/forge/logs/debug-1.log.gz differ diff --git a/adapters/forge/logs/debug-2.log.gz b/adapters/forge/logs/debug-2.log.gz index 9bc216e76..311ea1b5d 100644 Binary files a/adapters/forge/logs/debug-2.log.gz and b/adapters/forge/logs/debug-2.log.gz differ diff --git a/adapters/forge/logs/debug-3.log.gz b/adapters/forge/logs/debug-3.log.gz index b8467bbe0..9bc216e76 100644 Binary files a/adapters/forge/logs/debug-3.log.gz and b/adapters/forge/logs/debug-3.log.gz differ diff --git a/adapters/forge/logs/debug-4.log.gz b/adapters/forge/logs/debug-4.log.gz index d4536c195..b8467bbe0 100644 Binary files a/adapters/forge/logs/debug-4.log.gz and b/adapters/forge/logs/debug-4.log.gz differ diff --git a/adapters/forge/logs/debug-5.log.gz b/adapters/forge/logs/debug-5.log.gz index 1ca6f48af..d4536c195 100644 Binary files a/adapters/forge/logs/debug-5.log.gz and b/adapters/forge/logs/debug-5.log.gz differ diff --git a/adapters/forge/logs/debug.log b/adapters/forge/logs/debug.log index 57e386809..e33106aec 100644 --- a/adapters/forge/logs/debug.log +++ b/adapters/forge/logs/debug.log @@ -1,8 +1,98 @@ -[18Jul2026 17:42:16.031] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework -[18Jul2026 17:42:16.033] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple -[18Jul2026 17:42:16.033] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4 -[18Jul2026 17:42:18.043] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' -[18Jul2026 17:42:18.104] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[20Jul2026 19:42:36.918] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework +[20Jul2026 19:42:36.919] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple +[20Jul2026 19:42:36.919] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4 +[20Jul2026 19:42:38.772] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[20Jul2026 19:42:38.831] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: second disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:55) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[20Jul2026 19:42:38.837] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +java.lang.RuntimeException: first disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:54) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[20Jul2026 19:42:38.840] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -47,7 +137,7 @@ java.lang.RuntimeException: cleanup failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[18Jul2026 17:42:18.110] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[20Jul2026 19:42:38.843] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: enable failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -95,7 +185,7 @@ java.lang.RuntimeException: enable failed Suppressed: java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] ... 42 more -[18Jul2026 17:42:18.144] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +[20Jul2026 19:42:38.875] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179) ~[main/:?] @@ -142,7 +232,7 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[18Jul2026 17:42:18.147] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +[20Jul2026 19:42:38.878] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194) ~[main/:?] @@ -189,7 +279,7 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[18Jul2026 17:42:18.154] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +[20Jul2026 19:42:38.883] [Test worker/ERROR] [Iris/]: [worldcheck] check failed java.lang.IllegalStateException: check failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174) ~[main/:?] @@ -236,8 +326,8 @@ java.lang.IllegalStateException: check failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[18Jul2026 17:42:18.160] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' -[18Jul2026 17:42:18.161] [Test worker/ERROR] [Iris/]: Iris custom content provider discovery failed +[20Jul2026 19:42:38.889] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' +[20Jul2026 19:42:38.889] [Test worker/ERROR] [Iris/]: Iris custom content provider discovery failed java.lang.RuntimeException: provider init failed at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] diff --git a/adapters/forge/logs/latest.log b/adapters/forge/logs/latest.log index 6f345dfd4..8cb7e60aa 100644 --- a/adapters/forge/logs/latest.log +++ b/adapters/forge/logs/latest.log @@ -1,5 +1,95 @@ -[18Jul2026 17:42:18.043] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' -[18Jul2026 17:42:18.104] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[20Jul2026 19:42:38.772] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[20Jul2026 19:42:38.831] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: second disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:55) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[20Jul2026 19:42:38.837] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +java.lang.RuntimeException: first disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:54) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[20Jul2026 19:42:38.840] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -44,7 +134,7 @@ java.lang.RuntimeException: cleanup failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[18Jul2026 17:42:18.110] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[20Jul2026 19:42:38.843] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: enable failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -92,7 +182,7 @@ java.lang.RuntimeException: enable failed Suppressed: java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] ... 42 more -[18Jul2026 17:42:18.144] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +[20Jul2026 19:42:38.875] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179) ~[main/:?] @@ -139,7 +229,7 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[18Jul2026 17:42:18.147] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +[20Jul2026 19:42:38.878] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194) ~[main/:?] @@ -186,7 +276,7 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[18Jul2026 17:42:18.154] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +[20Jul2026 19:42:38.883] [Test worker/ERROR] [Iris/]: [worldcheck] check failed java.lang.IllegalStateException: check failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174) ~[main/:?] @@ -233,8 +323,8 @@ java.lang.IllegalStateException: check failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[18Jul2026 17:42:18.160] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' -[18Jul2026 17:42:18.161] [Test worker/ERROR] [Iris/]: Iris custom content provider discovery failed +[20Jul2026 19:42:38.889] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' +[20Jul2026 19:42:38.889] [Test worker/ERROR] [Iris/]: Iris custom content provider discovery failed java.lang.RuntimeException: provider init failed at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java index a9f2b65c0..50cfcbf4c 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java @@ -19,18 +19,37 @@ package art.arcane.iris.forge; import art.arcane.iris.modded.ModdedLoader; +import art.arcane.iris.modded.service.ModdedTreeFellerService; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.common.util.Result; +import net.minecraftforge.event.level.BlockEvent; import net.minecraftforge.fml.ModList; import net.minecraftforge.fml.loading.FMLEnvironment; import net.minecraftforge.fml.loading.FMLLoader; import net.minecraftforge.fml.loading.FMLPaths; import net.minecraftforge.forgespi.language.IModFileInfo; import net.minecraftforge.server.ServerLifecycleHooks; +import net.minecraftforge.server.permission.PermissionAPI; +import net.minecraftforge.server.permission.nodes.PermissionNode; +import net.minecraftforge.server.permission.nodes.PermissionTypes; import java.io.File; import java.nio.file.Path; public final class ForgeModdedLoader implements ModdedLoader { + public static final PermissionNode TREE_FELLER_PERMISSION = new PermissionNode<>( + "iris", + "treefeller", + PermissionTypes.BOOLEAN, + (player, playerId, contexts) -> + player != null && Commands.LEVEL_GAMEMASTERS.check(player.permissions()) + ); + @Override public String platformName() { return "forge"; @@ -73,4 +92,29 @@ public final class ForgeModdedLoader implements ModdedLoader { IModFileInfo info = ModList.getModFileById("irisworldgen"); return info == null ? null : info.getFile().getFilePath().toFile(); } + + @Override + public boolean hasTreeFellerPermission(ServerPlayer player) { + return PermissionAPI.getPermission(player, TREE_FELLER_PERMISSION); + } + + @Override + public boolean canTreeFellerBreak( + ServerLevel level, + ServerPlayer player, + BlockPos position, + BlockState state + ) { + return ModdedTreeFellerService.runBreakProbe(() -> { + BlockEvent.BreakEvent event = new BlockEvent.BreakEvent( + level, + position, + state, + player, + Result.DEFAULT + ); + BlockEvent.BreakEvent.BUS.post(event); + return !event.getResult().isDenied(); + }); + } } diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBlockLootModifier.java b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBlockLootModifier.java index 22c095df9..1fecce628 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBlockLootModifier.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBlockLootModifier.java @@ -65,6 +65,10 @@ public final class IrisForgeBlockLootModifier extends LootModifier { if (result == null) { return generatedLoot; } + if (result.routeCombinedDrops(generatedLoot)) { + generatedLoot.clear(); + return generatedLoot; + } if (result.replaceVanillaDrops()) { generatedLoot.clear(); } diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java index 478994553..9c7f9e8bd 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java @@ -44,12 +44,15 @@ import net.minecraftforge.event.server.ServerStartingEvent; import net.minecraftforge.event.server.ServerStoppingEvent; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.common.loot.IGlobalLootModifier; +import net.minecraftforge.common.util.BlockSnapshot; +import net.minecraftforge.eventbus.api.listener.Priority; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.loading.FMLEnvironment; import net.minecraftforge.fml.loading.FMLLoader; import net.minecraftforge.registries.DeferredRegister; import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.server.permission.events.PermissionGatherEvent; import java.util.function.Predicate; @@ -80,6 +83,11 @@ public final class IrisForgeBootstrap { ModdedEngineBootstrap.levelLoaded(level); } }); + LevelEvent.Unload.BUS.addListener((LevelEvent.Unload event) -> { + if (event.getLevel() instanceof ServerLevel level) { + ModdedEngineBootstrap.levelUnloaded(level); + } + }); PlayerEvent.PlayerLoggedInEvent.BUS.addListener((PlayerEvent.PlayerLoggedInEvent event) -> { if (event.getEntity() instanceof ServerPlayer player) { ModdedProtocolHandler.onPlayerJoin(player); @@ -96,11 +104,32 @@ public final class IrisForgeBootstrap { } }); RegisterCommandsEvent.BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); + PermissionGatherEvent.Nodes.BUS.addListener((PermissionGatherEvent.Nodes event) -> + event.addNodes(ForgeModdedLoader.TREE_FELLER_PERMISSION)); BlockEvent.BreakEvent.BUS.addListener((BlockEvent.BreakEvent event) -> { - if (event.getLevel() instanceof ServerLevel level && !event.getResult().isDenied()) { - ModdedBlockBreakHandler.prepare(level, event.getPos(), event.getState()); + if (event.getLevel() instanceof ServerLevel level + && event.getPlayer() instanceof ServerPlayer player + && !event.getResult().isDenied()) { + ModdedBlockBreakHandler.prepare(level, player, event.getPos(), event.getState()); } }); + BlockEvent.EntityPlaceEvent.BUS.addListener(Priority.MONITOR, false, (BlockEvent.EntityPlaceEvent event) -> { + if (!(event instanceof BlockEvent.EntityMultiPlaceEvent) + && event.getLevel() instanceof ServerLevel level) { + ModdedBlockBreakHandler.clearPlacedProvenance(level, event.getPos()); + } + }); + BlockEvent.EntityMultiPlaceEvent.BUS.addListener( + Priority.MONITOR, + false, + (BlockEvent.EntityMultiPlaceEvent event) -> { + for (BlockSnapshot snapshot : event.getReplacedBlockSnapshots()) { + if (snapshot.getLevel() instanceof ServerLevel level) { + ModdedBlockBreakHandler.clearPlacedProvenance(level, snapshot.getPos()); + } + } + } + ); PlayerInteractEvent.LeftClickBlock.BUS.addListener((Predicate) (PlayerInteractEvent.LeftClickBlock event) -> ModdedWandService.attackBlock(event.getEntity(), event.getLevel(), event.getHand(), event.getPos())); PlayerInteractEvent.RightClickBlock.BUS.addListener((Predicate) (PlayerInteractEvent.RightClickBlock event) -> diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java index ea09223e1..77f82d002 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java @@ -19,8 +19,11 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.math.RNG; import com.mojang.serialization.MapCodec; import net.minecraft.core.Holder; @@ -51,6 +54,7 @@ final class IrisModdedBiomeSource extends BiomeSource { private final ConcurrentHashMap> visibleBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> surfaceStructureBiomeCache = new ConcurrentHashMap<>(); + private final Set structureStateSources = ConcurrentHashMap.newKeySet(); private volatile IrisModdedChunkGenerator generator; private volatile Set possibleStructureBiomeKeys; @@ -67,6 +71,9 @@ final class IrisModdedBiomeSource extends BiomeSource { structureBiomeCache.clear(); surfaceStructureBiomeCache.clear(); possibleStructureBiomeKeys = null; + for (StructureStateBiomeSource source : structureStateSources) { + source.clearCache(); + } } BiomeSource forStructureState(HolderLookup structureSets) { @@ -91,7 +98,9 @@ final class IrisModdedBiomeSource extends BiomeSource { } } }); - return new StructureStateBiomeSource(this, Set.copyOf(possible)); + StructureStateBiomeSource source = new StructureStateBiomeSource(this, Set.copyOf(possible)); + structureStateSources.add(source); + return source; } @Override @@ -134,10 +143,19 @@ final class IrisModdedBiomeSource extends BiomeSource { @Override public Holder getNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) { Engine engine = engineOrNull(); - if (!isReady(engine)) { + if (engine == null) { return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); } - return getNoiseBiome(engine, quartX, quartY, quartZ, sampler); + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome"); + if (lease == null) { + throw new IllegalStateException("Iris structure biome lookup was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); + } + return getNoiseBiome(engine, quartX, quartY, quartZ, sampler); + } } private Holder getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ, @@ -160,24 +178,46 @@ final class IrisModdedBiomeSource extends BiomeSource { Holder getVisibleNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) { Engine engine = engineOrNull(); - if (!isReady(engine)) { + if (engine == null) { return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); } - long key = packNoiseKey(quartX, quartY, quartZ); - Holder cached = visibleBiomeCache.get(key); - if (cached != null) { - return cached; + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_visible_biome"); + if (lease == null) { + throw new IllegalStateException("Iris visible biome lookup was rejected during an engine transition"); } - Holder resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler); - Holder existing = visibleBiomeCache.putIfAbsent(key, resolved); - if (visibleBiomeCache.size() > BIOME_CACHE_MAX) { - visibleBiomeCache.clear(); + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); + } + long key = packNoiseKey(quartX, quartY, quartZ); + Holder cached = visibleBiomeCache.get(key); + if (cached != null) { + return cached; + } + Holder resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler); + Holder existing = visibleBiomeCache.putIfAbsent(key, resolved); + if (visibleBiomeCache.size() > BIOME_CACHE_MAX) { + visibleBiomeCache.clear(); + } + return existing == null ? resolved : existing; } - return existing == null ? resolved : existing; } boolean isStructureReachable(Holder structure) { - Set possible = possibleStructureBiomeKeys(); + Engine engine = engineOrNull(); + if (engine == null) { + return isStructureReachable(structure, possibleStructureBiomeKeys()); + } + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_reachability"); + if (lease == null) { + throw new IllegalStateException("Iris structure reachability was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + return isStructureReachable(structure, possibleStructureBiomeKeys()); + } + } + + private boolean isStructureReachable(Holder structure, Set possible) { for (Holder biome : structure.value().biomes()) { String key = holderKey(biome); if (isGeneratedBiomeKey(key, possible)) { @@ -191,26 +231,35 @@ final class IrisModdedBiomeSource extends BiomeSource { public Set> getBiomesWithin(int x, int y, int z, int radius, Climate.Sampler sampler) { int minQuartY = QuartPos.fromBlock(y - radius); Engine engine = engineOrNull(); - if (!isReady(engine)) { + if (engine == null) { return super.getBiomesWithin(x, y, z, radius, sampler); } - boolean monumentQuery = isMonumentSurfaceBiomeQuery( - y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); - if (!monumentQuery && !isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight())) { - return super.getBiomesWithin(x, y, z, radius, sampler); + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_biomes_within"); + if (lease == null) { + throw new IllegalStateException("Iris biome radius lookup was rejected during an engine transition"); } - int minQuartX = QuartPos.fromBlock(x - radius); - int maxQuartX = QuartPos.fromBlock(x + radius); - int minQuartZ = QuartPos.fromBlock(z - radius); - int maxQuartZ = QuartPos.fromBlock(z + radius); - int columns = (maxQuartX - minQuartX + 1) * (maxQuartZ - minQuartZ + 1); - Set> biomes = new HashSet<>(columns); - for (int quartZ = minQuartZ; quartZ <= maxQuartZ; quartZ++) { - for (int quartX = minQuartX; quartX <= maxQuartX; quartX++) { - biomes.add(getSurfaceStructureBiome(engine, quartX, quartZ, sampler)); + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris biome radius lookup has no active engine runtime"); } + boolean monumentQuery = isMonumentSurfaceBiomeQuery( + y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); + if (!monumentQuery && !isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight())) { + return super.getBiomesWithin(x, y, z, radius, sampler); + } + int minQuartX = QuartPos.fromBlock(x - radius); + int maxQuartX = QuartPos.fromBlock(x + radius); + int minQuartZ = QuartPos.fromBlock(z - radius); + int maxQuartZ = QuartPos.fromBlock(z + radius); + int columns = (maxQuartX - minQuartX + 1) * (maxQuartZ - minQuartZ + 1); + Set> biomes = new HashSet<>(columns); + for (int quartZ = minQuartZ; quartZ <= maxQuartZ; quartZ++) { + for (int quartX = minQuartX; quartX <= maxQuartX; quartX++) { + biomes.add(getSurfaceStructureBiome(engine, quartX, quartZ, sampler)); + } + } + return biomes; } - return biomes; } private Holder getSurfaceStructureBiome(Engine engine, int quartX, int quartZ, @@ -338,28 +387,37 @@ final class IrisModdedBiomeSource extends BiomeSource { throw new IllegalStateException("Iris structure biome source is not bound to its generator"); } Engine engine = current.awaitStructureEngine(); - Registry registry = biomeRegistry(); - if (registry == null) { - throw new IllegalStateException("Iris structure biome lookup has no biome registry"); + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_state_biome"); + if (lease == null) { + throw new IllegalStateException("Iris structure biome lookup was rejected during an engine transition"); } - IrisBiome irisBiome; - if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { - irisBiome = engine.getComplex().getTrueBiomeStream().get(quartX << 2, quartZ << 2); - } else { - BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); - irisBiome = resolution == null ? null : resolution.irisBiome(); + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); + } + Registry registry = biomeRegistry(); + if (registry == null) { + throw new IllegalStateException("Iris structure biome lookup has no biome registry"); + } + IrisBiome irisBiome; + if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { + irisBiome = engine.getComplex().getTrueBiomeStream().get(quartX << 2, quartZ << 2); + } else { + BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); + irisBiome = resolution == null ? null : resolution.irisBiome(); + } + if (irisBiome == null) { + throw new IllegalStateException("Iris returned no structure biome at quart " + + quartX + "," + quartY + "," + quartZ); + } + String derivativeKey = irisBiome.getStructureDerivativeKey(); + Holder resolved = resolveHolder(registry, derivativeKey); + if (resolved == null) { + throw new IllegalStateException("Iris structure biome derivative '" + derivativeKey + + "' is not registered at quart " + quartX + "," + quartY + "," + quartZ); + } + return resolved; } - if (irisBiome == null) { - throw new IllegalStateException("Iris returned no structure biome at quart " - + quartX + "," + quartY + "," + quartZ); - } - String derivativeKey = irisBiome.getStructureDerivativeKey(); - Holder resolved = resolveHolder(registry, derivativeKey); - if (resolved == null) { - throw new IllegalStateException("Iris structure biome derivative '" + derivativeKey - + "' is not registered at quart " + quartX + "," + quartY + "," + quartZ); - } - return resolved; } static long biomeResolutionSeed(long worldSeed, int blockX, int blockY, int blockZ) { @@ -403,6 +461,21 @@ final class IrisModdedBiomeSource extends BiomeSource { return current == null ? null : current.structureEngineOrNull(); } + private GenerationSessionLease tryAcquireGenerationLease(Engine engine, String operation) { + if (engine == null || engine.isClosed()) { + return null; + } + try { + return engine.acquireGenerationLease(operation); + } catch (GenerationSessionException e) { + if (engine.isClosing() || e.isExpectedTeardown()) { + return null; + } + throw new IllegalStateException("Iris biome source could not acquire generation session for " + + operation + ".", e); + } + } + private Set possibleStructureBiomeKeys() { Set cached = possibleStructureBiomeKeys; if (cached != null) { @@ -429,24 +502,33 @@ final class IrisModdedBiomeSource extends BiomeSource { return Set.of(); } Engine engine = current.structureEngineOrNull(); - if (!isReady(engine)) { + if (engine == null) { return current.configuredStructureBiomeKeys(); } - LinkedHashSet possible = new LinkedHashSet<>(); - String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); - for (IrisBiome irisBiome : engine.getAllBiomes()) { - String derivative = normalizeKey(irisBiome.getStructureDerivativeKey()); - if (derivative != null) { - possible.add(derivative); - } - if (!irisBiome.isCustom()) { - continue; - } - for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - possible.add(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); - } + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome_keys"); + if (lease == null) { + throw new IllegalStateException("Iris structure biome key lookup was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris structure biome key lookup has no active engine runtime"); + } + LinkedHashSet possible = new LinkedHashSet<>(); + String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); + for (IrisBiome irisBiome : engine.getAllBiomes()) { + String derivative = normalizeKey(irisBiome.getStructureDerivativeKey()); + if (derivative != null) { + possible.add(derivative); + } + if (!irisBiome.isCustom()) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + possible.add(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); + } + } + return Set.copyOf(possible); } - return Set.copyOf(possible); } private static Set registeredBiomeKeys(Registry registry) { @@ -517,6 +599,10 @@ final class IrisModdedBiomeSource extends BiomeSource { this.possibleBiomes = possibleBiomes; } + private void clearCache() { + resolvedBiomes.clear(); + } + @Override protected MapCodec codec() { throw new UnsupportedOperationException("Structure state biome sources are not serializable"); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index ec457e522..e1de286f4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -22,6 +22,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.object.IrisBiome; @@ -37,6 +38,7 @@ import art.arcane.iris.nativegen.NativeStructurePostProcessor; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.math.RNG; import com.mojang.datafixers.util.Pair; @@ -185,6 +187,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private final ConcurrentHashMap worldCheckStructureShifts = new ConcurrentHashMap<>(); private final AtomicBoolean announced = new AtomicBoolean(false); private volatile boolean vanillaSpawnBiomesInitialized; + private volatile boolean unloading; private volatile Engine engine; private volatile String activePack; private volatile String activeDimensionKey; @@ -221,6 +224,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } synchronized void repointAndBind(ServerLevel level, String pack, String packDimensionKey, long seed) { + requireBindingAllowed(); if (level.getChunkSource().getGenerator() != this) { throw new IllegalArgumentException("ServerLevel does not use Iris generator '" + dimensionKey + "'"); } @@ -258,11 +262,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } public synchronized void unbindEngine() { + unloading = true; ServerLevel level = boundLevel(); unbindEngine(level); } synchronized void unbindEngine(ServerLevel level) { + unloading = true; if (level != null) { ModdedWorldEngines.evictOrThrow(level); } @@ -322,13 +328,16 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { BlockPos pos, int radius, boolean findUnexplored) { Engine current = engine(); - Pair> irisPlaced = findNearestIrisStructure( - level, holders, pos, Math.max(1, radius), findUnexplored, current); - HolderSet reachable = filterReachableNativeStructures(level, holders, current); - Pair> nativeLocated = reachable.size() == 0 - ? null - : super.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); - return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); + try (GenerationSessionLease lease = requireGenerationLease(current, "modded_structure_locate"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + Pair> irisPlaced = findNearestIrisStructure( + level, holders, pos, Math.max(1, radius), findUnexplored, current); + HolderSet reachable = filterReachableNativeStructures(level, holders, current); + Pair> nativeLocated = reachable.size() == 0 + ? null + : super.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); + return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); + } } public boolean isNativeStructureReachable(Holder structure) { @@ -412,8 +421,10 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private Engine engine() { + requireBindingAllowed(); Engine cached = engine; - if (cached != null) { + requireCompletedShutdown(cached); + if (cached != null && !cached.isClosed()) { return cached; } ServerLevel level = boundLevel(); @@ -423,14 +434,17 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return bindEngine(level); } - void bindLevel(ServerLevel level) { + synchronized void bindLevel(ServerLevel level) { if (level.getChunkSource().getGenerator() != this) { throw new IllegalArgumentException("ServerLevel does not use Iris generator '" + dimensionKey + "'"); } + requireCompletedShutdown(engine); + unloading = false; bindEngine(level); } private Engine bindEngine(ServerLevel level) { + requireBindingAllowed(); try { requireGlobalStructureGeneration( level.getServer().getWorldGenSettings().options().generateStructures(), dimensionKey); @@ -439,18 +453,22 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { throw error; } Engine cached = engine; + requireCompletedShutdown(cached); if (cached != null && !cached.isClosed() && cached.getComplex() != null) { engineBinding.complete(cached); return cached; } synchronized (this) { + requireBindingAllowed(); Engine existing = engine; + requireCompletedShutdown(existing); if (existing != null && !existing.isClosed() && existing.getComplex() != null) { engineBinding.complete(existing); return existing; } try { Engine created = ModdedWorldEngines.get(level, activePack, activeDimensionKey, seedOverride); + requireCompletedShutdown(created); if (created.isClosed() || created.getComplex() == null) { throw new IllegalStateException("Iris generator '" + dimensionKey + "' created an engine without a ready biome complex"); @@ -473,6 +491,19 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + private void requireCompletedShutdown(Engine current) { + if (current != null && current.isClosing() && !current.isClosed()) { + throw new IllegalStateException("Iris generator '" + dimensionKey + + "' cannot bind while its previous engine shutdown remains incomplete"); + } + } + + private void requireBindingAllowed() { + if (unloading) { + throw new IllegalStateException("Iris generator '" + dimensionKey + "' is unloading and cannot bind an engine"); + } + } + static void requireGlobalStructureGeneration(boolean enabled, String dimensionKey) { if (enabled) { return; @@ -483,8 +514,10 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private Engine engineOrNull() { + requireBindingAllowed(); Engine cached = engine; - if (cached != null) { + requireCompletedShutdown(cached); + if (cached != null && !cached.isClosed()) { return cached; } try { @@ -495,8 +528,10 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } Engine structureEngineOrNull() { + requireBindingAllowed(); engineBinding.throwIfFailed(dimensionKey); Engine cached = engine; + requireCompletedShutdown(cached); if (cached != null && !cached.isClosed() && cached.getComplex() != null) { return cached; } @@ -505,11 +540,14 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } Engine awaitStructureEngine() { + requireBindingAllowed(); Engine current = engine; + requireCompletedShutdown(current); if (current != null && !current.isClosed() && current.getComplex() != null) { return current; } Engine bound = engineBinding.await(dimensionKey); + requireCompletedShutdown(bound); if (bound.isClosed() || bound.getComplex() == null) { throw new IllegalStateException("Iris generator '" + dimensionKey + "' completed bootstrap without a ready biome complex"); @@ -518,7 +556,9 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private Engine requireDataQueryEngine(String operation) { + requireBindingAllowed(); Engine current = engine; + requireCompletedShutdown(current); if (current != null && !current.isClosed() && current.getComplex() != null) { return current; } @@ -561,18 +601,21 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return configuredStructureBiomeKeys; } Engine current = engine; - Iterable biomes; - String namespace; - if (current != null && !current.isClosed()) { - biomes = current.getAllBiomes(); - namespace = current.getDimension().getLoadKey(); - } else { - ConfiguredPack configured = configuredPack(); - Set resolved = collectConfiguredBiomeKeys(configured.dimension(), configured.data()); - configuredStructureBiomeKeys = resolved; - return resolved; + if (current != null && !current.isClosed() && !current.isClosing()) { + try (GenerationSessionLease lease = current.acquireGenerationLease("modded_configured_biome_keys"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + Set resolved = collectConfiguredBiomeKeys( + current.getAllBiomes(), current.getDimension().getLoadKey()); + configuredStructureBiomeKeys = resolved; + return resolved; + } catch (GenerationSessionException e) { + if (!current.isClosing() && !e.isExpectedTeardown()) { + throw new IllegalStateException("Iris configured biome lookup could not acquire its engine runtime.", e); + } + } } - Set resolved = collectConfiguredBiomeKeys(biomes, namespace); + ConfiguredPack configured = configuredPack(); + Set resolved = collectConfiguredBiomeKeys(configured.dimension(), configured.data()); configuredStructureBiomeKeys = resolved; return resolved; } @@ -654,7 +697,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } public Engine engineIfBound() { - return engine; + Engine current = engine; + return unloading || current == null || current.isClosing() || current.isClosed() ? null : current; } public Engine commandEngine() { @@ -710,23 +754,26 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return; } - String namespace = current.getDimension().getLoadKey().toLowerCase(Locale.ROOT); - for (IrisBiome irisBiome : current.getDimension().getReachableBiomes(current)) { - if (irisBiome == null || !irisBiome.isCustom()) { - continue; - } - Holder vanillaHolder = resolveBiomeHolder(registry, irisBiome.getVanillaDerivativeKey()); - if (vanillaHolder == null) { - continue; - } - for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - Holder customHolder = resolveBiomeHolder(registry, namespace + ":" + customBiome.getId()); - if (customHolder != null) { - vanillaSpawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder); + try (GenerationSessionLease lease = requireGenerationLease(current, "modded_spawn_biomes"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + String namespace = current.getDimension().getLoadKey().toLowerCase(Locale.ROOT); + for (IrisBiome irisBiome : current.getDimension().getReachableBiomes(current)) { + if (irisBiome == null || !irisBiome.isCustom()) { + continue; + } + Holder vanillaHolder = resolveBiomeHolder(registry, irisBiome.getVanillaDerivativeKey()); + if (vanillaHolder == null) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + Holder customHolder = resolveBiomeHolder(registry, namespace + ":" + customBiome.getId()); + if (customHolder != null) { + vanillaSpawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder); + } } } + vanillaSpawnBiomesInitialized = true; } - vanillaSpawnBiomesInitialized = true; } private Holder resolveBiomeHolder(Registry registry, String key) { @@ -750,8 +797,12 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public CompletableFuture createBiomes(RandomState randomState, Blender blender, StructureManager structureManager, ChunkAccess chunk) { - chunk.fillBiomesFromNoise(structureBiomeSource::getVisibleNoiseBiome, randomState.sampler()); - return CompletableFuture.completedFuture(chunk); + Engine current = engine(); + try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_biomes"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + chunk.fillBiomesFromNoise(structureBiomeSource::getVisibleNoiseBiome, randomState.sampler()); + return CompletableFuture.completedFuture(chunk); + } } @Override @@ -759,36 +810,46 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { Engine generationEngine = engine(); ChunkPos pos = chunk.getPos(); lastChunkGenAt = System.currentTimeMillis(); - if (announced.compareAndSet(false, true)) { - LOGGER.info("Iris generating {} through IrisModdedChunkGenerator (dim={} first chunk {},{})", - dimensionKey, generationEngine.getDimension().getLoadKey(), pos.x(), pos.z()); - } LOGGER.debug("Iris generating chunk {},{}", pos.x(), pos.z()); - int dimMinY = generationEngine.getMinHeight(); - int dimMaxY = generationEngine.getMaxHeight(); - int height = dimMaxY - dimMinY; PlatformBlockState air = IrisPlatforms.get().registries().air(); if (PARALLEL_CHUNK_SYSTEM) { return CompletableFuture.completedFuture( - generateTerrain(chunk, generationEngine, pos, dimMinY, height, air)); + generateTerrain(chunk, generationEngine, pos, air)); } return CompletableFuture.supplyAsync( - () -> generateTerrain(chunk, generationEngine, pos, dimMinY, height, air), + () -> generateTerrain(chunk, generationEngine, pos, air), genPool); } private ChunkAccess generateTerrain(ChunkAccess chunk, Engine generationEngine, ChunkPos pos, - int dimMinY, int height, PlatformBlockState air) { - ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air); - Hunk biomes = Hunk.newArrayHunk(16, height, 16); - try { + PlatformBlockState air) { + try (GenerationSessionLease lease = generationEngine.acquireGenerationLease("modded_chunk_pipeline"); + IrisContext.Scope ignored = IrisContext.open(generationEngine, lease.sessionId(), null)) { + if (announced.compareAndSet(false, true)) { + LOGGER.info("Iris generating {} through IrisModdedChunkGenerator (dim={} first chunk {},{})", + dimensionKey, generationEngine.getDimension().getLoadKey(), pos.x(), pos.z()); + } + int dimMinY = generationEngine.getMinHeight(); + int dimMaxY = generationEngine.getMaxHeight(); + int height = dimMaxY - dimMinY; + ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); generationEngine.generate(pos.getMinBlockX(), pos.getMinBlockZ(), blocks, biomes, false); + + writeBlocks(chunk, blocks, dimMinY, height); + writeTerrainHeightmaps(chunk, generationEngine, pos, height); + Heightmap.primeHeightmaps(chunk, EnumSet.of( + Heightmap.Types.MOTION_BLOCKING, + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES)); + ModdedWorldManager.enqueueGenerated(generationEngine, pos.x(), pos.z()); + return chunk; } catch (GenerationSessionException e) { - if (e.isExpectedTeardown()) { + if (generationEngine.isClosing() || e.isExpectedTeardown()) { LOGGER.debug("Iris chunk {},{} skipped: engine sealed for hotload/teardown", pos.x(), pos.z()); - return chunk; + throw new IllegalStateException( + "Iris chunk generation was rejected during an engine transition.", e); } LOGGER.error("Iris failed to generate chunk {},{}", pos.x(), pos.z(), e); throw new IllegalStateException("Iris generation failed for chunk " + pos.x() + "," + pos.z(), e); @@ -796,14 +857,6 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { LOGGER.error("Iris failed to generate chunk {},{}", pos.x(), pos.z(), e); throw new IllegalStateException("Iris generation failed for chunk " + pos.x() + "," + pos.z(), e); } - - writeBlocks(chunk, blocks, dimMinY, height); - writeTerrainHeightmaps(chunk, generationEngine, pos, height); - Heightmap.primeHeightmaps(chunk, EnumSet.of( - Heightmap.Types.MOTION_BLOCKING, - Heightmap.Types.MOTION_BLOCKING_NO_LEAVES)); - ModdedWorldManager.enqueueGenerated(generationEngine, pos.x(), pos.z()); - return chunk; } private void writeTerrainHeightmaps(ChunkAccess chunk, Engine generationEngine, ChunkPos pos, int height) { @@ -885,20 +938,31 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) { - placeVanillaStructures(level, chunk, structureManager); + Engine current = engine(); + try (GenerationSessionLease lease = requireGenerationLease(current, "modded_biome_decoration"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + placeVanillaStructures(level, chunk, structureManager); + } } @Override public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess chunk, StructureTemplateManager templateManager, ResourceKey levelKey) { Engine current = engine(); - Map previousStarts = new HashMap<>(chunk.getAllStarts()); - super.createStructures(registryAccess, structureState, structureManager, chunk, templateManager, levelKey); - adjustGeneratedStructures(registryAccess, chunk, previousStarts, current); + try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_structures"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + Map previousStarts = new HashMap<>(chunk.getAllStarts()); + super.createStructures(registryAccess, structureState, structureManager, chunk, templateManager, levelKey); + adjustGeneratedStructures(registryAccess, chunk, previousStarts, current); + } } @Override public void createReferences(WorldGenLevel level, StructureManager structureManager, ChunkAccess chunk) { - super.createReferences(level, structureManager, chunk); + Engine current = engine(); + try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_references"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + super.createReferences(level, structureManager, chunk); + } } private void adjustGeneratedStructures(RegistryAccess registryAccess, ChunkAccess chunk, @@ -1168,7 +1232,12 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor, RandomState randomState) { Engine current = requireDataQueryEngine("base height"); boolean ignoreFluid = !type.isOpaque().test(Blocks.WATER.defaultBlockState()); - return heightAccessor.getMinY() + current.getHeight(x, z, ignoreFluid) + 1; + try (GenerationSessionLease lease = current.acquireGenerationLease("modded_base_height"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + return heightAccessor.getMinY() + current.getHeight(x, z, ignoreFluid) + 1; + } catch (GenerationSessionException e) { + throw new IllegalStateException("Iris base height query could not acquire its engine runtime.", e); + } } @Override @@ -1176,17 +1245,30 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { int minY = heightAccessor.getMinY(); BlockState[] states = new BlockState[heightAccessor.getHeight()]; Engine current = requireDataQueryEngine("base column"); - BlockState airState = Blocks.AIR.defaultBlockState(); - int surface = current.getHeight(x, z, true); - int fluid = current.getHeight(x, z, false); - BlockState stone = Blocks.STONE.defaultBlockState(); - BlockState water = Blocks.WATER.defaultBlockState(); - int solidEnd = Math.max(0, Math.min(states.length, surface + 1)); - int fluidEnd = Math.max(solidEnd, Math.max(0, Math.min(states.length, fluid + 1))); - Arrays.fill(states, 0, solidEnd, stone); - Arrays.fill(states, solidEnd, fluidEnd, water); - Arrays.fill(states, fluidEnd, states.length, airState); - return new NoiseColumn(minY, states); + try (GenerationSessionLease lease = current.acquireGenerationLease("modded_base_column"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + BlockState airState = Blocks.AIR.defaultBlockState(); + int surface = current.getHeight(x, z, true); + int fluid = current.getHeight(x, z, false); + BlockState stone = Blocks.STONE.defaultBlockState(); + BlockState water = Blocks.WATER.defaultBlockState(); + int solidEnd = Math.max(0, Math.min(states.length, surface + 1)); + int fluidEnd = Math.max(solidEnd, Math.max(0, Math.min(states.length, fluid + 1))); + Arrays.fill(states, 0, solidEnd, stone); + Arrays.fill(states, solidEnd, fluidEnd, water); + Arrays.fill(states, fluidEnd, states.length, airState); + return new NoiseColumn(minY, states); + } catch (GenerationSessionException e) { + throw new IllegalStateException("Iris base column query could not acquire its engine runtime.", e); + } + } + + private GenerationSessionLease requireGenerationLease(Engine current, String operation) { + try { + return current.acquireGenerationLease(operation); + } catch (GenerationSessionException exception) { + throw new IllegalStateException("Iris " + operation + " could not acquire its engine runtime.", exception); + } } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java index a56e794ed..9a871eb03 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java @@ -21,6 +21,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.LootResolver; +import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisBlockDrops; @@ -28,11 +29,13 @@ import art.arcane.iris.engine.object.IrisLoot; import art.arcane.iris.engine.object.IrisMarker; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.modded.service.ModdedTreeFellerService; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterMarker; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.util.Mth; import net.minecraft.util.RandomSource; import net.minecraft.world.entity.EntityTypes; @@ -43,6 +46,7 @@ import net.minecraft.world.level.chunk.ChunkGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; public final class ModdedBlockBreakHandler { @@ -52,12 +56,24 @@ public final class ModdedBlockBreakHandler { private ModdedBlockBreakHandler() { } - public static void prepare(ServerLevel level, BlockPos position, BlockState brokenState) { + public static void prepare( + ServerLevel level, + ServerPlayer player, + BlockPos position, + BlockState brokenState + ) { + if (ModdedTreeFellerService.isBreakProbe()) { + return; + } if (engineFor(level) == null) { return; } BreakKey key = new BreakKey(level, position.asLong()); - PendingBreak pending = new PendingBreak(brokenState); + ModdedTreeFellerService treeFeller = treeFellerService(); + ModdedTreeFellerService.PreparedOrigin preparedOrigin = treeFeller == null + ? null + : treeFeller.prepare(level, player, position, brokenState); + PendingBreak pending = new PendingBreak(brokenState, preparedOrigin); PENDING.put(key, pending); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler != null) { @@ -69,11 +85,15 @@ public final class ModdedBlockBreakHandler { PENDING.clear(); } + public static void cancel(ServerLevel level, BlockPos position) { + PENDING.remove(new BreakKey(level, position.asLong())); + } + public static Result complete(ServerLevel level, BlockPos position, BlockState fallbackState) { BreakKey key = new BreakKey(level, position.asLong()); PendingBreak pending = PENDING.remove(key); - BlockState brokenState = pending == null ? fallbackState : pending.brokenState(); - return evaluateSafely(level, position, brokenState); + PendingBreak resolved = pending == null ? new PendingBreak(fallbackState, null) : pending; + return evaluateSafely(level, position, resolved); } public static Result completePrepared(ServerLevel level, BlockPos position) { @@ -82,7 +102,7 @@ public final class ModdedBlockBreakHandler { if (pending == null || level.getBlockState(position).equals(pending.brokenState())) { return null; } - return PENDING.remove(key, pending) ? evaluateSafely(level, position, pending.brokenState()) : null; + return PENDING.remove(key, pending) ? evaluateSafely(level, position, pending) : null; } public static void spawn(ServerLevel level, BlockPos position, KList drops) { @@ -115,13 +135,15 @@ public final class ModdedBlockBreakHandler { if (level.getBlockState(position).equals(pending.brokenState())) { return; } - Result result = evaluateSafely(level, position, pending.brokenState()); - spawn(level, position, result.drops()); + Result result = evaluateSafely(level, position, pending); + if (!result.routeCombinedDrops(List.of())) { + spawn(level, position, result.drops()); + } } - private static Result evaluateSafely(ServerLevel level, BlockPos position, BlockState brokenState) { + private static Result evaluateSafely(ServerLevel level, BlockPos position, PendingBreak pending) { try { - return evaluate(level, position, brokenState); + return evaluate(level, position, pending); } catch (Throwable error) { LOGGER.error("Iris block-break processing failed at {},{},{} in {}", position.getX(), position.getY(), position.getZ(), level.dimension().identifier(), error); @@ -129,13 +151,56 @@ public final class ModdedBlockBreakHandler { } } - private static Result evaluate(ServerLevel level, BlockPos position, BlockState brokenState) { + private static Result evaluate(ServerLevel level, BlockPos position, PendingBreak pending) { Engine engine = engineFor(level); if (engine == null || engine.isClosed()) { return Result.empty(); } removeMarker(engine, position); + Result drops = evaluateDrops(level, position, pending.brokenState(), engine); + ModdedTreeFellerService treeFeller = treeFellerService(); + ModdedTreeFellerService.OriginDropRoute route = treeFeller == null || pending.preparedOrigin() == null + ? null + : treeFeller.completeOrigin(pending.preparedOrigin()); + if (route == null) { + clearTreeProvenance(engine, position); + } + return drops.withRoute(route); + } + + public static Result evaluateManagedDrops(ServerLevel level, BlockPos position, BlockState brokenState) { + Engine engine = engineFor(level); + if (engine == null || engine.isClosed()) { + return Result.empty(); + } + try { + return evaluateDrops(level, position, brokenState, engine); + } catch (Throwable error) { + LOGGER.error("Iris managed block-drop processing failed at {},{},{} in {}", position.getX(), position.getY(), position.getZ(), + level.dimension().identifier(), error); + return Result.empty(); + } + } + + public static void completeManagedBreak(ServerLevel level, BlockPos position) { + Engine engine = engineFor(level); + if (engine != null && !engine.isClosed()) { + removeMarker(engine, position); + clearTreeProvenance(engine, position); + } + } + + public static void clearPlacedProvenance(ServerLevel level, BlockPos position) { + completeManagedBreak(level, position); + } + + private static Result evaluateDrops( + ServerLevel level, + BlockPos position, + BlockState brokenState, + Engine engine + ) { KList providers = providers(engine, position, brokenState); if (providers.isEmpty()) { return Result.empty(); @@ -155,7 +220,7 @@ public final class ModdedBlockBreakHandler { } } } - return new Result(drops, replaceVanillaDrops); + return new Result(drops, replaceVanillaDrops, null); } private static void removeMarker(Engine engine, BlockPos position) { @@ -174,6 +239,12 @@ public final class ModdedBlockBreakHandler { } } + private static void clearTreeProvenance(Engine engine, BlockPos position) { + int mantleY = position.getY() - engine.getMinHeight(); + engine.getMantle().getMantle().remove(position.getX(), mantleY, position.getZ(), String.class); + engine.getMantle().getMantle().remove(position.getX(), mantleY, position.getZ(), TreeBlockMaterial.class); + } + private static KList providers(Engine engine, BlockPos position, BlockState brokenState) { KList providers = new KList<>(); IrisData data = engine.getData(); @@ -227,7 +298,7 @@ public final class ModdedBlockBreakHandler { return exact ? configuredState.equals(brokenState) : configuredState.getBlock() == brokenState.getBlock(); } - private static Engine engineFor(ServerLevel level) { + public static Engine engineFor(ServerLevel level) { ChunkGenerator generator = level.getChunkSource().getGenerator(); if (!(generator instanceof IrisModdedChunkGenerator irisGenerator)) { return null; @@ -244,15 +315,72 @@ public final class ModdedBlockBreakHandler { } } - public record Result(KList drops, boolean replaceVanillaDrops) { + private static ModdedTreeFellerService treeFellerService() { + return ModdedEngineBootstrap.services().service(ModdedTreeFellerService.class); + } + + public static final class Result { + private final KList drops; + private final boolean replaceVanillaDrops; + private final ModdedTreeFellerService.OriginDropRoute route; + + private Result( + KList drops, + boolean replaceVanillaDrops, + ModdedTreeFellerService.OriginDropRoute route + ) { + this.drops = drops; + this.replaceVanillaDrops = replaceVanillaDrops; + this.route = route; + } + + public KList drops() { + return drops; + } + + public boolean replaceVanillaDrops() { + return replaceVanillaDrops; + } + + public boolean routeCombinedDrops(Iterable vanillaDrops) { + if (route == null) { + return false; + } + return route.route(combinedDrops(vanillaDrops)); + } + + public KList combinedDrops(Iterable vanillaDrops) { + KList combined = new KList<>(); + if (!replaceVanillaDrops) { + for (ItemStack stack : vanillaDrops) { + if (stack != null && !stack.isEmpty()) { + combined.add(stack.copy()); + } + } + } + for (ItemStack stack : drops) { + if (stack != null && !stack.isEmpty()) { + combined.add(stack.copy()); + } + } + return combined; + } + + private Result withRoute(ModdedTreeFellerService.OriginDropRoute route) { + return new Result(drops, replaceVanillaDrops, route); + } + private static Result empty() { - return new Result(new KList<>(), false); + return new Result(new KList<>(), false, null); } } private record BreakKey(ServerLevel level, long position) { } - private record PendingBreak(BlockState brokenState) { + private record PendingBreak( + BlockState brokenState, + ModdedTreeFellerService.PreparedOrigin preparedOrigin + ) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index 49b9d6c8f..37f148ad1 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -175,12 +175,18 @@ public final class ModdedDimensionManager { } return false; } + IrisModdedChunkGenerator generator = level.getChunkSource().getGenerator() + instanceof IrisModdedChunkGenerator irisGenerator + ? irisGenerator + : null; + boolean generatorUnbound = false; try { evacuate(server, level); - if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) { - generator.unbindEngine(); + if (generator != null) { + generator.unbindEngine(level); + generatorUnbound = true; } - ModdedWorldEngines.evict(level); + ModdedWorldEngines.evictOrThrow(level); level.save(null, true, false); serverAccess.removeLevel(server, key); level.close(); @@ -191,12 +197,31 @@ public final class ModdedDimensionManager { LOGGER.info("Iris removed runtime dimension '{}'", dimensionId); return true; } catch (Throwable e) { + rollbackRemoval(server, serverAccess, key, level, generator, generatorUnbound, e); LOGGER.error("Iris failed to remove runtime dimension '{}'", dimensionId, e); throw new IllegalStateException("Iris runtime dimension removal failed for " + dimensionId, e); } } } + private static void rollbackRemoval(MinecraftServer server, ModdedServerAccess serverAccess, + ResourceKey key, ServerLevel level, + IrisModdedChunkGenerator generator, boolean generatorUnbound, + Throwable failure) { + try { + if (!generatorUnbound || generator == null || !serverAccess.hasLevel(server, key)) { + return; + } + generator.bindLevel(level); + } catch (Throwable rollbackFailure) { + if (rollbackFailure != failure) { + failure.addSuppressed(rollbackFailure); + } + LOGGER.error("Iris failed to restore the engine for retained runtime dimension '{}'", + key.identifier(), rollbackFailure); + } + } + public static boolean teleport(ServerPlayer player, MinecraftServer server, String dimensionId, double x, double y, double z) { ServerLevel level = level(server, dimensionId); if (level == null) { 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 684f92d3f..518682a85 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 @@ -42,6 +42,7 @@ import art.arcane.iris.modded.service.ModdedLogFilterService; import art.arcane.iris.modded.service.ModdedPreservationService; import art.arcane.iris.modded.service.ModdedSettingsHotloadService; import art.arcane.iris.modded.service.ModdedStudioHotloadService; +import art.arcane.iris.modded.service.ModdedTreeFellerService; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; @@ -135,29 +136,82 @@ public final class ModdedEngineBootstrap { } } + public static void levelUnloaded(ServerLevel level) { + if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) { + return; + } + + try { + generator.unbindEngine(level); + } catch (Throwable exception) { + LOGGER.error("Iris engine unload failed for {}", level.dimension().identifier(), exception); + if (exception instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (exception instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris engine unload failed for " + + level.dimension().identifier(), exception); + } + } + public static void stop() { MinecraftServer stoppingServer = currentServer; - ModdedWorldCheck.serverStopped(stoppingServer); - ModdedProtocolHandler.stop(); - ModdedPregenJob.shutdown(); - ModdedObjectUndo.clearAll(); - ModdedWandService.clearAll(); - ModdedBlockBreakHandler.clear(); - ModdedStudioCommands.clear(); - ModdedWorldEngines.shutdown(); - ModdedPrimaryWorldRouter.clear(); - services().disableAll(); - ModdedDimensionManager.clear(); + Throwable failure = null; + failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer)); + failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop); + failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown); + failure = runStopStage(failure, "object undo", ModdedObjectUndo::clearAll); + failure = runStopStage(failure, "wand service", ModdedWandService::clearAll); + failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear); + failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear); + failure = runStopStage(failure, "services", () -> services().disableAll()); + failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown); + failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear); + failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear); ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { - scheduler.shutdown(); + failure = runStopStage(failure, "scheduler", scheduler::shutdown); } - IrisModdedChunkGenerator.shutdownGenPool(); - ModdedSentry.flush(); - ModdedStartup.reset(); - currentServer = null; - spawnCaptureServer = null; - initialSpawnWasDefault = false; + failure = runStopStage(failure, "generation pool", IrisModdedChunkGenerator::shutdownGenPool); + failure = runStopStage(failure, "sentry", ModdedSentry::flush); + failure = runStopStage(failure, "startup state", ModdedStartup::reset); + failure = runStopStage(failure, "server state", () -> { + currentServer = null; + spawnCaptureServer = null; + initialSpawnWasDefault = false; + }); + if (failure != null) { + LOGGER.error("Iris modded shutdown completed with failures", failure); + throw propagateStopFailure(failure); + } + } + + private static Throwable runStopStage(Throwable failure, String stage, StopAction action) { + try { + action.run(); + return failure; + } catch (Throwable stageFailure) { + LOGGER.error("Iris modded shutdown stage '{}' failed", stage, stageFailure); + if (failure == null) { + return stageFailure; + } + if (stageFailure != failure) { + failure.addSuppressed(stageFailure); + } + return failure; + } + } + + private static RuntimeException propagateStopFailure(Throwable failure) { + if (failure instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (failure instanceof Error fatalError) { + throw fatalError; + } + return new IllegalStateException("Iris modded shutdown completed with failures", failure); } private static void captureInitialSpawn(MinecraftServer server) { @@ -317,6 +371,7 @@ public final class ModdedEngineBootstrap { ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); createdServices.register(ModdedEntitySpawnService.class, new ModdedEntitySpawnService()); + createdServices.register(ModdedTreeFellerService.class, new ModdedTreeFellerService()); bindService(PreservationRegistry.class, preservation, rollback); bindService(EngineEffectsProvider.class, @@ -377,6 +432,11 @@ public final class ModdedEngineBootstrap { void restore() throws Throwable; } + @FunctionalInterface + private interface StopAction { + void run() throws Throwable; + } + private static final class BindRollback { private final ArrayDeque actions = new ArrayDeque<>(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java index d1754f161..03247425e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java @@ -18,7 +18,11 @@ package art.arcane.iris.modded; +import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.state.BlockState; import java.io.File; import java.nio.file.Path; @@ -39,4 +43,8 @@ public interface ModdedLoader { Path configDir(); File modJar(); + + boolean hasTreeFellerPermission(ServerPlayer player); + + boolean canTreeFellerBreak(ServerLevel level, ServerPlayer player, BlockPos position, BlockState state); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java index efd372504..c50e70516 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java @@ -26,7 +26,6 @@ import org.slf4j.LoggerFactory; import java.util.LinkedHashMap; import java.util.Map; -import java.util.function.Consumer; public final class ModdedServiceManager { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); @@ -92,7 +91,24 @@ public final class ModdedServiceManager { return; } enabled = false; - forEachReversed(this::disableService); + Throwable failure = null; + ModdedService[] ordered = services.values().toArray(new ModdedService[0]); + for (int i = ordered.length - 1; i >= 0; i--) { + ModdedService service = ordered[i]; + try { + service.onDisable(); + } catch (Throwable serviceFailure) { + LOGGER.error("Iris service onDisable failed for {}", service.getClass().getName(), serviceFailure); + if (failure == null) { + failure = serviceFailure; + } else if (serviceFailure != failure) { + failure.addSuppressed(serviceFailure); + } + } + } + if (failure != null) { + throw new IllegalStateException("One or more Iris services failed to disable", failure); + } } synchronized void rollback(Throwable failure) { @@ -106,14 +122,6 @@ public final class ModdedServiceManager { services.clear(); } - private void disableService(ModdedService service) { - try { - service.onDisable(); - } catch (Throwable error) { - LOGGER.error("Iris service onDisable failed for {}", service.getClass().getName(), error); - } - } - private void tickService(ModdedTickableService service, MinecraftServer server) { try { service.onServerTick(server); @@ -144,10 +152,4 @@ public final class ModdedServiceManager { return new IllegalStateException("Iris service failed to enable: " + service.getClass().getName(), failure); } - private void forEachReversed(Consumer action) { - ModdedService[] ordered = services.values().toArray(new ModdedService[0]); - for (int i = ordered.length - 1; i >= 0; i--) { - action.accept(ordered[i]); - } - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index c6cda5784..d8e1b23c3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -102,7 +102,7 @@ public final class ModdedWorldEngines { ModdedEngineBootstrap.bind(); PackValidationRegistry.requireLoadable(pack); File packDir = resolvePack(pack, dimensionKey); - IrisData data = IrisData.get(packDir); + IrisData data = IrisData.openRuntime(packDir); IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); if (dimension == null) { LOGGER.error("Iris pack '{}' at {} does not contain dimension '{}' (expected dimensions/{}.json). Install a matching Iris pack and restart.", @@ -184,17 +184,28 @@ public final class ModdedWorldEngines { } public static void shutdown() { - for (Map.Entry entry : ENGINES.entrySet()) { + Throwable failure = null; + for (Map.Entry entry : new ArrayList<>(ENGINES.entrySet())) { + ServerLevel level = entry.getKey(); Engine engine = entry.getValue(); try { - if (!engine.isClosed()) { - engine.close(); + close(engine); + if (!ENGINES.remove(level, engine) && ENGINES.containsKey(level)) { + throw new IllegalStateException("Iris engine mapping changed during shutdown for " + + level.dimension().identifier()); } - LOGGER.info("Iris engine closed for {}", entry.getKey().dimension().identifier()); + LOGGER.info("Iris engine closed for {}", level.dimension().identifier()); } catch (Throwable e) { - LOGGER.error("Iris engine close failed for {}", entry.getKey().dimension().identifier(), e); + LOGGER.error("Iris engine close failed for {}", level.dimension().identifier(), e); + if (failure == null) { + failure = e; + } else if (e != failure) { + failure.addSuppressed(e); + } } } - ENGINES.clear(); + if (failure != null) { + throw new IllegalStateException("One or more Iris engines failed to close; failed mappings were retained", failure); + } } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index f0ab19213..64ee6630e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -22,6 +22,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineLifecycleTasks; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.IRare; @@ -79,6 +80,9 @@ public final class ModdedWorldManager implements EngineWorldManager { private long lastAmbientAt; private long lastCountAt; private long lastInitialRecoveryAt; + private boolean initialSpawnQueueClosed; + private boolean mantleWarmupExecutorStopped; + private boolean mantleWarmupsCleared; private volatile boolean closed; private volatile int cachedEntityCount; private volatile int cachedConsideredChunks; @@ -116,6 +120,10 @@ public final class ModdedWorldManager implements EngineWorldManager { } public void serverTick(ServerLevel level) { + EngineLifecycleTasks.run(engine, "modded_world_manager_tick", () -> runServerTick(level)); + } + + private void runServerTick(ServerLevel level) { if (closed || engine.isClosed() || engine.getMantle().getMantle().isClosed()) { return; } @@ -239,7 +247,12 @@ public final class ModdedWorldManager implements EngineWorldManager { IrisLogging.error("Iris could not schedule the initial entity-spawn follow-up because the modded scheduler is unavailable."); return; } - scheduler.laterGlobal(() -> runInitialFollowUp(level, chunkX, chunkZ), RNG.r.i(5, 200)); + scheduler.laterGlobal( + () -> EngineLifecycleTasks.run( + engine, + "modded_world_manager_initial_spawn_followup", + () -> runInitialFollowUp(level, chunkX, chunkZ)), + RNG.r.i(5, 200)); } private void runInitialFollowUp(ServerLevel level, int chunkX, int chunkZ) { @@ -272,7 +285,14 @@ public final class ModdedWorldManager implements EngineWorldManager { return; } try { - mantleWarmupExecutor.execute(() -> warmupMantleChunk(key, chunkX, chunkZ)); + mantleWarmupExecutor.execute(() -> { + if (!EngineLifecycleTasks.run( + engine, + "modded_world_manager_mantle_warmup", + () -> warmupMantleChunk(key, chunkX, chunkZ))) { + mantleWarmups.remove(key); + } + }); } catch (RejectedExecutionException e) { mantleWarmups.remove(key); } @@ -727,11 +747,36 @@ public final class ModdedWorldManager implements EngineWorldManager { } @Override - public void close() { + public synchronized void close() { closed = true; - initialSpawnQueue.close(); - mantleWarmupExecutor.shutdownNow(); - mantleWarmups.clear(); + Throwable failure = null; + if (!initialSpawnQueueClosed) { + try { + initialSpawnQueue.close(); + initialSpawnQueueClosed = true; + } catch (Throwable e) { + failure = e; + } + } + if (!mantleWarmupExecutorStopped) { + try { + mantleWarmupExecutor.shutdownNow(); + mantleWarmupExecutorStopped = true; + } catch (Throwable e) { + failure = appendCloseFailure(failure, e); + } + } + if (!mantleWarmupsCleared) { + try { + mantleWarmups.clear(); + mantleWarmupsCleared = true; + } catch (Throwable e) { + failure = appendCloseFailure(failure, e); + } + } + if (failure != null) { + throw new IllegalStateException("Failed to completely stop the modded Iris world manager.", failure); + } } @Override @@ -757,4 +802,14 @@ public final class ModdedWorldManager implements EngineWorldManager { public void onSave() { engine.getMantle().save(); } + + private static Throwable appendCloseFailure(Throwable failure, Throwable next) { + if (failure == null) { + return next; + } + if (failure != next) { + failure.addSuppressed(next); + } + return failure; + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 42ea5a42a..bb738496a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -23,6 +23,8 @@ import art.arcane.iris.core.gui.GuiHost; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.Locator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; @@ -40,6 +42,7 @@ import art.arcane.iris.modded.ModdedLoader; import art.arcane.iris.modded.ModdedPackInstaller; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.matter.MatterMarker; @@ -95,7 +98,11 @@ import java.util.Optional; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.function.Predicate; @@ -104,6 +111,7 @@ public final class IrisModdedCommands { private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); private static final long LOCATE_TIMEOUT_MS = 120000L; private static final int NATIVE_STRUCTURE_LOCATE_RADIUS = 100; + private static final ConcurrentHashMap> ACTIVE_LOCATE_REQUESTS = new ConcurrentHashMap<>(); private static final SuggestionProvider BIOME_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestBiomeKeys(context, builder); private static final SuggestionProvider REGION_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestRegionKeys(context, builder); @@ -1172,32 +1180,79 @@ public final class IrisModdedCommands { int chunkX = player.blockPosition().getX() >> 4; int chunkZ = player.blockPosition().getZ() >> 4; ok(source, "Searching for " + label + "..."); - Thread thread = new Thread(() -> { - try { - Position2 at = locator.find(engine, new Position2(chunkX, chunkZ), LOCATE_TIMEOUT_MS, (Integer checks) -> { - }).get(); - if (at == null) { - server.execute(() -> fail(source, "Could not find " + label + " within the search timeout.")); - return; + CompletableFuture search; + try { + search = locator.find(engine, new Position2(chunkX, chunkZ), LOCATE_TIMEOUT_MS, (Integer checks) -> { + }); + } catch (WrongEngineBroException e) { + fail(source, "The engine for this world has been closed; rejoin the dimension and try again."); + return; + } + UUID playerId = player.getUUID(); + CompletableFuture previous = ACTIVE_LOCATE_REQUESTS.put(playerId, search); + if (previous != null && previous != search) { + previous.cancel(true); + } + search.whenComplete((Position2 at, Throwable error) -> completeLocate( + source, level, engine, player, label, server, playerId, search, at, error)); + } + + private static void completeLocate(CommandSourceStack source, ServerLevel level, Engine engine, + ServerPlayer player, String label, MinecraftServer server, UUID playerId, + CompletableFuture search, Position2 at, Throwable error) { + if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) { + return; + } + Throwable failure = unwrapCompletionFailure(error); + if (failure instanceof CancellationException) { + ACTIVE_LOCATE_REQUESTS.remove(playerId, search); + return; + } + if (failure != null) { + LOGGER.error("Iris locate failed for {}", label, failure); + server.execute(() -> { + if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) { + fail(source, "Search failed: " + failure); } - int blockX = (at.getX() << 4) + 8; - int blockZ = (at.getZ() << 4) + 8; - int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; - server.execute(() -> { - player.teleportTo(level, blockX + 0.5D, blockY, blockZ + 0.5D, Set.of(), player.getYRot(), player.getXRot(), false); - ok(source, "Teleported to " + label + " at " + blockX + " " + blockY + " " + blockZ); - }); - } catch (WrongEngineBroException e) { - server.execute(() -> fail(source, "The engine for this world has been closed; rejoin the dimension and try again.")); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (ExecutionException e) { - LOGGER.error("Iris locate failed for {}", label, e); - server.execute(() -> fail(source, "Search failed: " + e.getCause())); + }); + return; + } + if (at == null) { + server.execute(() -> { + if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) { + fail(source, "Could not find " + label + " within the search timeout."); + } + }); + return; + } + server.execute(() -> { + if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) { + teleportToLocateResult(source, level, engine, player, label, at); } - }, "Iris Locator"); - thread.setDaemon(true); - thread.start(); + }); + } + + private static void teleportToLocateResult(CommandSourceStack source, ServerLevel level, Engine engine, + ServerPlayer player, String label, Position2 at) { + int blockX = (at.getX() << 4) + 8; + int blockZ = (at.getZ() << 4) + 8; + try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; + player.teleportTo(level, blockX + 0.5D, blockY, blockZ + 0.5D, Set.of(), player.getYRot(), player.getXRot(), false); + ok(source, "Teleported to " + label + " at " + blockX + " " + blockY + " " + blockZ); + } catch (GenerationSessionException e) { + fail(source, "The engine changed while locating " + label + "; try again."); + } + } + + private static Throwable unwrapCompletionFailure(Throwable error) { + Throwable failure = error; + while ((failure instanceof CompletionException || failure instanceof ExecutionException) + && failure.getCause() != null) { + failure = failure.getCause(); + } + return failure; } private static int seed(CommandSourceStack source) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java index 497ea5c96..6cda2ace4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java @@ -35,8 +35,13 @@ import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.storage.LevelResource; import java.io.File; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; public final class ModdedPregenJob { + private static final long SHUTDOWN_TIMEOUT_MILLIS = 10_000L; + private static final AtomicReference ACTIVE = new AtomicReference<>(); private static volatile String dimension = "?"; private ModdedPregenJob() { @@ -54,13 +59,26 @@ public final class ModdedPregenJob { .radiusX(radiusBlocks) .radiusZ(radiusBlocks) .build(); - PregeneratorMethod method = new ModdedPregenMethod(level, engine, sync); + ModdedPregenMethod moddedMethod = new ModdedPregenMethod(level, engine, sync); + PregeneratorMethod method = moddedMethod; if (cached) { method = new CachedPregenMethod(method, PregenCache.create(cacheDirectory(level)).sync(), task); } + ActivePregen active = new ActivePregen(engine, moddedMethod); + ACTIVE.set(active); dimension = level.dimension().identifier().toString(); - new PregeneratorJob(task, method, engine); - return true; + try { + PregeneratorJob job = new PregeneratorJob(task, method, engine); + job.whenDone(() -> { + if (!moddedMethod.hasPendingFinalSave()) { + ACTIVE.compareAndSet(active, null); + } + }); + return true; + } catch (Throwable failure) { + ACTIVE.compareAndSet(active, null); + throw propagate(failure); + } } private static File cacheDirectory(ServerLevel level) { @@ -73,7 +91,17 @@ public final class ModdedPregenJob { } public static void shutdown() { - PregeneratorJob.shutdownAndWait(10_000L); + ActivePregen active = ACTIVE.get(); + shutdownAndSave(active, () -> PregeneratorJob.shutdownAndWait(SHUTDOWN_TIMEOUT_MILLIS)); + } + + public static boolean shutdownForWorld(String worldIdentity) { + PregeneratorJob job = PregeneratorJob.getInstance(); + if (job == null || !job.targetsWorldIdentity(worldIdentity)) { + return false; + } + ActivePregen active = matchingActive(worldIdentity); + return shutdownAndSave(active, () -> PregeneratorJob.shutdownInstanceForWorld(worldIdentity)); } public static Boolean pauseResume() { @@ -125,4 +153,64 @@ public final class ModdedPregenJob { status.append(ModdedCommandFeedback.footer()); return status; } + + private static ActivePregen matchingActive(String worldIdentity) { + ActivePregen active = ACTIVE.get(); + return active != null && active.targets(worldIdentity) ? active : null; + } + + private static boolean shutdownAndSave(ActivePregen active, BooleanSupplier shutdown) { + boolean deferred = active != null && active.method().deferFinalSaveToServerThread(); + boolean stopped = false; + boolean result = false; + Throwable failure = null; + try { + result = shutdown.getAsBoolean(); + stopped = result; + } catch (Throwable shutdownFailure) { + failure = shutdownFailure; + } + + if (deferred) { + try { + if (stopped) { + active.method().completeDeferredFinalSave(); + } else { + active.method().cancelDeferredFinalSave(); + } + } catch (Throwable saveFailure) { + if (failure == null) { + failure = saveFailure; + } else if (saveFailure != failure) { + failure.addSuppressed(saveFailure); + } + } + } + + if (failure == null && (stopped || PregeneratorJob.getInstance() == null)) { + ACTIVE.compareAndSet(active, null); + } + if (failure != null) { + throw propagate(failure); + } + return result; + } + + private static RuntimeException propagate(Throwable failure) { + if (failure instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (failure instanceof Error fatalError) { + throw fatalError; + } + return new IllegalStateException("Iris modded pregenerator shutdown failed", failure); + } + + private record ActivePregen(Engine engine, ModdedPregenMethod method) { + private boolean targets(String worldIdentity) { + return engine != null + && engine.getWorld() != null + && Objects.equals(engine.getWorld().identity(), worldIdentity); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java index 0a9a41d5b..1d24dce73 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java @@ -40,14 +40,18 @@ import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; 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 final class ModdedPregenMethod implements PregeneratorMethod { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final TicketType PREGEN_TICKET = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING | TicketType.FLAG_KEEP_DIMENSION_ACTIVE); private static final int ADAPTIVE_TIMEOUT_STEP = 3; private static final long ADAPTIVE_RECOVERY_INTERVAL = 64L; + private static final long FINAL_SAVE_TIMEOUT_MILLIS = 10_000L; + private static final long FINAL_SAVE_POLL_MILLIS = 50L; private static final boolean PARALLEL_CHUNK_SYSTEM = detectParallelChunkSystem(); private final ServerLevel level; @@ -62,6 +66,10 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private final AtomicInteger adaptiveLimit; private final AtomicInteger timeoutStreak = new AtomicInteger(); private final AtomicLong completed = new AtomicLong(); + private final AtomicBoolean finalSaveOnServerThread = new AtomicBoolean(false); + private final AtomicBoolean finalSaveDeferred = new AtomicBoolean(false); + private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false); + private final AtomicReference queuedFinalSave = new AtomicReference<>(); private final int timeoutSeconds; private final PregenMantleBackpressure backpressure; @@ -114,6 +122,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod { } LOGGER.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}", level.dimension().identifier(), completed.get(), inFlightPeak.get(), adaptiveLimit.get()); + if (deferFinalSaveIfRequested()) { + return; + } saveLevel(true); } @@ -122,24 +133,156 @@ public final class ModdedPregenMethod implements PregeneratorMethod { saveLevel(false); } - private void saveLevel(boolean wait) { - CompletableFuture saved = new CompletableFuture<>(); - level.getServer().execute(() -> { - try { - level.save(null, false, false); - } finally { - saved.complete(null); + boolean deferFinalSaveToServerThread() { + if (!level.getServer().isSameThread()) { + return false; + } + finalSaveOnServerThread.set(true); + return true; + } + + void completeDeferredFinalSave() { + requireServerThreadForFinalSave(); + cancelQueuedFinalSave(); + if (!finalSaveCompleted.get()) { + saveLevelOnServerThread(); + finalSaveCompleted.set(true); + } + finalSaveDeferred.set(false); + finalSaveOnServerThread.set(false); + } + + void cancelDeferredFinalSave() { + requireServerThreadForFinalSave(); + finalSaveOnServerThread.set(false); + if (finalSaveDeferred.get() || queuedFinalSave.get() != null) { + cancelQueuedFinalSave(); + if (!finalSaveCompleted.get()) { + saveLevelOnServerThread(); + finalSaveCompleted.set(true); } - }); - if (!wait) { + finalSaveDeferred.set(false); + } + } + + boolean hasPendingFinalSave() { + return finalSaveOnServerThread.get() + || finalSaveDeferred.get() + || queuedFinalSave.get() != null; + } + + private void saveLevel(boolean wait) { + if (wait) { + saveFinalLevel(); + return; + } + MinecraftServer server = level.getServer(); + if (server.isSameThread()) { + saveLevelOnServerThread(); + return; + } + server.execute(this::saveLevelOnServerThread); + } + + private void saveFinalLevel() { + MinecraftServer server = level.getServer(); + if (server.isSameThread()) { + saveLevelOnServerThread(); + finalSaveCompleted.set(true); + return; + } + if (deferFinalSaveIfRequested()) { + return; + } + + FinalSaveRequest request = new FinalSaveRequest(); + if (!queuedFinalSave.compareAndSet(null, request)) { + throw new IllegalStateException("Iris pregen final save is already queued for " + + level.dimension().identifier()); + } + try { + server.execute(() -> executeFinalSave(request)); + } catch (RuntimeException | Error failure) { + queuedFinalSave.compareAndSet(request, null); + request.fail(failure); + throw failure; + } + awaitFinalSave(request); + } + + private void executeFinalSave(FinalSaveRequest request) { + if (!request.claim()) { return; } try { - saved.get(10, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (TimeoutException | ExecutionException e) { - LOGGER.warn("Iris pregen level save did not complete in time for {}", level.dimension().identifier()); + saveLevelOnServerThread(); + finalSaveCompleted.set(true); + request.complete(); + } catch (RuntimeException | Error failure) { + request.fail(failure); + throw failure; + } finally { + queuedFinalSave.compareAndSet(request, null); + } + } + + private void awaitFinalSave(FinalSaveRequest request) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS); + while (true) { + if (deferFinalSaveIfRequested()) { + return; + } + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0L) { + LOGGER.warn("Iris pregen level save did not complete in time for {}", level.dimension().identifier()); + return; + } + long waitMillis = Math.max(1L, Math.min(FINAL_SAVE_POLL_MILLIS, + TimeUnit.NANOSECONDS.toMillis(remainingNanos))); + try { + request.completion().get(waitMillis, TimeUnit.MILLISECONDS); + return; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (TimeoutException e) { + continue; + } catch (ExecutionException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + LOGGER.error("Iris pregen level save failed for {}", level.dimension().identifier(), cause); + throw new IllegalStateException("Iris pregen level save failed for " + + level.dimension().identifier(), cause); + } + } + } + + private boolean deferFinalSaveIfRequested() { + if (!finalSaveOnServerThread.get()) { + return false; + } + finalSaveDeferred.set(true); + if (finalSaveOnServerThread.get()) { + return true; + } + finalSaveDeferred.set(false); + return false; + } + + private void cancelQueuedFinalSave() { + FinalSaveRequest request = queuedFinalSave.getAndSet(null); + if (request != null) { + request.cancel(); + } + } + + private void saveLevelOnServerThread() { + level.save(null, false, false); + } + + private void requireServerThreadForFinalSave() { + if (!level.getServer().isSameThread()) { + throw new IllegalStateException("Iris pregen final save must run on the Minecraft server thread for " + + level.dimension().identifier()); } } @@ -361,4 +504,31 @@ public final class ModdedPregenMethod implements PregeneratorMethod { public Mantle getMantle() { return engine.getMantle().getMantle(); } + + private static final class FinalSaveRequest { + private final CompletableFuture completion = new CompletableFuture<>(); + private final AtomicBoolean active = new AtomicBoolean(true); + + private boolean claim() { + return active.compareAndSet(true, false); + } + + private void complete() { + completion.complete(null); + } + + private void fail(Throwable failure) { + active.set(false); + completion.completeExceptionally(failure); + } + + private void cancel() { + active.set(false); + completion.complete(null); + } + + private CompletableFuture completion() { + return completion; + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java index 5738457d4..adab07301 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java @@ -19,13 +19,13 @@ package art.arcane.iris.modded.service; import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.gui.PregeneratorJob; -import art.arcane.iris.core.pregenerator.MantleHeapPressure; -import art.arcane.iris.core.runtime.GoldenHashEngine; -import art.arcane.iris.core.tools.WorldMaintenance; +import art.arcane.iris.core.service.EngineMaintenance; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.modded.ModdedWorldEngines; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.project.context.IrisContext; import net.minecraft.server.MinecraftServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,71 +33,74 @@ import org.slf4j.LoggerFactory; import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; -import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; public final class ModdedEngineMaintenanceService implements ModdedTickableService { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); - private static final long TRIM_PERIOD_MILLIS = 2_000L; + private static final long MAINTENANCE_PERIOD_MILLIS = 2_000L; private static final long SAVE_PERIOD_MILLIS = 60_000L; + private static final long SHUTDOWN_TIMEOUT_SECONDS = 30L; + private static final long INTERRUPT_DRAIN_TIMEOUT_SECONDS = 5L; - private final AtomicInteger tectonicLimit = new AtomicInteger(30); private final Set inFlight = Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>())); + private final Map lastSavedAt = Collections.synchronizedMap(new IdentityHashMap<>()); private volatile ExecutorService service; private long lastMaintenanceAt; - private long lastSaveAt; @Override public void onEnable() { - if (service != null) { + ExecutorService current = service; + if (current != null && !current.isShutdown()) { return; } - IrisSettings.IrisSettingsPerformance settings = IrisSettings.get().getPerformance(); - IrisSettings.IrisSettingsEngineSVC engineSettings = settings.getEngineSVC(); - ThreadFactory factory = (engineSettings.isUseVirtualThreads() + + IrisSettings.IrisSettingsEngineSVC settings = IrisSettings.get().getPerformance().getEngineSVC(); + ThreadFactory factory = (settings.isUseVirtualThreads() ? Thread.ofVirtual() - : Thread.ofPlatform().priority(engineSettings.getPriority())) + : Thread.ofPlatform().priority(settings.getPriority())) .name("Iris EngineSVC-", 0) .factory(); - service = Executors.newThreadPerTaskExecutor(factory); - tectonicLimit.set(settings.getTectonicPlateSize()); + service = Executors.newFixedThreadPool(EngineMaintenance.workerParallelism(), factory); lastMaintenanceAt = 0L; - lastSaveAt = System.currentTimeMillis(); + inFlight.clear(); + lastSavedAt.clear(); } @Override public void onDisable() { ExecutorService active = service; service = null; - if (active != null) { - active.shutdown(); + boolean drained = shutdownAndDrain(active); + if (drained) { + inFlight.clear(); + } + lastSavedAt.clear(); + if (!drained) { + throw new IllegalStateException("Iris engine maintenance workers remained active during service shutdown"); } - inFlight.clear(); } @Override public void onServerTick(MinecraftServer server) { ExecutorService active = service; - if (active == null) { + if (active == null || active.isShutdown()) { return; } + long now = System.currentTimeMillis(); - if (now - lastMaintenanceAt < TRIM_PERIOD_MILLIS) { + if (now - lastMaintenanceAt < MAINTENANCE_PERIOD_MILLIS) { return; } lastMaintenanceAt = now; - boolean flush = now - lastSaveAt >= SAVE_PERIOD_MILLIS; - if (flush) { - lastSaveAt = now; - } + Collection engines = ModdedWorldEngines.activeEngines(); - int share = tectonicLimit.get() / Math.max(engines.size(), 1); + reconcileSaveState(engines, now); for (Engine engine : engines) { if (!inFlight.add(engine)) { continue; @@ -105,101 +108,118 @@ public final class ModdedEngineMaintenanceService implements ModdedTickableServi try { active.execute(() -> { try { - maintain(engine, share, flush); + maintain(engine, now); } finally { inFlight.remove(engine); } }); - } catch (RejectedExecutionException rejected) { + } catch (RejectedExecutionException exception) { inFlight.remove(engine); + if (active == service && !active.isShutdown()) { + LOGGER.error("Iris rejected engine maintenance for {}", engineName(engine), exception); + } } } } - private void maintain(Engine engine, int share, boolean flush) { - if (engine == null || engine.isClosed() || engine.getMantle().getMantle().isClosed()) { + private void maintain(Engine engine, long scheduledAt) { + if (engine == null || engine.isClosing() || engine.isClosed()) { return; } - if (pregenTargets(engine)) { - return; - } - if (flush) { - try { - engine.save(); - } catch (Throwable e) { - if (isMantleClosed(e)) { - return; - } - IrisLogging.reportError(e); - LOGGER.error("Iris engine save failed for {}", engine.getWorld().name(), e); - } - } - if (!shouldReduce(engine) || shouldSkipForMaintenance(engine) || GoldenHashEngine.isActive()) { - return; - } - try { - if (pregenTargets(engine) && MantleHeapPressure.overHighWater()) { - engine.getMantle().trim(0L, 0); - } else { - engine.getMantle().trim(TimeUnit.SECONDS.toMillis(IrisSettings.get().getPerformance().getMantleKeepAlive()), activeTectonicLimit(engine, share)); - } - long unloadStart = System.currentTimeMillis(); - boolean heapPressure = pregenTargets(engine) && MantleHeapPressure.overHighWater(); - int unloadLimit = (heapPressure || IrisSettings.get().getPerformance().getEngineSVC().forceMulticoreWrite) ? 0 : activeTectonicLimit(engine, share); - int count = engine.getMantle().unloadTectonicPlate(unloadLimit); - if (heapPressure && MantleHeapPressure.overPanicWater()) { - MantleHeapPressure.requestPanicReclaim(); - } - if (count > 0) { - LOGGER.debug("Iris unloaded {} tectonic plates in {}ms for {}", count, System.currentTimeMillis() - unloadStart, engine.getWorld().name()); - } - } catch (Throwable e) { - if (isMantleClosed(e)) { + try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_engine_maintenance"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!EngineMaintenance.isAvailable(engine)) { return; } - IrisLogging.reportError(e); - LOGGER.error("Iris engine maintenance failed for {}", engine.getWorld().name(), e); + + boolean pregeneratorTargetsWorld = EngineMaintenance.pregeneratorTargets(engine); + if (pregeneratorTargetsWorld) { + return; + } + + saveIfDue(engine, scheduledAt); + if (!EngineMaintenance.shouldRun(engine)) { + return; + } + + EngineMaintenance.Outcome outcome = EngineMaintenance.run(engine); + if (outcome.unloadedTectonicPlates() > 0) { + LOGGER.debug("Iris unloaded {} tectonic plates in {}ms for {}", + outcome.unloadedTectonicPlates(), outcome.unloadDurationMillis(), engineName(engine)); + } + } catch (GenerationSessionException exception) { + if (engine.isClosing() || exception.isExpectedTeardown()) { + return; + } + IrisLogging.reportError(exception); + LOGGER.error("Iris engine maintenance session failed for {}", engineName(engine), exception); + } catch (Throwable exception) { + if (EngineMaintenance.isMantleClosed(exception)) { + return; + } + IrisLogging.reportError(exception); + LOGGER.error("Iris engine maintenance failed for {}", engineName(engine), exception); } } - private boolean shouldReduce(Engine engine) { - if (!engine.isStudio() || IrisSettings.get().getPerformance().isTrimMantleInStudio()) { + private void saveIfDue(Engine engine, long scheduledAt) { + Long lastSave = lastSavedAt.get(engine); + if (lastSave == null || scheduledAt - lastSave < SAVE_PERIOD_MILLIS) { + return; + } + + try { + engine.save(); + lastSavedAt.put(engine, System.currentTimeMillis()); + } catch (Throwable exception) { + if (EngineMaintenance.isMantleClosed(exception)) { + return; + } + IrisLogging.reportError(exception); + LOGGER.error("Iris engine save failed for {}", engineName(engine), exception); + } + } + + private void reconcileSaveState(Collection engines, long now) { + Set activeEngines = Collections.newSetFromMap(new IdentityHashMap<>()); + activeEngines.addAll(engines); + synchronized (lastSavedAt) { + lastSavedAt.keySet().removeIf(engine -> !activeEngines.contains(engine)); + for (Engine engine : activeEngines) { + lastSavedAt.putIfAbsent(engine, now); + } + } + } + + private boolean shutdownAndDrain(ExecutorService active) { + if (active == null) { return true; } - return pregenTargets(engine); - } - private boolean shouldSkipForMaintenance(Engine engine) { - if (engine.getWorld() == null || !WorldMaintenance.isWorldMaintenanceActive(engine.getWorld().identity())) { - return false; - } - return !pregenTargets(engine); - } - - private boolean pregenTargets(Engine engine) { - if (engine.getWorld() == null) { - return false; - } - PregeneratorJob job = PregeneratorJob.getInstance(); - return job != null && job.targetsWorldIdentity(engine.getWorld().identity()); - } - - private int activeTectonicLimit(Engine engine, int share) { - if (!pregenTargets(engine)) { - return share; - } - return Math.max(share, IrisSettings.get().getPregen().getEffectiveResidentTectonicPlates(engine.getHeight())); - } - - private static boolean isMantleClosed(Throwable throwable) { - Throwable current = throwable; - while (current != null) { - String message = current.getMessage(); - if (message != null && message.toLowerCase(Locale.ROOT).contains("mantle is closed")) { + active.shutdown(); + try { + if (active.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { return true; } - current = current.getCause(); + active.shutdownNow(); + if (active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + return true; + } + IllegalStateException failure = new IllegalStateException( + "Iris engine maintenance workers did not stop after shutdownNow"); + IrisLogging.reportError(failure); + LOGGER.error("Iris engine maintenance did not terminate; active engine lifecycle leases will block unsafe shutdown", failure); + return false; + } catch (InterruptedException exception) { + active.shutdownNow(); + Thread.currentThread().interrupt(); + IrisLogging.reportError(exception); + LOGGER.error("Interrupted while draining Iris engine maintenance", exception); + return false; } - return false; + } + + private static String engineName(Engine engine) { + return engine == null || engine.getWorld() == null ? "" : engine.getWorld().name(); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPreservationService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPreservationService.java index fc1226339..7a0de0e40 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPreservationService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPreservationService.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded.service; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.MeteredCache; import art.arcane.iris.engine.framework.PreservationRegistry; import org.slf4j.Logger; @@ -55,6 +56,7 @@ public final class ModdedPreservationService implements ModdedService, Preservat @Override public void dereference() { + IrisData.dereference(); threads.removeIf((Thread thread) -> !thread.isAlive()); services.removeIf(ExecutorService::isShutdown); caches.removeIf((WeakReference ref) -> { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java index cdf32c5e7..8c544aff8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java @@ -30,6 +30,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedWorkspaceGenerator; +import art.arcane.iris.modded.command.ModdedPregenJob; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.ReactiveFolder; import art.arcane.volmlib.util.scheduling.ChronoLatch; @@ -180,7 +181,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, @Override public void shutdownPregenerator(Engine engine) { - PregeneratorJob.shutdownInstance(); + IrisWorld world = engine.getWorld(); + if (world != null) { + ModdedPregenJob.shutdownForWorld(world.identity()); + } } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTreeFellerPresentation.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTreeFellerPresentation.java new file mode 100644 index 000000000..3fe5cb669 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTreeFellerPresentation.java @@ -0,0 +1,227 @@ +package art.arcane.iris.modded.service; + +import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.ModdedScheduler; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.BlockParticleOption; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.state.BlockState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +final class ModdedTreeFellerPresentation { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final int MIN_BLOCKS_PER_PULSE = 4; + private static final int MAX_BLOCKS_PER_PULSE = 64; + private static final int TARGET_EROSION_PULSES = 60; + private static final int MAX_EFFECT_ORIGINS_PER_PULSE = 16; + + private final ServerPlayer player; + private final ServerLevel sourceLevel; + private final List pendingDrops = new ArrayList<>(); + private final AtomicBoolean effectFailureReported = new AtomicBoolean(); + private final AtomicBoolean deliveryFailureReported = new AtomicBoolean(); + private boolean flushScheduled; + private double fallbackX; + private double fallbackY; + private double fallbackZ; + + ModdedTreeFellerPresentation(ServerPlayer player, ServerLevel sourceLevel) { + this.player = player; + this.sourceLevel = sourceLevel; + this.fallbackX = player.getX(); + this.fallbackY = player.getY() + 0.15D; + this.fallbackZ = player.getZ(); + } + + static int blocksPerPulse(int blockCount) { + int requested = Math.max(1, (blockCount + TARGET_EROSION_PULSES - 1) / TARGET_EROSION_PULSES); + return Math.max(MIN_BLOCKS_PER_PULSE, Math.min(requested, MAX_BLOCKS_PER_PULSE)); + } + + static int effectStride(int blocksPerPulse) { + return Math.max( + 1, + (blocksPerPulse + MAX_EFFECT_ORIGINS_PER_PULSE - 1) / MAX_EFFECT_ORIGINS_PER_PULSE + ); + } + + static List consolidateDrops(Collection drops) { + List consolidated = new ArrayList<>(); + for (ItemStack drop : drops) { + mergeDrop(consolidated, drop); + } + return List.copyOf(consolidated); + } + + void activate(BlockPos position, BlockState state) { + try { + double x = position.getX() + 0.5D; + double y = position.getY() + 0.5D; + double z = position.getZ() + 0.5D; + sourceLevel.sendParticles(ParticleTypes.ENCHANT, x, y, z, 24, 0.45D, 0.45D, 0.45D, 0.18D); + sourceLevel.sendParticles(ParticleTypes.END_ROD, x, y, z, 8, 0.25D, 0.25D, 0.25D, 0.035D); + sourceLevel.sendParticles( + new BlockParticleOption(ParticleTypes.BLOCK, state), + x, + y, + z, + 8, + 0.25D, + 0.25D, + 0.25D, + 0.04D + ); + sourceLevel.playSound(null, position, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.PLAYERS, 0.55F, 1.35F); + sourceLevel.playSound(null, position, SoundEvents.AMETHYST_BLOCK_CHIME, SoundSource.PLAYERS, 0.4F, 0.8F); + } catch (Throwable error) { + reportEffectFailure(error); + } + } + + void erode(BlockPos position, BlockState state, int processed, int effectStride, float pitch) { + if (processed % effectStride != 0) { + return; + } + try { + double x = position.getX() + 0.5D; + double y = position.getY() + 0.5D; + double z = position.getZ() + 0.5D; + sourceLevel.sendParticles( + new BlockParticleOption(ParticleTypes.BLOCK, state), + x, + y, + z, + 5, + 0.3D, + 0.3D, + 0.3D, + 0.04D + ); + sourceLevel.sendParticles(ParticleTypes.ENCHANT, x, y, z, 3, 0.28D, 0.28D, 0.28D, 0.12D); + sourceLevel.playSound(null, position, SoundEvents.AMETHYST_BLOCK_CHIME, SoundSource.PLAYERS, 0.22F, pitch); + } catch (Throwable error) { + reportEffectFailure(error); + } + } + + synchronized boolean route(Iterable drops) { + for (ItemStack drop : drops) { + if (drop != null && !drop.isEmpty()) { + pendingDrops.add(drop.copy()); + } + } + scheduleFlush(); + return true; + } + + synchronized void flush() { + flushScheduled = false; + if (pendingDrops.isEmpty()) { + return; + } + List drops = consolidateDrops(pendingDrops); + pendingDrops.clear(); + boolean atPlayer = !player.isRemoved() && player.level() == sourceLevel; + double x = atPlayer ? player.getX() : fallbackX; + double y = atPlayer ? player.getY() + 0.15D : fallbackY; + double z = atPlayer ? player.getZ() : fallbackZ; + if (atPlayer) { + fallbackX = x; + fallbackY = y; + fallbackZ = z; + } + int delivered = 0; + for (ItemStack drop : drops) { + try { + ItemEntity item = new ItemEntity(sourceLevel, x, y, z, drop, 0D, 0.08D, 0D); + item.setDefaultPickUpDelay(); + if (sourceLevel.addFreshEntity(item)) { + delivered++; + } else { + pendingDrops.add(drop.copy()); + } + } catch (Throwable error) { + pendingDrops.add(drop.copy()); + reportDeliveryFailure(error); + } + } + try { + int particles = Math.min(32, 6 + (delivered * 2)); + sourceLevel.sendParticles(ParticleTypes.ENCHANT, x, y + 0.35D, z, particles, 0.3D, 0.25D, 0.3D, 0.1D); + sourceLevel.playSound(null, x, y, z, SoundEvents.AMETHYST_BLOCK_CHIME, SoundSource.PLAYERS, 0.28F, 1.75F); + } catch (Throwable error) { + reportEffectFailure(error); + } + if (!pendingDrops.isEmpty()) { + scheduleFlush(); + } + } + + synchronized void finish() { + flush(); + } + + private synchronized void scheduleFlush() { + if (pendingDrops.isEmpty() || flushScheduled) { + return; + } + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + flush(); + return; + } + flushScheduled = true; + scheduler.laterGlobal(this::flush, 1); + } + + private static void mergeDrop(List consolidated, ItemStack drop) { + if (drop == null || drop.isEmpty()) { + return; + } + ItemStack remaining = drop.copy(); + for (ItemStack existing : consolidated) { + if (!ItemStack.isSameItemSameComponents(existing, remaining)) { + continue; + } + int capacity = existing.getMaxStackSize() - existing.getCount(); + if (capacity <= 0) { + continue; + } + int moved = Math.min(capacity, remaining.getCount()); + existing.grow(moved); + remaining.shrink(moved); + if (remaining.isEmpty()) { + return; + } + } + while (!remaining.isEmpty()) { + int amount = Math.min(remaining.getCount(), remaining.getMaxStackSize()); + consolidated.add(remaining.copyWithCount(amount)); + remaining.shrink(amount); + } + } + + private void reportEffectFailure(Throwable error) { + if (effectFailureReported.compareAndSet(false, true)) { + LOGGER.error("Iris modded tree-feller presentation failed", error); + } + } + + private void reportDeliveryFailure(Throwable error) { + if (deliveryFailureReported.compareAndSet(false, true)) { + LOGGER.error("Iris modded tree-feller drop delivery failed", error); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTreeFellerService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTreeFellerService.java new file mode 100644 index 000000000..00ef52ec7 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTreeFellerService.java @@ -0,0 +1,554 @@ +package art.arcane.iris.modded.service; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.service.tree.TreeDefinitionIndex; +import art.arcane.iris.core.service.tree.TreeMarkerTraversal; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.StructurePlacementMarker; +import art.arcane.iris.engine.framework.TreeBlockMaterial; +import art.arcane.iris.modded.ModdedBlockBreakHandler; +import art.arcane.iris.modded.ModdedBlockState; +import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.ModdedScheduler; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.tags.BlockTags; +import net.minecraft.tags.ItemTags; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; + +public final class ModdedTreeFellerService implements ModdedTickableService { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final ThreadLocal BREAK_PROBE_DEPTH = ThreadLocal.withInitial(() -> 0); + + private final AtomicBoolean enabled = new AtomicBoolean(); + private final Set activeClaims = ConcurrentHashMap.newKeySet(); + private final Set activeRuns = ConcurrentHashMap.newKeySet(); + private final Map definitions = Collections.synchronizedMap(new WeakHashMap<>()); + + public static boolean isBreakProbe() { + return BREAK_PROBE_DEPTH.get() > 0; + } + + public static boolean runBreakProbe(BooleanSupplier probe) { + int depth = BREAK_PROBE_DEPTH.get(); + BREAK_PROBE_DEPTH.set(depth + 1); + try { + return probe.getAsBoolean(); + } finally { + if (depth == 0) { + BREAK_PROBE_DEPTH.remove(); + } else { + BREAK_PROBE_DEPTH.set(depth); + } + } + } + + @Override + public void onEnable() { + enabled.set(true); + } + + @Override + public void onDisable() { + enabled.set(false); + for (FellingRun run : List.copyOf(activeRuns)) { + finish(run); + } + activeRuns.clear(); + activeClaims.clear(); + definitions.clear(); + } + + @Override + public void onServerTick(MinecraftServer server) { + for (FellingRun run : List.copyOf(activeRuns)) { + if (!isRunControlActive(run)) { + finish(run); + } + } + } + + public PreparedOrigin prepare( + ServerLevel level, + ServerPlayer player, + BlockPos position, + BlockState state + ) { + IrisSettings.IrisSettingsTreeFeller settings = IrisSettings.get().getTreeFeller(); + if (!enabled.get() + || settings == null + || !settings.isEnabled() + || !player.gameMode().isSurvival() + || !player.isShiftKeyDown() + || !state.is(BlockTags.LOGS) + || !player.getInventory().getSelectedItem().is(ItemTags.AXES) + || !ModdedEngineBootstrap.loader().hasTreeFellerPermission(player)) { + return null; + } + Engine engine = ModdedBlockBreakHandler.engineFor(level); + if (engine == null || engine.isClosed()) { + return null; + } + int minimumY = level.getMinY(); + String marker = markerAt(engine, minimumY, position); + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker); + if (decoded == null || decoded.structureAware()) { + return null; + } + TreeBlockMaterial expectedMaterial = materialAt(engine, minimumY, position); + if (expectedMaterial != null && !expectedMaterial.matches(ModdedBlockState.serialize(state))) { + return null; + } + if (expectedMaterial == null + && !decoded.objectKey().startsWith("trees/") + && !definitionIndex(engine).isTreeMarker(marker)) { + return null; + } + ItemStack tool = player.getInventory().getSelectedItem(); + return new PreparedOrigin( + this, + level, + player, + position.immutable(), + state, + engine, + marker, + minimumY, + level.getMaxY(), + player.getInventory().getSelectedSlot(), + tool.copy(), + settings.getDurabilityPreservationChance() + ); + } + + public OriginDropRoute completeOrigin(PreparedOrigin prepared) { + if (prepared == null + || prepared.owner() != this + || !enabled.get() + || prepared.engine().isClosed()) { + return null; + } + TreeClaim claim = new TreeClaim(prepared.level(), prepared.marker()); + if (!activeClaims.add(claim)) { + return null; + } + ModdedTreeFellerPresentation presentation = new ModdedTreeFellerPresentation( + prepared.player(), + prepared.level() + ); + FellingRun run = new FellingRun(claim, prepared, presentation); + if (!normalizeOriginTool(run)) { + activeClaims.remove(claim); + return null; + } + activeRuns.add(run); + presentation.activate(prepared.position(), prepared.state()); + OriginDropRoute route = presentation::route; + if (run.toolBroken) { + ModdedBlockBreakHandler.completeManagedBreak(prepared.level(), prepared.position()); + finish(run); + return route; + } + discover(run); + return route; + } + + private TreeDefinitionIndex definitionIndex(Engine engine) { + synchronized (definitions) { + return definitions.computeIfAbsent(engine, TreeDefinitionIndex::build); + } + } + + private boolean normalizeOriginTool(FellingRun run) { + PreparedOrigin prepared = run.prepared; + ItemStack before = prepared.toolBefore(); + ItemStack current = prepared.player().getInventory().getItem(prepared.heldSlot()); + boolean currentMatches = !current.isEmpty() + && ItemStack.matchesIgnoringComponents( + before, + current, + (componentType) -> componentType == DataComponents.DAMAGE + ); + if (!before.isDamageableItem()) { + if (!currentMatches || !ItemStack.isSameItemSameComponents(before, current)) { + return false; + } + run.expectedTool = current.copy(); + return true; + } + + boolean preserve = ThreadLocalRandom.current().nextInt(100) < prepared.preservationChance(); + int desiredDamage = before.getDamageValue() + (preserve ? 0 : 1); + if (desiredDamage >= before.getMaxDamage()) { + if (!current.isEmpty() && !currentMatches) { + return false; + } + Item brokenItem = before.getItem(); + prepared.player().getInventory().setItem(prepared.heldSlot(), ItemStack.EMPTY); + if (!current.isEmpty()) { + prepared.player().onEquippedItemBroken(brokenItem, EquipmentSlot.MAINHAND); + } + prepared.player().inventoryMenu.sendAllDataToRemote(); + run.expectedTool = ItemStack.EMPTY; + run.toolBroken = true; + return true; + } + if (!current.isEmpty() && !currentMatches) { + return false; + } + ItemStack normalized = before.copy(); + normalized.setDamageValue(desiredDamage); + prepared.player().getInventory().setItem(prepared.heldSlot(), normalized); + prepared.player().inventoryMenu.sendAllDataToRemote(); + run.expectedTool = normalized.copy(); + return true; + } + + private void discover(FellingRun run) { + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + ModdedBlockBreakHandler.completeManagedBreak(run.prepared.level(), run.prepared.position()); + finish(run); + return; + } + scheduler.async(() -> { + TreeMarkerTraversal.Discovery discovery; + try { + PreparedOrigin prepared = run.prepared; + TreeMarkerTraversal.Position trigger = positionOf(prepared.position()); + discovery = TreeMarkerTraversal.discover( + trigger, + prepared.marker(), + prepared.minimumY(), + prepared.maximumY(), + (x, y, z) -> markerAt(prepared.engine(), prepared.minimumY(), x, y, z) + ); + } catch (Throwable error) { + LOGGER.error("Iris modded tree-feller discovery failed", error); + discovery = new TreeMarkerTraversal.Discovery(List.of(), false); + } + TreeMarkerTraversal.Discovery resolved = discovery; + scheduler.global(() -> beginErosion(run, resolved)); + }); + } + + private void beginErosion(FellingRun run, TreeMarkerTraversal.Discovery discovery) { + ModdedBlockBreakHandler.completeManagedBreak(run.prepared.level(), run.prepared.position()); + if (run.finished.get() || !discovery.complete()) { + finish(run); + return; + } + TreeMarkerTraversal.Position trigger = positionOf(run.prepared.position()); + run.work = discovery.members().stream() + .filter((position) -> !position.equals(trigger)) + .toList(); + if (run.work.isEmpty()) { + finish(run); + return; + } + run.blocksPerPulse = ModdedTreeFellerPresentation.blocksPerPulse(run.work.size()); + run.effectStride = ModdedTreeFellerPresentation.effectStride(run.blocksPerPulse); + scheduleNextPulse(run); + } + + private void scheduleNextPulse(FellingRun run) { + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + finish(run); + return; + } + scheduler.laterGlobal(() -> processPulse(run), 1); + } + + private void processPulse(FellingRun run) { + if (run.finished.get() || !isRunControlActive(run)) { + finish(run); + return; + } + int processedThisPulse = 0; + while (processedThisPulse < run.blocksPerPulse && run.cursor < run.work.size()) { + if (!isRunControlActive(run)) { + finish(run); + return; + } + TreeMarkerTraversal.Position position = run.work.get(run.cursor++); + MemberResult result = processMember(run, position); + if (result == MemberResult.STOP) { + finish(run); + return; + } + processedThisPulse++; + } + run.presentation.flush(); + if (run.cursor >= run.work.size()) { + finish(run); + return; + } + scheduleNextPulse(run); + } + + private MemberResult processMember(FellingRun run, TreeMarkerTraversal.Position position) { + PreparedOrigin prepared = run.prepared; + BlockPos blockPosition = blockPosition(position); + ServerLevel level = prepared.level(); + if (!level.isLoaded(blockPosition)) { + return MemberResult.STOP; + } + BlockState state = level.getBlockState(blockPosition); + if (state.isAir()) { + ModdedBlockBreakHandler.completeManagedBreak(level, blockPosition); + return MemberResult.CONTINUE; + } + if (!prepared.marker().equals(markerAt(prepared.engine(), prepared.minimumY(), blockPosition))) { + return MemberResult.STOP; + } + TreeBlockMaterial expectedMaterial = materialAt(prepared.engine(), prepared.minimumY(), blockPosition); + if (expectedMaterial != null && !expectedMaterial.matches(ModdedBlockState.serialize(state))) { + ModdedBlockBreakHandler.completeManagedBreak(level, blockPosition); + return state.is(BlockTags.LOGS) ? MemberResult.STOP : MemberResult.CONTINUE; + } + boolean log = state.is(BlockTags.LOGS); + ServerPlayer player = prepared.player(); + if (!level.mayInteract(player, blockPosition) + || player.blockActionRestricted(level, blockPosition, player.gameMode()) + || !player.getInventory().getSelectedItem().canDestroyBlock( + state, + level, + blockPosition, + player + )) { + return log ? MemberResult.STOP : MemberResult.CONTINUE; + } + if (!ModdedEngineBootstrap.loader().canTreeFellerBreak( + level, + player, + blockPosition, + state + )) { + return log ? MemberResult.STOP : MemberResult.CONTINUE; + } + if (!state.equals(level.getBlockState(blockPosition)) + || !prepared.marker().equals(markerAt(prepared.engine(), prepared.minimumY(), blockPosition))) { + return MemberResult.STOP; + } + + ItemStack toolForDrops = prepared.player().getInventory().getSelectedItem().copy(); + BlockEntity blockEntity = state.hasBlockEntity() ? level.getBlockEntity(blockPosition) : null; + List vanillaDrops = Block.getDrops( + state, + level, + blockPosition, + blockEntity, + player, + toolForDrops + ); + ModdedBlockBreakHandler.Result customDrops = ModdedBlockBreakHandler.evaluateManagedDrops( + level, + blockPosition, + state + ); + ToolReservation reservation = log ? reserveToolDamage(run) : ToolReservation.free(toolForDrops); + if (reservation == null) { + return MemberResult.STOP; + } + if (!level.destroyBlock(blockPosition, false, player, 512)) { + refundToolDamage(run, reservation); + return MemberResult.STOP; + } + ModdedBlockBreakHandler.completeManagedBreak(level, blockPosition); + int processed = run.processed++; + float progress = run.work.size() <= 1 ? 1F : (float) processed / (float) (run.work.size() - 1); + float pitch = Math.min(1.95F, 0.65F + (progress * 1.25F)); + run.presentation.erode(blockPosition, state, processed, run.effectStride, pitch); + run.presentation.route(customDrops.combinedDrops(vanillaDrops)); + return reservation.broke() ? MemberResult.STOP : MemberResult.CONTINUE; + } + + private ToolReservation reserveToolDamage(FellingRun run) { + PreparedOrigin prepared = run.prepared; + ItemStack current = prepared.player().getInventory().getSelectedItem(); + if (!ItemStack.isSameItemSameComponents(current, run.expectedTool) || !current.is(ItemTags.AXES)) { + return null; + } + ItemStack before = current.copy(); + if (!current.isDamageableItem() + || ThreadLocalRandom.current().nextInt(100) < prepared.preservationChance()) { + return ToolReservation.free(before); + } + int nextDamage = current.getDamageValue() + 1; + if (nextDamage >= current.getMaxDamage()) { + Item brokenItem = current.getItem(); + prepared.player().getInventory().setSelectedItem(ItemStack.EMPTY); + prepared.player().onEquippedItemBroken(brokenItem, EquipmentSlot.MAINHAND); + prepared.player().inventoryMenu.sendAllDataToRemote(); + run.expectedTool = ItemStack.EMPTY; + return new ToolReservation(before, true, true); + } + current.setDamageValue(nextDamage); + prepared.player().getInventory().setSelectedItem(current); + prepared.player().inventoryMenu.sendAllDataToRemote(); + run.expectedTool = current.copy(); + return new ToolReservation(before, true, false); + } + + private void refundToolDamage(FellingRun run, ToolReservation reservation) { + if (!reservation.charged()) { + return; + } + PreparedOrigin prepared = run.prepared; + ItemStack current = prepared.player().getInventory().getSelectedItem(); + boolean expectedEmpty = run.expectedTool.isEmpty(); + if ((expectedEmpty && current.isEmpty()) + || (!expectedEmpty && ItemStack.isSameItemSameComponents(current, run.expectedTool))) { + ItemStack restored = reservation.before().copy(); + prepared.player().getInventory().setSelectedItem(restored); + prepared.player().inventoryMenu.sendAllDataToRemote(); + run.expectedTool = restored.copy(); + } + } + + private boolean isRunControlActive(FellingRun run) { + PreparedOrigin prepared = run.prepared; + ServerPlayer player = prepared.player(); + IrisSettings.IrisSettingsTreeFeller settings = IrisSettings.get().getTreeFeller(); + if (!enabled.get() + || settings == null + || !settings.isEnabled() + || run.finished.get() + || player.isRemoved() + || player.hasDisconnected() + || player.level() != prepared.level() + || !player.gameMode().isSurvival() + || !player.isShiftKeyDown() + || player.getInventory().getSelectedSlot() != prepared.heldSlot() + || run.expectedTool.isEmpty()) { + return false; + } + ItemStack current = player.getInventory().getSelectedItem(); + return current.is(ItemTags.AXES) && ItemStack.isSameItemSameComponents(current, run.expectedTool); + } + + private void finish(FellingRun run) { + if (run.finished.compareAndSet(false, true)) { + activeRuns.remove(run); + activeClaims.remove(run.claim); + ModdedBlockBreakHandler.completeManagedBreak(run.prepared.level(), run.prepared.position()); + run.presentation.finish(); + } + } + + private String markerAt(Engine engine, int minimumY, BlockPos position) { + return markerAt(engine, minimumY, position.getX(), position.getY(), position.getZ()); + } + + private String markerAt(Engine engine, int minimumY, int x, int y, int z) { + return engine.getMantle().getMantle().get(x, y - minimumY, z, String.class); + } + + private TreeBlockMaterial materialAt(Engine engine, int minimumY, BlockPos position) { + return engine.getMantle().getMantle().get( + position.getX(), + position.getY() - minimumY, + position.getZ(), + TreeBlockMaterial.class + ); + } + + private TreeMarkerTraversal.Position positionOf(BlockPos position) { + return new TreeMarkerTraversal.Position(position.getX(), position.getY(), position.getZ()); + } + + private BlockPos blockPosition(TreeMarkerTraversal.Position position) { + return new BlockPos(position.x(), position.y(), position.z()); + } + + public record PreparedOrigin( + ModdedTreeFellerService owner, + ServerLevel level, + ServerPlayer player, + BlockPos position, + BlockState state, + Engine engine, + String marker, + int minimumY, + int maximumY, + int heldSlot, + ItemStack toolBefore, + int preservationChance + ) { + public PreparedOrigin { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(level, "level"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(position, "position"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(engine, "engine"); + Objects.requireNonNull(marker, "marker"); + Objects.requireNonNull(toolBefore, "toolBefore"); + } + } + + @FunctionalInterface + public interface OriginDropRoute { + boolean route(Iterable drops); + } + + private enum MemberResult { + CONTINUE, + STOP + } + + private record TreeClaim(ServerLevel level, String marker) { + } + + private record ToolReservation(ItemStack before, boolean charged, boolean broke) { + private static ToolReservation free(ItemStack before) { + return new ToolReservation(before, false, false); + } + } + + private static final class FellingRun { + private final TreeClaim claim; + private final PreparedOrigin prepared; + private final ModdedTreeFellerPresentation presentation; + private final AtomicBoolean finished = new AtomicBoolean(); + private ItemStack expectedTool = ItemStack.EMPTY; + private List work = List.of(); + private int cursor; + private int processed; + private int blocksPerPulse = 1; + private int effectStride = 1; + private boolean toolBroken; + + private FellingRun( + TreeClaim claim, + PreparedOrigin prepared, + ModdedTreeFellerPresentation presentation + ) { + this.claim = claim; + this.prepared = prepared; + this.presentation = presentation; + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java new file mode 100644 index 000000000..288d780f9 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java @@ -0,0 +1,143 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedGenerationLeaseContractTest { + private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources"; + + @Test + public void biomeRuntimeReadsAreGenerationLeased() throws IOException { + String source = source("art/arcane/iris/modded/IrisModdedBiomeSource.java"); + + assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_biome\")")); + assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_visible_biome\")")); + assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_biomes_within\")")); + assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_reachability\")")); + assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_state_biome\")")); + assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_biome_keys\")")); + assertTrue(source.contains("engine == null || engine.isClosed()")); + assertTrue(source.contains("catch (GenerationSessionException e)")); + assertTrue(source.contains("e.isExpectedTeardown()")); + assertTrue(source.contains("Iris structure biome key lookup was rejected during an engine transition")); + assertTrue(source.contains("source.clearCache()")); + } + + @Test + public void terrainAndHeightQueriesShareGenerationSessions() throws IOException { + String source = source("art/arcane/iris/modded/IrisModdedChunkGenerator.java"); + + assertTrue(source.contains("generationEngine.acquireGenerationLease(\"modded_chunk_pipeline\")")); + assertTrue(source.contains("current.acquireGenerationLease(\"modded_base_height\")")); + assertTrue(source.contains("current.acquireGenerationLease(\"modded_base_column\")")); + assertTrue(source.contains("current.acquireGenerationLease(\"modded_configured_biome_keys\")")); + assertTrue(source.contains("generationEngine.isClosing() || e.isExpectedTeardown()")); + String terrain = method(source, "private ChunkAccess generateTerrain("); + int sessionFailure = terrain.indexOf("catch (GenerationSessionException e)"); + int generalFailure = terrain.indexOf("catch (Throwable e)", sessionFailure); + String sessionHandler = terrain.substring(sessionFailure, generalFailure); + assertTrue(sessionHandler.contains("throw new IllegalStateException")); + assertFalse(sessionHandler.contains("return chunk;")); + + String references = method(source, "public void createReferences("); + assertTrue(references.contains("requireGenerationLease(current, \"modded_create_references\")")); + assertTrue(references.contains("IrisContext.open(current, lease.sessionId(), null)")); + assertTrue(references.contains("super.createReferences(level, structureManager, chunk);")); + } + + @Test + public void preservationDereferencesSharedPackData() throws IOException { + String source = source("art/arcane/iris/modded/service/ModdedPreservationService.java"); + int dereference = source.indexOf("public void dereference()"); + int irisData = source.indexOf("IrisData.dereference();", dereference); + int trackedResources = source.indexOf("threads.removeIf", dereference); + + assertTrue(dereference >= 0); + assertTrue(irisData > dereference); + assertTrue(trackedResources > irisData); + } + + @Test + public void hotloadShutdownOnlyTargetsItsOwnWorld() throws IOException { + String source = source("art/arcane/iris/modded/service/ModdedStudioHotloadService.java"); + int shutdown = source.indexOf("public void shutdownPregenerator(Engine engine)"); + int nextMethod = source.indexOf("private boolean throttled", shutdown); + String method = source.substring(shutdown, nextMethod); + + assertTrue(method.contains("ModdedPregenJob.shutdownForWorld(world.identity());")); + assertFalse(method.contains("PregeneratorJob.shutdownInstance();")); + } + + @Test + public void maintenanceOwnsOneGenerationSessionUntilWorkCompletes() throws IOException { + String source = source("art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java"); + String maintain = method(source, "private void maintain(Engine engine, long scheduledAt)"); + + assertTrue(maintain.contains("engine.acquireGenerationLease(\"modded_engine_maintenance\")")); + assertTrue(maintain.contains("IrisContext.open(engine, lease.sessionId(), null)")); + assertTrue(maintain.contains("exception.isExpectedTeardown()")); + assertTrue(source.contains("active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)")); + } + + @Test + public void blockingPregenShutdownDefersItsFinalSaveToTheServerThread() throws IOException { + String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java"); + String shutdown = method(jobSource, "private static boolean shutdownAndSave("); + assertTrue(shutdown.contains("deferFinalSaveToServerThread()")); + assertTrue(shutdown.contains("completeDeferredFinalSave()")); + assertTrue(shutdown.contains("cancelDeferredFinalSave()")); + + String methodSource = source("art/arcane/iris/modded/command/ModdedPregenMethod.java"); + String close = method(methodSource, "public void close()"); + assertTrue(close.contains("deferFinalSaveIfRequested()")); + + String await = method(methodSource, "private void awaitFinalSave("); + assertTrue(await.contains("FINAL_SAVE_POLL_MILLIS")); + assertTrue(await.contains("deferFinalSaveIfRequested()")); + + String complete = method(methodSource, "void completeDeferredFinalSave()"); + int cancel = complete.indexOf("cancelQueuedFinalSave();"); + int directSave = complete.indexOf("saveLevelOnServerThread();"); + assertTrue(cancel >= 0); + assertTrue(directSave > cancel); + + String queuedSave = method(methodSource, "private void executeFinalSave("); + assertTrue(queuedSave.contains("if (!request.claim())")); + String cancelRequest = method(methodSource, "private void cancelQueuedFinalSave()"); + assertTrue(cancelRequest.contains("request.cancel();")); + String pending = method(methodSource, "boolean hasPendingFinalSave()"); + assertTrue(pending.contains("queuedFinalSave.get() != null")); + } + + private static String source(String relativePath) throws IOException { + String root = System.getProperty(SOURCE_ROOT_PROPERTY); + assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY, root != null && !root.isBlank()); + return Files.readString(Path.of(root, relativePath)); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract signature: " + signature, start >= 0); + int openBrace = source.indexOf('{', start); + assertTrue("Missing source contract method body: " + signature, openBrace >= 0); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java index 7be056249..381820af1 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java @@ -141,7 +141,35 @@ public class ModdedLifecycleFailureContractTest { String source = source("ModdedEngineBootstrap.java"); String stop = method(source, "public static void stop("); - assertEquals(1, occurrences(stop, "ModdedStartup.reset();")); + assertEquals(1, occurrences(stop, "ModdedStartup::reset")); + } + + @Test + public void engineBootstrapAttemptsEveryShutdownStageAndAggregatesFailures() throws IOException { + String source = source("ModdedEngineBootstrap.java"); + String stop = method(source, "public static void stop("); + + assertBefore(stop, "\"services\"", "\"world engines\""); + assertBefore(stop, "\"world engines\"", "\"dimension manager\""); + assertBefore(stop, "\"server state\"", "if (failure != null)"); + assertTrue(stop.contains("throw propagateStopFailure(failure);")); + + String runStage = method(source, "private static Throwable runStopStage("); + assertTrue(runStage.contains("catch (Throwable stageFailure)")); + assertTrue(runStage.contains("failure.addSuppressed(stageFailure);")); + } + + @Test + public void worldShutdownRemovesOnlySuccessfullyClosedMappings() throws IOException { + String source = source("ModdedWorldEngines.java"); + String shutdown = method(source, "public static void shutdown()"); + + assertTrue(shutdown.contains("new ArrayList<>(ENGINES.entrySet())")); + assertFalse(shutdown.contains("ENGINES.clear()")); + assertBefore(shutdown, "close(engine);", "ENGINES.remove(level, engine)"); + assertTrue(shutdown.contains("ENGINES.containsKey(level)")); + assertTrue(shutdown.contains("failure.addSuppressed(e);")); + assertTrue(shutdown.contains("failed mappings were retained")); } @Test @@ -160,7 +188,37 @@ public class ModdedLifecycleFailureContractTest { String unbind = method(source, "synchronized void unbindEngine(ServerLevel level)"); assertFalse(unbind.contains("finally")); + assertBefore(unbind, "unloading = true;", "ModdedWorldEngines.evictOrThrow(level);"); assertBefore(unbind, "ModdedWorldEngines.evictOrThrow(level);", "clearEngineBinding();"); + String binding = method(source, "private Engine bindEngine(ServerLevel level)"); + assertTrue(binding.contains("requireBindingAllowed();")); + assertTrue(binding.contains("requireCompletedShutdown(cached);")); + String bindLevel = method(source, "synchronized void bindLevel(ServerLevel level)"); + assertBefore(bindLevel, "requireCompletedShutdown(engine);", "unloading = false;"); + String boundEngine = method(source, "public Engine engineIfBound()"); + assertTrue(boundEngine.contains("unloading || current == null || current.isClosing() || current.isClosed()")); + } + + @Test + public void loaderUnloadSurfacesCloseFailureAndDynamicRemovalUsesStrictEviction() throws IOException { + String bootstrapSource = source("ModdedEngineBootstrap.java"); + String unload = method(bootstrapSource, "public static void levelUnloaded(ServerLevel level)"); + String failure = catchBlock(unload); + assertTrue(failure.contains("LOGGER.error(")); + assertTrue(failure.contains("throw ")); + + String managerSource = source("ModdedDimensionManager.java"); + String remove = method(managerSource, "public static boolean remove(MinecraftServer server, String dimensionId, boolean wipeStorage)"); + assertTrue(remove.contains("ModdedWorldEngines.evictOrThrow(level);")); + assertFalse(remove.contains("ModdedWorldEngines.evict(level);")); + assertTrue(remove.contains("generatorUnbound = true;")); + assertTrue(remove.contains("rollbackRemoval(server, serverAccess, key, level, generator, generatorUnbound, e);")); + + String rollback = method(managerSource, "private static void rollbackRemoval("); + assertTrue(rollback.contains("serverAccess.hasLevel(server, key)")); + assertTrue(rollback.contains("generator.bindLevel(level);")); + assertTrue(rollback.contains("failure.addSuppressed(rollbackFailure);")); + assertTrue(rollback.contains("LOGGER.error(")); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java index 401f7d3b6..bebdcd41a 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java @@ -48,7 +48,28 @@ public class ModdedServiceManagerTest { assertNull(manager.service(SecondService.class)); } + @Test + public void disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures() { + ModdedServiceManager manager = new ModdedServiceManager(); + RuntimeException firstFailure = new RuntimeException("first disable failed"); + RuntimeException secondFailure = new RuntimeException("second disable failed"); + FirstService first = manager.register(FirstService.class, new FirstService()); + first.disableFailure = firstFailure; + SecondService second = manager.register( + SecondService.class, new SecondService(null, secondFailure)); + + manager.enableAll(); + IllegalStateException thrown = assertThrows(IllegalStateException.class, manager::disableAll); + + assertEquals(1, first.disableCount); + assertEquals(1, second.disableCount); + assertSame(secondFailure, thrown.getCause()); + assertEquals(1, secondFailure.getSuppressed().length); + assertSame(firstFailure, secondFailure.getSuppressed()[0]); + } + private static final class FirstService implements ModdedService { + private RuntimeException disableFailure; private int enableCount; private int disableCount; @@ -60,6 +81,9 @@ public class ModdedServiceManagerTest { @Override public void onDisable() { disableCount++; + if (disableFailure != null) { + throw disableFailure; + } } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedTreeFellerPresentationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedTreeFellerPresentationTest.java new file mode 100644 index 000000000..ecd4cf37a --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedTreeFellerPresentationTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.modded.service; + +import net.minecraft.SharedConstants; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.component.DataComponents; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class ModdedTreeFellerPresentationTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + DataComponentMap stackComponents = DataComponentMap.builder() + .set(DataComponents.MAX_STACK_SIZE, 64) + .build(); + if (!Items.OAK_LOG.builtInRegistryHolder().areComponentsBound()) { + Items.OAK_LOG.builtInRegistryHolder().bindComponents(stackComponents); + } + if (!Items.BIRCH_LOG.builtInRegistryHolder().areComponentsBound()) { + Items.BIRCH_LOG.builtInRegistryHolder().bindComponents(stackComponents); + } + } + + @Test + public void pulseSizingKeepsErosionReadableAndBounded() { + assertEquals(4, ModdedTreeFellerPresentation.blocksPerPulse(1)); + assertEquals(4, ModdedTreeFellerPresentation.blocksPerPulse(240)); + assertEquals(10, ModdedTreeFellerPresentation.blocksPerPulse(600)); + assertEquals(64, ModdedTreeFellerPresentation.blocksPerPulse(100_000)); + assertEquals(4, ModdedTreeFellerPresentation.effectStride(64)); + } + + @Test + public void compatibleDropsConsolidateWithoutExceedingStackLimits() { + List drops = ModdedTreeFellerPresentation.consolidateDrops(List.of( + new ItemStack(Items.OAK_LOG, 48), + new ItemStack(Items.OAK_LOG, 48), + new ItemStack(Items.BIRCH_LOG, 3) + )); + + assertEquals(3, drops.size()); + assertEquals(64, drops.get(0).getCount()); + assertEquals(32, drops.get(1).getCount()); + assertEquals(3, drops.get(2).getCount()); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedTreeFellerServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedTreeFellerServiceTest.java new file mode 100644 index 000000000..6b47113bb --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedTreeFellerServiceTest.java @@ -0,0 +1,37 @@ +package art.arcane.iris.modded.service; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ModdedTreeFellerServiceTest { + @Test + public void syntheticBreakProbeDepthIsNestedAndRestored() { + assertFalse(ModdedTreeFellerService.isBreakProbe()); + + boolean accepted = ModdedTreeFellerService.runBreakProbe(() -> { + assertTrue(ModdedTreeFellerService.isBreakProbe()); + return ModdedTreeFellerService.runBreakProbe(() -> { + assertTrue(ModdedTreeFellerService.isBreakProbe()); + return true; + }); + }); + + assertTrue(accepted); + assertFalse(ModdedTreeFellerService.isBreakProbe()); + } + + @Test + public void syntheticBreakProbeDepthIsRestoredAfterFailure() { + try { + ModdedTreeFellerService.runBreakProbe(() -> { + throw new IllegalStateException("probe failure"); + }); + fail("Expected probe failure"); + } catch (IllegalStateException expected) { + assertFalse(ModdedTreeFellerService.isBreakProbe()); + } + } +} diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java index b342e84a3..df3557f7f 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java @@ -27,6 +27,7 @@ import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.serialization.MapCodec; import net.minecraft.core.registries.Registries; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.packs.PackType; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.item.ItemEntity; @@ -34,15 +35,18 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.chunk.ChunkGenerator; import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.EventPriority; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; import net.neoforged.fml.loading.FMLEnvironment; import net.neoforged.fml.loading.FMLLoader; import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.common.util.BlockSnapshot; import net.neoforged.neoforge.event.AddPackFindersEvent; import net.neoforged.neoforge.event.RegisterCommandsEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; import net.neoforged.neoforge.event.level.BlockDropsEvent; +import net.neoforged.neoforge.event.level.BlockEvent; import net.neoforged.neoforge.event.level.LevelEvent; import net.neoforged.neoforge.event.level.block.BreakBlockEvent; import net.neoforged.neoforge.event.server.ServerAboutToStartEvent; @@ -51,6 +55,9 @@ import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent; import net.neoforged.neoforge.registries.DeferredRegister; +import net.neoforged.neoforge.server.permission.events.PermissionGatherEvent; + +import java.util.List; @Mod("irisworldgen") public final class IrisNeoForgeBootstrap { @@ -82,18 +89,55 @@ public final class IrisNeoForgeBootstrap { ModdedEngineBootstrap.levelLoaded(level); } }); - NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); - NeoForge.EVENT_BUS.addListener((BreakBlockEvent event) -> { - if (event.getLevel() instanceof ServerLevel level && !event.isCanceled()) { - ModdedBlockBreakHandler.prepare(level, event.getPos(), event.getState()); + NeoForge.EVENT_BUS.addListener((LevelEvent.Unload event) -> { + if (event.getLevel() instanceof ServerLevel level) { + ModdedEngineBootstrap.levelUnloaded(level); } }); + NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); + NeoForge.EVENT_BUS.addListener((PermissionGatherEvent.Nodes event) -> + event.addNodes(NeoForgeModdedLoader.TREE_FELLER_PERMISSION)); + NeoForge.EVENT_BUS.addListener((BreakBlockEvent event) -> { + if (event.getLevel() instanceof ServerLevel level + && event.getPlayer() instanceof ServerPlayer player + && !event.isCanceled()) { + ModdedBlockBreakHandler.prepare(level, player, event.getPos(), event.getState()); + } + }); + NeoForge.EVENT_BUS.addListener( + EventPriority.LOWEST, + false, + (BlockEvent.EntityPlaceEvent event) -> { + if (!(event instanceof BlockEvent.EntityMultiPlaceEvent) + && event.getLevel() instanceof ServerLevel level) { + ModdedBlockBreakHandler.clearPlacedProvenance(level, event.getPos()); + } + } + ); + NeoForge.EVENT_BUS.addListener( + EventPriority.LOWEST, + false, + (BlockEvent.EntityMultiPlaceEvent event) -> { + for (BlockSnapshot snapshot : event.getReplacedBlockSnapshots()) { + if (snapshot.getLevel() instanceof ServerLevel level) { + ModdedBlockBreakHandler.clearPlacedProvenance(level, snapshot.getPos()); + } + } + } + ); NeoForge.EVENT_BUS.addListener((BlockDropsEvent event) -> { if (!(event.getBreaker() instanceof Player)) { return; } ServerLevel level = event.getLevel(); ModdedBlockBreakHandler.Result result = ModdedBlockBreakHandler.complete(level, event.getPos(), event.getState()); + List vanillaDrops = event.getDrops().stream() + .map(ItemEntity::getItem) + .toList(); + if (result.routeCombinedDrops(vanillaDrops)) { + event.getDrops().clear(); + return; + } if (result.replaceVanillaDrops()) { event.getDrops().clear(); } diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java index 2480fcd7e..ef0d4e2ca 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java @@ -19,18 +19,37 @@ package art.arcane.iris.neoforge; import art.arcane.iris.modded.ModdedLoader; +import art.arcane.iris.modded.service.ModdedTreeFellerService; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.level.block.BreakBlockEvent; import net.neoforged.fml.ModList; import net.neoforged.fml.loading.FMLEnvironment; import net.neoforged.fml.loading.FMLLoader; import net.neoforged.fml.loading.FMLPaths; import net.neoforged.neoforge.server.ServerLifecycleHooks; +import net.neoforged.neoforge.server.permission.PermissionAPI; +import net.neoforged.neoforge.server.permission.nodes.PermissionNode; +import net.neoforged.neoforge.server.permission.nodes.PermissionTypes; import net.neoforged.neoforgespi.language.IModFileInfo; import java.io.File; import java.nio.file.Path; public final class NeoForgeModdedLoader implements ModdedLoader { + public static final PermissionNode TREE_FELLER_PERMISSION = new PermissionNode<>( + "iris", + "treefeller", + PermissionTypes.BOOLEAN, + (player, playerId, contexts) -> + player != null && Commands.LEVEL_GAMEMASTERS.check(player.permissions()) + ); + @Override public String platformName() { return "neoforge"; @@ -73,4 +92,23 @@ public final class NeoForgeModdedLoader implements ModdedLoader { IModFileInfo info = ModList.get().getModFileById("irisworldgen"); return info == null ? null : info.getFile().getFilePath().toFile(); } + + @Override + public boolean hasTreeFellerPermission(ServerPlayer player) { + return PermissionAPI.getPermission(player, TREE_FELLER_PERMISSION); + } + + @Override + public boolean canTreeFellerBreak( + ServerLevel level, + ServerPlayer player, + BlockPos position, + BlockState state + ) { + return ModdedTreeFellerService.runBreakProbe(() -> { + BreakBlockEvent event = new BreakBlockEvent(level, position, state, player); + NeoForge.EVENT_BUS.post(event); + return !event.isCanceled(); + }); + } } diff --git a/core/src/main/java/art/arcane/iris/core/IrisSettings.java b/core/src/main/java/art/arcane/iris/core/IrisSettings.java index 6867309e4..b9a7e8105 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -45,6 +45,7 @@ public class IrisSettings { private IrisSettingsPerformance performance = new IrisSettingsPerformance(); private IrisSettingsPregen pregen = new IrisSettingsPregen(); private IrisSettingsSentry sentry = new IrisSettingsSentry(); + private IrisSettingsTreeFeller treeFeller = new IrisSettingsTreeFeller(); public static int getThreadCount(int c) { return Math.max(switch (c) { @@ -229,16 +230,8 @@ public class IrisSettings { public int noiseCacheSize = 1_024; public int resourceLoaderCacheSize = 1_024; public int objectLoaderCacheSize = 4_096; - public int tectonicPlateSize = -1; public int mantleCleanupDelay = 200; public boolean simdKernels = true; - - public int getTectonicPlateSize() { - if (tectonicPlateSize > 0) - return tectonicPlateSize; - - return (int) (getHardware.getProcessMemory() / 512L); - } } @Data @@ -286,6 +279,16 @@ public class IrisSettings { public boolean preventLeafDecay = true; } + @Data + public static class IrisSettingsTreeFeller { + public boolean enabled = false; + public int durabilityPreservationChance = 0; + + public int getDurabilityPreservationChance() { + return Math.max(0, Math.min(durabilityPreservationChance, 100)); + } + } + @Data public static class IrisSettingsStudio { public boolean studio = true; @@ -300,9 +303,22 @@ public class IrisSettings { public boolean useVirtualThreads = true; public boolean forceMulticoreWrite = false; public int priority = Thread.NORM_PRIORITY; + public int parallelism = -1; public int getPriority() { return Math.max(Math.min(priority, Thread.MAX_PRIORITY), Thread.MIN_PRIORITY); } + + public int getParallelism() { + int processors = Math.max(1, Runtime.getRuntime().availableProcessors()); + if (parallelism > 0) { + int maximumParallelism = processors > Integer.MAX_VALUE / 2 + ? Integer.MAX_VALUE + : processors * 2; + return Math.min(parallelism, maximumParallelism); + } + + return Math.max(1, (int) Math.ceil(Math.sqrt(processors))); + } } } diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index b4ab75221..fc48f5d74 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -45,6 +45,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; public class PregeneratorJob implements PregenListener, PregenRenderSource { + private static final long WORLD_SHUTDOWN_TIMEOUT_MILLIS = 15_000L; private static final Color COLOR_EXISTS = parseColor("#4d7d5b"); private static final Color COLOR_BLACK = parseColor("#4d7d5b"); private static final Color COLOR_MANTLE = parseColor("#3c2773"); @@ -130,18 +131,36 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return true; } + public static boolean shutdownInstanceForWorld(String worldIdentity) { + PregeneratorJob inst = instance.get(); + if (inst == null || !inst.targetsWorldIdentity(worldIdentity)) { + return false; + } + + return shutdownAndWait(inst, WORLD_SHUTDOWN_TIMEOUT_MILLIS); + } + public static boolean shutdownAndWait(long timeoutMs) { PregeneratorJob inst = instance.get(); if (inst == null) { return false; } + return shutdownAndWait(inst, timeoutMs); + } + + private static boolean shutdownAndWait(PregeneratorJob inst, long timeoutMs) { inst.pregenerator.close(); inst.worker.interrupt(); try { - inst.worker.join(timeoutMs); + inst.worker.join(Math.max(1L, timeoutMs)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while stopping the Iris pregenerator.", e); + } + if (inst.worker.isAlive()) { + throw new IllegalStateException("Timed out while stopping the Iris pregenerator after " + + Math.max(1L, timeoutMs) + "ms."); } instance.compareAndSet(inst, null); return true; @@ -184,7 +203,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return inst == null ? -1L : Math.max(0L, inst.lastChunksRemaining); } - public record PregenProgress(double percent, long generated, long totalChunks, double chunksPerSecond, long chunksRemaining, long eta, long elapsed, String method, boolean paused, long failed, String worldName) { + public record PregenProgress(double percent, long generated, long totalChunks, double chunksPerSecond, long chunksRemaining, long eta, long elapsed, String method, boolean paused, long failed, String worldName, String worldIdentity) { } public static PregenProgress progressSnapshot() { @@ -205,7 +224,8 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { inst.lastMethod, inst.paused(), inst.pregenerator.getFailedChunks(), - inst.worldName()); + inst.worldName(), + inst.worldIdentity()); } public String worldName() { @@ -216,6 +236,13 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return engine.getWorld().name(); } + public String worldIdentity() { + if (engine == null || engine.getWorld() == null) { + return null; + } + return engine.getWorld().identity(); + } + public boolean targetsWorldIdentity(String worldIdentity) { if (worldIdentity == null || engine == null || engine.getWorld() == null) { return false; diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index e0d4b029d..b9d1aff1b 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -65,7 +65,11 @@ import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.common.reflect.KeyedType; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; +import art.arcane.iris.util.project.context.IrisContext; +import lombok.AccessLevel; import lombok.Data; +import lombok.Getter; +import lombok.Setter; import org.jetbrains.annotations.Nullable; import java.io.File; @@ -73,15 +77,20 @@ import java.io.FileReader; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedList; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; @Data public class IrisData implements ExclusionStrategy, TypeAdapterFactory { - private static final KMap dataLoaders = new KMap<>(); + private static final Map dataLoaders = new ConcurrentHashMap<>(); private final File dataFolder; private final int id; private final boolean datapackCompiler; @@ -108,14 +117,15 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { private Gson snippetLoader; private GsonBuilder builder; private KMap, ResourceLoader> loaders = new KMap<>(); - private Engine engine; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient List engines = new ArrayList<>(); private IrisData(File dataFolder) { this(dataFolder, false); } private IrisData(File dataFolder, boolean datapackCompiler) { - this.engine = null; this.dataFolder = dataFolder; this.id = RNG.r.imax(); this.datapackCompiler = datapackCompiler; @@ -130,6 +140,10 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { return dataLoaders.computeIfAbsent(dataFolder, IrisData::new); } + public static IrisData openRuntime(File dataFolder) { + return new IrisData(dataFolder); + } + public static IrisData openDatapackCompiler(File dataFolder) { return new IrisData(dataFolder, true); } @@ -275,9 +289,72 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public void cleanupEngine() { - if (engine != null && engine.isClosed()) { - engine = null; - IrisLogging.debug("Dereferenced Data " + getId() + " " + getDataFolder()); + int removed; + synchronized (engines) { + int previousSize = engines.size(); + removeClosedEngines(); + removed = previousSize - engines.size(); + } + if (removed > 0) { + IrisLogging.debug("Dereferenced " + removed + " Data registration(s) " + getId() + " " + getDataFolder()); + } + } + + public Engine getEngine() { + IrisContext context = IrisContext.get(); + if (context != null) { + Engine contextEngine = context.getEngine(); + if (!contextEngine.isClosed() && contextEngine.getData() == this) { + return contextEngine; + } + } + + synchronized (engines) { + removeClosedEngines(); + return engines.size() == 1 ? engines.get(0) : null; + } + } + + public List getEngines() { + synchronized (engines) { + removeClosedEngines(); + return List.copyOf(engines); + } + } + + public void registerEngine(Engine engine) { + Objects.requireNonNull(engine, "engine"); + synchronized (engines) { + for (Engine registeredEngine : engines) { + if (registeredEngine == engine) { + return; + } + } + engines.add(engine); + } + } + + public void unregisterEngine(Engine engine) { + if (engine == null) { + return; + } + synchronized (engines) { + Iterator iterator = engines.iterator(); + while (iterator.hasNext()) { + if (iterator.next() == engine) { + iterator.remove(); + return; + } + } + } + } + + private void removeClosedEngines() { + Iterator iterator = engines.iterator(); + while (iterator.hasNext()) { + if (iterator.next().isClosed()) { + iterator.remove(); + } } } @@ -287,6 +364,9 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { public void close() { closed = true; dump(); + synchronized (engines) { + engines.clear(); + } if (dataLoaders.get(dataFolder) == this) { dataLoaders.remove(dataFolder); } @@ -365,7 +445,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { gson = builder.create(); - if (engine != null) { + for (Engine engine : getEngines()) { engine.hotload(); } } @@ -405,7 +485,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { invalidateLoader(structureLoader); invalidateLoader(jigsawPoolLoader); invalidateLoader(jigsawPieceLoader); - if (engine != null) { + for (Engine engine : getEngines()) { IrisStructureLocator.invalidate(engine); } } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java index 569a76c1f..19f30ed3c 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java @@ -65,6 +65,16 @@ public final class MantleHeapPressure { return false; } + public static double reclaimUrgency(double fraction) { + if (!Double.isFinite(fraction) || fraction <= LOW_WATER) { + return 0D; + } + if (fraction >= HIGH_WATER) { + return 1D; + } + return (fraction - LOW_WATER) / (HIGH_WATER - LOW_WATER); + } + public static boolean overPanicWater() { return usedFraction() >= PANIC_WATER; } diff --git a/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java b/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java new file mode 100644 index 000000000..913b9c591 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java @@ -0,0 +1,94 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.pregenerator.MantleHeapPressure; +import art.arcane.iris.core.runtime.GoldenHashEngine; +import art.arcane.iris.core.tools.WorldMaintenance; +import art.arcane.iris.engine.framework.Engine; + +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +public final class EngineMaintenance { + private EngineMaintenance() { + } + + public static boolean isAvailable(Engine engine) { + return engine != null + && !engine.isClosing() + && !engine.isClosed() + && !engine.getMantle().getMantle().isClosed(); + } + + public static boolean pregeneratorTargets(Engine engine) { + if (engine == null || engine.getWorld() == null) { + return false; + } + + PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); + return pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(engine.getWorld().identity()); + } + + public static boolean shouldRun(Engine engine) { + if (engine.isStudio() + && !IrisSettings.get().getPerformance().isTrimMantleInStudio()) { + return false; + } + if (GoldenHashEngine.isActive()) { + return false; + } + + return engine.getWorld() == null + || !WorldMaintenance.isWorldMaintenanceActive(engine.getWorld().identity()); + } + + public static int workerParallelism() { + return IrisSettings.get().getPerformance().getEngineSVC().getParallelism(); + } + + public static Outcome run(Engine engine) { + IrisSettings.IrisSettingsPerformance settings = IrisSettings.get().getPerformance(); + double heapUsage = MantleHeapPressure.usedFraction(); + Plan plan = plan(settings.getMantleKeepAlive(), heapUsage, settings.getEngineSVC().isForceMulticoreWrite()); + + engine.getMantle().trim(plan.idleDurationMillis()); + + long unloadStart = System.nanoTime(); + int unloadedTectonicPlates = engine.getMantle().unloadTectonicPlate( + plan.multicoreUnload() ? 0 : Integer.MAX_VALUE); + if (plan.heapPressure() && MantleHeapPressure.overPanicWater()) { + MantleHeapPressure.requestPanicReclaim(); + } + + return new Outcome( + unloadedTectonicPlates, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - unloadStart)); + } + + public static boolean isMantleClosed(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + String message = current.getMessage(); + if (message != null && message.toLowerCase(Locale.ROOT).contains("mantle is closed")) { + return true; + } + current = current.getCause(); + } + return false; + } + + static Plan plan(int mantleKeepAliveSeconds, double heapUsage, boolean forceMulticoreWrite) { + long baseIdleDurationMillis = TimeUnit.SECONDS.toMillis(Math.max(0, mantleKeepAliveSeconds)); + double reclaimUrgency = MantleHeapPressure.reclaimUrgency(heapUsage); + long idleDurationMillis = Math.round(baseIdleDurationMillis * (1D - reclaimUrgency)); + boolean heapPressure = reclaimUrgency >= 1D; + return new Plan(idleDurationMillis, heapPressure || forceMulticoreWrite, heapPressure); + } + + public record Outcome(int unloadedTectonicPlates, long unloadDurationMillis) { + } + + record Plan(long idleDurationMillis, boolean multicoreUnload, boolean heapPressure) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java index 5e85212f7..0592f9b3c 100644 --- a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java @@ -23,6 +23,7 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; @@ -59,6 +60,7 @@ import org.bukkit.event.world.StructureGrowEvent; import java.util.List; import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Predicate; public class TreeSVC implements IrisService { @@ -138,6 +140,7 @@ public class TreeSVC implements IrisService { saplingPlane.forEach(block -> block.setType(Material.AIR)); IrisObject object = worldAccess.getData().getObjectLoader().load(placement.getPlace().getRandom(RNG.r)); + String treeMarker = object.getLoadKey() + "@" + ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE); List blockStateList = new KList<>(); KMap dataCache = new KMap<>(); // TODO: REAL CLASSES!!!! @@ -234,7 +237,7 @@ public class TreeSVC implements IrisService { event.setCancelled(true); - J.s(() -> { + Runnable growTask = () -> { StructureGrowEvent iGrow = new StructureGrowEvent(event.getLocation(), event.getSpecies(), event.isFromBonemeal(), event.getPlayer(), blockStateList); block = true; @@ -253,9 +256,20 @@ public class TreeSVC implements IrisService { block.setBlockData(data.getBase(), false); IrisServices.get(ExternalDataSVC.class).processUpdate(engine, block, data.getCustom()); } else block.setBlockData(d, false); + int mantleY = block.getY() - event.getWorld().getMinHeight(); + engine.getMantle().getMantle().set(block.getX(), mantleY, block.getZ(), treeMarker); + engine.getMantle().getMantle().set( + block.getX(), + mantleY, + block.getZ(), + TreeBlockMaterial.of(block.getBlockData().getAsString()) + ); } } - }); + }; + if (!J.runAt(event.getLocation(), growTask) && !J.isFolia()) { + J.s(growTask); + } } /** diff --git a/core/src/main/java/art/arcane/iris/core/service/tree/BlockDropRouter.java b/core/src/main/java/art/arcane/iris/core/service/tree/BlockDropRouter.java new file mode 100644 index 000000000..5aad1eab0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/tree/BlockDropRouter.java @@ -0,0 +1,6 @@ +package art.arcane.iris.core.service.tree; + +@FunctionalInterface +public interface BlockDropRouter { + boolean routeDrop(Object drop); +} diff --git a/core/src/main/java/art/arcane/iris/core/service/tree/TreeDefinitionIndex.java b/core/src/main/java/art/arcane/iris/core/service/tree/TreeDefinitionIndex.java new file mode 100644 index 000000000..c468c9fe3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/tree/TreeDefinitionIndex.java @@ -0,0 +1,97 @@ +package art.arcane.iris.core.service.tree; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.StructurePlacementMarker; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisProceduralObjects; +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisRegion; + +import java.util.HashSet; +import java.util.Set; + +public final class TreeDefinitionIndex { + private static final String PROCEDURAL_TREE_PREFIX = "procedural/tree/"; + + private final Set explicitObjectKeys; + private final Set proceduralTreeKeys; + + private TreeDefinitionIndex(Set explicitObjectKeys, Set proceduralTreeKeys) { + this.explicitObjectKeys = Set.copyOf(explicitObjectKeys); + this.proceduralTreeKeys = Set.copyOf(proceduralTreeKeys); + } + + public static TreeDefinitionIndex build(Engine engine) { + Set explicitObjectKeys = new HashSet<>(); + Set proceduralTreeKeys = new HashSet<>(); + + for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { + if (region == null) { + continue; + } + collectPlacements(region.getObjects(), explicitObjectKeys); + collectProceduralTrees(region.getProceduralObjects(), proceduralTreeKeys); + } + + for (IrisBiome biome : engine.getDimension().getReachableBiomes(engine)) { + if (biome == null) { + continue; + } + collectPlacements(biome.getObjects(), explicitObjectKeys); + collectProceduralTrees(biome.getProceduralObjects(), proceduralTreeKeys); + } + + return new TreeDefinitionIndex(explicitObjectKeys, proceduralTreeKeys); + } + + public boolean isTreeMarker(String marker) { + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker); + if (decoded == null || decoded.structureAware()) { + return false; + } + + String objectKey = decoded.objectKey(); + if (objectKey.startsWith(PROCEDURAL_TREE_PREFIX)) { + return proceduralTreeKeys.contains(objectKey); + } + if (objectKey.startsWith("procedural/")) { + return false; + } + return objectKey.startsWith("trees/") || explicitObjectKeys.contains(objectKey); + } + + private static void collectPlacements(Iterable placements, Set objectKeys) { + if (placements == null) { + return; + } + for (IrisObjectPlacement placement : placements) { + if (placement == null + || placement.getTrees() == null + || placement.getTrees().isEmpty() + || placement.getPlace() == null) { + continue; + } + for (String objectKey : placement.getPlace()) { + if (objectKey != null && !objectKey.isBlank()) { + objectKeys.add(objectKey); + } + } + } + } + + private static void collectProceduralTrees(IrisProceduralObjects proceduralObjects, Set objectKeys) { + if (proceduralObjects == null || proceduralObjects.getTrees() == null) { + return; + } + for (IrisProceduralTree tree : proceduralObjects.getTrees()) { + if (tree == null || tree.getName() == null || tree.getName().isBlank()) { + continue; + } + int variants = Math.max(1, tree.getVariants()); + for (int index = 0; index < variants; index++) { + objectKeys.add(tree.getVariantLoadKey(index)); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/tree/TreeMarkerTraversal.java b/core/src/main/java/art/arcane/iris/core/service/tree/TreeMarkerTraversal.java new file mode 100644 index 000000000..9a9c96ab1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/tree/TreeMarkerTraversal.java @@ -0,0 +1,84 @@ +package art.arcane.iris.core.service.tree; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class TreeMarkerTraversal { + public static final int MAX_MEMBERS = 131_072; + public static final int MAX_VISITED = 1_000_000; + public static final int MAX_AXIS_DISTANCE = 256; + + private TreeMarkerTraversal() { + } + + public static Discovery discover(Position trigger, String marker, int minimumY, int maximumY, MarkerLookup lookup) { + ArrayDeque pending = new ArrayDeque<>(); + Set visited = new HashSet<>(); + List members = new ArrayList<>(); + pending.add(trigger); + visited.add(trigger); + + while (!pending.isEmpty()) { + Position current = pending.removeFirst(); + if (!marker.equals(lookup.markerAt(current.x(), current.y(), current.z()))) { + continue; + } + if (members.size() >= MAX_MEMBERS) { + return new Discovery(List.copyOf(members), false); + } + members.add(current); + + for (int xOffset = -1; xOffset <= 1; xOffset++) { + for (int yOffset = -1; yOffset <= 1; yOffset++) { + for (int zOffset = -1; zOffset <= 1; zOffset++) { + if (xOffset == 0 && yOffset == 0 && zOffset == 0) { + continue; + } + Position next = current.offset(xOffset, yOffset, zOffset); + if (next.y() < minimumY || next.y() >= maximumY) { + continue; + } + if (!withinAxisBounds(trigger, next)) { + if (marker.equals(lookup.markerAt(next.x(), next.y(), next.z()))) { + return new Discovery(List.copyOf(members), false); + } + continue; + } + if (!visited.add(next)) { + continue; + } + if (visited.size() > MAX_VISITED) { + return new Discovery(List.copyOf(members), false); + } + pending.addLast(next); + } + } + } + } + + return new Discovery(List.copyOf(members), true); + } + + private static boolean withinAxisBounds(Position trigger, Position position) { + return Math.abs(position.x() - trigger.x()) <= MAX_AXIS_DISTANCE + && Math.abs(position.y() - trigger.y()) <= MAX_AXIS_DISTANCE + && Math.abs(position.z() - trigger.z()) <= MAX_AXIS_DISTANCE; + } + + @FunctionalInterface + public interface MarkerLookup { + String markerAt(int x, int y, int z); + } + + public record Position(int x, int y, int z) { + public Position offset(int xOffset, int yOffset, int zOffset) { + return new Position(x + xOffset, y + yOffset, z + zOffset); + } + } + + public record Discovery(List members, boolean complete) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/EnginePanic.java b/core/src/main/java/art/arcane/iris/engine/EnginePanic.java index 7c0f31faf..0e82c5c97 100644 --- a/core/src/main/java/art/arcane/iris/engine/EnginePanic.java +++ b/core/src/main/java/art/arcane/iris/engine/EnginePanic.java @@ -19,30 +19,87 @@ package art.arcane.iris.engine; import art.arcane.iris.spi.IrisLogging; -import art.arcane.volmlib.util.collection.KMap; -public class EnginePanic { - private static final KMap stuff = new KMap<>(); - private static KMap last = new KMap<>(); +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +public final class EnginePanic { + private static final Diagnostics GLOBAL = scoped("global"); + + private EnginePanic() { + } + + public static Diagnostics scoped(String scope) { + return new Diagnostics(scope); + } public static void add(String key, String value) { - stuff.put(key, value); + GLOBAL.add(key, value); } public static void saveLast() { - last = stuff.copy(); + GLOBAL.saveLast(); } public static void lastPanic() { - for (String i : last.keySet()) { - IrisLogging.error("Last Panic " + i + ": " + stuff.get(i)); - } + GLOBAL.lastPanic(); } public static void panic() { - lastPanic(); - for (String i : stuff.keySet()) { - IrisLogging.error("Engine Panic " + i + ": " + stuff.get(i)); + GLOBAL.panic(); + } + + public static final class Diagnostics { + private final String scope; + private final ThreadLocal> current = ThreadLocal.withInitial(LinkedHashMap::new); + private final AtomicReference> last = new AtomicReference<>(Map.of()); + + private Diagnostics(String scope) { + this.scope = scope == null || scope.isBlank() ? "unknown" : scope; + } + + public void add(String key, String value) { + current.get().put(String.valueOf(key), String.valueOf(value)); + } + + public void saveLast() { + last.set(currentSnapshot()); + current.remove(); + } + + public void lastPanic() { + log("Last Panic", last.get()); + } + + public void panic() { + Map currentSnapshot = currentSnapshot(); + lastPanic(); + log("Engine Panic", currentSnapshot); + current.remove(); + } + + Map currentSnapshot() { + return immutableSnapshot(current.get()); + } + + Map lastSnapshot() { + return last.get(); + } + + private void log(String prefix, Map snapshot) { + for (Map.Entry entry : snapshot.entrySet()) { + IrisLogging.error(prefix + " [" + scope + "] " + entry.getKey() + ": " + entry.getValue()); + } + } + + private static Map immutableSnapshot(Map values) { + if (values.isEmpty()) { + return Map.of(); + } + + return Collections.unmodifiableMap(new LinkedHashMap<>(values)); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/GenerationCacheWarmer.java b/core/src/main/java/art/arcane/iris/engine/GenerationCacheWarmer.java index b35768676..235187313 100644 --- a/core/src/main/java/art/arcane/iris/engine/GenerationCacheWarmer.java +++ b/core/src/main/java/art/arcane/iris/engine/GenerationCacheWarmer.java @@ -48,7 +48,7 @@ public final class GenerationCacheWarmer { KList biomes = engine.getAllBiomes(); biomes.sort(Comparator.comparing(IrisBiome::getLoadKey)); for (IrisBiome biome : biomes) { - warmPlacements(biome.getObjects(), root, counter, data); + warmPlacements(biome.getObjects(), root, counter, data, engine); warmDecorators(biome.getDecorators(), root, counter, data); warmOres(biome.getOres(), root, counter, data); warmProcedural(biome.getProceduralObjects(), root, counter, data); @@ -57,7 +57,7 @@ public final class GenerationCacheWarmer { KList regions = engine.getDimension().getAllRegions(engine); regions.sort(Comparator.comparing(IrisRegion::getLoadKey)); for (IrisRegion region : regions) { - warmPlacements(region.getObjects(), root, counter, data); + warmPlacements(region.getObjects(), root, counter, data, engine); warmOres(region.getOres(), root, counter, data); warmProcedural(region.getProceduralObjects(), root, counter, data); } @@ -71,7 +71,8 @@ public final class GenerationCacheWarmer { IrisLogging.debug("[IrisEngine timing] cache warm " + counter[0] + " configs=" + (M.ms() - start) + "ms"); } - private static void warmPlacements(KList placements, RNG root, int[] counter, IrisData data) { + private static void warmPlacements(KList placements, RNG root, int[] counter, + IrisData data, Engine engine) { if (placements == null) { return; } @@ -80,7 +81,7 @@ public final class GenerationCacheWarmer { continue; } RNG rng = root.nextParallelRNG(counter[0]++); - placement.getSurfaceWarp(rng, data); + placement.getSurfaceWarp(rng, data, engine); placement.getDensity(rng, 0, 0, data); } } 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 d57cb0f21..31e989ae3 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -111,7 +111,7 @@ public class IrisComplex implements DataProvider { public IrisComplex(Engine engine, boolean simple) { int cacheSize = IrisSettings.get().getPerformance().getNoiseCacheSize(); - IrisBiome emptyBiome = new IrisBiome(); + IrisBiome emptyBiome = new IrisBiome().setInferredType(InferredType.CAVE); UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes()); this.rng = new RNG(engine.getSeedManager().getComplex()); this.data = engine.getData(); @@ -124,19 +124,23 @@ public class IrisComplex implements DataProvider { Map> inferredStreams = new HashMap<>(); if (focusBiome != null) { - focusBiome.setInferredType(InferredType.LAND); + focusBiome = focusBiome.withInferredType(InferredType.LAND); focusRegion = findRegion(focusBiome, engine); } //@builder if (focusRegion != null) { + prepareInferredBiomes(focusRegion); focusRegion.getAllBiomes(this).forEach(this::registerGenerators); } else { - engine.getDimension() - .getRegions() - .forEach(i -> data.getRegionLoader().load(i) - .getAllBiomes(this) - .forEach(this::registerGenerators)); + engine.getDimension().getRegions().forEach(regionKey -> { + IrisRegion region = data.getRegionLoader().load(regionKey); + if (region == null) { + return; + } + prepareInferredBiomes(region); + region.getAllBiomes(this).forEach(this::registerGenerators); + }); } generatorBounds = buildGeneratorBounds(engine); KList overlayNoise = engine.getDimension().getOverlayNoise(); @@ -171,7 +175,7 @@ public class IrisComplex implements DataProvider { -> engine.getDimension().getCaveBiomeStyle().create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream() .zoom(engine.getDimension().getBiomeZoom()) .zoom(r.getCaveBiomeZoom()) - .selectRarity(data.getBiomeLoader().loadAll(r.getCaveBiomes())) + .selectRarity(loadInferredBiomes(r.getCaveBiomes(), InferredType.CAVE)) .onNull(emptyBiome) ).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize).waste("Cave Biome Stream"); inferredStreams.put(InferredType.CAVE, caveBiomeStream); @@ -181,7 +185,7 @@ public class IrisComplex implements DataProvider { .zoom(engine.getDimension().getBiomeZoom()) .zoom(engine.getDimension().getLandZoom()) .zoom(r.getLandBiomeZoom()) - .selectRarity(data.getBiomeLoader().loadAll(r.getLandBiomes(), (t) -> t.setInferredType(InferredType.LAND))) + .selectRarity(loadInferredBiomes(r.getLandBiomes(), InferredType.LAND)) ).convertAware2D(ProceduralStream::get) .cache2D("landBiomeStream", engine, cacheSize).waste("Land Biome Stream"); inferredStreams.put(InferredType.LAND, landBiomeStream); @@ -191,7 +195,7 @@ public class IrisComplex implements DataProvider { .zoom(engine.getDimension().getBiomeZoom()) .zoom(engine.getDimension().getSeaZoom()) .zoom(r.getSeaBiomeZoom()) - .selectRarity(data.getBiomeLoader().loadAll(r.getSeaBiomes(), (t) -> t.setInferredType(InferredType.SEA))) + .selectRarity(loadInferredBiomes(r.getSeaBiomes(), InferredType.SEA)) ).convertAware2D(ProceduralStream::get) .cache2D("seaBiomeStream", engine, cacheSize).waste("Sea Biome Stream"); inferredStreams.put(InferredType.SEA, seaBiomeStream); @@ -200,7 +204,7 @@ public class IrisComplex implements DataProvider { -> engine.getDimension().getShoreBiomeStyle().create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream() .zoom(engine.getDimension().getBiomeZoom()) .zoom(r.getShoreBiomeZoom()) - .selectRarity(data.getBiomeLoader().loadAll(r.getShoreBiomes(), (t) -> t.setInferredType(InferredType.SHORE))) + .selectRarity(loadInferredBiomes(r.getShoreBiomes(), InferredType.SHORE)) ).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize).waste("Shore Biome Stream"); inferredStreams.put(InferredType.SHORE, shoreBiomeStream); bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(), @@ -312,22 +316,49 @@ public class IrisComplex implements DataProvider { } private IrisBiome fixBiomeType(Double height, IrisBiome biome, IrisRegion region, Double x, Double z, double fluidHeight) { + IrisBiome resolved = resolveSurfaceBiome( + height, + biome, + region, + x, + z, + fluidHeight, + landBiomeStream, + seaBiomeStream, + shoreBiomeStream); + return resolved == biome ? biome : implode(resolved, x, z); + } + + static IrisBiome resolveSurfaceBiome( + double height, + IrisBiome biome, + IrisRegion region, + double x, + double z, + double fluidHeight, + ProceduralStream landBiomes, + ProceduralStream seaBiomes, + ProceduralStream shoreBiomes + ) { + if (biome == null || region == null) { + return biome; + } double sh = region.getShoreHeight(x, z); if (height >= fluidHeight - 1 && height <= fluidHeight + sh && !biome.isShore()) { - return shoreBiomeStream.get(x, z); + return shoreBiomes.get(x, z); } if (height > fluidHeight + sh && !biome.isLand()) { - return landBiomeStream.get(x, z); + return landBiomes.get(x, z); } if (height < fluidHeight && !biome.isAquatic()) { - return seaBiomeStream.get(x, z); + return seaBiomes.get(x, z); } if (height == fluidHeight && !biome.isShore()) { - return shoreBiomeStream.get(x, z); + return shoreBiomes.get(x, z); } return biome; @@ -446,6 +477,21 @@ public class IrisComplex implements DataProvider { return Math.max(Math.min(getInterpolatedHeight(engine, x, z, seed) + fluidHeight + overlayStream.get(x, z), engine.getHeight()), 0); } + private void prepareInferredBiomes(IrisRegion region) { + loadInferredBiomes(region.getLandBiomes(), InferredType.LAND); + loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE); + loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA); + loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE); + } + + private KList loadInferredBiomes(KList keys, InferredType type) { + KList inferred = new KList<>(); + for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) { + inferred.add(biome.withInferredType(type)); + } + return inferred; + } + private void registerGenerators(IrisBiome biome) { generatorBiomes.add(biome); biome.getGenerators().forEach(c -> registerGenerator(c.getCachedGenerator(this))); @@ -532,8 +578,7 @@ public class IrisComplex implements DataProvider { CNG childCell = b.getChildrenGenerator(rng, 123, b.getChildShrinkFactor()); ChildSelectionPlan childSelectionPlan = resolveChildSelectionPlan(b); - IrisBiome biome = childSelectionPlan.select(childCell, x, z); - biome.setInferredType(b.getInferredType()); + IrisBiome biome = childSelectionPlan.select(childCell, x, z).withInferredType(b.getInferredType()); return implode(biome, x, z, max - 1); } @@ -640,7 +685,7 @@ public class IrisComplex implements DataProvider { } } - private static class ChildSelectionPlan { + static final class ChildSelectionPlan { private final IrisBiome[] mappedBiomes; private final int maxIndex; @@ -649,7 +694,7 @@ public class IrisComplex implements DataProvider { this.maxIndex = mappedBiomes.length - 1; } - private static ChildSelectionPlan create(KList options) { + static ChildSelectionPlan create(KList options) { if (options.isEmpty()) { return new ChildSelectionPlan(new IrisBiome[0]); } @@ -690,7 +735,7 @@ public class IrisComplex implements DataProvider { return new ChildSelectionPlan(mappedBiomes); } - private IrisBiome select(CNG childCell, double x, double z) { + IrisBiome select(CNG childCell, double x, double z) { if (mappedBiomes.length == 0) { return null; } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index ba155078b..e9c75e08c 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -47,6 +47,7 @@ import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.IrisLogging; import com.google.common.util.concurrent.AtomicDouble; import com.google.gson.Gson; +import com.google.gson.JsonParseException; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.protocol.IrisMessage; @@ -56,7 +57,6 @@ import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.structure.StructureIndexService; -import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.common.plugin.VolmitSender; @@ -78,19 +78,41 @@ import art.arcane.volmlib.util.matter.MatterStructurePOI; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; +import lombok.AccessLevel; import lombok.Data; +import lombok.Getter; +import lombok.Setter; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @Data public class IrisEngine implements Engine { + private static final long SESSION_DRAIN_TIMEOUT_MILLIS = 15000L; + private static final long BACKGROUND_TASK_TIMEOUT_MILLIS = 15000L; + private static final Object TICK_LOCK = new Object(); + private static final Set TICK_ENGINES = Collections.newSetFromMap(new IdentityHashMap<>()); + private static int sharedTickTask = -1; + private final AtomicInteger bud; private final AtomicInteger buds; private final AtomicInteger generated; @@ -104,39 +126,63 @@ public class IrisEngine implements Engine { private final EngineMetrics metrics; private final boolean studio; private final AtomicRollingSequence wallClock; - private final int art; - private final AtomicCache engineData = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final Object lifecycleLock = new Object(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final Object backgroundTaskLock = new Object(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final Object engineDataLock = new Object(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final List backgroundTasks = new ArrayList<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final ThreadLocal runtimeAssembly = new ThreadLocal<>(); private final AtomicBoolean cleaning; private final ChronoLatch cleanLatch; private final SeedManager seedManager; private final GenerationSessionManager generationSessions; private final EnginePlatformHooks platformHooks; private final AtomicBoolean closing; - private CompletableFuture hash32; - private EngineMode mode; - private EngineEffects effects; - private EngineWorldManager worldManager; + @Setter(AccessLevel.NONE) + private volatile IrisEngineData engineData; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private volatile EngineRuntime runtime; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private volatile EngineTarget publishedTarget; private volatile int parallelism; - private boolean failing; - private boolean closed; - private int cacheId; - private double maxBiomeObjectDensity; - private double maxBiomeLayerDensity; - private double maxBiomeDecoratorDensity; - private IrisComplex complex; - private UpperDimensionContext upperContext; + private volatile boolean failing; + private volatile boolean closed; + private boolean runtimeReleased; + private boolean targetReleased; + private boolean mantleReleased; + private boolean engineDataReleased; + private boolean preservationReleased; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private volatile LifecycleState lifecycleState; + private boolean backgroundTaskAdmission; private final AtomicBoolean modeFallbackLogged; private final AtomicBoolean prefetchSaveStarted; public IrisEngine(EngineTarget target, boolean studio) { this.studio = studio; this.target = target; + this.publishedTarget = target; this.platformHooks = IrisServices.get(EnginePlatformHooks.class); + this.generationSessions = new GenerationSessionManager(); + this.closing = new AtomicBoolean(true); + this.lifecycleState = LifecycleState.INITIALIZING; + this.closed = false; + this.failing = false; getEngineData(); verifySeed(); this.seedManager = new SeedManager(target.getWorld().getRawWorldSeed()); - this.generationSessions = new GenerationSessionManager(); - this.closing = new AtomicBoolean(false); bud = new AtomicInteger(0); buds = new AtomicInteger(0); metrics = new EngineMetrics(32); @@ -154,32 +200,43 @@ public class IrisEngine implements Engine { cleaning = new AtomicBoolean(false); modeFallbackLogged = new AtomicBoolean(false); prefetchSaveStarted = new AtomicBoolean(false); - if (studio) { - _t0 = M.ms(); - getData().dump(); - getData().clearLists(); - getTarget().setDimension(getData().getDimensionLoader().load(getDimension().getLoadKey())); - IrisLogging.debug("[IrisEngine timing] dump+clearLists+reload=" + (M.ms() - _t0) + "ms"); - } - getData().setEngine(this); - _t0 = M.ms(); - getData().loadPrefetch(this); - IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms"); + sealInitialGenerationSession(); + try { - StructureIndexService.writeOnce(getData()); + if (studio) { + _t0 = M.ms(); + getData().dump(); + getData().clearLists(); + IrisDimension replacement = getData().getDimensionLoader().load(getDimension().getLoadKey()); + if (replacement == null) { + throw new IllegalStateException("Studio engine could not load Iris dimension '" + getDimension().getLoadKey() + "'"); + } + publishedTarget = new EngineTarget(getWorld(), replacement, getData()); + IrisLogging.debug("[IrisEngine timing] dump+clearLists+reload=" + (M.ms() - _t0) + "ms"); + } + getData().registerEngine(this); + _t0 = M.ms(); + getData().loadPrefetch(this); + IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms"); + try { + StructureIndexService.writeOnce(getData()); + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + } + IrisLogging.info("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); + _t0 = M.ms(); + EngineRuntime initialRuntime = buildRuntime(); + publishRuntime(initialRuntime, null); + IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); + _t0 = M.ms(); + GenerationCacheWarmer.warm(this); + IrisLogging.debug("[IrisEngine timing] cache warm total=" + (M.ms() - _t0) + "ms"); + registerTicking(this); } catch (Throwable e) { - IrisLogging.reportError(e); + cleanupFailedConstruction(e); + throw new IllegalStateException("Failed to initialize Iris engine for world '" + target.getWorld().name() + "'.", e); } - IrisLogging.info("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); - failing = false; - closed = false; - art = J.ar(this::tickRandomPlayer, 0); - _t0 = M.ms(); - setupEngine(); - IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); - _t0 = M.ms(); - GenerationCacheWarmer.warm(this); - IrisLogging.debug("[IrisEngine timing] cache warm total=" + (M.ms() - _t0) + "ms"); IrisLogging.debug("Engine Initialized " + getCacheID()); } @@ -190,102 +247,278 @@ public class IrisEngine implements Engine { } private void tickRandomPlayer() { + if (closing.get() || closed) { + return; + } recycle(); if (perSecondBudLatch.flip()) { buds.set(bud.get()); bud.set(0); } - if (effects != null) { - effects.tickRandomPlayer(); - } - } - - private void prehotload() { - closing.set(true); - try { - generationSessions.sealAndAwait("hotload", 15000L); - } catch (GenerationSessionException e) { - throw new IllegalStateException(e); - } - - EngineWorldManager currentWorldManager = worldManager; - worldManager = null; - if (currentWorldManager != null) { - currentWorldManager.close(); - } - - IrisComplex currentComplex = complex; - complex = null; - if (currentComplex != null) { - currentComplex.close(); - } - - EngineEffects currentEffects = effects; - effects = null; + EngineEffects currentEffects = getEffects(); if (currentEffects != null) { - currentEffects.close(); - } - - EngineMode currentMode = mode; - mode = null; - if (currentMode != null) { - currentMode.close(); - } - - if (getWorld().hasPlatformWorld()) { - J.a(() -> platformHooks.refreshWorkspace(this)); + currentEffects.tickRandomPlayer(); } } - private void setupEngine() { + private static void registerTicking(IrisEngine engine) { + synchronized (TICK_LOCK) { + TICK_ENGINES.add(engine); + if (sharedTickTask == -1) { + sharedTickTask = J.ar(IrisEngine::tickEngines, 1); + } + } + } + + private static void unregisterTicking(IrisEngine engine) { + synchronized (TICK_LOCK) { + TICK_ENGINES.remove(engine); + if (TICK_ENGINES.isEmpty() && sharedTickTask != -1) { + J.car(sharedTickTask); + sharedTickTask = -1; + } + } + } + + private static void tickEngines() { + List engines; + synchronized (TICK_LOCK) { + engines = List.copyOf(TICK_ENGINES); + } + for (IrisEngine engine : engines) { + try { + engine.tickRandomPlayer(); + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + } + } + } + + private void sealInitialGenerationSession() { try { - IrisStructureLocator.invalidate(this); - StructureReachability.invalidate(this); - generationSessions.activateNextSession(); - closing.set(false); - IrisLogging.debug("Setup Engine " + getCacheID()); - cacheId = RNG.r.nextInt(); - long t0 = M.ms(); - complex = ensureComplex(); - IrisLogging.debug("[IrisEngine timing] ensureComplex=" + (M.ms() - t0) + "ms"); - t0 = M.ms(); - upperContext = buildUpperContext(); - IrisLogging.debug("[IrisEngine timing] buildUpperContext=" + (M.ms() - t0) + "ms"); - t0 = M.ms(); - effects = IrisServices.get(EngineEffectsProvider.class).create(this); - IrisLogging.debug("[IrisEngine timing] EngineEffects=" + (M.ms() - t0) + "ms"); - hash32 = new CompletableFuture<>(); - t0 = M.ms(); + generationSessions.sealAndAwait("initialization", 0L); + } catch (GenerationSessionException e) { + throw new IllegalStateException("Failed to seal the initial Iris generation session.", e); + } + } + + private void sealForTransition(String reason, boolean teardown) { + closing.set(true); + closeBackgroundTaskAdmission(); + try { + generationSessions.sealAndAwait(reason, SESSION_DRAIN_TIMEOUT_MILLIS, teardown); + BackgroundTaskDrain backgroundDrain = drainBackgroundTasks(reason); + backgroundDrain.requireComplete(reason); + } catch (GenerationSessionException e) { + throw new IllegalStateException("Failed to drain Iris generation for " + reason + ".", e); + } + } + + private EngineRuntime buildRuntime() { + return buildRuntime(getTarget()); + } + + private EngineRuntime buildRuntime(EngineTarget runtimeTarget) { + RuntimeAssembly assembly = new RuntimeAssembly(RNG.r.nextInt(), runtimeTarget); + runtimeAssembly.set(assembly); + try (IrisContext.Scope ignored = IrisContext.open(this, generationSessions.currentSessionId(), null)) { + IrisLogging.debug("Setup Engine " + assembly.cacheId); + long started = M.ms(); + assembly.complex = new IrisComplex(this); + IrisLogging.debug("[IrisEngine timing] complex=" + (M.ms() - started) + "ms"); + started = M.ms(); + assembly.upperContext = buildUpperContext(); + IrisLogging.debug("[IrisEngine timing] buildUpperContext=" + (M.ms() - started) + "ms"); + started = M.ms(); + assembly.effects = IrisServices.get(EngineEffectsProvider.class).create(this); + if (assembly.effects == null) { + throw new IllegalStateException("Engine effects provider returned null"); + } + IrisLogging.debug("[IrisEngine timing] EngineEffects=" + (M.ms() - started) + "ms"); + assembly.hash32 = new CompletableFuture<>(); + started = M.ms(); mantle.hotload(); - IrisLogging.debug("[IrisEngine timing] mantle.hotload=" + (M.ms() - t0) + "ms"); - t0 = M.ms(); - setupMode(); - IrisLogging.debug("[IrisEngine timing] setupMode=" + (M.ms() - t0) + "ms"); - t0 = M.ms(); - EngineWorldManager manager = IrisServices.get(EngineWorldManagerProvider.class).create(this); - worldManager = manager; - IrisLogging.debug("[IrisEngine timing] IrisWorldManager=" + (M.ms() - t0) + "ms"); - J.a(this::computeBiomeMaxes); - J.a(() -> { - File[] roots = getData().getLoaders() - .values() - .stream() - .map(ResourceLoader::getFolderName) - .map(n -> new File(getData().getDataFolder(), n)) - .filter(File::exists) - .filter(File::isDirectory) - .toArray(File[]::new); - hash32.complete(IO.hashRecursiveMeta(roots)); - }); - J.a(() -> platformHooks.refreshDatapackWorkspace(this)); + IrisLogging.debug("[IrisEngine timing] mantle.hotload=" + (M.ms() - started) + "ms"); + started = M.ms(); + assembly.mode = createMode(); + IrisLogging.debug("[IrisEngine timing] setupMode=" + (M.ms() - started) + "ms"); + started = M.ms(); + assembly.worldManager = IrisServices.get(EngineWorldManagerProvider.class).create(this); + if (assembly.worldManager == null) { + throw new IllegalStateException("Engine world manager provider returned null"); + } + IrisLogging.debug("[IrisEngine timing] IrisWorldManager=" + (M.ms() - started) + "ms"); + BiomeMaxes biomeMaxes = computeBiomeMaxes(); + return assembly.freeze(biomeMaxes); } catch (Throwable e) { - IrisLogging.error("FAILED TO SETUP ENGINE!"); + Throwable cleanupFailure = closeAssembly(assembly, e); + if (cleanupFailure != e) { + e.addSuppressed(cleanupFailure); + } + throw new IllegalStateException("Failed to build a complete Iris engine runtime.", e); + } finally { + runtimeAssembly.remove(); + } + } + + private void publishRuntime(EngineRuntime next, EngineRuntime previous) { + Throwable retirementFailure = closeRuntime(previous, null); + if (retirementFailure != null) { + retirementFailure = closeRuntime(next, retirementFailure); + lifecycleState = LifecycleState.FAILED; + throw new IllegalStateException("Failed to retire the previous Iris engine runtime.", retirementFailure); + } + if (runtime == previous) { + runtime = null; + } + + try { + next.worldManager.start(); + } catch (Throwable e) { + Throwable cleanupFailure = closeRuntime(next, e); + if (cleanupFailure != e) { + e.addSuppressed(cleanupFailure); + } + lifecycleState = LifecycleState.FAILED; + throw new IllegalStateException("Failed to start the Iris world manager.", e); + } + + runtime = next; + publishedTarget = next.target; + generationSessions.activateNextSession(); + lifecycleState = LifecycleState.RUNNING; + closing.set(false); + openBackgroundTaskAdmission(); + scheduleRuntimeTasks(next); + IrisLogging.debug("Engine Setup Complete " + next.cacheId); + } + + private void scheduleRuntimeTasks(EngineRuntime engineRuntime) { + try { + if (!scheduleTrackedTask(() -> { + try { + File[] roots = getData().getLoaders() + .values() + .stream() + .map(ResourceLoader::getFolderName) + .map(name -> new File(getData().getDataFolder(), name)) + .filter(File::exists) + .filter(File::isDirectory) + .toArray(File[]::new); + engineRuntime.hash32.complete(IO.hashRecursiveMeta(roots)); + } catch (Throwable e) { + engineRuntime.hash32.completeExceptionally(e); + throw propagate(e); + } + })) { + throw new IllegalStateException("Iris background task admission closed before pack hashing."); + } + } catch (Throwable e) { + engineRuntime.hash32.completeExceptionally(e); IrisLogging.reportError(e); e.printStackTrace(); } + try { + if (!scheduleTrackedTask(() -> platformHooks.refreshDatapackWorkspace(this))) { + throw new IllegalStateException("Iris background task admission closed before datapack workspace refresh."); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + } + } - IrisLogging.debug("Engine Setup Complete " + getCacheID()); + private boolean scheduleTrackedTask(Runnable task) { + synchronized (backgroundTaskLock) { + backgroundTasks.removeIf(tracked -> tracked.completion.isDone() + && !tracked.completion.isCompletedExceptionally()); + if (!backgroundTaskAdmission) { + return false; + } + TrackedBackgroundTask tracked = new TrackedBackgroundTask(); + Future future = J.a(() -> { + tracked.started.set(true); + try { + task.run(); + tracked.completion.complete(null); + return null; + } catch (Throwable exception) { + tracked.completion.completeExceptionally(exception); + throw propagate(exception); + } + }); + if (future == null) { + throw new IllegalStateException("Iris background task scheduler returned no task handle."); + } + tracked.future = future; + backgroundTasks.add(tracked); + return true; + } + } + + private BackgroundTaskDrain drainBackgroundTasks(String reason) { + List tasks; + synchronized (backgroundTaskLock) { + tasks = List.copyOf(backgroundTasks); + } + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS); + Throwable failure = null; + for (TrackedBackgroundTask task : tasks) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0L) { + cancelBackgroundTask(task, reason); + failure = appendFailure(failure, new TimeoutException("Timed out waiting for Iris background tasks during " + reason + ".")); + continue; + } + try { + task.completion.get(remaining, TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + cancelBackgroundTask(task, reason); + failure = appendFailure(failure, e); + } catch (ExecutionException | TimeoutException e) { + cancelBackgroundTask(task, reason); + failure = appendFailure(failure, e); + } + } + boolean complete; + synchronized (backgroundTaskLock) { + backgroundTasks.removeIf(tracked -> tracked.completion.isDone()); + complete = backgroundTasks.isEmpty(); + } + return new BackgroundTaskDrain(failure, complete); + } + + private void cancelBackgroundTask(TrackedBackgroundTask task, String reason) { + Future future = task.future; + if (future != null && future.cancel(true) && !task.started.get()) { + task.completion.completeExceptionally( + new IllegalStateException("Iris background task was cancelled before starting during " + reason + ".")); + } + } + + private void openBackgroundTaskAdmission() { + synchronized (backgroundTaskLock) { + backgroundTaskAdmission = true; + } + } + + private void closeBackgroundTaskAdmission() { + synchronized (backgroundTaskLock) { + backgroundTaskAdmission = false; + } + } + + private void cancelBackgroundTasks(String reason) { + List tasks; + synchronized (backgroundTaskLock) { + tasks = List.copyOf(backgroundTasks); + } + for (TrackedBackgroundTask task : tasks) { + cancelBackgroundTask(task, reason); + } } private UpperDimensionContext buildUpperContext() { @@ -307,82 +540,57 @@ public class IrisEngine implements Engine { return null; } - private void setupMode() { - EngineMode currentMode = mode; - if (currentMode != null) { - currentMode.close(); - } - - mode = null; - mode = ensureMode(); - } - - private EngineMode ensureMode() { - EngineMode currentMode = mode; - if (currentMode != null) { - return currentMode; - } - - synchronized (this) { - currentMode = mode; - if (currentMode != null) { - return currentMode; + private EngineMode createMode() { + Throwable configuredFailure = null; + try { + IrisDimensionMode configuredMode = getDimension().getMode(); + if (configuredMode == null) { + configuredMode = new IrisDimensionMode(); + getDimension().setMode(configuredMode); } - - try { - IrisComplex readyComplex = ensureComplex(); - if (readyComplex == null) { - throw new IllegalStateException("Iris complex is unavailable"); - } - - IrisDimensionMode configuredMode = getDimension().getMode(); - if (configuredMode == null) { - configuredMode = new IrisDimensionMode(); - getDimension().setMode(configuredMode); - } - - currentMode = configuredMode.create(this); - if (currentMode == null) { - throw new IllegalStateException("Dimension mode factory returned null"); - } - } catch (Throwable e) { - IrisLogging.reportError(e); - if (modeFallbackLogged.compareAndSet(false, true)) { - IrisLogging.warn("Failed to initialize configured dimension mode for " + getDimension().getLoadKey() + ", falling back to OVERWORLD mode."); - } - currentMode = IrisDimensionModeType.OVERWORLD.create(this); + EngineMode configured = configuredMode.create(this); + if (configured == null) { + throw new IllegalStateException("Dimension mode factory returned null"); } - - mode = currentMode; - return currentMode; - } - } - - private IrisComplex ensureComplex() { - IrisComplex currentComplex = complex; - if (currentComplex != null) { - return currentComplex; - } - - if (closed) { - return null; - } - - synchronized (this) { - currentComplex = complex; - if (currentComplex != null) { - return currentComplex; + return configured; + } catch (Throwable e) { + configuredFailure = e; + IrisLogging.reportError(e); + e.printStackTrace(); + if (modeFallbackLogged.compareAndSet(false, true)) { + IrisLogging.warn("Failed to initialize configured dimension mode for " + getDimension().getLoadKey() + ", falling back to OVERWORLD mode."); } + } - currentComplex = new IrisComplex(this); - complex = currentComplex; - return currentComplex; + try { + EngineMode fallback = IrisDimensionModeType.OVERWORLD.create(this); + if (fallback == null) { + throw new IllegalStateException("OVERWORLD mode factory returned null"); + } + return fallback; + } catch (Throwable fallbackFailure) { + fallbackFailure.addSuppressed(configuredFailure); + throw new IllegalStateException("Both configured and fallback Iris engine modes failed.", fallbackFailure); } } @Override public void generateMatter(int x, int z, boolean multicore, ChunkContext context) { - getMantle().generateMatter(x, z, multicore, context); + try (GenerationSessionLease lease = acquireGenerationLease("matter_generate"); + IrisContext.Scope ignored = IrisContext.open(this, lease.sessionId(), context)) { + IrisComplex activeComplex = getComplex(); + if (context == null || context.getComplex() != activeComplex) { + throw new IllegalStateException("Matter generation context does not belong to the active Iris runtime."); + } + if (context.getGenerationSessionId() != 0L + && context.getGenerationSessionId() != lease.sessionId()) { + throw new IllegalStateException("Matter generation context belongs to Iris session " + + context.getGenerationSessionId() + " instead of " + lease.sessionId() + "."); + } + getMantle().generateMatter(x, z, multicore, context); + } catch (GenerationSessionException e) { + throw new IllegalStateException("Matter generation was rejected by the Iris lifecycle.", e); + } } @Override @@ -415,30 +623,125 @@ public class IrisEngine implements Engine { } public void hotloadComplex() { - complex.close(); - complex = new IrisComplex(this); - upperContext = buildUpperContext(); + synchronized (lifecycleLock) { + requireRunning("rebuild the biome complex"); + lifecycleState = LifecycleState.HOTLOADING; + EngineRuntime previous = runtime; + IrisComplex nextComplex = null; + try { + sealForTransition("complex hotload", false); + RuntimeAssembly assembly = new RuntimeAssembly(RNG.r.nextInt(), previous.target); + runtimeAssembly.set(assembly); + EngineRuntime next; + try (IrisContext.Scope ignored = IrisContext.open(this, generationSessions.currentSessionId(), null)) { + assembly.complex = new IrisComplex(this); + nextComplex = assembly.complex; + assembly.upperContext = buildUpperContext(); + BiomeMaxes biomeMaxes = computeBiomeMaxes(); + next = previous.withComplex(assembly.cacheId, assembly.complex, assembly.upperContext, biomeMaxes); + } finally { + runtimeAssembly.remove(); + } + Throwable retirementFailure = runCleanup(null, previous.complex::close); + if (retirementFailure != null) { + retirementFailure = runCleanup(retirementFailure, nextComplex::close); + nextComplex = null; + lifecycleState = LifecycleState.FAILED; + throw new IllegalStateException("Failed to retire the previous Iris biome complex.", retirementFailure); + } + runtime = next; + generationSessions.activateNextSession(); + lifecycleState = LifecycleState.RUNNING; + closing.set(false); + openBackgroundTaskAdmission(); + } catch (Throwable e) { + if (nextComplex != null && nextComplex != previous.complex) { + Throwable cleanupFailure = runCleanup(null, nextComplex::close); + if (cleanupFailure != null) { + e.addSuppressed(cleanupFailure); + } + } + if (lifecycleState != LifecycleState.FAILED) { + restoreRuntimeAfterFailedTransition(previous); + } + throw new IllegalStateException("Failed to rebuild the Iris biome complex.", e); + } + } } public void hotloadSilently() { - try { - getData().dump(); - getData().clearLists(); - IrisDimension replacement = getData().getDimensionLoader().load(getDimension().getLoadKey()); - if (replacement == null) { - throw new IllegalStateException("Studio hotload could not reload Iris dimension '" + getDimension().getLoadKey() + "'"); + synchronized (lifecycleLock) { + requireRunning("hotload"); + lifecycleState = LifecycleState.HOTLOADING; + EngineRuntime previousRuntime = runtime; + IrisDimension previousDimension = getDimension(); + IrisData previousData = getData(); + IrisData replacementData = null; + boolean published = false; + try { + sealForTransition("hotload", false); + replacementData = IrisData.openRuntime(previousData.getDataFolder()); + IrisDimension replacement = replacementData.getDimensionLoader().load(previousDimension.getLoadKey()); + if (replacement == null) { + throw new IllegalStateException("Studio hotload could not reload Iris dimension '" + previousDimension.getLoadKey() + "'"); + } + platformHooks.validateDimensionHotload(this, replacement); + replacementData.registerEngine(this); + IrisStructureLocator.invalidate(this); + StructureReachability.invalidate(this); + EngineTarget replacementTarget = new EngineTarget(getWorld(), replacement, replacementData); + EngineRuntime nextRuntime = buildRuntime(replacementTarget); + publishRuntime(nextRuntime, previousRuntime); + published = true; + previousData.unregisterEngine(this); + Throwable previousDataFailure = runCleanup(null, previousData::close); + if (previousDataFailure != null) { + IrisLogging.error("Failed to completely release the previous Iris data runtime."); + IrisLogging.reportError(previousDataFailure); + previousDataFailure.printStackTrace(); + } + prefetchSaveStarted.set(false); + getEngineData().getStatistics().hotloaded(); + if (getWorld().hasPlatformWorld()) { + if (!scheduleTrackedTask(() -> { + platformHooks.refreshWorkspace(this); + platformHooks.reloadDatapacks(this); + })) { + throw new IllegalStateException("Iris background task admission closed before workspace refresh."); + } + } + broadcastStudioHotload(false, ""); + } catch (Throwable e) { + if (!published) { + if (replacementData != null) { + replacementData.unregisterEngine(this); + Throwable replacementDataFailure = runCleanup(null, replacementData::close); + if (replacementDataFailure != null) { + e.addSuppressed(replacementDataFailure); + } + } + IrisStructureLocator.invalidate(this); + StructureReachability.invalidate(this); + if (lifecycleState != LifecycleState.FAILED) { + Throwable rollbackFailure = runCleanup(null, getMantle()::hotload); + if (rollbackFailure == null) { + restoreRuntimeAfterFailedTransition(previousRuntime); + } else { + runtime = previousRuntime; + lifecycleState = LifecycleState.FAILED; + e.addSuppressed(rollbackFailure); + } + } + } + broadcastStudioHotload(true, e.getClass().getSimpleName() + ": " + e.getMessage()); + if (e instanceof Error error) { + throw error; + } + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Iris hotload failed.", e); } - platformHooks.validateDimensionHotload(this, replacement); - getTarget().setDimension(replacement); - prehotload(); - setupEngine(); - if (getWorld().hasPlatformWorld()) { - J.a(() -> platformHooks.reloadDatapacks(this)); - } - broadcastStudioHotload(false, ""); - } catch (RuntimeException | Error e) { - broadcastStudioHotload(true, e.getClass().getSimpleName() + ": " + e.getMessage()); - throw e; } } @@ -458,46 +761,48 @@ public class IrisEngine implements Engine { @Override public IrisEngineData getEngineData() { - return engineData.aquire(() -> { - //TODO: Method this file + IrisEngineData loaded = engineData; + if (loaded != null) { + return loaded; + } + synchronized (engineDataLock) { + loaded = engineData; + if (loaded != null) { + return loaded; + } File f = new File(getWorld().worldFolder(), "iris/engine-data/" + getDimension().getLoadKey() + ".json"); - IrisEngineData data = null; - if (f.exists()) { try { - data = new Gson().fromJson(IO.readAll(f), IrisEngineData.class); - if (data == null) { - IrisLogging.error("Failed to read Engine Data! Corrupted File? recreating..."); + loaded = new Gson().fromJson(IO.readAll(f), IrisEngineData.class); + if (loaded == null) { + throw new IllegalStateException("Engine data file contains no JSON object: " + f.getAbsolutePath()); } + } catch (IOException | JsonParseException e) { + IrisLogging.reportError(e); + e.printStackTrace(); + throw new IllegalStateException("Failed to read Iris engine data without modifying it: " + f.getAbsolutePath(), e); + } + } + + if (loaded == null) { + loaded = new IrisEngineData(); + loaded.getStatistics().setVersion(IrisPlatforms.get().irisVersionNumber()); + loaded.getStatistics().setMCVersion(IrisPlatforms.get().minecraftVersionNumber()); + loaded.getStatistics().setUpgradedVersion(IrisPlatforms.get().irisVersionNumber()); + if (loaded.getStatistics().getVersion() == -1 || loaded.getStatistics().getMCVersion() == -1) { + IrisLogging.error("Failed to setup Engine Data!"); + } + try { + writeEngineDataAtomically(f, loaded); } catch (IOException e) { IrisLogging.reportError(e); e.printStackTrace(); + throw new IllegalStateException("Failed to create Iris engine data: " + f.getAbsolutePath(), e); } } - - if (data == null) { - data = new IrisEngineData(); - data.getStatistics().setVersion(IrisPlatforms.get().irisVersionNumber()); - data.getStatistics().setMCVersion(IrisPlatforms.get().minecraftVersionNumber()); - data.getStatistics().setUpgradedVersion(IrisPlatforms.get().irisVersionNumber()); - if (data.getStatistics().getVersion() == -1 || data.getStatistics().getMCVersion() == -1 ) { - IrisLogging.error("Failed to setup Engine Data!"); - } - - if (f.getParentFile().exists() || f.getParentFile().mkdirs()) { - try { - IO.writeAll(f, new Gson().toJson(data)); - } catch (IOException e) { - IrisLogging.reportError(e); - e.printStackTrace(); - } - } else { - IrisLogging.error("Failed to setup Engine Data!"); - } - } - - return data; - }); + engineData = loaded; + return loaded; + } } @Override @@ -528,7 +833,10 @@ public class IrisEngine implements Engine { return studio; } - private void computeBiomeMaxes() { + private BiomeMaxes computeBiomeMaxes() { + double objectDensity = 0D; + double layerDensity = 0D; + double decoratorDensity = 0D; for (IrisBiome i : getDimension().getReachableBiomes(this)) { double density = 0; @@ -536,22 +844,23 @@ public class IrisEngine implements Engine { density += j.getDensity() * j.getChance(); } - maxBiomeObjectDensity = Math.max(maxBiomeObjectDensity, density); + objectDensity = Math.max(objectDensity, density); density = 0; for (IrisDecorator j : i.getDecorators()) { density += Math.max(j.getStackMax(), 1) * j.getChance(); } - maxBiomeDecoratorDensity = Math.max(maxBiomeDecoratorDensity, density); + decoratorDensity = Math.max(decoratorDensity, density); density = 0; for (IrisBiomePaletteLayer j : i.getLayers()) { density++; } - maxBiomeLayerDensity = Math.max(maxBiomeLayerDensity, density); + layerDensity = Math.max(layerDensity, density); } + return new BiomeMaxes(objectDensity, layerDensity, decoratorDensity); } @Override @@ -616,39 +925,68 @@ public class IrisEngine implements Engine { @Override public void close() { - platformHooks.shutdownPregenerator(this); - closing.set(true); - closed = true; - J.car(art); - try { - generationSessions.sealAndAwait("close", 15000L, true); - } catch (GenerationSessionException e) { - throw new IllegalStateException(e); + Throwable failure; + synchronized (lifecycleLock) { + if (closed) { + return; + } + lifecycleState = LifecycleState.CLOSING; + closing.set(true); + closeBackgroundTaskAdmission(); + unregisterTicking(this); + platformHooks.shutdownPregenerator(this); + try { + generationSessions.sealAndAwait("close", SESSION_DRAIN_TIMEOUT_MILLIS, true); + } catch (GenerationSessionException e) { + throw new IllegalStateException("Failed to drain Iris generation for close.", e); + } + + BackgroundTaskDrain backgroundDrain = drainBackgroundTasks("close"); + failure = backgroundDrain.failure; + if (!backgroundDrain.allowsResourceRelease() && failure == null) { + failure = new IllegalStateException("Iris background tasks remain active during close."); + } + + if (backgroundDrain.allowsResourceRelease()) { + Throwable prefetchFailure = runCleanup(null, this::savePrefetchOnce); + Throwable engineDataFailure = runCleanup(null, this::saveEngineData); + failure = appendFailure(failure, prefetchFailure); + failure = appendFailure(failure, engineDataFailure); + failure = releaseRuntime(failure); + if (runtimeReleased) { + failure = releaseTarget(failure); + } + if (targetReleased) { + failure = releaseMantle(failure); + } + if (prefetchFailure == null + && engineDataFailure == null + && runtimeReleased + && targetReleased + && mantleReleased) { + failure = releaseEngineDataForShutdown(failure); + } + if (engineDataReleased) { + failure = releasePreservation(failure); + } + } + if (failure == null + && runtimeReleased + && targetReleased + && mantleReleased + && engineDataReleased + && preservationReleased) { + closed = true; + lifecycleState = LifecycleState.CLOSED; + IrisLogging.debug("Engine Fully Shutdown!"); + } } - EngineWorldManager currentWorldManager = getWorldManager(); - if (currentWorldManager != null) { - currentWorldManager.close(); + if (failure != null) { + IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + getWorld().name() + "."); + IrisLogging.reportError(failure); + failure.printStackTrace(); + throw new IllegalStateException("Iris engine shutdown remains incomplete after cleanup failures.", failure); } - savePrefetchOnce(); - getTarget().close(); - saveEngineData(); - getMantle().close(); - IrisComplex currentComplex = complex; - if (currentComplex != null) { - currentComplex.close(); - } - complex = null; - EngineMode currentMode = mode; - if (currentMode != null) { - currentMode.close(); - } - mode = null; - effects = null; - worldManager = null; - getData().dump(); - getData().clearLists(); - IrisServices.get(PreservationRegistry.class).dereference(); - IrisLogging.debug("Engine Fully Shutdown!"); } private boolean isPregeneratorActiveForThisWorld() { @@ -657,18 +995,101 @@ public class IrisEngine implements Engine { private void savePrefetchOnce() { if (prefetchSaveStarted.compareAndSet(false, true)) { - getData().savePrefetch(this); + try { + getData().savePrefetch(this); + } catch (Throwable e) { + prefetchSaveStarted.set(false); + throw propagate(e); + } } } @Override public IrisComplex getComplex() { - return ensureComplex(); + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null && assembly.complex != null) { + return assembly.complex; + } + EngineRuntime current = runtime; + return current == null ? null : current.complex; + } + + @Override + public EngineTarget getTarget() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.target; + } + EngineRuntime current = runtime; + return current == null ? publishedTarget : current.target; } @Override public EngineMode getMode() { - return ensureMode(); + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null && assembly.mode != null) { + return assembly.mode; + } + EngineRuntime current = requireRuntime("access the engine mode"); + return current.mode; + } + + @Override + public EngineEffects getEffects() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null && assembly.effects != null) { + return assembly.effects; + } + EngineRuntime current = runtime; + return current == null ? null : current.effects; + } + + @Override + public EngineWorldManager getWorldManager() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null && assembly.worldManager != null) { + return assembly.worldManager; + } + EngineRuntime current = runtime; + return current == null ? null : current.worldManager; + } + + @Override + public UpperDimensionContext getUpperContext() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.upperContext; + } + EngineRuntime current = runtime; + return current == null ? null : current.upperContext; + } + + @Override + public CompletableFuture getHash32() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null && assembly.hash32 != null) { + return assembly.hash32; + } + EngineRuntime current = requireRuntime("access the pack hash"); + return current.hash32; + } + + @Override + public double getMaxBiomeObjectDensity() { + EngineRuntime current = runtime; + return current == null ? 0D : current.biomeMaxes.objectDensity; + } + + @Override + public double getMaxBiomeLayerDensity() { + EngineRuntime current = runtime; + return current == null ? 0D : current.biomeMaxes.layerDensity; + } + + @Override + public double getMaxBiomeDecoratorDensity() { + EngineRuntime current = runtime; + return current == null ? 0D : current.biomeMaxes.decoratorDensity; } @Override @@ -682,6 +1103,9 @@ public class IrisEngine implements Engine { @Override public void recycle() { + if (closing.get() || closed) { + return; + } if (!cleanLatch.flip()) { return; } @@ -693,7 +1117,7 @@ public class IrisEngine implements Engine { cleaning.set(true); - J.a(() -> { + if (!scheduleTrackedTask(() -> { try { getData().getObjectLoader().clean(); } catch (Throwable e) { @@ -702,16 +1126,14 @@ public class IrisEngine implements Engine { } cleaning.lazySet(false); - }); + })) { + cleaning.set(false); + } } @BlockCoordinates @Override public void generate(int x, int z, Hunk vblocks, Hunk vbiomes, boolean multicore) throws WrongEngineBroException { - if (closing.get() || closed) { - throw new GenerationSessionException("Generation session is closed for world \"" + getWorld().name() + "\".", true); - } - try (GenerationSessionLease lease = acquireGenerationLease("chunk_generate"); IrisContext.Scope generationScope = IrisContext.open(this, lease.sessionId(), null)) { getEngineData().getStatistics().generatedChunk(); @@ -726,7 +1148,7 @@ public class IrisEngine implements Engine { } } } else { - EngineMode activeMode = ensureMode(); + EngineMode activeMode = getMode(); activeMode.generate(x, z, blocks, vbiomes, multicore, lease.sessionId()); } @@ -738,13 +1160,17 @@ public class IrisEngine implements Engine { generated.incrementAndGet(); if (generated.get() == 661 && !isPregeneratorActiveForThisWorld()) { - J.a(this::savePrefetchOnce); + scheduleTrackedTask(this::savePrefetchOnce); } } catch (GenerationSessionException e) { throw e; } catch (Throwable e) { IrisLogging.reportError(e); fail("Failed to generate " + x + ", " + z, e); + if (e instanceof WrongEngineBroException wrongEngine) { + throw wrongEngine; + } + throw new WrongEngineBroException("Failed to generate chunk at " + x + ", " + z + ".", e); } } @@ -755,16 +1181,17 @@ public class IrisEngine implements Engine { @Override public void saveEngineData() { - //TODO: Method this file - File f = new File(getWorld().worldFolder(), "iris/engine-data/" + getDimension().getLoadKey() + ".json"); - f.getParentFile().mkdirs(); - try { - IO.writeAll(f, new Gson().toJson(getEngineData())); - IrisLogging.debug("Saved Engine Data"); - } catch (IOException e) { - IrisLogging.error("Failed to save Engine Data"); - IrisLogging.reportError(e); - e.printStackTrace(); + synchronized (engineDataLock) { + File f = new File(getWorld().worldFolder(), "iris/engine-data/" + getDimension().getLoadKey() + ".json"); + try { + writeEngineDataAtomically(f, getEngineData()); + IrisLogging.debug("Saved Engine Data"); + } catch (IOException e) { + IrisLogging.error("Failed to save Engine Data"); + IrisLogging.reportError(e); + e.printStackTrace(); + throw new IllegalStateException("Failed to save Iris engine data: " + f.getAbsolutePath(), e); + } } } @@ -806,7 +1233,250 @@ public class IrisEngine implements Engine { @Override public int getCacheID() { - return cacheId; + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.cacheId; + } + EngineRuntime current = runtime; + return current == null ? -1 : current.cacheId; + } + + private void requireRunning(String operation) { + if (closed || closing.get() || lifecycleState != LifecycleState.RUNNING || runtime == null) { + throw new IllegalStateException("Cannot " + operation + " while Iris engine " + getWorld().name() + + " is " + lifecycleState.name().toLowerCase() + "."); + } + } + + private void restoreRuntimeAfterFailedTransition(EngineRuntime previous) { + runtime = previous; + if (generationSessions.activeLeases() == 0) { + generationSessions.activateNextSession(); + lifecycleState = LifecycleState.RUNNING; + closing.set(false); + openBackgroundTaskAdmission(); + return; + } + lifecycleState = LifecycleState.FAILED; + } + + private EngineRuntime requireRuntime(String operation) { + EngineRuntime current = runtime; + if (current == null) { + throw new IllegalStateException("Cannot " + operation + " without an active Iris runtime for " + + getWorld().name() + "."); + } + return current; + } + + private void releaseEngineData() { + IrisData data = getData(); + data.unregisterEngine(this); + if (data.getEngines().isEmpty()) { + data.close(); + data.clearLists(); + } + } + + private void cleanupFailedConstruction(Throwable original) { + closing.set(true); + closeBackgroundTaskAdmission(); + unregisterTicking(this); + lifecycleState = LifecycleState.FAILED; + Throwable cleanupFailure = null; + try { + generationSessions.sealAndAwait("failed initialization", 0L, true); + } catch (Throwable e) { + cleanupFailure = appendFailure(cleanupFailure, e); + } + cancelBackgroundTasks("failed initialization"); + BackgroundTaskDrain backgroundDrain = drainBackgroundTasks("failed initialization"); + cleanupFailure = appendFailure(cleanupFailure, backgroundDrain.failure); + if (!backgroundDrain.allowsResourceRelease()) { + cleanupFailure = appendFailure(cleanupFailure, + new IllegalStateException("Iris background tasks remain active after failed initialization.")); + if (cleanupFailure != original) { + original.addSuppressed(cleanupFailure); + } + return; + } + cleanupFailure = closeRuntime(runtime, cleanupFailure); + runtime = null; + cleanupFailure = runCleanup(cleanupFailure, getTarget()::close); + cleanupFailure = runCleanup(cleanupFailure, getMantle()::close); + cleanupFailure = runCleanup(cleanupFailure, this::releaseEngineData); + closed = true; + cleanupFailure = runCleanup(cleanupFailure, () -> { + PreservationRegistry registry = IrisServices.getOrNull(PreservationRegistry.class); + if (registry != null) { + registry.dereference(); + } + }); + if (cleanupFailure != null && cleanupFailure != original) { + original.addSuppressed(cleanupFailure); + } + } + + private Throwable closeAssembly(RuntimeAssembly assembly, Throwable failure) { + if (assembly == null) { + return failure; + } + failure = runCleanup(failure, () -> { + if (assembly.worldManager != null) { + assembly.worldManager.close(); + } + }); + failure = runCleanup(failure, () -> { + if (assembly.effects != null) { + assembly.effects.close(); + } + }); + failure = runCleanup(failure, () -> { + if (assembly.mode != null) { + assembly.mode.close(); + } + }); + failure = runCleanup(failure, () -> { + if (assembly.complex != null) { + assembly.complex.close(); + } + }); + failure = runCleanup(failure, () -> { + if (assembly.hash32 != null) { + assembly.hash32.cancel(true); + } + }); + return failure; + } + + private Throwable closeRuntime(EngineRuntime engineRuntime, Throwable failure) { + if (engineRuntime == null) { + return failure; + } + failure = runCleanup(failure, engineRuntime.worldManager::close); + failure = runCleanup(failure, engineRuntime.effects::close); + failure = runCleanup(failure, engineRuntime.mode::close); + failure = runCleanup(failure, engineRuntime.complex::close); + failure = runCleanup(failure, () -> engineRuntime.hash32.cancel(true)); + return failure; + } + + private Throwable releaseRuntime(Throwable failure) { + if (runtimeReleased) { + return failure; + } + Throwable runtimeFailure = closeRuntime(runtime, null); + if (runtimeFailure != null) { + return appendFailure(failure, runtimeFailure); + } + runtime = null; + runtimeReleased = true; + return failure; + } + + private Throwable releaseTarget(Throwable failure) { + if (targetReleased) { + return failure; + } + Throwable targetFailure = runCleanup(null, getTarget()::close); + if (targetFailure != null) { + return appendFailure(failure, targetFailure); + } + targetReleased = true; + return failure; + } + + private Throwable releaseMantle(Throwable failure) { + if (mantleReleased) { + return failure; + } + Throwable mantleFailure = runCleanup(null, getMantle()::close); + if (mantleFailure != null) { + return appendFailure(failure, mantleFailure); + } + mantleReleased = true; + return failure; + } + + private Throwable releaseEngineDataForShutdown(Throwable failure) { + if (engineDataReleased) { + return failure; + } + Throwable dataFailure = runCleanup(null, this::releaseEngineData); + if (dataFailure != null) { + return appendFailure(failure, dataFailure); + } + engineDataReleased = true; + return failure; + } + + private Throwable releasePreservation(Throwable failure) { + if (preservationReleased) { + return failure; + } + Throwable preservationFailure = runCleanup(null, () -> { + PreservationRegistry registry = IrisServices.getOrNull(PreservationRegistry.class); + if (registry != null) { + registry.dereference(); + } + }); + if (preservationFailure != null) { + return appendFailure(failure, preservationFailure); + } + preservationReleased = true; + return failure; + } + + private static Throwable runCleanup(Throwable failure, Runnable cleanup) { + try { + cleanup.run(); + } catch (Throwable e) { + return appendFailure(failure, e); + } + return failure; + } + + private static Throwable appendFailure(Throwable failure, Throwable additional) { + if (additional == null) { + return failure; + } + if (failure == null) { + return additional; + } + if (failure != additional) { + failure.addSuppressed(additional); + } + return failure; + } + + private static RuntimeException propagate(Throwable throwable) { + if (throwable instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (throwable instanceof Error error) { + throw error; + } + return new IllegalStateException(throwable); + } + + static void writeEngineDataAtomically(File file, IrisEngineData data) throws IOException { + Path output = file.toPath(); + Path parent = output.getParent(); + if (parent == null) { + throw new IOException("Engine data path has no parent: " + output); + } + Files.createDirectories(parent); + Path temporary = Files.createTempFile(parent, output.getFileName().toString(), ".tmp"); + try { + Files.writeString(temporary, new Gson().toJson(data), StandardCharsets.UTF_8); + try { + Files.move(temporary, output, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } } private boolean EngineSafe() { @@ -824,4 +1494,99 @@ public class IrisEngine implements Engine { return true; } + private enum LifecycleState { + INITIALIZING, + RUNNING, + HOTLOADING, + CLOSING, + CLOSED, + FAILED + } + + private record BiomeMaxes(double objectDensity, double layerDensity, double decoratorDensity) { + } + + private record EngineRuntime( + int cacheId, + EngineTarget target, + IrisComplex complex, + UpperDimensionContext upperContext, + EngineEffects effects, + EngineMode mode, + EngineWorldManager worldManager, + CompletableFuture hash32, + BiomeMaxes biomeMaxes + ) { + private EngineRuntime withComplex( + int nextCacheId, + IrisComplex nextComplex, + UpperDimensionContext nextUpperContext, + BiomeMaxes nextBiomeMaxes + ) { + return new EngineRuntime( + nextCacheId, + target, + nextComplex, + nextUpperContext, + effects, + mode, + worldManager, + hash32, + nextBiomeMaxes); + } + } + + private static final class RuntimeAssembly { + private final int cacheId; + private final EngineTarget target; + private IrisComplex complex; + private UpperDimensionContext upperContext; + private EngineEffects effects; + private EngineMode mode; + private EngineWorldManager worldManager; + private CompletableFuture hash32; + + private RuntimeAssembly(int cacheId, EngineTarget target) { + this.cacheId = cacheId; + this.target = target; + } + + private EngineRuntime freeze(BiomeMaxes biomeMaxes) { + if (complex == null || effects == null || mode == null || worldManager == null || hash32 == null) { + throw new IllegalStateException("Cannot publish an incomplete Iris engine runtime."); + } + return new EngineRuntime( + cacheId, + target, + complex, + upperContext, + effects, + mode, + worldManager, + hash32, + biomeMaxes); + } + } + + private static final class TrackedBackgroundTask { + private final AtomicBoolean started = new AtomicBoolean(); + private final CompletableFuture completion = new CompletableFuture<>(); + private volatile Future future; + } + + record BackgroundTaskDrain(Throwable failure, boolean complete) { + boolean allowsResourceRelease() { + return complete; + } + + private void requireComplete(String reason) { + if (failure != null) { + throw new IllegalStateException("Iris background tasks failed to drain during " + reason + ".", failure); + } + if (!complete) { + throw new IllegalStateException("Iris background tasks remain active during " + reason + "."); + } + } + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java index b36cd785c..7592bf3b8 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java @@ -36,19 +36,23 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; public class IrisEngineEffects extends EngineAssignedComponent implements EngineEffects { private static final long EFFECT_BUDGET_NANOS = 1_500_000L; + private static final long EMPTY_PLAYER_REFRESH_NANOS = 1_000_000_000L; private final ConcurrentHashMap players; private final Semaphore limit; private final AtomicBoolean playerMapUpdateQueued; + private final AtomicLong nextEmptyRefresh; public IrisEngineEffects(Engine engine) { super(engine, "FX"); players = new ConcurrentHashMap<>(); limit = new Semaphore(1); playerMapUpdateQueued = new AtomicBoolean(false); + nextEmptyRefresh = new AtomicLong(0L); } @Override @@ -93,7 +97,15 @@ public class IrisEngineEffects extends EngineAssignedComponent implements Engine return; } try { - if (players.isEmpty() || M.r(0.02)) { + if (players.isEmpty()) { + long now = System.nanoTime(); + long next = nextEmptyRefresh.get(); + if (now >= next && nextEmptyRefresh.compareAndSet(next, now + EMPTY_PLAYER_REFRESH_NANOS)) { + updatePlayerMap(); + } + return; + } + if (M.r(0.02)) { updatePlayerMap(); return; } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java index 72d48f5ff..3fbafcbb4 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -22,7 +22,6 @@ import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.WorldMaintenance; -import art.arcane.iris.engine.EnginePanic; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; @@ -66,7 +65,9 @@ import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; @Data @EqualsAndHashCode(exclude = "engine") @@ -182,8 +183,8 @@ public class IrisEngineMantle implements EngineMantle { IrisMatterSupport.ensureRegistered(); File dataFolder = new File(engine.getWorld().worldFolder(), "mantle"); int worldHeight = engine.getTarget().getHeight(); - MantleDataAdapter adapter = createRuntimeDataAdapter(engine.getData()); - MantleHooks hooks = createRuntimeHooks(); + MantleDataAdapter adapter = createDataAdapter(engine::getData); + MantleHooks hooks = createHooks(EnginePanic.scoped("world " + engine.getWorld().name())); art.arcane.volmlib.util.mantle.Mantle.RegionIO> regionIO = createRegionIO(dataFolder, worldHeight, adapter, hooks); return new Mantle<>( @@ -199,14 +200,14 @@ public class IrisEngineMantle implements EngineMantle { } public static MantleDataAdapter createRuntimeDataAdapter(IrisData data) { - return createDataAdapter(data); + return createDataAdapter(() -> data); } public static MantleHooks createRuntimeHooks() { - return createHooks(); + return createHooks(EnginePanic.scoped("runtime mantle")); } - private static MantleDataAdapter createDataAdapter(IrisData data) { + private static MantleDataAdapter createDataAdapter(Supplier dataSupplier) { return new MantleDataAdapter<>() { @Override public Matter createSection() { @@ -215,6 +216,7 @@ public class IrisEngineMantle implements EngineMantle { @Override public Matter readSection(art.arcane.volmlib.util.io.CountingDataInputStream din) throws IOException { + IrisData data = Objects.requireNonNull(dataSupplier.get(), "Iris mantle data is unavailable."); try (IrisMatterContext.Scope scope = IrisMatterContext.open(data)) { return Matter.readDin(din); } @@ -285,11 +287,11 @@ public class IrisEngineMantle implements EngineMantle { }; } - private static MantleHooks createHooks() { + private static MantleHooks createHooks(EnginePanic.Diagnostics panic) { return new MantleHooks() { @Override public void onBeforeReadSection(int index) { - EnginePanic.add("read.section", "Section[" + index + "]"); + panic.add("read.section", "Section[" + index + "]"); } @Override @@ -299,22 +301,22 @@ public class IrisEngineMantle implements EngineMantle { art.arcane.volmlib.util.io.CountingDataInputStream din, IOException error) { IrisLogging.error("Failed to read chunk section, skipping it."); - EnginePanic.add("read.byte.range", start + " " + end); - EnginePanic.add("read.byte.current", din.count() + ""); + panic.add("read.byte.range", start + " " + end); + panic.add("read.byte.current", din.count() + ""); IrisLogging.reportError(error); error.printStackTrace(); - EnginePanic.panic(); + panic.panic(); TectonicPlate.addError(); } @Override public void onBeforeReadChunk(int index) { - EnginePanic.add("read-chunk", "Chunk[" + index + "]"); + panic.add("read-chunk", "Chunk[" + index + "]"); } @Override public void onAfterReadChunk(int index) { - EnginePanic.saveLast(); + panic.saveLast(); } @Override @@ -324,11 +326,11 @@ public class IrisEngineMantle implements EngineMantle { art.arcane.volmlib.util.io.CountingDataInputStream din, Throwable error) { IrisLogging.error("Failed to read chunk, creating a new chunk instead."); - EnginePanic.add("read.byte.range", start + " " + end); - EnginePanic.add("read.byte.current", din.count() + ""); + panic.add("read.byte.range", start + " " + end); + panic.add("read.byte.current", din.count() + ""); IrisLogging.reportError(error); error.printStackTrace(); - EnginePanic.panic(); + panic.panic(); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index cbff7eebb..f28ef24ef 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -22,6 +22,7 @@ import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.service.tree.BlockDropRouter; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; @@ -82,9 +83,12 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -95,7 +99,6 @@ public class IrisWorldManager extends EngineAssignedWorldManager { private static final int MAX_FORCED_CHUNK_UPDATES = 128; private final Looper looper; - private final int id; private final KList updateQueue = new KList<>(); private final ChronoLatch cl; private final ChronoLatch clw; @@ -111,10 +114,15 @@ public class IrisWorldManager extends EngineAssignedWorldManager { private final Set chunkUpdateQueue = ConcurrentHashMap.newKeySet(); private final AtomicBoolean chunkUpdateScanScheduled = new AtomicBoolean(); private final AtomicBoolean chunkDiscoveryScanScheduled = new AtomicBoolean(); - private int entityCount = 0; - private int actuallySpawned = 0; + private final AtomicBoolean entityCountWarningReported = new AtomicBoolean(); + private final AtomicBoolean entityCountErrorReported = new AtomicBoolean(); + private boolean looperStopped; + private boolean cleanupServiceStopped; + private volatile int entityCount = 0; + private final AtomicInteger actuallySpawned = new AtomicInteger(); private int cooldown = 0; private int forcedChunkUpdateCursor = 0; + private volatile boolean entityCountValid = false; private volatile boolean playersPresent = false; private KSet injectBiomes = new KSet<>(); private volatile int loadedChunkCount = 0; @@ -128,7 +136,6 @@ public class IrisWorldManager extends EngineAssignedWorldManager { chunkUpdater = null; chunkDiscovery = null; cleanupService = null; - id = -1; } public IrisWorldManager(Engine engine) { @@ -143,55 +150,82 @@ public class IrisWorldManager extends EngineAssignedWorldManager { thread.setPriority(Thread.MIN_PRIORITY); return thread; }); - id = engine.getCacheID(); looper = new Looper() { @Override protected long loop() { - if (getEngine().isClosed() || getEngine().getCacheID() != id) { - interrupt(); + if (!isManagerStarted()) { + return -1L; } - - if (!getEngine().getWorld().hasPlatformWorld() && clw.flip()) { - J.runGlobal(() -> BukkitWorldBinding.tryBind(getEngine().getWorld())); - } - - if (getEngine().getWorld().hasPlatformWorld()) { - if (chunkUpdater.flip()) { - updateChunks(); - } - - if (!playersPresent) { - return 5000; - } - - if (chunkDiscovery.flip()) { - discoverChunks(); - } - - if (cln.flip()) { - engine.getEngineData().cleanup(getEngine()); - } - - if (!IrisSettings.get().getWorld().isMarkerEntitySpawningSystem() && !IrisSettings.get().getWorld().isAmbientEntitySpawningSystem()) { - return 3000; - } - - onAsyncTick(); - } - - return IrisSettings.get().getWorld().getAsyncTickIntervalMS(); + return callManagerTask( + "bukkit_world_manager_loop", + IrisWorldManager.this::runLoop, + 250L); } }; looper.setPriority(Thread.MIN_PRIORITY); looper.setName("Iris World Manager " + getTarget().getWorld().name()); } - public void startManager() { + private long runLoop() { + if (getEngine().isClosed()) { + looper.interrupt(); + return -1L; + } + + if (!getEngine().getWorld().hasPlatformWorld() && clw.flip()) { + J.runGlobal(() -> runManagerTask( + "bukkit_world_manager_bind", + () -> BukkitWorldBinding.tryBind(getEngine().getWorld()))); + } + + if (getEngine().getWorld().hasPlatformWorld()) { + if (chunkUpdater.flip()) { + updateChunks(); + } + + if (!playersPresent) { + return 5000L; + } + + if (chunkDiscovery.flip()) { + discoverChunks(); + } + + if (cln.flip()) { + getEngine().getEngineData().cleanup(getEngine()); + } + + if (!IrisSettings.get().getWorld().isMarkerEntitySpawningSystem() + && !IrisSettings.get().getWorld().isAmbientEntitySpawningSystem()) { + return 3000L; + } + + onAsyncTick(); + } + + return IrisSettings.get().getWorld().getAsyncTickIntervalMS(); + } + + @Override + public void start() { + super.start(); if (!looper.isAlive()) { looper.start(); } } + private Runnable managedTask(String operation, Runnable task) { + return () -> runManagerTask(operation, task); + } + + private Runnable managedTask(String operation, Runnable task, Runnable unavailable) { + return () -> { + if (!runManagerTask(operation, task)) { + unavailable.run(); + } + }; + } + private void discoverChunks() { World world = BukkitWorldBinding.world(getEngine().getWorld()); if (world == null) { @@ -206,7 +240,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - boolean scheduled = J.runGlobal(() -> { + boolean scheduled = J.runGlobal(managedTask("bukkit_world_manager_discover_chunks", () -> { try { if (getEngine().isClosed() || !world.equals(BukkitWorldBinding.world(getEngine().getWorld()))) { return; @@ -217,7 +251,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { continue; } - J.runEntity(player, () -> { + J.runEntity(player, managedTask("bukkit_world_manager_discover_player", () -> { if (!player.isOnline() || !world.equals(player.getWorld())) { return; } @@ -232,14 +266,14 @@ public class IrisWorldManager extends EngineAssignedWorldManager { raiseDiscoveredChunkFlag(world, chunkX, chunkZ); } } - }); + })); } } catch (Throwable e) { IrisLogging.reportError(e); } finally { chunkDiscoveryScanScheduled.set(false); } - }); + }, () -> chunkDiscoveryScanScheduled.set(false))); if (!scheduled) { chunkDiscoveryScanScheduled.set(false); } @@ -260,7 +294,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - J.a(() -> { + J.a(managedTask("bukkit_world_manager_discovered_flag", () -> { try { Mantle mantle = getMantle(); if (!mantle.hasFlag(chunkX, chunkZ, MantleFlag.DISCOVERED)) { @@ -271,7 +305,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } finally { discoveredFlagQueue.remove(key); } - }); + }, () -> discoveredFlagQueue.remove(key))); } private void updateChunks() { @@ -288,7 +322,10 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - boolean scheduled = J.runGlobal(() -> updateChunksOnGlobal(world)); + boolean scheduled = J.runGlobal(managedTask( + "bukkit_world_manager_update_chunks", + () -> updateChunksOnGlobal(world), + () -> chunkUpdateScanScheduled.set(false))); if (!scheduled) { chunkUpdateScanScheduled.set(false); } @@ -308,7 +345,9 @@ public class IrisWorldManager extends EngineAssignedWorldManager { continue; } - J.runEntity(player, () -> schedulePlayerChunkUpdates(world, player)); + J.runEntity(player, managedTask( + "bukkit_world_manager_player_chunk_updates", + () -> schedulePlayerChunkUpdates(world, player))); } scheduleForcedChunkUpdates(world); @@ -363,13 +402,13 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } try { - boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + boolean scheduled = J.runRegion(world, chunkX, chunkZ, managedTask("bukkit_world_manager_chunk_update", () -> { try { updateChunkRegion(world, chunkX, chunkZ); } finally { chunkUpdateQueue.remove(key); } - }); + }, () -> chunkUpdateQueue.remove(key))); if (!scheduled) { chunkUpdateQueue.remove(key); } @@ -409,12 +448,12 @@ public class IrisWorldManager extends EngineAssignedWorldManager { raiseInitialSpawnMarkerFlag(world, chunkX, chunkZ, () -> { int delay = RNG.r.i(5, 200); - J.runRegion(world, chunkX, chunkZ, () -> { + J.runRegion(world, chunkX, chunkZ, managedTask("bukkit_world_manager_initial_spawn_followup", () -> { if (!world.isChunkLoaded(chunkX, chunkZ)) { return; } spawnIn(world.getChunkAt(chunkX, chunkZ), true); - }, delay); + }), delay); Chunk markerChunk = world.getChunkAt(chunkX, chunkZ); forEachMarkerSpawner(markerChunk, (block, spawners) -> { @@ -442,7 +481,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - J.a(() -> { + J.a(managedTask("bukkit_world_manager_spawn_marker_flag", () -> { boolean raised = false; try { Mantle mantle = getMantle(); @@ -460,13 +499,13 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - J.runRegion(world, chunkX, chunkZ, () -> { + J.runRegion(world, chunkX, chunkZ, managedTask("bukkit_world_manager_spawn_marker_callback", () -> { if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { return; } onFirstRaise.run(); - }); - }); + })); + }, () -> markerFlagQueue.remove(key))); } private void warmupMantleChunkAsync(int chunkX, int chunkZ) { @@ -475,7 +514,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - J.a(() -> { + J.a(managedTask("bukkit_world_manager_mantle_warmup", () -> { try { getMantle().getChunk(chunkX, chunkZ); } catch (Throwable e) { @@ -483,11 +522,11 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } finally { mantleWarmupQueue.remove(key); } - }); + }, () -> mantleWarmupQueue.remove(key))); } private boolean onAsyncTick() { - if (getEngine().isClosed()) { + if (getEngine().isClosing() || getEngine().isClosed()) { return false; } @@ -496,7 +535,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return false; } - actuallySpawned = 0; + actuallySpawned.set(0); if (!getEngine().getWorld().hasPlatformWorld()) { IrisLogging.debug("Can't spawn. No real world"); @@ -509,8 +548,16 @@ public class IrisWorldManager extends EngineAssignedWorldManager { World realWorld = BukkitWorldBinding.world(getEngine().getWorld()); if (realWorld == null) { entityCount = 0; + entityCountValid = false; } else if (J.isFolia()) { - entityCount = getFoliaLivingEntityCount(realWorld); + Integer count = getFoliaLivingEntityCount(realWorld); + if (count != null) { + entityCount = count; + entityCountValid = true; + resetEntityCountFailures(); + } else { + entityCountValid = false; + } } else { CompletableFuture future = new CompletableFuture<>(); boolean scheduled = J.runGlobal(() -> { @@ -526,14 +573,32 @@ public class IrisWorldManager extends EngineAssignedWorldManager { future.completeExceptionally(ex); } }); - entityCount = scheduled ? future.get(2, TimeUnit.SECONDS) : 0; + if (scheduled) { + entityCount = future.get(2, TimeUnit.SECONDS); + entityCountValid = true; + resetEntityCountFailures(); + } else { + reportEntityCountFailure("Unable to schedule the global entity count; pausing Iris entity spawning until a complete count is available.", null); + } } + } catch (InterruptedException e) { + entityCountValid = false; + Thread.currentThread().interrupt(); + return false; + } catch (TimeoutException e) { + reportEntityCountFailure("Timed out while counting entities; pausing Iris entity spawning until a complete count is available.", null); + } catch (ExecutionException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + reportEntityCountFailure("Failed to count entities; pausing Iris entity spawning until a complete count is available.", cause); } catch (Throwable e) { - IrisLogging.reportError(e); - close(); + reportEntityCountFailure("Failed to count entities; pausing Iris entity spawning until a complete count is available.", e); } } + if (!entityCountValid) { + return false; + } + double epx = getEntitySaturation(); if (epx > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) { IrisLogging.debug("Can't spawn. The entity per chunk ratio is at " + Form.pc(epx, 2) + " > 100% (total entities " + entityCount + ")"); @@ -549,16 +614,22 @@ public class IrisWorldManager extends EngineAssignedWorldManager { Position2[] cc = getLoadedChunkPositionsSnapshot(world); while (spawnBuffer-- > 0) { + if (getEngine().isClosing() || getEngine().isClosed()) { + return actuallySpawned.get() > 0; + } + if (cc.length == 0) { IrisLogging.debug("Can't spawn. No chunks!"); return false; } Position2 c = cc[RNG.r.nextInt(cc.length)]; - spawnChunkSafely(world, c.getX(), c.getZ(), false); + if (!spawnChunkSafely(world, c.getX(), c.getZ(), false)) { + return actuallySpawned.get() > 0; + } } - return actuallySpawned > 0; + return actuallySpawned.get() > 0; } private boolean isPregenActiveForThisWorld() { @@ -604,13 +675,16 @@ public class IrisWorldManager extends EngineAssignedWorldManager { try { return future.get(2, TimeUnit.SECONDS); - } catch (Throwable e) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new Position2[0]; + } catch (ExecutionException | TimeoutException e) { IrisLogging.reportError(e); return new Position2[0]; } } - private int getFoliaLivingEntityCount(World world) { + private Integer getFoliaLivingEntityCount(World world) { CompletableFuture> playerFuture = new CompletableFuture<>(); boolean scheduled = J.runGlobal(() -> { try { @@ -620,18 +694,29 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } }); if (!scheduled) { - return 0; + reportEntityCountFailure("Unable to schedule the Folia player snapshot; pausing Iris entity spawning until a complete count is available.", null); + return null; } List players; try { players = playerFuture.get(2, TimeUnit.SECONDS); - } catch (Throwable e) { - IrisLogging.reportError(e); - return 0; + } catch (InterruptedException e) { + entityCountValid = false; + Thread.currentThread().interrupt(); + return null; + } catch (TimeoutException e) { + reportEntityCountFailure("Timed out while reading the Folia player snapshot; pausing Iris entity spawning until a complete count is available.", null); + return null; + } catch (ExecutionException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + reportEntityCountFailure("Failed to read the Folia player snapshot; pausing Iris entity spawning until a complete count is available.", cause); + return null; } Map candidates = new ConcurrentHashMap<>(); + AtomicBoolean incomplete = new AtomicBoolean(); + AtomicReference failure = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(players.size()); for (Player player : players) { @@ -651,21 +736,28 @@ public class IrisWorldManager extends EngineAssignedWorldManager { candidates.put(nearby.getUniqueId().toString(), nearby); } } + } catch (Throwable e) { + incomplete.set(true); + failure.compareAndSet(null, e); } finally { latch.countDown(); } })) { + incomplete.set(true); latch.countDown(); } } - try { - latch.await(2, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + if (!awaitEntityTasks(latch, 2, TimeUnit.SECONDS) || incomplete.get()) { + if (!Thread.currentThread().isInterrupted()) { + reportEntityCountFailure("The Folia entity candidate scan was incomplete; pausing Iris entity spawning until a complete count is available.", failure.get()); + } + return null; } AtomicInteger count = new AtomicInteger(); + incomplete.set(false); + failure.set(null); CountDownLatch entityLatch = new CountDownLatch(candidates.size()); for (Entity entity : candidates.values()) { if (!J.runEntity(entity, () -> { @@ -673,47 +765,117 @@ public class IrisWorldManager extends EngineAssignedWorldManager { if (entity instanceof LivingEntity && world.equals(entity.getWorld()) && !entity.isDead()) { count.incrementAndGet(); } + } catch (Throwable e) { + incomplete.set(true); + failure.compareAndSet(null, e); } finally { entityLatch.countDown(); } })) { + incomplete.set(true); entityLatch.countDown(); } } - try { - entityLatch.await(2, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + if (!awaitEntityTasks(entityLatch, 2, TimeUnit.SECONDS) || incomplete.get()) { + if (!Thread.currentThread().isInterrupted()) { + reportEntityCountFailure("The Folia entity validation scan was incomplete; pausing Iris entity spawning until a complete count is available.", failure.get()); + } + return null; } + return count.get(); } - private void spawnChunkSafely(World world, int chunkX, int chunkZ, boolean initial) { + static boolean awaitEntityTasks(CountDownLatch latch, long timeout, TimeUnit unit) { + try { + return latch.await(timeout, unit); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + private boolean spawnChunkSafely(World world, int chunkX, int chunkZ, boolean initial) { if (world == null) { - return; + return false; } CompletableFuture future = new CompletableFuture<>(); - J.runRegion(world, chunkX, chunkZ, () -> { - try { - if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { - return; - } - - spawnIn(world.getChunkAt(chunkX, chunkZ), initial); - } finally { - future.complete(null); + AtomicBoolean failureReported = new AtomicBoolean(); + future.whenComplete((ignored, failure) -> { + if (failure != null) { + reportSpawnFailure(chunkX, chunkZ, failure, failureReported); } }); + boolean scheduled; + try { + scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + try { + if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { + future.complete(null); + return; + } + + spawnIn(world.getChunkAt(chunkX, chunkZ), initial); + future.complete(null); + } catch (Throwable e) { + future.completeExceptionally(e); + } + }); + } catch (Throwable e) { + IrisLogging.reportError("Failed to schedule an Iris entity spawn for chunk " + chunkX + "," + chunkZ + ".", e); + return false; + } + + if (!scheduled) { + IrisLogging.debug("Skipped Iris entity spawning because the region task was not accepted for chunk " + chunkX + "," + chunkZ + "."); + return false; + } try { future.get(5, TimeUnit.SECONDS); - } catch (Throwable e) { - IrisLogging.reportError(e); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (TimeoutException e) { + IrisLogging.warn("Timed out waiting for Iris entity spawning in chunk %d,%d; deferring the remaining spawn buffer.", chunkX, chunkZ); + return false; + } catch (ExecutionException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + reportSpawnFailure(chunkX, chunkZ, cause, failureReported); + return false; } } + private void reportEntityCountFailure(String message, Throwable error) { + entityCountValid = false; + if (error != null) { + if (entityCountErrorReported.compareAndSet(false, true)) { + IrisLogging.reportError(message, error); + } + return; + } + + if (entityCountWarningReported.compareAndSet(false, true)) { + IrisLogging.warn(message); + } + } + + private void resetEntityCountFailures() { + entityCountWarningReported.set(false); + entityCountErrorReported.set(false); + } + + private void reportSpawnFailure(int chunkX, int chunkZ, Throwable failure, AtomicBoolean failureReported) { + if (!failureReported.compareAndSet(false, true)) { + return; + } + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + IrisLogging.reportError("Failed to spawn Iris entities in chunk " + chunkX + "," + chunkZ + ".", cause); + } + private void spawnIn(Chunk c, boolean initial) { if (getEngine().isClosed()) { return; @@ -736,8 +898,10 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } spawn(block, s, false); - J.runRegion(c.getWorld(), c.getX(), c.getZ(), () -> raiseInitialSpawnMarkerFlag(c.getWorld(), c.getX(), c.getZ(), - () -> spawn(block, s, true))); + J.runRegion(c.getWorld(), c.getX(), c.getZ(), managedTask( + "bukkit_world_manager_marker_spawn_followup", + () -> raiseInitialSpawnMarkerFlag(c.getWorld(), c.getX(), c.getZ(), + () -> spawn(block, s, true)))); }); } @@ -775,17 +939,13 @@ public class IrisWorldManager extends EngineAssignedWorldManager { if (v == null || v.getReferenceSpawner() == null) return; - try { - spawn(c, v); - } catch (Throwable e) { - J.runRegion(c.getWorld(), c.getX(), c.getZ(), () -> spawn(c, v)); - } + spawn(c, v); } private void spawn(Chunk c, IrisEntitySpawn i) { IrisSpawner ref = i.getReferenceSpawner(); int s = i.spawn(getEngine(), c, RNG.r); - actuallySpawned += s; + actuallySpawned.addAndGet(s); if (s > 0) { ref.spawn(getEngine(), c.getX(), c.getZ()); } @@ -797,7 +957,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; int s = i.spawn(getEngine(), pos, RNG.r); - actuallySpawned += s; + actuallySpawned.addAndGet(s); if (s > 0) { ref.spawn(getEngine(), PowerOfTwoCoordinates.blockToChunkFloor(pos.getX()), PowerOfTwoCoordinates.blockToChunkFloor(pos.getZ())); } @@ -857,10 +1017,10 @@ public class IrisWorldManager extends EngineAssignedWorldManager { int cX = e.getX(), cZ = e.getZ(); Long key = Cache.key(cX, cZ); - cleanup.put(key, cleanupService.schedule(() -> { + cleanup.put(key, cleanupService.schedule(managedTask("bukkit_world_manager_chunk_cleanup", () -> { cleanup.remove(key); getEngine().cleanupMantleChunk(cX, cZ); - }, Math.max(IrisSettings.get().getPerformance().mantleCleanupDelay * 50L, 0), TimeUnit.MILLISECONDS)); + }, () -> cleanup.remove(key)), Math.max(IrisSettings.get().getPerformance().mantleCleanupDelay * 50L, 0), TimeUnit.MILLISECONDS)); } @Override @@ -898,15 +1058,17 @@ public class IrisWorldManager extends EngineAssignedWorldManager { @Override public void teleportAsync(PlayerTeleportEvent e) { e.setCancelled(true); - warmupAreaAsync(e.getPlayer(), e.getTo(), () -> J.runEntity(e.getPlayer(), () -> { + warmupAreaAsync(e.getPlayer(), e.getTo(), () -> J.runEntity(e.getPlayer(), managedTask( + "bukkit_world_manager_teleport", + () -> { ignoreTP.set(true); e.getPlayer().teleport(e.getTo(), e.getCause()); ignoreTP.set(false); - })); + }))); } private void warmupAreaAsync(Player player, Location to, Runnable r) { - J.a(() -> { + J.a(managedTask("bukkit_world_manager_teleport_warmup", () -> { int viewDistance = 2; KList> futures = new KList<>(); for (int i = -viewDistance; i <= viewDistance; i++) { @@ -945,7 +1107,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return "Loading Chunks"; } }.queue(futures).execute(new VolmitSender(player), true, r); - }); + })); } public Map> getSpawnersFromMarkers(Chunk c) { @@ -1024,14 +1186,14 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - J.a(() -> { + J.a(managedTask("bukkit_world_manager_marker_scan", () -> { try { Map markerData = collectMarkerSpawnData(chunkX, chunkZ); if (markerData.isEmpty()) { return; } - J.runRegion(world, chunkX, chunkZ, () -> { + J.runRegion(world, chunkX, chunkZ, managedTask("bukkit_world_manager_marker_scan_region", () -> { if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { return; } @@ -1050,13 +1212,13 @@ public class IrisWorldManager extends EngineAssignedWorldManager { consumer.accept(new IrisPosition(relative.getX(), relative.getY() + minY, relative.getZ()), data.spawners); }); - }); + })); } catch (Throwable e) { IrisLogging.reportError(e); } finally { markerScanQueue.remove(key); } - }); + }, () -> markerScanQueue.remove(key))); } private Map collectMarkerSpawnData(int chunkX, int chunkZ) { @@ -1107,13 +1269,13 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } private void removeMarkerAsync(IrisPosition marker) { - J.a(() -> { + J.a(managedTask("bukkit_world_manager_remove_marker", () -> { try { getMantle().remove(marker.getX(), marker.getY(), marker.getZ(), MatterMarker.class); } catch (Throwable e) { IrisLogging.reportError(e); } - }); + })); } private static final class MarkerSpawnData { @@ -1127,21 +1289,6 @@ public class IrisWorldManager extends EngineAssignedWorldManager { int blockX = e.getBlock().getX(); int mantleY = toMantleY(e.getBlock().getY(), getEngine().getWorld().minHeight()); int blockZ = e.getBlock().getZ(); - J.a(() -> { - MatterMarker marker = getMantle().get(blockX, mantleY, blockZ, MatterMarker.class); - - if (marker != null) { - if (marker.getTag().equals("cave_floor") || marker.getTag().equals("cave_ceiling")) { - return; - } - - IrisMarker mark = getData().getMarkerLoader().load(marker.getTag()); - - if (mark == null || mark.isRemoveOnChange()) { - getMantle().remove(blockX, mantleY, blockZ, MatterMarker.class); - } - } - }); KList d = new KList<>(); IrisBiome b = EngineBukkitOps.getBiome(getEngine(), e.getBlock().getLocation()); @@ -1159,15 +1306,29 @@ public class IrisWorldManager extends EngineAssignedWorldManager { e.setDropItems(false); } - if (d.isNotEmpty()) { - World w = e.getBlock().getWorld(); - Location dropLocation = e.getBlock().getLocation().clone().add(.5, .5, .5); - Runnable dropTask = () -> d.forEach(item -> w.dropItemNaturally(dropLocation, item)); - if (!J.runAt(dropLocation, dropTask)) { - if (!J.isFolia()) { - J.s(dropTask); - } + World w = e.getBlock().getWorld(); + Location blockLocation = e.getBlock().getLocation(); + Location dropLocation = blockLocation.clone().add(.5, .5, .5); + BlockDropRouter dropRouter = e instanceof BlockDropRouter router ? router : null; + Runnable finalizedBreak = managedTask("bukkit_world_manager_block_break_finalize", () -> { + if (e.isCancelled()) { + return; } + J.a(managedTask("bukkit_world_manager_block_break_marker", () -> { + MatterMarker marker = getMantle().get(blockX, mantleY, blockZ, MatterMarker.class); + if (marker == null || marker.getTag().equals("cave_floor") || marker.getTag().equals("cave_ceiling")) { + return; + } + + IrisMarker mark = getData().getMarkerLoader().load(marker.getTag()); + if (mark == null || mark.isRemoveOnChange()) { + getMantle().remove(blockX, mantleY, blockZ, MatterMarker.class); + } + })); + routeDrops(d, dropRouter, item -> w.dropItemNaturally(dropLocation, item)); + }); + if (!J.runAt(blockLocation, finalizedBreak, 1) && !J.isFolia()) { + J.s(finalizedBreak, 1); } } } @@ -1176,6 +1337,22 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return worldY - minHeight; } + static void routeDrops(Iterable drops, BlockDropRouter router, Consumer fallback) { + for (T drop : drops) { + boolean routed = false; + if (router != null) { + try { + routed = router.routeDrop(drop); + } catch (Throwable error) { + IrisLogging.reportError("Failed to route a deferred Iris block drop.", error); + } + } + if (!routed) { + fallback.accept(drop); + } + } + } + static int toWorldY(int mantleY, int minHeight) { return mantleY + minHeight; } @@ -1190,11 +1367,35 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } @Override - public void close() { - super.close(); - looper.interrupt(); - if (cleanupService != null) { - cleanupService.shutdownNow(); + public synchronized void close() { + Throwable failure = null; + try { + super.close(); + } catch (Throwable e) { + failure = e; + } + if (!looperStopped) { + try { + if (looper != null) { + looper.interrupt(); + } + looperStopped = true; + } catch (Throwable e) { + failure = appendCloseFailure(failure, e); + } + } + if (!cleanupServiceStopped) { + try { + if (cleanupService != null) { + cleanupService.shutdownNow(); + } + cleanupServiceStopped = true; + } catch (Throwable e) { + failure = appendCloseFailure(failure, e); + } + } + if (failure != null) { + throw new IllegalStateException("Failed to completely stop the Bukkit Iris world manager.", failure); } } @@ -1212,6 +1413,16 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return (double) entityCount / (loadedChunkCount + 1) * 1.28; } + private static Throwable appendCloseFailure(Throwable failure, Throwable next) { + if (failure == null) { + return next; + } + if (failure != next) { + failure.addSuppressed(next); + } + return failure; + } + @Data private static class ChunkCounter implements Predicate { private final Entity[] entities; diff --git a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java index fa8b6d6b0..34fc8216e 100644 --- a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -69,7 +69,7 @@ public class UpperDimensionContext implements DataProvider { engine.getData(), chunkHeight, complex.getHeightStream(), - complex.getBaseBiomeStream(), + complex.getTrueBiomeStream(), complex.getRegionStream(), complex.getRockStream(), true @@ -89,6 +89,8 @@ public class UpperDimensionContext implements DataProvider { Map> generators = new HashMap<>(); Set allBiomes = Collections.newSetFromMap(new IdentityHashMap<>()); + Map childSelectionPlans = + Collections.synchronizedMap(new IdentityHashMap<>()); upperDim.getRegions().forEach(regionKey -> { IrisRegion region = upperData.getRegionLoader().load(regionKey); if (region != null) { @@ -135,8 +137,7 @@ public class UpperDimensionContext implements DataProvider { .zoom(upperDim.getBiomeZoom()) .zoom(upperDim.getLandZoom()) .zoom(r.getLandBiomeZoom()) - .selectRarity(upperData.getBiomeLoader().loadAll(r.getLandBiomes(), - t -> t.setInferredType(InferredType.LAND)))) + .selectRarity(loadInferredBiomes(upperData, r.getLandBiomes(), InferredType.LAND))) .convertAware2D(ProceduralStream::get); ProceduralStream seaBiomeStream = regionStream .convert(r -> upperDim.getSeaBiomeStyle() @@ -144,16 +145,14 @@ public class UpperDimensionContext implements DataProvider { .zoom(upperDim.getBiomeZoom()) .zoom(upperDim.getSeaZoom()) .zoom(r.getSeaBiomeZoom()) - .selectRarity(upperData.getBiomeLoader().loadAll(r.getSeaBiomes(), - t -> t.setInferredType(InferredType.SEA)))) + .selectRarity(loadInferredBiomes(upperData, r.getSeaBiomes(), InferredType.SEA))) .convertAware2D(ProceduralStream::get); ProceduralStream shoreBiomeStream = regionStream .convert(r -> upperDim.getShoreBiomeStyle() .create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), upperData).stream() .zoom(upperDim.getBiomeZoom()) .zoom(r.getShoreBiomeZoom()) - .selectRarity(upperData.getBiomeLoader().loadAll(r.getShoreBiomes(), - t -> t.setInferredType(InferredType.SHORE)))) + .selectRarity(loadInferredBiomes(upperData, r.getShoreBiomes(), InferredType.SHORE))) .convertAware2D(ProceduralStream::get); Map> inferredStreams = new HashMap<>(); @@ -170,7 +169,9 @@ public class UpperDimensionContext implements DataProvider { .convertAware2D((t, x, z) -> { ProceduralStream stream = inferredStreams.get(t); return stream != null ? stream.get(x, z) : inferredStreams.get(InferredType.LAND).get(x, z); - }); + }) + .convertAware2D((biome, x, z) -> implode( + biome, x, z, rng, dataProvider, childSelectionPlans, 3)); KList overlayNoise = upperDim.getOverlayNoise(); ProceduralStream overlayStream = overlayNoise.isEmpty() @@ -235,6 +236,23 @@ public class UpperDimensionContext implements DataProvider { return Math.max(Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight), 0); }, Interpolated.DOUBLE); + ProceduralStream finalBiomeStream = heightStream.convertAware2D((height, x, z) -> { + IrisBiome baseBiome = baseBiomeStream.get(x, z); + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + height, + baseBiome, + regionStream.get(x, z), + x, + z, + fluidHeight, + landBiomeStream, + seaBiomeStream, + shoreBiomeStream); + return resolved == baseBiome + ? baseBiome + : implode(resolved, x, z, rng, dataProvider, childSelectionPlans, 3); + }); + ProceduralStream rockStream = upperDim.getRockPalette() .getLayerGenerator(rng.nextParallelRNG(45), upperData).stream() .select(upperDim.getRockPalette().getBlockData(upperData)); @@ -244,13 +262,67 @@ public class UpperDimensionContext implements DataProvider { upperData, chunkHeight, heightStream, - baseBiomeStream, + finalBiomeStream, regionStream, rockStream, false ); } + private static KList loadInferredBiomes(IrisData data, KList keys, InferredType type) { + KList inferred = new KList<>(); + for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) { + inferred.add(biome.withInferredType(type)); + } + return inferred; + } + + private static IrisBiome implode( + IrisBiome biome, + double x, + double z, + RNG rng, + DataProvider dataProvider, + Map childSelectionPlans, + int remainingDepth + ) { + if (biome == null || remainingDepth < 0 || biome.getChildren().isEmpty()) { + return biome; + } + + IrisComplex.ChildSelectionPlan selectionPlan = childSelectionPlans.get(biome); + if (selectionPlan == null) { + synchronized (childSelectionPlans) { + selectionPlan = childSelectionPlans.get(biome); + if (selectionPlan == null) { + KList options = new KList<>(); + for (IrisBiome child : biome.getRealChildren(dataProvider)) { + if (child != null) { + options.add(child); + } + } + options.add(biome); + selectionPlan = IrisComplex.ChildSelectionPlan.create(options); + childSelectionPlans.put(biome, selectionPlan); + } + } + } + + IrisBiome selected = selectionPlan.select( + biome.getChildrenGenerator(rng, 123, biome.getChildShrinkFactor()), x, z); + if (selected == null) { + return biome; + } + return implode( + selected.withInferredType(biome.getInferredType()), + x, + z, + rng, + dataProvider, + childSelectionPlans, + remainingDepth - 1); + } + public int getUpperSurfaceY(int x, int z) { double rawHeight = heightStream.get((double) x, (double) z); return chunkHeight - 1 - (int) Math.round(rawHeight); 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 44f277747..9b4358be3 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 @@ -48,6 +48,7 @@ import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.util.common.data.DataProvider; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.documentation.BlockCoordinates; @@ -127,6 +128,15 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return GenerationSessionLease.noop(); } + IrisContext context = IrisContext.get(); + if (context != null && context.getEngine() == this && context.getGenerationSessionId() != 0L) { + return generationSessions.continueSession(operation, context.getGenerationSessionId()); + } + if (isClosing() || isClosed()) { + throw new GenerationSessionException("Generation session rejected new work for " + operation + + " while the Iris engine is closing.", isClosed()); + } + return generationSessions.acquire(operation); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java index 13fc1ff46..2099b1cd5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java @@ -33,82 +33,194 @@ import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.event.world.ChunkUnloadEvent; import org.bukkit.event.world.WorldSaveEvent; -import org.bukkit.event.world.WorldUnloadEvent; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; public abstract class EngineAssignedWorldManager extends EngineAssignedComponent implements EngineWorldManager, BukkitEngineWorldManager, Listener { - private final int taskId; + private final Object managerLifecycleLock; + private final AtomicBoolean started; + private boolean listenerRegistered; + private boolean closeRequested; + private int taskId; protected AtomicBoolean ignoreTP = new AtomicBoolean(false); public EngineAssignedWorldManager() { super(null, null); + managerLifecycleLock = new Object(); + started = new AtomicBoolean(false); taskId = -1; } public EngineAssignedWorldManager(Engine engine) { super(engine, "World"); - BukkitPlatform.volmitPlugin().registerListener(this); - taskId = J.sr(this::onTick, 1); + managerLifecycleLock = new Object(); + started = new AtomicBoolean(false); + taskId = -1; + } + + @Override + public void start() { + synchronized (managerLifecycleLock) { + if (started.get()) { + return; + } + if (closeRequested) { + throw new IllegalStateException("Cannot restart a closed Iris world manager."); + } + + started.set(true); + try { + listenerRegistered = true; + registerManagerListener(); + taskId = scheduleManagerTick(() -> runManagerTask("bukkit_world_manager_tick", this::onTick)); + } catch (Throwable e) { + started.set(false); + closeRequested = true; + Throwable cleanupFailure = closeManagerResources(); + if (cleanupFailure != null) { + e.addSuppressed(cleanupFailure); + } + throw propagate(e); + } + } } @EventHandler public void on(IrisEngineHotloadEvent e) { - for (Player i : BukkitWorldBinding.players(e.getEngine().getWorld())) { - i.playSound(i.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_BREAK, 1f, 1.8f); - VolmitSender s = new VolmitSender(i); - s.sendTitle(C.IRIS + "Engine " + C.AQUA + "Hotloaded", 70, 60, 410); - } + runManagerTask("bukkit_world_manager_hotload_event", () -> { + for (Player i : BukkitWorldBinding.players(e.getEngine().getWorld())) { + i.playSound(i.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_BREAK, 1f, 1.8f); + VolmitSender s = new VolmitSender(i); + s.sendTitle(C.IRIS + "Engine " + C.AQUA + "Hotloaded", 70, 60, 410); + } + }); } @EventHandler public void on(WorldSaveEvent e) { - if (e.getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { - getEngine().save(); - } - } - - @EventHandler - public void on(WorldUnloadEvent e) { - if (e.getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { - getEngine().close(); - } + runManagerTask("bukkit_world_manager_save", () -> { + if (e.getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { + getEngine().save(); + } + }); } @EventHandler public void on(BlockBreakEvent e) { - if (e.getPlayer().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { - onBlockBreak(e); - } + runManagerTask("bukkit_world_manager_block_break", () -> { + if (e.getPlayer().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { + onBlockBreak(e); + } + }); } @EventHandler public void on(BlockPlaceEvent e) { - if (e.getPlayer().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { - onBlockPlace(e); - } + runManagerTask("bukkit_world_manager_block_place", () -> { + if (e.getPlayer().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { + onBlockPlace(e); + } + }); } @EventHandler public void on(ChunkLoadEvent e) { - if (e.getChunk().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { - onChunkLoad(e.getChunk(), e.isNewChunk()); - } + runManagerTask("bukkit_world_manager_chunk_load", () -> { + if (e.getChunk().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { + onChunkLoad(e.getChunk(), e.isNewChunk()); + } + }); } @EventHandler public void on(ChunkUnloadEvent e) { - if (e.getChunk().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { - onChunkUnload(e.getChunk()); - } + runManagerTask("bukkit_world_manager_chunk_unload", () -> { + if (e.getChunk().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { + onChunkUnload(e.getChunk()); + } + }); } @Override public void close() { - super.close(); - BukkitPlatform.volmitPlugin().unregisterListener(this); - if (taskId != -1) { - J.csr(taskId); + Throwable failure; + synchronized (managerLifecycleLock) { + closeRequested = true; + started.set(false); + failure = closeManagerResources(); + } + if (failure != null) { + throw new IllegalStateException("Failed to completely stop the Iris world manager.", failure); } } + + protected void registerManagerListener() { + BukkitPlatform.volmitPlugin().registerListener(this); + } + + protected int scheduleManagerTick(Runnable tick) { + return J.sr(tick, 1); + } + + protected void unregisterManagerListener() { + BukkitPlatform.volmitPlugin().unregisterListener(this); + } + + protected void cancelManagerTick(int scheduledTaskId) { + J.csr(scheduledTaskId); + } + + private Throwable closeManagerResources() { + Throwable failure = null; + if (listenerRegistered) { + try { + unregisterManagerListener(); + listenerRegistered = false; + } catch (Throwable e) { + failure = e; + } + } + if (taskId != -1) { + try { + cancelManagerTick(taskId); + taskId = -1; + } catch (Throwable e) { + if (failure == null) { + failure = e; + } else if (failure != e) { + failure.addSuppressed(e); + } + } + } + return failure; + } + + private RuntimeException propagate(Throwable failure) { + if (failure instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (failure instanceof Error error) { + throw error; + } + return new IllegalStateException(failure); + } + + protected boolean runManagerTask(String operation, Runnable task) { + if (!started.get()) { + return false; + } + return EngineLifecycleTasks.run(getEngine(), operation, task); + } + + protected T callManagerTask(String operation, Supplier task, T unavailable) { + if (!started.get()) { + return unavailable; + } + return EngineLifecycleTasks.call(getEngine(), operation, task, unavailable); + } + + protected boolean isManagerStarted() { + return started.get(); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineLifecycleTasks.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineLifecycleTasks.java new file mode 100644 index 000000000..d358c8bd3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineLifecycleTasks.java @@ -0,0 +1,40 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.util.project.context.IrisContext; + +import java.util.Objects; +import java.util.function.Supplier; + +public final class EngineLifecycleTasks { + private EngineLifecycleTasks() { + } + + public static boolean run(Engine engine, String operation, Runnable task) { + Objects.requireNonNull(engine); + Objects.requireNonNull(operation); + Objects.requireNonNull(task); + try (GenerationSessionLease lease = engine.acquireGenerationLease(operation); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + task.run(); + return true; + } catch (GenerationSessionException exception) { + if (engine.isClosing() || engine.isClosed() || exception.isExpectedTeardown()) { + return false; + } + throw new IllegalStateException("Iris lifecycle rejected " + operation + ".", exception); + } + } + + public static T call(Engine engine, String operation, Supplier task, T unavailable) { + Objects.requireNonNull(task); + try (GenerationSessionLease lease = engine.acquireGenerationLease(operation); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + return task.get(); + } catch (GenerationSessionException exception) { + if (engine.isClosing() || engine.isClosed() || exception.isExpectedTeardown()) { + return unavailable; + } + throw new IllegalStateException("Iris lifecycle rejected " + operation + ".", exception); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineMetrics.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineMetrics.java index 587a343bc..fb165b81b 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineMetrics.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineMetrics.java @@ -22,6 +22,9 @@ import art.arcane.volmlib.util.atomics.AtomicRollingSequence; import art.arcane.volmlib.util.collection.KMap; import lombok.Data; +import java.util.LinkedHashMap; +import java.util.Map; + @Data public class EngineMetrics { private final AtomicRollingSequence total; @@ -111,4 +114,24 @@ public class EngineMetrics { return v; } + + public Map telemetryAverages() { + Map averages = new LinkedHashMap<>(); + averages.put("total", total.getAverage()); + averages.put("updates", updates.getAverage()); + averages.put("terrain", terrain.getAverage()); + averages.put("biome", biome.getAverage()); + averages.put("post", post.getAverage()); + averages.put("perfection", perfection.getAverage()); + averages.put("decoration", decoration.getAverage()); + averages.put("cave", cave.getAverage()); + averages.put("deposit", deposit.getAverage()); + averages.put("carve.resolve", carveResolve.getAverage()); + averages.put("carve.apply", carveApply.getAverage()); + averages.put("context.prefill", contextPrefill.getAverage()); + averages.put("pregen.wait.permit", pregenWaitPermit.getAverage()); + averages.put("pregen.wait.adaptive", pregenWaitAdaptive.getAverage()); + averages.entrySet().removeIf(entry -> !Double.isFinite(entry.getValue()) || entry.getValue() < 0D); + return Map.copyOf(averages); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineTarget.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineTarget.java index 2fd45b264..c9947a0b6 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineTarget.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineTarget.java @@ -32,8 +32,8 @@ import lombok.ToString; public class EngineTarget { private final MultiBurst burster; private final IrisData data; - private IrisDimension dimension; - private IrisWorld world; + private final IrisDimension dimension; + private final IrisWorld world; public EngineTarget(IrisWorld world, IrisDimension dimension, IrisData data) { this.world = world; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineTelemetrySnapshot.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineTelemetrySnapshot.java new file mode 100644 index 000000000..eac0eeb03 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineTelemetrySnapshot.java @@ -0,0 +1,222 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisEngineStatistics; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public record EngineTelemetrySnapshot( + long sampledAtMs, + String worldIdentity, + String worldName, + String dimensionKey, + boolean active, + boolean studio, + boolean closing, + boolean failed, + long loadedChunks, + long loadedEntities, + double entitySaturation, + long generatedSession, + long generatedTotal, + double chunksPerSecond, + long blockUpdatesPerSecond, + int parallelism, + int activeGenerationLeases, + long hotloadsTotal, + long mantleResidentPlates, + long mantleQueuedPlates, + double mantleIdleMs, + Map generationTimingsMs +) { + public EngineTelemetrySnapshot { + if (worldIdentity == null || worldIdentity.isBlank()) { + throw new IllegalArgumentException("Engine telemetry requires a world identity"); + } + worldIdentity = worldIdentity.trim(); + worldName = worldName == null || worldName.isBlank() ? worldIdentity : worldName.trim(); + dimensionKey = dimensionKey == null ? "" : dimensionKey.trim(); + loadedChunks = Math.max(0L, loadedChunks); + loadedEntities = Math.max(0L, loadedEntities); + entitySaturation = finiteNonNegative(entitySaturation); + generatedSession = Math.max(0L, generatedSession); + generatedTotal = Math.max(0L, generatedTotal); + chunksPerSecond = finiteNonNegative(chunksPerSecond); + blockUpdatesPerSecond = Math.max(0L, blockUpdatesPerSecond); + parallelism = Math.max(0, parallelism); + activeGenerationLeases = Math.max(0, activeGenerationLeases); + hotloadsTotal = Math.max(0L, hotloadsTotal); + mantleResidentPlates = Math.max(0L, mantleResidentPlates); + mantleQueuedPlates = Math.max(0L, mantleQueuedPlates); + mantleIdleMs = finiteNonNegative(mantleIdleMs); + generationTimingsMs = sanitizeTimings(generationTimingsMs); + } + + public static EngineTelemetrySnapshot capture(Engine engine, double chunksPerSecond, long sampledAtMs) { + if (engine == null) { + throw new IllegalArgumentException("Engine cannot be null"); + } + + EngineWorldManager worldManager = engine.getWorldManager(); + GenerationSessionManager sessions = engine.getGenerationSessions(); + IrisEngineStatistics statistics = engine.getEngineData().getStatistics(); + boolean closing = engine.isClosing(); + boolean closed = engine.isClosed(); + boolean failed = engine.hasFailed(); + return new EngineTelemetrySnapshot( + sampledAtMs, + engine.getWorld().identity(), + engine.getWorld().name(), + engine.getDimension().getLoadKey(), + !closing && !closed && !failed, + engine.isStudio(), + closing, + failed, + worldManager == null ? 0L : worldManager.getChunkCount(), + worldManager == null ? 0L : worldManager.getEntityCount(), + worldManager == null ? 0D : worldManager.getEntitySaturation(), + engine.getGenerated(), + statistics == null ? 0L : statistics.getChunksGenerated(), + chunksPerSecond, + engine.getBlockUpdatesPerSecond(), + engine.getParallelism(), + sessions == null ? 0 : sessions.activeLeases(), + statistics == null ? 0L : statistics.getTotalHotloads(), + engine.getMantle().getLoadedRegionCount(), + engine.getMantle().getUnloadRegionCount(), + engine.getMantle().getAdjustedIdleDuration(), + engine.getMetrics().telemetryAverages() + ); + } + + public static Aggregate aggregate(List snapshots) { + List safeSnapshots = snapshots == null ? List.of() : snapshots; + int active = 0; + int studio = 0; + int closing = 0; + int failed = 0; + long loadedChunks = 0L; + long loadedEntities = 0L; + double entitySaturation = 0D; + long generatedSession = 0L; + long generatedTotal = 0L; + double chunksPerSecond = 0D; + long blockUpdatesPerSecond = 0L; + int parallelism = 0; + int activeGenerationLeases = 0; + long hotloadsTotal = 0L; + long mantleResidentPlates = 0L; + long mantleQueuedPlates = 0L; + double mantleIdleTotal = 0D; + double mantleIdleMax = 0D; + double mantleIdleMin = Double.POSITIVE_INFINITY; + int mantleIdleCount = 0; + int worldCount = 0; + Map timingMaxima = new LinkedHashMap<>(); + + for (EngineTelemetrySnapshot snapshot : safeSnapshots) { + if (snapshot == null) { + continue; + } + worldCount++; + active += snapshot.active() ? 1 : 0; + studio += snapshot.studio() ? 1 : 0; + closing += snapshot.closing() ? 1 : 0; + failed += snapshot.failed() ? 1 : 0; + loadedChunks += snapshot.loadedChunks(); + loadedEntities += snapshot.loadedEntities(); + entitySaturation = Math.max(entitySaturation, snapshot.entitySaturation()); + generatedSession += snapshot.generatedSession(); + generatedTotal += snapshot.generatedTotal(); + chunksPerSecond += snapshot.chunksPerSecond(); + blockUpdatesPerSecond += snapshot.blockUpdatesPerSecond(); + parallelism += snapshot.parallelism(); + activeGenerationLeases += snapshot.activeGenerationLeases(); + hotloadsTotal += snapshot.hotloadsTotal(); + mantleResidentPlates += snapshot.mantleResidentPlates(); + mantleQueuedPlates += snapshot.mantleQueuedPlates(); + mantleIdleTotal += snapshot.mantleIdleMs(); + mantleIdleMax = Math.max(mantleIdleMax, snapshot.mantleIdleMs()); + mantleIdleMin = Math.min(mantleIdleMin, snapshot.mantleIdleMs()); + mantleIdleCount++; + for (Map.Entry entry : snapshot.generationTimingsMs().entrySet()) { + timingMaxima.merge(entry.getKey(), entry.getValue(), Math::max); + } + } + + return new Aggregate( + worldCount, + active, + studio, + closing, + failed, + loadedChunks, + loadedEntities, + entitySaturation, + generatedSession, + generatedTotal, + chunksPerSecond, + blockUpdatesPerSecond, + parallelism, + activeGenerationLeases, + hotloadsTotal, + mantleResidentPlates, + mantleQueuedPlates, + mantleIdleCount == 0 ? 0D : mantleIdleTotal / mantleIdleCount, + mantleIdleMax, + mantleIdleCount == 0 ? 0D : mantleIdleMin, + Map.copyOf(timingMaxima) + ); + } + + private static double finiteNonNegative(double value) { + return Double.isFinite(value) && value > 0D ? value : 0D; + } + + private static Map sanitizeTimings(Map timings) { + if (timings == null || timings.isEmpty()) { + return Map.of(); + } + Map sanitized = new LinkedHashMap<>(timings.size()); + for (Map.Entry entry : timings.entrySet()) { + String key = entry.getKey(); + Double value = entry.getValue(); + if (key == null || key.isBlank() || value == null || !Double.isFinite(value) || value < 0D) { + continue; + } + sanitized.put(key, value); + } + return Map.copyOf(sanitized); + } + + public record Aggregate( + int worldCount, + int active, + int studio, + int closing, + int failed, + long loadedChunks, + long loadedEntities, + double entitySaturationMax, + long generatedSession, + long generatedTotal, + double chunksPerSecond, + long blockUpdatesPerSecond, + int parallelism, + int activeGenerationLeases, + long hotloadsTotal, + long mantleResidentPlates, + long mantleQueuedPlates, + double mantleIdleAverageMs, + double mantleIdleMaxMs, + double mantleIdleMinMs, + Map generationTimingMaximaMs + ) { + public Aggregate { + generationTimingMaximaMs = generationTimingMaximaMs == null + ? Map.of() + : Map.copyOf(generationTimingMaximaMs); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java index 7d6c80e9a..3158c9bd5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java @@ -20,6 +20,9 @@ package art.arcane.iris.engine.framework; @SuppressWarnings("EmptyMethod") public interface EngineWorldManager { + default void start() { + } + void close(); int getEntityCount(); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java b/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java index 1651a9788..d571a8f4e 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java @@ -38,6 +38,23 @@ public final class GenerationSessionManager { } } + public GenerationSessionLease continueSession(String operation, long sessionId) throws GenerationSessionException { + while (true) { + GenerationSessionState state = current.get(); + if (state == null || state.sessionId() != sessionId) { + throw rejected(operation, state); + } + + state.activeLeases().incrementAndGet(); + if (state != current.get()) { + releaseLease(state); + continue; + } + + return new GenerationSessionLease(this, state, state.sessionId()); + } + } + public long currentSessionId() { GenerationSessionState state = current.get(); return state == null ? 0L : state.sessionId(); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java index 44d7ac51b..78794dc00 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java @@ -25,6 +25,7 @@ import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.util.common.parallel.BurstExecutor; import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KSet; @@ -32,7 +33,7 @@ import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import java.util.ArrayDeque; -import java.util.concurrent.Future; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -105,18 +106,15 @@ public final class HintedLocator implements Locator { } @Override - public Future find(Engine engine, Position2 pos, long timeout, Consumer checks) throws WrongEngineBroException { + public CompletableFuture find(Engine engine, Position2 pos, long timeout, Consumer checks) throws WrongEngineBroException { if (engine.isClosed()) { throw new WrongEngineBroException(); } - Locator.cancelSearch(); - - return MultiBurst.burst.completeValue(() -> { - AtomicBoolean stop = new AtomicBoolean(false); - LocatorCanceller.cancel = () -> stop.set(true); - - try { + AtomicBoolean stop = new AtomicBoolean(false); + CompletableFuture search = MultiBurst.burst.completeValueAsync(() -> { + try (GenerationSessionLease lease = engine.acquireGenerationLease("hinted_locator_search"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { SearchPlan plan = planner.apply(engine); if (!plan.isPossible()) { @@ -124,10 +122,9 @@ public final class HintedLocator implements Locator { } return search(engine, plan, pos, timeout, checks, stop); - } finally { - LocatorCanceller.cancel = null; } }); + return LocatorCanceller.requestScoped(search, stop); } private Position2 search(Engine engine, SearchPlan plan, Position2 pos, long timeout, Consumer checks, AtomicBoolean stop) { @@ -140,7 +137,7 @@ public final class HintedLocator implements Locator { KList batch = new KList<>(); for (int ring = 0; ring <= maxRing; ring++) { - if (stop.get() || stopwatch.getMilliseconds() >= timeout) { + if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) { return null; } @@ -173,7 +170,7 @@ public final class HintedLocator implements Locator { int index = i; Position2 sample = batch.get(i); executor.queue(() -> { - if (stop.get() || (fine && matched.get())) { + if (stop.get() || engine.isClosing() || (fine && matched.get())) { return; } @@ -210,7 +207,7 @@ public final class HintedLocator implements Locator { return batch.get(i); } - if (stop.get() || stopwatch.getMilliseconds() >= timeout) { + if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) { return null; } @@ -228,7 +225,7 @@ public final class HintedLocator implements Locator { KList cells = new KList<>(); for (int ring = 0; ring <= stride; ring++) { - if (stop.get() || stopwatch.getMilliseconds() >= timeout) { + if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) { return null; } @@ -239,7 +236,7 @@ public final class HintedLocator implements Locator { for (Position2 cell : cells) { executor.queue(() -> { - if (stop.get() || found.get() != null) { + if (stop.get() || engine.isClosing() || found.get() != null) { return; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java index c6b77cf1e..3fcc116b9 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java @@ -34,7 +34,7 @@ import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import java.util.Set; -import java.util.concurrent.Future; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -42,13 +42,6 @@ import java.util.function.Consumer; @FunctionalInterface public interface Locator { - static void cancelSearch() { - if (LocatorCanceller.cancel != null) { - LocatorCanceller.cancel.run(); - LocatorCanceller.cancel = null; - } - } - static Locator region(String loadKey) { Locator exact = (e, c) -> e.getRegion((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); return new HintedLocator<>(exact, (engine) -> HintedLocator.regionPlan(engine, loadKey)); @@ -107,55 +100,54 @@ public interface Locator { boolean matches(Engine engine, Position2 chunk); - default Future find(Engine engine, Position2 pos, long timeout, Consumer checks) throws WrongEngineBroException { + default CompletableFuture find(Engine engine, Position2 pos, long timeout, Consumer checks) throws WrongEngineBroException { if (engine.isClosed()) { throw new WrongEngineBroException(); } - cancelSearch(); + AtomicBoolean stop = new AtomicBoolean(false); + CompletableFuture search = MultiBurst.burst.completeValueAsync(() -> { + try (GenerationSessionLease lease = engine.acquireGenerationLease("locator_search"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + int tc = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32; + MultiBurst burst = MultiBurst.burst; + AtomicBoolean found = new AtomicBoolean(false); + AtomicInteger searched = new AtomicInteger(); + AtomicReference foundPos = new AtomicReference<>(); + PrecisionStopwatch px = PrecisionStopwatch.start(); + AtomicReference next = new AtomicReference<>(pos); + Spiraler s = new Spiraler(100000, 100000, (x, z) -> next.set(new Position2(x, z))); + s.setOffset(pos.getX(), pos.getZ()); + s.next(); + while (!found.get() && !stop.get() && !engine.isClosing() && px.getMilliseconds() < timeout) { + BurstExecutor e = burst.burst(tc); - return MultiBurst.burst.completeValue(() -> { - int tc = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32; - MultiBurst burst = MultiBurst.burst; - AtomicBoolean found = new AtomicBoolean(false); - AtomicInteger searched = new AtomicInteger(); - AtomicBoolean stop = new AtomicBoolean(false); - AtomicReference foundPos = new AtomicReference<>(); - PrecisionStopwatch px = PrecisionStopwatch.start(); - LocatorCanceller.cancel = () -> stop.set(true); - AtomicReference next = new AtomicReference<>(pos); - Spiraler s = new Spiraler(100000, 100000, (x, z) -> next.set(new Position2(x, z))); - s.setOffset(pos.getX(), pos.getZ()); - s.next(); - while (!found.get() && !stop.get() && px.getMilliseconds() < timeout) { - BurstExecutor e = burst.burst(tc); + for (int i = 0; i < tc; i++) { + Position2 p = next.get(); + s.next(); + e.queue(() -> { + if (found.get() || stop.get() || engine.isClosing()) { + return; + } + if (matches(engine, p)) { + foundPos.compareAndSet(null, p); + found.set(true); + } + searched.incrementAndGet(); + }); + } - for (int i = 0; i < tc; i++) { - Position2 p = next.get(); - s.next(); - e.queue(() -> { - if (found.get()) { - return; - } - if (matches(engine, p)) { - foundPos.compareAndSet(null, p); - found.set(true); - } - searched.incrementAndGet(); - }); + e.complete(); + checks.accept(searched.get()); } - e.complete(); - checks.accept(searched.get()); + if (found.get() && foundPos.get() != null) { + return foundPos.get(); + } + + return null; } - - LocatorCanceller.cancel = null; - - if (found.get() && foundPos.get() != null) { - return foundPos.get(); - } - - return null; }); + return LocatorCanceller.requestScoped(search, stop); } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/LocatorCanceller.java b/core/src/main/java/art/arcane/iris/engine/framework/LocatorCanceller.java index 0188b5f48..7525db96d 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/LocatorCanceller.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/LocatorCanceller.java @@ -18,6 +18,49 @@ package art.arcane.iris.engine.framework; -public class LocatorCanceller { +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class LocatorCanceller { protected static Runnable cancel = null; + + private LocatorCanceller() { + } + + static CompletableFuture requestScoped(CompletableFuture future, AtomicBoolean stop) { + return new RequestFuture<>(Objects.requireNonNull(future), Objects.requireNonNull(stop)); + } + + private static final class RequestFuture extends CompletableFuture { + private final CompletableFuture delegate; + private final AtomicBoolean stop; + + private RequestFuture(CompletableFuture delegate, AtomicBoolean stop) { + this.delegate = delegate; + this.stop = stop; + delegate.whenComplete((value, exception) -> { + if (isDone()) { + return; + } + if (exception == null) { + complete(value); + } else { + completeExceptionally(exception); + } + }); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + if (isDone()) { + return false; + } + + stop.set(true); + boolean cancelled = super.cancel(mayInterruptIfRunning); + delegate.cancel(mayInterruptIfRunning); + return cancelled; + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/TreeBlockMaterial.java b/core/src/main/java/art/arcane/iris/engine/framework/TreeBlockMaterial.java new file mode 100644 index 000000000..b24b0915a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/TreeBlockMaterial.java @@ -0,0 +1,28 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.spi.PlatformBlockState; + +import java.util.Objects; + +public record TreeBlockMaterial(String materialKey) { + public TreeBlockMaterial { + Objects.requireNonNull(materialKey, "materialKey"); + if (materialKey.isBlank()) { + throw new IllegalArgumentException("materialKey must not be blank"); + } + } + + public static TreeBlockMaterial of(PlatformBlockState state) { + return of(Objects.requireNonNull(state, "state").key()); + } + + public static TreeBlockMaterial of(String blockStateKey) { + String key = Objects.requireNonNull(blockStateKey, "blockStateKey"); + int properties = key.indexOf('['); + return new TreeBlockMaterial(properties < 0 ? key : key.substring(0, properties)); + } + + public boolean matches(String blockStateKey) { + return equals(of(blockStateKey)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java b/core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java index 1208684a5..943a35a9b 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java @@ -26,4 +26,8 @@ public class WrongEngineBroException extends Exception { public WrongEngineBroException(String message) { super(message); } + + public WrongEngineBroException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 349b0dc73..2351b831d 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -26,6 +26,7 @@ import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.mantle.components.MantleObjectComponent; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisPosition; @@ -138,6 +139,10 @@ public interface EngineMantle extends MatterGenerator { return getEngine().getDimension().isDebugSmartBore(); } + default void trim(long duration) { + getMantle().trim(duration); + } + default void trim(long dur, int limit) { getMantle().trim(dur, limit); } @@ -281,6 +286,7 @@ public interface EngineMantle extends MatterGenerator { chunk.deleteSlices(MatterCavern.class); chunk.deleteSlices(MatterFluidBody.class); chunk.deleteSlices(MatterMarker.class); + chunk.deleteSlices(TreeBlockMaterial.class); chunk.trimSlices(); }); } finally { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index 7983ea3ba..a85d8c243 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -25,6 +25,7 @@ import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructurePlacementMarker; +import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.IrisMantleComponent; @@ -556,6 +557,7 @@ public class IrisStructureComponent extends IrisMantleComponent { for (ObjectBlockPosition position : positions) { writer.clearBlock(position.x(), position.y(), position.z()); writer.clearData(position.x(), position.y(), position.z(), String.class); + writer.clearData(position.x(), position.y(), position.z(), TreeBlockMaterial.class); writer.clearData(position.x(), position.y(), position.z(), TileWrapper.class); writer.clearData(position.x(), position.y(), position.z(), MatterStructurePOI.class); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index 5e4382c44..973796e16 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -30,6 +30,7 @@ import art.arcane.iris.engine.mantle.IrisMantleComponent; import art.arcane.iris.engine.mantle.MantleWriter; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.object.CarvingMode; import art.arcane.iris.engine.object.DecayControlPlacer; import art.arcane.iris.engine.object.IObjectPlacer; @@ -45,6 +46,7 @@ import art.arcane.iris.engine.object.IrisObjectTranslate; import art.arcane.iris.engine.object.IrisObjectVacuum; import art.arcane.iris.engine.object.IrisProceduralObjects; import art.arcane.iris.engine.object.IrisProceduralPlacement; +import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.engine.object.TileData; @@ -138,6 +140,16 @@ public class MantleObjectComponent extends IrisMantleComponent { return key + "@" + id; } + private static boolean isTreePlacement(IrisObject object, IrisObjectPlacement placement) { + String key = object == null ? null : object.getLoadKey(); + return (key != null && key.startsWith("trees/")) + || (placement != null && placement.getTrees() != null && placement.getTrees().isNotEmpty()); + } + + private static void writeTreeMaterial(IObjectPlacer placer, int x, int y, int z, PlatformBlockState data) { + placer.setData(x, y, z, TreeBlockMaterial.of(data)); + } + @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { IrisComplex complex = context.getComplex(); @@ -440,6 +452,7 @@ public class MantleObjectComponent extends IrisMantleComponent { boolean golden = isGoldenDebugChunk(x, z); CaveAnchorCache caveAnchorCache = new CaveAnchorCache(); for (IrisProceduralPlacement p : proceduralObjects.getAllPlacements()) { + boolean treePlacement = p instanceof IrisProceduralTree; boolean chancePassed = rng.chance(p.getChance() + rng.d(-0.005, 0.005)); if (golden) { IrisLogging.info("Goldendebug procedural chance: chunk=" + x + "," + z @@ -535,6 +548,7 @@ public class MantleObjectComponent extends IrisMantleComponent { minDepthBelowSurface, id, "procedural", + treePlacement, rng ); placeResult = contained.resultY(); @@ -545,6 +559,9 @@ public class MantleObjectComponent extends IrisMantleComponent { if (marker != null) { placer.setData(b.getX(), b.getY(), b.getZ(), marker); } + if (treePlacement && marker != null) { + writeTreeMaterial(placer, b.getX(), b.getY(), b.getZ(), data); + } }, null, getData()); } if (golden) { @@ -608,6 +625,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int minDepthBelowSurface, int id, String markerContext, + boolean treePlacement, RNG rng ) { CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(placer, anchorY, minDepthBelowSurface); @@ -620,6 +638,9 @@ public class MantleObjectComponent extends IrisMantleComponent { if (marker != null) { transaction.setData(block.getX(), block.getY(), block.getZ(), marker); } + if (treePlacement && marker != null) { + writeTreeMaterial(transaction, block.getX(), block.getY(), block.getZ(), data); + } if (placement.isDolphinTarget() && placement.isUnderwater() && B.isStorageChest(data)) { transaction.setData(block.getX(), block.getY(), block.getZ(), MatterStructurePOI.BURIED_TREASURE); } @@ -669,6 +690,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int xx = rng.i(x, x + 16); int zz = rng.i(z, z + 16); IrisObjectPlacement effectivePlacement = resolveEffectivePlacement(objectPlacement, v); + boolean treePlacement = isTreePlacement(v, effectivePlacement); int id = rng.i(0, Integer.MAX_VALUE); IObjectPlacer placePlacer = golden ? new GoldenDebugPlacer(writer, scope + "/" + v.getLoadKey()) : writer; if (golden) { @@ -686,6 +708,9 @@ public class MantleObjectComponent extends IrisMantleComponent { if (marker != null) { writer.setData(b.getX(), b.getY(), b.getZ(), marker); } + if (treePlacement && marker != null) { + writeTreeMaterial(writer, b.getX(), b.getY(), b.getZ(), data); + } if (effectivePlacement.isDolphinTarget() && effectivePlacement.isUnderwater() && B.isStorageChest(data)) { writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); } @@ -866,6 +891,7 @@ public class MantleObjectComponent extends IrisMantleComponent { objectMinDepthBelowSurface, id, "cave", + isTreePlacement(object, effectivePlacement), rng ); int result = contained.resultY(); @@ -1046,12 +1072,16 @@ public class MantleObjectComponent extends IrisMantleComponent { if (forcePlace) { placement.setForcePlace(true); } + boolean treePlacement = isTreePlacement(v, objectPlacement); int result = v.place(xx, anchorY, zz, writer, placement, rng, (b, data) -> { String marker = placementMarker(v, id, "upper"); if (marker != null) { writer.setData(b.getX(), b.getY(), b.getZ(), marker); } + if (treePlacement && marker != null) { + writeTreeMaterial(writer, b.getX(), b.getY(), b.getZ(), data); + } if (placement.isDolphinTarget() && placement.isUnderwater() && B.isStorageChest(data)) { writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index e85c53124..eaac977e3 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -59,6 +59,7 @@ import org.bukkit.block.Biome; import java.awt.Color; import java.util.EnumMap; +import java.util.Objects; @Accessors(chain = true) @NoArgsConstructor @@ -216,7 +217,10 @@ public class IrisBiome extends IrisRegistrant implements IRare { @ArrayType(min = 1, type = IrisDepositVariant.class) @Desc("Deposit ore remap rules scoped to this biome. Each entry declares a vertical band and a source->replacement block id map. Applied before regional and dimension rules; first matching biome rule wins.") private KList depositVariants = new KList<>(); - private transient InferredType inferredType; + private transient volatile InferredType inferredType; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient EnumMap inferredVariants = new EnumMap<>(InferredType.class); @Desc("Collection of ores to be generated") @ArrayType(type = IrisOreGenerator.class, min = 1) private KList ores = new KList<>(); @@ -242,6 +246,37 @@ public class IrisBiome extends IrisRegistrant implements IRare { return !getUndergroundOres().isEmpty(); } + public synchronized IrisBiome setInferredType(InferredType inferredType) { + this.inferredType = inferredType; + return this; + } + + public synchronized IrisBiome withInferredType(InferredType type) { + Objects.requireNonNull(type, "type"); + if (inferredType == null) { + inferredType = type; + return this; + } + if (inferredType == type) { + return this; + } + IrisBiome cached = inferredVariants.get(type); + if (cached != null) { + return cached; + } + IrisData data = getLoader(); + if (data == null) { + throw new IllegalStateException("Cannot create an inferred biome variant without an Iris data loader."); + } + IrisBiome variant = data.getGson().fromJson(data.getGson().toJson(this), IrisBiome.class); + variant.setLoader(data); + variant.setLoadKey(getLoadKey()); + variant.setLoadFile(getLoadFile()); + variant.inferredType = type; + inferredVariants.put(type, variant); + return variant; + } + private PlatformBlockState generateOres(KList localOres, int x, int y, int z, RNG rng, IrisData data) { if (localOres.isEmpty()) { return null; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStatistics.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStatistics.java index 43cb4e74c..b541bd67d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStatistics.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStatistics.java @@ -20,16 +20,23 @@ package art.arcane.iris.engine.object; import lombok.Data; +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; + @Data public class IrisEngineStatistics { - private int totalHotloads = 0; - private int chunksGenerated = 0; - private int IrisToUpgradedVersion = 0; - private int IrisCreationVersion = 0; - private int MinecraftVersion = 0; + private static final AtomicIntegerFieldUpdater TOTAL_HOTLOADS = + AtomicIntegerFieldUpdater.newUpdater(IrisEngineStatistics.class, "totalHotloads"); + private static final AtomicIntegerFieldUpdater CHUNKS_GENERATED = + AtomicIntegerFieldUpdater.newUpdater(IrisEngineStatistics.class, "chunksGenerated"); + + private volatile int totalHotloads = 0; + private volatile int chunksGenerated = 0; + private volatile int IrisToUpgradedVersion = 0; + private volatile int IrisCreationVersion = 0; + private volatile int MinecraftVersion = 0; public void generatedChunk() { - chunksGenerated++; + CHUNKS_GENERATED.incrementAndGet(this); } public void setUpgradedVersion(int i) { @@ -55,6 +62,6 @@ public class IrisEngineStatistics { } public void hotloaded() { - totalHotloads++; + TOTAL_HOTLOADS.incrementAndGet(this); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java b/core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java index 8357d4aee..a75c8e24a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java @@ -20,13 +20,14 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.Required; import art.arcane.iris.engine.object.annotations.Snippet; -import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.stream.ProceduralStream; +import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; @@ -36,6 +37,11 @@ import lombok.NoArgsConstructor; import lombok.Setter; import lombok.experimental.Accessors; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.Map; + @Snippet("expression-load") @Accessors(chain = true) @NoArgsConstructor @@ -44,6 +50,7 @@ import lombok.experimental.Accessors; @Data @EqualsAndHashCode(callSuper = false) public class IrisExpressionLoad { + private static final int STYLE_CACHE_SIZE = 8; @Required @Desc("The variable to assign this value to. Do not set the name to x, y, or z") private String name = ""; @@ -60,24 +67,30 @@ public class IrisExpressionLoad { @Desc("If defined, iris will use an internal value from the engine as it's value") private IrisEngineValueType engineValue = null; - private transient AtomicCache> streamCache = new AtomicCache<>(); - private transient AtomicCache valueCache = new AtomicCache<>(); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) - private transient final KMap styleCache = new KMap<>(); + private transient final Map engineCaches = + Collections.synchronizedMap(new IdentityHashMap<>()); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient final ConcurrentLinkedHashMap standaloneStyleCache = + new ConcurrentLinkedHashMap.Builder() + .maximumWeightedCapacity(STYLE_CACHE_SIZE) + .build(); public double getValue(RNG rng, IrisData data, double x, double z) { if (engineValue != null) { - return valueCache.aquire(() -> engineValue.get(data.getEngine())); + Engine engine = requireEngine(data); + return cacheFor(engine).value.aquire(() -> engineValue.get(engine)); } if (engineStreamValue != null) { - return streamCache.aquire(() -> engineStreamValue.get(data.getEngine())).get(x, z); + Engine engine = requireEngine(data); + return cacheFor(engine).stream.aquire(() -> engineStreamValue.get(engine)).get(x, z); } if (styleValue != null) { - return styleCache.computeIfAbsent(rng.getSeed(), k -> styleValue.createNoCache(new RNG(k), data)) - .noise(x, z); + return style(data, rng).noise(x, z); } return staticValue; @@ -85,18 +98,92 @@ public class IrisExpressionLoad { public double getValue(RNG rng, IrisData data, double x, double y, double z) { if (engineValue != null) { - return valueCache.aquire(() -> engineValue.get(data.getEngine())); + Engine engine = requireEngine(data); + return cacheFor(engine).value.aquire(() -> engineValue.get(engine)); } if (engineStreamValue != null) { - return streamCache.aquire(() -> engineStreamValue.get(data.getEngine())).get(x, z); + Engine engine = requireEngine(data); + return cacheFor(engine).stream.aquire(() -> engineStreamValue.get(engine)).get(x, z); } if (styleValue != null) { - return styleCache.computeIfAbsent(rng.getSeed(), k -> styleValue.createNoCache(new RNG(k), data)) - .noise(x, y, z); + return style(data, rng).noise(x, y, z); } return staticValue; } + + private Engine requireEngine(IrisData data) { + Engine engine = data.getEngine(); + if (engine == null) { + throw new IllegalStateException("Expression variable '" + name + "' requires an active Iris engine."); + } + return engine; + } + + private EngineCache cacheFor(Engine engine) { + synchronized (engineCaches) { + Iterator> iterator = engineCaches.entrySet().iterator(); + while (iterator.hasNext()) { + if (iterator.next().getKey().isClosed()) { + iterator.remove(); + } + } + EngineCache cache = engineCaches.get(engine); + if (cache != null) { + return cache; + } + EngineCache created = new EngineCache(); + engineCaches.put(engine, created); + return created; + } + } + + private CNG style(IrisData data, RNG rng) { + Engine engine = data.getEngine(); + long seed = rng.getSeed(); + if (engine != null) { + return cacheFor(engine).styles.computeIfAbsent(seed, + ignored -> styleValue.createNoCache(new RNG(seed), data)); + } + StandaloneStyleKey key = new StandaloneStyleKey(data, seed); + return standaloneStyleCache.computeIfAbsent(key, + ignored -> styleValue.createNoCache(new RNG(seed), data)); + } + + private static final class EngineCache { + private final AtomicCache> stream = new AtomicCache<>(); + private final AtomicCache value = new AtomicCache<>(); + private final ConcurrentLinkedHashMap styles = + new ConcurrentLinkedHashMap.Builder() + .maximumWeightedCapacity(STYLE_CACHE_SIZE) + .build(); + } + + private static final class StandaloneStyleKey { + private final IrisData data; + private final long seed; + + private StandaloneStyleKey(IrisData data, long seed) { + this.data = data; + this.seed = seed; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof StandaloneStyleKey key)) { + return false; + } + return data == key.data && seed == key.seed; + } + + @Override + public int hashCode() { + return 31 * System.identityHashCode(data) + Long.hashCode(seed); + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java index d82d929b2..b3572ea68 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java @@ -19,7 +19,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; @@ -30,9 +30,13 @@ import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.ExpressionNoise; import art.arcane.iris.util.project.noise.ImageNoise; +import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.Accessors; import java.io.File; @@ -46,8 +50,14 @@ import java.util.concurrent.ConcurrentHashMap; @Desc("A gen style") @Data public class IrisGeneratorStyle { + private static final int GENERATOR_CACHE_SIZE = 8; private static final ConcurrentHashMap ACTIVE_CACHE_KEYS = new ConcurrentHashMap<>(); - private final transient AtomicCache cng = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient ConcurrentLinkedHashMap generatorCache = + new ConcurrentLinkedHashMap.Builder() + .maximumWeightedCapacity(GENERATOR_CACHE_SIZE) + .build(); @Desc("The chance is 1 in CHANCE per interval") private NoiseStyle style = NoiseStyle.FLAT; @@ -90,11 +100,11 @@ public class IrisGeneratorStyle { } public CNG createNoCache(RNG rng, IrisData data) { - return createNoCache(rng, data, false, 0, false); + return createNoCache(rng, data, false, 0, false, resolveEngine(data)); } public CNG createForPrebake(RNG rng, IrisData data, int fallbackCacheSize) { - return createNoCache(rng, data, false, Math.max(0, fallbackCacheSize), true); + return createNoCache(rng, data, false, Math.max(0, fallbackCacheSize), true, resolveEngine(data)); } @@ -120,15 +130,16 @@ public class IrisGeneratorStyle { return hash(); } - private String cachePrefix(RNG rng, int effectiveCacheSize) { + private String cachePrefix(RNG rng, int effectiveCacheSize, long engineStamp) { return "style-" + Integer.toUnsignedString(hash()) + "-seed-" + Long.toUnsignedString(rng.getSeed()) + + "-eng-" + Long.toUnsignedString(engineStamp) + "-sz-" + effectiveCacheSize + "-src-"; } - private String cacheKey(RNG rng, long sourceStamp, int effectiveCacheSize) { - return cachePrefix(rng, effectiveCacheSize) + Long.toUnsignedString(sourceStamp); + private String cacheKey(RNG rng, long sourceStamp, int effectiveCacheSize, long engineStamp) { + return cachePrefix(rng, effectiveCacheSize, engineStamp) + Long.toUnsignedString(sourceStamp); } private void clearStaleCacheEntries(IrisData data, String prefix, String key) { @@ -156,10 +167,11 @@ public class IrisGeneratorStyle { } public CNG createNoCache(RNG rng, IrisData data, boolean actuallyCached) { - return createNoCache(rng, data, actuallyCached, 0, false); + return createNoCache(rng, data, actuallyCached, 0, false, resolveEngine(data)); } - private CNG createNoCache(RNG rng, IrisData data, boolean actuallyCached, int fallbackCacheSize, boolean quietCacheLog) { + private CNG createNoCache(RNG rng, IrisData data, boolean actuallyCached, int fallbackCacheSize, + boolean quietCacheLog, Engine engine) { CNG cng = null; long sourceStamp = 0L; if (getExpression() != null) { @@ -182,7 +194,8 @@ public class IrisGeneratorStyle { cng.setTrueFracturing(axialFracturing); if (fracture != null) { - cng.fractureWith(fracture.createNoCache(rng.nextParallelRNG(2934), data, false, fallbackCacheSize, quietCacheLog), fracture.getMultiplier()); + cng.fractureWith(fracture.createNoCache(rng.nextParallelRNG(2934), data, false, + fallbackCacheSize, quietCacheLog, engine), fracture.getMultiplier()); } if (cellularFrequency > 0) { @@ -191,8 +204,9 @@ public class IrisGeneratorStyle { int effectiveCacheSize = cacheSize > 0 ? cacheSize : Math.max(0, fallbackCacheSize); if (effectiveCacheSize > 0) { - String key = cacheKey(rng, sourceStamp, effectiveCacheSize); - clearStaleCacheEntries(data, cachePrefix(rng, effectiveCacheSize), key); + long engineStamp = engineStamp(engine); + String key = cacheKey(rng, sourceStamp, effectiveCacheSize, engineStamp); + clearStaleCacheEntries(data, cachePrefix(rng, effectiveCacheSize, engineStamp), key); cng = cng.cached(effectiveCacheSize, key, data.getDataFolder(), quietCacheLog); } @@ -204,7 +218,13 @@ public class IrisGeneratorStyle { } public CNG create(RNG rng, IrisData data) { - return cng.aquire(() -> createNoCache(rng, data, true)); + return create(rng, data, resolveEngine(data)); + } + + public CNG create(RNG rng, IrisData data, Engine engine) { + GeneratorCacheKey key = new GeneratorCacheKey(data, engine, rng.getSeed()); + return generatorCache.computeIfAbsent(key, + ignored -> createNoCache(rng, data, true, 0, false, engine)); } @SuppressWarnings("BooleanMethodIsAlwaysInverted") @@ -215,4 +235,51 @@ public class IrisGeneratorStyle { public double getMaxFractureDistance() { return multiplier; } + + private long engineStamp(Engine engine) { + if (engine == null) { + return 0L; + } + return Integer.toUnsignedLong(Objects.hash( + engine.getSeedManager().getSeed(), + engine.getMinHeight(), + engine.getMaxHeight(), + engine.getDimension().getLoadKey(), + engine.getDimension().getFluidHeight() + )); + } + + private Engine resolveEngine(IrisData data) { + return data == null ? null : data.getEngine(); + } + + private static final class GeneratorCacheKey { + private final IrisData data; + private final Engine engine; + private final long seed; + + private GeneratorCacheKey(IrisData data, Engine engine, long seed) { + this.data = data; + this.engine = engine; + this.seed = seed; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof GeneratorCacheKey key)) { + return false; + } + return data == key.data && engine == key.engine && seed == key.seed; + } + + @Override + public int hashCode() { + int result = System.identityHashCode(data); + result = 31 * result + System.identityHashCode(engine); + return 31 * result + Long.hashCode(seed); + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index 0ec396c1d..ac0c80321 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -38,11 +38,15 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.data.DataProvider; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; +import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import com.google.gson.annotations.SerializedName; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.Accessors; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -62,7 +66,13 @@ import java.util.function.Function; @Desc("Represents an iris object placer. It places objects.") @Data public class IrisObjectPlacement { - private final transient AtomicCache surfaceWarp = new AtomicCache<>(); + private static final int SURFACE_WARP_CACHE_SIZE = 8; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient ConcurrentLinkedHashMap surfaceWarpCache = + new ConcurrentLinkedHashMap.Builder() + .maximumWeightedCapacity(SURFACE_WARP_CACHE_SIZE) + .build(); @RegistryListResource(IrisObject.class) @Required @ArrayType(min = 1, type = String.class) @@ -211,11 +221,14 @@ public class IrisObjectPlacement { } public CNG getSurfaceWarp(RNG rng, IrisData data) { - return surfaceWarp.aquire(() -> { - Engine engine = data.getEngine(); - RNG warpRng = engine != null ? new RNG(engine.getSeedManager().getComponent() + 1024) : new RNG(8675309L); - return getWarp().create(warpRng, data); - }); + return getSurfaceWarp(rng, data, data.getEngine()); + } + + public CNG getSurfaceWarp(RNG rng, IrisData data, @Nullable Engine engine) { + long seed = engine == null ? rng.getSeed() : engine.getSeedManager().getComponent() + 1024L; + SurfaceWarpCacheKey key = new SurfaceWarpCacheKey(data, engine, seed); + return surfaceWarpCache.computeIfAbsent(key, + ignored -> getWarp().create(new RNG(seed), data, engine)); } public double warp(RNG rng, double x, double y, double z, IrisData data) { @@ -344,6 +357,36 @@ public class IrisObjectPlacement { return null; } + private static final class SurfaceWarpCacheKey { + private final IrisData data; + private final Engine engine; + private final long seed; + + private SurfaceWarpCacheKey(IrisData data, Engine engine, long seed) { + this.data = data; + this.engine = engine; + this.seed = seed; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof SurfaceWarpCacheKey key)) { + return false; + } + return data == key.data && engine == key.engine && seed == key.seed; + } + + @Override + public int hashCode() { + int result = System.identityHashCode(data); + result = 31 * result + System.identityHashCode(engine); + return 31 * result + Long.hashCode(seed); + } + } + private static class TableCache { final transient WeightedTables global = new WeightedTables(); final transient KMap basic = new KMap<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java index 8afd0e3ce..c851ff4c4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java @@ -275,7 +275,7 @@ public class IrisProceduralTree implements IrisProceduralPlacement { if (object == null || object.getBlocks().isEmpty()) { continue; } - object.setLoadKey("procedural/" + name + "#" + i); + object.setLoadKey(getVariantLoadKey(i)); object.setLoader(data); baked.add(object); } @@ -303,6 +303,13 @@ public class IrisProceduralTree implements IrisProceduralPlacement { return baked.get(rng.i(baked.size())); } + public String getVariantLoadKey(int index) { + if (index < 0) { + throw new IllegalArgumentException("index must not be negative"); + } + return "procedural/tree/" + name + "#" + index; + } + public IrisObjectPlacement asPlacement() { IrisObjectPlacement placement = new IrisObjectPlacement(); placement.setMode(mode); 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 37bf15e24..34c9b4c96 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 @@ -22,7 +22,6 @@ import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.HeightMap; import org.bukkit.Location; -import org.bukkit.Material; import org.bukkit.World; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Semaphore; @@ -41,6 +40,7 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.object.IrisDimensionContractException; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; @@ -57,6 +57,7 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.M; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.hunk.view.ChunkDataHunkHolder; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.io.ReactiveFolder; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; @@ -266,7 +267,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (engine != null) return engine.getTarget(); return targetCache.aquire(() -> { - IrisData data = IrisData.get(dataLocation); + IrisData data = IrisData.openRuntime(dataLocation); data.dump(); data.clearLists(); IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); @@ -383,39 +384,48 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public CompletableFuture closeAsync() { - CompletableFuture existing = closeFuture.get(); - if (existing != null && !existing.isDone()) { - return existing; - } - closing = true; - CompletableFuture future = withExclusiveControlFuture(() -> { - Looper activeHotloader = hotloader; - hotloader = null; - if (isStudio() && activeHotloader != null) { - activeHotloader.interrupt(); - try { - activeHotloader.join(1000L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - IrisLogging.reportError(e); - } + CompletableFuture future = new CompletableFuture<>(); + while (!closeFuture.compareAndSet(null, future)) { + CompletableFuture existing = closeFuture.get(); + if (existing != null) { + return existing; } - - Engine currentEngine = engine; - if (currentEngine != null && !currentEngine.isClosed()) { - currentEngine.close(); - } - folder.clear(); - populators.clear(); - }); - if (!closeFuture.compareAndSet(existing, future)) { - CompletableFuture winningFuture = closeFuture.get(); - return winningFuture == null ? future : winningFuture; } - future.whenComplete((ignored, throwable) -> { - if (throwable != null) { + CompletableFuture operation; + try { + operation = withExclusiveControlFuture(() -> { + Looper activeHotloader = hotloader; + hotloader = null; + if (isStudio() && activeHotloader != null) { + activeHotloader.interrupt(); + try { + activeHotloader.join(1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + IrisLogging.reportError(e); + } + } + + Engine currentEngine = engine; + if (currentEngine != null && !currentEngine.isClosed()) { + currentEngine.close(); + } + folder.clear(); + populators.clear(); + }); + } catch (Throwable throwable) { + future.completeExceptionally(throwable); + closeFuture.compareAndSet(future, null); + return future; + } + + operation.whenComplete((ignored, throwable) -> { + if (throwable == null) { + future.complete(null); + } else { + future.completeExceptionally(throwable); closeFuture.compareAndSet(future, null); } }); @@ -446,8 +456,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } catch (InterruptedException e) { Thread.currentThread().interrupt(); IrisLogging.reportError(e); + e.printStackTrace(); } catch (Throwable e) { IrisLogging.reportError(e); + e.printStackTrace(); } finally { if (acquired) { loadLock.release(LOAD_LOCKS); @@ -486,7 +498,7 @@ 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) { if (closing) { - return; + throw new IllegalStateException("Iris chunk generation was rejected while the generator is closing."); } throwIfInitializationFailed(); @@ -501,8 +513,11 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun ChunkDataHunkHolder blocks = new ChunkDataHunkHolder(d); Hunk biomes = Hunk.viewBiomes(tc); boolean useMulticore = studio && !J.isFolia(); - engine.generate(x << 4, z << 4, blocks, biomes, useMulticore); - blocks.apply(); + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + engine.generate(x << 4, z << 4, blocks, biomes, useMulticore); + blocks.apply(); + } } IrisLogging.debug("Generated " + x + " " + z); @@ -510,7 +525,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun throw e; } catch (GenerationSessionException e) { if (closing || isExpectedTeardown(engine, e)) { - return; + throw new IllegalStateException("Iris chunk generation was rejected during an engine transition.", e); } IrisLogging.error("======================================"); @@ -518,22 +533,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun reportErrorChunk(x, z, e); IrisLogging.error("======================================"); - for (int i = 0; i < 16; i++) { - for (int j = 0; j < 16; j++) { - d.setBlock(i, 0, j, Material.RED_GLAZED_TERRACOTTA.createBlockData()); - } - } + throw new IllegalStateException("Iris chunk generation could not acquire its engine runtime.", e); } catch (Throwable e) { IrisLogging.error("======================================"); e.printStackTrace(); reportErrorChunk(x, z, e); IrisLogging.error("======================================"); - for (int i = 0; i < 16; i++) { - for (int j = 0; j < 16; j++) { - d.setBlock(i, 0, j, Material.RED_GLAZED_TERRACOTTA.createBlockData()); - } - } + throw new IllegalStateException("Iris failed to generate chunk " + x + "," + z + ".", e); } } @@ -595,7 +602,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun default -> false; }; - return currentEngine.getMinHeight() + currentEngine.getHeight(x, z, ignoreFluid) + 1; + try (GenerationSessionLease lease = currentEngine.acquireGenerationLease("bukkit_base_height"); + IrisContext.Scope ignored = IrisContext.open(currentEngine, lease.sessionId(), null)) { + return currentEngine.getMinHeight() + currentEngine.getHeight(x, z, ignoreFluid) + 1; + } catch (GenerationSessionException e) { + throw new IllegalStateException("Iris base height query was rejected for world '" + + worldInfo.getName() + "'.", e); + } } private void computeStudioGenerator() { diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index 516c8ae6b..5035e44dd 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -22,8 +22,8 @@ import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.core.events.IrisLootEvent; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.service.ExternalDataSVC; -import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.Locator; import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.framework.PlacedObject; @@ -43,6 +43,7 @@ import art.arcane.iris.util.common.reflect.KeyedType; import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.common.scheduling.jobs.SingleJob; import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.mantle.flag.MantleFlag; @@ -74,7 +75,10 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.loot.Lootable; import java.lang.reflect.Method; +import java.util.UUID; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicLong; @@ -83,6 +87,8 @@ import java.util.function.Consumer; import java.util.function.Predicate; public final class EngineBukkitOps { + private static final ConcurrentHashMap> ACTIVE_LOCATE_REQUESTS = new ConcurrentHashMap<>(); + private EngineBukkitOps() { } @@ -526,11 +532,11 @@ public final class EngineBukkitOps { } public static void gotoBiome(Engine engine, IrisBiome biome, Player player, boolean teleport) { - find(Locator.surfaceBiome(biome.getLoadKey()), player, teleport, "Biome " + biome.getName()); + find(engine, Locator.surfaceBiome(biome.getLoadKey()), player, teleport, "Biome " + biome.getName()); } public static void gotoObject(Engine engine, String s, Player player, boolean teleport) { - find(Locator.object(s), player, teleport, "Object " + s); + find(engine, Locator.object(s), player, teleport, "Object " + s); } public static void gotoRegion(Engine engine, IrisRegion r, Player player, boolean teleport) { @@ -539,19 +545,19 @@ public final class EngineBukkitOps { return; } - find(Locator.region(r.getLoadKey()), player, teleport, "Region " + r.getName()); + find(engine, Locator.region(r.getLoadKey()), player, teleport, "Region " + r.getName()); } public static void gotoPOI(Engine engine, String type, Player p, boolean teleport) { - find(Locator.poi(type), p, teleport, "POI " + type); + find(engine, Locator.poi(type), p, teleport, "POI " + type); } public static void gotoStructure(Engine engine, String key, Player player, boolean teleport) { - find(Locator.structure(key), player, teleport, "Structure " + key); + find(engine, Locator.structure(key), player, teleport, "Structure " + key); } - private static void find(Locator locator, Player player, boolean teleport, String message) { - find(locator, player, 120_000, location -> { + private static void find(Engine engine, Locator locator, Player player, boolean teleport, String message) { + find(engine, locator, player, 120_000, location -> { if (location == null) { player.sendMessage(C.RED + "Could not find " + message + " within search range."); return; @@ -565,26 +571,50 @@ public final class EngineBukkitOps { }); } - private static void find(Locator locator, Player player, long timeout, Consumer consumer) { + private static void find(Engine engine, Locator locator, Player player, long timeout, Consumer consumer) { AtomicLong checks = new AtomicLong(); long ms = M.ms(); + World world = player.getWorld(); + Location origin = player.getLocation(); + int originChunkX = origin.getBlockX() >> 4; + int originChunkZ = origin.getBlockZ() >> 4; new SingleJob("Searching", () -> { + CompletableFuture search = null; + boolean resultDispatched = false; + UUID playerId = player.getUniqueId(); try { - World world = player.getWorld(); - Engine engine = IrisToolbelt.access(world).getEngine(); - Position2 at = locator.find(engine, new Position2(player.getLocation().getBlockX() >> 4, player.getLocation().getBlockZ() >> 4), timeout, checks::set).get(); + search = locator.find(engine, new Position2(originChunkX, originChunkZ), timeout, checks::set); + CompletableFuture previous = ACTIVE_LOCATE_REQUESTS.put(playerId, search); + if (previous != null && previous != search) { + previous.cancel(true); + } + Position2 at = search.get(); + if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) { + return; + } if (at != null) { int bx = (at.getX() << 4) + 8; int bz = (at.getZ() << 4) + 8; - consumer.accept(new Location(world, bx, - world.getHighestBlockYAt(bx, bz) + 2, - bz)); + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_locator_result"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + int by = engine.getMinHeight() + engine.getHeight(bx, bz, false) + 2; + resultDispatched = dispatchLocateResult( + player, world, consumer, new Location(world, bx, by, bz), playerId, search); + } } else { - consumer.accept(null); + resultDispatched = dispatchLocateResult(player, world, consumer, null, playerId, search); } - } catch (WrongEngineBroException | InterruptedException | ExecutionException e) { + } catch (CancellationException ignored) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (WrongEngineBroException | ExecutionException e) { + IrisLogging.reportError(e); e.printStackTrace(); + } finally { + if (search != null && !resultDispatched) { + ACTIVE_LOCATE_REQUESTS.remove(playerId, search); + } } }) { @Override @@ -604,6 +634,24 @@ public final class EngineBukkitOps { }.execute(new VolmitSender(player)); } + private static boolean dispatchLocateResult(Player player, World world, Consumer consumer, + Location location, UUID playerId, + CompletableFuture search) { + boolean scheduled = J.runEntity(player, () -> { + if (!ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) { + return; + } + if (!player.isOnline() || !world.equals(player.getWorld())) { + return; + } + consumer.accept(location); + }); + if (!scheduled) { + IrisLogging.warn("Could not schedule an Iris locator result for " + player.getName() + "."); + } + return scheduled; + } + private static void teleportAsyncSafely(Player player, Location location) { if (player == null || location == null) { return; diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java index 5e06ad6f9..bc3c0a777 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java @@ -21,11 +21,13 @@ package art.arcane.iris.engine.platform.studio.generators; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.project.context.IrisContext; import java.util.Objects; @@ -43,21 +45,28 @@ public class BiomeBuffetGenerator extends EnginedStudioGenerator { } @Override - public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException { + public synchronized void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException { int id = Cache.to1D(x / biomeSize, 0, z / biomeSize, width, 1); - if (id >= 0 && id < biomes.length) { - IrisBiome biome = biomes[id]; - String foc = engine.getDimension().getFocus(); - - if (!Objects.equals(foc, biome.getLoadKey())) { - engine.getDimension().setFocus(biome.getLoadKey()); - engine.hotloadComplex(); + if (id < 0 || id >= biomes.length) { + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_biome_buffet_stage"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + tc.setRegion(0, 0, 0, 16, 1, 16, FLOOR); } + return; + } + IrisBiome biome = biomes[id]; + String focus = engine.getDimension().getFocus(); + + if (!Objects.equals(focus, biome.getLoadKey())) { + engine.getDimension().setFocus(biome.getLoadKey()); + engine.hotloadComplex(); + } + + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_biome_buffet_stage"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { engine.generate(x << 4, z << 4, tc, true); - } else { - tc.setRegion(0, 0, 0, 16, 1, 16, FLOOR); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java index 6d291bac2..db25412dc 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java @@ -25,6 +25,7 @@ import art.arcane.iris.core.runtime.ObjectStudioLayout.GridCell; import art.arcane.iris.core.service.ObjectStudioSaveService; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator; @@ -38,6 +39,7 @@ import art.arcane.iris.util.common.math.Vector3i; import org.bukkit.Material; import org.bukkit.block.Biome; import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.project.context.IrisContext; import java.io.File; import java.util.LinkedHashMap; @@ -84,6 +86,13 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator { @Override public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException { + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_object_studio_stage"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + generateChunkWithinSession(engine, tc, x, z); + } + } + + private void generateChunkWithinSession(Engine engine, TerrainChunk tc, int x, int z) { ensureLayout(engine); int floorY = Math.max(engine.getMinHeight(), ObjectStudioLayout.FLOOR_Y); 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 dfb7cf9c9..396ffda53 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 @@ -5,8 +5,11 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.volmlib.util.parallel.MultiBurstSupport; import art.arcane.volmlib.util.math.M; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinWorkerThread; +import java.util.concurrent.Future; import java.util.function.IntSupplier; public class MultiBurst extends MultiBurstSupport { @@ -44,6 +47,29 @@ public class MultiBurst extends MultiBurstSupport { return worker.getPool() == pool; } + public CompletableFuture completeValueAsync(Callable task) { + CompletableFuture completion = new CompletableFuture<>(); + Future submitted; + try { + submitted = service().submit(() -> { + try { + completion.complete(task.call()); + } catch (Throwable exception) { + completion.completeExceptionally(exception); + } + }); + } catch (Throwable exception) { + completion.completeExceptionally(exception); + return completion; + } + completion.whenComplete((value, exception) -> { + if (completion.isCancelled()) { + submitted.cancel(true); + } + }); + return completion; + } + @Override public BurstExecutor burst(int estimate) { return new BurstExecutor(service(), estimate); diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java b/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java index af102fd75..776dc18ac 100644 --- a/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java +++ b/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java @@ -26,6 +26,7 @@ import art.arcane.iris.util.project.matter.slices.IdentifierMatter; import art.arcane.iris.util.project.matter.slices.PlatformBlockMatter; import art.arcane.iris.util.project.matter.slices.SpawnerMatter; import art.arcane.iris.util.project.matter.slices.TileMatter; +import art.arcane.iris.util.project.matter.slices.TreeBlockMaterialMatter; import art.arcane.volmlib.util.matter.IrisMatter; import art.arcane.volmlib.util.matter.Matter; import org.bukkit.block.data.BlockData; @@ -63,6 +64,7 @@ public final class IrisMatterSupport { IrisMatter.registerSliceType(new PlatformBlockMatter()); IrisMatter.registerSliceType(new SpawnerMatter()); IrisMatter.registerSliceType(new TileMatter()); + IrisMatter.registerSliceType(new TreeBlockMaterialMatter()); registered = true; } diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/slices/TreeBlockMaterialMatter.java b/core/src/main/java/art/arcane/iris/util/project/matter/slices/TreeBlockMaterialMatter.java new file mode 100644 index 000000000..889126c73 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/project/matter/slices/TreeBlockMaterialMatter.java @@ -0,0 +1,36 @@ +package art.arcane.iris.util.project.matter.slices; + +import art.arcane.iris.engine.framework.TreeBlockMaterial; +import art.arcane.volmlib.util.data.palette.Palette; +import art.arcane.volmlib.util.matter.Sliced; +import art.arcane.volmlib.util.matter.slices.RawMatter; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +@Sliced +public class TreeBlockMaterialMatter extends RawMatter { + public TreeBlockMaterialMatter() { + this(1, 1, 1); + } + + public TreeBlockMaterialMatter(int width, int height, int depth) { + super(width, height, depth, TreeBlockMaterial.class); + } + + @Override + public Palette getGlobalPalette() { + return null; + } + + @Override + public void writeNode(TreeBlockMaterial material, DataOutputStream output) throws IOException { + output.writeUTF(material.materialKey()); + } + + @Override + public TreeBlockMaterial readNode(DataInputStream input) throws IOException { + return new TreeBlockMaterial(input.readUTF()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/loader/IrisDataEngineRegistryTest.java b/core/src/test/java/art/arcane/iris/core/loader/IrisDataEngineRegistryTest.java new file mode 100644 index 000000000..834dcc1cc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/loader/IrisDataEngineRegistryTest.java @@ -0,0 +1,92 @@ +package art.arcane.iris.core.loader; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.project.context.IrisContext; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisDataEngineRegistryTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisData data; + + @Before + public void registerPreservationService() { + IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry()); + } + + @After + public void closeDataAndServices() { + if (data != null) { + data.close(); + } + IrisServices.clear(); + } + + @Test + public void ambiguousRegistrationsRequireMatchingExecutionContext() throws Exception { + data = IrisData.openRuntime(temporaryFolder.newFolder("pack")); + AtomicBoolean firstClosed = new AtomicBoolean(false); + Engine first = engine(firstClosed); + Engine second = engine(new AtomicBoolean(false)); + + data.registerEngine(first); + data.registerEngine(first); + data.registerEngine(second); + + assertEquals(2, data.getEngines().size()); + assertNull(data.getEngine()); + try (IrisContext.Scope ignored = IrisContext.open(first, 1L, null)) { + assertSame(first, data.getEngine()); + } + + firstClosed.set(true); + data.cleanupEngine(); + + assertEquals(1, data.getEngines().size()); + assertSame(second, data.getEngine()); + data.unregisterEngine(second); + assertNull(data.getEngine()); + } + + private Engine engine(AtomicBoolean closed) { + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + when(engine.isClosed()).thenAnswer(ignored -> closed.get()); + return engine; + } + + private static final class NoOpPreservationRegistry implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/EngineMaintenanceTest.java b/core/src/test/java/art/arcane/iris/core/service/EngineMaintenanceTest.java new file mode 100644 index 000000000..b678bec5b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/EngineMaintenanceTest.java @@ -0,0 +1,93 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.IrisSettings; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class EngineMaintenanceTest { + @Test + public void normalMaintenanceUsesIdleAgeWithoutAResidentPlateLimit() { + EngineMaintenance.Plan plan = EngineMaintenance.plan(30, 0.50D, false); + + assertEquals(30_000L, plan.idleDurationMillis()); + assertFalse(plan.multicoreUnload()); + assertFalse(plan.heapPressure()); + } + + @Test + public void negativeKeepAliveIsClampedToImmediateEligibility() { + EngineMaintenance.Plan plan = EngineMaintenance.plan(-1, 0.50D, false); + + assertEquals(0L, plan.idleDurationMillis()); + assertFalse(plan.multicoreUnload()); + assertFalse(plan.heapPressure()); + } + + @Test + public void risingHeapUsageGraduallyShortensRetention() { + EngineMaintenance.Plan plan = EngineMaintenance.plan(30, 0.87D, false); + + assertEquals(15_000L, plan.idleDurationMillis()); + assertFalse(plan.multicoreUnload()); + assertFalse(plan.heapPressure()); + } + + @Test + public void heapPressureRequestsImmediateParallelReclamation() { + EngineMaintenance.Plan plan = EngineMaintenance.plan(30, 0.92D, false); + + assertEquals(0L, plan.idleDurationMillis()); + assertTrue(plan.multicoreUnload()); + assertTrue(plan.heapPressure()); + } + + @Test + public void forcedMulticoreWriteDoesNotShortenNormalRetention() { + EngineMaintenance.Plan plan = EngineMaintenance.plan(30, 0.50D, true); + + assertEquals(30_000L, plan.idleDurationMillis()); + assertTrue(plan.multicoreUnload()); + assertFalse(plan.heapPressure()); + } + + @Test + public void nestedMantleClosedFailureIsRecognized() { + IllegalStateException cause = new IllegalStateException("Mantle is closed"); + RuntimeException failure = new RuntimeException("maintenance failed", cause); + + assertTrue(EngineMaintenance.isMantleClosed(failure)); + assertFalse(EngineMaintenance.isMantleClosed(new IllegalStateException("unrelated"))); + } + + @Test + public void configuredParallelismOverridesHardwareSizing() { + IrisSettings.IrisSettingsEngineSVC settings = new IrisSettings.IrisSettingsEngineSVC(); + settings.parallelism = 1; + + assertEquals(1, settings.getParallelism()); + } + + @Test + public void configuredParallelismIsBoundedByHardware() { + IrisSettings.IrisSettingsEngineSVC settings = new IrisSettings.IrisSettingsEngineSVC(); + settings.parallelism = Integer.MAX_VALUE; + int processors = Math.max(1, Runtime.getRuntime().availableProcessors()); + int maximumParallelism = processors > Integer.MAX_VALUE / 2 + ? Integer.MAX_VALUE + : processors * 2; + + assertEquals(maximumParallelism, settings.getParallelism()); + } + + @Test + public void automaticParallelismScalesWithAvailableProcessors() { + IrisSettings.IrisSettingsEngineSVC settings = new IrisSettings.IrisSettingsEngineSVC(); + settings.parallelism = 0; + int processors = Math.max(1, Runtime.getRuntime().availableProcessors()); + + assertEquals(Math.max(1, (int) Math.ceil(Math.sqrt(processors))), settings.getParallelism()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/tree/TreeDefinitionIndexTest.java b/core/src/test/java/art/arcane/iris/core/service/tree/TreeDefinitionIndexTest.java new file mode 100644 index 000000000..cd5cadfb4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/tree/TreeDefinitionIndexTest.java @@ -0,0 +1,58 @@ +package art.arcane.iris.core.service.tree; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.StructurePlacementMarker; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisProceduralObjects; +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisTree; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TreeDefinitionIndexTest { + @Test + public void indexSeparatesOrdinaryProceduralAndStructureOwnership() { + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisRegion region = new IrisRegion(); + IrisBiome biome = new IrisBiome(); + + IrisObjectPlacement explicit = new IrisObjectPlacement(); + explicit.getPlace().add("custom/ancient_oak"); + explicit.getTrees().add(new IrisTree()); + region.getObjects().add(explicit); + + IrisProceduralTree proceduralTree = new IrisProceduralTree(); + proceduralTree.setName("towering-oak"); + proceduralTree.setVariants(2); + IrisProceduralObjects proceduralObjects = new IrisProceduralObjects(); + proceduralObjects.getTrees().add(proceduralTree); + biome.setProceduralObjects(proceduralObjects); + + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getAllRegions(engine)).thenReturn(new KList().qadd(region)); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList().qadd(biome)); + + TreeDefinitionIndex index = TreeDefinitionIndex.build(engine); + + assertTrue(index.isTreeMarker("trees/oak/giant@1")); + assertTrue(index.isTreeMarker("custom/ancient_oak@2")); + assertTrue(index.isTreeMarker("procedural/tree/towering-oak#0@3")); + assertTrue(index.isTreeMarker("procedural/tree/towering-oak#1@4")); + assertFalse(index.isTreeMarker("procedural/towering-oak#0@3")); + assertFalse(index.isTreeMarker("procedural/tree/towering-oak#2@5")); + assertFalse(index.isTreeMarker(StructurePlacementMarker.encodeStructure( + "trees/oak/giant", + 1, + "village" + ))); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/tree/TreeMarkerTraversalTest.java b/core/src/test/java/art/arcane/iris/core/service/tree/TreeMarkerTraversalTest.java new file mode 100644 index 000000000..133332386 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/tree/TreeMarkerTraversalTest.java @@ -0,0 +1,77 @@ +package art.arcane.iris.core.service.tree; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TreeMarkerTraversalTest { + @Test + public void traversalUsesExactMarkerAndIncludesTriggerOnce() { + String marker = "trees/oak@7"; + TreeMarkerTraversal.Position trigger = new TreeMarkerTraversal.Position(0, 64, 0); + Map markers = new HashMap<>(); + markers.put(trigger, marker); + markers.put(new TreeMarkerTraversal.Position(1, 65, 1), marker); + markers.put(new TreeMarkerTraversal.Position(2, 65, 1), "trees/oak@8"); + + TreeMarkerTraversal.Discovery discovery = TreeMarkerTraversal.discover( + trigger, + marker, + -64, + 320, + (x, y, z) -> markers.get(new TreeMarkerTraversal.Position(x, y, z)) + ); + + assertTrue(discovery.complete()); + assertEquals(2, discovery.members().size()); + assertEquals(trigger, discovery.members().getFirst()); + assertEquals(1, discovery.members().stream().filter(trigger::equals).count()); + } + + @Test + public void matchingOwnershipBeyondAxisBoundReportsIncomplete() { + String marker = "trees/giant@99"; + TreeMarkerTraversal.Position trigger = new TreeMarkerTraversal.Position(0, 64, 0); + + TreeMarkerTraversal.Discovery discovery = TreeMarkerTraversal.discover( + trigger, + marker, + -64, + 320, + (x, y, z) -> y == 64 && z == 0 && x >= 0 && x <= TreeMarkerTraversal.MAX_AXIS_DISTANCE + 1 + ? marker + : null + ); + + assertFalse(discovery.complete()); + assertEquals(TreeMarkerTraversal.MAX_AXIS_DISTANCE + 1, discovery.members().size()); + } + + @Test + public void connectedMembersAreDiscoveredInOutwardErosionOrder() { + String marker = "trees/giant@12"; + TreeMarkerTraversal.Position trigger = new TreeMarkerTraversal.Position(0, 64, 0); + TreeMarkerTraversal.Position near = new TreeMarkerTraversal.Position(1, 64, 0); + TreeMarkerTraversal.Position far = new TreeMarkerTraversal.Position(2, 64, 0); + Map markers = new HashMap<>(); + markers.put(trigger, marker); + markers.put(near, marker); + markers.put(far, marker); + + TreeMarkerTraversal.Discovery discovery = TreeMarkerTraversal.discover( + trigger, + marker, + -64, + 320, + (x, y, z) -> markers.get(new TreeMarkerTraversal.Position(x, y, z)) + ); + + assertEquals(List.of(trigger, near, far), discovery.members()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/EnginePanicTest.java b/core/src/test/java/art/arcane/iris/engine/EnginePanicTest.java new file mode 100644 index 000000000..e2038cb87 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/EnginePanicTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.engine; + +import org.junit.Test; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class EnginePanicTest { + @Test + public void savedSnapshotDoesNotReadCurrentValues() { + EnginePanic.Diagnostics diagnostics = EnginePanic.scoped("test-world"); + diagnostics.add("read-chunk", "Chunk[1]"); + diagnostics.saveLast(); + diagnostics.add("read-chunk", "Chunk[2]"); + + assertEquals("Chunk[1]", diagnostics.lastSnapshot().get("read-chunk")); + assertEquals("Chunk[2]", diagnostics.currentSnapshot().get("read-chunk")); + } + + @Test + public void currentDiagnosticsAreIsolatedByThread() throws Exception { + EnginePanic.Diagnostics diagnostics = EnginePanic.scoped("test-world"); + AtomicReference> first = new AtomicReference<>(); + AtomicReference> second = new AtomicReference<>(); + Thread firstThread = new Thread(() -> { + diagnostics.add("read-chunk", "Chunk[10]"); + first.set(diagnostics.currentSnapshot()); + }); + Thread secondThread = new Thread(() -> { + diagnostics.add("read-chunk", "Chunk[20]"); + second.set(diagnostics.currentSnapshot()); + }); + + firstThread.start(); + firstThread.join(); + secondThread.start(); + secondThread.join(); + + assertEquals("Chunk[10]", first.get().get("read-chunk")); + assertEquals("Chunk[20]", second.get().get("read-chunk")); + assertTrue(diagnostics.currentSnapshot().isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexImplodeParityTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexImplodeParityTest.java index fbf4b448a..7c22081d0 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexImplodeParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexImplodeParityTest.java @@ -4,25 +4,15 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.util.project.noise.CNG; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.Server; -import org.bukkit.block.data.BlockData; import org.junit.BeforeClass; import org.junit.Test; -import org.mockito.invocation.InvocationOnMock; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; -import java.util.Locale; -import java.util.logging.Logger; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -32,17 +22,6 @@ public class IrisComplexImplodeParityTest { @BeforeClass public static void setup() throws Exception { - if (Bukkit.getServer() == null) { - Server server = mock(Server.class); - doReturn(Logger.getLogger("IrisTest")).when(server).getLogger(); - doReturn("IrisTestServer").when(server).getName(); - doReturn("1.0").when(server).getVersion(); - doReturn("1.0").when(server).getBukkitVersion(); - doAnswer((InvocationOnMock invocation) -> namedBlockData(invocation.getArgument(0, Material.class).name().toLowerCase(Locale.ROOT))).when(server).createBlockData(any(Material.class)); - doAnswer((InvocationOnMock invocation) -> namedBlockData(invocation.getArgument(0, String.class))).when(server).createBlockData(anyString()); - Bukkit.setServer(server); - } - Class childSelectionClass = Class.forName("art.arcane.iris.engine.IrisComplex$ChildSelectionPlan"); childSelectionCreateMethod = childSelectionClass.getDeclaredMethod("create", KList.class); childSelectionCreateMethod.setAccessible(true); @@ -50,13 +29,6 @@ public class IrisComplexImplodeParityTest { childSelectionSelectMethod.setAccessible(true); } - private static BlockData namedBlockData(String key) { - String canonical = key.indexOf(':') >= 0 ? key : "minecraft:" + key; - BlockData data = mock(BlockData.class); - doReturn(canonical).when(data).getAsString(); - return data; - } - @Test public void selectionPlanMatchesLegacyFitRarityAcrossSeedAndCoordinateGrid() throws Exception { List> scenarios = buildScenarios(); diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java new file mode 100644 index 000000000..5142dd313 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java @@ -0,0 +1,65 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class IrisComplexSurfaceBiomeTest { + @Test + public void shorelineHeightSelectsShoreBiome() { + IrisBiome base = mock(IrisBiome.class); + IrisBiome shore = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + doReturn(false).when(base).isShore(); + doReturn(3D).when(region).getShoreHeight(12D, 18D); + + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + 63D, + base, + region, + 12D, + 18D, + 63D, + constant(base), + constant(base), + constant(shore)); + + assertSame(shore, resolved); + } + + @Test + public void raisedAquaticBiomeReturnsToLand() { + IrisBiome aquatic = mock(IrisBiome.class); + IrisBiome land = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + doReturn(false).when(aquatic).isShore(); + doReturn(false).when(aquatic).isLand(); + doReturn(1D).when(region).getShoreHeight(4D, 7D); + + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + 66D, + aquatic, + region, + 4D, + 7D, + 63D, + constant(land), + constant(aquatic), + constant(aquatic)); + + assertSame(land, resolved); + } + + private static ProceduralStream constant(IrisBiome biome) { + @SuppressWarnings("unchecked") + ProceduralStream stream = mock(ProceduralStream.class); + doReturn(biome).when(stream).get(anyDouble(), anyDouble()); + return stream; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java new file mode 100644 index 000000000..a8a24e5f2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.object.IrisEngineData; +import com.google.gson.Gson; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class IrisEngineDataPersistenceTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void atomicWriteCreatesAndReplacesEngineData() throws Exception { + File folder = temporaryFolder.newFolder("engine-data"); + File output = new File(folder, "dimension.json"); + IrisEngineData first = new IrisEngineData(); + first.getStatistics().setVersion(10); + + IrisEngine.writeEngineDataAtomically(output, first); + + IrisEngineData firstRead = new Gson().fromJson(Files.readString(output.toPath()), IrisEngineData.class); + assertEquals(10, firstRead.getStatistics().getVersion()); + + IrisEngineData replacement = new IrisEngineData(); + replacement.getStatistics().setVersion(20); + IrisEngine.writeEngineDataAtomically(output, replacement); + + IrisEngineData replacementRead = new Gson().fromJson(Files.readString(output.toPath()), IrisEngineData.class); + assertEquals(20, replacementRead.getStatistics().getVersion()); + File[] temporaryFiles = folder.listFiles((ignored, name) -> name.endsWith(".tmp")); + assertFalse(temporaryFiles != null && temporaryFiles.length > 0); + } + + @Test(expected = IOException.class) + public void atomicWriteRejectsParentlessPath() throws Exception { + IrisEngine.writeEngineDataAtomically(new File("parentless-engine-data.json"), new IrisEngineData()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineLifecycleGateTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineLifecycleGateTest.java new file mode 100644 index 000000000..1e3b18b86 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineLifecycleGateTest.java @@ -0,0 +1,26 @@ +package art.arcane.iris.engine; + +import org.junit.Test; + +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisEngineLifecycleGateTest { + @Test + public void incompleteBackgroundDrainBlocksResourceRelease() { + IrisEngine.BackgroundTaskDrain drain = new IrisEngine.BackgroundTaskDrain( + new TimeoutException("still running"), false); + + assertFalse(drain.allowsResourceRelease()); + } + + @Test + public void completedFailedTaskAllowsSafeResourceRelease() { + IrisEngine.BackgroundTaskDrain drain = new IrisEngine.BackgroundTaskDrain( + new IllegalStateException("completed exceptionally"), true); + + assertTrue(drain.allowsResourceRelease()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java index 05c8e8984..abaf4b4ae 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java @@ -2,7 +2,15 @@ package art.arcane.iris.engine; import org.junit.Test; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class IrisWorldManagerMarkerTest { @Test @@ -18,4 +26,50 @@ public class IrisWorldManagerMarkerTest { assertEquals(255, IrisWorldManager.toWorldY(319, -64)); assertEquals(42, IrisWorldManager.toWorldY(42, 0)); } + + @Test + public void completedEntityTasksAreAccepted() { + assertTrue(IrisWorldManager.awaitEntityTasks(new CountDownLatch(0), 0, TimeUnit.MILLISECONDS)); + } + + @Test + public void incompleteEntityTasksAreRejected() { + assertFalse(IrisWorldManager.awaitEntityTasks(new CountDownLatch(1), 0, TimeUnit.MILLISECONDS)); + } + + @Test + public void interruptedEntityWaitPreservesInterruptStatus() throws Exception { + AtomicBoolean preserved = new AtomicBoolean(); + Thread thread = new Thread(() -> { + Thread.currentThread().interrupt(); + boolean completed = IrisWorldManager.awaitEntityTasks(new CountDownLatch(1), 1, TimeUnit.SECONDS); + preserved.set(!completed && Thread.currentThread().isInterrupted()); + }); + + thread.start(); + thread.join(); + + assertTrue(preserved.get()); + } + + @Test + public void deferredDropsUseTheRouteAndFallbackOnlyWhenDeclined() { + List routed = new ArrayList<>(); + List fallback = new ArrayList<>(); + + IrisWorldManager.routeDrops( + List.of("routed", "fallback"), + drop -> { + if (drop.equals("routed")) { + routed.add((String) drop); + return true; + } + return false; + }, + fallback::add + ); + + assertEquals(List.of("routed"), routed); + assertEquals(List.of("fallback"), fallback); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerLifecycleTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerLifecycleTest.java new file mode 100644 index 000000000..7adec5ffb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerLifecycleTest.java @@ -0,0 +1,132 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; +import org.bukkit.Chunk; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerTeleportEvent; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; + +public class EngineAssignedWorldManagerLifecycleTest { + @Test + public void failedRegistrationStillAttemptsListenerRollback() { + TestWorldManager manager = new TestWorldManager(mock(Engine.class)); + manager.failRegister = true; + + assertThrows(IllegalStateException.class, manager::start); + + assertEquals(1, manager.unregisterAttempts); + assertEquals(0, manager.cancelAttempts); + } + + @Test + public void closeRetriesOnlyIncompleteManagerResources() { + TestWorldManager manager = new TestWorldManager(mock(Engine.class)); + manager.start(); + manager.failUnregister = true; + manager.failCancel = true; + + assertThrows(IllegalStateException.class, manager::close); + + assertEquals(1, manager.unregisterAttempts); + assertEquals(1, manager.cancelAttempts); + + manager.failUnregister = false; + manager.failCancel = false; + manager.close(); + + assertEquals(2, manager.unregisterAttempts); + assertEquals(2, manager.cancelAttempts); + + manager.close(); + + assertEquals(2, manager.unregisterAttempts); + assertEquals(2, manager.cancelAttempts); + } + + private static final class TestWorldManager extends EngineAssignedWorldManager { + private boolean failRegister; + private boolean failUnregister; + private boolean failCancel; + private int unregisterAttempts; + private int cancelAttempts; + + private TestWorldManager(Engine engine) { + super(engine); + } + + @Override + protected void registerManagerListener() { + if (failRegister) { + throw new IllegalStateException("registration failure"); + } + } + + @Override + protected int scheduleManagerTick(Runnable tick) { + return 42; + } + + @Override + protected void unregisterManagerListener() { + unregisterAttempts++; + if (failUnregister) { + throw new IllegalStateException("listener failure"); + } + } + + @Override + protected void cancelManagerTick(int scheduledTaskId) { + cancelAttempts++; + if (failCancel) { + throw new IllegalStateException("scheduler failure"); + } + } + + @Override + public int getEntityCount() { + return 0; + } + + @Override + public int getChunkCount() { + return 0; + } + + @Override + public double getEntitySaturation() { + return 0.0; + } + + @Override + public void onTick() { + } + + @Override + public void onSave() { + } + + @Override + public void onBlockBreak(BlockBreakEvent event) { + } + + @Override + public void onBlockPlace(BlockPlaceEvent event) { + } + + @Override + public void onChunkLoad(Chunk chunk, boolean generated) { + } + + @Override + public void onChunkUnload(Chunk chunk) { + } + + @Override + public void teleportAsync(PlayerTeleportEvent event) { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineLifecycleTasksTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineLifecycleTasksTest.java new file mode 100644 index 000000000..781cf0517 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineLifecycleTasksTest.java @@ -0,0 +1,95 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.util.project.context.IrisContext; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class EngineLifecycleTasksTest { + @Test + public void activeLifecycleTaskHoldsGenerationDrainUntilCompletion() throws Exception { + GenerationSessionManager manager = new GenerationSessionManager(); + Engine engine = mock(Engine.class); + when(engine.acquireGenerationLease(anyString())) + .thenAnswer(invocation -> manager.acquire(invocation.getArgument(0))); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference taskFailure = new AtomicReference<>(); + AtomicReference contextEngine = new AtomicReference<>(); + Thread task = new Thread(() -> { + try { + EngineLifecycleTasks.run(engine, "world_manager_test", () -> { + contextEngine.set(IrisContext.get().getEngine()); + entered.countDown(); + try { + release.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + }); + } catch (Throwable exception) { + taskFailure.set(exception); + } + }); + task.start(); + + assertTrue(entered.await(1L, TimeUnit.SECONDS)); + CountDownLatch drained = new CountDownLatch(1); + Thread sealer = new Thread(() -> { + try { + manager.sealAndAwait("hotload", 1_000L); + drained.countDown(); + } catch (GenerationSessionException exception) { + taskFailure.set(exception); + } + }); + sealer.start(); + + waitForSeal(manager); + assertFalse(drained.await(50L, TimeUnit.MILLISECONDS)); + release.countDown(); + assertTrue(drained.await(1L, TimeUnit.SECONDS)); + task.join(1_000L); + sealer.join(1_000L); + assertSame(engine, contextEngine.get()); + assertTrue(taskFailure.get() == null); + } + + @Test + public void sealedLifecycleRejectsQueuedManagerTask() throws Exception { + GenerationSessionManager manager = new GenerationSessionManager(); + manager.sealAndAwait("hotload", 1_000L); + Engine engine = mock(Engine.class); + when(engine.acquireGenerationLease(anyString())) + .thenAnswer(invocation -> manager.acquire(invocation.getArgument(0))); + when(engine.isClosing()).thenReturn(true); + AtomicBoolean ran = new AtomicBoolean(); + + boolean accepted = EngineLifecycleTasks.run(engine, "queued_world_manager_test", () -> ran.set(true)); + + assertFalse(accepted); + assertFalse(ran.get()); + } + + private void waitForSeal(GenerationSessionManager manager) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1L); + while (System.nanoTime() < deadline) { + try (GenerationSessionLease ignored = manager.acquire("seal_probe")) { + Thread.onSpinWait(); + } catch (GenerationSessionException expected) { + return; + } + } + throw new AssertionError("Generation session did not seal within one second."); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineTelemetrySnapshotTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineTelemetrySnapshotTest.java new file mode 100644 index 000000000..16daa4bfc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineTelemetrySnapshotTest.java @@ -0,0 +1,186 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class EngineTelemetrySnapshotTest { + @Test + public void aggregatesMultipleWorldsWithExplicitSumMaxAndMeanRules() { + EngineTelemetrySnapshot first = snapshot( + "minecraft:overworld", + "world", + true, + false, + 12L, + 3L, + 0.25D, + 4L, + 100L, + 2D, + 5L, + 2, + 1, + 1L, + 4L, + 1L, + 10D, + Map.of("total", 5D, "terrain", 2D) + ); + EngineTelemetrySnapshot second = snapshot( + "minecraft:the_nether", + "world_nether", + false, + true, + 8L, + 5L, + 0.75D, + 6L, + 200L, + 3D, + 7L, + 4, + 2, + 2L, + 6L, + 3L, + 30D, + Map.of("total", 8D, "terrain", 1D) + ); + + EngineTelemetrySnapshot.Aggregate aggregate = EngineTelemetrySnapshot.aggregate(List.of(first, second)); + + assertEquals(2, aggregate.worldCount()); + assertEquals(1, aggregate.active()); + assertEquals(1, aggregate.studio()); + assertEquals(1, aggregate.failed()); + assertEquals(20L, aggregate.loadedChunks()); + assertEquals(8L, aggregate.loadedEntities()); + assertEquals(0.75D, aggregate.entitySaturationMax(), 0D); + assertEquals(10L, aggregate.generatedSession()); + assertEquals(300L, aggregate.generatedTotal()); + assertEquals(5D, aggregate.chunksPerSecond(), 0D); + assertEquals(12L, aggregate.blockUpdatesPerSecond()); + assertEquals(6, aggregate.parallelism()); + assertEquals(3, aggregate.activeGenerationLeases()); + assertEquals(3L, aggregate.hotloadsTotal()); + assertEquals(10L, aggregate.mantleResidentPlates()); + assertEquals(4L, aggregate.mantleQueuedPlates()); + assertEquals(20D, aggregate.mantleIdleAverageMs(), 0D); + assertEquals(30D, aggregate.mantleIdleMaxMs(), 0D); + assertEquals(10D, aggregate.mantleIdleMinMs(), 0D); + assertEquals(8D, aggregate.generationTimingMaximaMs().get("total"), 0D); + assertEquals(2D, aggregate.generationTimingMaximaMs().get("terrain"), 0D); + } + + @Test + public void normalizesInvalidRuntimeCountersAtSnapshotBoundary() { + EngineTelemetrySnapshot snapshot = new EngineTelemetrySnapshot( + 1L, + "minecraft:overworld", + "world", + "overworld", + true, + false, + false, + false, + -1L, + -1L, + Double.NaN, + -1L, + -1L, + Double.POSITIVE_INFINITY, + -1L, + -1, + -1, + -1L, + -1L, + -1L, + Double.NaN, + Map.of("bad", Double.NaN, "negative", -1D, "valid", 2D) + ); + + assertEquals(0L, snapshot.loadedChunks()); + assertEquals(0D, snapshot.entitySaturation(), 0D); + assertEquals(0D, snapshot.chunksPerSecond(), 0D); + assertEquals(Map.of("valid", 2D), snapshot.generationTimingsMs()); + } + + @Test + public void aggregateWorldCountIgnoresMissingSnapshots() { + List snapshots = new ArrayList<>(); + snapshots.add(snapshot( + "minecraft:overworld", + "world", + true, + false, + 0L, + 0L, + 0D, + 0L, + 0L, + 0D, + 0L, + 0, + 0, + 0L, + 0L, + 0L, + 0D, + Map.of() + )); + snapshots.add(null); + + assertEquals(1, EngineTelemetrySnapshot.aggregate(snapshots).worldCount()); + } + + private static EngineTelemetrySnapshot snapshot( + String identity, + String name, + boolean active, + boolean studio, + long chunks, + long entities, + double saturation, + long generatedSession, + long generatedTotal, + double chunksPerSecond, + long blockUpdates, + int parallelism, + int leases, + long hotloads, + long resident, + long queued, + double idleMs, + Map timings + ) { + return new EngineTelemetrySnapshot( + 1_000L, + identity, + name, + "dimension", + active, + studio, + false, + !active, + chunks, + entities, + saturation, + generatedSession, + generatedTotal, + chunksPerSecond, + blockUpdates, + parallelism, + leases, + hotloads, + resident, + queued, + idleMs, + timings + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/GenerationSessionManagerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/GenerationSessionManagerTest.java index 1e5aef8ba..5c4bf2f54 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/GenerationSessionManagerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/GenerationSessionManagerTest.java @@ -4,8 +4,11 @@ import org.junit.Test; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotEquals; public class GenerationSessionManagerTest { @Test @@ -44,4 +47,56 @@ public class GenerationSessionManagerTest { manager.sealAndAwait("close", 1000L, true); } + + @Test + public void activatingAfterSealPublishesAnIndependentSession() throws Exception { + GenerationSessionManager manager = new GenerationSessionManager(); + long sealedSession = manager.currentSessionId(); + manager.sealAndAwait("hotload", 1000L); + + manager.activateNextSession(); + + try (GenerationSessionLease lease = manager.acquire("chunk_generate")) { + assertNotEquals(sealedSession, lease.sessionId()); + } + } + + @Test + public void nestedWorkCanContinueAnAlreadyLeasedSessionAfterSeal() throws Exception { + GenerationSessionManager manager = new GenerationSessionManager(); + GenerationSessionLease outer = manager.acquire("chunk_pipeline"); + AtomicReference sealFailure = new AtomicReference<>(); + Thread sealer = new Thread(() -> { + try { + manager.sealAndAwait("hotload", 1000L); + } catch (Throwable exception) { + sealFailure.set(exception); + } + }); + sealer.start(); + + waitForSeal(manager); + try (GenerationSessionLease nested = manager.continueSession("biome_lookup", outer.sessionId())) { + assertEquals(outer.sessionId(), nested.sessionId()); + } + outer.close(); + sealer.join(1000L); + + assertTrue(!sealer.isAlive()); + if (sealFailure.get() != null) { + throw new AssertionError("Session seal failed", sealFailure.get()); + } + } + + private void waitForSeal(GenerationSessionManager manager) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1L); + while (System.nanoTime() < deadline) { + try (GenerationSessionLease ignored = manager.acquire("seal_probe")) { + Thread.onSpinWait(); + } catch (GenerationSessionException expected) { + return; + } + } + throw new AssertionError("Generation session did not seal within one second."); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java index 20c3664ef..628817ada 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java @@ -22,6 +22,9 @@ import java.io.File; import java.nio.file.Files; import java.util.HashSet; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiFunction; @@ -74,7 +77,7 @@ public class HintedLocatorTest { @Before @SuppressWarnings("unchecked") - public void setup() { + public void setup() throws Exception { engine = mock(Engine.class); dimension = mock(IrisDimension.class); complex = mock(IrisComplex.class); @@ -86,6 +89,7 @@ public class HintedLocatorTest { when(engine.getFocus()).thenReturn(null); when(engine.getFocusRegion()).thenReturn(null); when(engine.isClosed()).thenReturn(false); + when(engine.acquireGenerationLease(any(String.class))).thenReturn(GenerationSessionLease.noop()); when(data.getBiomeLoader()).thenReturn(biomeLoader); } @@ -371,6 +375,35 @@ public class HintedLocatorTest { assertTrue(elapsed < 10_000); } + @Test + public void startingAnotherSearchDoesNotCancelTheActiveRequest() throws Exception { + CountDownLatch planning = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + Position2 origin = new Position2(4, -7); + Locator firstLocator = new HintedLocator<>((ignoredEngine, chunk) -> chunk.equals(origin), ignoredEngine -> { + planning.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return HintedLocator.SearchPlan.impossible(); + } + return HintedLocator.SearchPlan.unpruned(); + }); + Locator secondLocator = new HintedLocator<>((ignoredEngine, chunk) -> false, + ignoredEngine -> HintedLocator.SearchPlan.impossible()); + + Future first = firstLocator.find(engine, origin, 60_000, (Integer count) -> { + }); + assertTrue(planning.await(10, TimeUnit.SECONDS)); + Future second = secondLocator.find(engine, origin, 60_000, (Integer count) -> { + }); + release.countDown(); + + assertNull(second.get(10, TimeUnit.SECONDS)); + assertEquals(origin, first.get(10, TimeUnit.SECONDS)); + } + @Test public void findLocatesObjectThroughCoarseCascade() throws Exception { IrisRegion regionA = region("reg_a"); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/LocatorCancellerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/LocatorCancellerTest.java new file mode 100644 index 000000000..3ff66c04f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/LocatorCancellerTest.java @@ -0,0 +1,44 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class LocatorCancellerTest { + @Test + public void cancellingOneRequestDoesNotCancelAnother() throws Exception { + CompletableFuture firstDelegate = new CompletableFuture<>(); + CompletableFuture secondDelegate = new CompletableFuture<>(); + AtomicBoolean firstStop = new AtomicBoolean(); + AtomicBoolean secondStop = new AtomicBoolean(); + Future first = LocatorCanceller.requestScoped(firstDelegate, firstStop); + Future second = LocatorCanceller.requestScoped(secondDelegate, secondStop); + + assertTrue(first.cancel(false)); + secondDelegate.complete("found"); + + assertTrue(firstStop.get()); + assertTrue(first.isCancelled()); + assertTrue(firstDelegate.isCancelled()); + assertFalse(secondStop.get()); + assertFalse(second.isCancelled()); + assertEquals("found", second.get()); + } + + @Test + public void completedRequestCannotBeCancelled() throws Exception { + CompletableFuture delegate = CompletableFuture.completedFuture("done"); + AtomicBoolean stop = new AtomicBoolean(); + Future request = LocatorCanceller.requestScoped(delegate, stop); + + assertFalse(request.cancel(false)); + assertFalse(stop.get()); + assertEquals("done", request.get()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/TreeBlockMaterialTest.java b/core/src/test/java/art/arcane/iris/engine/framework/TreeBlockMaterialTest.java new file mode 100644 index 000000000..a494cce94 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/TreeBlockMaterialTest.java @@ -0,0 +1,16 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TreeBlockMaterialTest { + @Test + public void materialComparisonIgnoresBlockStateProperties() { + TreeBlockMaterial expected = TreeBlockMaterial.of("minecraft:oak_log[axis=y]"); + + assertTrue(expected.matches("minecraft:oak_log[axis=x]")); + assertFalse(expected.matches("minecraft:spruce_log[axis=y]")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeInferenceIsolationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeInferenceIsolationTest.java new file mode 100644 index 000000000..548e1570b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeInferenceIsolationTest.java @@ -0,0 +1,32 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import com.google.gson.Gson; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBiomeInferenceIsolationTest { + @Test + public void conflictingRolesUseStableCachedVariants() { + Gson gson = new Gson(); + IrisData data = mock(IrisData.class); + when(data.getGson()).thenReturn(gson); + IrisBiome biome = gson.fromJson("{\"name\":\"Shared\"}", IrisBiome.class); + biome.setLoader(data); + biome.setLoadKey("shared"); + + IrisBiome land = biome.withInferredType(InferredType.LAND); + IrisBiome sea = biome.withInferredType(InferredType.SEA); + + assertSame(biome, land); + assertNotSame(land, sea); + assertSame(sea, biome.withInferredType(InferredType.SEA)); + assertEquals(InferredType.LAND, land.getInferredType()); + assertEquals(InferredType.SEA, sea.getInferredType()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisExpressionLoadEngineCacheTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisExpressionLoadEngineCacheTest.java new file mode 100644 index 000000000..feb122e63 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisExpressionLoadEngineCacheTest.java @@ -0,0 +1,73 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisExpressionLoadEngineCacheTest { + @Test + public void engineValuesAreCachedPerEngineIdentity() { + Engine first = mock(Engine.class); + Engine second = mock(Engine.class); + when(first.getHeight()).thenReturn(128); + when(second.getHeight()).thenReturn(320); + AtomicReference active = new AtomicReference<>(first); + IrisData data = data(active); + IrisExpressionLoad load = new IrisExpressionLoad() + .setName("height") + .setEngineValue(IrisEngineValueType.ENGINE_HEIGHT); + + assertEquals(128D, load.getValue(new RNG(1L), data, 0D, 0D), 0D); + active.set(second); + assertEquals(320D, load.getValue(new RNG(1L), data, 0D, 0D), 0D); + } + + @Test + public void engineStreamsAreCachedPerEngineIdentity() { + Engine first = streamEngine(12D); + Engine second = streamEngine(27D); + AtomicReference active = new AtomicReference<>(first); + IrisData data = data(active); + IrisExpressionLoad load = new IrisExpressionLoad() + .setName("height") + .setEngineStreamValue(IrisEngineStreamType.HEIGHT); + + assertEquals(12D, load.getValue(new RNG(1L), data, 4D, 8D), 0D); + active.set(second); + assertEquals(27D, load.getValue(new RNG(1L), data, 4D, 8D), 0D); + } + + @Test(expected = IllegalStateException.class) + public void engineBackedValueFailsWithoutUnambiguousEngine() { + IrisData data = mock(IrisData.class); + IrisExpressionLoad load = new IrisExpressionLoad() + .setName("height") + .setEngineValue(IrisEngineValueType.ENGINE_HEIGHT); + + load.getValue(new RNG(1L), data, 0D, 0D); + } + + private IrisData data(AtomicReference active) { + IrisData data = mock(IrisData.class); + when(data.getEngine()).thenAnswer(ignored -> active.get()); + return data; + } + + private Engine streamEngine(double value) { + ProceduralStream stream = ProceduralStream.ofDouble((x, z) -> value); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getHeightStream()).thenReturn(stream); + Engine engine = mock(Engine.class); + when(engine.getComplex()).thenReturn(complex); + return engine; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisGeneratorStyleCacheIsolationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisGeneratorStyleCacheIsolationTest.java new file mode 100644 index 000000000..eff4af074 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisGeneratorStyleCacheIsolationTest.java @@ -0,0 +1,59 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisGeneratorStyleCacheIsolationTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void cacheSeparatesSeedsAndEngineIdentity() throws Exception { + Engine firstEngine = mock(Engine.class); + Engine secondEngine = mock(Engine.class); + AtomicReference activeEngine = new AtomicReference<>(firstEngine); + IrisData data = mock(IrisData.class); + when(data.getEngine()).thenAnswer(ignored -> activeEngine.get()); + when(data.getDataFolder()).thenReturn(temporaryFolder.newFolder("pack")); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.SIMPLEX); + + CNG first = style.create(new RNG(41L), data); + CNG firstAgain = style.create(new RNG(41L), data); + CNG differentSeed = style.create(new RNG(42L), data); + activeEngine.set(secondEngine); + CNG differentEngine = style.create(new RNG(41L), data); + + assertSame(first, firstAgain); + assertNotSame(first, differentSeed); + assertNotSame(first, differentEngine); + assertNotEquals(first.noise(12D, -7D), differentSeed.noise(12D, -7D), 0D); + } + + @Test + public void cacheEvictsOldEngineSeedEntries() { + IrisData data = mock(IrisData.class); + Engine engine = mock(Engine.class); + when(data.getEngine()).thenReturn(engine); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.SIMPLEX); + CNG first = style.create(new RNG(0L), data); + + for (long seed = 1L; seed <= 8L; seed++) { + style.create(new RNG(seed), data); + } + + assertNotSame(first, style.create(new RNG(0L), data)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementSurfaceWarpCacheTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementSurfaceWarpCacheTest.java new file mode 100644 index 000000000..7d2c9e5c0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementSurfaceWarpCacheTest.java @@ -0,0 +1,56 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisObjectPlacementSurfaceWarpCacheTest { + @Test + public void surfaceWarpSeparatesEngineSeeds() { + Engine first = engine(100L); + Engine second = engine(200L); + AtomicReference active = new AtomicReference<>(first); + IrisData data = mock(IrisData.class); + when(data.getEngine()).thenAnswer(ignored -> active.get()); + IrisObjectPlacement placement = placement(); + + CNG firstWarp = placement.getSurfaceWarp(new RNG(1L), data); + assertSame(firstWarp, placement.getSurfaceWarp(new RNG(999L), data)); + active.set(second); + + assertNotSame(firstWarp, placement.getSurfaceWarp(new RNG(1L), data)); + } + + @Test + public void standaloneSurfaceWarpUsesSuppliedSeed() { + IrisData data = mock(IrisData.class); + IrisObjectPlacement placement = placement(); + + CNG first = placement.getSurfaceWarp(new RNG(1L), data); + + assertSame(first, placement.getSurfaceWarp(new RNG(1L), data)); + assertNotSame(first, placement.getSurfaceWarp(new RNG(2L), data)); + } + + private IrisObjectPlacement placement() { + return new IrisObjectPlacement().setWarp(new IrisGeneratorStyle(NoiseStyle.SIMPLEX)); + } + + private Engine engine(long componentSeed) { + SeedManager seedManager = mock(SeedManager.class); + when(seedManager.getComponent()).thenReturn(componentSeed); + Engine engine = mock(Engine.class); + when(engine.getSeedManager()).thenReturn(seedManager); + return engine; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java index 8743b4ba5..4e4f5fa31 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java @@ -21,6 +21,14 @@ import static org.junit.Assert.assertTrue; public class ProceduralTreeGeneratorTest { + @Test + public void variantsUseTreeSpecificProceduralOwnershipKeys() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setName("towering-oak"); + + assertEquals("procedural/tree/towering-oak#3", tree.getVariantLoadKey(3)); + } + @Test public void trunkBuildsConnectedColumnOfRequestedHeight() { IrisProceduralTree tree = new IrisProceduralTree(); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f655d5226..e6db7990d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,7 @@ fabricApi-eventsInteraction = "5.2.6+8f57f7ee9e" fabricApi-networking = "6.3.3+72073ef09e" fabricApi-rendering = "25.3.1+6988455e9e" fabricApi-keyMapping = "2.0.5+e2bdee789e" +fabricApi-permission = "1.0.3+b7020ca29e" [libraries] # Core Libraries @@ -121,6 +122,7 @@ fabricApi-eventsInteraction = { module = "net.fabricmc.fabric-api:fabric-events- fabricApi-networking = { module = "net.fabricmc.fabric-api:fabric-networking-api-v1", version.ref = "fabricApi-networking" } fabricApi-rendering = { module = "net.fabricmc.fabric-api:fabric-rendering-v1", version.ref = "fabricApi-rendering" } fabricApi-keyMapping = { module = "net.fabricmc.fabric-api:fabric-key-mapping-api-v1", version.ref = "fabricApi-keyMapping" } +fabricApi-permission = { module = "net.fabricmc.fabric-api:fabric-permission-api-v1", version.ref = "fabricApi-permission" } [plugins] shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }