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 17990e7f4..a4ec5b763 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 @@ -23,6 +23,7 @@ import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator; import art.arcane.iris.nativegen.NativeStructureVegetationClearer; import art.arcane.iris.nativegen.NativeStructureVerticalPlacer; import art.arcane.iris.nativegen.NativeStructureVanillaLocator; +import art.arcane.iris.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.IrisCustomData; @@ -87,6 +88,7 @@ import org.bukkit.block.data.BlockData; import org.spigotmc.SpigotWorldConfig; import javax.annotation.Nullable; +import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; @@ -130,6 +132,24 @@ public class IrisChunkGenerator extends CustomChunkGenerator { this.runtimeMinY = level.getMinY(); this.runtimeHeight = level.getHeight(); this.runtimeSeaLevel = runtimeMinY + engine.getDimension().getFluidHeight(); + installNativeStructureVolumeIndex(level); + } + + private void installNativeStructureVolumeIndex(ServerLevel level) { + WeakReference levelReference = new WeakReference<>(level); + WeakReference generatorReference = new WeakReference<>(this); + WeakReference biomeSourceReference = new WeakReference<>(customBiomeSource); + NativeStructureVolumeIndex.install(engine, new NativeStructureVolumeIndex.Context( + level.registryAccess(), + level.getServer().getStructureManager(), + level.dimension(), + LevelHeightAccessor.create(runtimeMinY, runtimeHeight), + generatorReference::get, + biomeSourceReference::get, + () -> { + ServerLevel active = levelReference.get(); + return active == null ? null : active.getChunkSource().getGeneratorState(); + })); } @Override diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index f4d9e5dc6..384debbee 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -13,6 +13,8 @@ import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeStructureVolume; +import art.arcane.iris.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.nativegen.NativeStructureFactory; @@ -794,6 +796,11 @@ public class NMSBinding implements INMSBinding { return true; } + @Override + public KList nativeStructureVolumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { + return NativeStructureVolumeIndex.volumes(engine, minX, minZ, maxX, maxZ); + } + @Override public int getBiomeId(Biome biome) { for (World i : Bukkit.getWorlds()) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureTemplateOccupancyTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureTemplateOccupancyTest.java new file mode 100644 index 000000000..769c79904 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureTemplateOccupancyTest.java @@ -0,0 +1,233 @@ +package art.arcane.iris.nativegen; + +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.MapCodec; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.valueproviders.ConstantInt; +import net.minecraft.world.level.LevelReader; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlackstoneReplaceProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlockAgeProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlockIgnoreProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.CappedProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.GravityProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.JigsawReplacementProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.LavaSubmergedBlockProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.NopProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.ProtectedBlockProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.RuleProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeStructureTemplateOccupancyTest { + private static final BoundingBox PROCESSING_AREA = new BoundingBox(0, 56, 0, 15, 80, 0); + private static final int TEMPLATE_WIDTH = 32; + private static final int TERRAIN_HEIGHT = 64; + + @BeforeClass + public static void bootstrapMinecraft() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void straddlingGravityPieceReadsHeightsOnlyInsideTheProcessingArea() throws Exception { + ColumnRecorder recorder = new ColumnRecorder(); + + resolve(straddlingPiece( + List.of(new GravityProcessor(Heightmap.Types.WORLD_SURFACE_WG, 0))), recorder); + + assertEquals(columns(0, 15), recorder.heightColumns); + } + + @Test + public void straddlingPieceRunsProcessorsOnlyInsideTheProcessingArea() throws Exception { + CountingProcessor counter = new CountingProcessor(); + + resolve(straddlingPiece(List.of(counter)), new ColumnRecorder()); + + assertEquals(columns(0, 15), counter.columns); + assertTrue(counter.columns.size() < TEMPLATE_WIDTH); + } + + @Test + public void cappedStraddlingPieceRunsProcessorsAcrossTheWholePiece() throws Exception { + CountingProcessor counter = new CountingProcessor(); + + resolve(straddlingPiece(List.of(counter, + new CappedProcessor(NopProcessor.INSTANCE, ConstantInt.of(0)))), + new ColumnRecorder()); + + assertEquals(columns(0, TEMPLATE_WIDTH - 1), counter.columns); + } + + @Test + public void cappedProcessorIsTheOnlyProcessorThatDisablesTheProcessingAreaClip() { + for (StructureProcessor processor : List.of( + BlockIgnoreProcessor.STRUCTURE_BLOCK, + JigsawReplacementProcessor.INSTANCE, + LavaSubmergedBlockProcessor.INSTANCE, + NopProcessor.INSTANCE, + BlackstoneReplaceProcessor.INSTANCE, + new GravityProcessor(Heightmap.Types.WORLD_SURFACE_WG, 0), + new RuleProcessor(List.of()), + new BlockAgeProcessor(0.5F), + new ProtectedBlockProcessor(HolderSet.empty()))) { + assertFalse(processor.getClass().getName(), + processor.evaluatesEntirePieceState()); + } + assertTrue(new CappedProcessor(NopProcessor.INSTANCE, ConstantInt.of(4)) + .evaluatesEntirePieceState()); + } + + @Test + public void placementSettingsCarryTheProcessingAreaAsTheirClipBox() throws Exception { + PoolElementStructurePiece piece = straddlingPiece(List.of()); + + StructurePlaceSettings settings = NativeStructureReflection.resolvePlacementSettings( + (SinglePoolElement) piece.getElement(), piece, PROCESSING_AREA); + + assertEquals(PROCESSING_AREA, settings.getBoundingBox()); + } + + private static void resolve(PoolElementStructurePiece piece, ColumnRecorder recorder) { + NativeStructureTemplateOccupancy.resolve( + world(recorder), piece, piece.getPosition(), PROCESSING_AREA, + NativeStructureTemplateOccupancyTest::forbiddenTemplateManager, + PROCESSING_AREA::isInside, amount -> { + }); + } + + private static PoolElementStructurePiece straddlingPiece( + List processors) throws Exception { + BoundingBox bounds = new BoundingBox(0, 70, 0, TEMPLATE_WIDTH - 1, 76, 0); + return new PoolElementStructurePiece( + null, new InlineSinglePoolElement(template(), processors), + new BlockPos(bounds.minX(), bounds.minY(), bounds.minZ()), + 1, Rotation.NONE, bounds, LiquidSettings.APPLY_WATERLOGGING); + } + + private static Set columns(int minimumX, int maximumX) { + Set columns = new TreeSet<>(); + for (int x = minimumX; x <= maximumX; x++) { + columns.add(x); + } + return columns; + } + + private static StructureTemplateManager forbiddenTemplateManager() { + throw new AssertionError("Inline templates must not resolve a template manager"); + } + + private static StructureTemplate template() throws Exception { + List blocks = new ArrayList<>(); + for (int x = 0; x < TEMPLATE_WIDTH; x++) { + blocks.add(new StructureTemplate.StructureBlockInfo( + new BlockPos(x, 0, 0), Blocks.COBBLESTONE.defaultBlockState(), null)); + } + Constructor constructor = + StructureTemplate.Palette.class.getDeclaredConstructor(List.class); + assertTrue(constructor.trySetAccessible()); + StructureTemplate template = new StructureTemplate(); + template.palettes.add(constructor.newInstance(blocks)); + return template; + } + + private static WorldGenLevel world(ColumnRecorder recorder) { + InvocationHandler handler = (proxy, method, arguments) -> { + String methodName = method.getName(); + if (methodName.equals("getHeight") && arguments.length == 3) { + recorder.heightColumns.add((Integer) arguments[1]); + return TERRAIN_HEIGHT; + } + if (methodName.equals("getBlockState")) { + recorder.stateColumns.add(((BlockPos) arguments[0]).getX()); + return Blocks.STONE.defaultBlockState(); + } + if (methodName.equals("getLevel")) { + return null; + } + if (methodName.equals("holderLookup")) { + return BuiltInRegistries.BLOCK; + } + if (methodName.equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (methodName.equals("equals")) { + return proxy == arguments[0]; + } + if (methodName.equals("toString")) { + return "occupancy-test-world"; + } + throw new UnsupportedOperationException(method.toString()); + }; + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), + new Class[]{WorldGenLevel.class}, handler); + } + + private static final class ColumnRecorder { + private final Set heightColumns = new TreeSet<>(); + private final Set stateColumns = new TreeSet<>(); + } + + private static final class CountingProcessor implements StructureProcessor { + private final Set columns = new TreeSet<>(); + + @Override + public StructureTemplate.StructureBlockInfo processBlock( + LevelReader level, BlockPos targetPosition, BlockPos referencePos, + BlockPos templateRelativePos, + StructureTemplate.StructureBlockInfo processedBlockInfo, + StructurePlaceSettings settings) { + columns.add(processedBlockInfo.pos().getX()); + return processedBlockInfo; + } + + @Override + public MapCodec codec() { + return BlockIgnoreProcessor.STRUCTURE_BLOCK.codec(); + } + } + + private static final class InlineSinglePoolElement extends SinglePoolElement { + private InlineSinglePoolElement( + StructureTemplate template, List processors) { + super(Either.right(template), + Holder.direct(new StructureProcessorList(processors)), + StructureTemplatePool.Projection.RIGID, + Optional.empty()); + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java new file mode 100644 index 000000000..edda00a7b --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java @@ -0,0 +1,160 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeStructureVolume; +import art.arcane.volmlib.util.collection.KList; +import net.minecraft.SharedConstants; +import net.minecraft.core.HolderSet; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; +import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class NativeStructureVolumeIndexTest { + private static final String STRUCTURE_KEY = "minecraft:swamp_hut"; + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void assembledPiecesBecomeWorldSpacePieceVolumes() { + StructureStart start = swampHut(0, 0, 4, 6); + BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); + + KList volumes = NativeStructureVolumeIndex.appendPieces(null, STRUCTURE_KEY, start); + + assertEquals(1, volumes.size()); + NativeStructureVolume volume = volumes.getFirst(); + assertEquals(STRUCTURE_KEY, volume.structure()); + assertEquals(bounds.minX(), volume.minX()); + assertEquals(bounds.minY(), volume.minY()); + assertEquals(bounds.minZ(), volume.minZ()); + assertEquals(bounds.maxX(), volume.maxX()); + assertEquals(bounds.maxY(), volume.maxY()); + assertEquals(bounds.maxZ(), volume.maxZ()); + } + + @Test + public void invalidStartsContributeNoVolumes() { + assertNull(NativeStructureVolumeIndex.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START)); + assertNull(NativeStructureVolumeIndex.appendPieces(null, STRUCTURE_KEY, null)); + } + + @Test + public void coldAndCachedQueriesResolveIdenticalVolumes() { + CountingResolver resolver = new CountingResolver(0, 0); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); + + KList cold = index.resolve(null, 0, 0, 15, 15); + int coldResolutions = resolver.resolutions(); + KList cached = index.resolve(null, 0, 0, 15, 15); + + assertTrue(coldResolutions > 0); + assertFalse(cold.isEmpty()); + assertEquals(cold, cached); + assertEquals(coldResolutions, resolver.resolutions()); + } + + @Test + public void independentIndexesResolveIdenticalVolumesFromTheSameSeed() { + NativeStructureVolumeIndex first = NativeStructureVolumeIndex.forTesting(new CountingResolver(0, 0)); + NativeStructureVolumeIndex second = NativeStructureVolumeIndex.forTesting(new CountingResolver(0, 0)); + + assertEquals(first.resolve(null, 0, 0, 15, 15), second.resolve(null, 0, 0, 15, 15)); + assertEquals(first.resolve(null, -32, -32, 47, 47), second.resolve(null, -32, -32, 47, 47)); + } + + @Test + public void volumesOutsideTheQueryRectAreExcluded() { + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(new CountingResolver(0, 0)); + BoundingBox bounds = swampHut(0, 0, 4, 6).getPieces().getFirst().getBoundingBox(); + + KList hit = index.resolve(null, bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ()); + KList miss = index.resolve(null, + bounds.maxX() + 1, bounds.minZ(), bounds.maxX() + 8, bounds.maxZ()); + + assertEquals(1, hit.size()); + assertTrue(miss.isEmpty()); + } + + @Test + public void startsWithinTheOriginReachContributeAndBeyondItDoNot() { + int reach = NativeStructureVolumeIndex.originReachChunks(); + NativeStructureVolumeIndex reachable = NativeStructureVolumeIndex.forTesting(new CountingResolver(reach, 0)); + NativeStructureVolumeIndex unreachable = NativeStructureVolumeIndex.forTesting(new CountingResolver(reach + 1, 0)); + + assertFalse(reachable.resolve(null, 0, 0, 15, 15).isEmpty()); + assertTrue(unreachable.resolve(null, 0, 0, 15, 15).isEmpty()); + } + + @Test + public void volumeResolutionNeverReadsChunkOrOwnershipState() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.nativeStructureVolumeIndexSource"))); + + assertFalse(source.contains("StructureManager")); + assertFalse(source.contains("getStartForStructure")); + assertFalse(source.contains("getAllStarts")); + assertFalse(source.contains("ChunkAccess")); + assertFalse(source.contains("NativeStructureOwnershipStore")); + assertFalse(source.contains("NativeStructureOwnershipRecovery")); + } + + @Test + public void vanillaVolumesAreGatedOnPackPolicy() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.nativeStructureVolumeIndexSource"))); + + assertTrue(source.contains("NativeStructureGenerationPolicy.resolve(")); + assertTrue(source.contains("if (!decision.generate())")); + assertTrue(source.contains("isStructureChunk(")); + } + + private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) { + Structure source = new SwampHutStructure(new Structure.StructureSettings(HolderSet.empty())); + SwampHutPiece piece = new SwampHutPiece(RandomSource.create(17L), x, z); + return new StructureStart(source, new ChunkPos(chunkX, chunkZ), 0, new PiecesContainer(List.of(piece))); + } + + private static final class CountingResolver implements NativeStructureVolumeIndex.OriginResolver { + private final AtomicInteger resolutions = new AtomicInteger(); + private final int originChunkX; + private final int originChunkZ; + + private CountingResolver(int originChunkX, int originChunkZ) { + this.originChunkX = originChunkX; + this.originChunkZ = originChunkZ; + } + + private int resolutions() { + return resolutions.get(); + } + + @Override + public KList volumesAt(Engine engine, int chunkX, int chunkZ) { + resolutions.incrementAndGet(); + if (chunkX != originChunkX || chunkZ != originChunkZ) { + return NativeStructureVolume.NONE; + } + return NativeStructureVolumeIndex.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); + } + } +} 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 a9301e305..9f03179f5 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 @@ -23,6 +23,7 @@ import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.events.IrisEngineHotloadEvent; import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.project.IrisCodeWorkspace; import art.arcane.iris.core.service.IrisApiEventSVC; @@ -31,15 +32,22 @@ import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineMode; import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.framework.NativeStructureVolume; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KList; import org.bukkit.World; public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { + @Override + public KList nativeStructureVolumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { + return INMS.get().nativeStructureVolumes(engine, minX, minZ, maxX, maxZ); + } + @Override public void refreshWorkspace(Engine engine) { new IrisCodeWorkspace(new IrisProject(engine.getData().getDataFolder())).updateWorkspace(); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java new file mode 100644 index 000000000..7d39615ce --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java @@ -0,0 +1,385 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; +import art.arcane.iris.engine.framework.NativeStructureStartPlan; +import art.arcane.iris.engine.framework.NativeStructureVolume; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.collection.KList; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.IntBinaryOperator; +import java.util.function.Supplier; + +/** + * Resolves the world-space piece bounds of every native structure that will generate around a query rect. + * + *

The answer is derived from the seed, the registries and this pack's structure policy only: candidate origins + * come from the placement grid and from vanilla structure placements, and each candidate is assembled through the + * same {@link NativeStructureFactory} path chunk generation uses. Nothing here reads chunk state, ownership records + * or generation progress, so the same query answers identically no matter which chunks already exist. + */ +public final class NativeStructureVolumeIndex { + private static final int ORIGIN_REACH_CHUNKS = NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS; + private static final int MAX_CACHED_ORIGIN_CHUNKS = 16_384; + private static final int MAX_CACHED_QUERY_CHUNKS = 4_096; + private static final Map INDEXES = + Collections.synchronizedMap(new WeakHashMap<>()); + private static final Set WARNED_RESOLUTION_FAILURES = ConcurrentHashMap.newKeySet(); + + private final Context context; + private final OriginResolver origins; + private final Map> originCache = lru(MAX_CACHED_ORIGIN_CHUNKS); + private final Map> queryCache = lru(MAX_CACHED_QUERY_CHUNKS); + private final ConcurrentHashMap>> originBuilds = + new ConcurrentHashMap<>(); + private final ConcurrentHashMap>> queryBuilds = + new ConcurrentHashMap<>(); + + private NativeStructureVolumeIndex(Context context) { + this.context = Objects.requireNonNull(context, "Native structure volume context must not be null"); + this.origins = this::buildOriginVolumes; + } + + private NativeStructureVolumeIndex(OriginResolver origins) { + this.context = null; + this.origins = Objects.requireNonNull(origins, "Native structure origin resolver must not be null"); + } + + public static void install(Engine engine, Context context) { + Objects.requireNonNull(engine, "Native structure volume index requires an engine"); + INDEXES.put(engine, new NativeStructureVolumeIndex(context)); + } + + public static void uninstall(Engine engine) { + if (engine != null) { + INDEXES.remove(engine); + } + } + + public static KList volumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { + NativeStructureVolumeIndex index = engine == null ? null : INDEXES.get(engine); + return index == null ? NativeStructureVolume.NONE : index.resolve(engine, minX, minZ, maxX, maxZ); + } + + static NativeStructureVolumeIndex forTesting(OriginResolver origins) { + return new NativeStructureVolumeIndex(origins); + } + + static int originReachChunks() { + return ORIGIN_REACH_CHUNKS; + } + + KList resolve(Engine engine, int minX, int minZ, int maxX, int maxZ) { + int fromChunkX = Math.min(minX, maxX) >> 4; + int toChunkX = Math.max(minX, maxX) >> 4; + int fromChunkZ = Math.min(minZ, maxZ) >> 4; + int toChunkZ = Math.max(minZ, maxZ) >> 4; + KList matches = null; + for (int chunkX = fromChunkX; chunkX <= toChunkX; chunkX++) { + for (int chunkZ = fromChunkZ; chunkZ <= toChunkZ; chunkZ++) { + for (NativeStructureVolume volume : chunkVolumes(engine, chunkX, chunkZ)) { + if (!volume.intersectsRect(minX, minZ, maxX, maxZ)) { + continue; + } + if (matches == null) { + matches = new KList<>(); + } + if (!matches.contains(volume)) { + matches.add(volume); + } + } + } + } + return matches == null ? NativeStructureVolume.NONE : matches; + } + + private KList chunkVolumes(Engine engine, int chunkX, int chunkZ) { + return cached(queryCache, queryBuilds, chunkKey(chunkX, chunkZ), + () -> buildChunkVolumes(engine, chunkX, chunkZ)); + } + + private KList buildChunkVolumes(Engine engine, int chunkX, int chunkZ) { + int minX = chunkX << 4; + int minZ = chunkZ << 4; + int maxX = minX + 15; + int maxZ = minZ + 15; + KList volumes = null; + for (int originX = chunkX - ORIGIN_REACH_CHUNKS; originX <= chunkX + ORIGIN_REACH_CHUNKS; originX++) { + for (int originZ = chunkZ - ORIGIN_REACH_CHUNKS; originZ <= chunkZ + ORIGIN_REACH_CHUNKS; originZ++) { + for (NativeStructureVolume volume : originVolumes(engine, originX, originZ)) { + if (!volume.intersectsRect(minX, minZ, maxX, maxZ)) { + continue; + } + if (volumes == null) { + volumes = new KList<>(); + } + volumes.add(volume); + } + } + } + return volumes == null ? NativeStructureVolume.NONE : volumes; + } + + KList originVolumes(Engine engine, int chunkX, int chunkZ) { + return cached(originCache, originBuilds, chunkKey(chunkX, chunkZ), + () -> origins.volumesAt(engine, chunkX, chunkZ)); + } + + private KList buildOriginVolumes(Engine engine, int chunkX, int chunkZ) { + ChunkGeneratorStructureState state = context.structureState().get(); + ChunkGenerator generator = context.generator().get(); + BiomeSource biomeSource = context.biomeSource().get(); + if (state == null || generator == null || biomeSource == null) { + return NativeStructureVolume.NONE; + } + Registry registry = context.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Bindings bindings = new Bindings(state, generator, biomeSource); + KList volumes = appendPlannedVolumes(engine, bindings, registry, chunkX, chunkZ, null); + volumes = appendVanillaVolumes(engine, bindings, registry, chunkX, chunkZ, volumes); + return volumes == null ? NativeStructureVolume.NONE : volumes; + } + + private KList appendPlannedVolumes(Engine engine, Bindings bindings, + Registry registry, int chunkX, int chunkZ, + KList volumes) { + KList plans; + try { + plans = NativeStructurePlacementPlanner.plansAt(engine, chunkX, chunkZ); + } catch (RuntimeException | Error error) { + warnOnce("iris-placements", error); + return volumes; + } + + KList target = volumes; + for (NativeStructureStartPlan plan : plans) { + IrisNativeStructureDecision decision = NativeStructurePlacementPlanner.decisionFor(plan); + if (!decision.generate()) { + continue; + } + Identifier identifier = Identifier.tryParse(plan.source().getStructure()); + if (identifier == null) { + continue; + } + Structure structure = registry.getValue(identifier); + if (structure == null) { + continue; + } + String structureId = identifier.toString(); + try { + StructureStart generated = NativeStructureFactory.generate( + generationContext(engine, bindings), registry.wrapAsHolder(structure), plan, 0); + target = appendPieces(target, structureId, generated); + } catch (RuntimeException | Error error) { + warnOnce(structureId, error); + } + } + return target; + } + + private KList appendVanillaVolumes(Engine engine, Bindings bindings, + Registry registry, int chunkX, int chunkZ, + KList volumes) { + ChunkPos chunkPos = new ChunkPos(chunkX, chunkZ); + IntBinaryOperator surfaceHeight = surfaceHeight(engine); + int worldMinY = context.heightAccessor().getMinY(); + int worldMaxYExclusive = worldMinY + context.heightAccessor().getHeight(); + KList target = volumes; + for (Holder setHolder : bindings.state().possibleStructureSets()) { + StructureSet set = setHolder.value(); + if (!set.placement().isStructureChunk(bindings.state(), chunkX, chunkZ)) { + continue; + } + for (StructureSet.StructureSelectionEntry entry : set.structures()) { + Holder holder = entry.structure(); + Structure structure = holder.value(); + Identifier identifier = registry.getKey(structure); + if (identifier == null) { + continue; + } + String structureId = identifier.toString(); + boolean undergroundStep = NativeStructureVegetationClearer.isUndergroundStep(structure.step()); + try { + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve( + engine, structureId, undergroundStep); + if (!decision.generate()) { + continue; + } + StructureStart generated = structure.generate( + holder, + context.levelKey(), + context.registryAccess(), + bindings.generator(), + bindings.biomeSource(), + bindings.state().randomState(), + context.templateManager(), + bindings.state().getLevelSeed(), + chunkPos, + 0, + context.heightAccessor(), + structure.biomes()::contains + ); + if (generated == null || !generated.isValid()) { + continue; + } + NativeStructureVerticalPlacer.applyVerticalPlacement( + generated, + structureId, + decision.yShift(), + bindings.generator().getSeaLevel(), + worldMinY, + worldMaxYExclusive, + undergroundStep, + decision.preserveSourceY(), + decision.yBand(), + surfaceHeight); + target = appendPieces(target, structureId, generated); + } catch (RuntimeException | Error error) { + warnOnce(structureId, error); + } + } + } + return target; + } + + private NativeStructureFactory.GenerationContext generationContext(Engine engine, Bindings bindings) { + return new NativeStructureFactory.GenerationContext( + context.registryAccess(), + bindings.generator(), + bindings.biomeSource(), + bindings.state().randomState(), + context.templateManager(), + bindings.state().getLevelSeed(), + context.levelKey(), + context.heightAccessor(), + biome -> true, + bindings.generator().getSeaLevel(), + surfaceHeight(engine) + ); + } + + private record Bindings(ChunkGeneratorStructureState state, ChunkGenerator generator, BiomeSource biomeSource) { + } + + @FunctionalInterface + interface OriginResolver { + KList volumesAt(Engine engine, int chunkX, int chunkZ); + } + + private static IntBinaryOperator surfaceHeight(Engine engine) { + return (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight(); + } + + static KList appendPieces(KList volumes, + String structureId, StructureStart start) { + if (start == null || !start.isValid()) { + return volumes; + } + KList target = volumes; + for (StructurePiece piece : start.getPieces()) { + BoundingBox bounds = piece.getBoundingBox(); + if (target == null) { + target = new KList<>(); + } + target.add(new NativeStructureVolume(structureId, + bounds.minX(), bounds.minY(), bounds.minZ(), + bounds.maxX(), bounds.maxY(), bounds.maxZ())); + } + return target; + } + + private KList cached(Map> cache, + ConcurrentHashMap>> builds, + long key, Supplier> loader) { + synchronized (cache) { + KList hit = cache.get(key); + if (hit != null) { + return hit; + } + } + + CompletableFuture> future = new CompletableFuture<>(); + CompletableFuture> existing = builds.putIfAbsent(key, future); + if (existing != null) { + return existing.join(); + } + + try { + KList built = loader.get(); + synchronized (cache) { + cache.put(key, built); + } + future.complete(built); + return built; + } catch (RuntimeException | Error error) { + future.completeExceptionally(error); + throw error; + } finally { + builds.remove(key, future); + } + } + + private static void warnOnce(String structureId, Throwable error) { + if (WARNED_RESOLUTION_FAILURES.add(structureId)) { + IrisLogging.warn("Native structure volume resolution failed for '" + structureId + + "'; objects will not be vetoed against it: " + + error.getClass().getSimpleName() + ":" + error.getMessage()); + } + } + + private static long chunkKey(int chunkX, int chunkZ) { + return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL); + } + + private static Map> lru(int capacity) { + return new LinkedHashMap<>(16, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > capacity; + } + }; + } + + /** + * The generator, biome source and structure state arrive as suppliers so an installed index never holds the + * level (and through it the engine) strongly. That keeps the weakly keyed registry collectable on world unload. + */ + public record Context( + RegistryAccess registryAccess, + StructureTemplateManager templateManager, + ResourceKey levelKey, + LevelHeightAccessor heightAccessor, + Supplier generator, + Supplier biomeSource, + Supplier structureState + ) { + } +} 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 becbab540..d2dd908a6 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 @@ -350,6 +350,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { requireCompletedShutdown(engine); unloading = false; Engine bound = bindEngine(level); + nativeStructures.installVolumeIndex(level, bound); // Bind time: a feature-order cycle is reported here, once, and degrades to features-off. Non-waiting for // the same reason as repointAndBind: this method owns the generator monitor. importedFeatures.prepareWithoutWaiting(bound); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java index 8180c4bcb..d29ac2ea8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java @@ -36,6 +36,7 @@ import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator; import art.arcane.iris.nativegen.NativeStructureVegetationClearer; import art.arcane.iris.nativegen.NativeStructureVerticalPlacer; import art.arcane.iris.nativegen.NativeStructureVanillaLocator; +import art.arcane.iris.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.math.RNG; @@ -50,10 +51,13 @@ import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.StructureManager; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.RandomSupport; import net.minecraft.world.level.levelgen.WorldgenRandom; @@ -63,6 +67,7 @@ import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -87,6 +92,23 @@ final class ModdedNativeStructureStage { this.generator = generator; } + void installVolumeIndex(ServerLevel level, Engine engine) { + WeakReference levelReference = new WeakReference<>(level); + WeakReference generatorReference = new WeakReference<>(generator); + WeakReference biomeSourceReference = new WeakReference<>(generator.structureBiomeSource); + NativeStructureVolumeIndex.install(engine, new NativeStructureVolumeIndex.Context( + level.registryAccess(), + level.getServer().getStructureManager(), + level.dimension(), + LevelHeightAccessor.create(level.getMinY(), level.getHeight()), + generatorReference::get, + biomeSourceReference::get, + () -> { + ServerLevel active = levelReference.get(); + return active == null ? null : active.getChunkSource().getGeneratorState(); + })); + } + Pair> findNearestIrisStructure(ServerLevel level, HolderSet holders, BlockPos pos, int radius, boolean findUnexplored, 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 8c544aff8..91737b66b 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 @@ -23,6 +23,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.framework.NativeStructureVolume; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; @@ -30,6 +31,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.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.modded.command.ModdedPregenJob; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.ReactiveFolder; @@ -88,6 +90,11 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, writeWorkspace(engine, "workspace refresh"); } + @Override + public KList nativeStructureVolumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { + return NativeStructureVolumeIndex.volumes(engine, minX, minZ, maxX, maxZ); + } + @Override public void refreshDatapackWorkspace(Engine engine) { IrisData data = engine.getData(); diff --git a/build.gradle b/build.gradle index bea54c9c2..e363fa2ad 100644 --- a/build.gradle +++ b/build.gradle @@ -127,6 +127,8 @@ nmsBindings.each { key, value -> rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java').absolutePath) systemProperty('iris.nativeStructureGenerationKeysSource', rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationKeys.java').absolutePath) + systemProperty('iris.nativeStructureVolumeIndexSource', + rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java').absolutePath) systemProperty('iris.customBiomeSource', rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/CustomBiomeSource.java").absolutePath) systemProperty('iris.vanillaStructureBiomesSource', diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index e6df63d36..798a8102c 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -80,6 +80,7 @@ import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.volmlib.util.localization.MessageArgument; public class ServerConfigurator { private static final Object DATAPACK_INSTALL_LOCK = new Object(); + private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace"; public static void configure() { IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration(); @@ -266,50 +267,71 @@ public class ServerConfigurator { public static DatapackInstallResult installDataPacksIfChanged(boolean fullInstall) { synchronized (DATAPACK_INSTALL_LOCK) { File packsDir = IrisPlatforms.get().dataFolder("packs"); - String current; + File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint"); + FingerprintCache cached = readFingerprintCache(cacheFile.toPath()); + PackFingerprint fingerprint; try { - current = computePackFingerprint(packsDir); + fingerprint = resolvePackFingerprint(packsDir, cached.metadata(), cached.content()); } catch (RuntimeException exception) { IrisLogging.reportError("Unable to fingerprint Iris packs safely", exception); return DatapackInstallResult.failedResult(); } - File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint"); - String cached = ""; - if (cacheFile.exists()) { - try { - cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim(); - } catch (IOException e) { - cached = ""; + String current = fingerprint.content(); + if (!current.isEmpty() && current.equals(cached.content())) { + if (!fingerprint.metadata().equals(cached.metadata())) { + writeFingerprintCache(cacheFile.toPath(), fingerprint); } - } - if (!current.isEmpty() && current.equals(cached)) { IrisLogging.debug("Data packs unchanged, skipping install."); return DatapackInstallResult.unchangedResult(); } DatapackInstallResult result = installDataPacksLocked(resolveDataFixer(), fullInstall); if (result.succeeded()) { - try { - writeFingerprintAtomic(cacheFile.toPath(), current); - } catch (IOException e) { - IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage()); - } + writeFingerprintCache(cacheFile.toPath(), fingerprint); } return result; } } - public static String computePackFingerprint(File packsDir) { - if (packsDir == null) { + static PackFingerprint resolvePackFingerprint(File packsDir, String cachedMetadata, String cachedContent) { + String metadata = computePackMetadataDigest(packsDir); + if (!metadata.isEmpty() + && metadata.equals(cachedMetadata) + && cachedContent != null + && !cachedContent.isEmpty()) { + return new PackFingerprint(metadata, cachedContent); + } + return new PackFingerprint(metadata, computePackFingerprint(packsDir)); + } + + public static String computePackMetadataDigest(File packsDir) { + Path root = resolveFingerprintRoot(packsDir); + if (root == null) { return ""; } - Path root = packsDir.toPath().toAbsolutePath().normalize(); - if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { - return ""; - } - if (!Files.isDirectory(root)) { - if (Files.isSymbolicLink(root)) { - throw new IllegalArgumentException("Iris packs root target is missing or unsafe: " + root); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + List entries = collectFingerprintEntries(root.toRealPath()); + entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); + for (FingerprintEntry entry : entries) { + BasicFileAttributes attributes = Files.readAttributes( + entry.source(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8); + updateDigestInt(digest, relativePath.length); + digest.update(relativePath); + updateDigestLong(digest, attributes.size()); + updateDigestLong(digest, attributes.lastModifiedTime().toMillis()); } + return HexFormat.of().formatHex(digest.digest()); + } catch (IOException exception) { + throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + public static String computePackFingerprint(File packsDir) { + Path root = resolveFingerprintRoot(packsDir); + if (root == null) { return ""; } try { @@ -340,6 +362,45 @@ public class ServerConfigurator { } } + private static Path resolveFingerprintRoot(File packsDir) { + if (packsDir == null) { + return null; + } + Path root = packsDir.toPath().toAbsolutePath().normalize(); + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.isDirectory(root)) { + if (Files.isSymbolicLink(root)) { + throw new IllegalArgumentException("Iris packs root target is missing or unsafe: " + root); + } + return null; + } + return root; + } + + private static FingerprintCache readFingerprintCache(Path cacheFile) { + if (!Files.isRegularFile(cacheFile)) { + return new FingerprintCache("", ""); + } + try { + List lines = Files.readAllLines(cacheFile, StandardCharsets.UTF_8); + String content = lines.isEmpty() ? "" : lines.getFirst().trim(); + String metadata = lines.size() > 1 ? lines.get(1).trim() : ""; + return new FingerprintCache(content, metadata); + } catch (IOException e) { + return new FingerprintCache("", ""); + } + } + + private static void writeFingerprintCache(Path cacheFile, PackFingerprint fingerprint) { + try { + writeFingerprintAtomic(cacheFile, fingerprint.content() + "\n" + fingerprint.metadata()); + } catch (IOException e) { + IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage()); + } + } + private static void writeFingerprintAtomic(Path target, String fingerprint) throws IOException { Path absoluteTarget = target.toAbsolutePath().normalize(); Path parent = absoluteTarget.getParent(); @@ -368,7 +429,7 @@ public class ServerConfigurator { try (Stream children = Files.list(root)) { for (Path child : children.toList()) { String childName = child.getFileName().toString(); - if (PackDirectoryResolver.isHiddenName(childName)) { + if (PackDirectoryResolver.isHiddenName(childName) || isGeneratedPackFile(childName)) { continue; } if (Files.isSymbolicLink(child)) { @@ -406,7 +467,8 @@ public class ServerConfigurator { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - if (PackDirectoryResolver.isHiddenName(file.getFileName().toString())) { + String fileName = file.getFileName().toString(); + if (PackDirectoryResolver.isHiddenName(fileName) || isGeneratedPackFile(fileName)) { return FileVisitResult.CONTINUE; } if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { @@ -427,9 +489,19 @@ public class ServerConfigurator { }); } + private static boolean isGeneratedPackFile(String name) { + return name != null && name.endsWith(CODE_WORKSPACE_SUFFIX); + } + private record FingerprintEntry(Path source, String relativePath) { } + record PackFingerprint(String metadata, String content) { + } + + private record FingerprintCache(String content, String metadata) { + } + private static void updateDigestInt(MessageDigest digest, int value) { for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { digest.update((byte) (value >>> shift)); diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index 12509ae02..cb4716bd1 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -352,9 +352,13 @@ public final class DatapackIngestService { boolean successful = true; for (Entry entry : manifest.entries) { File stagedDir = new File(stagingDir, entry.id); + if (isRecordedUnchangedInstall(stagedDir, worldFolders, entry, stripOverrides)) { + continue; + } if (!isUsableStaging(stagedDir, entry)) { IrisLogging.error("Managed datapack staging is unusable for '" + entry.id + "' at " + stagedDir.getPath()); + forgetInstallMetadata(entry); successful = false; continue; } @@ -364,8 +368,10 @@ public final class DatapackIngestService { IrisLogging.warn("Repaired installed datapack '" + entry.id + "' from Iris staging before datapack compilation."); } + recordInstallMetadata(stagedDir, worldFolders, entry); } catch (IOException e) { IrisLogging.reportError(e); + forgetInstallMetadata(entry); successful = false; } } @@ -373,6 +379,127 @@ public final class DatapackIngestService { return successful; } + private static boolean isRecordedUnchangedInstall( + File stagedDir, + KList worldFolders, + Entry entry, + boolean stripOverrides + ) { + if (entry.stagingMetadata == null || entry.stagingMetadata.isBlank() + || entry.installMetadata == null || entry.installMetadata.size() != worldFolders.size()) { + return false; + } + try { + if (!isRecordedManagedDirectory(stagedDir, entry) + || !entry.stagingMetadata.equals(metadataDigest(stagedDir))) { + return false; + } + for (File worldFolder : worldFolders) { + File target = new File(worldFolder, entry.id); + if (!isRecordedManagedDirectory(target, entry) + || new File(target, OVERRIDES_STRIPPED_MARKER).isFile() != stripOverrides) { + return false; + } + String recorded = entry.installMetadata.get(installMetadataKey(target)); + if (recorded == null || !recorded.equals(metadataDigest(target))) { + return false; + } + } + return true; + } catch (IOException e) { + IrisLogging.debug("Managed datapack '" + entry.id + + "' requires full verification: " + e.getMessage()); + return false; + } + } + + private static boolean isRecordedManagedDirectory(File directory, Entry entry) throws IOException { + Path path = directory.toPath(); + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) { + return false; + } + if (!new File(directory, "pack.mcmeta").isFile()) { + return false; + } + Ownership ownership = readOwnershipOrNull(directory); + return ownership != null + && ownershipSourceMatches(ownership, entry) + && Objects.equals(ownership.versionId, entry.versionId) + && Objects.equals(ownership.versionNumber, entry.versionNumber) + && Objects.equals(ownership.sha1, entry.sha1); + } + + private static void recordInstallMetadata(File stagedDir, KList worldFolders, Entry entry) { + try { + Map recorded = new HashMap<>(); + for (File worldFolder : worldFolders) { + File target = new File(worldFolder, entry.id); + recorded.put(installMetadataKey(target), metadataDigest(target)); + } + entry.stagingMetadata = metadataDigest(stagedDir); + entry.installMetadata = recorded; + } catch (IOException e) { + IrisLogging.debug("Unable to record managed datapack metadata for '" + entry.id + + "': " + e.getMessage()); + forgetInstallMetadata(entry); + } + } + + private static void forgetInstallMetadata(Entry entry) { + entry.stagingMetadata = ""; + entry.installMetadata = new HashMap<>(); + } + + private static String installMetadataKey(File target) { + return target.toPath().toAbsolutePath().normalize().toString(); + } + + private static String metadataDigest(File root) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + Path rootPath = root.toPath().toAbsolutePath().normalize(); + List entries = new ArrayList<>(); + try (Stream paths = Files.walk(rootPath)) { + Iterator iterator = paths.iterator(); + int pathCount = 0; + while (iterator.hasNext()) { + Path path = iterator.next(); + if (path.equals(rootPath) || isFinderMetadata(path)) { + continue; + } + pathCount++; + if (pathCount > MAX_MANAGED_PATHS) { + throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths"); + } + if (Files.isSymbolicLink(path)) { + throw new IOException("Datapack contains a symbolic link: " + path); + } + entries.add(path); + } + } + entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString())); + for (Path entry : entries) { + String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/'); + byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8); + BasicFileAttributes attributes = Files.readAttributes( + entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attributes.isDirectory() && !attributes.isRegularFile()) { + throw new IOException("Datapack contains an unsupported filesystem entry: " + entry); + } + digest.update((byte) (attributes.isDirectory() ? 1 : 2)); + updateDigestInt(digest, relativeBytes.length); + digest.update(relativeBytes); + if (!attributes.isDirectory()) { + updateDigestLong(digest, attributes.size()); + updateDigestLong(digest, attributes.lastModifiedTime().toMillis()); + } + } + return hex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-256 algorithm unavailable", e); + } + } + static boolean recoverBeforeReapply(File root, List worldFolders) { try { recoverTransactions(root, worldFolders); @@ -1166,6 +1293,7 @@ public final class DatapackIngestService { } private static void recordInstallResult(VolmitSender sender, Report report, Entry entry, InstallResult result, String versionNumber) { + forgetInstallMetadata(entry); if (result.changed()) { report.updated.add(entry.id + " (" + safe(versionNumber) + ")"); report.requiresRestart = true; @@ -2805,8 +2933,11 @@ public final class DatapackIngestService { copy.lastModified = resolved.lastModified; copy.installedEpoch = resolved.installedEpoch; copy.structuresImported = resolved.structuresImported; + copy.stagingMetadata = resolved.stagingMetadata; copy.structureKeys = new ArrayList<>(copyList(resolved.structureKeys)); copy.templateKeys = new ArrayList<>(copyList(resolved.templateKeys)); + copy.installMetadata = new HashMap<>(Objects.requireNonNullElseGet( + resolved.installMetadata, Map::of)); copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet( resolved.importedTargets, Map::of)); copy.importedBundles = new HashMap<>(); @@ -2915,6 +3046,8 @@ public final class DatapackIngestService { } entry.structureKeys = normalizeKeys(entry.structureKeys); entry.templateKeys = normalizeKeys(entry.templateKeys); + entry.stagingMetadata = entry.stagingMetadata == null ? "" : entry.stagingMetadata.trim(); + entry.installMetadata = normalizeImportedTargets(entry.installMetadata); entry.importedTargets = normalizeImportedTargets(entry.importedTargets); entry.importedBundles = normalizeImportedBundles(entry.importedBundles); if (!urls.add(entry.url) || !ids.add(entry.id)) { @@ -4472,8 +4605,10 @@ public final class DatapackIngestService { public String lastModified; public long installedEpoch; public boolean structuresImported; + public String stagingMetadata = ""; public List structureKeys = new ArrayList<>(); public List templateKeys = new ArrayList<>(); + public Map installMetadata = new HashMap<>(); public Map importedTargets = new HashMap<>(); public Map> importedBundles = new HashMap<>(); } diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 79cf39cf4..49ba51533 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -26,6 +26,7 @@ import art.arcane.iris.core.nms.container.BlockProperty; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeStructureVolume; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata; @@ -143,6 +144,14 @@ public interface INMSBinding { return false; } + /** + * World-space piece bounds of every native structure that will generate inside the given XZ rect. Bindings + * without native structure support answer with no volumes, which leaves the object veto inert. + */ + default KList nativeStructureVolumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { + return NativeStructureVolume.NONE; + } + int getBiomeId(Biome biome); MCABiomeContainer newBiomeContainer(int min, int max, int[] data); diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java b/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java index 0404d824a..7ce539ad4 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java @@ -35,7 +35,6 @@ import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.localization.MessageArgument; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import org.dom4j.Document; import org.dom4j.Element; @@ -43,7 +42,11 @@ import java.awt.Desktop; import java.awt.GraphicsEnvironment; import java.io.File; import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.UUID; @SuppressWarnings("ALL") @@ -129,10 +132,7 @@ public class IrisCodeWorkspace { File ws = getCodeWorkspaceFile(); try { - PrecisionStopwatch p = PrecisionStopwatch.start(); - JSONObject j = createCodeWorkspaceConfig(); - IO.writeAll(ws, j.toString(4)); - p.end(); + writeIfChanged(ws, createCodeWorkspaceConfig().toString(4)); return true; } catch (Throwable e) { IrisLogging.reportError(e); @@ -153,6 +153,13 @@ public class IrisCodeWorkspace { return createCodeWorkspaceConfig(true); } + private static void writeIfChanged(File target, String rendered) throws IOException { + if (target.isFile() && (rendered + "\n").equals(IO.readAll(target))) { + return; + } + IO.writeAll(target, rendered); + } + private JSONObject createCodeWorkspaceConfig(boolean includeSchemas) { JSONObject ws = new JSONObject(); JSONArray folders = new JSONArray(); @@ -184,16 +191,17 @@ public class IrisCodeWorkspace { settings.put("[json]", jc); settings.put("json.maxItemsComputed", 30000); JSONArray schemas = new JSONArray(); + List schemaEntries = new ArrayList<>(); IrisData dm = null; if (includeSchemas) { dm = IrisData.get(project.getPath()); for (ResourceLoader r : dm.getLoaders().v()) { if (r.supportsSchemas()) { - schemas.put(r.buildSchema()); + schemaEntries.add(r.buildSchema()); } } - for (Class i : dm.resolveSnippets()) { + for (Class i : sortedSnippets(dm.resolveSnippets())) { try { String snipType = i.getDeclaredAnnotation(Snippet.class).value(); JSONObject o = new JSONObject(); @@ -205,7 +213,7 @@ public class IrisCodeWorkspace { o.put("fileMatch", new JSONArray(fm.toArray())); o.put("url", "./.iris/schema/snippet/" + snipType + "-schema.json"); - schemas.put(o); + schemaEntries.add(o); IrisData snippetData = dm; File a = new File(snippetData.getDataFolder(), ".iris/schema/snippet/" + snipType + "-schema.json"); J.attemptAsync(() -> { @@ -219,6 +227,11 @@ public class IrisCodeWorkspace { e.printStackTrace(); } } + + schemaEntries.sort(Comparator.comparing(entry -> entry.getString("url"))); + for (JSONObject entry : schemaEntries) { + schemas.put(entry); + } } settings.put("json.schemas", schemas); @@ -295,4 +308,10 @@ public class IrisCodeWorkspace { } return ws; } + + private static List> sortedSnippets(Set> snippets) { + List> sorted = new ArrayList<>(snippets); + sorted.sort(Comparator.comparing(Class::getName)); + return sorted; + } } 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 b54fb5b64..01a541899 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -25,6 +25,9 @@ import art.arcane.iris.engine.framework.EngineEffects; import art.arcane.iris.engine.framework.EngineMetrics; import art.arcane.iris.engine.framework.EngineMode; import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.framework.NativeStructureVolume; +import art.arcane.iris.engine.framework.NativeStructureVolumeMemo; +import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.GenerationSessionException; @@ -116,6 +119,9 @@ public class IrisEngine implements Engine { private final SeedManager seedManager; private final GenerationSessionManager generationSessions; private final EnginePlatformHooks platformHooks; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final NativeStructureVolumeMemo nativeStructureVolumeMemo = new NativeStructureVolumeMemo(); private final AtomicBoolean closing; @Setter(AccessLevel.NONE) volatile IrisEngineData engineData; @@ -316,9 +322,15 @@ public class IrisEngine implements Engine { } public void hotloadSilently() { + nativeStructureVolumeMemo.clear(); hotloader.hotloadSilently(); } + @Override + public KList getNativeStructureVolumes(int minX, int minZ, int maxX, int maxZ) { + return nativeStructureVolumeMemo.volumes(this, platformHooks, minX, minZ, maxX, maxZ); + } + @Override public IrisEngineData getEngineData() { return engineDataStore.getEngineData(); 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 0a025be01..cb42e385a 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 @@ -92,6 +92,15 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, EnginePlatformHooks getPlatformHooks(); + /** + * World-space native structure piece bounds overlapping the given XZ rect. The answer is a pure function of the + * seed, the registries and this pack's structure policy, so it never depends on generation order. + */ + default KList getNativeStructureVolumes(int minX, int minZ, int maxX, int maxZ) { + EnginePlatformHooks hooks = getPlatformHooks(); + return hooks == null ? NativeStructureVolume.NONE : hooks.nativeStructureVolumes(this, minX, minZ, maxX, maxZ); + } + int getBlockUpdatesPerSecond(); void printMetrics(VolmitSender sender); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java index c982c425c..ca95eb3d5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java @@ -19,8 +19,17 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.volmlib.util.collection.KList; public interface EnginePlatformHooks { + /** + * World-space piece bounds of every native structure that will generate inside the given XZ rect. Platforms + * without native structures return no volumes, which keeps the object veto free on those platforms. + */ + default KList nativeStructureVolumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { + return NativeStructureVolume.NONE; + } + default void refreshWorkspace(Engine engine) { } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolume.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolume.java new file mode 100644 index 000000000..01a92f4ab --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolume.java @@ -0,0 +1,68 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.volmlib.util.collection.KList; + +/** + * World-space axis-aligned bounds of one native structure piece. Volumes are resolved from seed, registry and + * pack policy alone so the same query answers identically regardless of which chunks exist. + */ +public record NativeStructureVolume( + String structure, + int minX, + int minY, + int minZ, + int maxX, + int maxY, + int maxZ +) { + public static final KList NONE = new KList<>(); + + public static NativeStructureVolume of(String structure, int aX, int aY, int aZ, int bX, int bY, int bZ) { + return new NativeStructureVolume( + structure, + Math.min(aX, bX), + Math.min(aY, bY), + Math.min(aZ, bZ), + Math.max(aX, bX), + Math.max(aY, bY), + Math.max(aZ, bZ)); + } + + public boolean intersectsRect(int rectMinX, int rectMinZ, int rectMaxX, int rectMaxZ) { + return maxX >= rectMinX && minX <= rectMaxX && maxZ >= rectMinZ && minZ <= rectMaxZ; + } + + public boolean intersects(int boxMinX, int boxMinY, int boxMinZ, int boxMaxX, int boxMaxY, int boxMaxZ) { + return maxX >= boxMinX && minX <= boxMaxX + && maxY >= boxMinY && minY <= boxMaxY + && maxZ >= boxMinZ && minZ <= boxMaxZ; + } + + public boolean contains(int x, int y, int z) { + return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ; + } + + public boolean containsWithin(int x, int y, int z, int margin) { + return x >= minX - margin && x <= maxX + margin + && y >= minY - margin && y <= maxY + margin + && z >= minZ - margin && z <= maxZ + margin; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemo.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemo.java new file mode 100644 index 000000000..c48ac3357 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemo.java @@ -0,0 +1,94 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.volmlib.util.collection.KList; +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; + +/** + * Chunk keyed memo in front of the platform volume hook. One chunk's object pass fires the same rect query dozens of + * times, so the hook is asked once per chunk column and every later object reuses that answer. Long keyed so the + * overwhelmingly common "no native structure anywhere near" lookup allocates nothing at all. + */ +public final class NativeStructureVolumeMemo { + private static final int MAX_CACHED_CHUNKS = 2_048; + + private final Long2ObjectLinkedOpenHashMap> chunks = + new Long2ObjectLinkedOpenHashMap<>(); + + public KList volumes(Engine engine, EnginePlatformHooks hooks, + int minX, int minZ, int maxX, int maxZ) { + if (hooks == null) { + return NativeStructureVolume.NONE; + } + + int fromChunkX = Math.min(minX, maxX) >> 4; + int toChunkX = Math.max(minX, maxX) >> 4; + int fromChunkZ = Math.min(minZ, maxZ) >> 4; + int toChunkZ = Math.max(minZ, maxZ) >> 4; + KList matches = null; + for (int chunkX = fromChunkX; chunkX <= toChunkX; chunkX++) { + for (int chunkZ = fromChunkZ; chunkZ <= toChunkZ; chunkZ++) { + for (NativeStructureVolume volume : chunkVolumes(engine, hooks, chunkX, chunkZ)) { + if (!volume.intersectsRect(minX, minZ, maxX, maxZ)) { + continue; + } + if (matches == null) { + matches = new KList<>(); + } + if (!matches.contains(volume)) { + matches.add(volume); + } + } + } + } + return matches == null ? NativeStructureVolume.NONE : matches; + } + + public void clear() { + synchronized (chunks) { + chunks.clear(); + } + } + + private KList chunkVolumes(Engine engine, EnginePlatformHooks hooks, + int chunkX, int chunkZ) { + long key = ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL); + synchronized (chunks) { + KList hit = chunks.getAndMoveToFirst(key); + if (hit != null) { + return hit; + } + } + + int minX = chunkX << 4; + int minZ = chunkZ << 4; + KList resolved = hooks.nativeStructureVolumes(engine, minX, minZ, minX + 15, minZ + 15); + if (resolved == null) { + resolved = NativeStructureVolume.NONE; + } + synchronized (chunks) { + chunks.putAndMoveToFirst(key, resolved); + while (chunks.size() > MAX_CACHED_CHUNKS) { + chunks.removeLast(); + } + } + return resolved; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index ed6f8ae95..eef62362e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -21,6 +21,7 @@ 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.engine.framework.NativeStructureVolume; import art.arcane.iris.engine.framework.PlacedObject; import art.arcane.iris.engine.framework.placer.HeightmapObjectPlacer; import art.arcane.iris.spi.IrisLogging; @@ -343,6 +344,12 @@ final class IrisObjectPlacementRunner { return -1; } + int warpMargin = warped ? (int) Math.ceil(Math.abs(config.getWarp().getMultiplier()) / 2D) : 0; + if (!rawStructurePiece && nativeStructureVetoes(placer, config, spin, translating, translateOffset, ceilingHang, + yv < 0 && config.getMode() == ObjectPlaceMode.PAINT, warpMargin, x, y + yrand, z)) { + return -1; + } + if (!config.isForcePlace() && !rawStructurePiece && (!config.getAllowedCollisions().isEmpty() || !config.getForbiddenCollisions().isEmpty())) { Engine engine = rdata.getEngine(); IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); @@ -841,6 +848,87 @@ final class IrisObjectPlacementRunner { return !wouldReplace && (rawStructurePiece || !air); } + /** + * Objects may never intersect a native structure piece. The rect query is the fast path: no native structures + * near this placement means no per block work at all. Only when the placement envelope meets a piece does the + * precise pass run, and the first solid block inside a piece rejects the whole object before any write. + */ + private boolean nativeStructureVetoes(IObjectPlacer placer, IrisObjectPlacement config, SpinKernel spin, + boolean translating, IrisBlockVector translateOffset, boolean ceilingHang, + boolean paint, int warpMargin, int x, int y, int z) { + Engine engine = placer.getEngine(); + if (engine == null) { + return false; + } + + int margin = (Math.max(self.getW(), Math.max(self.getH(), self.getD())) / 2) + 1 + warpMargin; + if (translating) { + margin += Math.max(Math.abs(translateOffset.getBlockX()), + Math.max(Math.abs(translateOffset.getBlockY()), Math.abs(translateOffset.getBlockZ()))); + } + + KList volumes = engine.getNativeStructureVolumes(x - margin, z - margin, x + margin, z + margin); + if (volumes == null || volumes.isEmpty()) { + return false; + } + + int envelopeMinY = paint ? Integer.MIN_VALUE : y - margin; + int envelopeMaxY = paint ? Integer.MAX_VALUE : y + margin; + boolean envelopeMeetsPiece = false; + for (NativeStructureVolume volume : volumes) { + if (volume.intersects(x - margin, envelopeMinY, z - margin, x + margin, envelopeMaxY, z + margin)) { + envelopeMeetsPiece = true; + break; + } + } + + if (!envelopeMeetsPiece) { + return false; + } + + self.readLock.lock(); + try { + VectorMap.Cursor cursor = self.blocks.cursor(); + while (cursor.next()) { + PlatformBlockState state = cursor.value(); + if (state == null || isAirBlock(state)) { + continue; + } + + IrisBlockVector i = cursor.key().clone(); + spin.rotate(i); + if (ceilingHang) { + i.setY(-i.getBlockY()); + } + if (translating) { + i.add(translateOffset); + } + + int xx = x + (int) Math.round(i.getX()); + int zz = z + (int) Math.round(i.getZ()); + int yy = paint + ? (int) Math.round(i.getY()) + Math.floorDiv(self.h, 2) + + placer.getHighest(xx, zz, self.getLoader(), config.isUnderwater()) + : y + (int) Math.round(i.getY()); + + for (NativeStructureVolume volume : volumes) { + if (volume.containsWithin(xx, yy, zz, warpMargin)) { + return true; + } + } + } + } finally { + self.readLock.unlock(); + } + + return false; + } + + private static boolean isAirBlock(PlatformBlockState state) { + String material = IrisObjectShaping.materialKey(state); + return material.equals("minecraft:air") || material.equals("minecraft:cave_air"); + } + private void warnImplausibleBedrockPlacement(IObjectPlacer placer, IrisObjectPlacement config, int x, int y, int z) { String key = self.getLoadKey(); String fingerprint = (key == null ? "" : key) + "|" + config.getMode(); diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java index b7ee785d0..dce4e75e4 100644 --- a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java @@ -107,6 +107,69 @@ public class ServerConfiguratorDatapackFingerprintTest { assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir)); } + @Test + public void computePackFingerprintIgnoresGeneratedCodeWorkspaceFiles() throws Exception { + File packsDir = tmp.newFolder("workspace-packs"); + Path dimension = packsDir.toPath().resolve("overworld/dimensions/overworld.json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, "authored", StandardCharsets.UTF_8); + String before = ServerConfigurator.computePackFingerprint(packsDir); + + Path workspace = packsDir.toPath().resolve("overworld/overworld.code-workspace"); + Files.writeString(workspace, "{\"folders\":[]}", StandardCharsets.UTF_8); + + assertEquals("Iris-generated workspace files must not alter the fingerprint", + before, ServerConfigurator.computePackFingerprint(packsDir)); + + Files.writeString(workspace, "{\"folders\":[{\"path\":\".\"}]}", StandardCharsets.UTF_8); + + assertEquals("Reordered workspace bytes must not alter the fingerprint", + before, ServerConfigurator.computePackFingerprint(packsDir)); + } + + @Test + public void resolvePackFingerprintReusesCachedContentWhileMetadataIsUnchanged() throws Exception { + File packsDir = tmp.newFolder("two-tier-packs"); + Path dimension = packsDir.toPath().resolve("testpack/dimensions/overworld.json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, "aaaa", StandardCharsets.UTF_8); + ServerConfigurator.PackFingerprint first = + ServerConfigurator.resolvePackFingerprint(packsDir, "", ""); + assertEquals(ServerConfigurator.computePackFingerprint(packsDir), first.content()); + assertNotEquals("", first.metadata()); + + FileTime originalMtime = Files.getLastModifiedTime(dimension); + Files.writeString(dimension, "bbbb", StandardCharsets.UTF_8); + Files.setLastModifiedTime(dimension, originalMtime); + ServerConfigurator.PackFingerprint reused = + ServerConfigurator.resolvePackFingerprint(packsDir, first.metadata(), first.content()); + + assertEquals("Unchanged metadata must reuse the cached content fingerprint", + first.content(), reused.content()); + + Files.setLastModifiedTime(dimension, FileTime.fromMillis(originalMtime.toMillis() + 5000L)); + ServerConfigurator.PackFingerprint rehashed = + ServerConfigurator.resolvePackFingerprint(packsDir, first.metadata(), first.content()); + + assertNotEquals("Changed metadata must re-hash pack contents", + first.content(), rehashed.content()); + assertEquals(ServerConfigurator.computePackFingerprint(packsDir), rehashed.content()); + } + + @Test + public void computePackMetadataDigestIgnoresGeneratedCodeWorkspaceFiles() throws Exception { + File packsDir = tmp.newFolder("metadata-workspace-packs"); + Path dimension = packsDir.toPath().resolve("overworld/dimensions/overworld.json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, "authored", StandardCharsets.UTF_8); + String before = ServerConfigurator.computePackMetadataDigest(packsDir); + + Files.writeString(packsDir.toPath().resolve("overworld/overworld.code-workspace"), + "{\"folders\":[]}", StandardCharsets.UTF_8); + + assertEquals(before, ServerConfigurator.computePackMetadataDigest(packsDir)); + } + @Test public void computePackFingerprintRejectsSymbolicLinks() throws Exception { File packsDir = tmp.newFolder("unsafe-packs"); diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java index 492b69790..0701340b0 100644 --- a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java @@ -31,6 +31,7 @@ import java.nio.channels.ServerSocketChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -2557,6 +2558,111 @@ public class DatapackIngestServiceTest { assertFalse(new File(staging, entry.id).exists()); } + @Test + public void reapplyRecordsStagingAndInstallMetadataForTheNextPass() throws Exception { + ReapplyFixture fixture = reapplyFixture("reapply-record"); + + assertTrue(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), false)); + + assertTrue(new File(fixture.target(), ".iris-managed.json").isFile()); + JsonObject recorded = manifestEntry(fixture.root()); + assertFalse(recorded.get("stagingMetadata").getAsString().isBlank()); + assertTrue(recorded.getAsJsonObject("installMetadata") + .has(fixture.target().toPath().toAbsolutePath().normalize().toString())); + } + + @Test + public void unchangedStagingAndTargetSkipContentHashingOnReapply() throws Exception { + ReapplyFixture fixture = reapplyFixture("reapply-shortcircuit"); + assertTrue(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), false)); + + Path staged = fixture.staging().toPath().resolve("value.txt"); + FileTime stamp = Files.getLastModifiedTime(staged); + Files.writeString(staged, "wxyz", StandardCharsets.UTF_8); + Files.setLastModifiedTime(staged, stamp); + + assertTrue(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), false)); + + assertEquals("abcd", Files.readString( + new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void changedStagingMetadataForcesFullReapplyVerification() throws Exception { + ReapplyFixture fixture = reapplyFixture("reapply-staging-change"); + assertTrue(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), false)); + + Path staged = fixture.staging().toPath().resolve("value.txt"); + FileTime stamp = Files.getLastModifiedTime(staged); + Files.writeString(staged, "wxyz", StandardCharsets.UTF_8); + Files.setLastModifiedTime(staged, FileTime.fromMillis(stamp.toMillis() + 5000L)); + + assertFalse(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), false)); + } + + @Test + public void changedInstallTargetIsRepairedDespiteRecordedMetadata() throws Exception { + ReapplyFixture fixture = reapplyFixture("reapply-target-change"); + assertTrue(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), false)); + + Path installed = fixture.target().toPath().resolve("value.txt"); + Files.writeString(installed, "zzzz", StandardCharsets.UTF_8); + Files.setLastModifiedTime(installed, FileTime.fromMillis( + Files.getLastModifiedTime(installed).toMillis() + 5000L)); + + assertTrue(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), false)); + + assertEquals("abcd", Files.readString(installed, StandardCharsets.UTF_8)); + } + + @Test + public void flippedOverrideStrippingForcesFullReapplyVerification() throws Exception { + ReapplyFixture fixture = reapplyFixture("reapply-strip-change"); + assertTrue(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), false)); + assertFalse(new File(fixture.target(), ".iris-overrides-stripped").exists()); + + assertTrue(DatapackIngestService.reapplyStagedDirectories( + fixture.root(), fixture.stagingRoot(), fixture.worlds(), true)); + + assertTrue(new File(fixture.target(), ".iris-overrides-stripped").isFile()); + } + + private ReapplyFixture reapplyFixture(String name) throws Exception { + File root = temporaryFolder.newFolder(name + "-root"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + File stagingRoot = new File(root, "staging"); + File staging = new File(stagingRoot, entry.id); + writeManagedDatapack(staging, entry, "abcd"); + writeManifest(root, entry); + File world = temporaryFolder.newFolder(name + "-world"); + KList worlds = new KList<>(); + worlds.add(world); + return new ReapplyFixture(root, stagingRoot, staging, worlds, new File(world, entry.id)); + } + + private JsonObject manifestEntry(File root) throws Exception { + JsonObject manifest = JsonParser.parseString(Files.readString( + new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8)).getAsJsonObject(); + return manifest.getAsJsonArray("entries").get(0).getAsJsonObject(); + } + + private record ReapplyFixture( + File root, + File stagingRoot, + File staging, + KList worlds, + File target + ) { + } + private LegacyStagingFixture legacyStagingFixture( String name, boolean committed, diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisCodeWorkspaceTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisCodeWorkspaceTest.java new file mode 100644 index 000000000..d7da5dc82 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisCodeWorkspaceTest.java @@ -0,0 +1,123 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Answers; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +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 IrisCodeWorkspaceTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisPlatform previousPlatform; + private IrisSettings previousSettings; + private IrisData data; + + @Before + public void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + previousSettings = IrisSettings.settings; + IrisPlatforms.unbind(); + IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS); + when(platform.dataFolder()).thenReturn(temporaryFolder.getRoot()); + IrisPlatforms.bind(platform); + IrisSettings.settings = new IrisSettings(); + IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry()); + } + + @After + public void restorePlatform() { + if (data != null) { + data.close(); + data = null; + } + IrisServices.clear(); + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + IrisSettings.settings = previousSettings; + } + + @Test + public void updateWorkspaceDoesNotRewriteAnUnchangedWorkspaceFile() throws Exception { + File pack = temporaryFolder.newFolder("overworld"); + IrisCodeWorkspace workspace = new IrisCodeWorkspace(new IrisProject(pack)); + + assertTrue(workspace.updateWorkspace()); + data = IrisData.get(pack); + File file = workspace.getCodeWorkspaceFile(); + byte[] first = Files.readAllBytes(file.toPath()); + FileTime stamp = FileTime.fromMillis(Files.getLastModifiedTime(file.toPath()).toMillis() - 60_000L); + Files.setLastModifiedTime(file.toPath(), stamp); + + assertTrue(workspace.updateWorkspace()); + + assertArrayEquals("Unchanged workspace bytes must stay identical", + first, Files.readAllBytes(file.toPath())); + assertEquals("Unchanged workspace content must not be rewritten", + stamp.toMillis(), Files.getLastModifiedTime(file.toPath()).toMillis()); + } + + @Test + public void workspaceSchemaEntriesAreEmittedInStableSortedOrder() throws Exception { + File pack = temporaryFolder.newFolder("sorted"); + JSONObject configuration = new IrisCodeWorkspace(new IrisProject(pack)).createCodeWorkspaceConfig(); + data = IrisData.get(pack); + + JSONArray schemas = configuration.getJSONObject("settings").getJSONArray("json.schemas"); + List urls = new ArrayList<>(); + for (int i = 0; i < schemas.length(); i++) { + urls.add(schemas.getJSONObject(i).getString("url")); + } + List sorted = new ArrayList<>(urls); + Collections.sort(sorted); + + assertFalse("Expected the workspace to declare schemas", urls.isEmpty()); + assertEquals("Schema entries must be emitted in a boot-stable order", sorted, urls); + } + + 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/runtime/WorldRuntimeControlServiceTimeLockTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java index 3d6e48cc2..21fd6f009 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java @@ -32,7 +32,10 @@ public class WorldRuntimeControlServiceTimeLockTest { PluginManager pluginManager = mock(PluginManager.class); doReturn(pluginManager).when(server).getPluginManager(); doReturn(Logger.getLogger("WorldRuntimeControlServiceTimeLockTest")).when(server).getLogger(); - Bukkit.setServer(server); + try { + Bukkit.setServer(server); + } catch (Throwable ignored) { + } } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemoTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemoTest.java new file mode 100644 index 000000000..bddfbdeea --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemoTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class NativeStructureVolumeMemoTest { + @Test + public void repeatedQueriesInsideOneChunkAskThePlatformOnce() { + RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 15, 78, 15)); + NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); + + KList first = memo.volumes(null, hooks, 2, 2, 6, 6); + KList second = memo.volumes(null, hooks, 8, 8, 12, 12); + + assertEquals(1, hooks.queries().size()); + assertEquals(first, second); + } + + @Test + public void chunkQueriesCoverTheWholeChunkColumn() { + RecordingHooks hooks = new RecordingHooks(); + NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); + + memo.volumes(null, hooks, 20, 36, 21, 37); + + assertEquals(1, hooks.queries().size()); + assertEquals(List.of(16, 32, 31, 47), hooks.queries().getFirst()); + } + + @Test + public void rectsSpanningChunksUnionWithoutDuplicates() { + NativeStructureVolume shared = volume(10, 64, 10, 40, 78, 40); + RecordingHooks hooks = new RecordingHooks(shared); + NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); + + KList volumes = memo.volumes(null, hooks, 12, 12, 20, 20); + + assertEquals(4, hooks.queries().size()); + assertEquals(1, volumes.size()); + assertEquals(shared, volumes.getFirst()); + } + + @Test + public void volumesOutsideTheRectAreFiltered() { + RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 3, 78, 3)); + NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); + + assertTrue(memo.volumes(null, hooks, 5, 5, 9, 9).isEmpty()); + } + + @Test + public void clearingTheMemoReQueriesThePlatform() { + RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 15, 78, 15)); + NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); + + memo.volumes(null, hooks, 2, 2, 6, 6); + memo.clear(); + memo.volumes(null, hooks, 2, 2, 6, 6); + + assertEquals(2, hooks.queries().size()); + } + + @Test + public void missingHooksResolveNoVolumes() { + NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); + + assertTrue(memo.volumes(null, null, 0, 0, 15, 15).isEmpty()); + } + + private static NativeStructureVolume volume(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + return new NativeStructureVolume("minecraft:village_swamp", minX, minY, minZ, maxX, maxY, maxZ); + } + + private static final class RecordingHooks implements EnginePlatformHooks { + private final KList> queries = new KList<>(); + private final List answer = new ArrayList<>(); + + private RecordingHooks(NativeStructureVolume... volumes) { + for (NativeStructureVolume volume : volumes) { + answer.add(volume); + } + } + + private KList> queries() { + return queries; + } + + @Override + public KList nativeStructureVolumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { + queries.add(List.of(minX, minZ, maxX, maxZ)); + KList volumes = new KList<>(); + for (NativeStructureVolume volume : answer) { + if (volume.intersectsRect(minX, minZ, maxX, maxZ)) { + volumes.add(volume); + } + } + return volumes; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeSuppressionTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeSuppressionTest.java new file mode 100644 index 000000000..3b94a31bc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeSuppressionTest.java @@ -0,0 +1,67 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisImportedStructureControl; +import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.StructureDistribution; +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; + +/** + * The gate the native structure volume index applies before it assembles anything: a structure the pack suppresses + * contributes no piece volumes, so it can never veto an object placement. + */ +public class NativeStructureVolumeSuppressionTest { + @Test + public void packDisabledStructuresContributeNoVolumes() { + Engine engine = engine(); + + assertFalse(NativeStructureGenerationPolicy + .resolve(engine, "minecraft:village_swamp", false).generate()); + assertFalse(NativeStructureGenerationPolicy + .resolve(engine, "minecraft:village_plains", false).generate()); + } + + @Test + public void enabledStructuresContributeVolumes() { + Engine engine = engine(); + + assertTrue(NativeStructureGenerationPolicy + .resolve(engine, "towns_and_towers:village_swamp", false).generate()); + } + + @Test + public void plannedIrisStartsContributeVolumesThroughTheirOwnDecision() { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setDistribution(StructureDistribution.DENSITY) + .setDensity(1D); + placement.getNativeStructures().add(new IrisNativeStructure() + .setStructure("minecraft:ancient_city") + .setWeight(1)); + NativeStructureStartPlan plan = new NativeStructureStartPlan( + placement, placement.getNativeStructures().getFirst(), 3, 5, -30); + + assertTrue(NativeStructurePlacementPlanner.decisionFor(plan).generate()); + } + + private Engine engine() { + IrisImportedStructureControl control = new IrisImportedStructureControl(); + control.getDisabled().add("minecraft:village"); + IrisDimension dimension = mock(IrisDimension.class); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(mock(IrisData.class)); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getImportedStructures()).thenReturn(control); + when(dimension.getStructures()).thenReturn(new KList<>()); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); + return engine; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeTest.java new file mode 100644 index 000000000..42525e7fa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeStructureVolumeTest { + private static final NativeStructureVolume PIECE = + new NativeStructureVolume("minecraft:village_plains", 10, 64, 20, 25, 78, 40); + + @Test + public void rectQueriesIgnoreTheVerticalAxis() { + assertTrue(PIECE.intersectsRect(0, 0, 10, 20)); + assertTrue(PIECE.intersectsRect(25, 40, 60, 60)); + assertFalse(PIECE.intersectsRect(26, 20, 60, 40)); + assertFalse(PIECE.intersectsRect(10, 41, 25, 60)); + } + + @Test + public void boxQueriesSeparateStackedVolumes() { + assertTrue(PIECE.intersects(0, 60, 0, 40, 70, 50)); + assertFalse(PIECE.intersects(0, 0, 0, 40, 63, 50)); + assertFalse(PIECE.intersects(0, 79, 0, 40, 200, 50)); + } + + @Test + public void containmentIsInclusiveOnEveryFace() { + assertTrue(PIECE.contains(10, 64, 20)); + assertTrue(PIECE.contains(25, 78, 40)); + assertFalse(PIECE.contains(9, 64, 20)); + assertFalse(PIECE.contains(25, 79, 40)); + } + + @Test + public void marginWidensContainmentSymmetrically() { + assertFalse(PIECE.containsWithin(8, 64, 20, 1)); + assertTrue(PIECE.containsWithin(8, 64, 20, 2)); + assertTrue(PIECE.containsWithin(25, 80, 42, 2)); + } + + @Test + public void factoryNormalizesCornerOrder() { + NativeStructureVolume normalized = NativeStructureVolume.of("test:piece", 25, 78, 40, 10, 64, 20); + + assertEquals(PIECE.minX(), normalized.minX()); + assertEquals(PIECE.minY(), normalized.minY()); + assertEquals(PIECE.minZ(), normalized.minZ()); + assertEquals(PIECE.maxX(), normalized.maxX()); + assertEquals(PIECE.maxY(), normalized.maxY()); + assertEquals(PIECE.maxZ(), normalized.maxZ()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java index b1027059b..61fdcd2ef 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java @@ -46,7 +46,10 @@ public class IrisDimensionCarvingResolverParityTest { 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); + try { + Bukkit.setServer(server); + } catch (Throwable ignored) { + } } private static BlockData namedBlockData(String key) { diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisFloatingChildBiomesCarvingResolutionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisFloatingChildBiomesCarvingResolutionTest.java index 9e3a01c82..928eeafa2 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisFloatingChildBiomesCarvingResolutionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisFloatingChildBiomesCarvingResolutionTest.java @@ -38,7 +38,10 @@ public class IrisFloatingChildBiomesCarvingResolutionTest { 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); + try { + Bukkit.setServer(server); + } catch (Throwable ignored) { + } } private static BlockData namedBlockData(String key) { diff --git a/core/src/test/java/art/arcane/iris/engine/object/NativeStructureObjectVetoTest.java b/core/src/test/java/art/arcane/iris/engine/object/NativeStructureObjectVetoTest.java new file mode 100644 index 000000000..e99ebda6c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/NativeStructureObjectVetoTest.java @@ -0,0 +1,320 @@ +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.engine.framework.NativeStructureVolume; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NativeStructureObjectVetoTest { + private static final int SURFACE_Y = 80; + + private IrisData data; + private Engine engine; + private PlatformBlockState log; + + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + + log = state("minecraft:oak_log", true); + @SuppressWarnings("unchecked") + ProceduralStream heightStream = mock(ProceduralStream.class); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getHeightStream()).thenReturn(heightStream); + engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(256); + when(engine.getComplex()).thenReturn(complex); + when(engine.getDimension()).thenReturn(mock(IrisDimension.class)); + volumes(); + data = mock(IrisData.class); + when(data.getEngine()).thenReturn(engine); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void objectPlacesWhenNoNativeStructureIsNear() { + RecordingPlacer placer = new RecordingPlacer(engine); + + assertTrue(place(placer) >= 0); + assertFalse(placer.written().isEmpty()); + } + + @Test + public void canopyBlockInsideAPieceRejectsTheWholeObject() { + int canopyY = plantedTopY(); + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(-1, canopyY, -1, 1, canopyY + 4, 1)); + + assertEquals(-1, place(placer)); + assertTrue(placer.written().isEmpty()); + } + + @Test + public void trunkBlockInsideAPieceRejectsTheWholeObject() { + int baseY = plantedBottomY(); + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(0, baseY, 0, 0, baseY, 0)); + + assertEquals(-1, place(placer)); + assertTrue(placer.written().isEmpty()); + } + + @Test + public void pieceOverlappingOnlyTheEnvelopeStillPlaces() { + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(2, plantedBottomY(), 2, 4, plantedTopY(), 4)); + + assertTrue(place(placer) >= 0); + assertFalse(placer.written().isEmpty()); + } + + @Test + public void pieceBelowTheObjectStillPlaces() { + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(-32, -60, -32, 32, plantedBottomY() - 1, 32)); + + assertTrue(place(placer) >= 0); + assertFalse(placer.written().isEmpty()); + } + + @Test + public void rejectionWritesNoBlocksTilesOrMarkers() { + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(-64, -64, -64, 64, 320, 64)); + + assertEquals(-1, place(placer)); + assertTrue(placer.written().isEmpty()); + assertEquals(0, placer.tiles()); + assertEquals(0, placer.markers()); + } + + /** + * Iris authored structure pieces are arbitrated by the jigsaw placement scope, not by this veto: rejecting them + * one piece at a time would publish a partial structure. The sibling placement pins the test to the exemption + * rather than to geometry that simply misses every volume. + */ + @Test + public void irisStructurePiecesBypassTheVetoUnlikeTheirSiblings() { + volumes(volume(-64, -64, -64, 64, 320, 64)); + + RecordingPlacer vetoed = new RecordingPlacer(engine); + assertEquals(-1, tree().place(0, SURFACE_Y, 0, vetoed, placement(), new RNG(1234L), data)); + assertTrue(vetoed.written().isEmpty()); + + RecordingPlacer exempt = new RecordingPlacer(engine); + IrisObjectPlacement structurePiece = placement(); + structurePiece.setMode(ObjectPlaceMode.STRUCTURE_PIECE); + + assertTrue(tree().place(0, SURFACE_Y, 0, exempt, structurePiece, new RNG(1234L), data) >= 0); + assertFalse(exempt.written().isEmpty()); + } + + @Test + public void forcePlaceStillObeysTheVeto() { + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(-64, -64, -64, 64, 320, 64)); + IrisObjectPlacement placement = placement(); + placement.setForcePlace(true); + + assertEquals(-1, tree().place(0, -1, 0, placer, placement, new RNG(1234L), data)); + assertTrue(placer.written().isEmpty()); + } + + @Test + public void structurePiecePlacementsBypassTheVeto() { + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(-64, -64, -64, 64, 320, 64)); + IrisObjectPlacement placement = placement(); + placement.setMode(ObjectPlaceMode.STRUCTURE_PIECE); + + assertTrue(tree().place(0, 100, 0, placer, placement, new RNG(1234L), data) >= 0); + assertFalse(placer.written().isEmpty()); + } + + private int plantedTopY() { + int top = Integer.MIN_VALUE; + for (int[] position : plantedPositions()) { + top = Math.max(top, position[1]); + } + return top; + } + + private int plantedBottomY() { + int bottom = Integer.MAX_VALUE; + for (int[] position : plantedPositions()) { + bottom = Math.min(bottom, position[1]); + } + return bottom; + } + + private List plantedPositions() { + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(); + assertTrue(place(placer) >= 0); + assertFalse(placer.written().isEmpty()); + return placer.written(); + } + + private int place(RecordingPlacer placer) { + return tree().place(0, -1, 0, placer, placement(), new RNG(1234L), data); + } + + private IrisObjectPlacement placement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(ObjectPlaceMode.CENTER_HEIGHT); + return placement; + } + + private IrisObject tree() { + IrisObject object = new IrisObject(3, 7, 3); + for (int y = 0; y < 7; y++) { + object.setUnsigned(1, y, 1, log); + } + return object; + } + + private void volumes(NativeStructureVolume... volumes) { + KList list = new KList<>(); + for (NativeStructureVolume volume : volumes) { + list.add(volume); + } + when(engine.getNativeStructureVolumes(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(list); + } + + private NativeStructureVolume volume(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + return new NativeStructureVolume("minecraft:village_plains", minX, minY, minZ, maxX, maxY, maxZ); + } + + private static PlatformBlockState state(String key, boolean solid) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.isSolid()).thenReturn(solid); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(key); + return state; + } + + private static final class RecordingPlacer implements IObjectPlacer { + private final List written = new ArrayList<>(); + private final PlatformBlockState air = state("minecraft:air", false); + private final Engine engine; + private int tiles; + private int markers; + + private RecordingPlacer(Engine engine) { + this.engine = engine; + } + + private List written() { + return written; + } + + private int tiles() { + return tiles; + } + + private int markers() { + return markers; + } + + @Override + public int getHighest(int x, int z, IrisData data) { + return SURFACE_Y; + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + return SURFACE_Y; + } + + @Override + public void set(int x, int y, int z, PlatformBlockState state) { + written.add(new int[]{x, y, z}); + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + return air; + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + return false; + } + + @Override + public boolean isSolid(int x, int y, int z) { + return false; + } + + @Override + public boolean isUnderwater(int x, int z) { + return false; + } + + @Override + public int getFluidHeight() { + return 0; + } + + @Override + public boolean isDebugSmartBore() { + return false; + } + + @Override + public void setTile(int x, int y, int z, TileData tile) { + tiles++; + } + + @Override + public void setData(int x, int y, int z, T data) { + markers++; + } + + @Override + public T getData(int x, int y, int z, Class type) { + return null; + } + + @Override + public Engine getEngine() { + return engine; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java index 24d1f6fae..4b31e14de 100644 --- a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java @@ -62,7 +62,10 @@ public class BukkitSpiConformanceTest { doReturn("1.0").when(server).getVersion(); doReturn("1.0").when(server).getBukkitVersion(); doAnswer((InvocationOnMock invocation) -> blockData("minecraft:" + invocation.getArgument(0, Material.class).name().toLowerCase(Locale.ROOT))).when(server).createBlockData(any(Material.class)); - Bukkit.setServer(server); + try { + Bukkit.setServer(server); + } catch (Throwable ignored) { + } } doAnswer((InvocationOnMock invocation) -> blockData(invocation.getArgument(0))).when(server).createBlockData(anyString()); } diff --git a/core/src/test/java/art/arcane/iris/util/project/noise/CNGInjectorParityTest.java b/core/src/test/java/art/arcane/iris/util/project/noise/CNGInjectorParityTest.java index 874688db1..d3c707f7b 100644 --- a/core/src/test/java/art/arcane/iris/util/project/noise/CNGInjectorParityTest.java +++ b/core/src/test/java/art/arcane/iris/util/project/noise/CNGInjectorParityTest.java @@ -36,7 +36,10 @@ public class CNGInjectorParityTest { 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); + try { + Bukkit.setServer(server); + } catch (Throwable ignored) { + } } private static BlockData namedBlockData(String key) {